Merge pull request #314 from GeWuYou/feat/godot-logging-compliance-polish

Feat/godot logging compliance polish
This commit is contained in:
gewuyou 2026-05-03 10:07:03 +08:00 committed by GitHub
commit 918a61f3b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 3116 additions and 279 deletions

View File

@ -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 \

View File

@ -6,16 +6,42 @@ namespace GFramework.Core.Logging;
/// 日志抽象基类,封装日志级别判断、格式化与异常处理逻辑。
/// 平台日志器只需实现 Write 方法即可。
/// </summary>
public abstract class AbstractLogger(
string? name = null,
LogLevel minLevel = LogLevel.Info) : IStructuredLogger
public abstract class AbstractLogger : IStructuredLogger
{
/// <summary>
/// 根日志记录器的名称常量
/// </summary>
public const string RootLoggerName = "ROOT";
private readonly string _name = name ?? RootLoggerName;
private readonly Func<LogLevel> _minLevelProvider;
private readonly string _name;
/// <summary>
/// 使用固定最小日志级别初始化日志器。
/// </summary>
/// <param name="name">日志器名称。</param>
/// <param name="minLevel">最小日志级别。</param>
protected AbstractLogger(
string? name = null,
LogLevel minLevel = LogLevel.Info)
: this(name, () => minLevel)
{
}
/// <summary>
/// 使用动态最小日志级别提供器初始化日志器。
/// </summary>
/// <param name="name">日志器名称。</param>
/// <param name="minLevelProvider">最小日志级别提供器。</param>
protected AbstractLogger(
string? name,
Func<LogLevel> minLevelProvider)
{
ArgumentNullException.ThrowIfNull(minLevelProvider);
_name = name ?? RootLoggerName;
_minLevelProvider = minLevelProvider;
}
#region Metadata
@ -47,7 +73,7 @@ public abstract class AbstractLogger(
/// <returns>如果指定级别大于等于最小级别则返回true否则返回false</returns>
protected bool IsEnabled(LogLevel level)
{
return level >= minLevel;
return level >= _minLevelProvider();
}
/// <summary>

View File

@ -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"));
}
}

View File

@ -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;
/// <summary>
/// Verifies Godot logging configuration discovery, parsing, normalization, and live settings propagation.
/// </summary>
[TestFixture]
public sealed class GodotLoggerSettingsLoaderTests
{
/// <summary>
/// Verifies that configuration discovery honors the environment path, executable directory, and project path order.
/// </summary>
[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);
}
}
/// <summary>
/// Verifies that JSON settings bind Godot logger options and category log-level overrides.
/// </summary>
[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));
});
}
/// <summary>
/// Verifies that nullable JSON option fields are normalized before the runtime receives the settings snapshot.
/// </summary>
[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"));
});
}
/// <summary>
/// Verifies that numeric JSON log levels must map to defined <see cref="LogLevel"/> values.
/// </summary>
[Test]
public void LoadFromJsonString_Should_Reject_Invalid_Numeric_LogLevel()
{
const string json = """
{
"Logging": {
"LogLevel": {
"Default": 999
}
}
}
""";
var error = Assert.Throws<JsonException>(() => GodotLoggerSettingsLoader.LoadFromJsonString(json));
Assert.That(error?.Message, Does.Contain("Unsupported numeric LogLevel value '999'"));
}
/// <summary>
/// Verifies that cached provider loggers read the latest settings after the provider snapshot changes.
/// </summary>
[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<string, LogLevel>(StringComparer.Ordinal)
{
["Game.Services"] = LogLevel.Debug
});
Assert.Multiple(() =>
{
Assert.That(logger.IsInfoEnabled(), Is.True);
Assert.That(logger.IsDebugEnabled(), Is.False);
});
}
/// <summary>
/// Verifies that caller-supplied structured property keys cannot break Godot log rendering.
/// </summary>
[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"));
}
}

View File

@ -0,0 +1,597 @@
using System;
using System.Linq;
using System.Threading;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Defers resolving the real Godot logger until the first logging operation needs it.
/// </summary>
/// <remarks>
/// This wrapper allows static logger fields to be created before <see cref="GodotLog.Configure"/> or
/// <see cref="GodotLog.UseAsDefaultProvider"/> 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.
/// </remarks>
/// <param name="category">The category passed to the provider when the real logger is first needed.</param>
/// <param name="providerAccessor">The accessor that returns the current provider at first use.</param>
internal sealed class DeferredLogger(string category, Func<ILoggerFactoryProvider> providerAccessor) : IStructuredLogger
{
private ILogger? _inner;
/// <summary>
/// Gets the resolved inner logger, creating and atomically publishing it on first use.
/// </summary>
/// <remarks>
/// The property is intentionally the single resolution gate so all delegated members share the same thread-safe
/// lazy initialization behavior.
/// </remarks>
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;
}
}
/// <summary>
/// Gets the category name reported by the resolved logger.
/// </summary>
/// <returns>The logger category name.</returns>
public string Name()
{
return Inner.Name();
}
/// <summary>
/// Returns whether trace messages are enabled by the current provider settings.
/// </summary>
/// <returns>true when trace messages should be emitted; otherwise false.</returns>
public bool IsTraceEnabled()
{
return Inner.IsTraceEnabled();
}
/// <summary>
/// Returns whether debug messages are enabled by the current provider settings.
/// </summary>
/// <returns>true when debug messages should be emitted; otherwise false.</returns>
public bool IsDebugEnabled()
{
return Inner.IsDebugEnabled();
}
/// <summary>
/// Returns whether informational messages are enabled by the current provider settings.
/// </summary>
/// <returns>true when informational messages should be emitted; otherwise false.</returns>
public bool IsInfoEnabled()
{
return Inner.IsInfoEnabled();
}
/// <summary>
/// Returns whether warning messages are enabled by the current provider settings.
/// </summary>
/// <returns>true when warning messages should be emitted; otherwise false.</returns>
public bool IsWarnEnabled()
{
return Inner.IsWarnEnabled();
}
/// <summary>
/// Returns whether error messages are enabled by the current provider settings.
/// </summary>
/// <returns>true when error messages should be emitted; otherwise false.</returns>
public bool IsErrorEnabled()
{
return Inner.IsErrorEnabled();
}
/// <summary>
/// Returns whether fatal messages are enabled by the current provider settings.
/// </summary>
/// <returns>true when fatal messages should be emitted; otherwise false.</returns>
public bool IsFatalEnabled()
{
return Inner.IsFatalEnabled();
}
/// <summary>
/// Returns whether the specified log level is enabled by the current provider settings.
/// </summary>
/// <param name="level">The level to check.</param>
/// <returns>true when the level should be emitted; otherwise false.</returns>
public bool IsEnabledForLevel(LogLevel level)
{
return Inner.IsEnabledForLevel(level);
}
/// <summary>
/// Writes a trace message through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
public void Trace(string msg)
{
Inner.Trace(msg);
}
/// <summary>
/// Writes a formatted trace message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg">The first format argument.</param>
public void Trace(string format, object arg)
{
Inner.Trace(format, arg);
}
/// <summary>
/// Writes a formatted trace message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg1">The first format argument.</param>
/// <param name="arg2">The second format argument.</param>
public void Trace(string format, object arg1, object arg2)
{
Inner.Trace(format, arg1, arg2);
}
/// <summary>
/// Writes a formatted trace message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arguments">The format arguments.</param>
public void Trace(string format, params object[] arguments)
{
Inner.Trace(format, arguments);
}
/// <summary>
/// Writes a trace message and exception through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
/// <param name="t">The exception to attach.</param>
public void Trace(string msg, Exception t)
{
Inner.Trace(msg, t);
}
/// <summary>
/// Writes a debug message through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
public void Debug(string msg)
{
Inner.Debug(msg);
}
/// <summary>
/// Writes a formatted debug message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg">The first format argument.</param>
public void Debug(string format, object arg)
{
Inner.Debug(format, arg);
}
/// <summary>
/// Writes a formatted debug message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg1">The first format argument.</param>
/// <param name="arg2">The second format argument.</param>
public void Debug(string format, object arg1, object arg2)
{
Inner.Debug(format, arg1, arg2);
}
/// <summary>
/// Writes a formatted debug message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arguments">The format arguments.</param>
public void Debug(string format, params object[] arguments)
{
Inner.Debug(format, arguments);
}
/// <summary>
/// Writes a debug message and exception through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
/// <param name="t">The exception to attach.</param>
public void Debug(string msg, Exception t)
{
Inner.Debug(msg, t);
}
/// <summary>
/// Writes an informational message through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
public void Info(string msg)
{
Inner.Info(msg);
}
/// <summary>
/// Writes a formatted informational message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg">The first format argument.</param>
public void Info(string format, object arg)
{
Inner.Info(format, arg);
}
/// <summary>
/// Writes a formatted informational message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg1">The first format argument.</param>
/// <param name="arg2">The second format argument.</param>
public void Info(string format, object arg1, object arg2)
{
Inner.Info(format, arg1, arg2);
}
/// <summary>
/// Writes a formatted informational message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arguments">The format arguments.</param>
public void Info(string format, params object[] arguments)
{
Inner.Info(format, arguments);
}
/// <summary>
/// Writes an informational message and exception through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
/// <param name="t">The exception to attach.</param>
public void Info(string msg, Exception t)
{
Inner.Info(msg, t);
}
/// <summary>
/// Writes a warning message through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
public void Warn(string msg)
{
Inner.Warn(msg);
}
/// <summary>
/// Writes a formatted warning message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg">The first format argument.</param>
public void Warn(string format, object arg)
{
Inner.Warn(format, arg);
}
/// <summary>
/// Writes a formatted warning message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg1">The first format argument.</param>
/// <param name="arg2">The second format argument.</param>
public void Warn(string format, object arg1, object arg2)
{
Inner.Warn(format, arg1, arg2);
}
/// <summary>
/// Writes a formatted warning message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arguments">The format arguments.</param>
public void Warn(string format, params object[] arguments)
{
Inner.Warn(format, arguments);
}
/// <summary>
/// Writes a warning message and exception through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
/// <param name="t">The exception to attach.</param>
public void Warn(string msg, Exception t)
{
Inner.Warn(msg, t);
}
/// <summary>
/// Writes an error message through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
public void Error(string msg)
{
Inner.Error(msg);
}
/// <summary>
/// Writes a formatted error message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg">The first format argument.</param>
public void Error(string format, object arg)
{
Inner.Error(format, arg);
}
/// <summary>
/// Writes a formatted error message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg1">The first format argument.</param>
/// <param name="arg2">The second format argument.</param>
public void Error(string format, object arg1, object arg2)
{
Inner.Error(format, arg1, arg2);
}
/// <summary>
/// Writes a formatted error message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arguments">The format arguments.</param>
public void Error(string format, params object[] arguments)
{
Inner.Error(format, arguments);
}
/// <summary>
/// Writes an error message and exception through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
/// <param name="t">The exception to attach.</param>
public void Error(string msg, Exception t)
{
Inner.Error(msg, t);
}
/// <summary>
/// Writes a fatal message through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
public void Fatal(string msg)
{
Inner.Fatal(msg);
}
/// <summary>
/// Writes a formatted fatal message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg">The first format argument.</param>
public void Fatal(string format, object arg)
{
Inner.Fatal(format, arg);
}
/// <summary>
/// Writes a formatted fatal message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg1">The first format argument.</param>
/// <param name="arg2">The second format argument.</param>
public void Fatal(string format, object arg1, object arg2)
{
Inner.Fatal(format, arg1, arg2);
}
/// <summary>
/// Writes a formatted fatal message through the resolved logger.
/// </summary>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arguments">The format arguments.</param>
public void Fatal(string format, params object[] arguments)
{
Inner.Fatal(format, arguments);
}
/// <summary>
/// Writes a fatal message and exception through the resolved logger.
/// </summary>
/// <param name="msg">The message to write.</param>
/// <param name="t">The exception to attach.</param>
public void Fatal(string msg, Exception t)
{
Inner.Fatal(msg, t);
}
/// <summary>
/// Writes a message at the specified level through the resolved logger.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="message">The message to write.</param>
public void Log(LogLevel level, string message)
{
LogFallback(level, message, exception: null);
}
/// <summary>
/// Writes a formatted message at the specified level while preserving deferred formatting semantics.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg">The first format argument.</param>
public void Log(LogLevel level, string format, object arg)
{
Inner.Log(level, format, arg);
}
/// <summary>
/// Writes a formatted message at the specified level while preserving deferred formatting semantics.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arg1">The first format argument.</param>
/// <param name="arg2">The second format argument.</param>
public void Log(LogLevel level, string format, object arg1, object arg2)
{
Inner.Log(level, format, arg1, arg2);
}
/// <summary>
/// Writes a formatted message at the specified level while preserving deferred formatting semantics.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="format">The format string interpreted by the resolved logger.</param>
/// <param name="arguments">The format arguments.</param>
public void Log(LogLevel level, string format, params object[] arguments)
{
Inner.Log(level, format, arguments);
}
/// <summary>
/// Writes a message and exception at the specified level through the resolved logger.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="message">The message to write.</param>
/// <param name="exception">The exception to attach.</param>
public void Log(LogLevel level, string message, Exception exception)
{
LogFallback(level, message, exception);
}
/// <summary>
/// Writes a structured message through the resolved logger when it supports structured properties.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="message">The message to write.</param>
/// <param name="properties">The structured properties to attach.</param>
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);
}
/// <summary>
/// Writes a structured message and exception through the resolved logger when it supports structured properties.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="message">The message to write.</param>
/// <param name="exception">The optional exception to attach.</param>
/// <param name="properties">The structured properties to attach.</param>
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);
}
/// <summary>
/// Resolves the real logger from the current provider for the deferred category.
/// </summary>
/// <returns>The logger created by the current provider.</returns>
private ILogger ResolveLogger()
{
return providerAccessor().CreateLogger(category);
}
/// <summary>
/// Routes a message through the non-structured logger surface when the resolved logger lacks structured support.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="message">The message to write.</param>
/// <param name="exception">The optional exception to attach.</param>
/// <param name="properties">The structured properties rendered into a suffix for fallback loggers.</param>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="level"/> is not supported.</exception>
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.");
}
}
/// <summary>
/// Routes a non-structured message through the fallback path.
/// </summary>
/// <param name="level">The level to write.</param>
/// <param name="message">The message to write.</param>
/// <param name="exception">The optional exception to attach.</param>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="level"/> is not supported.</exception>
private void LogFallback(LogLevel level, string message, Exception? exception)
{
LogFallback(level, message, exception, []);
}
/// <summary>
/// Chooses the message-only or exception-aware write delegate for fallback logging.
/// </summary>
/// <param name="message">The rendered fallback message.</param>
/// <param name="exception">The optional exception to attach.</param>
/// <param name="writeMessage">The delegate used when no exception is present.</param>
/// <param name="writeException">The delegate used when an exception is present.</param>
private static void WriteFallback(
string message,
Exception? exception,
Action<string> writeMessage,
Action<string, Exception> writeException)
{
if (exception == null)
{
writeMessage(message);
}
else
{
writeException(message, exception);
}
}
}

View File

@ -0,0 +1,127 @@
using System;
using System.Threading;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Static Godot logging entry point with auto-discovered configuration and deferred logger creation.
/// </summary>
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<GodotLoggerOptions>? _configure;
private static readonly Lazy<GodotLogConfigurationSource> LazyConfigurationSource = new(
CreateConfigurationSource,
LazyThreadSafetyMode.ExecutionAndPublication);
private static readonly Lazy<GodotLoggerFactoryProvider> LazyProvider = new(
static () => new GodotLoggerFactoryProvider(() => LazyConfigurationSource.Value.CurrentSettings),
LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>
/// Applies imperative option overrides before the global Godot logger provider is materialized.
/// </summary>
/// <param name="configure">The options mutator.</param>
public static void Configure(Action<GodotLoggerOptions> 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;
}
}
/// <summary>
/// Gets the lazily-configured Godot logger provider.
/// </summary>
public static ILoggerFactoryProvider Provider
{
get
{
lock (ConfigureLock)
{
return LazyProvider.Value;
}
}
}
/// <summary>
/// Gets the discovered configuration file path, if any, without materializing the global configuration source.
/// </summary>
/// <remarks>
/// This property is safe for diagnostics before <see cref="Configure"/> runs. When the source is not created
/// yet, it performs discovery directly instead of touching <c>LazyConfigurationSource.Value</c>, so callers do
/// not accidentally lock in the default options before configuring <see cref="Provider"/>.
/// </remarks>
public static string? ConfigurationPath => LazyConfigurationSource.IsValueCreated
? LazyConfigurationSource.Value.ConfigurationPath
: GodotLoggerSettingsLoader.DiscoverConfigurationPath();
/// <summary>
/// Creates a logger for the specified category without materializing the provider until first use.
/// </summary>
public static ILogger CreateLogger(string category)
{
ArgumentException.ThrowIfNullOrWhiteSpace(category);
return new DeferredLogger(category, static () => Provider);
}
/// <summary>
/// Creates a logger for the specified type without materializing the provider until first use.
/// </summary>
public static ILogger CreateLogger<T>()
{
return CreateLogger(GetCategoryName(typeof(T)));
}
/// <summary>
/// Installs the Godot provider as the current global resolver provider.
/// </summary>
public static void UseAsDefaultProvider()
{
LoggerFactoryResolver.Provider = Provider;
}
/// <summary>
/// Stops the file watcher owned by the materialized configuration source, if the source has been created.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}
}

View File

@ -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;
/// <summary>
/// Owns discovery, loading, hot reload, and publication of the current Godot logger settings snapshot.
/// </summary>
/// <remarks>
/// Construction follows a fixed lifecycle: discover the configuration path, perform an initial strict load, then
/// subscribe a <see cref="FileSystemWatcher"/> when a concrete file exists. <see cref="CurrentSettings"/> is
/// published through <see cref="Volatile"/> 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.
/// </remarks>
internal sealed class GodotLogConfigurationSource : IDisposable
{
private readonly Action<GodotLoggerOptions>? _configure;
private readonly FileSystemWatcher? _watcher;
private GodotLoggerSettings _currentSettings = GodotLoggerSettings.Default;
/// <summary>
/// Initializes the configuration source and starts watching the discovered file when one is available.
/// </summary>
/// <param name="configure">Optional imperative option overrides applied after file settings are loaded.</param>
/// <exception cref="IOException">Thrown during initial loading when the configuration file cannot be read.</exception>
/// <exception cref="UnauthorizedAccessException">Thrown during initial loading when the configuration file is locked.</exception>
/// <remarks>
/// 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.
/// </remarks>
public GodotLogConfigurationSource(Action<GodotLoggerOptions>? 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);
}
/// <summary>
/// Gets the discovered configuration file path, or null when no supported location contains a file.
/// </summary>
public string? ConfigurationPath { get; }
/// <summary>
/// Gets the last successfully loaded settings snapshot.
/// </summary>
/// <remarks>
/// The snapshot is read through <c>Volatile.Read</c> so logger instances running on other
/// threads observe settings published by reload callbacks without taking the configuration lock.
/// </remarks>
public GodotLoggerSettings CurrentSettings => Volatile.Read(ref _currentSettings);
/// <summary>
/// Stops the file watcher before the source is abandoned.
/// </summary>
/// <remarks>
/// Disposal does not clear <see cref="CurrentSettings"/>; existing loggers can continue using the last published
/// snapshot after watcher notifications have been stopped.
/// </remarks>
public void Dispose()
{
_watcher?.Dispose();
}
/// <summary>
/// Creates the watcher that drives hot reload for the discovered configuration file.
/// </summary>
/// <param name="configurationPath">The configuration file to watch.</param>
/// <returns>A configured watcher, or null when no stable directory and file name can be resolved.</returns>
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);
}
/// <summary>
/// Reloads settings and publishes them when loading succeeds.
/// </summary>
/// <param name="throwOnError">Whether load errors should escape to the caller.</param>
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.
}
}
/// <summary>
/// Loads settings with short retry/backoff for startup races with file writers or deployment tools.
/// </summary>
/// <returns>The loaded settings snapshot.</returns>
/// <exception cref="InvalidOperationException">Thrown when no retry produced a usable settings snapshot.</exception>
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.");
}
/// <summary>
/// Loads settings from disk or defaults, then applies imperative overrides.
/// </summary>
/// <returns>The settings snapshot to publish.</returns>
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));
}
/// <summary>
/// Creates a mutable options copy before user overrides are applied.
/// </summary>
/// <param name="options">The options from the file or default settings.</param>
/// <returns>A normalized mutable copy.</returns>
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<GFramework.Core.Abstractions.Logging.LogLevel, string>(colors)
: []
}.CreateNormalizedCopy();
}
/// <summary>
/// Copies category log level overrides into an ordinal dictionary.
/// </summary>
/// <param name="settings">The source settings snapshot.</param>
/// <returns>A copy that preserves exact and prefix matching semantics.</returns>
private static IReadOnlyDictionary<string, LogLevel> CopyLoggerLevels(
GodotLoggerSettings settings)
{
var levels = new Dictionary<string, LogLevel>(StringComparer.Ordinal);
foreach (var pair in settings.LoggerLevels)
{
levels[pair.Key] = pair.Value;
}
return levels;
}
}

View File

@ -0,0 +1,21 @@
using System;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Carries the already-resolved values that <see cref="GodotLogTemplate"/> needs to render one log line.
/// </summary>
/// <param name="Timestamp">The UTC timestamp assigned to the log entry.</param>
/// <param name="Level">The severity level used for filtering, formatting, and Godot debug routing.</param>
/// <param name="Category">The source logger category name.</param>
/// <param name="Message">The formatted log message body.</param>
/// <param name="Color">The Godot BBCode color name resolved for <paramref name="Level"/>.</param>
/// <param name="Properties">The preformatted structured property suffix, or an empty string when none exist.</param>
internal readonly record struct GodotLogRenderContext(
DateTime Timestamp,
LogLevel Level,
string Category,
string Message,
string Color,
string Properties);

View File

@ -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;
/// <summary>
/// Parses and renders Godot logger output templates.
/// </summary>
/// <remarks>
/// Supported placeholders include <c>{timestamp}</c>, <c>{timestamp:format}</c>, <c>{level}</c>,
/// <c>{level:u3}</c>, <c>{level:l3}</c>, <c>{level:padded}</c>, <c>{category}</c>,
/// <c>{category:lN}</c>, <c>{category:rN}</c>, <c>{color}</c>, <c>{message}</c>, and
/// <c>{properties}</c>. Unknown placeholders are rendered back as <c>{key}</c> 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.
/// </remarks>
internal sealed class GodotLogTemplate
{
/// <summary>
/// Caches parsed template instances by the raw template text.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static readonly BoundedCache<GodotLogTemplate> Cache = new(maxEntries: 256);
/// <summary>
/// Caches formatted category names for this template instance.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private readonly BoundedCache<string> _categoryCache = new(maxEntries: 1024);
private readonly int _literalLength;
private readonly Action<StringBuilder, GodotLogRenderContext>[] _segments;
private GodotLogTemplate(string template)
{
(_segments, _literalLength) = ParseCore(template);
}
/// <summary>
/// Parses or retrieves a cached template.
/// </summary>
/// <param name="template">The template text.</param>
/// <returns>An immutable parsed template.</returns>
public static GodotLogTemplate Parse(string template)
{
ArgumentNullException.ThrowIfNull(template);
return Cache.GetOrAdd(template, () => new GodotLogTemplate(template));
}
/// <summary>
/// Renders the template against a concrete log context.
/// </summary>
/// <param name="context">The resolved values for the log entry.</param>
/// <returns>The rendered Godot log line.</returns>
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();
}
/// <summary>
/// Converts template text into literal and placeholder render segments.
/// </summary>
/// <param name="template">The template text to parse.</param>
/// <returns>The render segments and total literal length used to size the output builder.</returns>
private (Action<StringBuilder, GodotLogRenderContext>[] Segments, int LiteralLength) ParseCore(string template)
{
var segments = new List<Action<StringBuilder, GodotLogRenderContext>>();
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));
}
}
/// <summary>
/// Creates the render delegate for one placeholder key.
/// </summary>
/// <param name="key">The placeholder name and optional format suffix.</param>
/// <returns>A delegate that appends the placeholder value.</returns>
private Action<StringBuilder, GodotLogRenderContext> 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('}')
};
}
/// <summary>
/// Creates the render delegate for a timestamp placeholder.
/// </summary>
/// <param name="format">The optional .NET timestamp format.</param>
/// <returns>A delegate that appends the formatted timestamp using invariant culture.</returns>
private Action<StringBuilder, GodotLogRenderContext> 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));
}
/// <summary>
/// Creates the render delegate for a level placeholder.
/// </summary>
/// <param name="format">The level format, such as <c>u3</c>, <c>l3</c>, or <c>padded</c>.</param>
/// <returns>A delegate that appends the formatted level.</returns>
private static Action<StringBuilder, GodotLogRenderContext> 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)
};
}
/// <summary>
/// Creates the render delegate for a category placeholder.
/// </summary>
/// <param name="format">The category alignment format, such as <c>l16</c> or <c>r32</c>.</param>
/// <returns>A delegate that appends the category with optional abbreviation and padding.</returns>
private Action<StringBuilder, GodotLogRenderContext> 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));
}
/// <summary>
/// Formats and caches one category for a category alignment segment.
/// </summary>
/// <param name="category">The full category name.</param>
/// <param name="format">The original segment format used as part of the cache key.</param>
/// <param name="width">The desired category width.</param>
/// <param name="padLeft">Whether the result is left-padded instead of right-padded.</param>
/// <returns>The abbreviated and padded category string.</returns>
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);
});
}
/// <summary>
/// Abbreviates dotted category names to fit a target width.
/// </summary>
/// <param name="category">The category to abbreviate.</param>
/// <param name="maxLength">The maximum rendered length.</param>
/// <returns>The category shortened by initials, dropped prefixes, or final-segment truncation.</returns>
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;
}
/// <summary>
/// Converts a level to its three-character form.
/// </summary>
/// <param name="level">The level to format.</param>
/// <param name="upper">Whether the result should use uppercase letters.</param>
/// <returns>A three-character level label, or <c>unk</c> for undefined enum values.</returns>
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;
}
/// <summary>
/// Converts a level to the fixed-width historical Godot logger label.
/// </summary>
/// <param name="level">The level to format.</param>
/// <returns>A padded level label, or <see cref="object.ToString"/> output for undefined enum values.</returns>
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<TValue>
{
private readonly ConcurrentDictionary<string, CacheEntry<TValue>> _entries = new(StringComparer.Ordinal);
private readonly int _maxEntries;
private long _sequence;
internal BoundedCache(int maxEntries)
{
_maxEntries = maxEntries;
}
internal TValue GetOrAdd(string key, Func<TValue> valueFactory)
{
if (_entries.TryGetValue(key, out var existing))
{
return existing.Value;
}
var created = new CacheEntry<TValue>(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>(TValue Value, long Sequence);
}

View File

@ -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;
/// <summary>
/// Godot平台的日志记录器实现。
/// 该类继承自 <see cref="AbstractLogger"/>,用于在 Godot 引擎中输出日志信息。
/// 支持不同日志级别的输出,并根据级别调用 Godot 的相应方法。
/// Godot platform logger implementation.
/// </summary>
/// <param name="name">日志记录器的名称,默认为根日志记录器名称。</param>
/// <param name="minLevel">最低日志级别,默认为 <see cref="LogLevel.Info"/>。</param>
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<GodotLoggerOptions> _optionsProvider;
/// <summary>
/// 写入日志的核心方法。
/// 格式化日志消息并根据日志级别调用 Godot 的输出方法。
/// Initializes a logger that preserves the historical fixed-format template.
/// </summary>
/// <param name="level">日志级别。</param>
/// <param name="message">日志消息内容。</param>
/// <param name="exception">可选的异常信息。</param>
/// <param name="name">The logger name.</param>
/// <param name="minLevel">The minimum enabled log level.</param>
public GodotLogger(string? name = null, LogLevel minLevel = LogLevel.Info)
: this(
name ?? RootLoggerName,
CreateFixedOptionsProvider(minLevel),
() => minLevel)
{
}
/// <summary>
/// Initializes a logger with Godot-specific formatting options.
/// </summary>
/// <param name="name">The logger name.</param>
/// <param name="options">The logger options.</param>
public GodotLogger(string? name, GodotLoggerOptions options)
: this(
name ?? RootLoggerName,
CreateOptionsProvider(options),
CreateMinLevelProvider(options))
{
}
/// <summary>
/// Initializes the core logger with dynamic options and level providers.
/// </summary>
/// <param name="name">The resolved logger name used in rendered output.</param>
/// <param name="optionsProvider">The provider that supplies the latest rendering options for each write.</param>
/// <param name="minLevelProvider">The provider that supplies the latest effective minimum level.</param>
/// <remarks>
/// 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.
/// </remarks>
internal GodotLogger(
string name,
Func<GodotLoggerOptions> optionsProvider,
Func<LogLevel> minLevelProvider)
: base(name, minLevelProvider ?? throw new ArgumentNullException(nameof(minLevelProvider)))
{
_optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider));
}
/// <summary>
/// Writes a log entry to Godot.
/// </summary>
/// <param name="level">The log level.</param>
/// <param name="message">The rendered message body.</param>
/// <param name="exception">The optional exception.</param>
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;
/// <summary>
/// Uses Godot-aware structured rendering instead of the base string concatenation fallback.
/// </summary>
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);
}
/// <summary>
/// Uses Godot-aware structured rendering instead of the base string concatenation fallback.
/// </summary>
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<string, object?> 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<string, object?>(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<string, object?> EmptyProperties =
new Dictionary<string, object?>(StringComparer.Ordinal);
private static Func<GodotLoggerOptions> CreateFixedOptionsProvider(LogLevel minLevel)
{
var options = GodotLoggerOptions.ForMinimumLevel(minLevel);
return () => options;
}
private static Func<GodotLoggerOptions> CreateOptionsProvider(GodotLoggerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return () => options;
}
private static Func<LogLevel> 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;
}
}

View File

@ -1,20 +1,46 @@
using GFramework.Core.Abstractions.Logging;
using System;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Godot日志工厂类用于创建Godot平台专用的日志记录器实例
/// Creates Godot platform logger instances.
/// </summary>
public class GodotLoggerFactory : ILoggerFactory
public sealed class GodotLoggerFactory : ILoggerFactory
{
private readonly GodotLoggerOptions? _options;
/// <summary>
/// 获取指定名称的日志记录器实例
/// Initializes a factory that preserves the historical fixed-format logger behavior.
/// </summary>
/// <param name="name">日志记录器的名称</param>
/// <param name="minLevel">日志记录器的最小日志级别</param>
/// <returns>返回GodotLogger类型的日志记录器实例</returns>
public GodotLoggerFactory()
{
}
/// <summary>
/// Initializes a factory with Godot-specific formatting options.
/// </summary>
/// <param name="options">The logger options.</param>
public GodotLoggerFactory(GodotLoggerOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Gets a logger with the specified name.
/// </summary>
/// <param name="name">The logger name.</param>
/// <param name="minLevel">The minimum enabled level.</param>
/// <returns>A Godot logger instance.</returns>
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));
}
}

View File

@ -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;
/// <summary>
/// Godot日志工厂提供程序用于创建Godot日志记录器实例
/// Provides cached Godot logger instances.
/// </summary>
public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider
{
private readonly ILoggerFactory _cachedFactory;
private readonly ConcurrentDictionary<string, ILogger> _loggers = new(StringComparer.Ordinal);
private readonly Func<GodotLoggerSettings> _settingsProvider;
/// <summary>
/// 初始化Godot日志记录器工厂提供程序
/// Initializes a Godot logger provider with the default logger factory.
/// </summary>
public GodotLoggerFactoryProvider()
: this(static () => GodotLoggerSettings.Default)
{
_cachedFactory = new CachedLoggerFactory(new GodotLoggerFactory());
}
/// <summary>
/// 获取或设置最小日志级别
/// Initializes a Godot logger provider with Godot-specific formatting options.
/// </summary>
/// <param name="options">The logger options.</param>
public GodotLoggerFactoryProvider(GodotLoggerOptions options)
: this(CreateStaticSettingsProvider(options))
{
}
internal GodotLoggerFactoryProvider(Func<GodotLoggerSettings> settingsProvider)
{
_settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider));
}
/// <summary>
/// Gets or sets the provider minimum level.
/// </summary>
public LogLevel MinLevel { get; set; }
/// <summary>
/// 创建指定名称的日志记录器实例(带缓存)
/// Creates a cached logger with the specified name.
/// </summary>
/// <param name="name">日志记录器的名称</param>
/// <returns>返回配置了最小日志级别的Godot日志记录器实例</returns>
/// <param name="name">The logger name.</param>
/// <returns>A logger configured with <see cref="MinLevel"/>.</returns>
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);
}
private GodotLoggerOptions GetOptions()
{
return _settingsProvider().Options;
}
private LogLevel GetEffectiveMinLevel(string categoryName)
{
return _settingsProvider().GetEffectiveMinLevel(categoryName, MinLevel);
}
private static Func<GodotLoggerSettings> CreateStaticSettingsProvider(GodotLoggerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var settings = GodotLoggerSettings.FromOptions(options);
return () => settings;
}
}

View File

@ -0,0 +1,17 @@
namespace GFramework.Godot.Logging;
/// <summary>
/// Selects the Godot logger output behavior.
/// </summary>
public enum GodotLoggerMode
{
/// <summary>
/// Uses rich BBCode console output and mirrors warnings/errors to the Godot debugger panel.
/// </summary>
Debug,
/// <summary>
/// Uses plain console output without rich text or debugger panel mirroring.
/// </summary>
Release
}

View File

@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Godot logger formatting and routing options.
/// </summary>
public sealed class GodotLoggerOptions
{
private static readonly IReadOnlyDictionary<LogLevel, string> DefaultColors = new Dictionary<LogLevel, string>
{
[LogLevel.Trace] = "gray",
[LogLevel.Debug] = "cyan",
[LogLevel.Info] = "white",
[LogLevel.Warning] = "orange",
[LogLevel.Error] = "red",
[LogLevel.Fatal] = "deep_pink"
};
/// <summary>
/// Gets or sets the output mode.
/// </summary>
public GodotLoggerMode Mode { get; set; } = GodotLoggerMode.Debug;
/// <summary>
/// Gets or sets the minimum level used by <see cref="GodotLoggerMode.Debug"/>.
/// </summary>
public LogLevel DebugMinLevel { get; set; } = LogLevel.Debug;
/// <summary>
/// Gets or sets the minimum level used by <see cref="GodotLoggerMode.Release"/>.
/// </summary>
public LogLevel ReleaseMinLevel { get; set; } = LogLevel.Info;
/// <summary>
/// Gets or sets the BBCode-capable template used by <see cref="GodotLoggerMode.Debug"/>.
/// </summary>
#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
/// <summary>
/// Gets or sets the plain text template used by <see cref="GodotLoggerMode.Release"/>.
/// </summary>
#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
/// <summary>
/// Gets or sets Godot named colors by log level.
/// </summary>
#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
public Dictionary<LogLevel, string> Colors { get; set; } = new(DefaultColors);
#pragma warning restore MA0016
/// <summary>
/// Creates options that preserve the previous Godot logger defaults for a fixed minimum level.
/// </summary>
/// <param name="minLevel">The minimum enabled level.</param>
/// <returns>Options equivalent to the previous fixed-format logger behavior.</returns>
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<LogLevel, string>(DefaultColors)
};
}
/// <summary>
/// Returns the configured color for the specified level.
/// </summary>
/// <param name="level">The level.</param>
/// <returns>The Godot named color.</returns>
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";
}
/// <summary>
/// Gets the active minimum level for the current <see cref="Mode"/>.
/// </summary>
/// <returns>
/// <see cref="DebugMinLevel"/> when <see cref="Mode"/> is <see cref="GodotLoggerMode.Debug"/>; otherwise
/// <see cref="ReleaseMinLevel"/>.
/// </returns>
/// <remarks>
/// Factories use this value as the option-level floor before category-specific settings are applied.
/// </remarks>
internal LogLevel GetEffectiveMinLevel()
{
return Mode == GodotLoggerMode.Debug ? DebugMinLevel : ReleaseMinLevel;
}
/// <summary>
/// Creates a copy whose debug and release floors are at least <paramref name="minLevel"/>.
/// </summary>
/// <param name="minLevel">The minimum level that both mode-specific floors must satisfy.</param>
/// <returns>A normalized copy with stricter or equal mode-specific minimum levels.</returns>
/// <remarks>
/// The operation can raise <see cref="DebugMinLevel"/> and <see cref="ReleaseMinLevel"/> through
/// <see cref="Max(LogLevel, LogLevel)"/>, but it never lowers them. <see cref="DebugOutputTemplate"/>,
/// <see cref="ReleaseOutputTemplate"/>, and <see cref="Colors"/> are preserved through a defensive copy.
/// </remarks>
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)
};
}
/// <summary>
/// Creates a copy that replaces missing templates or color mappings with safe defaults.
/// </summary>
/// <returns>A normalized copy suitable for runtime rendering.</returns>
/// <remarks>
/// JSON input can set <see cref="DebugOutputTemplate"/>, <see cref="ReleaseOutputTemplate"/>, or
/// <see cref="Colors"/> 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.
/// </remarks>
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<LogLevel, string> CopyColorsWithDefaults(Dictionary<LogLevel, string>? colors)
{
var merged = new Dictionary<LogLevel, string>(DefaultColors);
if (colors == null)
{
return merged;
}
foreach (var pair in colors)
{
if (!string.IsNullOrWhiteSpace(pair.Value))
{
merged[pair.Key] = pair.Value;
}
}
return merged;
}
}

View File

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Represents one immutable Godot logger configuration snapshot.
/// </summary>
/// <remarks>
/// A snapshot combines mode-specific <see cref="Options"/>, 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
/// <c>Game.Services</c> for <c>Game.Services.Inventory</c>, and finally <see cref="DefaultLogLevel"/> is used when
/// present.
/// </remarks>
internal sealed class GodotLoggerSettings
{
private readonly IReadOnlyDictionary<string, LogLevel> _loggerLevels;
/// <summary>
/// Gets the default settings snapshot used when no configuration file is available.
/// </summary>
public static GodotLoggerSettings Default { get; } = new(new GodotLoggerOptions());
/// <summary>
/// Creates a settings snapshot from normalized options and optional category thresholds.
/// </summary>
/// <param name="options">The formatting and mode options for this snapshot.</param>
/// <param name="defaultLogLevel">The optional fallback level used when no category override matches.</param>
/// <param name="loggerLevels">Exact category names or dotted prefixes mapped to minimum levels.</param>
public GodotLoggerSettings(
GodotLoggerOptions options,
LogLevel? defaultLogLevel = null,
IReadOnlyDictionary<string, LogLevel>? loggerLevels = null)
{
Options = (options ?? throw new ArgumentNullException(nameof(options))).CreateNormalizedCopy();
DefaultLogLevel = defaultLogLevel;
_loggerLevels = loggerLevels ?? new Dictionary<string, LogLevel>(StringComparer.Ordinal);
}
/// <summary>
/// Gets the optional fallback minimum level for categories without exact or prefix overrides.
/// </summary>
public LogLevel? DefaultLogLevel { get; }
/// <summary>
/// Gets normalized rendering and mode options for this snapshot.
/// </summary>
public GodotLoggerOptions Options { get; }
/// <summary>
/// Gets exact and dotted-prefix category level overrides.
/// </summary>
/// <remarks>
/// Keys are interpreted with <see cref="StringComparer.Ordinal"/> semantics. A key only matches a child category
/// when the category starts with the key plus a dot, which prevents <c>Game.Service</c> from matching
/// <c>Game.Services</c> accidentally.
/// </remarks>
public IReadOnlyDictionary<string, LogLevel> LoggerLevels => _loggerLevels;
/// <summary>
/// Creates a settings snapshot from options without any category overrides.
/// </summary>
/// <param name="options">The options to normalize and wrap.</param>
/// <returns>A settings snapshot that relies only on the option-level minimum level.</returns>
public static GodotLoggerSettings FromOptions(GodotLoggerOptions options)
{
return new GodotLoggerSettings(options);
}
/// <summary>
/// Calculates the effective minimum level for a category.
/// </summary>
/// <param name="categoryName">The logger category name.</param>
/// <param name="providerMinLevel">The provider-level floor captured by the logger.</param>
/// <returns>The strictest level selected from options, provider floor, and category configuration.</returns>
/// <remarks>
/// The merge starts with <see cref="GodotLoggerOptions.GetEffectiveMinLevel"/> and
/// <paramref name="providerMinLevel"/>, then applies <see cref="GetConfiguredMinLevel"/> when it returns a
/// value. <see cref="Max(LogLevel, LogLevel)"/> is used at each step so configuration can only make a logger
/// stricter, never more verbose than the active floor.
/// </remarks>
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;
}
/// <summary>
/// Finds the configured category level using exact match, longest dotted-prefix match, then default fallback.
/// </summary>
/// <param name="categoryName">The category to resolve.</param>
/// <returns>The configured level, or null when no default or override applies.</returns>
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;
}
/// <summary>
/// Returns the stricter of two log levels.
/// </summary>
/// <param name="left">The first level.</param>
/// <param name="right">The second level.</param>
/// <returns>The level with the higher severity ordering.</returns>
private static LogLevel Max(LogLevel left, LogLevel right)
{
return left > right ? left : right;
}
}

View File

@ -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;
/// <summary>
/// Discovers and parses Godot logging configuration documents.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class GodotLoggerSettingsLoader
{
/// <summary>
/// Names the environment variable that can point to an explicit Godot logging configuration file.
/// </summary>
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)
}
};
/// <summary>
/// Finds the first supported configuration file location.
/// </summary>
/// <param name="environmentPath">Optional explicit path used instead of reading the environment variable.</param>
/// <param name="processPath">Optional process path used when checking the executable directory.</param>
/// <param name="projectPathResolver">Optional resolver for Godot <c>res://</c> paths.</param>
/// <returns>The first existing configuration path, or null when none exists.</returns>
public static string? DiscoverConfigurationPath(
string? environmentPath = null,
string? processPath = null,
Func<string, string?>? 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;
}
/// <summary>
/// Loads a settings snapshot from a JSON file.
/// </summary>
/// <param name="filePath">The configuration file path.</param>
/// <returns>The parsed and normalized settings snapshot.</returns>
/// <exception cref="FileNotFoundException">Thrown when <paramref name="filePath"/> does not exist.</exception>
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));
}
/// <summary>
/// Parses a settings snapshot from a JSON string.
/// </summary>
/// <param name="json">The JSON configuration content.</param>
/// <returns>The parsed and normalized settings snapshot.</returns>
/// <exception cref="JsonException">Thrown when an unsupported log level or malformed document is encountered.</exception>
public static GodotLoggerSettings LoadFromJsonString(string json)
{
ArgumentNullException.ThrowIfNull(json);
var root = JsonSerializer.Deserialize<GodotLoggerSettingsDocument>(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<string, LogLevel>(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<string, LogLevel>? LogLevel { get; set; }
}
private sealed class GodotLogLevelJsonConverter : JsonConverter<LogLevel>
{
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}'.")
};
}
}
}

View File

@ -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`

View File

@ -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 环境,而不是继续改本地格式

View File

@ -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 hardeningRP-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-upRP-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 环境而不是扩大本地格式清理范围

View File

@ -0,0 +1,23 @@
# Semantic Release 版本迁移归档SEMREL-RP-0042026-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`

View File

@ -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 调整开始时创建新的恢复点编号

View File

@ -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

View File

@ -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<Main>();
```
它提供三件事:
- 在第一次真正创建 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<T>()`
### 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 模型搬进来
## 继续阅读

View File

@ -5,9 +5,10 @@
import os
import re
from pathlib import Path
import sys
from argparse import ArgumentParser
ROOT_DIR = "/mnt/f/gewuyou/System/Documents/WorkSpace/GameDev/GFramework"
DEFAULT_ROOT_DIR = os.getcwd()
# 命名空间替换规则(按优先级排序,长的先匹配)
NAMESPACE_RULES = [
@ -120,15 +121,26 @@ def update_file(file_path):
return 0
except Exception as e:
print(f"错误处理文件 {file_path}: {e}")
return 0
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):
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']]
@ -140,15 +152,28 @@ def main():
updated_files = 0
total_replacements = 0
failed_files = []
for file_path in cs_files:
replacements = update_file(file_path)
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__':
main()
sys.exit(main())

View File

@ -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.