diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 5138e12..af39a84 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -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-.jsonl` files are produced by normal Godot runs and map runtime ADV diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 1ac12db..82627b8 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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 diff --git a/docs/platform-portability.md b/docs/platform-portability.md index ec4b913..447ba88 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -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 `MS 明朝`/`MS ゴシック` 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 | diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index e2f74c4..194f5e1 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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:** diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 5ce60f6..9c4e6a5 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -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 ` — 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 ` / `--window-height ` — 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, diff --git a/engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs b/engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs index 4117577..b832e82 100644 --- a/engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs +++ b/engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs @@ -64,6 +64,30 @@ public class ImmediateSurfaceTextRendererTests Assert.All(destination.Pixels, value => Assert.Equal(0, value)); } + [Fact] + public void PortableRequestsPreserveUnicodeOutsideCp932() + { + IReadOnlyList 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)] diff --git a/engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs b/engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs index 7a86f8f..1be823f 100644 --- a/engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs +++ b/engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs @@ -10,8 +10,8 @@ public readonly record struct ImmediateSurfaceTextResult( int CursorY); /// -/// 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. /// 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 requests = CreateRequests(text, style); + IReadOnlyList 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 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; } diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 7cd9c48..92fa213 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -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> _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 _historyText = new(); + private readonly CachedGlyphMaskRasterizer _surfaceTextMaskCache; + private readonly ImmediateSurfaceTextRenderer _surfaceTextPixelRenderer; + private readonly RetainedGlyphLayoutEngine _retainedGlyphLayoutEngine; + private readonly GlyphRasterizerBackendInfo _surfaceTextBackendInfo; private readonly HashSet _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()), 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 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 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(); - 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 SnapshotLiveAdvText() - { - lock (_textLock) - { - var snapshot = new List(_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; - } - } - /// - /// 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. /// public int AdvPageLayoutSlot { @@ -694,28 +518,11 @@ public sealed class GodotAdvHost : IHost } } - public IReadOnlyList SnapshotSurfaceText(int surfaceSlot) - { - lock (_textLock) - return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty(); - } - - public void SnapshotSurfaceText(int surfaceSlot, List 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()); - 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 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 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 visible, long firstHandle, long count, - int targetSlot, int targetWidth, int targetHeight) - { - List projected; - lock (_textLock) - projected = _surfaceText.TryGetValue(targetSlot, out var retained) - ? new List(retained) - : new List(); - - 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? source; - lock (_textLock) - source = _surfaceText.TryGetValue(raw.SourceSlot, out var draws) - ? new List(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 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, diff --git a/godot/GodotTextServerGlyphMaskRasterizer.cs b/godot/GodotTextServerGlyphMaskRasterizer.cs new file mode 100644 index 0000000..45a9832 --- /dev/null +++ b/godot/GodotTextServerGlyphMaskRasterizer.cs @@ -0,0 +1,180 @@ +using System; +using Age.Engine.Text; +using Godot; + +/// +/// Portable, explicitly non-pixel-exact glyph masks obtained from Godot's TextServer atlas. +/// AGE still owns layout, effects, blending, retained ordering, and surface lifetime. +/// +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 _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( + 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}'."); +} diff --git a/godot/Main.cs b/godot/Main.cs index 25550e1..71d6a39 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -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