engine: complete portable glyph text backend

This commit is contained in:
gamer147
2026-07-30 19:47:21 -04:00
parent bb227ea106
commit c9a0869dc8
12 changed files with 619 additions and 840 deletions

View File

@@ -118,7 +118,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md)
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine
plus the optional exact Windows text adapter), including the
TITLE-only F4 debug scene launcher
portable TextServer glyph adapter, per-profile font-substitution
configuration, and TITLE-only F4 debug scene launcher
```
The disposable `build/page-map-<SCENE>.jsonl` files are produced by normal Godot runs and map runtime ADV

View File

@@ -3650,6 +3650,30 @@ The required fidelity follow-up is implementation of that decoded mask contract;
validation evidence, not the source of rendering behavior. That work is deferred until gameplay settles
and is scoped in `docs/remake-architecture-and-roadmap.md`; it is not the next Phase A/B slice.
## ADV portable glyph backend and Label retirement (2026-07-30)
Godot TextServer is now the portable glyph-mask source. It exposes rasterized atlas coverage plus glyph
offset and horizontal advance without adding a second native font library; AGE's existing deterministic
compositor still owns effects, integer blending, layout, surfaces, and retained publication. Portable
requests use Unicode directly and carry CP932 only when representable so the existing Japanese wrap rules
remain available. TextServer's second advance component is intentionally ignored because it describes
vertical-layout advance rather than GDI's horizontal `gmCellIncY`.
`godot/config/himegari-text-rendering.json` owns the policy id, bounded font/glyph cache capacities, and
ordered Mincho/Gothic/default system-family substitutions. The backend reports itself as explicitly
non-GDI-exact. Startup option `--text-backend auto|gdi|portable` retains the acceptance seam: `auto` chooses
the exact ACP-932 GDI path when available and otherwise the portable policy, while forced `gdi` fails
clearly instead of changing fidelity silently.
After forced portable and forced exact modes both passed immediate-surface, live-layout, History,
complete-line, bounded-cache, retained publication/reset, GPU-upload, surface-copy, and clear gates, the
gameplay `Label` pools were deleted. Immediate, live, and History text no longer has a top-level overlay or
metadata projection path; mask/backend failures are explicit, and all gameplay text inherits the bound
surface's z-order, alpha, tint, fade, transform, clipping, capture, and lifetime.
Validation: engine **580/580**, zero-warning Godot build, and threaded `SELFTEST OK` in both forced
`portable-godot-textserver` and forced `windows-gdi-gray4` modes.
## Persistence native-format reconnaissance complete (2026-07-24)
The deferred save/profile ownership question now has a compatibility-mode answer. The remaining native

View File

@@ -33,7 +33,7 @@ or replaced before claiming portable exports.
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
| ADV font discovery/raster fidelity | `Age.Engine.Text` remains OS-neutral. Godot selects the separate `Age.Engine.Text.Windows` backend for immediate `0x204`/`0x205` strings, live ADV, and retained History when Windows ACP 932 is available; exact masks are cached, composited into layout/numbered RGBA surfaces, and published through ordinary retained handles. The wait atlas is likewise bound at its script-configured handle and no longer uses a detached Godot node. The fallback loads Windows ` 明朝`/` ゴシック` when available and otherwise chooses the Japanese/default font with FreeType embolden and a Godot outline | Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle. Immediate, live, History, and wait-marker presentation now obey retained ordering, alpha/tint/fade, transforms, clipping, capture, HIDEWIN suspension, partial erase/republication, reset, and History teardown. Non-Windows/non-932 runs report why exact text selection failed and atomically retain the old Label presentations; they do not claim GDI parity | Add an explicitly non-identical portable backend and face-substitution policy, then remove the unavailable-rasterizer Label fallback and its remaining overlay compensations. Keep the GDI project out of non-Windows deliverables rather than hiding failed exact selection |
| ADV font discovery/raster fidelity | `Age.Engine.Text` remains OS-neutral. On compatible ACP-932 Windows, `auto` selects the separate exact `Age.Engine.Text.Windows` GDI gray-4 backend. Otherwise Godot TextServer supplies Unicode masks and metrics from ordered system-font substitutions in `godot/config/himegari-text-rendering.json`; this policy is explicitly not GDI pixel-exact. Immediate `0x204`/`0x205`, live ADV, retained History, and the wait atlas all enter ordinary RGBA surfaces/retained handles | Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle. Forced exact and forced portable self-tests cover immediate, live, and History rasterization, bounded caching, complete-line materialization, retained ordering, alpha/tint/fade, transforms, clipping, capture, HIDEWIN suspension, partial erase/republication, reset, and teardown. Gameplay `Label` pools and overlay projection are gone | Produce and smoke-test Linux/macOS exports, confirm installed family availability/fallback selection there, and keep the GDI project out of non-Windows deliverables |
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
| Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target |
| Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS |

View File

@@ -635,16 +635,17 @@ primitive, which cannot be made equivalent by choosing another embolden constant
the persisted backlog. The configured wait atlas now binds at op `0x212`'s handle, advances source cells
only on the configured frame boundary, participates in op-`0x20a` republication and HIDEWIN suspension,
and disappears when the wait service stops. The standalone Godot wait `TextureRect` and its raw-callback
visibility exception are gone. Exact History no longer enters the layout-keyed Label pool; that code and
the live/immediate Label pools remain solely as the explicit unavailable-rasterizer fallback until step 7
supplies the portable glyph backend. Focused lifecycle tests, all 579 engine tests, the exact History
visibility exception are gone. At this checkpoint exact History no longer entered the layout-keyed Label
pool; step 7 subsequently supplied the portable backend and removed every remaining gameplay Label pool.
Focused lifecycle tests, all 579 engine tests, the exact History
Godot self-test, and a real SC0000 Vulkan capture cover the retained path.
7. **Complete and select the portable backend.** Spike the smallest maintainable way to obtain portable
font masks/metrics (Godot/TextServer offscreen extraction versus a bounded FreeType-compatible adapter)
before choosing a dependency. Define face substitution and raster policy as profile/configuration data,
keep its output explicitly non-identical to GDI, and run the same layout/compositor tests. Retain a
development-only backend switch through acceptance; remove the old Label fallback only when both exact
Windows and portable startup/legibility gates pass.
7. **Complete and select the portable backend — completed 2026-07-30.** Godot's TextServer atlas is the
portable mask/metrics source, avoiding another native dependency. Profile data in
`godot/config/himegari-text-rendering.json` owns ordered Mincho/Gothic substitutions and bounded cache
sizes. `auto` selects exact GDI when its ACP-932 gate passes and otherwise selects the explicitly
non-pixel-exact Unicode backend; development runs can force either with `--text-backend`. Both modes pass
the same immediate/live/History retained-glyph self-test. Gameplay `Label` pools and surface-text overlay
projection are removed, so text always participates in the ordinary surface compositor.
**Acceptance gates:**

View File

@@ -346,6 +346,11 @@ use the last `input-wait` event in `timeline.jsonl` as the authoritative manual
are current-working-directory-relative. This is the stable handoff for future game-profile launchers.
- `--scene <NAME>` — override the default `SYSTEM4` root with a direct diagnostic scene, e.g. `--scene SC0240` (executes 29 nested subroutines).
- `--selftest` — headless; runs a **synthesized** scene through the thread/suspend/`CallDeferred` plumbing and asserts it matches a live headless run (full handling; no vm0/frozen golden). Exits.
- `--text-backend auto|gdi|portable` — select gameplay glyph-mask rasterization. `auto` (default) uses the
exact GDI gray-4 backend only when the Windows ACP-932 gate passes, otherwise the explicitly non-identical
Godot TextServer Unicode policy. `gdi` fails startup when exact selection is unavailable; `portable`
never initializes GDI and is the cross-platform acceptance/debug mode. The selected backend and portable
policy id are printed at startup.
- `--window-width <pixels>` / `--window-height <pixels>` — override only the physical windowed client
dimensions; each omitted dimension defaults independently to the SYS4INI logical canvas. Godot keeps
that logical canvas and aspect-preserving letterboxing, so these options do not change VM coordinates,

View File

@@ -64,6 +64,30 @@ public class ImmediateSurfaceTextRendererTests
Assert.All(destination.Pixels, value => Assert.Equal(0, value));
}
[Fact]
public void PortableRequestsPreserveUnicodeOutsideCp932()
{
IReadOnlyList<GlyphRasterRequest> requests =
ImmediateSurfaceTextRenderer.CreateRequests(
"A😀",
Style(),
GlyphRasterPolicy.PortableUnicode);
Assert.Collection(
requests,
request =>
{
Assert.Equal(GlyphRasterPolicy.PortableUnicode, request.Policy);
Assert.Equal((ushort)0x41, request.Cp932Code);
},
request =>
{
Assert.Equal(GlyphRasterPolicy.PortableUnicode, request.Policy);
Assert.Equal(0x1f600, request.UnicodeScalar);
Assert.Null(request.Cp932Code);
});
}
[Theory]
[InlineData(24, 24)]
[InlineData(25, 24)]

View File

@@ -10,8 +10,8 @@ public readonly record struct ImmediateSurfaceTextResult(
int CursorY);
/// <summary>
/// Converts one native CP932 string into exact glyph requests and composites it directly into an
/// AGE RGBA surface. Request construction completes before any pixels change, so an unsupported
/// Converts one string into backend-policy glyph requests and composites it directly into an AGE
/// RGBA surface. Request construction completes before any pixels change, so an unsupported
/// character cannot leave a partially drawn string behind.
/// </summary>
public sealed class ImmediateSurfaceTextRenderer
@@ -20,9 +20,15 @@ public sealed class ImmediateSurfaceTextRenderer
private static readonly Encoding Cp932 = CreateCp932();
private readonly IGlyphMaskRasterizer _rasterizer;
private readonly GlyphRasterPolicy _policy;
public ImmediateSurfaceTextRenderer(IGlyphMaskRasterizer rasterizer)
=> _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
public ImmediateSurfaceTextRenderer(
IGlyphMaskRasterizer rasterizer,
GlyphRasterPolicy policy = GlyphRasterPolicy.NativeCp932Gray4)
{
_rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
_policy = policy;
}
public ImmediateSurfaceTextResult Render(
RgbaImage destination,
@@ -33,7 +39,7 @@ public sealed class ImmediateSurfaceTextRenderer
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentNullException.ThrowIfNull(text);
IReadOnlyList<GlyphRasterRequest> requests = CreateRequests(text, style);
IReadOnlyList<GlyphRasterRequest> requests = CreateRequests(text, style, _policy);
int cursorX = x;
int cursorY = y;
foreach (GlyphRasterRequest request in requests)
@@ -49,7 +55,8 @@ public sealed class ImmediateSurfaceTextRenderer
public static IReadOnlyList<GlyphRasterRequest> CreateRequests(
string text,
AdvTextStyle style)
AdvTextStyle style,
GlyphRasterPolicy policy = GlyphRasterPolicy.NativeCp932Gray4)
{
ArgumentNullException.ThrowIfNull(text);
int requestedHeight = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
@@ -63,26 +70,28 @@ public sealed class ImmediateSurfaceTextRenderer
foreach (Rune rune in text.EnumerateRunes())
{
byte[] encoded;
ushort? cp932Code = null;
try
{
encoded = Cp932.GetBytes(rune.ToString());
byte[] encoded = Cp932.GetBytes(rune.ToString());
if (encoded.Length is < 1 or > 2)
throw new ArgumentException(
$"U+{rune.Value:X4} encoded to unsupported CP932 length {encoded.Length}.",
nameof(text));
cp932Code = encoded.Length == 1
? encoded[0]
: (ushort)((encoded[0] << 8) | encoded[1]);
}
catch (EncoderFallbackException error)
{
throw new ArgumentException(
$"U+{rune.Value:X4} is not representable in native CP932 text.", nameof(text), error);
if (policy == GlyphRasterPolicy.NativeCp932Gray4)
throw new ArgumentException(
$"U+{rune.Value:X4} is not representable in native CP932 text.",
nameof(text), error);
}
if (encoded.Length is < 1 or > 2)
throw new ArgumentException(
$"U+{rune.Value:X4} encoded to unsupported CP932 length {encoded.Length}.",
nameof(text));
ushort cp932Code = encoded.Length == 1
? encoded[0]
: (ushort)((encoded[0] << 8) | encoded[1]);
requests.Add(new GlyphRasterRequest(
fontFace, pixelHeight, requestedWidth, weight,
rune.Value, cp932Code, GlyphRasterPolicy.NativeCp932Gray4));
rune.Value, cp932Code, policy));
}
return requests;
}

View File

@@ -65,21 +65,16 @@ public sealed class GodotAdvHost : IHost
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private volatile bool _stopping;
private readonly object _textLock = new();
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
private readonly CachedGlyphMaskRasterizer? _surfaceTextMaskCache;
private readonly ImmediateSurfaceTextRenderer? _surfaceTextPixelRenderer;
private readonly RetainedGlyphLayoutEngine? _retainedGlyphLayoutEngine;
private readonly GlyphRasterizerBackendInfo? _surfaceTextBackendInfo;
private string _surfaceTextFallbackReason;
private bool _surfaceTextFallbackWarningReported;
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
private readonly CachedGlyphMaskRasterizer _surfaceTextMaskCache;
private readonly ImmediateSurfaceTextRenderer _surfaceTextPixelRenderer;
private readonly RetainedGlyphLayoutEngine _retainedGlyphLayoutEngine;
private readonly GlyphRasterizerBackendInfo _surfaceTextBackendInfo;
private readonly HashSet<int> _retainedHistoryLayouts = new();
private sealed class LiveTextState
{
public required AdvLiveTextRun Run;
public required long StartedMs;
public required int GlyphDelayMilliseconds;
public bool RetainedPixels;
public int FirstGlyphIndex;
public int GlyphCount;
}
@@ -137,10 +132,10 @@ public sealed class GodotAdvHost : IHost
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
IGlyphMaskRasterizer surfaceTextRasterizer,
GodotTimelineLog? timeline = null,
bool synchronizeExplicitPresentation = true,
IGlyphMaskRasterizer? surfaceTextRasterizer = null,
string? surfaceTextFallbackReason = null)
int surfaceTextMaskCacheCapacity = 2048)
{
_main = main; _res = res; _rootScene = scene; _clock = clock;
_locator = locator; _timeline = timeline;
@@ -148,35 +143,30 @@ public sealed class GodotAdvHost : IHost
_screenHeight = logicalCanvas.Height;
_slotDims[0] = (_screenWidth, _screenHeight);
_synchronizeExplicitPresentation = synchronizeExplicitPresentation;
ArgumentNullException.ThrowIfNull(surfaceTextRasterizer);
_surfaceTextBackendInfo =
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo;
if (surfaceTextRasterizer != null)
{
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
surfaceTextRasterizer, capacity: 2048);
_surfaceTextPixelRenderer =
new ImmediateSurfaceTextRenderer(_surfaceTextMaskCache);
_retainedGlyphLayoutEngine =
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
}
_surfaceTextFallbackReason = surfaceTextFallbackReason
?? (surfaceTextRasterizer == null
? "No glyph-mask rasterizer was selected."
: "");
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo
?? throw new ArgumentException(
"Gameplay glyph rasterizers must identify their policy.",
nameof(surfaceTextRasterizer));
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
surfaceTextRasterizer, capacity: surfaceTextMaskCacheCapacity);
_surfaceTextPixelRenderer =
new ImmediateSurfaceTextRenderer(
_surfaceTextMaskCache, _surfaceTextBackendInfo.Policy);
_retainedGlyphLayoutEngine =
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
}
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
public bool UsesSurfaceTextPixels => _surfaceTextPixelRenderer != null;
public GlyphRasterizerBackendInfo? SurfaceTextBackendInfo => _surfaceTextBackendInfo;
public string SurfaceTextFallbackReason => _surfaceTextFallbackReason;
public bool UsesSurfaceTextPixels => true;
public GlyphRasterizerBackendInfo SurfaceTextBackendInfo => _surfaceTextBackendInfo;
public (int Count, int Capacity, long Hits, long Misses) SurfaceTextMaskCacheStats
=> _surfaceTextMaskCache == null
? (0, 0, 0, 0)
: (
_surfaceTextMaskCache.Count,
_surfaceTextMaskCache.Capacity,
_surfaceTextMaskCache.Hits,
_surfaceTextMaskCache.Misses);
=> (
_surfaceTextMaskCache.Count,
_surfaceTextMaskCache.Capacity,
_surfaceTextMaskCache.Hits,
_surfaceTextMaskCache.Misses);
public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
@@ -271,79 +261,12 @@ public sealed class GodotAdvHost : IHost
}
public void ShowText(int offset, string text)
{
AdvTextLayoutSnapshot layout;
int delay;
lock (_textLock)
{
layout = new AdvTextLayoutSnapshot(
_currentAdvLayout, _screenWidth, _screenHeight, 0, 0,
_advTextX, _advTextY, _screenWidth, _screenHeight);
delay = _messageGlyphDelayMilliseconds;
}
ShowText(new AdvLiveTextRun(
offset, layout, AdvTextStyle.Default, text, Array.Empty<string>()), delay);
}
=> throw new NotSupportedException(
"Godot gameplay text requires a retained layout binding.");
public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds)
{
Captured.Add((run.SourceOffset, run.Text));
_locator.Text(run.SourceOffset, run.Text);
int delay = System.Math.Max(0, glyphDelayMilliseconds);
var state = new LiveTextState
{
Run = run,
StartedMs = _clock.NowMs,
GlyphDelayMilliseconds = delay,
};
lock (_textLock)
{
_liveText.Add(state);
_activeLiveText = state;
_advText = run.Text;
_advTextX = run.Layout.CursorX;
_advTextY = run.Layout.CursorY;
_currentAdvLayout = run.Layout.Slot;
_advTextStartedMs = state.StartedMs;
_activeGlyphDelayMilliseconds = delay;
_advTextForceComplete = _messageSkipActive;
IsTextRevealing = run.Text.Length > 0 && delay > 0 && !_messageSkipActive;
}
_timeline?.State("text-reveal", new()
{
["offset"] = $"0x{run.SourceOffset:x}",
["layout"] = run.Layout.Slot,
["x"] = run.Layout.OriginX + run.Layout.CursorX,
["y"] = run.Layout.OriginY + run.Layout.CursorY,
["glyphs"] = run.Text.Length, ["delay_ms"] = delay,
});
if (!IsTextRevealing)
{
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
return;
}
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
while (IsTextRevealing && !_stopping)
{
lock (_textLock)
{
if (_advTextForceComplete
|| _clock.NowMs - _advTextStartedMs >= run.Text.Length * (long)delay)
IsTextRevealing = false;
}
if (IsTextRevealing) _frameSignal.WaitOne(50);
}
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
}
=> throw new NotSupportedException(
"Godot gameplay text requires a retained layout binding.");
public AdvRetainedTextRunResult? ShowText(
GfxState gfx,
@@ -351,18 +274,15 @@ public sealed class GodotAdvHost : IHost
AdvLiveTextRun run,
int glyphDelayMilliseconds)
{
if (_retainedGlyphLayoutEngine == null
|| binding.LayoutSlot != run.Layout.Slot
if (binding.LayoutSlot != run.Layout.Slot
|| binding.FirstObjectHandle < 0
|| binding.ObjectCapacity <= 0
|| !TryPrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result))
{
ShowText(run, glyphDelayMilliseconds);
return null;
}
|| binding.ObjectCapacity <= 0)
throw new InvalidOperationException(
$"ADV layout {run.Layout.Slot} has no retained presentation binding.");
PrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result);
Captured.Add((run.SourceOffset, run.Text));
_locator.Text(run.SourceOffset, run.Text);
@@ -376,7 +296,6 @@ public sealed class GodotAdvHost : IHost
Run = run,
StartedMs = _clock.NowMs,
GlyphDelayMilliseconds = delay,
RetainedPixels = true,
FirstGlyphIndex = result.FirstGlyphIndex,
GlyphCount = revealGlyphCount,
};
@@ -451,7 +370,7 @@ public sealed class GodotAdvHost : IHost
return result;
}
private bool TryPrepareRetainedTextRun(
private void PrepareRetainedTextRun(
GfxState gfx,
AdvTextLayoutPresentationBinding binding,
AdvLiveTextRun run,
@@ -461,12 +380,9 @@ public sealed class GodotAdvHost : IHost
presentation = null!;
result = default;
if (run.Layout.Width <= 0 || run.Layout.Height <= 0)
{
ReportSurfaceTextFallback(
throw new InvalidOperationException(
$"ADV layout {run.Layout.Slot} has invalid dimensions " +
$"{run.Layout.Width}x{run.Layout.Height}.");
return false;
}
RgbaImage destination = ResolveSurfacePixels(binding.SourceSurfaceSlot)
?? new RgbaImage(
@@ -475,43 +391,27 @@ public sealed class GodotAdvHost : IHost
new byte[checked(run.Layout.Width * run.Layout.Height * 4)]);
if (destination.Width != run.Layout.Width
|| destination.Height != run.Layout.Height)
{
ReportSurfaceTextFallback(
throw new InvalidOperationException(
$"ADV layout {run.Layout.Slot} surface {binding.SourceSurfaceSlot} is " +
$"{destination.Width}x{destination.Height}; expected " +
$"{run.Layout.Width}x{run.Layout.Height}.");
return false;
}
var updated = new RgbaImage(
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
GlyphTextLayoutResult rendered;
try
{
IReadOnlyList<GlyphRasterRequest> requests =
ImmediateSurfaceTextRenderer.CreateRequests(run.Text, run.Style);
rendered = _retainedGlyphLayoutEngine!.Render(
updated,
new GlyphTextLayoutOptions(
run.Layout.CursorX,
run.Layout.CursorY,
binding.ResetCursorX,
run.Layout.Right,
run.Layout.Bottom,
WrapHorizontally: true,
run.Style),
requests);
}
catch (Exception error) when (
error is ArgumentException
or InvalidOperationException
or PlatformNotSupportedException
or System.ComponentModel.Win32Exception)
{
ReportSurfaceTextFallback(
$"{error.GetType().Name}: {error.Message}");
return false;
}
IReadOnlyList<GlyphRasterRequest> requests =
ImmediateSurfaceTextRenderer.CreateRequests(
run.Text, run.Style, _surfaceTextBackendInfo.Policy);
GlyphTextLayoutResult rendered = _retainedGlyphLayoutEngine.Render(
updated,
new GlyphTextLayoutOptions(
run.Layout.CursorX,
run.Layout.CursorY,
binding.ResetCursorX,
run.Layout.Right,
run.Layout.Bottom,
WrapHorizontally: true,
run.Style),
requests);
lock (_textLock)
{
@@ -522,11 +422,8 @@ public sealed class GodotAdvHost : IHost
_retainedTextLayouts.Add(binding.LayoutSlot, presentation);
}
else if (presentation.Binding != binding)
{
ReportSurfaceTextFallback(
throw new InvalidOperationException(
$"ADV layout {binding.LayoutSlot} changed its retained binding without reset.");
return false;
}
int first = presentation.Append(
rendered.Records, run.Layout.OriginX, run.Layout.OriginY);
result = new AdvRetainedTextRunResult(
@@ -541,7 +438,6 @@ public sealed class GodotAdvHost : IHost
_surfaceImages[binding.SourceSurfaceSlot] = updated;
_slotDims[binding.SourceSurfaceSlot] = (updated.Width, updated.Height);
gfx.CreateSurface(binding.SourceSurfaceSlot);
return true;
}
public void SetAdvTextCursor(int layoutSlot, int x, int y)
@@ -560,46 +456,19 @@ public sealed class GodotAdvHost : IHost
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style)
{
if (_surfaceTextPixelRenderer != null
&& TryDrawStringPixels(surfaceSlot, x, y, text, style))
{
lock (_textLock)
if (_surfaceText.TryGetValue(surfaceSlot, out var fallbackDraws))
{
fallbackDraws.RemoveAll(draw => draw.X == x && draw.Y == y);
if (fallbackDraws.Count == 0) _surfaceText.Remove(surfaceSlot);
}
_timeline?.Event("draw-string", new()
{
["surface"] = surfaceSlot,
["x"] = x,
["y"] = y,
["text"] = text,
["presentation"] = "rgba-glyph-mask",
["backend"] = _surfaceTextBackendInfo?.Id ?? "unidentified",
});
return;
}
lock (_textLock)
{
if (!_surfaceText.TryGetValue(surfaceSlot, out var draws))
_surfaceText[surfaceSlot] = draws = new List<SurfaceTextDraw>();
draws.RemoveAll(draw => draw.X == x && draw.Y == y);
draws.Add(new SurfaceTextDraw(x, y, text, style));
}
DrawStringPixels(surfaceSlot, x, y, text, style);
_timeline?.Event("draw-string", new()
{
["surface"] = surfaceSlot,
["x"] = x,
["y"] = y,
["text"] = text,
["presentation"] = "label-fallback",
["reason"] = _surfaceTextFallbackReason,
["presentation"] = "rgba-glyph-mask",
["backend"] = _surfaceTextBackendInfo.Id,
});
}
private bool TryDrawStringPixels(
private void DrawStringPixels(
int surfaceSlot, int x, int y, string text, AdvTextStyle style)
{
RgbaImage? destination = ResolveSurfacePixels(surfaceSlot);
@@ -611,40 +480,15 @@ public sealed class GodotAdvHost : IHost
dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
if (destination == null || destination.Width <= 0 || destination.Height <= 0)
{
ReportSurfaceTextFallback(
throw new InvalidOperationException(
$"Surface {surfaceSlot} has no rasterizable pixel allocation.");
return false;
}
var updated = new RgbaImage(
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
try
{
_surfaceTextPixelRenderer!.Render(updated, x, y, text, style);
}
catch (Exception error) when (
error is ArgumentException
or InvalidOperationException
or PlatformNotSupportedException
or System.ComponentModel.Win32Exception)
{
ReportSurfaceTextFallback(
$"{error.GetType().Name}: {error.Message}");
return false;
}
_surfaceTextPixelRenderer.Render(updated, x, y, text, style);
lock (_imageLock) _surfaceImages[surfaceSlot] = updated;
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
return true;
}
private void ReportSurfaceTextFallback(string reason)
{
_surfaceTextFallbackReason = reason;
if (_surfaceTextFallbackWarningReported) return;
_surfaceTextFallbackWarningReported = true;
ReportWarning($"surface text fell back to Label metadata: {reason}");
}
public (string Text, int X, int Y, int VisibleGlyphs, bool Revealing) SnapshotAdvText()
@@ -660,30 +504,10 @@ public sealed class GodotAdvHost : IHost
}
}
public IReadOnlyList<LiveAdvTextSnapshot> SnapshotLiveAdvText()
{
lock (_textLock)
{
var snapshot = new List<LiveAdvTextSnapshot>(_liveText.Count);
foreach (LiveTextState state in _liveText)
{
if (state.RetainedPixels) continue;
bool revealing = ReferenceEquals(state, _activeLiveText) && IsTextRevealing;
int visible = !revealing || _advTextForceComplete || state.GlyphDelayMilliseconds == 0
? state.Run.Text.Length
: (int)System.Math.Clamp(
(_clock.NowMs - state.StartedMs) / state.GlyphDelayMilliseconds + 1,
0, state.Run.Text.Length);
snapshot.Add(new LiveAdvTextSnapshot(state.Run, visible, revealing));
}
return snapshot;
}
}
/// <summary>
/// Layout that owns the ordinary ADV overlay. Nested callback scripts such as HISTORY can select and
/// mutate other layouts while the parent wait remains parked; those transient selections must not move
/// the parent page when its overlay becomes visible again.
/// Layout that owns the ordinary retained ADV page. Nested callback scripts such as HISTORY can select
/// and mutate other layouts while the parent wait remains parked; those transient selections must not
/// move the parent page when its retained range is restored.
/// </summary>
public int AdvPageLayoutSlot
{
@@ -694,28 +518,11 @@ public sealed class GodotAdvHost : IHost
}
}
public IReadOnlyList<SurfaceTextDraw> SnapshotSurfaceText(int surfaceSlot)
{
lock (_textLock)
return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>();
}
public void SnapshotSurfaceText(int surfaceSlot, List<SurfaceTextDraw> snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
lock (_textLock)
{
snapshot.Clear();
if (_surfaceText.TryGetValue(surfaceSlot, out var draws)) snapshot.AddRange(draws);
}
}
public void ClearRenderedAdvTextLayout(int layoutSlot)
{
lock (_textLock)
{
int slot = layoutSlot == 0 ? _currentAdvLayout : layoutSlot;
_historyText.Remove(slot);
if (_retainedTextLayouts.ContainsKey(slot)) return;
_liveText.RemoveAll(state => state.Run.Layout.Slot == slot);
if (_activeLiveText?.Run.Layout.Slot == slot)
@@ -733,7 +540,6 @@ public sealed class GodotAdvHost : IHost
{
lock (_textLock)
{
_historyText.Remove(binding.LayoutSlot);
_retainedHistoryLayouts.Remove(binding.LayoutSlot);
_retainedTextLayouts.Remove(binding.LayoutSlot);
_liveText.RemoveAll(
@@ -770,18 +576,6 @@ public sealed class GodotAdvHost : IHost
});
}
public void RenderTextHistory(AdvTextHistoryRenderBatch batch)
{
lock (_textLock) _historyText[batch.LayoutSlot] = batch;
_timeline?.Event("history-render", new()
{
["layout"] = batch.LayoutSlot, ["record"] = batch.FirstRecordIndex,
["x"] = batch.Layout.OriginX + batch.Layout.CursorX,
["y"] = batch.Layout.OriginY + batch.Layout.CursorY,
["text"] = batch.Text,
});
}
public bool RenderTextHistory(
GfxState gfx,
AdvTextLayoutPresentationBinding binding,
@@ -793,25 +587,21 @@ public sealed class GodotAdvHost : IHost
batch.Style,
batch.Text,
Array.Empty<string>());
if (_retainedGlyphLayoutEngine == null
|| binding.LayoutSlot != batch.LayoutSlot
if (binding.LayoutSlot != batch.LayoutSlot
|| binding.FirstObjectHandle < 0
|| binding.ObjectCapacity <= 0
|| !TryPrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result))
{
RenderTextHistory(batch);
return false;
}
|| binding.ObjectCapacity <= 0)
throw new InvalidOperationException(
$"History layout {batch.LayoutSlot} has no retained presentation binding.");
PrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result);
presentation.PublishThrough(
gfx,
checked(result.FirstGlyphIndex + result.GlyphCount));
lock (_textLock)
{
_historyText.Remove(batch.LayoutSlot);
_retainedHistoryLayouts.Add(batch.LayoutSlot);
}
Interlocked.Exchange(ref _presentRequested, 1);
@@ -828,16 +618,6 @@ public sealed class GodotAdvHost : IHost
return true;
}
public void EndTextHistoryPresentation()
{
lock (_textLock)
{
_historyText.Clear();
_retainedHistoryLayouts.Clear();
}
_timeline?.Event("history-presentation-end");
}
public void EndTextHistoryPresentation(GfxState gfx)
{
RetainedAdvTextLayoutPresentation[] retained;
@@ -856,7 +636,6 @@ public sealed class GodotAdvHost : IHost
foreach (int slot in _retainedHistoryLayouts)
_retainedTextLayouts.Remove(slot);
_retainedHistoryLayouts.Clear();
_historyText.Clear();
}
foreach (RetainedAdvTextLayoutPresentation presentation in retained)
@@ -870,11 +649,6 @@ public sealed class GodotAdvHost : IHost
});
}
public IReadOnlyList<AdvTextHistoryRenderBatch> SnapshotRenderedTextHistory()
{
lock (_textLock) return _historyText.Values.OrderBy(batch => batch.LayoutSlot).ToArray();
}
private void ClearAllocatedSurfacePixels(int surfaceSlot)
{
if (!_slotDims.TryGetValue(surfaceSlot, out var dimensions)
@@ -898,17 +672,6 @@ public sealed class GodotAdvHost : IHost
public void FillSurfaceRect(SurfaceRectFill fill)
{
lock (_textLock)
{
if (_surfaceText.TryGetValue(fill.SurfaceSlot, out var draws))
{
long right = (long)fill.X + System.Math.Max(0, fill.Width);
long bottom = (long)fill.Y + System.Math.Max(0, fill.Height);
draws.RemoveAll(draw => draw.X >= fill.X && draw.X < right
&& draw.Y >= fill.Y && draw.Y < bottom);
}
}
RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot);
if (destination == null && _slotDims.TryGetValue(fill.SurfaceSlot, out var dimensions)
&& dimensions.W >= 0 && dimensions.H >= 0)
@@ -1655,9 +1418,7 @@ public sealed class GodotAdvHost : IHost
}
lock (_textLock)
{
_surfaceText.Clear();
_surfaceResources.Clear();
_historyText.Clear();
_retainedHistoryLayouts.Clear();
_liveText.Clear();
_retainedTextLayouts.Clear();
@@ -1792,7 +1553,6 @@ public sealed class GodotAdvHost : IHost
public void CreateTexture(int slot, int width, int height)
{
lock (_textLock) _surfaceText.Remove(slot);
lock (_textLock) _surfaceResources.Remove(slot);
int safeWidth = System.Math.Max(0, width);
int safeHeight = System.Math.Max(0, height);
@@ -1817,7 +1577,6 @@ public sealed class GodotAdvHost : IHost
}
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources[slot] = resourceId;
}
var asset = _res.ResolveTexture(resourceId);
@@ -1851,7 +1610,6 @@ public sealed class GodotAdvHost : IHost
}
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
@@ -2152,7 +1910,6 @@ public sealed class GodotAdvHost : IHost
}
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
@@ -2187,9 +1944,6 @@ public sealed class GodotAdvHost : IHost
}
else
{
// For an offscreen target, discard separately retained text draws so its modeled pixel
// contents observe the native D3D clear as well.
lock (_textLock) _surfaceText.Remove(surfaceSlot);
lock (_imageLock)
{
if (_surfaceImages.TryGetValue(surfaceSlot, out var image))
@@ -2235,8 +1989,6 @@ public sealed class GodotAdvHost : IHost
});
lock (_imageLock) _surfaceImages[targetSlot] = destination;
PublishSurfaceTextRangeToSurface(
gfx, visible, firstHandle, count, targetSlot, dimensions.W, dimensions.H);
IReadOnlyList<RenderObject> retained = visible
.Where(item => item.Handle >= firstHandle && item.Handle - firstHandle < count)
.ToArray();
@@ -2251,52 +2003,6 @@ public sealed class GodotAdvHost : IHost
});
}
private void PublishSurfaceTextRangeToSurface(
GfxState gfx, IReadOnlyList<RenderObject> visible, long firstHandle, long count,
int targetSlot, int targetWidth, int targetHeight)
{
List<SurfaceTextDraw> projected;
lock (_textLock)
projected = _surfaceText.TryGetValue(targetSlot, out var retained)
? new List<SurfaceTextDraw>(retained)
: new List<SurfaceTextDraw>();
foreach (RenderObject item in visible)
{
if (item.Handle < firstHandle || item.Handle - firstHandle >= count) continue;
var raw = gfx.TryGet(item.Handle);
if (raw == null) continue;
List<SurfaceTextDraw>? source;
lock (_textLock)
source = _surfaceText.TryGetValue(raw.SourceSlot, out var draws)
? new List<SurfaceTextDraw>(draws)
: null;
if (source == null) continue;
Affine2D localToTarget =
Transform2DMath.Build(item.Transform, item.Rotation, item.ScaleCycle)
.FromLocalOrigin(item.DstX, item.DstY);
if (item.RangeTransform is { } rangeTransform)
localToTarget = localToTarget.Then(rangeTransform);
foreach (SurfaceTextDraw draw in source)
{
if (draw.X < item.SrcX || draw.X >= item.SrcX + item.W ||
draw.Y < item.SrcY || draw.Y >= item.SrcY + item.H) continue;
var position = localToTarget.Apply(draw.X - item.SrcX, draw.Y - item.SrcY);
int x = (int)System.Math.Round(position.X);
int y = (int)System.Math.Round(position.Y);
if (x < 0 || x >= targetWidth || y < 0 || y >= targetHeight) continue;
projected.Add(new SurfaceTextDraw(x, y, draw.Text, draw.Style));
}
}
lock (_textLock)
{
if (projected.Count == 0) _surfaceText.Remove(targetSlot);
else _surfaceText[targetSlot] = projected;
}
}
public void ReleaseSurfaceRange(int firstSlot, int count)
{
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
@@ -2316,7 +2022,6 @@ public sealed class GodotAdvHost : IHost
{
for (int slot = firstSlot; slot < end; slot++)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
}
foreach (int layoutSlot in _retainedTextLayouts
@@ -2655,9 +2360,6 @@ public sealed class GodotAdvHost : IHost
}
}
public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style);
public readonly record struct LiveAdvTextSnapshot(
AdvLiveTextRun Run, int VisibleGlyphs, bool Revealing);
public sealed record GodotHostDiagnosticSnapshot(
string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping,
bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended,

View File

@@ -0,0 +1,180 @@
using System;
using Age.Engine.Text;
using Godot;
/// <summary>
/// Portable, explicitly non-pixel-exact glyph masks obtained from Godot's TextServer atlas.
/// AGE still owns layout, effects, blending, retained ordering, and surface lifetime.
/// </summary>
public sealed class GodotTextServerGlyphMaskRasterizer :
IIdentifiedGlyphMaskRasterizer, IDisposable
{
private readonly record struct FontKey(string RequestedFace, int Weight);
private readonly object _gate = new();
private readonly PortableTextRenderingPolicy _policy;
private readonly TextServer _textServer;
private readonly BoundedLruCache<FontKey, SystemFont> _fonts;
private bool _disposed;
public GodotTextServerGlyphMaskRasterizer(PortableTextRenderingPolicy policy)
{
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
_textServer = TextServerManager.GetPrimaryInterface()
?? throw new InvalidOperationException("Godot has no primary TextServer interface.");
_fonts = new BoundedLruCache<FontKey, SystemFont>(
policy.FontCacheCapacity, font => font.Dispose());
BackendInfo = new GlyphRasterizerBackendInfo(
"portable-godot-textserver",
"Godot TextServer system fonts",
GlyphRasterPolicy.PortableUnicode,
NativePixelExact: false,
$"policy={policy.Id}; raster={policy.Raster.Antialiasing}/" +
$"{policy.Raster.Hinting}/{policy.Raster.SubpixelPositioning}; " +
"Unicode system-font substitution; not GDI pixel-exact");
}
public GlyphRasterizerBackendInfo BackendInfo { get; }
public GlyphMask Rasterize(GlyphRasterRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Policy != GlyphRasterPolicy.PortableUnicode)
throw new ArgumentException(
"Godot TextServer accepts only portable Unicode glyph requests.",
nameof(request));
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
SystemFont font = ResolveFont(request);
// Asking the high-level Font for the character primes both the selected system face
// and any system fallback RID before we inspect the TextServer cache directly.
_ = font.GetCharSize(request.UnicodeScalar, request.PixelHeight);
Rid rid = FindGlyphRid(font, request);
long glyph = _textServer.FontGetGlyphIndex(
rid, request.PixelHeight, request.UnicodeScalar, 0);
if (glyph == 0 && request.UnicodeScalar != 0)
throw MissingGlyph(request);
var sizeKey = new Vector2I(request.PixelHeight, 0);
_textServer.FontRenderGlyph(rid, sizeKey, glyph);
Vector2 glyphSize = _textServer.FontGetGlyphSize(rid, sizeKey, glyph);
Vector2 glyphOffset = _textServer.FontGetGlyphOffset(rid, sizeKey, glyph);
Vector2 advance = _textServer.FontGetGlyphAdvance(
rid, request.PixelHeight, glyph);
Rect2 uv = _textServer.FontGetGlyphUVRect(rid, sizeKey, glyph);
int width = Math.Max(0, Mathf.RoundToInt(glyphSize.X));
int height = Math.Max(0, Mathf.RoundToInt(glyphSize.Y));
byte[] coverage = width == 0 || height == 0
? []
: ExtractCoverage(rid, sizeKey, glyph, uv, width, height);
int advanceX = Mathf.RoundToInt(advance.X);
return new GlyphMask(
width,
height,
width,
Mathf.RoundToInt(glyphOffset.X),
-Mathf.RoundToInt(glyphOffset.Y),
advanceX,
0,
Math.Max(width, advanceX),
request.PixelHeight,
coverage);
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
_disposed = true;
_fonts.Clear();
}
}
private SystemFont ResolveFont(GlyphRasterRequest request)
{
PortableFontRasterPolicy raster = _policy.Raster;
int weight = request.Weight >= raster.BoldThreshold
? raster.BoldWeight
: raster.RegularWeight;
var key = new FontKey(request.FontFace, weight);
if (_fonts.TryGetValue(key, out SystemFont? cached)) return cached;
var font = new SystemFont
{
FontNames = _policy.ResolveFamilies(request.FontFace),
FontWeight = weight,
AllowSystemFallback = raster.AllowSystemFallback,
Antialiasing = raster.Antialiasing switch
{
"none" => TextServer.FontAntialiasing.None,
"lcd" => TextServer.FontAntialiasing.Lcd,
_ => TextServer.FontAntialiasing.Gray,
},
Hinting = raster.Hinting switch
{
"none" => TextServer.Hinting.None,
"light" => TextServer.Hinting.Light,
_ => TextServer.Hinting.Normal,
},
SubpixelPositioning = raster.SubpixelPositioning switch
{
"auto" => TextServer.SubpixelPositioning.Auto,
"one-half" => TextServer.SubpixelPositioning.OneHalf,
"one-quarter" => TextServer.SubpixelPositioning.OneQuarter,
_ => TextServer.SubpixelPositioning.Disabled,
},
MultichannelSignedDistanceField =
raster.MultichannelSignedDistanceField,
};
_fonts.Set(key, font);
return font;
}
private Rid FindGlyphRid(SystemFont font, GlyphRasterRequest request)
{
foreach (Rid rid in font.GetRids())
if (_textServer.FontHasChar(rid, request.UnicodeScalar))
return rid;
throw MissingGlyph(request);
}
private byte[] ExtractCoverage(
Rid rid,
Vector2I sizeKey,
long glyph,
Rect2 uv,
int width,
int height)
{
long textureIndex = _textServer.FontGetGlyphTextureIdx(rid, sizeKey, glyph);
Image atlas = _textServer.FontGetTextureImage(rid, sizeKey, textureIndex)
?? throw new InvalidOperationException("TextServer returned no glyph atlas image.");
int left = Mathf.RoundToInt(uv.Position.X);
int top = Mathf.RoundToInt(uv.Position.Y);
var result = new byte[checked(width * height)];
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
{
Color pixel = atlas.GetPixel(left + x, top + y);
float mask = atlas.GetFormat() switch
{
Image.Format.L8 => pixel.R,
Image.Format.Rgb8 => Math.Max(pixel.R, Math.Max(pixel.G, pixel.B)),
_ => pixel.A,
};
result[y * width + x] =
(byte)Math.Clamp(Mathf.RoundToInt(mask * 16f), 0, 16);
}
return result;
}
private static Exception MissingGlyph(GlyphRasterRequest request)
=> new InvalidOperationException(
$"Portable font policy could not resolve U+{request.UnicodeScalar:X4} " +
$"for authored face '{request.FontFace}'.");
}

View File

@@ -20,12 +20,6 @@ using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
public partial class Main : Godot.Control
{
// Provisional approximation only: AGE asks GDI to synthesize LOGFONT weight 700, grid-fit a
// GGO_GRAY4 mask, and composites that mask itself. Godot instead applies FreeType embolden plus
// spacing. Do not retune these values from screenshots; replace this approximation from the decoded
// native glyph-mask contract. See docs/engine-re.md.
private const float NativeBoldEmbolden = 0.53f;
private const int NativeBoldGlyphSpacing = 1;
private int _screenWidth = Sys4LogicalCanvas.DefaultWidth;
private int _screenHeight = Sys4LogicalCanvas.DefaultHeight;
private WindowLaunchOptions _windowOptions;
@@ -40,18 +34,6 @@ public partial class Main : Godot.Control
// One managed composition target for the entire frame. Layer helpers mutate it in place; only the
// completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer.
private byte[] _screenPixels = [];
private Label _text = null!;
private Label _speaker = null!;
private readonly System.Collections.Generic.List<Label> _advTextLabels = new();
private readonly System.Collections.Generic.List<Label> _surfaceTextLabels = new();
private readonly System.Collections.Generic.Dictionary<int, Label> _historyTextLabels = new();
private Font? _presentationFallbackFont;
private readonly Dictionary<string, Font> _presentationFaceFonts =
new(System.StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FontVariation> _presentationBoldFonts =
new(System.StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _unavailablePresentationFaces =
new(System.StringComparer.OrdinalIgnoreCase);
private Label _status = null!;
private Label _locatorHud = null!;
private AudioStreamPlayer _bgm = null!; // looping background music
@@ -69,9 +51,7 @@ public partial class Main : Godot.Control
private Sys4RegIniStore? _sys4RegIniStore;
private VirtualMachine _vm = null!;
private GodotAdvHost _host = null!;
#if AGE_WINDOWS_GDI
private WindowsGdiGlyphMaskRasterizer? _exactGlyphRasterizer;
#endif
private IDisposable? _glyphRasterizerOwner;
private FullwidthTextEditorDialog? _fullwidthTextEditor;
private Sys4ScriptProvider? _scripts;
private DebugSceneLauncher? _debugSceneLauncher;
@@ -191,14 +171,6 @@ public partial class Main : Godot.Control
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_gpuRenderer = new GpuRetainedRenderer(this);
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore };
AddChild(_text);
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_advTextLabels.Add(_text);
_speaker = new Label { MouseFilter = MouseFilterEnum.Ignore, Visible = false };
AddChild(_speaker);
_speaker.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_surfaceTextLabels.Add(_speaker);
_status = new Label();
AddChild(_status);
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
@@ -207,7 +179,7 @@ public partial class Main : Godot.Control
_locatorHud.Position = new Vector2(8, 8);
AddChild(_locatorHud);
// Best-effort CJK font so the visual isn't tofu (headless self-test doesn't depend on it).
// Best-effort CJK font for diagnostic/status UI. Gameplay text is always surface pixels.
foreach (var fp in new[] { "C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/YuGothM.ttc",
"C:/Windows/Fonts/YuGothR.ttc", "C:/Windows/Fonts/meiryo.ttc" })
{
@@ -215,18 +187,8 @@ public partial class Main : Godot.Control
try
{
var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) };
_presentationFallbackFont = ff;
_text.AddThemeFontOverride("font", ff);
_speaker.AddThemeFontOverride("font", ff);
_status.AddThemeFontOverride("font", ff);
_locatorHud.AddThemeFontOverride("font", ff);
_text.AddThemeFontSizeOverride("font_size", 25);
_speaker.AddThemeFontSizeOverride("font_size", 25);
_text.AddThemeConstantOverride("outline_size", 1);
_speaker.AddThemeConstantOverride("outline_size", 1);
var outline = new Color(0x60 / 255f, 0x60 / 255f, 0x60 / 255f, 1);
_text.AddThemeColorOverride("font_outline_color", outline);
_speaker.AddThemeColorOverride("font_outline_color", outline);
break;
}
catch { /* fall back to the default font */ }
@@ -258,6 +220,7 @@ public partial class Main : Godot.Control
double speed = 1.0; // --speed <f>: sleeps + retained presentation clocks
long transitionClickMs = -1; // --transition-click-ms <n>: force active transitions after n virtual ms
bool holdMessageSkip = false; // --hold-message-skip: hold native logical action 6 for diagnostics
string textBackend = "auto"; // --text-backend auto|gdi|portable
string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run
string? pageMapPath = null; // --page-map <jsonl>: override default build/page-map-SCxxxx.jsonl
for (int i = 0; i < userArgs.Length; i++)
@@ -283,6 +246,8 @@ public partial class Main : Godot.Control
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
if (userArgs[i] == "--transition-click-ms" && i + 1 < userArgs.Length) long.TryParse(userArgs[i + 1], out transitionClickMs);
if (userArgs[i] == "--hold-message-skip") holdMessageSkip = true;
if (userArgs[i] == "--text-backend" && i + 1 < userArgs.Length)
textBackend = userArgs[i + 1].ToLowerInvariant();
if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1];
if (userArgs[i] == "--page-map" && i + 1 < userArgs.Length) pageMapPath = userArgs[i + 1];
if (userArgs[i] == "--locator-hud") _locatorHudVisible = true;
@@ -299,6 +264,14 @@ public partial class Main : Godot.Control
}
if (!double.IsFinite(speed) || speed <= 0) speed = 1.0;
if (textBackend is not ("auto" or "gdi" or "portable"))
{
GD.PushError(
$"[startup] unknown --text-backend '{textBackend}'; " +
"expected auto, gdi, or portable");
GetTree().Quit(2);
return;
}
_clock.Speed = System.Math.Clamp(speed, 0.05, 8.0);
GD.Print($"[renderer] retained backend={(_useGpuBackend ? "gpu" : "software")}");
@@ -367,43 +340,68 @@ public partial class Main : Godot.Control
? new ResourceMap(scripts.Catalog, trackedAssetStore)
: new ResourceMap(catalog, _assetStore);
IGlyphMaskRasterizer? surfaceTextRasterizer = null;
string surfaceTextFallbackReason;
PortableTextRenderingPolicy? portableTextPolicy = null;
string? exactUnavailable = null;
try
{
#if AGE_WINDOWS_GDI
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
{
try
if (textBackend != "portable")
{
_exactGlyphRasterizer = new WindowsGdiGlyphMaskRasterizer();
surfaceTextRasterizer = _exactGlyphRasterizer;
surfaceTextFallbackReason = "";
GD.Print(
$"[text] immediate surface strings use {_exactGlyphRasterizer.BackendInfo.Id}: " +
_exactGlyphRasterizer.BackendInfo.Detail);
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
{
try
{
var exact = new WindowsGdiGlyphMaskRasterizer();
surfaceTextRasterizer = exact;
_glyphRasterizerOwner = exact;
}
catch (Exception error) when (textBackend == "auto")
{
exactUnavailable =
$"Exact Windows GDI glyph backend failed to initialize: {error.Message}";
}
}
else exactUnavailable = availability;
}
catch (Exception error)
{
surfaceTextFallbackReason =
$"Exact Windows GDI glyph backend failed to initialize: {error.Message}";
GD.PushWarning($"[text] {surfaceTextFallbackReason}");
}
}
else
{
surfaceTextFallbackReason = availability;
GD.PushWarning(
$"[text] immediate surface strings retain the Label fallback: {availability}");
}
#else
surfaceTextFallbackReason =
"The exact Windows GDI glyph adapter is not part of this platform build.";
GD.PushWarning(
$"[text] immediate surface strings retain the Label fallback: {surfaceTextFallbackReason}");
if (textBackend == "gdi")
exactUnavailable =
"The exact Windows GDI glyph adapter is not part of this platform build.";
#endif
if (surfaceTextRasterizer == null)
{
if (textBackend == "gdi")
throw new PlatformNotSupportedException(
exactUnavailable ?? "Exact Windows GDI text is unavailable.");
portableTextPolicy = PortableTextRenderingPolicy.Load();
var portable =
new GodotTextServerGlyphMaskRasterizer(portableTextPolicy);
surfaceTextRasterizer = portable;
_glyphRasterizerOwner = portable;
}
}
catch (Exception error)
{
GD.PushError($"[startup] text backend selection failed: {error.Message}");
GetTree().Quit(2);
return;
}
var selectedTextBackend =
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo
?? throw new InvalidOperationException(
"Selected text backend does not report its policy.");
GD.Print(
$"[text] gameplay strings use {selectedTextBackend.Id}: " +
selectedTextBackend.Detail);
if (textBackend == "auto" && exactUnavailable != null)
GD.Print($"[text] exact backend unavailable; selected portable: {exactUnavailable}");
_host = new GodotAdvHost(
this, resources, scene, _clock, _locator, logicalCanvas, _timeline,
this, resources, scene, _clock, _locator, logicalCanvas,
surfaceTextRasterizer,
timeline: _timeline,
synchronizeExplicitPresentation: !_selftest,
surfaceTextRasterizer: surfaceTextRasterizer,
surfaceTextFallbackReason: surfaceTextFallbackReason)
surfaceTextMaskCacheCapacity:
portableTextPolicy?.GlyphMaskCacheCapacity ?? 2048)
{
SleepScale = sleepScale,
TraceOps = _gfxLogPath != null,
@@ -567,13 +565,6 @@ public partial class Main : Godot.Control
}
perf?.RecordRecomposeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase);
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
@@ -1044,10 +1035,8 @@ public partial class Main : Godot.Control
DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose();
_gpuRenderer?.Dispose();
#if AGE_WINDOWS_GDI
_exactGlyphRasterizer?.Dispose();
_exactGlyphRasterizer = null;
#endif
_glyphRasterizerOwner?.Dispose();
_glyphRasterizerOwner = null;
if (_perf != null)
{
_perf.Dispose();
@@ -1087,7 +1076,6 @@ public partial class Main : Godot.Control
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
private readonly System.Collections.Generic.List<SurfaceTextDraw> _surfaceTextSnapshot = new();
private void Recomposite()
{
@@ -1134,11 +1122,6 @@ public partial class Main : Godot.Control
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
}
_perf?.BeginRecomposite(screenTransition: false);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
foreach (var label in _surfaceTextLabels) label.Visible = false;
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
int surfaceTextLabelIndex = 0;
_gpuRenderer.BeginFrame(publicationPolicy.AppendGpuLayers);
foreach (var v in _visibleSnapshot)
{
@@ -1197,24 +1180,6 @@ public partial class Main : Godot.Control
resolved.IsDynamic, v.Blend);
}
}
if (rawObject != null)
{
_host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
foreach (var surfaceText in _surfaceTextSnapshot)
{
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
var label = GetSurfaceTextLabel(surfaceTextLabelIndex++);
ApplySurfaceTextTransform(
label, affine, surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY);
label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)),
System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY)));
label.Text = surfaceText.Text;
ApplyAdvTextStyle(label, surfaceText.Style);
label.Visible = true;
}
}
}
var stats = _gpuRenderer.EndFrame();
@@ -1327,25 +1292,22 @@ public partial class Main : Godot.Control
{
System.Array.Clear(_screenPixels);
}
foreach (var label in _surfaceTextLabels) label.Visible = false;
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
int surfaceTextLabelIndex = 0;
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
if (hasScreenTransition)
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
CompositeVisibleObjects(transition.Source, 1f, decisions);
FillQuad(0, 0, _screenWidth, _screenHeight, 0, (float)transition.Progress);
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
ref surfaceTextLabelIndex, decisions, false);
CompositeVisibleObjects(transition.Target, (float)transition.Progress, decisions);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
else
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true);
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, decisions);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
@@ -1360,10 +1322,10 @@ public partial class Main : Godot.Control
_perf?.EndRecomposite();
}
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
ref int surfaceTextLabelIndex,
System.Collections.Generic.Dictionary<long, string>? decisions,
bool includeSurfaceText)
private void CompositeVisibleObjects(
IReadOnlyList<RenderObject> visible,
float globalOpacity,
System.Collections.Generic.Dictionary<long, string>? decisions)
{
int z = 0;
foreach (var v in visible) // interpolate at the retained-presentation clock
@@ -1454,247 +1416,10 @@ public partial class Main : Godot.Control
}
}
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
if (includeSurfaceText && rawObject != null)
{
_host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
foreach (var surfaceText in _surfaceTextSnapshot)
{
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
var label = GetSurfaceTextLabel(surfaceTextLabelIndex++);
ApplySurfaceTextTransform(
label, localToDest, surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY);
label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)),
System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY)));
label.Text = surfaceText.Text;
ApplyAdvTextStyle(label, surfaceText.Style);
label.Visible = true;
}
}
z++;
}
}
private void UpdateAdvTextPresentation()
{
// Modal callback scripts composite their own full-screen UI while the enclosing ADV wait remains
// parked. Live layout text is a Godot overlay rather than part of the retained surface. During a
// raw-input callback, keep runs owned by that callback's script stack (STUDY -> MAMES) while hiding
// enclosing ADV runs that would otherwise leak above a nested screen such as HISTORY. Native
// retained-glyph erases remove ordinary live runs through the host before this presentation pass.
foreach (var label in _advTextLabels) label.Visible = false;
if (_host.IsAdvPagePresentationSuspended) return;
int labelIndex = 0;
string? rawInputOwner = _vm.RawInputCallbackScriptName;
var snapshots = _host.SnapshotLiveAdvText();
for (int i = 0; i < snapshots.Count; i++)
{
var snapshot = snapshots[i];
var run = snapshot.Run;
if (run.Text.Length == 0 || !ShouldShow(run)) continue;
var visibleText = new System.Text.StringBuilder();
AppendVisible(snapshot);
while (i + 1 < snapshots.Count
&& snapshots[i + 1].Run.Layout == run.Layout
&& snapshots[i + 1].Run.Style == run.Style
&& ShouldShow(snapshots[i + 1].Run))
{
i++;
AppendVisible(snapshots[i]);
}
var label = GetAdvTextLabel(labelIndex++);
var layout = run.Layout;
label.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
label.Size = new Vector2(System.Math.Max(1, layout.Right - layout.CursorX),
System.Math.Max(1, layout.Bottom - layout.CursorY));
label.Text = visibleText.ToString();
ApplyAdvTextStyle(label, run.Style);
label.Visible = true;
void AppendVisible(LiveAdvTextSnapshot item)
{
int count = System.Math.Clamp(item.VisibleGlyphs, 0, item.Run.Text.Length);
if (count != 0) visibleText.Append(item.Run.Text, 0, count);
}
bool ShouldShow(AdvLiveTextRun item)
=> rawInputOwner == null || item.BelongsToScript(rawInputOwner);
}
}
private void UpdateHistoryTextPresentation()
{
foreach (var label in _historyTextLabels.Values) label.Visible = false;
foreach (var batch in _host.SnapshotRenderedTextHistory())
{
if (batch.Text.Length == 0) continue;
if (!_historyTextLabels.TryGetValue(batch.LayoutSlot, out var label))
{
label = CreateAdvPresentationLabel();
_historyTextLabels.Add(batch.LayoutSlot, label);
}
var layout = batch.Layout;
label.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
label.Size = new Vector2(System.Math.Max(1, layout.Right - layout.CursorX),
System.Math.Max(1, layout.Bottom - layout.CursorY));
label.Text = batch.Text;
ApplyAdvTextStyle(label, batch.Style);
label.Visible = true;
}
}
private Label GetSurfaceTextLabel(int index)
{
while (_surfaceTextLabels.Count <= index) _surfaceTextLabels.Add(CreateAdvPresentationLabel());
return _surfaceTextLabels[index];
}
private Label GetAdvTextLabel(int index)
{
while (_advTextLabels.Count <= index) _advTextLabels.Add(CreateAdvPresentationLabel());
return _advTextLabels[index];
}
private static void ApplySurfaceTextTransform(Label label, Affine2D localToDestination,
int localX, int localY)
{
var position = localToDestination.Apply(localX, localY);
var (rotation, scaleX, scaleY) = localToDestination.DecomposeCanvasAxes();
label.Position = new Vector2((float)position.X, (float)position.Y);
label.PivotOffset = Vector2.Zero;
label.Rotation = (float)rotation;
label.Scale = new Vector2((float)scaleX, (float)scaleY);
}
private Label CreateAdvPresentationLabel()
{
var label = new Label
{
MouseFilter = MouseFilterEnum.Ignore,
Visible = false,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
ClipText = true,
};
label.AddThemeFontOverride("font", _text.GetThemeFont("font"));
AddChild(label);
return label;
}
private void ApplyAdvTextStyle(Label label, AdvTextStyle style)
{
int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
Font regularFont = ResolvePresentationFont(style.FontFace, out string fontKey);
Font presentationFont = regularFont;
if (style.Bold)
{
if (!_presentationBoldFonts.TryGetValue(fontKey, out FontVariation? boldFont))
{
boldFont = new FontVariation
{
BaseFont = regularFont,
VariationEmbolden = NativeBoldEmbolden,
SpacingGlyph = NativeBoldGlyphSpacing,
};
_presentationBoldFonts.Add(fontKey, boldFont);
}
presentationFont = boldFont;
}
label.AddThemeFontOverride("font", presentationFont);
label.AddThemeFontSizeOverride("font_size", fontSize);
label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White));
Color effectColor = RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f));
AdvTextEffectTheme effect = ResolveAdvTextEffectTheme(style);
label.AddThemeColorOverride("font_outline_color", effectColor);
label.AddThemeColorOverride("font_shadow_color", effect.ShadowEnabled ? effectColor : Colors.Transparent);
label.AddThemeConstantOverride("line_spacing",
CalibratedLineSpacing(style.LineSpacing, fontSize, presentationFont.GetHeight(fontSize)));
label.AddThemeConstantOverride("outline_size", effect.OutlineSize);
label.AddThemeConstantOverride("shadow_offset_x", effect.ShadowOffsetX);
label.AddThemeConstantOverride("shadow_offset_y", effect.ShadowOffsetY);
label.AddThemeConstantOverride("shadow_outline_size", 0);
}
private static int CalibratedLineSpacing(int nativeSpacing, int nativeFontHeight, float backendFontHeight)
=> nativeSpacing + nativeFontHeight - System.Math.Max(1, (int)System.MathF.Round(backendFontHeight));
private readonly record struct AdvTextEffectTheme(
int OutlineSize,
bool ShadowEnabled,
int ShadowOffsetX,
int ShadowOffsetY);
private static AdvTextEffectTheme ResolveAdvTextEffectTheme(AdvTextStyle style)
=> style.RenderMode switch
{
// AGE mode 1 draws one effect-color glyph at (+x,+y), then the primary glyph.
1 => new(0, true, style.EffectOffsetX, style.EffectOffsetY),
// AGE mode 3 samples an ellipse. Himegari always uses equal (1,1) radii, for which
// Godot's symmetric outline is the closest backend-native presentation.
3 => new(System.Math.Max(System.Math.Abs(style.EffectOffsetX),
System.Math.Abs(style.EffectOffsetY)),
false, 0, 0),
_ => new(0, false, 0, 0),
};
private Font ResolvePresentationFont(string requestedFace, out string fontKey)
{
_presentationFallbackFont ??= _text.GetThemeFont("font");
string face = requestedFace?.Trim() ?? "";
if (face.Length == 0)
{
fontKey = "";
return _presentationFallbackFont;
}
if (_presentationFaceFonts.TryGetValue(face, out Font? cached))
{
fontKey = face;
return cached;
}
if (!_unavailablePresentationFaces.Contains(face))
{
foreach (string path in PresentationFontPaths(face))
{
if (!System.IO.File.Exists(path)) continue;
try
{
var loaded = new FontFile { Data = System.IO.File.ReadAllBytes(path) };
_presentationFaceFonts.Add(face, loaded);
fontKey = face;
return loaded;
}
catch
{
// Try the next known file before falling back to the presentation default.
}
}
_unavailablePresentationFaces.Add(face);
}
fontKey = "";
return _presentationFallbackFont;
}
private static IEnumerable<string> PresentationFontPaths(string face)
{
if (face.Equals(" 明朝", System.StringComparison.OrdinalIgnoreCase)
|| face.Equals("MS Mincho", System.StringComparison.OrdinalIgnoreCase))
{
yield return "C:/Windows/Fonts/msmincho.ttc";
}
else if (face.Equals(" ゴシック", System.StringComparison.OrdinalIgnoreCase)
|| face.Equals("MS Gothic", System.StringComparison.OrdinalIgnoreCase))
{
yield return "C:/Windows/Fonts/msgothic.ttc";
}
}
private static Color RgbColor(long rgb, Color fallback)
{
if ((rgb & 0x00ff_ffff) == 0) return fallback;
return new Color(((rgb >> 16) & 0xff) / 255f, ((rgb >> 8) & 0xff) / 255f, (rgb & 0xff) / 255f, 1);
}
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
=> state is { } c
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
@@ -2289,7 +2014,6 @@ public partial class Main : Godot.Control
}
}
public void AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak()
{
_pageCount++;
@@ -2298,7 +2022,6 @@ public partial class Main : Godot.Control
}
public void ClearPage()
{
foreach (var label in _advTextLabels) label.Text = "";
_status.Text = "";
}
public void ShowEnd() => _status.Text = "— end —";
@@ -2372,71 +2095,29 @@ public partial class Main : Godot.Control
&& !_bgm.Playing
&& _bgmFadeTween == null
&& System.Math.Abs(_bgm.VolumeDb) < 0.001f;
AdvTextEffectTheme mode1 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
{
RenderMode = 1,
EffectOffsetX = 0,
EffectOffsetY = 0,
});
AdvTextEffectTheme mode3 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
{
RenderMode = 3,
EffectOffsetX = 1,
EffectOffsetY = 1,
});
AdvTextEffectTheme mode0 = ResolveAdvTextEffectTheme(AdvTextStyle.Default);
bool textEffectModesOk = mode1 == new AdvTextEffectTheme(0, true, 0, 0)
&& mode3 == new AdvTextEffectTheme(1, false, 0, 0)
&& mode0 == new AdvTextEffectTheme(0, false, 0, 0);
var textEffectSmoke = new Label();
AddChild(textEffectSmoke);
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
{
RenderMode = 1,
EffectColor = 0x123456,
EffectOffsetX = 2,
EffectOffsetY = -1,
});
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 0
&& textEffectSmoke.GetThemeConstant("shadow_offset_x") == 2
&& textEffectSmoke.GetThemeConstant("shadow_offset_y") == -1
&& textEffectSmoke.GetThemeColor("font_shadow_color").A > 0.99f;
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
{
RenderMode = 3,
EffectColor = 0x123456,
EffectOffsetX = 1,
EffectOffsetY = 1,
});
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 1
&& textEffectSmoke.GetThemeColor("font_shadow_color").A < 0.01f;
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
{
PrimaryFontSize = 24,
Bold = true,
FontFace = " 明朝",
LineSpacing = 8,
});
var calibratedBold = textEffectSmoke.GetThemeFont("font") as FontVariation;
bool fontCalibrationOk = calibratedBold != null
&& System.Math.Abs(calibratedBold.VariationEmbolden
- NativeBoldEmbolden) < 0.001f
&& calibratedBold.SpacingGlyph == NativeBoldGlyphSpacing
&& CalibratedLineSpacing(8, 24, 25) == 7
&& CalibratedLineSpacing(9, 16, 17) == 8
&& textEffectSmoke.GetThemeConstant("line_spacing")
== CalibratedLineSpacing(
8, 24, calibratedBold.GetHeight(24));
IReadOnlyList<(int X, int Y)> outline =
AgeGlyphMaskCompositor.GetMode3OutlineOffsets(1, 1);
bool textEffectModesOk =
outline.Count == 12
&& outline.Contains((1, 0))
&& outline.Contains((-1, 0))
&& outline.Contains((0, 1))
&& outline.Contains((0, -1));
bool fontCalibrationOk =
ImmediateSurfaceTextRenderer.NativePixelHeight(24) == 24
&& ImmediateSurfaceTextRenderer.NativePixelHeight(25) == 24
&& ImmediateSurfaceTextRenderer.NativePixelHeight(32) == 31
&& ImmediateSurfaceTextRenderer.NativePixelHeight(33) == 31;
bool immediateSurfaceTextOk;
string immediateSurfaceTextMode;
_host.CreateTexture(997, 32, 24);
_host.CreateTexture(997, 64, 24);
var immediateStyle = AdvTextStyle.Default with
{
PrimaryFontSize = 16,
TextColor = 0xffffff,
FontFace = ImmediateSurfaceTextRenderer.DefaultFontFace,
};
_host.DrawStringToSurface(997, 1, 1, "A", immediateStyle);
_host.DrawStringToSurface(997, 1, 1, "A", immediateStyle);
if (_host.UsesSurfaceTextPixels)
{
RgbaImage? pixels = _host.CaptureSurfacePixels(997);
@@ -2459,40 +2140,44 @@ public partial class Main : Godot.Control
gpuAccepted &= stats.DrawItems == 1 && stats.TextureUploads == 1;
}
_host.CreateTexture(996, 32, 24);
_host.CreateTexture(996, 64, 24);
_host.CopySurfaceRect(new SurfaceRectCopy(
997, 996, 0, 0, 32, 24, 0, 0));
997, 996, 0, 0, 64, 24, 0, 0));
RgbaImage? copied = _host.CaptureSurfacePixels(996);
bool copiedPixels = pixels != null
&& copied != null
&& pixels.Pixels.SequenceEqual(copied.Pixels);
_host.FillSurfaceRect(new SurfaceRectFill(
996, 0, 0, 32, 24, 0, 0));
996, 0, 0, 64, 24, 0, 0));
RgbaImage? cleared = _host.CaptureSurfacePixels(996);
bool clearedPixels = cleared != null && cleared.Pixels.All(value => value == 0);
immediateSurfaceTextOk =
pixelsPresent
&& _host.SnapshotSurfaceText(997).Count == 0
&& backend is
{
Id: "windows-gdi-gray4",
Policy: GlyphRasterPolicy.NativeCp932Gray4,
NativePixelExact: true,
}
&& backend is not null
&& (backend.Policy == GlyphRasterPolicy.NativeCp932Gray4
? backend is
{
Id: "windows-gdi-gray4",
NativePixelExact: true,
}
: backend is
{
Id: "portable-godot-textserver",
Policy: GlyphRasterPolicy.PortableUnicode,
NativePixelExact: false,
})
&& cache.Count is > 0 and <= 2048
&& cache.Capacity == 2048
&& gpuAccepted
&& copiedPixels
&& clearedPixels;
immediateSurfaceTextMode = "exact-rgba";
immediateSurfaceTextMode =
$"rgba:{backend?.Id}";
}
else
{
immediateSurfaceTextOk =
_host.CaptureSurfacePixels(997)?.Pixels.All(value => value == 0) == true
&& _host.SnapshotSurfaceText(997).Count == 1
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
immediateSurfaceTextMode = "label-fallback";
immediateSurfaceTextOk = false;
immediateSurfaceTextMode = "unavailable";
}
_host.ReleaseSurface(996);
_host.ReleaseSurface(997);
@@ -2519,7 +2204,6 @@ public partial class Main : Godot.Control
&& liveSurface != null
&& liveSurface.Pixels.Where((_, index) => index % 4 == 3)
.Any(alpha => alpha != 0)
&& _host.SnapshotLiveAdvText().Count == 0
&& _vm.TextHistory.GetLayoutSnapshot(1).CursorX
> liveBinding.ResetCursorX;
@@ -2556,14 +2240,15 @@ public partial class Main : Godot.Control
&& suspended
&& restored
&& reset;
liveRetainedTextMode = "retained-glyphs";
liveRetainedTextMode =
$"retained-glyphs;built={builtCompleteLine};objects={liveObjects.Count};" +
$"republished={republishedPartialErase};suspended={suspended};" +
$"restored={restored};reset={reset}";
}
else
{
liveRetainedTextOk =
_host.SnapshotLiveAdvText().Count == 3
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
liveRetainedTextMode = "label-fallback";
liveRetainedTextOk = false;
liveRetainedTextMode = "unavailable";
}
_vm.TextHistory.DefineLayout(2, 256, 64, 10, 100);
_vm.TextHistory.SetResetCursor(2, 1, 1);
@@ -2603,8 +2288,7 @@ public partial class Main : Godot.Control
== historyBatch.Text.Length
&& historySurface != null
&& historySurface.Pixels.Where((_, index) => index % 4 == 3)
.Any(alpha => alpha != 0)
&& _host.SnapshotRenderedTextHistory().Count == 0;
.Any(alpha => alpha != 0);
_vm.Gfx.EraseRange(
historyBinding.FirstObjectHandle,
historyBinding.ObjectCapacity);
@@ -2618,15 +2302,14 @@ public partial class Main : Godot.Control
< historyBinding.ObjectCapacity)
&& _host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot)
?.Pixels.All(value => value == 0) == true;
historyRetainedTextMode = "retained-glyphs";
historyRetainedTextMode =
$"retained-glyphs;used={historyUsedRetained}";
}
else
{
historyRetainedTextOk =
!historyUsedRetained
&& _host.SnapshotRenderedTextHistory().Count == 1;
historyRetainedTextOk = false;
_host.EndTextHistoryPresentation(_vm.Gfx);
historyRetainedTextMode = "label-fallback";
historyRetainedTextMode = "unavailable";
}
Window rootWindow = GetTree().Root;
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
@@ -2661,7 +2344,6 @@ public partial class Main : Godot.Control
_vm.Gfx, _clock.NowMs, backbufferSnapshot);
backbufferPreservationOk &=
clearedPolicy == new BackbufferPublicationPolicy(false, false);
textEffectSmoke.QueueFree();
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
&& firstRiffBoundaryOk
&& bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
public sealed class PortableTextRenderingPolicy
{
public const string ResourcePath = "res://config/himegari-text-rendering.json";
public string Id { get; set; } = "";
public int FontCacheCapacity { get; set; } = 8;
public int GlyphMaskCacheCapacity { get; set; } = 2048;
public PortableFontRasterPolicy Raster { get; set; } = new();
public string[] DefaultSubstitutes { get; set; } = [];
public PortableFontFamilyPolicy[] Families { get; set; } = [];
public static PortableTextRenderingPolicy Load()
{
string json = Godot.FileAccess.GetFileAsString(ResourcePath);
if (string.IsNullOrWhiteSpace(json))
throw new InvalidOperationException(
$"Portable text policy '{ResourcePath}' is missing or empty.");
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
PortableTextRenderingPolicy policy =
JsonSerializer.Deserialize<PortableTextRenderingPolicy>(json, options)
?? throw new InvalidOperationException(
$"Portable text policy '{ResourcePath}' decoded to null.");
policy.Validate();
return policy;
}
public string[] ResolveFamilies(string requestedFace)
{
string requested = requestedFace?.Trim() ?? "";
PortableFontFamilyPolicy? mapped = Families.FirstOrDefault(
family => family.Requested.Any(
alias => alias.Equals(requested, StringComparison.OrdinalIgnoreCase)));
IEnumerable<string> candidates = mapped?.Substitutes ?? DefaultSubstitutes;
// The authored face remains the last candidate. This preserves a useful match on systems
// that happen to provide it without making that Windows font a portable dependency.
return candidates
.Append(requested)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private void Validate()
{
if (string.IsNullOrWhiteSpace(Id))
throw new InvalidOperationException("Portable text policy requires a non-empty id.");
if (FontCacheCapacity <= 0)
throw new InvalidOperationException("Portable text fontCacheCapacity must be positive.");
if (GlyphMaskCacheCapacity <= 0)
throw new InvalidOperationException(
"Portable text glyphMaskCacheCapacity must be positive.");
if (DefaultSubstitutes.Length == 0)
throw new InvalidOperationException(
"Portable text policy requires at least one default substitute.");
Raster.Validate();
foreach (PortableFontFamilyPolicy family in Families)
{
if (family.Requested.Length == 0 || family.Substitutes.Length == 0)
throw new InvalidOperationException(
"Every portable font family mapping requires requested aliases and substitutes.");
}
}
}
public sealed class PortableFontFamilyPolicy
{
public string[] Requested { get; set; } = [];
public string[] Substitutes { get; set; } = [];
}
public sealed class PortableFontRasterPolicy
{
public string Antialiasing { get; set; } = "gray";
public string Hinting { get; set; } = "normal";
public string SubpixelPositioning { get; set; } = "disabled";
public bool AllowSystemFallback { get; set; } = true;
public bool MultichannelSignedDistanceField { get; set; }
public int RegularWeight { get; set; } = 400;
public int BoldWeight { get; set; } = 700;
public int BoldThreshold { get; set; } = 700;
public void Validate()
{
if (Antialiasing is not ("none" or "gray" or "lcd"))
throw new InvalidOperationException(
"Portable raster antialiasing must be none, gray, or lcd.");
if (Hinting is not ("none" or "light" or "normal"))
throw new InvalidOperationException(
"Portable raster hinting must be none, light, or normal.");
if (SubpixelPositioning is not ("disabled" or "auto" or "one-half" or "one-quarter"))
throw new InvalidOperationException(
"Portable raster subpixelPositioning has an unsupported value.");
if (RegularWeight is < 100 or > 999
|| BoldWeight is < 100 or > 999
|| BoldThreshold is < 0 or > 1000)
throw new InvalidOperationException(
"Portable raster weights are outside their valid ranges.");
}
}

View File

@@ -0,0 +1,47 @@
{
"id": "himegari-godot-system-font-v1",
"fontCacheCapacity": 8,
"glyphMaskCacheCapacity": 2048,
"raster": {
"antialiasing": "gray",
"hinting": "normal",
"subpixelPositioning": "disabled",
"allowSystemFallback": true,
"multichannelSignedDistanceField": false,
"regularWeight": 400,
"boldWeight": 700,
"boldThreshold": 700
},
"defaultSubstitutes": [
"Noto Sans CJK JP",
"Noto Sans JP",
"Yu Gothic",
"Hiragino Kaku Gothic ProN",
"IPAGothic",
"sans-serif"
],
"families": [
{
"requested": [ " 明朝", "MS Mincho" ],
"substitutes": [
"Noto Serif CJK JP",
"Noto Serif JP",
"Yu Mincho",
"Hiragino Mincho ProN",
"IPAMincho",
"serif"
]
},
{
"requested": [ " ゴシック", "MS Gothic" ],
"substitutes": [
"Noto Sans CJK JP",
"Noto Sans JP",
"Yu Gothic",
"Hiragino Kaku Gothic ProN",
"IPAGothic",
"sans-serif"
]
}
]
}