using System; using System.Text; using System.Threading; using AibisDream.Utility; using UnityEngine; using UnityEngine.SceneManagement; namespace AibisDream.Kit { /// /// Runtime file logging entry point. Captures Unity logs and routes them through /// an asynchronous file writer so mobile builds do not block on every log line. /// internal static class LogKit { public const int SaveDays = 30; private static FileLogger _fileLogger; private static LogSettings _settings; private static bool _isInitialized; private static bool _isForwardingToUnity; internal static bool IsInitialized => _isInitialized; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Initialize() { if (_isInitialized) return; _settings = LogSettings.CreateDefault(); if (!_settings.Enabled) return; LogEntry.SetMainThreadId(Thread.CurrentThread.ManagedThreadId); _fileLogger = new FileLogger(ConstRef.LogFilePath, _settings); _fileLogger.Write(LogEntry.System(LogLevel.Info, LogCategory.General, BuildStartupHeader())); Application.logMessageReceivedThreaded += LogMessage; Application.quitting += Shutdown; Application.focusChanged += OnFocusChanged; _isInitialized = true; } internal static void Write(LogEntry entry) { if (!_isInitialized || _fileLogger == null) return; _fileLogger.Write(entry); } internal static void ForwardToUnity(Action logAction) { if (logAction == null) return; _isForwardingToUnity = true; try { logAction.Invoke(); } finally { _isForwardingToUnity = false; } } internal static bool ShouldWriteToConsole(LogLevel level) { return _settings == null || _settings.EchoToConsole && level >= _settings.ConsoleMinLevel; } private static void LogMessage(string condition, string stackTrace, LogType type) { if (_isForwardingToUnity) return; Write(LogEntry.FromUnity(condition, stackTrace, type, _settings.ShouldIncludeStackTrace)); } private static void OnFocusChanged(bool hasFocus) { if (!hasFocus) _fileLogger?.Flush(TimeSpan.FromMilliseconds(_settings?.ShutdownDrainMilliseconds ?? 500)); } private static void Shutdown() { if (!_isInitialized) return; Application.logMessageReceivedThreaded -= LogMessage; Application.quitting -= Shutdown; Application.focusChanged -= OnFocusChanged; _fileLogger?.Write(LogEntry.System(LogLevel.Info, LogCategory.General, "System shutdown.")); _fileLogger?.OnDestroy(); _fileLogger = null; _isInitialized = false; } private static string BuildStartupHeader() { return string.Join( " | ", "System start", $"version={Application.version}", $"platform={Application.platform}", $"unity={Application.unityVersion}", $"device={SystemInfo.deviceModel}", $"os={SystemInfo.operatingSystem}", $"graphics={SystemInfo.graphicsDeviceName}", $"maxTexture={SystemInfo.maxTextureSize}", $"memory={SystemInfo.systemMemorySize}MB"); } } public enum LogLevel { Debug = 0, Info = 1, Warning = 2, Error = 3, Fatal = 4 } public enum LogCategory { General, Unity, Save, Yarn, Resource, Scene, UI, Audio, FixSystem, MiniGame } public static class GameLog { public static void Debug(LogCategory category, string message, string context = null) { Write(LogLevel.Debug, category, message, context, null); } public static void Info(LogCategory category, string message, string context = null) { Write(LogLevel.Info, category, message, context, null); } public static void Warn(LogCategory category, string message, string context = null) { Write(LogLevel.Warning, category, message, context, null); } public static void Error(LogCategory category, string message, string context = null) { Write(LogLevel.Error, category, message, context, null); } public static void Exception(LogCategory category, Exception exception, string context = null) { var message = exception == null ? "Unknown exception" : exception.Message; Write(LogLevel.Error, category, message, context, exception); } private static void Write(LogLevel level, LogCategory category, string message, string context, Exception exception) { var entry = LogEntry.FromGame(level, category, message, context, exception); LogKit.Write(entry); if (!LogKit.ShouldWriteToConsole(level)) return; var consoleMessage = $"[{category}] {message}"; if (!string.IsNullOrEmpty(context)) consoleMessage += $" | {context}"; LogKit.ForwardToUnity(() => { if (exception != null) { UnityEngine.Debug.LogException(exception); return; } switch (level) { case LogLevel.Warning: UnityEngine.Debug.LogWarning(consoleMessage); break; case LogLevel.Error: case LogLevel.Fatal: UnityEngine.Debug.LogError(consoleMessage); break; default: UnityEngine.Debug.Log(consoleMessage); break; } }); } } internal sealed class LogSettings { public bool Enabled { get; private set; } public bool EchoToConsole { get; private set; } public LogLevel MinLevel { get; private set; } public LogLevel ConsoleMinLevel { get; private set; } public int QueueCapacity { get; private set; } public int BatchSize { get; private set; } public int FlushIntervalMilliseconds { get; private set; } public int ShutdownDrainMilliseconds { get; private set; } public long MaxFileBytes { get; private set; } public long MaxTotalBytes { get; private set; } public int MaxSessionFiles { get; private set; } public bool IncludeWarningStackTrace { get; private set; } public string SessionId { get; private set; } public string AppVersion { get; private set; } public string Platform { get; private set; } public static string PlatformName { get { #if UNITY_ANDROID return "android"; #elif UNITY_STANDALONE_WIN return "windows"; #elif UNITY_STANDALONE_OSX return "mac"; #elif UNITY_STANDALONE_LINUX return "linux"; #else return UnityEngine.Application.platform.ToString().ToLowerInvariant(); #endif } } public bool ShouldWrite(LogLevel level) { return Enabled && level >= MinLevel; } public bool ShouldIncludeStackTrace(LogLevel level) { return level >= LogLevel.Error || IncludeWarningStackTrace && level == LogLevel.Warning; } public static LogSettings CreateDefault() { return Create(LogSettingsProfiles.Current); } private static LogSettings Create(LogSettingsProfile profile) { return new LogSettings { Enabled = profile.Enabled, EchoToConsole = profile.EchoToConsole, MinLevel = profile.MinLevel, ConsoleMinLevel = profile.ConsoleMinLevel, QueueCapacity = profile.QueueCapacity, BatchSize = profile.BatchSize, FlushIntervalMilliseconds = profile.FlushIntervalMilliseconds, ShutdownDrainMilliseconds = profile.ShutdownDrainMilliseconds, MaxFileBytes = profile.MaxFileBytes, MaxTotalBytes = profile.MaxTotalBytes, MaxSessionFiles = profile.MaxSessionFiles, IncludeWarningStackTrace = profile.IncludeWarningStackTrace, SessionId = Guid.NewGuid().ToString("N").Substring(0, 8), AppVersion = UnityEngine.Application.version, Platform = PlatformName }; } } internal readonly struct LogEntry { private static int _mainThreadId; public readonly DateTime TimestampUtc; public readonly LogLevel Level; public readonly LogCategory Category; public readonly string Message; public readonly string StackTrace; public readonly string Context; public readonly string SceneName; public readonly int FrameCount; public readonly int ThreadId; private LogEntry(LogLevel level, LogCategory category, string message, string stackTrace, string context) { TimestampUtc = DateTime.UtcNow; Level = level; Category = category; Message = message ?? string.Empty; StackTrace = stackTrace ?? string.Empty; Context = context ?? string.Empty; var isMainThread = Thread.CurrentThread.ManagedThreadId == _mainThreadId; SceneName = isMainThread ? GetSceneName() : string.Empty; FrameCount = isMainThread ? Time.frameCount : -1; ThreadId = Thread.CurrentThread.ManagedThreadId; } public static void SetMainThreadId(int threadId) { _mainThreadId = threadId; } public static LogEntry System(LogLevel level, LogCategory category, string message) { return new LogEntry(level, category, message, string.Empty, string.Empty); } public static LogEntry FromUnity(string condition, string stackTrace, LogType type, Func shouldIncludeStackTrace) { var level = ToLogLevel(type); var stack = shouldIncludeStackTrace != null && shouldIncludeStackTrace(level) ? stackTrace : string.Empty; return new LogEntry(level, LogCategory.Unity, condition, stack, string.Empty); } public static LogEntry FromGame(LogLevel level, LogCategory category, string message, string context, Exception exception) { var stack = exception == null ? string.Empty : exception.ToString(); return new LogEntry(level, category, message, stack, context); } private static LogLevel ToLogLevel(LogType type) { switch (type) { case LogType.Warning: return LogLevel.Warning; case LogType.Error: return LogLevel.Error; case LogType.Exception: case LogType.Assert: return LogLevel.Fatal; default: return LogLevel.Info; } } private static string GetSceneName() { try { return SceneManager.GetActiveScene().name; } catch { return string.Empty; } } } internal static class LogFormatter { public static string Format(LogEntry entry) { var builder = new StringBuilder(256); builder.Append(entry.TimestampUtc.ToString("O")); builder.Append(" | "); builder.Append(entry.Level); builder.Append(" | "); builder.Append(entry.Category); builder.Append(" | scene="); builder.Append(Escape(entry.SceneName)); builder.Append(" | frame="); builder.Append(entry.FrameCount); builder.Append(" | thread="); builder.Append(entry.ThreadId); if (!string.IsNullOrEmpty(entry.Context)) { builder.Append(" | context="); builder.Append(Escape(entry.Context)); } builder.Append(" | "); builder.Append(Escape(entry.Message)); if (!string.IsNullOrEmpty(entry.StackTrace)) { builder.Append(" | stack="); builder.Append(Escape(entry.StackTrace)); } return builder.ToString(); } private static string Escape(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; return value.Replace("\r", "\\r").Replace("\n", "\\n"); } } }