diff --git a/.github/workflows/license-compliance.yml b/.github/workflows/license-compliance.yml
index 2ba55482..b5099fad 100644
--- a/.github/workflows/license-compliance.yml
+++ b/.github/workflows/license-compliance.yml
@@ -62,6 +62,7 @@ jobs:
# with: 配置上传的具体内容
# name: 工件名称,用于标识上传的文件集合
# path: 指定需要上传的文件路径列表(支持多行格式)
+ # third-party-licenses/**: 手工维护的参考源码许可证原文
- name: Upload compliance artifacts
uses: actions/upload-artifact@v7
with:
@@ -69,6 +70,7 @@ jobs:
path: |
NOTICE
THIRD_PARTY_LICENSES.md
+ third-party-licenses/**
sbom.spdx.json
sbom.cyclonedx.json
sbom-spdx-validation.txt
@@ -79,15 +81,17 @@ jobs:
# 压缩包中包含以下文件:
# - NOTICE: 项目声明文件
# - THIRD_PARTY_LICENSES.md: 第三方许可证列表
+ # - third-party-licenses/: 手工维护的参考源码许可证原文
# - sbom.spdx.json: SPDX 格式的软件物料清单
# - sbom.cyclonedx.json: CycloneDX 格式的软件物料清单
# - sbom-spdx-validation.txt: SPDX 格式验证结果
# - sbom-cyclonedx-validation.txt: CycloneDX 格式验证结果
- name: Package compliance bundle
run: |
- zip license-compliance.zip \
+ zip -r license-compliance.zip \
NOTICE \
THIRD_PARTY_LICENSES.md \
+ third-party-licenses \
sbom.spdx.json \
sbom.cyclonedx.json \
sbom-spdx-validation.txt \
diff --git a/GFramework.Core/Logging/AbstractLogger.cs b/GFramework.Core/Logging/AbstractLogger.cs
index 3ca42a22..90dee078 100644
--- a/GFramework.Core/Logging/AbstractLogger.cs
+++ b/GFramework.Core/Logging/AbstractLogger.cs
@@ -6,16 +6,42 @@ namespace GFramework.Core.Logging;
/// 日志抽象基类,封装日志级别判断、格式化与异常处理逻辑。
/// 平台日志器只需实现 Write 方法即可。
///
-public abstract class AbstractLogger(
- string? name = null,
- LogLevel minLevel = LogLevel.Info) : IStructuredLogger
+public abstract class AbstractLogger : IStructuredLogger
{
///
/// 根日志记录器的名称常量
///
public const string RootLoggerName = "ROOT";
- private readonly string _name = name ?? RootLoggerName;
+ private readonly Func _minLevelProvider;
+ private readonly string _name;
+
+ ///
+ /// 使用固定最小日志级别初始化日志器。
+ ///
+ /// 日志器名称。
+ /// 最小日志级别。
+ protected AbstractLogger(
+ string? name = null,
+ LogLevel minLevel = LogLevel.Info)
+ : this(name, () => minLevel)
+ {
+ }
+
+ ///
+ /// 使用动态最小日志级别提供器初始化日志器。
+ ///
+ /// 日志器名称。
+ /// 最小日志级别提供器。
+ protected AbstractLogger(
+ string? name,
+ Func minLevelProvider)
+ {
+ ArgumentNullException.ThrowIfNull(minLevelProvider);
+
+ _name = name ?? RootLoggerName;
+ _minLevelProvider = minLevelProvider;
+ }
#region Metadata
@@ -47,7 +73,7 @@ public abstract class AbstractLogger(
/// 如果指定级别大于等于最小级别则返回true,否则返回false
protected bool IsEnabled(LogLevel level)
{
- return level >= minLevel;
+ return level >= _minLevelProvider();
}
///
@@ -568,4 +594,4 @@ public abstract class AbstractLogger(
#region Core Pipeline (Private)
#endregion
-}
\ No newline at end of file
+}
diff --git a/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs b/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs
new file mode 100644
index 00000000..a1a34e3b
--- /dev/null
+++ b/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs
@@ -0,0 +1,142 @@
+using System;
+using GFramework.Core.Abstractions.Logging;
+using GFramework.Godot.Logging;
+
+namespace GFramework.Godot.Tests.Logging;
+
+[TestFixture]
+public sealed class GodotLogTemplateTests
+{
+ [Test]
+ public void Render_Should_Format_Timestamp_Level_Color_Category_And_Message()
+ {
+ var template = GodotLogTemplate.Parse("[{timestamp:yyyyMMdd}] [color={color}]{level:u3}[/color] [{category:l16}] {message}");
+ var context = new GodotLogRenderContext(
+ new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
+ LogLevel.Warning,
+ "Game.Services.Inventory",
+ "Loaded",
+ "orange",
+ string.Empty);
+
+ var result = template.Render(context);
+
+ Assert.That(result, Is.EqualTo("[20260502] [color=orange]WRN[/color] [G.S.Inventory ] Loaded"));
+ }
+
+ [Test]
+ public void Render_Should_Support_Lowercase_Level_Format()
+ {
+ var template = GodotLogTemplate.Parse("{level:l3}:{message}");
+ var context = new GodotLogRenderContext(
+ new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
+ LogLevel.Debug,
+ "Game",
+ "Ready",
+ "cyan",
+ string.Empty);
+
+ var result = template.Render(context);
+
+ Assert.That(result, Is.EqualTo("dbg:Ready"));
+ }
+
+ [Test]
+ public void Render_Should_Right_Align_Category()
+ {
+ var template = GodotLogTemplate.Parse("[{category:r10}]");
+ var context = new GodotLogRenderContext(
+ new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
+ LogLevel.Info,
+ "UI",
+ "Ready",
+ "white",
+ string.Empty);
+
+ var result = template.Render(context);
+
+ Assert.That(result, Is.EqualTo("[ UI]"));
+ }
+
+ [Test]
+ public void Render_Should_Preserve_Unknown_Placeholders()
+ {
+ var template = GodotLogTemplate.Parse("{message} {unknown}");
+ var context = new GodotLogRenderContext(
+ new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
+ LogLevel.Info,
+ "Game",
+ "Ready",
+ "white",
+ string.Empty);
+
+ var result = template.Render(context);
+
+ Assert.That(result, Is.EqualTo("Ready {unknown}"));
+ }
+
+ [Test]
+ public void Render_Should_Format_Padded_Level()
+ {
+ var template = GodotLogTemplate.Parse("{level:padded}");
+ var context = new GodotLogRenderContext(
+ new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
+ LogLevel.Info,
+ "Game",
+ "Ready",
+ "white",
+ string.Empty);
+
+ var result = template.Render(context);
+
+ Assert.That(result, Is.EqualTo("INFO "));
+ }
+
+ [Test]
+ public void Render_Should_Append_Properties_Placeholder()
+ {
+ var template = GodotLogTemplate.Parse("{message}{properties}");
+ var context = new GodotLogRenderContext(
+ new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
+ LogLevel.Info,
+ "Game",
+ "Ready",
+ "white",
+ " | Scene=Boot");
+
+ var result = template.Render(context);
+
+ Assert.That(result, Is.EqualTo("Ready | Scene=Boot"));
+ }
+
+ [Test]
+ public void Options_ForMinimumLevel_Should_Preserve_Fixed_Minimum_Level()
+ {
+ var options = GodotLoggerOptions.ForMinimumLevel(LogLevel.Warning);
+
+ Assert.That(options.Mode, Is.EqualTo(GodotLoggerMode.Debug));
+ Assert.That(options.DebugMinLevel, Is.EqualTo(LogLevel.Warning));
+ Assert.That(options.ReleaseMinLevel, Is.EqualTo(LogLevel.Warning));
+ }
+
+ [Test]
+ public void Options_Should_Use_Default_Color_When_Configured_Color_Is_Missing()
+ {
+ var options = new GodotLoggerOptions();
+ options.Colors.Remove(LogLevel.Error);
+
+ var result = options.GetColor(LogLevel.Error);
+
+ Assert.That(result, Is.EqualTo("red"));
+ }
+
+ [Test]
+ public void Options_Should_Use_White_Color_When_Level_Is_Not_Defined()
+ {
+ var options = new GodotLoggerOptions();
+
+ var result = options.GetColor((LogLevel)999);
+
+ Assert.That(result, Is.EqualTo("white"));
+ }
+}
diff --git a/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs b/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs
new file mode 100644
index 00000000..749ac715
--- /dev/null
+++ b/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Text.Json;
+using GFramework.Core.Abstractions.Logging;
+using GFramework.Godot.Logging;
+
+namespace GFramework.Godot.Tests.Logging;
+
+///
+/// Verifies Godot logging configuration discovery, parsing, normalization, and live settings propagation.
+///
+[TestFixture]
+public sealed class GodotLoggerSettingsLoaderTests
+{
+ ///
+ /// Verifies that configuration discovery honors the environment path, executable directory, and project path order.
+ ///
+ [Test]
+ public void DiscoverConfigurationPath_Should_Prefer_EnvironmentVariable_Then_ProcessPath_Then_ProjectPath()
+ {
+ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+
+ try
+ {
+ var envPath = Path.Combine(root, "env.json");
+ File.WriteAllText(envPath, "{}");
+
+ var executableDirectory = Path.Combine(root, "bin");
+ Directory.CreateDirectory(executableDirectory);
+ var processPath = Path.Combine(executableDirectory, "game.exe");
+ var executableConfigPath = Path.Combine(executableDirectory, "appsettings.json");
+ File.WriteAllText(executableConfigPath, "{}");
+
+ var projectPath = Path.Combine(root, "project-appsettings.json");
+ File.WriteAllText(projectPath, "{}");
+
+ var discoveredFromEnvironment = GodotLoggerSettingsLoader.DiscoverConfigurationPath(
+ environmentPath: envPath,
+ processPath: processPath,
+ projectPathResolver: _ => projectPath);
+ var discoveredFromProcess = GodotLoggerSettingsLoader.DiscoverConfigurationPath(
+ environmentPath: Path.Combine(root, "missing-env.json"),
+ processPath: processPath,
+ projectPathResolver: _ => projectPath);
+ var discoveredFromProject = GodotLoggerSettingsLoader.DiscoverConfigurationPath(
+ environmentPath: Path.Combine(root, "missing-env.json"),
+ processPath: Path.Combine(root, "missing", "game.exe"),
+ projectPathResolver: _ => projectPath);
+
+ Assert.That(discoveredFromEnvironment, Is.EqualTo(envPath));
+ Assert.That(discoveredFromProcess, Is.EqualTo(executableConfigPath));
+ Assert.That(discoveredFromProject, Is.EqualTo(projectPath));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// Verifies that JSON settings bind Godot logger options and category log-level overrides.
+ ///
+ [Test]
+ public void LoadFromJsonString_Should_Read_GodotLogger_Options_And_Category_Levels()
+ {
+ const string json = """
+ {
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Game.Services": "Error"
+ },
+ "GodotLogger": {
+ "Mode": "Release",
+ "DebugMinLevel": "Debug",
+ "ReleaseMinLevel": "Information",
+ "DebugOutputTemplate": "[dbg] {message}",
+ "ReleaseOutputTemplate": "[rel] {message}{properties}",
+ "Colors": {
+ "Info": "aqua"
+ }
+ }
+ }
+ }
+ """;
+
+ var settings = GodotLoggerSettingsLoader.LoadFromJsonString(json);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(settings.Options.Mode, Is.EqualTo(GodotLoggerMode.Release));
+ Assert.That(settings.Options.ReleaseOutputTemplate, Is.EqualTo("[rel] {message}{properties}"));
+ Assert.That(settings.Options.GetColor(LogLevel.Info), Is.EqualTo("aqua"));
+ Assert.That(settings.GetEffectiveMinLevel("Game.Services.Inventory", LogLevel.Trace), Is.EqualTo(LogLevel.Error));
+ Assert.That(settings.GetEffectiveMinLevel("Game.Other", LogLevel.Trace), Is.EqualTo(LogLevel.Warning));
+ });
+ }
+
+ ///
+ /// Verifies that nullable JSON option fields are normalized before the runtime receives the settings snapshot.
+ ///
+ [Test]
+ public void LoadFromJsonString_Should_Normalize_Null_GodotLogger_Options()
+ {
+ const string json = """
+ {
+ "Logging": {
+ "GodotLogger": {
+ "DebugOutputTemplate": null,
+ "ReleaseOutputTemplate": null,
+ "Colors": null
+ }
+ }
+ }
+ """;
+
+ var settings = GodotLoggerSettingsLoader.LoadFromJsonString(json);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(settings.Options.DebugOutputTemplate, Is.Not.Null.And.Not.Empty);
+ Assert.That(settings.Options.ReleaseOutputTemplate, Is.Not.Null.And.Not.Empty);
+ Assert.That(settings.Options.GetColor(LogLevel.Info), Is.EqualTo("white"));
+ Assert.That(settings.Options.GetColor(LogLevel.Error), Is.EqualTo("red"));
+ });
+ }
+
+ ///
+ /// Verifies that numeric JSON log levels must map to defined values.
+ ///
+ [Test]
+ public void LoadFromJsonString_Should_Reject_Invalid_Numeric_LogLevel()
+ {
+ const string json = """
+ {
+ "Logging": {
+ "LogLevel": {
+ "Default": 999
+ }
+ }
+ }
+ """;
+
+ var error = Assert.Throws(() => GodotLoggerSettingsLoader.LoadFromJsonString(json));
+
+ Assert.That(error?.Message, Does.Contain("Unsupported numeric LogLevel value '999'"));
+ }
+
+ ///
+ /// Verifies that cached provider loggers read the latest settings after the provider snapshot changes.
+ ///
+ [Test]
+ public void Provider_Should_Apply_Updated_Settings_To_Existing_Loggers()
+ {
+ var settings = new GodotLoggerSettings(new GodotLoggerOptions
+ {
+ Mode = GodotLoggerMode.Debug,
+ DebugMinLevel = LogLevel.Error,
+ ReleaseMinLevel = LogLevel.Error
+ });
+ var provider = new GodotLoggerFactoryProvider(() => settings);
+ var logger = provider.CreateLogger("Game.Services.Inventory");
+
+ Assert.That(logger.IsInfoEnabled(), Is.False);
+
+ settings = new GodotLoggerSettings(
+ new GodotLoggerOptions
+ {
+ Mode = GodotLoggerMode.Debug,
+ DebugMinLevel = LogLevel.Info,
+ ReleaseMinLevel = LogLevel.Info
+ },
+ defaultLogLevel: LogLevel.Trace,
+ loggerLevels: new Dictionary(StringComparer.Ordinal)
+ {
+ ["Game.Services"] = LogLevel.Debug
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(logger.IsInfoEnabled(), Is.True);
+ Assert.That(logger.IsDebugEnabled(), Is.False);
+ });
+ }
+
+ ///
+ /// Verifies that caller-supplied structured property keys cannot break Godot log rendering.
+ ///
+ [Test]
+ public void StructuredProperties_Should_Skip_Blank_Keys_And_Trim_Valid_Keys()
+ {
+ var formatProperties = typeof(GodotLogger).GetMethod(
+ "FormatProperties",
+ BindingFlags.NonPublic | BindingFlags.Static);
+ var properties = new (string Key, object? Value)[]
+ {
+ (null!, "ignored"),
+ (" ", "ignored"),
+ (" Player ", 42)
+ };
+
+ var result = formatProperties?.Invoke(null, [properties]);
+
+ Assert.That(result, Is.EqualTo(" | Player=42"));
+ }
+}
diff --git a/GFramework.Godot/Logging/DeferredLogger.cs b/GFramework.Godot/Logging/DeferredLogger.cs
new file mode 100644
index 00000000..35215030
--- /dev/null
+++ b/GFramework.Godot/Logging/DeferredLogger.cs
@@ -0,0 +1,597 @@
+using System;
+using System.Linq;
+using System.Threading;
+using GFramework.Core.Abstractions.Logging;
+
+namespace GFramework.Godot.Logging;
+
+///
+/// Defers resolving the real Godot logger until the first logging operation needs it.
+///
+///
+/// This wrapper allows static logger fields to be created before or
+/// runs. The resolved inner logger is published with an atomic compare
+/// exchange so concurrent first-use calls converge on one cached instance without relying on the non-atomic
+/// null-coalescing assignment pattern.
+///
+/// The category passed to the provider when the real logger is first needed.
+/// The accessor that returns the current provider at first use.
+internal sealed class DeferredLogger(string category, Func providerAccessor) : IStructuredLogger
+{
+ private ILogger? _inner;
+
+ ///
+ /// Gets the resolved inner logger, creating and atomically publishing it on first use.
+ ///
+ ///
+ /// The property is intentionally the single resolution gate so all delegated members share the same thread-safe
+ /// lazy initialization behavior.
+ ///
+ private ILogger Inner
+ {
+ get
+ {
+ var current = Volatile.Read(ref _inner);
+ if (current != null)
+ {
+ return current;
+ }
+
+ var createdLogger = ResolveLogger();
+
+ // Multiple callers can resolve concurrently; only one publishes the cached reference.
+ return Interlocked.CompareExchange(ref _inner, createdLogger, null) ?? createdLogger;
+ }
+ }
+
+ ///
+ /// Gets the category name reported by the resolved logger.
+ ///
+ /// The logger category name.
+ public string Name()
+ {
+ return Inner.Name();
+ }
+
+ ///
+ /// Returns whether trace messages are enabled by the current provider settings.
+ ///
+ /// true when trace messages should be emitted; otherwise false.
+ public bool IsTraceEnabled()
+ {
+ return Inner.IsTraceEnabled();
+ }
+
+ ///
+ /// Returns whether debug messages are enabled by the current provider settings.
+ ///
+ /// true when debug messages should be emitted; otherwise false.
+ public bool IsDebugEnabled()
+ {
+ return Inner.IsDebugEnabled();
+ }
+
+ ///
+ /// Returns whether informational messages are enabled by the current provider settings.
+ ///
+ /// true when informational messages should be emitted; otherwise false.
+ public bool IsInfoEnabled()
+ {
+ return Inner.IsInfoEnabled();
+ }
+
+ ///
+ /// Returns whether warning messages are enabled by the current provider settings.
+ ///
+ /// true when warning messages should be emitted; otherwise false.
+ public bool IsWarnEnabled()
+ {
+ return Inner.IsWarnEnabled();
+ }
+
+ ///
+ /// Returns whether error messages are enabled by the current provider settings.
+ ///
+ /// true when error messages should be emitted; otherwise false.
+ public bool IsErrorEnabled()
+ {
+ return Inner.IsErrorEnabled();
+ }
+
+ ///
+ /// Returns whether fatal messages are enabled by the current provider settings.
+ ///
+ /// true when fatal messages should be emitted; otherwise false.
+ public bool IsFatalEnabled()
+ {
+ return Inner.IsFatalEnabled();
+ }
+
+ ///
+ /// Returns whether the specified log level is enabled by the current provider settings.
+ ///
+ /// The level to check.
+ /// true when the level should be emitted; otherwise false.
+ public bool IsEnabledForLevel(LogLevel level)
+ {
+ return Inner.IsEnabledForLevel(level);
+ }
+
+ ///
+ /// Writes a trace message through the resolved logger.
+ ///
+ /// The message to write.
+ public void Trace(string msg)
+ {
+ Inner.Trace(msg);
+ }
+
+ ///
+ /// Writes a formatted trace message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ public void Trace(string format, object arg)
+ {
+ Inner.Trace(format, arg);
+ }
+
+ ///
+ /// Writes a formatted trace message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ /// The second format argument.
+ public void Trace(string format, object arg1, object arg2)
+ {
+ Inner.Trace(format, arg1, arg2);
+ }
+
+ ///
+ /// Writes a formatted trace message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The format arguments.
+ public void Trace(string format, params object[] arguments)
+ {
+ Inner.Trace(format, arguments);
+ }
+
+ ///
+ /// Writes a trace message and exception through the resolved logger.
+ ///
+ /// The message to write.
+ /// The exception to attach.
+ public void Trace(string msg, Exception t)
+ {
+ Inner.Trace(msg, t);
+ }
+
+ ///
+ /// Writes a debug message through the resolved logger.
+ ///
+ /// The message to write.
+ public void Debug(string msg)
+ {
+ Inner.Debug(msg);
+ }
+
+ ///
+ /// Writes a formatted debug message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ public void Debug(string format, object arg)
+ {
+ Inner.Debug(format, arg);
+ }
+
+ ///
+ /// Writes a formatted debug message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ /// The second format argument.
+ public void Debug(string format, object arg1, object arg2)
+ {
+ Inner.Debug(format, arg1, arg2);
+ }
+
+ ///
+ /// Writes a formatted debug message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The format arguments.
+ public void Debug(string format, params object[] arguments)
+ {
+ Inner.Debug(format, arguments);
+ }
+
+ ///
+ /// Writes a debug message and exception through the resolved logger.
+ ///
+ /// The message to write.
+ /// The exception to attach.
+ public void Debug(string msg, Exception t)
+ {
+ Inner.Debug(msg, t);
+ }
+
+ ///
+ /// Writes an informational message through the resolved logger.
+ ///
+ /// The message to write.
+ public void Info(string msg)
+ {
+ Inner.Info(msg);
+ }
+
+ ///
+ /// Writes a formatted informational message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ public void Info(string format, object arg)
+ {
+ Inner.Info(format, arg);
+ }
+
+ ///
+ /// Writes a formatted informational message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ /// The second format argument.
+ public void Info(string format, object arg1, object arg2)
+ {
+ Inner.Info(format, arg1, arg2);
+ }
+
+ ///
+ /// Writes a formatted informational message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The format arguments.
+ public void Info(string format, params object[] arguments)
+ {
+ Inner.Info(format, arguments);
+ }
+
+ ///
+ /// Writes an informational message and exception through the resolved logger.
+ ///
+ /// The message to write.
+ /// The exception to attach.
+ public void Info(string msg, Exception t)
+ {
+ Inner.Info(msg, t);
+ }
+
+ ///
+ /// Writes a warning message through the resolved logger.
+ ///
+ /// The message to write.
+ public void Warn(string msg)
+ {
+ Inner.Warn(msg);
+ }
+
+ ///
+ /// Writes a formatted warning message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ public void Warn(string format, object arg)
+ {
+ Inner.Warn(format, arg);
+ }
+
+ ///
+ /// Writes a formatted warning message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ /// The second format argument.
+ public void Warn(string format, object arg1, object arg2)
+ {
+ Inner.Warn(format, arg1, arg2);
+ }
+
+ ///
+ /// Writes a formatted warning message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The format arguments.
+ public void Warn(string format, params object[] arguments)
+ {
+ Inner.Warn(format, arguments);
+ }
+
+ ///
+ /// Writes a warning message and exception through the resolved logger.
+ ///
+ /// The message to write.
+ /// The exception to attach.
+ public void Warn(string msg, Exception t)
+ {
+ Inner.Warn(msg, t);
+ }
+
+ ///
+ /// Writes an error message through the resolved logger.
+ ///
+ /// The message to write.
+ public void Error(string msg)
+ {
+ Inner.Error(msg);
+ }
+
+ ///
+ /// Writes a formatted error message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ public void Error(string format, object arg)
+ {
+ Inner.Error(format, arg);
+ }
+
+ ///
+ /// Writes a formatted error message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ /// The second format argument.
+ public void Error(string format, object arg1, object arg2)
+ {
+ Inner.Error(format, arg1, arg2);
+ }
+
+ ///
+ /// Writes a formatted error message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The format arguments.
+ public void Error(string format, params object[] arguments)
+ {
+ Inner.Error(format, arguments);
+ }
+
+ ///
+ /// Writes an error message and exception through the resolved logger.
+ ///
+ /// The message to write.
+ /// The exception to attach.
+ public void Error(string msg, Exception t)
+ {
+ Inner.Error(msg, t);
+ }
+
+ ///
+ /// Writes a fatal message through the resolved logger.
+ ///
+ /// The message to write.
+ public void Fatal(string msg)
+ {
+ Inner.Fatal(msg);
+ }
+
+ ///
+ /// Writes a formatted fatal message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ public void Fatal(string format, object arg)
+ {
+ Inner.Fatal(format, arg);
+ }
+
+ ///
+ /// Writes a formatted fatal message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ /// The second format argument.
+ public void Fatal(string format, object arg1, object arg2)
+ {
+ Inner.Fatal(format, arg1, arg2);
+ }
+
+ ///
+ /// Writes a formatted fatal message through the resolved logger.
+ ///
+ /// The format string interpreted by the resolved logger.
+ /// The format arguments.
+ public void Fatal(string format, params object[] arguments)
+ {
+ Inner.Fatal(format, arguments);
+ }
+
+ ///
+ /// Writes a fatal message and exception through the resolved logger.
+ ///
+ /// The message to write.
+ /// The exception to attach.
+ public void Fatal(string msg, Exception t)
+ {
+ Inner.Fatal(msg, t);
+ }
+
+ ///
+ /// Writes a message at the specified level through the resolved logger.
+ ///
+ /// The level to write.
+ /// The message to write.
+ public void Log(LogLevel level, string message)
+ {
+ LogFallback(level, message, exception: null);
+ }
+
+ ///
+ /// Writes a formatted message at the specified level while preserving deferred formatting semantics.
+ ///
+ /// The level to write.
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ public void Log(LogLevel level, string format, object arg)
+ {
+ Inner.Log(level, format, arg);
+ }
+
+ ///
+ /// Writes a formatted message at the specified level while preserving deferred formatting semantics.
+ ///
+ /// The level to write.
+ /// The format string interpreted by the resolved logger.
+ /// The first format argument.
+ /// The second format argument.
+ public void Log(LogLevel level, string format, object arg1, object arg2)
+ {
+ Inner.Log(level, format, arg1, arg2);
+ }
+
+ ///
+ /// Writes a formatted message at the specified level while preserving deferred formatting semantics.
+ ///
+ /// The level to write.
+ /// The format string interpreted by the resolved logger.
+ /// The format arguments.
+ public void Log(LogLevel level, string format, params object[] arguments)
+ {
+ Inner.Log(level, format, arguments);
+ }
+
+ ///
+ /// Writes a message and exception at the specified level through the resolved logger.
+ ///
+ /// The level to write.
+ /// The message to write.
+ /// The exception to attach.
+ public void Log(LogLevel level, string message, Exception exception)
+ {
+ LogFallback(level, message, exception);
+ }
+
+ ///
+ /// Writes a structured message through the resolved logger when it supports structured properties.
+ ///
+ /// The level to write.
+ /// The message to write.
+ /// The structured properties to attach.
+ public void Log(LogLevel level, string message, params (string Key, object? Value)[] properties)
+ {
+ if (Inner is IStructuredLogger structuredLogger)
+ {
+ structuredLogger.Log(level, message, properties);
+ return;
+ }
+
+ LogFallback(level, message, exception: null, properties);
+ }
+
+ ///
+ /// Writes a structured message and exception through the resolved logger when it supports structured properties.
+ ///
+ /// The level to write.
+ /// The message to write.
+ /// The optional exception to attach.
+ /// The structured properties to attach.
+ public void Log(LogLevel level, string message, Exception? exception, params (string Key, object? Value)[] properties)
+ {
+ if (Inner is IStructuredLogger structuredLogger)
+ {
+ structuredLogger.Log(level, message, exception, properties);
+ return;
+ }
+
+ LogFallback(level, message, exception, properties);
+ }
+
+ ///
+ /// Resolves the real logger from the current provider for the deferred category.
+ ///
+ /// The logger created by the current provider.
+ private ILogger ResolveLogger()
+ {
+ return providerAccessor().CreateLogger(category);
+ }
+
+ ///
+ /// Routes a message through the non-structured logger surface when the resolved logger lacks structured support.
+ ///
+ /// The level to write.
+ /// The message to write.
+ /// The optional exception to attach.
+ /// The structured properties rendered into a suffix for fallback loggers.
+ /// Thrown when is not supported.
+ private void LogFallback(
+ LogLevel level,
+ string message,
+ Exception? exception,
+ params (string Key, object? Value)[] properties)
+ {
+ var suffix = properties.Length == 0
+ ? string.Empty
+ : " | " + string.Join(", ", properties.Select(static property => $"{property.Key}={property.Value}"));
+ var rendered = message + suffix;
+
+ switch (level)
+ {
+ case LogLevel.Trace:
+ WriteFallback(rendered, exception, Inner.Trace, Inner.Trace);
+ break;
+ case LogLevel.Debug:
+ WriteFallback(rendered, exception, Inner.Debug, Inner.Debug);
+ break;
+ case LogLevel.Info:
+ WriteFallback(rendered, exception, Inner.Info, Inner.Info);
+ break;
+ case LogLevel.Warning:
+ WriteFallback(rendered, exception, Inner.Warn, Inner.Warn);
+ break;
+ case LogLevel.Error:
+ WriteFallback(rendered, exception, Inner.Error, Inner.Error);
+ break;
+ case LogLevel.Fatal:
+ WriteFallback(rendered, exception, Inner.Fatal, Inner.Fatal);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(level), level, "Unsupported log level.");
+ }
+ }
+
+ ///
+ /// Routes a non-structured message through the fallback path.
+ ///
+ /// The level to write.
+ /// The message to write.
+ /// The optional exception to attach.
+ /// Thrown when is not supported.
+ private void LogFallback(LogLevel level, string message, Exception? exception)
+ {
+ LogFallback(level, message, exception, []);
+ }
+
+ ///
+ /// Chooses the message-only or exception-aware write delegate for fallback logging.
+ ///
+ /// The rendered fallback message.
+ /// The optional exception to attach.
+ /// The delegate used when no exception is present.
+ /// The delegate used when an exception is present.
+ private static void WriteFallback(
+ string message,
+ Exception? exception,
+ Action writeMessage,
+ Action writeException)
+ {
+ if (exception == null)
+ {
+ writeMessage(message);
+ }
+ else
+ {
+ writeException(message, exception);
+ }
+ }
+}
diff --git a/GFramework.Godot/Logging/GodotLog.cs b/GFramework.Godot/Logging/GodotLog.cs
new file mode 100644
index 00000000..f305168a
--- /dev/null
+++ b/GFramework.Godot/Logging/GodotLog.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Threading;
+using GFramework.Core.Abstractions.Logging;
+
+namespace GFramework.Godot.Logging;
+
+///
+/// Static Godot logging entry point with auto-discovered configuration and deferred logger creation.
+///
+public static class GodotLog
+{
+#if NET9_0_OR_GREATER
+ private static readonly System.Threading.Lock ConfigureLock = new();
+#else
+ private static readonly object ConfigureLock = new();
+#endif
+ private static Action? _configure;
+
+ private static readonly Lazy LazyConfigurationSource = new(
+ CreateConfigurationSource,
+ LazyThreadSafetyMode.ExecutionAndPublication);
+
+ private static readonly Lazy LazyProvider = new(
+ static () => new GodotLoggerFactoryProvider(() => LazyConfigurationSource.Value.CurrentSettings),
+ LazyThreadSafetyMode.ExecutionAndPublication);
+
+ ///
+ /// Applies imperative option overrides before the global Godot logger provider is materialized.
+ ///
+ /// The options mutator.
+ public static void Configure(Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(configure);
+
+ lock (ConfigureLock)
+ {
+ if (LazyProvider.IsValueCreated || LazyConfigurationSource.IsValueCreated)
+ {
+ throw new InvalidOperationException(
+ "GodotLog.Configure must be called before any GodotLog provider or configuration source is materialized.");
+ }
+
+ _configure = configure;
+ }
+ }
+
+ ///
+ /// Gets the lazily-configured Godot logger provider.
+ ///
+ public static ILoggerFactoryProvider Provider
+ {
+ get
+ {
+ lock (ConfigureLock)
+ {
+ return LazyProvider.Value;
+ }
+ }
+ }
+
+ ///
+ /// Gets the discovered configuration file path, if any, without materializing the global configuration source.
+ ///
+ ///
+ /// This property is safe for diagnostics before runs. When the source is not created
+ /// yet, it performs discovery directly instead of touching LazyConfigurationSource.Value, so callers do
+ /// not accidentally lock in the default options before configuring .
+ ///
+ public static string? ConfigurationPath => LazyConfigurationSource.IsValueCreated
+ ? LazyConfigurationSource.Value.ConfigurationPath
+ : GodotLoggerSettingsLoader.DiscoverConfigurationPath();
+
+ ///
+ /// Creates a logger for the specified category without materializing the provider until first use.
+ ///
+ public static ILogger CreateLogger(string category)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(category);
+ return new DeferredLogger(category, static () => Provider);
+ }
+
+ ///
+ /// Creates a logger for the specified type without materializing the provider until first use.
+ ///
+ public static ILogger CreateLogger()
+ {
+ return CreateLogger(GetCategoryName(typeof(T)));
+ }
+
+ ///
+ /// Installs the Godot provider as the current global resolver provider.
+ ///
+ public static void UseAsDefaultProvider()
+ {
+ LoggerFactoryResolver.Provider = Provider;
+ }
+
+ ///
+ /// Stops the file watcher owned by the materialized configuration source, if the source has been created.
+ ///
+ ///
+ /// Godot hosts often keep process-wide logging for the whole game lifetime. Dedicated servers and tests can call
+ /// this method during teardown to release the watcher handle deterministically. The static lazy source is not
+ /// reset; later logger usage continues with the last published settings snapshot but no longer receives reload
+ /// notifications from the disposed watcher.
+ ///
+ public static void Shutdown()
+ {
+ if (LazyConfigurationSource.IsValueCreated)
+ {
+ LazyConfigurationSource.Value.Dispose();
+ }
+ }
+
+ private static GodotLogConfigurationSource CreateConfigurationSource()
+ {
+ lock (ConfigureLock)
+ {
+ return new GodotLogConfigurationSource(_configure);
+ }
+ }
+
+ private static string GetCategoryName(Type type)
+ {
+ return type.FullName ?? type.Name;
+ }
+}
diff --git a/GFramework.Godot/Logging/GodotLogConfigurationSource.cs b/GFramework.Godot/Logging/GodotLogConfigurationSource.cs
new file mode 100644
index 00000000..7ee68d25
--- /dev/null
+++ b/GFramework.Godot/Logging/GodotLogConfigurationSource.cs
@@ -0,0 +1,228 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using GFramework.Core.Abstractions.Logging;
+
+namespace GFramework.Godot.Logging;
+
+///
+/// Owns discovery, loading, hot reload, and publication of the current Godot logger settings snapshot.
+///
+///
+/// Construction follows a fixed lifecycle: discover the configuration path, perform an initial strict load, then
+/// subscribe a when a concrete file exists. is
+/// published through so cached loggers can read a last-good immutable snapshot without
+/// locking. Hot reload keeps the previous settings when a transient parse or file-system error occurs.
+///
+internal sealed class GodotLogConfigurationSource : IDisposable
+{
+ private readonly Action? _configure;
+ private readonly FileSystemWatcher? _watcher;
+ private GodotLoggerSettings _currentSettings = GodotLoggerSettings.Default;
+
+ ///
+ /// Initializes the configuration source and starts watching the discovered file when one is available.
+ ///
+ /// Optional imperative option overrides applied after file settings are loaded.
+ /// Thrown during initial loading when the configuration file cannot be read.
+ /// Thrown during initial loading when the configuration file is locked.
+ ///
+ /// Initial loading uses retry/backoff and propagates the final error because startup configuration failures should
+ /// be visible. Watcher callbacks use the hot-reload path and preserve the previous snapshot on failure.
+ ///
+ public GodotLogConfigurationSource(Action? configure)
+ {
+ _configure = configure;
+
+ // Discovery is done before the first strict reload so startup reports invalid files immediately.
+ ConfigurationPath = GodotLoggerSettingsLoader.DiscoverConfigurationPath();
+ Reload(throwOnError: true);
+ _watcher = CreateWatcher(ConfigurationPath);
+ }
+
+ ///
+ /// Gets the discovered configuration file path, or null when no supported location contains a file.
+ ///
+ public string? ConfigurationPath { get; }
+
+ ///
+ /// Gets the last successfully loaded settings snapshot.
+ ///
+ ///
+ /// The snapshot is read through Volatile.Read so logger instances running on other
+ /// threads observe settings published by reload callbacks without taking the configuration lock.
+ ///
+ public GodotLoggerSettings CurrentSettings => Volatile.Read(ref _currentSettings);
+
+ ///
+ /// Stops the file watcher before the source is abandoned.
+ ///
+ ///
+ /// Disposal does not clear ; existing loggers can continue using the last published
+ /// snapshot after watcher notifications have been stopped.
+ ///
+ public void Dispose()
+ {
+ _watcher?.Dispose();
+ }
+
+ ///
+ /// Creates the watcher that drives hot reload for the discovered configuration file.
+ ///
+ /// The configuration file to watch.
+ /// A configured watcher, or null when no stable directory and file name can be resolved.
+ private FileSystemWatcher? CreateWatcher(string? configurationPath)
+ {
+ if (string.IsNullOrWhiteSpace(configurationPath))
+ {
+ return null;
+ }
+
+ var directory = Path.GetDirectoryName(configurationPath);
+ var fileName = Path.GetFileName(configurationPath);
+ if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(fileName))
+ {
+ return null;
+ }
+
+ // FileSystemWatcher raises callbacks on thread-pool threads; callbacks keep reload work short and non-blocking.
+ var watcher = new FileSystemWatcher(directory, fileName)
+ {
+ EnableRaisingEvents = true,
+ NotifyFilter = NotifyFilters.CreationTime
+ | NotifyFilters.FileName
+ | NotifyFilters.LastWrite
+ | NotifyFilters.Size
+ };
+
+ watcher.Changed += OnConfigurationChanged;
+ watcher.Created += OnConfigurationChanged;
+ watcher.Deleted += OnConfigurationChanged;
+ watcher.Renamed += OnConfigurationRenamed;
+ return watcher;
+ }
+
+ private void OnConfigurationChanged(object sender, FileSystemEventArgs e)
+ {
+ Reload(throwOnError: false);
+ }
+
+ private void OnConfigurationRenamed(object sender, RenamedEventArgs e)
+ {
+ Reload(throwOnError: false);
+ }
+
+ ///
+ /// Reloads settings and publishes them when loading succeeds.
+ ///
+ /// Whether load errors should escape to the caller.
+ private void Reload(bool throwOnError)
+ {
+ try
+ {
+ var settings = throwOnError ? LoadSettingsWithRetry() : LoadSettings();
+
+ // Volatile publication gives cached loggers a coherent replacement snapshot without per-log locks.
+ Volatile.Write(ref _currentSettings, settings);
+ }
+ catch when (!throwOnError)
+ {
+ // Ignore transient parse or file-lock failures during hot reload and keep the last good snapshot.
+ }
+ }
+
+ ///
+ /// Loads settings with short retry/backoff for startup races with file writers or deployment tools.
+ ///
+ /// The loaded settings snapshot.
+ /// Thrown when no retry produced a usable settings snapshot.
+ private GodotLoggerSettings LoadSettingsWithRetry()
+ {
+ Exception? lastError = null;
+
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ try
+ {
+ return LoadSettings();
+ }
+ catch (IOException ex)
+ {
+ lastError = ex;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ lastError = ex;
+ }
+
+ if (attempt < 2)
+ {
+ // Startup can race with a writer finishing appsettings.json; keep the retry bounded and deterministic.
+ Thread.Sleep(50);
+ }
+ }
+
+ throw lastError ?? new InvalidOperationException("Failed to load Godot logging configuration.");
+ }
+
+ ///
+ /// Loads settings from disk or defaults, then applies imperative overrides.
+ ///
+ /// The settings snapshot to publish.
+ private GodotLoggerSettings LoadSettings()
+ {
+ var settings = string.IsNullOrWhiteSpace(ConfigurationPath) || !File.Exists(ConfigurationPath)
+ ? GodotLoggerSettings.Default
+ : GodotLoggerSettingsLoader.LoadFromJsonFile(ConfigurationPath);
+
+ if (_configure == null)
+ {
+ return settings;
+ }
+
+ var configuredOptions = CloneOptions(settings.Options);
+ _configure(configuredOptions);
+ return new GodotLoggerSettings(
+ configuredOptions.CreateNormalizedCopy(),
+ settings.DefaultLogLevel,
+ CopyLoggerLevels(settings));
+ }
+
+ ///
+ /// Creates a mutable options copy before user overrides are applied.
+ ///
+ /// The options from the file or default settings.
+ /// A normalized mutable copy.
+ private static GodotLoggerOptions CloneOptions(GodotLoggerOptions options)
+ {
+ return new GodotLoggerOptions
+ {
+ Mode = options.Mode,
+ DebugMinLevel = options.DebugMinLevel,
+ ReleaseMinLevel = options.ReleaseMinLevel,
+ DebugOutputTemplate = options.DebugOutputTemplate,
+ ReleaseOutputTemplate = options.ReleaseOutputTemplate,
+ Colors = options.Colors is { } colors
+ ? new Dictionary(colors)
+ : []
+ }.CreateNormalizedCopy();
+ }
+
+ ///
+ /// Copies category log level overrides into an ordinal dictionary.
+ ///
+ /// The source settings snapshot.
+ /// A copy that preserves exact and prefix matching semantics.
+ private static IReadOnlyDictionary CopyLoggerLevels(
+ GodotLoggerSettings settings)
+ {
+ var levels = new Dictionary(StringComparer.Ordinal);
+ foreach (var pair in settings.LoggerLevels)
+ {
+ levels[pair.Key] = pair.Value;
+ }
+
+ return levels;
+ }
+}
diff --git a/GFramework.Godot/Logging/GodotLogRenderContext.cs b/GFramework.Godot/Logging/GodotLogRenderContext.cs
new file mode 100644
index 00000000..095706cc
--- /dev/null
+++ b/GFramework.Godot/Logging/GodotLogRenderContext.cs
@@ -0,0 +1,21 @@
+using System;
+using GFramework.Core.Abstractions.Logging;
+
+namespace GFramework.Godot.Logging;
+
+///
+/// Carries the already-resolved values that needs to render one log line.
+///
+/// The UTC timestamp assigned to the log entry.
+/// The severity level used for filtering, formatting, and Godot debug routing.
+/// The source logger category name.
+/// The formatted log message body.
+/// The Godot BBCode color name resolved for .
+/// The preformatted structured property suffix, or an empty string when none exist.
+internal readonly record struct GodotLogRenderContext(
+ DateTime Timestamp,
+ LogLevel Level,
+ string Category,
+ string Message,
+ string Color,
+ string Properties);
diff --git a/GFramework.Godot/Logging/GodotLogTemplate.cs b/GFramework.Godot/Logging/GodotLogTemplate.cs
new file mode 100644
index 00000000..5bc147a2
--- /dev/null
+++ b/GFramework.Godot/Logging/GodotLogTemplate.cs
@@ -0,0 +1,374 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using System.Threading;
+using GFramework.Core.Abstractions.Logging;
+
+namespace GFramework.Godot.Logging;
+
+///
+/// Parses and renders Godot logger output templates.
+///
+///
+/// Supported placeholders include {timestamp}, {timestamp:format}, {level},
+/// {level:u3}, {level:l3}, {level:padded}, {category},
+/// {category:lN}, {category:rN}, {color}, {message}, and
+/// {properties}. Unknown placeholders are rendered back as {key} so configuration mistakes stay
+/// visible instead of silently deleting text. Parsed templates and category formatting results use bounded
+/// concurrent caches to avoid unbounded growth across hot reloads or dynamic category names.
+///
+internal sealed class GodotLogTemplate
+{
+ ///
+ /// Caches parsed template instances by the raw template text.
+ ///
+ ///
+ /// The cache is process-wide because templates are immutable after parsing. It is bounded so repeated hot reloads
+ /// with unique template strings cannot grow memory without limit.
+ ///
+ private static readonly BoundedCache Cache = new(maxEntries: 256);
+
+ ///
+ /// Caches formatted category names for this template instance.
+ ///
+ ///
+ /// Category formatting depends on the template segment and category name. The per-template cache is bounded to
+ /// protect long-running hosts that create loggers with dynamic category names.
+ ///
+ private readonly BoundedCache _categoryCache = new(maxEntries: 1024);
+ private readonly int _literalLength;
+ private readonly Action[] _segments;
+
+ private GodotLogTemplate(string template)
+ {
+ (_segments, _literalLength) = ParseCore(template);
+ }
+
+ ///
+ /// Parses or retrieves a cached template.
+ ///
+ /// The template text.
+ /// An immutable parsed template.
+ public static GodotLogTemplate Parse(string template)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+ return Cache.GetOrAdd(template, () => new GodotLogTemplate(template));
+ }
+
+ ///
+ /// Renders the template against a concrete log context.
+ ///
+ /// The resolved values for the log entry.
+ /// The rendered Godot log line.
+ public string Render(GodotLogRenderContext context)
+ {
+ var builder = new StringBuilder(_literalLength + context.Category.Length + context.Message.Length + 48);
+ foreach (var segment in _segments)
+ {
+ segment(builder, context);
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ /// Converts template text into literal and placeholder render segments.
+ ///
+ /// The template text to parse.
+ /// The render segments and total literal length used to size the output builder.
+ private (Action[] Segments, int LiteralLength) ParseCore(string template)
+ {
+ var segments = new List>();
+ var literalLength = 0;
+ var position = 0;
+
+ while (position < template.Length)
+ {
+ // The parser is deliberately small: scan literal runs, then turn balanced placeholders into delegates.
+ var open = template.IndexOf('{', position);
+ if (open < 0)
+ {
+ AddLiteral(template[position..]);
+ break;
+ }
+
+ if (open > position)
+ {
+ AddLiteral(template[position..open]);
+ }
+
+ var close = template.IndexOf('}', open + 1);
+ if (close < 0)
+ {
+ AddLiteral(template[open..]);
+ break;
+ }
+
+ var key = template.Substring(open + 1, close - open - 1);
+ segments.Add(CreateSegment(key));
+ position = close + 1;
+ }
+
+ return ([.. segments], literalLength);
+
+ void AddLiteral(string literal)
+ {
+ if (literal.Length == 0)
+ {
+ return;
+ }
+
+ literalLength += literal.Length;
+ // Capturing the literal string once avoids reparsing or slicing it on each rendered log entry.
+ segments.Add((builder, _) => builder.Append(literal));
+ }
+ }
+
+ ///
+ /// Creates the render delegate for one placeholder key.
+ ///
+ /// The placeholder name and optional format suffix.
+ /// A delegate that appends the placeholder value.
+ private Action CreateSegment(string key)
+ {
+ return key switch
+ {
+ "category" => static (builder, context) => builder.Append(context.Category),
+ "color" => static (builder, context) => builder.Append(context.Color),
+ "level" => static (builder, context) => builder.Append(context.Level),
+ "message" => static (builder, context) => builder.Append(context.Message),
+ "properties" => static (builder, context) => builder.Append(context.Properties),
+ "timestamp" => static (builder, context) => builder.Append(context.Timestamp.ToString(
+ "yyyy-MM-dd HH:mm:ss.fff",
+ CultureInfo.InvariantCulture)),
+ not null when key.StartsWith("category:", StringComparison.Ordinal) => CreateCategorySegment(key[9..]),
+ not null when key.StartsWith("level:", StringComparison.Ordinal) => CreateLevelSegment(key[6..]),
+ not null when key.StartsWith("timestamp:", StringComparison.Ordinal) => CreateTimestampSegment(key[10..]),
+ // Preserve unknown placeholders so configuration errors are visible in the rendered log line.
+ _ => (builder, _) => builder.Append('{').Append(key).Append('}')
+ };
+ }
+
+ ///
+ /// Creates the render delegate for a timestamp placeholder.
+ ///
+ /// The optional .NET timestamp format.
+ /// A delegate that appends the formatted timestamp using invariant culture.
+ private Action CreateTimestampSegment(string format)
+ {
+ if (string.IsNullOrWhiteSpace(format))
+ {
+ return static (builder, context) => builder.Append(context.Timestamp.ToString(
+ "yyyy-MM-dd HH:mm:ss.fff",
+ CultureInfo.InvariantCulture));
+ }
+
+ return (builder, context) => builder.Append(context.Timestamp.ToString(format, CultureInfo.InvariantCulture));
+ }
+
+ ///
+ /// Creates the render delegate for a level placeholder.
+ ///
+ /// The level format, such as u3, l3, or padded.
+ /// A delegate that appends the formatted level.
+ private static Action CreateLevelSegment(string format)
+ {
+ return format switch
+ {
+ "u3" or "U3" => static (builder, context) => builder.Append(ToShortLevel(context.Level, upper: true)),
+ "l3" or "L3" => static (builder, context) => builder.Append(ToShortLevel(context.Level, upper: false)),
+ "padded" or "Padded" => static (builder, context) => builder.Append(ToPaddedLevel(context.Level)),
+ _ => static (builder, context) => builder.Append(context.Level)
+ };
+ }
+
+ ///
+ /// Creates the render delegate for a category placeholder.
+ ///
+ /// The category alignment format, such as l16 or r32.
+ /// A delegate that appends the category with optional abbreviation and padding.
+ private Action CreateCategorySegment(string format)
+ {
+ if (format.Length < 2)
+ {
+ return static (builder, context) => builder.Append(context.Category);
+ }
+
+ var alignment = format[0];
+ if (alignment is not 'l' and not 'r')
+ {
+ return static (builder, context) => builder.Append(context.Category);
+ }
+
+ if (!int.TryParse(format[1..], NumberStyles.None, CultureInfo.InvariantCulture, out var width) || width <= 0)
+ {
+ return static (builder, context) => builder.Append(context.Category);
+ }
+
+ return alignment == 'l'
+ ? (builder, context) => builder.Append(GetFormattedCategory(context.Category, format, width, padLeft: false))
+ : (builder, context) => builder.Append(GetFormattedCategory(context.Category, format, width, padLeft: true));
+ }
+
+ ///
+ /// Formats and caches one category for a category alignment segment.
+ ///
+ /// The full category name.
+ /// The original segment format used as part of the cache key.
+ /// The desired category width.
+ /// Whether the result is left-padded instead of right-padded.
+ /// The abbreviated and padded category string.
+ private string GetFormattedCategory(string category, string format, int width, bool padLeft)
+ {
+ // Include the format in the key because the same category can render differently per width and alignment.
+ var cacheKey = string.Concat(format, "\0", category);
+ return _categoryCache.GetOrAdd(cacheKey, () =>
+ {
+ var abbreviated = AbbreviateCategory(category, width);
+ return padLeft ? abbreviated.PadLeft(width) : abbreviated.PadRight(width);
+ });
+ }
+
+ ///
+ /// Abbreviates dotted category names to fit a target width.
+ ///
+ /// The category to abbreviate.
+ /// The maximum rendered length.
+ /// The category shortened by initials, dropped prefixes, or final-segment truncation.
+ private static string AbbreviateCategory(string category, int maxLength)
+ {
+ if (category.Length <= maxLength)
+ {
+ return category;
+ }
+
+ var parts = category.Split('.');
+ if (parts.Length == 1)
+ {
+ return category[..maxLength];
+ }
+
+ for (var i = 0; i < parts.Length - 1; i++)
+ {
+ // Collapse namespace-like prefixes first so the most specific final segment remains readable.
+ if (parts[i].Length > 1)
+ {
+ parts[i] = parts[i][..1];
+ }
+ }
+
+ var start = 0;
+ while (start < parts.Length - 1)
+ {
+ var joined = string.Join(".", parts, start, parts.Length - start);
+ if (joined.Length <= maxLength)
+ {
+ return joined;
+ }
+
+ start++;
+ }
+
+ var last = parts[^1];
+ return last.Length > maxLength ? last[..maxLength] : last;
+ }
+
+ ///
+ /// Converts a level to its three-character form.
+ ///
+ /// The level to format.
+ /// Whether the result should use uppercase letters.
+ /// A three-character level label, or unk for undefined enum values.
+ private static string ToShortLevel(LogLevel level, bool upper)
+ {
+ var value = level switch
+ {
+ LogLevel.Trace => "trc",
+ LogLevel.Debug => "dbg",
+ LogLevel.Info => "inf",
+ LogLevel.Warning => "wrn",
+ LogLevel.Error => "err",
+ LogLevel.Fatal => "ftl",
+ _ => "unk"
+ };
+
+ return upper ? value.ToUpperInvariant() : value;
+ }
+
+ ///
+ /// Converts a level to the fixed-width historical Godot logger label.
+ ///
+ /// The level to format.
+ /// A padded level label, or output for undefined enum values.
+ private static string ToPaddedLevel(LogLevel level)
+ {
+ return level switch
+ {
+ LogLevel.Trace => "TRACE ",
+ LogLevel.Debug => "DEBUG ",
+ LogLevel.Info => "INFO ",
+ LogLevel.Warning => "WARNING",
+ LogLevel.Error => "ERROR ",
+ LogLevel.Fatal => "FATAL ",
+ _ => level.ToString()
+ };
+ }
+
+ private sealed class BoundedCache
+ {
+ private readonly ConcurrentDictionary> _entries = new(StringComparer.Ordinal);
+ private readonly int _maxEntries;
+ private long _sequence;
+
+ internal BoundedCache(int maxEntries)
+ {
+ _maxEntries = maxEntries;
+ }
+
+ internal TValue GetOrAdd(string key, Func valueFactory)
+ {
+ if (_entries.TryGetValue(key, out var existing))
+ {
+ return existing.Value;
+ }
+
+ var created = new CacheEntry(valueFactory(), Interlocked.Increment(ref _sequence));
+ var stored = _entries.GetOrAdd(key, created);
+ if (stored.Sequence == created.Sequence)
+ {
+ Trim();
+ }
+
+ return stored.Value;
+ }
+
+ private void Trim()
+ {
+ while (_entries.Count > _maxEntries)
+ {
+ var oldestKey = string.Empty;
+ var oldestSequence = long.MaxValue;
+
+ foreach (var pair in _entries)
+ {
+ if (pair.Value.Sequence >= oldestSequence)
+ {
+ continue;
+ }
+
+ oldestKey = pair.Key;
+ oldestSequence = pair.Value.Sequence;
+ }
+
+ if (oldestSequence == long.MaxValue || !_entries.TryRemove(oldestKey, out _))
+ {
+ break;
+ }
+ }
+ }
+ }
+
+ private readonly record struct CacheEntry(TValue Value, long Sequence);
+}
diff --git a/GFramework.Godot/Logging/GodotLogger.cs b/GFramework.Godot/Logging/GodotLogger.cs
index e0fc2948..d6dbe974 100644
--- a/GFramework.Godot/Logging/GodotLogger.cs
+++ b/GFramework.Godot/Logging/GodotLogger.cs
@@ -1,4 +1,7 @@
-using System.Globalization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging;
using Godot;
@@ -6,69 +9,215 @@ using Godot;
namespace GFramework.Godot.Logging;
///
-/// Godot平台的日志记录器实现。
-/// 该类继承自 ,用于在 Godot 引擎中输出日志信息。
-/// 支持不同日志级别的输出,并根据级别调用 Godot 的相应方法。
+/// Godot platform logger implementation.
///
-/// 日志记录器的名称,默认为根日志记录器名称。
-/// 最低日志级别,默认为 。
-public sealed class GodotLogger(
- string? name = null,
- LogLevel minLevel = LogLevel.Info) : AbstractLogger(name ?? RootLoggerName, minLevel)
+public sealed class GodotLogger : AbstractLogger
{
- // 静态缓存日志级别字符串,避免重复格式化
- private static readonly string[] LevelStrings =
- [
- "TRACE ",
- "DEBUG ",
- "INFO ",
- "WARNING",
- "ERROR ",
- "FATAL "
- ];
+ private readonly Func _optionsProvider;
///
- /// 写入日志的核心方法。
- /// 格式化日志消息并根据日志级别调用 Godot 的输出方法。
+ /// Initializes a logger that preserves the historical fixed-format template.
///
- /// 日志级别。
- /// 日志消息内容。
- /// 可选的异常信息。
+ /// The logger name.
+ /// The minimum enabled log level.
+ public GodotLogger(string? name = null, LogLevel minLevel = LogLevel.Info)
+ : this(
+ name ?? RootLoggerName,
+ CreateFixedOptionsProvider(minLevel),
+ () => minLevel)
+ {
+ }
+
+ ///
+ /// Initializes a logger with Godot-specific formatting options.
+ ///
+ /// The logger name.
+ /// The logger options.
+ public GodotLogger(string? name, GodotLoggerOptions options)
+ : this(
+ name ?? RootLoggerName,
+ CreateOptionsProvider(options),
+ CreateMinLevelProvider(options))
+ {
+ }
+
+ ///
+ /// Initializes the core logger with dynamic options and level providers.
+ ///
+ /// The resolved logger name used in rendered output.
+ /// The provider that supplies the latest rendering options for each write.
+ /// The provider that supplies the latest effective minimum level.
+ ///
+ /// The Godot factory uses this constructor so cached logger instances can observe hot-reloaded settings without
+ /// being recreated. The default public constructor supplies a fixed provider to avoid allocation on the log path.
+ ///
+ internal GodotLogger(
+ string name,
+ Func optionsProvider,
+ Func minLevelProvider)
+ : base(name, minLevelProvider ?? throw new ArgumentNullException(nameof(minLevelProvider)))
+ {
+ _optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider));
+ }
+
+ ///
+ /// Writes a log entry to Godot.
+ ///
+ /// The log level.
+ /// The rendered message body.
+ /// The optional exception.
protected override void Write(LogLevel level, string message, Exception? exception)
{
- // 构造时间戳和日志前缀
- var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
- var levelStr = LevelStrings[(int)level];
- var logPrefix = $"[{timestamp}] {levelStr} [{Name()}]";
+ WriteEntry(level, message, exception, properties: null);
+ }
- // 添加异常信息到日志消息中
- if (exception != null) message += "\n" + exception;
+ ///
+ /// Uses Godot-aware structured rendering instead of the base string concatenation fallback.
+ ///
+ public override void Log(LogLevel level, string message, params (string Key, object? Value)[] properties)
+ {
+ if (!IsEnabled(level))
+ {
+ return;
+ }
- var logMessage = $"{logPrefix} {message}";
+ WriteEntry(level, message, exception: null, properties);
+ }
+
+ ///
+ /// Uses Godot-aware structured rendering instead of the base string concatenation fallback.
+ ///
+ public override void Log(
+ LogLevel level,
+ string message,
+ Exception? exception,
+ params (string Key, object? Value)[] properties)
+ {
+ if (!IsEnabled(level))
+ {
+ return;
+ }
+
+ WriteEntry(level, message, exception, properties);
+ }
+
+ private void WriteEntry(
+ LogLevel level,
+ string message,
+ Exception? exception,
+ (string Key, object? Value)[]? properties)
+ {
+ var options = _optionsProvider();
+ var templateText = options.Mode == GodotLoggerMode.Debug
+ ? options.DebugOutputTemplate
+ : options.ReleaseOutputTemplate;
+ var context = new GodotLogRenderContext(
+ DateTime.UtcNow,
+ level,
+ Name(),
+ message,
+ options.GetColor(level),
+ FormatProperties(properties));
+ var rendered = GodotLogTemplate.Parse(templateText).Render(context);
+
+ if (options.Mode == GodotLoggerMode.Debug)
+ {
+ WriteDebug(level, rendered);
+ }
+ else
+ {
+ GD.Print(rendered);
+ }
+
+ if (exception != null)
+ {
+ GD.PrintErr(exception.ToString());
+ }
+ }
+
+ private static string FormatProperties((string Key, object? Value)[]? properties)
+ {
+ var merged = MergeProperties(properties);
+ if (merged.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ return " | " + string.Join(", ", merged.Select(static pair => $"{pair.Key}={FormatValue(pair.Value)}"));
+ }
+
+ private static IReadOnlyDictionary MergeProperties((string Key, object? Value)[]? properties)
+ {
+ var contextProperties = LogContext.Current;
+ if ((properties == null || properties.Length == 0) && contextProperties.Count == 0)
+ {
+ return EmptyProperties;
+ }
+
+ var merged = new Dictionary(contextProperties, StringComparer.Ordinal);
+ if (properties != null)
+ {
+ foreach (var property in properties)
+ {
+ if (string.IsNullOrWhiteSpace(property.Key))
+ {
+ continue;
+ }
+
+ merged[property.Key.Trim()] = property.Value;
+ }
+ }
+
+ return merged;
+ }
+
+ private static readonly IReadOnlyDictionary EmptyProperties =
+ new Dictionary(StringComparer.Ordinal);
+
+ private static Func CreateFixedOptionsProvider(LogLevel minLevel)
+ {
+ var options = GodotLoggerOptions.ForMinimumLevel(minLevel);
+ return () => options;
+ }
+
+ private static Func CreateOptionsProvider(GodotLoggerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ return () => options;
+ }
+
+ private static Func CreateMinLevelProvider(GodotLoggerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ return () => options.GetEffectiveMinLevel();
+ }
+
+ private static string FormatValue(object? value)
+ {
+ if (value == null)
+ {
+ return "null";
+ }
+
+ return value switch
+ {
+ IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
+ _ => value.ToString() ?? string.Empty
+ };
+ }
+
+ private static void WriteDebug(LogLevel level, string rendered)
+ {
+ GD.PrintRich(rendered);
- // 根据日志级别选择 Godot 输出方法
switch (level)
{
case LogLevel.Fatal:
- GD.PushError(logMessage);
- break;
case LogLevel.Error:
- GD.PrintErr(logMessage);
+ GD.PushError(rendered);
break;
case LogLevel.Warning:
- GD.PushWarning(logMessage);
- break;
- case LogLevel.Trace:
- GD.PrintRich($"[color=gray]{logMessage}[/color]");
- break;
- case LogLevel.Debug:
- GD.PrintRich($"[color=cyan]{logMessage}[/color]");
- break;
- case LogLevel.Info:
- GD.Print(logMessage);
- break;
- default:
- GD.Print(logMessage);
+ GD.PushWarning(rendered);
break;
}
}
diff --git a/GFramework.Godot/Logging/GodotLoggerFactory.cs b/GFramework.Godot/Logging/GodotLoggerFactory.cs
index efbfb7d9..dfc179a0 100644
--- a/GFramework.Godot/Logging/GodotLoggerFactory.cs
+++ b/GFramework.Godot/Logging/GodotLoggerFactory.cs
@@ -1,20 +1,46 @@
-using GFramework.Core.Abstractions.Logging;
+using System;
+using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
///
-/// Godot日志工厂类,用于创建Godot平台专用的日志记录器实例
+/// Creates Godot platform logger instances.
///
-public class GodotLoggerFactory : ILoggerFactory
+public sealed class GodotLoggerFactory : ILoggerFactory
{
+ private readonly GodotLoggerOptions? _options;
+
///
- /// 获取指定名称的日志记录器实例
+ /// Initializes a factory that preserves the historical fixed-format logger behavior.
///
- /// 日志记录器的名称
- /// 日志记录器的最小日志级别
- /// 返回GodotLogger类型的日志记录器实例
+ public GodotLoggerFactory()
+ {
+ }
+
+ ///
+ /// Initializes a factory with Godot-specific formatting options.
+ ///
+ /// The logger options.
+ public GodotLoggerFactory(GodotLoggerOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ ///
+ /// Gets a logger with the specified name.
+ ///
+ /// The logger name.
+ /// The minimum enabled level.
+ /// A Godot logger instance.
public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info)
{
- return new GodotLogger(name, minLevel);
+ ArgumentNullException.ThrowIfNull(name);
+
+ if (_options == null)
+ {
+ return new GodotLogger(name, minLevel);
+ }
+
+ return new GodotLogger(name, _options.WithMinimumLevelFloor(minLevel));
}
-}
\ No newline at end of file
+}
diff --git a/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs b/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs
index 9ce22a91..5dfb48fa 100644
--- a/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs
+++ b/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs
@@ -1,35 +1,76 @@
-using GFramework.Core.Abstractions.Logging;
-using GFramework.Core.Logging;
+using System;
+using System.Collections.Concurrent;
+using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
///
-/// Godot日志工厂提供程序,用于创建Godot日志记录器实例
+/// Provides cached Godot logger instances.
///
public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider
{
- private readonly ILoggerFactory _cachedFactory;
+ private readonly ConcurrentDictionary _loggers = new(StringComparer.Ordinal);
+ private readonly Func _settingsProvider;
///
- /// 初始化Godot日志记录器工厂提供程序
+ /// Initializes a Godot logger provider with the default logger factory.
///
public GodotLoggerFactoryProvider()
+ : this(static () => GodotLoggerSettings.Default)
{
- _cachedFactory = new CachedLoggerFactory(new GodotLoggerFactory());
}
///
- /// 获取或设置最小日志级别
+ /// Initializes a Godot logger provider with Godot-specific formatting options.
+ ///
+ /// The logger options.
+ public GodotLoggerFactoryProvider(GodotLoggerOptions options)
+ : this(CreateStaticSettingsProvider(options))
+ {
+ }
+
+ internal GodotLoggerFactoryProvider(Func settingsProvider)
+ {
+ _settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider));
+ }
+
+ ///
+ /// Gets or sets the provider minimum level.
///
public LogLevel MinLevel { get; set; }
///
- /// 创建指定名称的日志记录器实例(带缓存)
+ /// Creates a cached logger with the specified name.
///
- /// 日志记录器的名称
- /// 返回配置了最小日志级别的Godot日志记录器实例
+ /// The logger name.
+ /// A logger configured with .
public ILogger CreateLogger(string name)
{
- return _cachedFactory.GetLogger(name, MinLevel);
+ ArgumentNullException.ThrowIfNull(name);
+
+ return _loggers.GetOrAdd(
+ name,
+ static (loggerName, provider) => new GodotLogger(
+ loggerName,
+ provider.GetOptions,
+ () => provider.GetEffectiveMinLevel(loggerName)),
+ this);
}
-}
\ No newline at end of file
+
+ private GodotLoggerOptions GetOptions()
+ {
+ return _settingsProvider().Options;
+ }
+
+ private LogLevel GetEffectiveMinLevel(string categoryName)
+ {
+ return _settingsProvider().GetEffectiveMinLevel(categoryName, MinLevel);
+ }
+
+ private static Func CreateStaticSettingsProvider(GodotLoggerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var settings = GodotLoggerSettings.FromOptions(options);
+ return () => settings;
+ }
+}
diff --git a/GFramework.Godot/Logging/GodotLoggerMode.cs b/GFramework.Godot/Logging/GodotLoggerMode.cs
new file mode 100644
index 00000000..8bde69fa
--- /dev/null
+++ b/GFramework.Godot/Logging/GodotLoggerMode.cs
@@ -0,0 +1,17 @@
+namespace GFramework.Godot.Logging;
+
+///
+/// Selects the Godot logger output behavior.
+///
+public enum GodotLoggerMode
+{
+ ///
+ /// Uses rich BBCode console output and mirrors warnings/errors to the Godot debugger panel.
+ ///
+ Debug,
+
+ ///
+ /// Uses plain console output without rich text or debugger panel mirroring.
+ ///
+ Release
+}
diff --git a/GFramework.Godot/Logging/GodotLoggerOptions.cs b/GFramework.Godot/Logging/GodotLoggerOptions.cs
new file mode 100644
index 00000000..25be0962
--- /dev/null
+++ b/GFramework.Godot/Logging/GodotLoggerOptions.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using GFramework.Core.Abstractions.Logging;
+
+namespace GFramework.Godot.Logging;
+
+///
+/// Godot logger formatting and routing options.
+///
+public sealed class GodotLoggerOptions
+{
+ private static readonly IReadOnlyDictionary DefaultColors = new Dictionary
+ {
+ [LogLevel.Trace] = "gray",
+ [LogLevel.Debug] = "cyan",
+ [LogLevel.Info] = "white",
+ [LogLevel.Warning] = "orange",
+ [LogLevel.Error] = "red",
+ [LogLevel.Fatal] = "deep_pink"
+ };
+
+ ///
+ /// Gets or sets the output mode.
+ ///
+ public GodotLoggerMode Mode { get; set; } = GodotLoggerMode.Debug;
+
+ ///
+ /// Gets or sets the minimum level used by .
+ ///
+ public LogLevel DebugMinLevel { get; set; } = LogLevel.Debug;
+
+ ///
+ /// Gets or sets the minimum level used by .
+ ///
+ public LogLevel ReleaseMinLevel { get; set; } = LogLevel.Info;
+
+ ///
+ /// Gets or sets the BBCode-capable template used by .
+ ///
+#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
+ public string DebugOutputTemplate { get; set; } =
+ "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}{properties}";
+#pragma warning restore MA0016
+
+ ///
+ /// Gets or sets the plain text template used by .
+ ///
+#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
+ public string ReleaseOutputTemplate { get; set; } =
+ "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}{properties}";
+#pragma warning restore MA0016
+
+ ///
+ /// Gets or sets Godot named colors by log level.
+ ///
+#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
+ public Dictionary Colors { get; set; } = new(DefaultColors);
+#pragma warning restore MA0016
+
+ ///
+ /// Creates options that preserve the previous Godot logger defaults for a fixed minimum level.
+ ///
+ /// The minimum enabled level.
+ /// Options equivalent to the previous fixed-format logger behavior.
+ public static GodotLoggerOptions ForMinimumLevel(LogLevel minLevel)
+ {
+ return new GodotLoggerOptions
+ {
+ Mode = GodotLoggerMode.Debug,
+ DebugMinLevel = minLevel,
+ ReleaseMinLevel = minLevel,
+ DebugOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}{properties}",
+ ReleaseOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}{properties}",
+ Colors = new Dictionary(DefaultColors)
+ };
+ }
+
+ ///
+ /// Returns the configured color for the specified level.
+ ///
+ /// The level.
+ /// The Godot named color.
+ public string GetColor(LogLevel level)
+ {
+ if (Colors is { } colors && colors.TryGetValue(level, out var color) && !string.IsNullOrWhiteSpace(color))
+ {
+ return color;
+ }
+
+ return DefaultColors.TryGetValue(level, out var fallback) ? fallback : "white";
+ }
+
+ ///
+ /// Gets the active minimum level for the current .
+ ///
+ ///
+ /// when is ; otherwise
+ /// .
+ ///
+ ///
+ /// Factories use this value as the option-level floor before category-specific settings are applied.
+ ///
+ internal LogLevel GetEffectiveMinLevel()
+ {
+ return Mode == GodotLoggerMode.Debug ? DebugMinLevel : ReleaseMinLevel;
+ }
+
+ ///
+ /// Creates a copy whose debug and release floors are at least .
+ ///
+ /// The minimum level that both mode-specific floors must satisfy.
+ /// A normalized copy with stricter or equal mode-specific minimum levels.
+ ///
+ /// The operation can raise and through
+ /// , but it never lowers them. ,
+ /// , and are preserved through a defensive copy.
+ ///
+ internal GodotLoggerOptions WithMinimumLevelFloor(LogLevel minLevel)
+ {
+ return new GodotLoggerOptions
+ {
+ Mode = Mode,
+ DebugMinLevel = Max(DebugMinLevel, minLevel),
+ ReleaseMinLevel = Max(ReleaseMinLevel, minLevel),
+ DebugOutputTemplate = DebugOutputTemplate,
+ ReleaseOutputTemplate = ReleaseOutputTemplate,
+ Colors = CopyColorsWithDefaults(Colors)
+ };
+ }
+
+ ///
+ /// Creates a copy that replaces missing templates or color mappings with safe defaults.
+ ///
+ /// A normalized copy suitable for runtime rendering.
+ ///
+ /// JSON input can set , , or
+ /// to null even though the public API treats them as non-null. This method keeps
+ /// deserialization and imperative configuration from publishing values that would fail during rendering.
+ ///
+ internal GodotLoggerOptions CreateNormalizedCopy()
+ {
+ var defaults = new GodotLoggerOptions();
+
+ return new GodotLoggerOptions
+ {
+ Mode = Mode,
+ DebugMinLevel = DebugMinLevel,
+ ReleaseMinLevel = ReleaseMinLevel,
+ DebugOutputTemplate = string.IsNullOrWhiteSpace(DebugOutputTemplate)
+ ? defaults.DebugOutputTemplate
+ : DebugOutputTemplate,
+ ReleaseOutputTemplate = string.IsNullOrWhiteSpace(ReleaseOutputTemplate)
+ ? defaults.ReleaseOutputTemplate
+ : ReleaseOutputTemplate,
+ Colors = CopyColorsWithDefaults(Colors)
+ };
+ }
+
+ private static LogLevel Max(LogLevel left, LogLevel right)
+ {
+ return left > right ? left : right;
+ }
+
+ private static Dictionary CopyColorsWithDefaults(Dictionary? colors)
+ {
+ var merged = new Dictionary(DefaultColors);
+ if (colors == null)
+ {
+ return merged;
+ }
+
+ foreach (var pair in colors)
+ {
+ if (!string.IsNullOrWhiteSpace(pair.Value))
+ {
+ merged[pair.Key] = pair.Value;
+ }
+ }
+
+ return merged;
+ }
+}
diff --git a/GFramework.Godot/Logging/GodotLoggerSettings.cs b/GFramework.Godot/Logging/GodotLoggerSettings.cs
new file mode 100644
index 00000000..e95962fe
--- /dev/null
+++ b/GFramework.Godot/Logging/GodotLoggerSettings.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using GFramework.Core.Abstractions.Logging;
+
+namespace GFramework.Godot.Logging;
+
+///
+/// Represents one immutable Godot logger configuration snapshot.
+///
+///
+/// A snapshot combines mode-specific , an optional default log level, and category overrides.
+/// Category matching is ordinal and deterministic: exact matches win first, then the longest dotted prefix such as
+/// Game.Services for Game.Services.Inventory, and finally is used when
+/// present.
+///
+internal sealed class GodotLoggerSettings
+{
+ private readonly IReadOnlyDictionary _loggerLevels;
+
+ ///
+ /// Gets the default settings snapshot used when no configuration file is available.
+ ///
+ public static GodotLoggerSettings Default { get; } = new(new GodotLoggerOptions());
+
+ ///
+ /// Creates a settings snapshot from normalized options and optional category thresholds.
+ ///
+ /// The formatting and mode options for this snapshot.
+ /// The optional fallback level used when no category override matches.
+ /// Exact category names or dotted prefixes mapped to minimum levels.
+ public GodotLoggerSettings(
+ GodotLoggerOptions options,
+ LogLevel? defaultLogLevel = null,
+ IReadOnlyDictionary? loggerLevels = null)
+ {
+ Options = (options ?? throw new ArgumentNullException(nameof(options))).CreateNormalizedCopy();
+ DefaultLogLevel = defaultLogLevel;
+ _loggerLevels = loggerLevels ?? new Dictionary(StringComparer.Ordinal);
+ }
+
+ ///
+ /// Gets the optional fallback minimum level for categories without exact or prefix overrides.
+ ///
+ public LogLevel? DefaultLogLevel { get; }
+
+ ///
+ /// Gets normalized rendering and mode options for this snapshot.
+ ///
+ public GodotLoggerOptions Options { get; }
+
+ ///
+ /// Gets exact and dotted-prefix category level overrides.
+ ///
+ ///
+ /// Keys are interpreted with semantics. A key only matches a child category
+ /// when the category starts with the key plus a dot, which prevents Game.Service from matching
+ /// Game.Services accidentally.
+ ///
+ public IReadOnlyDictionary LoggerLevels => _loggerLevels;
+
+ ///
+ /// Creates a settings snapshot from options without any category overrides.
+ ///
+ /// The options to normalize and wrap.
+ /// A settings snapshot that relies only on the option-level minimum level.
+ public static GodotLoggerSettings FromOptions(GodotLoggerOptions options)
+ {
+ return new GodotLoggerSettings(options);
+ }
+
+ ///
+ /// Calculates the effective minimum level for a category.
+ ///
+ /// The logger category name.
+ /// The provider-level floor captured by the logger.
+ /// The strictest level selected from options, provider floor, and category configuration.
+ ///
+ /// The merge starts with and
+ /// , then applies when it returns a
+ /// value. is used at each step so configuration can only make a logger
+ /// stricter, never more verbose than the active floor.
+ ///
+ public LogLevel GetEffectiveMinLevel(string categoryName, LogLevel providerMinLevel)
+ {
+ ArgumentNullException.ThrowIfNull(categoryName);
+
+ var effective = Max(Options.GetEffectiveMinLevel(), providerMinLevel);
+ var configuredLevel = GetConfiguredMinLevel(categoryName);
+ return configuredLevel.HasValue ? Max(effective, configuredLevel.Value) : effective;
+ }
+
+ ///
+ /// Finds the configured category level using exact match, longest dotted-prefix match, then default fallback.
+ ///
+ /// The category to resolve.
+ /// The configured level, or null when no default or override applies.
+ private LogLevel? GetConfiguredMinLevel(string categoryName)
+ {
+ // Exact category configuration is the most specific and avoids unnecessary prefix scans.
+ if (_loggerLevels.TryGetValue(categoryName, out var exactLevel))
+ {
+ return exactLevel;
+ }
+
+ var bestMatchLength = -1;
+ LogLevel? bestMatchLevel = DefaultLogLevel;
+
+ foreach (var pair in _loggerLevels)
+ {
+ // The dotted boundary keeps sibling categories from matching by raw string prefix alone.
+ if (!categoryName.StartsWith(pair.Key + ".", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (pair.Key.Length <= bestMatchLength)
+ {
+ continue;
+ }
+
+ bestMatchLength = pair.Key.Length;
+ bestMatchLevel = pair.Value;
+ }
+
+ return bestMatchLevel;
+ }
+
+ ///
+ /// Returns the stricter of two log levels.
+ ///
+ /// The first level.
+ /// The second level.
+ /// The level with the higher severity ordering.
+ private static LogLevel Max(LogLevel left, LogLevel right)
+ {
+ return left > right ? left : right;
+ }
+}
diff --git a/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs b/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs
new file mode 100644
index 00000000..d460ba84
--- /dev/null
+++ b/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using GFramework.Core.Abstractions.Logging;
+using Godot;
+
+namespace GFramework.Godot.Logging;
+
+///
+/// Discovers and parses Godot logging configuration documents.
+///
+///
+/// The loader treats JSON as external input: enum values are validated, nullable serializer output is normalized,
+/// and unsupported values produce clear exceptions before a settings snapshot reaches runtime log rendering.
+///
+internal static class GodotLoggerSettingsLoader
+{
+ ///
+ /// Names the environment variable that can point to an explicit Godot logging configuration file.
+ ///
+ internal const string ConfigEnvironmentVariableName = "GODOT_LOGGER_CONFIG";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ AllowTrailingCommas = true,
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ Converters =
+ {
+ new GodotLogLevelJsonConverter(),
+ new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: true)
+ }
+ };
+
+ ///
+ /// Finds the first supported configuration file location.
+ ///
+ /// Optional explicit path used instead of reading the environment variable.
+ /// Optional process path used when checking the executable directory.
+ /// Optional resolver for Godot res:// paths.
+ /// The first existing configuration path, or null when none exists.
+ public static string? DiscoverConfigurationPath(
+ string? environmentPath = null,
+ string? processPath = null,
+ Func? projectPathResolver = null)
+ {
+ var envPath = environmentPath ?? System.Environment.GetEnvironmentVariable(ConfigEnvironmentVariableName);
+ if (!string.IsNullOrWhiteSpace(envPath) && File.Exists(envPath))
+ {
+ return envPath;
+ }
+
+ var resolvedProcessPath = processPath ?? System.Environment.ProcessPath;
+ if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
+ {
+ var executableDirectory = Path.GetDirectoryName(resolvedProcessPath);
+ if (!string.IsNullOrWhiteSpace(executableDirectory))
+ {
+ var candidate = Path.Combine(executableDirectory, "appsettings.json");
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
+ }
+
+ var resolver = projectPathResolver ?? SafeGlobalizeProjectPath;
+ var projectCandidate = resolver("res://appsettings.json");
+ if (!string.IsNullOrWhiteSpace(projectCandidate) && File.Exists(projectCandidate))
+ {
+ return projectCandidate;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Loads a settings snapshot from a JSON file.
+ ///
+ /// The configuration file path.
+ /// The parsed and normalized settings snapshot.
+ /// Thrown when does not exist.
+ public static GodotLoggerSettings LoadFromJsonFile(string filePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
+
+ if (!File.Exists(filePath))
+ {
+ throw new FileNotFoundException($"Configuration file not found: {filePath}", filePath);
+ }
+
+ return LoadFromJsonString(File.ReadAllText(filePath));
+ }
+
+ ///
+ /// Parses a settings snapshot from a JSON string.
+ ///
+ /// The JSON configuration content.
+ /// The parsed and normalized settings snapshot.
+ /// Thrown when an unsupported log level or malformed document is encountered.
+ public static GodotLoggerSettings LoadFromJsonString(string json)
+ {
+ ArgumentNullException.ThrowIfNull(json);
+
+ var root = JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidOperationException("Failed to deserialize Godot logging configuration.");
+
+ var logging = root.Logging;
+ var options = (logging?.GodotLogger ?? new GodotLoggerOptions()).CreateNormalizedCopy();
+
+ LogLevel? defaultLogLevel = null;
+ var loggerLevels = new Dictionary(StringComparer.Ordinal);
+
+ if (logging?.LogLevel != null)
+ {
+ foreach (var pair in logging.LogLevel)
+ {
+ if (string.Equals(pair.Key, "Default", StringComparison.OrdinalIgnoreCase))
+ {
+ defaultLogLevel = pair.Value;
+ }
+ else
+ {
+ loggerLevels[pair.Key] = pair.Value;
+ }
+ }
+ }
+
+ return new GodotLoggerSettings(options, defaultLogLevel, loggerLevels);
+ }
+
+ private static string? SafeGlobalizeProjectPath(string path)
+ {
+ try
+ {
+ return ProjectSettings.GlobalizePath(path);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private sealed class GodotLoggerSettingsDocument
+ {
+ public LoggingDocument? Logging { get; set; }
+ }
+
+ private sealed class LoggingDocument
+ {
+ public GodotLoggerOptions? GodotLogger { get; set; }
+
+ public Dictionary? LogLevel { get; set; }
+ }
+
+ private sealed class GodotLogLevelJsonConverter : JsonConverter
+ {
+ public override LogLevel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numericValue))
+ {
+ if (!Enum.IsDefined(typeof(LogLevel), numericValue))
+ {
+ throw new JsonException(
+ $"Unsupported numeric {nameof(LogLevel)} value '{numericValue}'. Expected a defined {nameof(LogLevel)} value.");
+ }
+
+ return (LogLevel)numericValue;
+ }
+
+ if (reader.TokenType != JsonTokenType.String)
+ {
+ throw new JsonException($"Unexpected token {reader.TokenType} when parsing {nameof(LogLevel)}.");
+ }
+
+ return Parse(reader.GetString());
+ }
+
+ public override void Write(Utf8JsonWriter writer, LogLevel value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString());
+ }
+
+ public override LogLevel ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert,
+ JsonSerializerOptions options)
+ {
+ return Parse(reader.GetString());
+ }
+
+ public override void WriteAsPropertyName(Utf8JsonWriter writer, LogLevel value, JsonSerializerOptions options)
+ {
+ writer.WritePropertyName(value.ToString());
+ }
+
+ private static LogLevel Parse(string? value)
+ {
+ return value?.Trim() switch
+ {
+ "Trace" or "trace" => LogLevel.Trace,
+ "Debug" or "debug" => LogLevel.Debug,
+ "Info" or "info" or "Information" or "information" => LogLevel.Info,
+ "Warning" or "warning" or "Warn" or "warn" => LogLevel.Warning,
+ "Error" or "error" => LogLevel.Error,
+ "Fatal" or "fatal" or "Critical" or "critical" => LogLevel.Fatal,
+ _ => throw new JsonException($"Unsupported log level '{value}'.")
+ };
+ }
+ }
+}
diff --git a/ai-plan/public/README.md b/ai-plan/public/README.md
index 1aeea93f..447d2d23 100644
--- a/ai-plan/public/README.md
+++ b/ai-plan/public/README.md
@@ -38,6 +38,10 @@ help the current worktree land on the right recovery documents without scanning
- Purpose: continue the data repository persistence hardening plus the settings / serialization follow-up backlog.
- Tracking: `ai-plan/public/data-repository-persistence/todos/data-repository-persistence-tracking.md`
- Trace: `ai-plan/public/data-repository-persistence/traces/data-repository-persistence-trace.md`
+- `godot-logging-compliance-polish`
+ - Purpose: continue Godot logging host integration, configuration reload, structured-output polish, and follow-up work without forking the Core logging model.
+ - Tracking: `ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md`
+ - Trace: `ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md`
- `semantic-release-versioning`
- Purpose: migrate release version calculation from fixed patch bumps to semantic-release while keeping the existing tag-driven NuGet publish flow.
- Tracking: `ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md`
@@ -59,10 +63,10 @@ help the current worktree land on the right recovery documents without scanning
- Branch: `feat/data-repository-persistence`
- Worktree hint: `GFramework-data-repository-persistence`
- Priority 1: `data-repository-persistence`
-- Branch: `feat/semantic-release-versioning`
+- Branch: `feat/godot-logging-compliance-polish`
- Worktree hint: `GFramework`
- - Priority 1: `semantic-release-versioning`
-- Branch: `feat/release-summary-notes`
+ - Priority 1: `godot-logging-compliance-polish`
+- Branch: `feat/semantic-release-versioning`
- Worktree hint: `GFramework`
- Priority 1: `semantic-release-versioning`
- Branch: `docs/sdk-update-documentation`
diff --git a/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md b/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md
new file mode 100644
index 00000000..b2b18dcd
--- /dev/null
+++ b/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md
@@ -0,0 +1,90 @@
+# Godot Logging Compliance Polish 跟踪
+
+## 目标
+
+继续把 `GFramework.Godot.Logging` 从“基础可用的 Godot 输出适配”收敛成“对齐 `GodotLogger` 优点、但保持
+GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot / Core 日志统一留下清晰恢复点。
+
+## 当前恢复点
+
+- 恢复点编号:`GODOT-LOGGING-COMPLIANCE-POLISH-RP-003`
+- 当前阶段:`PR review follow-up`
+- 当前焦点:
+ - 已补齐 `GodotLog` 静态入口、延迟 logger 解析、配置自动发现与热重载
+ - 已让 `GodotLoggerFactoryProvider` 对已缓存 logger 生效动态配置,而不是只在新建 logger 时读快照
+ - 已让 `GodotLogger` 支持 `{properties}` 占位符,并把 `IStructuredLogger` / `LogContext` 属性落到 Godot 输出
+ - 已兼容 `GodotLogger` 风格配置值,如 `Information` / `Critical`
+ - 已处理 PR #314 最新 AI review 中仍适用的 XML docs、热路径分配、结构化属性兜底、文档示例和 tracking 精简问题
+ - 下一轮优先刷新 PR review / CI 反馈,避免继续扩大 Godot logging API 面
+
+## 当前状态摘要
+
+- `GFramework.Core` 仍是主日志框架;`GFramework.Godot` 没有引入第二套业务日志 API
+- `GFramework.Godot.Logging` 现在已经补上原 `GodotLogger` 项目最有价值的宿主便利层:
+ - `GodotLog`
+ - `DeferredLogger`
+ - 配置文件自动发现
+ - 文件热重载
+ - 结构化属性渲染
+- 本轮没有把 `Microsoft.Extensions.Logging` 的 `ILoggingBuilder` / `ILoggerProvider` 生态原样搬入 GFramework
+- `AbstractLogger` 已支持动态最小级别提供器,为 Godot 配置热更新生效打通基础能力
+
+## 当前活跃事实
+
+- 当前主题由分支 `feat/godot-logging-compliance-polish` 驱动,并已在 `ai-plan/public/README.md` 建立映射
+- `ai-libs/GodotLogger` 的 MIT 许可证已复制到 `third-party-licenses/GodotLogger/LICENSE`
+- `GodotLog` 当前的配置发现顺序为:
+ - `GODOT_LOGGER_CONFIG`
+ - 可执行目录 `appsettings.json`
+ - `res://appsettings.json`
+- `GodotLog.Configure(...)` 仍要求在首次 materialize provider 前调用;延迟 logger 会避免 `static readonly` 字段过早锁死配置
+- `GodotLoggerFactoryProvider` 当前按 logger 名称缓存实例,但每次判定级别和写日志都会读取最新 `GodotLoggerSettings`
+- Godot 模板默认已扩展为包含 `{properties}`,因此结构化属性和 `LogContext` 会进入渲染结果
+- 配置加载兼容两套级别命名:
+ - GFramework 风格:`Info` / `Fatal`
+ - `GodotLogger` 风格:`Information` / `Critical`
+- 现有设计仍保留 UTC 时间戳语义,没有为了对齐原项目而默认切回本地时间
+- `GodotLog.ConfigurationPath` 现在不会提前 materialize 全局配置源;`GodotLog.Shutdown()` 可释放已创建配置源的 watcher
+- 配置 JSON 会先归一化模板和颜色字典,并拒绝未定义的数字 `LogLevel`
+- `GodotLogTemplate` 的模板缓存和分类格式缓存已改为有界并发缓存,避免热重载或动态 category 长期单向增长
+- `refactor-scripts/update-namespaces.py` 已移除本机绝对路径默认值,并会把文件处理失败汇总成非零退出码
+- PR #314 最新 review 中,GodotLog watcher 释放、热重载回调阻塞、配置归一化、数字 `LogLevel` 校验、
+ 模板缓存和脚本健壮性问题已在当前 head 验证为已处理;本轮只继续修仍适用的问题
+- PR #314 最新 follow-up 中,`DeferredLogger` 格式化重载现在委托给 inner logger,`GodotLogger` 默认 options
+ provider 已改为构造时缓存,结构化属性会跳过空白 key 并使用 trimmed key
+
+## 当前风险
+
+- 双入口生命周期风险:如果同一宿主同时混用 `LoggerFactoryResolver.Provider` 与 `GodotLog`,需要明确谁是最终默认 provider
+ - 缓解措施:当前文档与实现都保留 `GodotLog.UseAsDefaultProvider()`,并继续把 `ArchitectureConfiguration` 方式写成默认推荐路径
+- Core / Godot 管线分离风险:Godot 侧虽然已有热重载与配置发现,但还没有变成 Core 可组合 appender
+ - 缓解措施:下一轮只评估“Godot sink / appender 化”,不再继续扩张独立的 Godot logging 面
+- 配置热重载的宿主差异风险:Godot 编辑器、导出包和测试宿主的文件系统语义不完全一致
+ - 缓解措施:active 入口先锁定 discovery / reload 语义,后续若遇到平台差异,再用定向回归和文档补充收口
+- `GodotLog.ConfigurationPath` 的“不会 materialize”语义没有加入自动化测试
+ - 缓解措施:直接调用会触碰 Godot project path resolver,在普通 test host 中可能崩溃;当前以实现和文档约束记录,后续若增加 Godot 宿主集成测试再覆盖
+
+## 活跃文档
+
+- 当前跟踪:[godot-logging-compliance-polish-tracking.md](./godot-logging-compliance-polish-tracking.md)
+- 当前 trace:[godot-logging-compliance-polish-trace.md](../traces/godot-logging-compliance-polish-trace.md)
+
+## 验证说明
+
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
+ - 结果:通过
+ - 备注:RP-003 follow-up 后 Godot logging 定向测试共 `15` 项通过
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
+ - 结果:通过
+ - 备注:RP-003 follow-up 后 Godot 测试项目共 `73` 项通过
+- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore --include ...`
+ - 结果:通过
+ - 备注:include 范围为本轮修改的 C# 文件;全项目 format 仍命中既有行尾 / 编码问题,详见 trace
+- 历史验证明细已保留在 [执行 trace](../traces/godot-logging-compliance-polish-trace.md) 的 `RP-001 验证` 与
+ `RP-002 验证` 小节,active tracking 入口只保留当前恢复点相关结果
+
+## 下一步
+
+1. 提交 RP-003 review follow-up 改动
+2. 刷新 PR review / CI 状态,确认最新 head 上 CodeRabbit 与 Greptile 线程是否关闭或变为 stale
+3. 若 CI 仍报 MegaLinter `dotnet-format` restore 失败,优先复核 Actions restore 环境,而不是继续改本地格式
diff --git a/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md b/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md
new file mode 100644
index 00000000..915585c8
--- /dev/null
+++ b/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md
@@ -0,0 +1,119 @@
+# Godot Logging Compliance Polish 追踪
+
+## 2026-05-02
+
+### 阶段:GodotLogger 优点吸收与宿主层补齐(RP-001)
+
+- 复核 `GodotLogger` 与 `GFramework.Godot.Logging` 后确认:
+ - 模板、颜色、Debug/Release 双模式、类别缩写和 Godot 输出路由已基本吸收
+ - 真正缺口主要在宿主接入与运行期配置层,而不是输出格式层
+- 本轮新建 `godot-logging-compliance-polish` topic,并将当前分支
+ `feat/godot-logging-compliance-polish` 映射到该主题
+- 为 `GFramework.Godot.Logging` 新增:
+ - `GodotLog`
+ - `DeferredLogger`
+ - `GodotLogConfigurationSource`
+ - `GodotLoggerSettings`
+ - `GodotLoggerSettingsLoader`
+- 关键实现决策:
+ - 不把 `Microsoft.Extensions.Logging` 的 builder / provider 生态整套移植进来
+ - 保持 `LoggerFactoryResolver` 与 `ArchitectureConfiguration` 仍是主接线方式
+ - 只吸收 `GodotLogger` 里对 GFramework 现有模型真正有价值的部分:
+ - 配置自动发现
+ - 热重载
+ - 延迟 logger 初始化
+ - 配置命名兼容
+- 为让热重载作用于已缓存 logger,调整 `AbstractLogger` 支持动态最小级别提供器,并让
+ `GodotLoggerFactoryProvider` / `GodotLogger` 在写入和级别判定时读取最新设置
+- 为让结构化日志在 Godot 侧不再退化成纯字符串,扩展:
+ - `GodotLogRenderContext`
+ - `GodotLogTemplate`
+ - `GodotLoggerOptions`
+ - `GodotLogger`
+ 使默认模板支持 `{properties}`,并将 `IStructuredLogger` / `LogContext` 属性渲染到输出中
+- 为兼容 `GodotLogger` 原项目配置习惯,在 `GodotLoggerSettingsLoader` 中补充枚举解析兼容:
+ - `Info` / `Information`
+ - `Fatal` / `Critical`
+ - `Warn` / `Warning`
+- 同步更新 `docs/zh-CN/godot/logging.md`,把文档结论从“只有薄适配层”刷新成“已具备宿主便利层和热重载语义”
+- 已从 `ai-libs/GodotLogger` 复制 MIT 许可证到 `third-party-licenses/GodotLogger/LICENSE`
+
+### RP-001 验证
+
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
+ - 结果:通过(11/11)
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
+ - 结果:通过(69/69)
+- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~Logging -nologo`
+ - 结果:通过(214/214)
+
+### RP-001 下一步
+
+1. 若继续推进本主题,优先评估 Godot 输出是否应变成 Core 可组合 appender / sink
+2. 若出现后续 review 反馈,直接在本 topic 追加 RP-002,而不是重新开临时 local-plan
+3. 若本主题阶段性完成,再把详细实现 history 迁入 `archive/`,active 入口只保留恢复点与风险
+
+### 阶段:PR review hardening(RP-002)
+
+- 使用 `$gframework-pr-review` 抓取 PR #314 最新 review payload,确认当前 head 上仍有 CodeRabbit 与 Greptile
+ 未解决线程
+- 接受并处理仍适用的 review 结论:
+ - `GodotLog.ConfigurationPath` 不应提前创建全局配置源,`Configure(...)` 需要在 provider 或配置源已创建后 fail-fast
+ - 静态配置源需要可显式释放 watcher,因此新增 `GodotLog.Shutdown()`
+ - `DeferredLogger` 首次解析改为 `Interlocked.CompareExchange` 发布,避免 `_inner ??=` 并发竞态
+ - `GodotLogger` 结构化 `Log(...)` 覆写改为复用 `IsEnabled(level)`,删除重复的最小级别 provider 字段
+ - JSON 配置输入需要归一化模板和颜色字典,并拒绝未定义的数字 `LogLevel`
+ - `GodotLogTemplate` 模板缓存和分类缓存需要有界,避免热重载或动态 category 长期增长
+ - `refactor-scripts/update-namespaces.py` 不能依赖本机绝对路径,也不能把文件处理异常吞成 0 次替换
+- 同步补充 Godot logging 内部类型和关键方法 XML 文档,说明热重载、快照发布、分类匹配和模板缓存语义
+- 同步更新 `docs/zh-CN/godot/logging.md`,记录 `ConfigurationPath` 的诊断语义和 `Shutdown()` teardown 用法
+
+### RP-002 验证
+
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
+ - 结果:通过(15/15)
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
+ - 结果:通过(73/73)
+- `python3 -B refactor-scripts/update-namespaces.py --help`
+ - 结果:通过
+
+### RP-002 下一步
+
+1. 提交 RP-002 review hardening 改动
+2. 刷新 PR review / CI,确认最新 head 是否关闭已处理线程
+3. 若 CI 仍只有 MegaLinter `dotnet-format` restore 失败,优先定位 Actions restore 环境
+
+## 2026-05-03
+
+### 阶段:PR review follow-up(RP-003)
+
+- 再次使用 `$gframework-pr-review` 抓取 PR #314 最新 review payload,确认当前 head 上仍有 CodeRabbit 与
+ Greptile 未解决线程
+- 本轮验证后接受并处理仍适用的 review 结论:
+ - `GodotLoggerSettingsLoaderTests` 公开测试类型与公开测试方法需要 XML 文档
+ - `DeferredLogger` 的公开接口成员需要 XML 文档,并且格式化 `Log(...)` 重载不应提前执行 `string.Format`
+ - `GodotLogger` 默认构造器不应在每条日志上重新创建 options,结构化属性 key 需要跳过空白并做 trim
+ - Godot logging 文档需要给出最小 `appsettings.json` 示例、放置约定和热重载覆盖说明
+ - active tracking 不应同时保留 RP-001 与 RP-002 的详细验证计数,trace 重复标题需要消除
+- 本轮验证后确认以下旧 review 结论在当前 head 已处理,无需重复改动:
+ - `GodotLog.Shutdown()` 已可释放 materialized configuration source 的 watcher
+ - hot-reload callback 已走无 `Thread.Sleep` 的 `LoadSettings()`,`Thread.Sleep` 只保留在 startup strict load retry
+ - JSON options 归一化、数字 `LogLevel` 校验、GodotLogTemplate 缓存和 namespace 脚本健壮性已在当前 head 存在
+
+### RP-003 验证
+
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo`
+ - 结果:通过(14/14)
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo`
+ - 结果:通过(72/72)
+- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore --include ...`
+ - 结果:通过,include 范围为本轮修改的三个 C# 文件
+- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore`
+ - 结果:未通过;命中既有 `GFramework.Godot.Tests/Coroutine/GodotTimeSourceTests.cs` 行尾与
+ `GFramework.Godot.Tests/GlobalUsings.cs` 编码问题,本轮未把该历史格式清理并入 PR review follow-up
+
+### RP-003 下一步
+
+1. 提交 RP-003 review follow-up 改动
+2. 刷新 PR review,确认 CodeRabbit / Greptile 线程是否关闭或 stale
+3. 若 CI 仍只有 MegaLinter `dotnet-format` restore 失败,继续定位 Actions restore 环境而不是扩大本地格式清理范围
diff --git a/ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md b/ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md
new file mode 100644
index 00000000..b3ad830e
--- /dev/null
+++ b/ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md
@@ -0,0 +1,23 @@
+# Semantic Release 版本迁移归档(SEMREL-RP-004,2026-05-02)
+
+## 归档范围
+
+- `feat/release-summary-notes` 分支的 release notes 模板修复
+- PR 归属展示与重复 commit 输出问题收敛
+- `SEMREL-RP-004` 的本地验证与合并后分支收尾
+
+## 历史完成项
+
+- 已确认 `.github/cliff.toml` 中旧模板会先输出未分组 commit 列表,再输出 grouped commit 列表,导致同一批变更重复出现。
+- 已移除未分组 commit 循环,只保留按 Conventional Commit group 分类后的 `What's Changed` 输出。
+- 已保留每条变更末尾的 `by @user in #PR` 输出,避免新增独立 PR 索引章节造成重复。
+- 已将 `.github/workflows/publish.yml` 的 GitHub Release 正文改为 `body_path: RELEASE_NOTES.md`,复用 `git-cliff-action` 生成的文件。
+- 已将 `feat/release-summary-notes` 合入 `main`,本地 `main` 已快进到合并提交 `35a62e6b`。
+- 已从 `ai-plan/public/README.md` 移除 `feat/release-summary-notes` 的 active topic 映射;`semantic-release-versioning` 主题本身仍保持 active。
+
+## 历史验证
+
+- `.github/cliff.toml` 通过 Python `tomllib` 解析。
+- `.github/workflows/publish.yml` 通过 PyYAML 解析。
+- `yq` 确认 GitHub Release step 使用 `body_path: RELEASE_NOTES.md`。
+- `dotnet build GFramework.sln -c Release` 通过,`0 warning / 0 error`。
diff --git a/ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md b/ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md
index a13acfa8..972d439b 100644
--- a/ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md
+++ b/ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md
@@ -13,12 +13,12 @@
## 当前恢复点
-- 恢复点编号:`SEMREL-RP-004`
-- 当前阶段:`Phase 2`
+- 恢复点编号:暂无
+- 当前阶段:等待下一轮 semantic-release 维护任务
- 当前焦点:
- - 收敛 release notes 的 PR 归属展示方式
- - 确保 `.github/cliff.toml` 不新增独立 PR 索引导致重复输出同一批 commits
- - 让分类变更列表本身承担 `What's Changed` 语义,并继续在每条 entry 末尾展示作者与 PR 链接
+ - `SEMREL-RP-004` 已归档到
+ `ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md`
+ - 后续如 CI 或发布流程继续暴露 semantic-release 问题,再从新的恢复点编号开始记录
### 已知风险
@@ -28,7 +28,6 @@
- `semantic-release` 的版本判断完全依赖 Conventional Commits;不规范提交会直接影响版本计算
- `cycjimmy/semantic-release-action@v6` 需要在 preview / release 两端都安装 `conventional-changelog-conventionalcommits`
以保证 `conventionalcommits` preset 在 GitHub Actions 中可解析
-- 当前仓库本地 `dotnet clean/build` 会带出既有 analyzer warnings;本轮仅修正发版配置与文档,不额外处理这些历史 warning
- `git-cliff-action` 的 `OUTPUT` 文件需要在 `softprops/action-gh-release` 执行时保留在当前工作目录,后续如调整
working-directory 或 artifact 路径,需要同步复查 `body_path`
@@ -40,39 +39,16 @@
- 已在 PAT 校验中补充 `permissions.push` 断言,避免 read-only token 通过 API 探活却在
`semantic-release` 的 `git push --dry-run` 阶段才失败
- 已为 PAT 校验的 `mktemp` 文件补充 `trap` 清理,避免异常退出时遗留临时文件路径干扰日志
-- 已同步更新 active trace 到 `SEMREL-RP-004`,记录本轮 PR review 收敛结果
-- 已用 `$gframework-pr-review` 抓取 PR #312 最新 review payload,确认未失败测试、未发现 MegaLinter 明细,仍有
- CodeRabbit / Greptile 针对 release notes 的未解决线程
-- 已移除 `.github/cliff.toml` 中 `## What's Changed` 下的未分组 commit 循环,仅保留按 Conventional Commit group
- 分类后的输出,避免每个 commit 在生成的 changelog 中出现两次
-- 已将 `.github/cliff.toml` 的分类变更列表重新置于 `## What's Changed` 下,但没有恢复未分组平铺列表;
- 每条变更继续通过 `print_commit` 输出 `by @user in #PR` 链接,满足 PR 追溯需求同时避免重复章节
-- 已将 `.github/workflows/publish.yml` 的 GitHub Release 正文从多行 expression 改为 `body_path: RELEASE_NOTES.md`,
- 复用 `git-cliff-action` 写出的 release notes 文件
-- 已在 `ai-plan/public/README.md` 中将 `feat/release-summary-notes` 映射到 `semantic-release-versioning`,便于后续
- `boot` 直接找到本次发布说明模板上下文
+- `SEMREL-RP-004` 的 release notes 模板修复、验证和合并后分支收尾已归档到
+ `ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md`
## 验证
-- `python3 -c 'import tomllib; tomllib.load(open(".github/cliff.toml", "rb")); print("cliff.toml OK")'`
- - 结果:通过
- - 备注:确认 `.github/cliff.toml` 仍为合法 TOML
-- `command -v git-cliff`
- - 结果:未找到本地 `git-cliff`
- - 备注:本地无法直接预览 git-cliff 输出,发布 workflow 仍通过 `orhun/git-cliff-action@v4` 提供运行时二进制
-- `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/publish.yml", encoding="utf-8")); print("publish.yml OK")'`
- - 结果:通过
- - 备注:确认 `.github/workflows/publish.yml` 仍可解析为 YAML
-- `yq '.jobs."create-release".steps[] | select(.name == "Create GitHub Release and Upload Assets") | .with' .github/workflows/publish.yml`
- - 结果:通过
- - 备注:确认 release step 现在使用 `body_path: RELEASE_NOTES.md`
-- `dotnet build GFramework.sln -c Release`
- - 结果:通过
- - 备注:Release 构建通过,`0 warning / 0 error`;本轮只改动 GitHub Actions / git-cliff 配置与恢复文档
+- `SEMREL-RP-004` 的本地验证结果已归档。
- 更早阶段的 dry-run / tag /抽象项目验证已归档到
`ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-2026-04-26.md`
## 下一步
-1. 提交并推送本轮 release notes 模板调整
-2. 如 CI 仍报告 release notes 发布问题,再优先复查 `git-cliff-action` 输出文件路径与模板渲染结果
+1. 如 CI 仍报告 release notes 发布问题,再优先复查 `git-cliff-action` 输出文件路径与模板渲染结果
+2. 下一轮 semantic-release 调整开始时创建新的恢复点编号
diff --git a/ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md b/ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md
index 2917ddd0..94150cc3 100644
--- a/ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md
+++ b/ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md
@@ -40,6 +40,16 @@
- `dotnet build GFramework.sln -c Release` 通过,`0 warning / 0 error`。
- 下一步是提交并推送本轮 PR review 修复,然后重新抓取 PR review 确认相关线程状态。
+## 2026-05-02
+
+### SEMREL-RP-004 合并后归档
+
+- `feat/release-summary-notes` 已合入 `main`,本地 `main` 快进到合并提交 `35a62e6b`。
+- 已将 `SEMREL-RP-004` 的 release notes 模板修复、验证和分支收尾记录归档到
+ `ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md`。
+- 已从 `ai-plan/public/README.md` 移除 `feat/release-summary-notes` 到 `semantic-release-versioning` 的 active topic 映射。
+- `semantic-release-versioning` 主题仍保持 active,等待下一轮 semantic-release 维护任务。
+
## 2026-04-26
### 当前恢复点(SEMREL-RP-004)
diff --git a/docs/zh-CN/godot/logging.md b/docs/zh-CN/godot/logging.md
index 039f9472..b6a71b44 100644
--- a/docs/zh-CN/godot/logging.md
+++ b/docs/zh-CN/godot/logging.md
@@ -5,11 +5,16 @@ description: 以当前 GFramework.Godot.Logging 源码与 CoreGrid 接线为准
# Godot 日志系统
-`GFramework.Godot` 当前的日志能力很收敛:它不是一套独立于 Core 的新日志框架,而是把现有 `ILogger` 调用面接到
-Godot 控制台。
+`GFramework.Godot` 当前的日志能力仍然以 Core 的 `ILogger` 调用面为中心,但已经不再只是一个薄输出适配层。
+除了把日志写到 Godot 控制台,它现在还补上了 Godot 宿主常见的接入便利层:
-换句话说,Godot 侧真正新增的是 provider / factory / logger 这层输出适配,而不是新的日志 API。业务代码仍然继续使用
-`LoggerFactoryResolver.Provider.CreateLogger(...)` 或 `[Log]` 生成的 `ILogger` 字段。
+- `GodotLog` 静态入口
+- 配置文件自动发现
+- 运行期配置热重载
+- 延迟 logger 解析,适合 `static readonly` 字段
+
+业务代码仍然继续使用 `LoggerFactoryResolver.Provider.CreateLogger(...)`、`GodotLog.CreateLogger(...)` 或 `[Log]`
+生成的 `ILogger` 字段;Godot 侧没有额外引入第二套业务日志 API。
## 当前公开入口
@@ -57,11 +62,79 @@ public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider
}
```
-它内部用 `CachedLoggerFactory` 包装 `GodotLoggerFactory`。缓存 key 由 `name` 和 `MinLevel` 共同组成,所以:
+当前 provider 会按 logger 名称缓存实例,但 logger 本身会在写入时读取当前配置快照,所以:
-- 同名、同 `MinLevel` 的 logger 会复用实例
-- 调整 `MinLevel` 后,新创建的 logger 会走新的缓存 key
-- 已经持有的旧 logger 不会被原地改写
+- 同名 logger 会复用实例
+- 调整 provider 最小级别或热更新配置后,已持有的 logger 会立即看到新行为
+- 不需要为了刷新模板、颜色或级别而重新创建 logger
+
+### `GodotLog`
+
+`GodotLog` 是新增的 Godot 宿主友好入口:
+
+```csharp
+using GFramework.Godot.Logging;
+
+GodotLog.Configure(options =>
+{
+ options.Mode = GodotLoggerMode.Debug;
+});
+
+GodotLog.UseAsDefaultProvider();
+
+var logger = GodotLog.CreateLogger();
+```
+
+它提供三件事:
+
+- 在第一次真正创建 provider 前允许代码覆写 `GodotLoggerOptions`
+- 自动按 `GODOT_LOGGER_CONFIG` -> 可执行目录 `appsettings.json` -> `res://appsettings.json` 顺序发现配置
+- 返回延迟解析 logger,避免 `static readonly` 字段过早锁死配置
+
+`GodotLog.ConfigurationPath` 可以用于诊断当前会命中的配置文件路径;读取它不会提前创建全局配置源,也不会让后续
+`GodotLog.Configure(...)` 失效。长生命周期服务器或测试宿主如果需要在退出时主动释放配置文件 watcher,可以调用
+`GodotLog.Shutdown()`;它会停止热重载监听,已创建 logger 仍然继续使用最后一次成功发布的配置快照。
+
+最小可复制的 `appsettings.json` 可以只包含 `Logging` 根节点。`LogLevel` 使用 `Default` 和类别名控制过滤阈值,
+`GodotLogger` 控制 Godot 输出模式、模板和颜色:
+
+```json
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Info",
+ "Game.Services": "Debug"
+ },
+ "GodotLogger": {
+ "Mode": "Debug",
+ "DebugMinLevel": "Debug",
+ "ReleaseMinLevel": "Info",
+ "DebugOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}{properties}",
+ "ReleaseOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}{properties}",
+ "Colors": {
+ "Info": "white",
+ "Warning": "orange",
+ "Error": "red"
+ }
+ }
+ }
+}
+```
+
+配置文件发现顺序固定为:
+
+1. `GODOT_LOGGER_CONFIG` 指向的文件
+2. 导出程序或测试进程所在目录的 `appsettings.json`
+3. Godot 项目资源根目录的 `res://appsettings.json`
+
+在编辑器项目里,`res://appsettings.json` 放在项目根目录;在导出包或专用服务器里,优先把
+`appsettings.json` 放到可执行文件同目录,便于运维脚本替换。运行中修改已发现的配置文件会热重载
+`Logging:LogLevel` 与 `Logging:GodotLogger` 下的模式、最小级别、模板和颜色;已创建 logger 不会重新实例化,
+但下一次级别判定和写入会读取最新成功发布的配置快照。热重载解析失败或文件被短暂锁定时会保留上一份可用配置。
+
+`GodotLog.Configure(...)` 适合在没有配置文件或需要代码覆盖默认值时使用,并且必须在首次创建 provider 或配置源前调用。
+`GodotLog.ConfigurationPath` 适合启动诊断和测试断言;`GodotLog.Shutdown()` 适合测试 teardown 或长生命周期服务器退出时释放
+文件 watcher,不会清空已经发布给 logger 的最后一份配置。
## 最小接入路径
@@ -121,7 +194,7 @@ public partial class SettingsPanel : Control
```
如果你已经在用 `GFramework.Core.SourceGenerators`,也可以继续让 `[Log]` 生成字段。Godot provider 只改变输出落点,
-不会改变 `[Log]` 的生成契约。
+不会改变 `[Log]` 的生成契约。需要静态字段延迟初始化时,也可以直接用 `GodotLog.CreateLogger()`。
### 3. Scene / UI 迁移日志会自动复用同一套 provider
@@ -152,7 +225,9 @@ RegisterHandler(new LoggingTransitionHandler());
| `Error` | `GD.PrintErr(...)` | 输出到错误流 |
| `Fatal` | `GD.PushError(...)` | 进入 Godot 错误通道 |
-异常追加格式也来自当前实现本身:
+结构化属性如果通过 `IStructuredLogger` 或 `LogContext` 传入,也会追加到模板里的 `{properties}` 占位符。
+
+异常追加格式仍然来自当前实现本身:
```text
[2026-04-22 10:30:47.012] ERROR [SaveSystem] 保存游戏失败
@@ -182,14 +257,16 @@ System.IO.IOException: ...
## 当前边界
-- 当前推荐接法是把 `GodotLoggerFactoryProvider` 放进 `ArchitectureConfiguration.LoggerProperties`;直接赋值
- `LoggerFactoryResolver.Provider` 仍然可用,但不该再写成默认采用路径
+- 当前推荐接法仍然是把 `GodotLoggerFactoryProvider` 放进 `ArchitectureConfiguration.LoggerProperties`;如果项目是纯
+ Godot 宿主,也可以在入口直接调用 `GodotLog.UseAsDefaultProvider()`
- `GFramework.Godot.Logging` 只解决 Godot 控制台输出,不提供文件落盘、JSON formatter、异步 appender 或按 namespace
的复杂过滤
- `GodotLogger` 只改变输出方式,不改变 `ILogger` 接口本身;业务代码不需要切换到 Godot 专用日志 API
- `[Log]`、`[ContextAware]` 这类字段注入能力不属于 `GFramework.Godot.Logging`
- Scene / UI 的 `LoggingTransitionHandler` 位于 `GFramework.Game`,Godot 侧只是通过 provider 让它们输出到 Godot 控制台
- 当前 `GodotLogger` 使用的是 UTC 时间戳;如果项目需要本地时区展示,需要自定义 provider / logger,而不是假定当前实现会自动转换
+- 当前配置热重载只覆盖 Godot logger 自身的模板、颜色、模式和级别;它没有把 `Microsoft.Extensions.Logging` 的整个
+ options / builder 模型搬进来
## 继续阅读
diff --git a/refactor-scripts/update-namespaces.py b/refactor-scripts/update-namespaces.py
index b9b7c205..c836d35d 100644
--- a/refactor-scripts/update-namespaces.py
+++ b/refactor-scripts/update-namespaces.py
@@ -1,154 +1,179 @@
-#!/usr/bin/env python3
-"""
-更新所有 C# 文件中的命名空间声明和 using 语句
-"""
-
-import os
-import re
-from pathlib import Path
-
-ROOT_DIR = "/mnt/f/gewuyou/System/Documents/WorkSpace/GameDev/GFramework"
-
-# 命名空间替换规则(按优先级排序,长的先匹配)
-NAMESPACE_RULES = [
- # CQRS 子命名空间
- (r'\.cqrs\.notification\b', '.CQRS.Notification'),
- (r'\.cqrs\.command\b', '.CQRS.Command'),
- (r'\.cqrs\.request\b', '.CQRS.Request'),
- (r'\.cqrs\.query\b', '.CQRS.Query'),
- (r'\.cqrs\.behaviors\b', '.CQRS.Behaviors'),
- (r'\.cqrs\b', '.CQRS'),
-
- # 嵌套命名空间
- (r'\.coroutine\.instructions\b', '.Coroutine.Instructions'),
- (r'\.coroutine\.extensions\b', '.Coroutine.Extensions'),
- (r'\.coroutine\b', '.Coroutine'),
-
- (r'\.events\.filters\b', '.Events.Filters'),
- (r'\.events\b', '.Events'),
-
- (r'\.logging\.appenders\b', '.Logging.Appenders'),
- (r'\.logging\.filters\b', '.Logging.Filters'),
- (r'\.logging\.formatters\b', '.Logging.Formatters'),
- (r'\.logging\b', '.Logging'),
-
- (r'\.functional\.async\b', '.Functional.Async'),
- (r'\.functional\.control\b', '.Functional.Control'),
- (r'\.functional\.functions\b', '.Functional.Functions'),
- (r'\.functional\.pipe\b', '.Functional.Pipe'),
- (r'\.functional\.result\b', '.Functional.Result'),
- (r'\.functional\b', '.Functional'),
-
- (r'\.services\.modules\b', '.Services.Modules'),
- (r'\.services\b', '.Services'),
-
- (r'\.extensions\.signal\b', '.Extensions.Signal'),
- (r'\.extensions\b', '.Extensions'),
-
- (r'\.setting\.data\b', '.Setting.Data'),
- (r'\.setting\.events\b', '.Setting.Events'),
- (r'\.setting\b', '.Setting'),
-
- (r'\.scene\.handler\b', '.Scene.Handler'),
- (r'\.scene\b', '.Scene'),
-
- (r'\.ui\.handler\b', '.UI.Handler'),
- (r'\.ui\b', '.UI'),
-
- (r'\.data\.events\b', '.Data.Events'),
- (r'\.data\b', '.Data'),
-
- # 单层命名空间
- (r'\.architecture\b', '.Architecture'),
- (r'\.bases\b', '.Bases'),
- (r'\.command\b', '.Command'),
- (r'\.configuration\b', '.Configuration'),
- (r'\.constants\b', '.Constants'),
- (r'\.enums\b', '.Enums'),
- (r'\.environment\b', '.Environment'),
- (r'\.internals\b', '.Internals'),
- (r'\.ioc\b', '.IoC'),
- (r'\.lifecycle\b', '.Lifecycle'),
- (r'\.model\b', '.Model'),
- (r'\.pause\b', '.Pause'),
- (r'\.pool\b', '.Pool'),
- (r'\.properties\b', '.Properties'),
- (r'\.property\b', '.Property'),
- (r'\.query\b', '.Query'),
- (r'\.registries\b', '.Registries'),
- (r'\.resource\b', '.Resource'),
- (r'\.rule\b', '.Rule'),
- (r'\.serializer\b', '.Serializer'),
- (r'\.state\b', '.State'),
- (r'\.storage\b', '.Storage'),
- (r'\.system\b', '.System'),
- (r'\.time\b', '.Time'),
- (r'\.utility\b', '.Utility'),
- (r'\.versioning\b', '.Versioning'),
- (r'\.asset\b', '.Asset'),
- (r'\.components\b', '.Components'),
- (r'\.systems\b', '.Systems'),
- (r'\.ecs\b', '.ECS'),
- (r'\.integration\b', '.Integration'),
- (r'\.mediator\b', '.Mediator'),
- (r'\.tests\b', '.Tests'),
- (r'\.analyzers\b', '.Analyzers'),
- (r'\.diagnostics\b', '.Diagnostics'),
- (r'\.generator\b', '.Generator'),
- (r'\.info\b', '.Info'),
-]
-
-def update_file(file_path):
- """更新单个文件中的命名空间"""
- try:
- with open(file_path, 'r', encoding='utf-8') as f:
- content = f.read()
-
- original_content = content
- replacements = 0
-
- for pattern, replacement in NAMESPACE_RULES:
- matches = re.findall(pattern, content, re.IGNORECASE)
- if matches:
- content = re.sub(pattern, replacement, content, flags=re.IGNORECASE)
- replacements += len(matches)
-
- if content != original_content:
- with open(file_path, 'w', encoding='utf-8') as f:
- f.write(content)
- return replacements
-
- return 0
- except Exception as e:
- print(f"错误处理文件 {file_path}: {e}")
- return 0
-
-def main():
- print("开始更新命名空间...")
-
- # 查找所有 C# 文件
- cs_files = []
- for root, dirs, files in os.walk(ROOT_DIR):
- # 跳过 bin, obj, Generated 目录
- dirs[:] = [d for d in dirs if d not in ['bin', 'obj', 'Generated', '.git', 'node_modules']]
-
- for file in files:
- if file.endswith('.cs'):
- cs_files.append(os.path.join(root, file))
-
- print(f"找到 {len(cs_files)} 个 C# 文件")
-
- updated_files = 0
- total_replacements = 0
-
- for file_path in cs_files:
- replacements = update_file(file_path)
- if replacements > 0:
- updated_files += 1
- total_replacements += replacements
- print(f"更新: {os.path.basename(file_path)} ({replacements} 处替换)")
-
- print(f"\n完成!更新了 {updated_files} 个文件,共 {total_replacements} 处替换")
-
-if __name__ == '__main__':
- main()
+#!/usr/bin/env python3
+"""
+更新所有 C# 文件中的命名空间声明和 using 语句
+"""
+
+import os
+import re
+import sys
+from argparse import ArgumentParser
+
+DEFAULT_ROOT_DIR = os.getcwd()
+
+# 命名空间替换规则(按优先级排序,长的先匹配)
+NAMESPACE_RULES = [
+ # CQRS 子命名空间
+ (r'\.cqrs\.notification\b', '.CQRS.Notification'),
+ (r'\.cqrs\.command\b', '.CQRS.Command'),
+ (r'\.cqrs\.request\b', '.CQRS.Request'),
+ (r'\.cqrs\.query\b', '.CQRS.Query'),
+ (r'\.cqrs\.behaviors\b', '.CQRS.Behaviors'),
+ (r'\.cqrs\b', '.CQRS'),
+
+ # 嵌套命名空间
+ (r'\.coroutine\.instructions\b', '.Coroutine.Instructions'),
+ (r'\.coroutine\.extensions\b', '.Coroutine.Extensions'),
+ (r'\.coroutine\b', '.Coroutine'),
+
+ (r'\.events\.filters\b', '.Events.Filters'),
+ (r'\.events\b', '.Events'),
+
+ (r'\.logging\.appenders\b', '.Logging.Appenders'),
+ (r'\.logging\.filters\b', '.Logging.Filters'),
+ (r'\.logging\.formatters\b', '.Logging.Formatters'),
+ (r'\.logging\b', '.Logging'),
+
+ (r'\.functional\.async\b', '.Functional.Async'),
+ (r'\.functional\.control\b', '.Functional.Control'),
+ (r'\.functional\.functions\b', '.Functional.Functions'),
+ (r'\.functional\.pipe\b', '.Functional.Pipe'),
+ (r'\.functional\.result\b', '.Functional.Result'),
+ (r'\.functional\b', '.Functional'),
+
+ (r'\.services\.modules\b', '.Services.Modules'),
+ (r'\.services\b', '.Services'),
+
+ (r'\.extensions\.signal\b', '.Extensions.Signal'),
+ (r'\.extensions\b', '.Extensions'),
+
+ (r'\.setting\.data\b', '.Setting.Data'),
+ (r'\.setting\.events\b', '.Setting.Events'),
+ (r'\.setting\b', '.Setting'),
+
+ (r'\.scene\.handler\b', '.Scene.Handler'),
+ (r'\.scene\b', '.Scene'),
+
+ (r'\.ui\.handler\b', '.UI.Handler'),
+ (r'\.ui\b', '.UI'),
+
+ (r'\.data\.events\b', '.Data.Events'),
+ (r'\.data\b', '.Data'),
+
+ # 单层命名空间
+ (r'\.architecture\b', '.Architecture'),
+ (r'\.bases\b', '.Bases'),
+ (r'\.command\b', '.Command'),
+ (r'\.configuration\b', '.Configuration'),
+ (r'\.constants\b', '.Constants'),
+ (r'\.enums\b', '.Enums'),
+ (r'\.environment\b', '.Environment'),
+ (r'\.internals\b', '.Internals'),
+ (r'\.ioc\b', '.IoC'),
+ (r'\.lifecycle\b', '.Lifecycle'),
+ (r'\.model\b', '.Model'),
+ (r'\.pause\b', '.Pause'),
+ (r'\.pool\b', '.Pool'),
+ (r'\.properties\b', '.Properties'),
+ (r'\.property\b', '.Property'),
+ (r'\.query\b', '.Query'),
+ (r'\.registries\b', '.Registries'),
+ (r'\.resource\b', '.Resource'),
+ (r'\.rule\b', '.Rule'),
+ (r'\.serializer\b', '.Serializer'),
+ (r'\.state\b', '.State'),
+ (r'\.storage\b', '.Storage'),
+ (r'\.system\b', '.System'),
+ (r'\.time\b', '.Time'),
+ (r'\.utility\b', '.Utility'),
+ (r'\.versioning\b', '.Versioning'),
+ (r'\.asset\b', '.Asset'),
+ (r'\.components\b', '.Components'),
+ (r'\.systems\b', '.Systems'),
+ (r'\.ecs\b', '.ECS'),
+ (r'\.integration\b', '.Integration'),
+ (r'\.mediator\b', '.Mediator'),
+ (r'\.tests\b', '.Tests'),
+ (r'\.analyzers\b', '.Analyzers'),
+ (r'\.diagnostics\b', '.Diagnostics'),
+ (r'\.generator\b', '.Generator'),
+ (r'\.info\b', '.Info'),
+]
+
+def update_file(file_path):
+ """更新单个文件中的命名空间"""
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ original_content = content
+ replacements = 0
+
+ for pattern, replacement in NAMESPACE_RULES:
+ matches = re.findall(pattern, content, re.IGNORECASE)
+ if matches:
+ content = re.sub(pattern, replacement, content, flags=re.IGNORECASE)
+ replacements += len(matches)
+
+ if content != original_content:
+ with open(file_path, 'w', encoding='utf-8') as f:
+ f.write(content)
+ return replacements
+
+ return 0
+ except Exception as e:
+ raise RuntimeError(f"错误处理文件 {file_path}: {e}") from e
+
+def main():
+ parser = ArgumentParser(description="更新 C# 文件中的命名空间声明和 using 语句")
+ parser.add_argument(
+ "--root-dir",
+ default=os.getenv("ROOT_DIR", DEFAULT_ROOT_DIR),
+ help="要扫描的仓库根目录,默认使用 ROOT_DIR 环境变量或当前工作目录")
+ args = parser.parse_args()
+ root_dir = os.path.abspath(args.root_dir)
+
+ if not os.path.isdir(root_dir):
+ print(f"根目录不存在或不是目录: {root_dir}", file=sys.stderr)
+ return 2
+
+ print("开始更新命名空间...")
+
+ # 查找所有 C# 文件
+ cs_files = []
+ for root, dirs, files in os.walk(root_dir):
+ # 跳过 bin, obj, Generated 目录
+ dirs[:] = [d for d in dirs if d not in ['bin', 'obj', 'Generated', '.git', 'node_modules']]
+
+ for file in files:
+ if file.endswith('.cs'):
+ cs_files.append(os.path.join(root, file))
+
+ print(f"找到 {len(cs_files)} 个 C# 文件")
+
+ updated_files = 0
+ total_replacements = 0
+ failed_files = []
+
+ for file_path in cs_files:
+ try:
+ replacements = update_file(file_path)
+ except RuntimeError as e:
+ failed_files.append((file_path, str(e)))
+ continue
+
+ if replacements > 0:
+ updated_files += 1
+ total_replacements += replacements
+ print(f"更新: {os.path.basename(file_path)} ({replacements} 处替换)")
+
+ print(f"\n完成!更新了 {updated_files} 个文件,共 {total_replacements} 处替换")
+ if failed_files:
+ print(f"失败文件数: {len(failed_files)}", file=sys.stderr)
+ for file_path, error in failed_files:
+ print(f"- {file_path}: {error}", file=sys.stderr)
+ return 1
+
+ return 0
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/third-party-licenses/GodotLogger/LICENSE b/third-party-licenses/GodotLogger/LICENSE
new file mode 100644
index 00000000..2d55aadd
--- /dev/null
+++ b/third-party-licenses/GodotLogger/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 gamedo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.