From c2b72cd4b988061b1f2fa1bb5fb32f700d89f596 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 30 Jul 2026 18:47:35 -0400 Subject: [PATCH] engine: render immediate text into surfaces --- docs/PROJECT-STRUCTURE.md | 5 +- docs/engine-re.md | 39 ++-- docs/platform-portability.md | 2 +- docs/remake-architecture-and-roadmap.md | 16 +- .../ImmediateSurfaceTextRendererTests.cs | 181 ++++++++++++++++++ .../Text/ImmediateSurfaceTextRenderer.cs | 103 ++++++++++ godot/GodotAdvHost.cs | 113 ++++++++++- godot/Himegari.csproj | 6 + godot/Main.cs | 120 +++++++++++- 9 files changed, 563 insertions(+), 22 deletions(-) create mode 100644 engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs create mode 100644 engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index d6924d2..b6a9b91 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -115,8 +115,9 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest, │ and bootstrap/build scripts (outputs stay under disposable build/) ├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md) - └── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine), - including the TITLE-only F4 debug scene launcher + └── 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 ``` The disposable `build/page-map-.jsonl` files are produced by normal Godot runs and map runtime ADV diff --git a/docs/engine-re.md b/docs/engine-re.md index bad38ce..e668dc9 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2961,14 +2961,14 @@ pixels differ. The screenshot is useful evidence that the difference is visible, behavioral source. The fidelity correction is consequently a decoded glyph-mask backend, not screenshot-driven embolden -calibration. A Windows reference implementation can call the same GDI APIs with the decoded `LOGFONTA` -and reproduce AGE's integer compositor. A portable backend must expose the same mask/metrics/compositing -contract while explicitly defining its font-substitution and rasterizer policy; FreeType output should -be treated as that backend's result, not claimed to be GDI-equivalent. Godot's one-pixel line-box -compensation remains independently supported by the matching viewport placement. Portable configurable -face substitutions/defaults remain deferred to the broader runtime configuration design. Implementation -is explicitly backlogged until gameplay settles; the scoped architecture and acceptance gates live in -`docs/remake-architecture-and-roadmap.md` under “AGE-exact glyph-mask text renderer.” +calibration. The Windows implementation calls the same GDI APIs with the decoded `LOGFONTA` and reproduces +AGE's integer compositor. A portable backend must expose the same mask/metrics/compositing contract while +explicitly defining its font-substitution and rasterizer policy; FreeType output should be treated as that +backend's result, not claimed to be GDI-equivalent. Godot's one-pixel line-box compensation remains +independently supported by the matching viewport placement. Portable configurable face +substitutions/defaults remain deferred to the broader runtime configuration design. The scoped architecture +and acceptance gates live in `docs/remake-architecture-and-roadmap.md` under “AGE-exact glyph-mask text +renderer.” The platform-neutral half of that correction landed on 2026-07-30 under `Age.Engine.Text`. `GlyphRasterRequest` carries face, positive pixel height, native requested width, weight, Unicode scalar, @@ -2982,8 +2982,8 @@ and returns the five-dword edge records plus final cursor, observed overflow, wr state. It preserves native horizontal precedence: vertical-only overflow stops before compositing, while a simultaneous horizontal overflow follows the wrap/kinsoku path. A reusable bounded LRU provides eviction callbacks for future native font handles; `CachedGlyphMaskRasterizer` applies the same bound to masks. This -core is not yet connected to ops `0x204`/`0x205`, live ADV, History, or Godot, so the Label backend and -visible behavior remain unchanged until the later integration steps. +core is not yet connected to live ADV or History; those paths still use Labels pending their retained-glyph +materialization steps. The independent Windows reference backend landed alongside, but outside, the neutral engine as `Age.Engine.Text.Windows.WindowsGdiGlyphMaskRasterizer`. It holds one `CreateICA("DISPLAY")` information @@ -2998,7 +2998,24 @@ code-page incompatibility and the constructor refuses to masquerade as exact whe On the reference installation, three test requests—24px regular Mincho `あ`, 24px weight-700 Mincho `姫`, and 16px weight-700 Gothic `ア`—match an independently created Unicode GDI font/DC call byte-for-byte in coverage and exactly in black box, aligned stride, glyph origin, cell extent, and advance. The backend is -not wired into Godot yet, so this gate validates mask provenance without changing visible rendering. +now selected by Godot for immediate surface strings when `TryGetAvailability` confirms Windows ACP 932. + +`ImmediateSurfaceTextRenderer` builds the complete CP932 request list before touching the destination, +making an unsupported character an atomic fallback rather than a partially rasterized string. It applies +the decoded LOGFONT rebuild rules (default 24px Mincho, odd heights rounded down, 32/33 mapped to 31, +negative half-width, and weight 700 only for bold), then advances from GDI's returned cell metrics while +the shared compositor writes into a cloned `RgbaImage`. `GodotAdvHost.DrawStringToSurface` publishes that +snapshot for ops `0x204` and `0x205`; successful exact draws add no `SurfaceTextDraw` metadata. Text on a +bound surface is therefore ordinary texture data in both software and GPU retained renderers: later handles +can occlude it, and the owning object's alpha/tint, animated fade, affine transform, source rectangle, +offscreen capture, fill/copy, and preservation behavior apply without a detached overlay. A bounded +2,048-mask cache sits above the already bounded GDI font cache. + +If the exact backend is unavailable, string conversion fails, or the target surface cannot be resolved, +the host records one explicit diagnostic and retains the old metadata/Label projection for that draw. The +fallback is constructed before surface publication, so it cannot mix a partial new raster with the Label. +Live ADV, History, and the wait indicator remain on their existing presentation paths for the later staged +migration. #### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11) diff --git a/docs/platform-portability.md b/docs/platform-portability.md index 5419edb..42af78f 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 | Live presentation still uses `godot/Main.cs` to load Windows `MS 明朝`/`MS ゴシック` when available and otherwise choose the existing Japanese-font/default fallback; its `Label` path substitutes FreeType embolden and a Godot outline. `Age.Engine.Text` remains OS-neutral. The separate optional `Age.Engine.Text.Windows` reference library now owns the authored GDI calls: `CreateICA`, bounded `CreateFontIndirectA` handles, `GetTextExtentPoint32A`, and `GetGlyphOutlineA(GGO_GRAY4_BITMAP)` | Visible behavior is unchanged because the reference backend is not wired into Godot. It explicitly requires Windows ACP 932 and reports unavailable elsewhere. Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle; synthetic masks continue to test the common compositor on every platform | Integrate immediate surface pixels against this exact Windows reference, then add an explicitly non-identical portable backend/face-substitution policy. Keep the GDI project optional and out of non-Windows deliverables rather than hiding failed exact selection | +| ADV font discovery/raster fidelity | `Age.Engine.Text` remains OS-neutral. Godot now selects the separate `Age.Engine.Text.Windows` backend for immediate `0x204`/`0x205` strings when Windows ACP 932 is available; exact masks are cached and composited into numbered RGBA surfaces. Live ADV and History still use the existing `Label` path, which loads Windows `MS 明朝`/`MS ゴシック` when available and otherwise chooses the Japanese/default fallback with FreeType embolden and a Godot outline | Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle. Immediate surface text now obeys retained ordering, alpha/tint/fade, transforms, clipping, capture, and surface mutation in both render paths. Non-Windows/non-932 runs report why exact selection failed and atomically retain the old surface-label projection; they do not claim GDI parity | Add an explicitly non-identical portable backend and face-substitution policy, then migrate live ADV/History and remove Label compensations. Keep the GDI project out of non-Windows deliverables rather than hiding failed exact selection | | 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 9759730..0eb5d81 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -601,11 +601,17 @@ primitive, which cannot be made equivalent by choosing another embolden constant representative regular/bold Mincho and bold Gothic requests byte-match an independent Unicode GDI oracle for coverage, stride, `GLYPHMETRICS`, extent, and advance. This reference backend remains opt-in and is not yet connected to live presentation; the existing Label path therefore remains the current fallback. -4. **Move immediate surface strings first.** Make ops `0x204`/`0x205` rasterize directly into the numbered - RGBA surface instead of appending `SurfaceTextDraw` metadata. Remove the separate surface-label projection - only after GPU and software paths prove that ordinary handle order, object alpha/tint, affine transforms, - source clipping, offscreen `0x222` captures, fills/copies, and backbuffer preservation all consume the same - pixels. This is the smallest integration slice and directly fixes the reported surface-fade defect. +4. **Move immediate surface strings first.** Completed 2026-07-30 for the exact Windows path. Ops + `0x204`/`0x205` now convert the complete Unicode string to explicit CP932 glyph identities before changing + any pixels, derive AGE's rebuilt negative height/half-width and weight, and composite the exact masks into + a cloned numbered `RgbaImage` snapshot. Successful draws publish no `SurfaceTextDraw`, so both retained + renderers consume the same pixels in ordinary handle order and naturally apply object alpha/tint, affine + transforms, source clipping, capture, fill/copy, and backbuffer preservation. Synthetic integration tests + pin later-handle occlusion, source clipping, scale/translation, static alpha/tint, animated fade, capture, + copy, and clear. The Godot self-test additionally exercises the GDI backend, 2,048-entry mask-cache bound, + dynamic GPU upload, and surface mutation path. Backend absence, an unrepresentable glyph, or an unresolved + surface falls back atomically to the prior metadata/Label projection with an explicit diagnostic; that + projection remains only for this temporary unavailable-backend case until the portable backend lands. 5. **Materialize live ADV glyphs as ordinary retained objects.** Extend the transient layout-presentation binding with its source surface, first handle, and capacity without changing the persisted History record ABI. Build the complete line before reveal, update the canonical live cursor from measured metrics, then diff --git a/engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs b/engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs new file mode 100644 index 0000000..4117577 --- /dev/null +++ b/engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs @@ -0,0 +1,181 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Text; + +public class ImmediateSurfaceTextRendererTests +{ + private sealed class RecordingRasterizer : IGlyphMaskRasterizer + { + public List Requests { get; } = new(); + + public GlyphMask Rasterize(GlyphRasterRequest request) + { + Requests.Add(request); + return new GlyphMask( + width: 1, height: 1, stride: 1, + originX: 0, originY: request.PixelHeight, + cellAdvanceX: request.Cp932Code <= 0x7f ? 2 : 4, + cellAdvanceY: 0, + cellWidth: request.Cp932Code <= 0x7f ? 2 : 4, + cellHeight: request.PixelHeight, + coverage: [16]); + } + } + + [Fact] + public void ImmediateStringBuildsExactCp932RequestsAndAdvancesFromGdiMetrics() + { + var rasterizer = new RecordingRasterizer(); + var renderer = new ImmediateSurfaceTextRenderer(rasterizer); + var destination = Image(16, 8); + AdvTextStyle style = Style() with + { + PrimaryFontSize = 5, + Bold = true, + FontFace = "MS ゴシック", + }; + + ImmediateSurfaceTextResult result = + renderer.Render(destination, 1, 2, "Aあ", style); + + Assert.Equal(new ImmediateSurfaceTextResult(2, 7, 2), result); + Assert.Collection(rasterizer.Requests, + ascii => Assert.Equal( + ("MS ゴシック", 4, -2, 700, 0x41, (ushort)0x41), + RequestIdentity(ascii)), + japanese => Assert.Equal( + ("MS ゴシック", 4, -2, 700, 0x3042, (ushort)0x82a0), + RequestIdentity(japanese))); + Assert.Equal((255, 255, 255, 255), Pixel(destination, 1, 2)); + Assert.Equal((255, 255, 255, 255), Pixel(destination, 3, 2)); + } + + [Fact] + public void UnsupportedCp932CharacterFailsBeforeRasterizationOrPixelMutation() + { + var rasterizer = new RecordingRasterizer(); + var renderer = new ImmediateSurfaceTextRenderer(rasterizer); + var destination = Image(8, 8); + + Assert.Throws( + () => renderer.Render(destination, 0, 0, "A😀", Style())); + + Assert.Empty(rasterizer.Requests); + Assert.All(destination.Pixels, value => Assert.Equal(0, value)); + } + + [Theory] + [InlineData(24, 24)] + [InlineData(25, 24)] + [InlineData(32, 31)] + [InlineData(33, 31)] + public void NativeFontHeightMatchesDecodedLogfontRebuildRules(int requested, int expected) + => Assert.Equal(expected, ImmediateSurfaceTextRenderer.NativePixelHeight(requested)); + + [Fact] + public void RasterizedTextPixelsObeyRetainedOrderAlphaTintTransformAndSourceClip() + { + var renderer = new ImmediateSurfaceTextRenderer(new RecordingRasterizer()); + var textSurface = Image(8, 8); + renderer.Render(textSurface, 1, 1, "AA", Style()); + + var gfx = new GfxState(); + gfx.CreateSurface(2); + gfx.BindDraw(10, 2, 1, 1, 1, 1, 0, 0); + gfx.SetCurrentScale(10, (200, 200, 100)); + gfx.SetCurrentTranslation(10, (3, 2, 0)); + gfx.SetObjectColorResolved(10, 128, 0x00ff00); + + var occluder = new RgbaImage(1, 1, [9, 8, 7, 255]); + gfx.SetSurface(3, 0x3000, -1); + gfx.BindDraw(20, 3, 0, 0, 1, 1, 3, 2); + + var destination = Image(8, 8); + IReadOnlyList visible = gfx.SnapshotVisibleObjects(); + int rendered = RetainedSurfaceRasterizer.CompositeRange( + destination, visible, 10, 1, _ => textSurface); + + Assert.Equal(1, rendered); + Assert.Equal((0, 128, 0, 128), Pixel(destination, 3, 2)); + Assert.Equal((0, 128, 0, 128), Pixel(destination, 4, 2)); + Assert.Equal((0, 0, 0, 0), Pixel(destination, 5, 2)); + + RetainedSurfaceRasterizer.CompositeRange( + destination, visible, 20, 1, _ => occluder); + Assert.Equal((9, 8, 7, 255), Pixel(destination, 3, 2)); + } + + [Fact] + public void RasterizedTextPixelsInheritTheBoundObjectsAnimatedFade() + { + var renderer = new ImmediateSurfaceTextRenderer(new RecordingRasterizer()); + var textSurface = Image(8, 8); + renderer.Render(textSurface, 1, 1, "A", Style()); + + var gfx = new GfxState(); + gfx.CreateSurface(2); + gfx.BindDraw(10, 2, 1, 1, 1, 1, 2, 3); + gfx.SetObjectColorResolved(10, 255, 0xffffff); + gfx.SetAnimatedObjectColorResolved( + 10, delayMs: 0, durationMs: 100, alpha: 0, rgb: 0xffffff); + gfx.SnapshotVisibleObjects(nowMs: 1000); // establish the native one-shot start + IReadOnlyList midpoint = gfx.SnapshotVisibleObjects(nowMs: 1050); + var destination = Image(8, 8); + + RetainedSurfaceRasterizer.CompositeRange( + destination, midpoint, 10, 1, _ => textSurface); + + Assert.InRange(Pixel(destination, 2, 3).A, 126, 128); + Assert.Equal(0, gfx.SnapshotVisibleObjects(nowMs: 1100).Single().Alpha); + } + + [Fact] + public void RasterizedTextSurvivesSurfaceCopyAndCanBeClearedAsPixels() + { + var renderer = new ImmediateSurfaceTextRenderer(new RecordingRasterizer()); + var source = Image(8, 8); + renderer.Render(source, 1, 1, "A", Style()); + var captured = new RgbaImage( + source.Width, source.Height, (byte[])source.Pixels.Clone()); + var destination = Image(8, 8); + + Assert.True(RgbaSurfaceOps.CopyRect(captured, destination, 1, 1, 1, 1, 4, 5)); + Assert.Equal((255, 255, 255, 255), Pixel(destination, 4, 5)); + + Assert.True(RgbaSurfaceOps.FillRect(destination, 4, 5, 1, 1, 0, 0)); + Assert.Equal((0, 0, 0, 0), Pixel(destination, 4, 5)); + } + + private static AdvTextStyle Style() + => new( + PrimaryFontSize: 4, + RubyFontSize: 0, + Bold: false, + TextColor: 0xffffff, + EffectColor: 0, + RenderMode: 0, + EffectOffsetX: 0, + EffectOffsetY: 0, + LineSpacing: 0, + FontFace: ""); + + private static ( + string Face, int Height, int Width, int Weight, int Scalar, ushort Cp932) + RequestIdentity(GlyphRasterRequest request) + => ( + request.FontFace, request.PixelHeight, request.RequestedWidth, request.Weight, + request.UnicodeScalar, request.Cp932Code!.Value); + + private static RgbaImage Image(int width, int height) + => new(width, height, new byte[checked(width * height * 4)]); + + private static (byte R, byte G, byte B, byte A) Pixel(RgbaImage image, int x, int y) + { + int offset = checked((y * image.Width + x) * 4); + return ( + image.Pixels[offset], + image.Pixels[offset + 1], + image.Pixels[offset + 2], + image.Pixels[offset + 3]); + } +} diff --git a/engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs b/engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs new file mode 100644 index 0000000..7a86f8f --- /dev/null +++ b/engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs @@ -0,0 +1,103 @@ +using System.Text; +using Age.Engine.Model; +using Age.Engine.Sys4; + +namespace Age.Engine.Text; + +public readonly record struct ImmediateSurfaceTextResult( + int GlyphCount, + int CursorX, + 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 +/// character cannot leave a partially drawn string behind. +/// +public sealed class ImmediateSurfaceTextRenderer +{ + public const string DefaultFontFace = "MS 明朝"; + + private static readonly Encoding Cp932 = CreateCp932(); + private readonly IGlyphMaskRasterizer _rasterizer; + + public ImmediateSurfaceTextRenderer(IGlyphMaskRasterizer rasterizer) + => _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer)); + + public ImmediateSurfaceTextResult Render( + RgbaImage destination, + int x, + int y, + string text, + AdvTextStyle style) + { + ArgumentNullException.ThrowIfNull(destination); + ArgumentNullException.ThrowIfNull(text); + IReadOnlyList requests = CreateRequests(text, style); + int cursorX = x; + int cursorY = y; + foreach (GlyphRasterRequest request in requests) + { + GlyphMask mask = _rasterizer.Rasterize(request); + AgeGlyphMaskCompositor.DrawGlyph( + destination, mask, cursorX, cursorY, request.PixelHeight, style); + cursorX = checked(cursorX + mask.CellAdvanceX); + cursorY = checked(cursorY + mask.CellAdvanceY); + } + return new ImmediateSurfaceTextResult(requests.Count, cursorX, cursorY); + } + + public static IReadOnlyList CreateRequests( + string text, + AdvTextStyle style) + { + ArgumentNullException.ThrowIfNull(text); + int requestedHeight = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24; + int pixelHeight = NativePixelHeight(requestedHeight); + int requestedWidth = -(pixelHeight / 2); + int weight = style.Bold ? 700 : 0; + string fontFace = string.IsNullOrWhiteSpace(style.FontFace) + ? DefaultFontFace + : style.FontFace; + var requests = new List(text.Length); + + foreach (Rune rune in text.EnumerateRunes()) + { + byte[] encoded; + try + { + encoded = Cp932.GetBytes(rune.ToString()); + } + catch (EncoderFallbackException error) + { + 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)); + } + return requests; + } + + public static int NativePixelHeight(int requestedHeight) + { + if (requestedHeight <= 0) return 24; + if (requestedHeight is 32 or 33) return 31; + return (requestedHeight & 1) != 0 ? requestedHeight - 1 : requestedHeight; + } + + private static Encoding CreateCp932() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 932, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); + } +} diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 033e50e..26f54d4 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -5,6 +5,7 @@ using System.Threading; using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; +using Age.Engine.Text; [Flags] public enum HostPresentationReason @@ -65,6 +66,11 @@ public sealed class GodotAdvHost : IHost private volatile bool _stopping; private readonly object _textLock = new(); private readonly Dictionary> _surfaceText = new(); + private readonly CachedGlyphMaskRasterizer? _surfaceTextMaskCache; + private readonly ImmediateSurfaceTextRenderer? _surfaceTextPixelRenderer; + private readonly GlyphRasterizerBackendInfo? _surfaceTextBackendInfo; + private string _surfaceTextFallbackReason; + private bool _surfaceTextFallbackWarningReported; private readonly Dictionary _historyText = new(); private sealed class LiveTextState { @@ -119,7 +125,9 @@ public sealed class GodotAdvHost : IHost public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock, PageLocatorState locator, Sys4LogicalCanvas logicalCanvas, GodotTimelineLog? timeline = null, - bool synchronizeExplicitPresentation = true) + bool synchronizeExplicitPresentation = true, + IGlyphMaskRasterizer? surfaceTextRasterizer = null, + string? surfaceTextFallbackReason = null) { _main = main; _res = res; _rootScene = scene; _clock = clock; _locator = locator; _timeline = timeline; @@ -127,9 +135,33 @@ public sealed class GodotAdvHost : IHost _screenHeight = logicalCanvas.Height; _slotDims[0] = (_screenWidth, _screenHeight); _synchronizeExplicitPresentation = synchronizeExplicitPresentation; + _surfaceTextBackendInfo = + (surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo; + if (surfaceTextRasterizer != null) + { + _surfaceTextMaskCache = new CachedGlyphMaskRasterizer( + surfaceTextRasterizer, capacity: 2048); + _surfaceTextPixelRenderer = + new ImmediateSurfaceTextRenderer(_surfaceTextMaskCache); + } + _surfaceTextFallbackReason = surfaceTextFallbackReason + ?? (surfaceTextRasterizer == null + ? "No glyph-mask rasterizer was selected." + : ""); } public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight); + public bool UsesSurfaceTextPixels => _surfaceTextPixelRenderer != null; + public GlyphRasterizerBackendInfo? SurfaceTextBackendInfo => _surfaceTextBackendInfo; + public string SurfaceTextFallbackReason => _surfaceTextFallbackReason; + public (int Count, int Capacity, long Hits, long Misses) SurfaceTextMaskCacheStats + => _surfaceTextMaskCache == null + ? (0, 0, 0, 0) + : ( + _surfaceTextMaskCache.Count, + _surfaceTextMaskCache.Capacity, + _surfaceTextMaskCache.Hits, + _surfaceTextMaskCache.Misses); public void ReportWarning(string message) => System.Console.Error.WriteLine(message); @@ -314,6 +346,27 @@ 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)) @@ -321,7 +374,63 @@ public sealed class GodotAdvHost : IHost draws.RemoveAll(draw => draw.X == x && draw.Y == y); draws.Add(new SurfaceTextDraw(x, y, text, style)); } - _timeline?.Event("draw-string", new() { ["surface"] = surfaceSlot, ["x"] = x, ["y"] = y, ["text"] = text }); + _timeline?.Event("draw-string", new() + { + ["surface"] = surfaceSlot, + ["x"] = x, + ["y"] = y, + ["text"] = text, + ["presentation"] = "label-fallback", + ["reason"] = _surfaceTextFallbackReason, + }); + } + + private bool TryDrawStringPixels( + int surfaceSlot, int x, int y, string text, AdvTextStyle style) + { + RgbaImage? destination = ResolveSurfacePixels(surfaceSlot); + if (destination == null + && _slotDims.TryGetValue(surfaceSlot, out var dimensions) + && dimensions.W > 0 + && dimensions.H > 0) + destination = new RgbaImage( + dimensions.W, dimensions.H, + new byte[checked(dimensions.W * dimensions.H * 4)]); + if (destination == null || destination.Width <= 0 || destination.Height <= 0) + { + ReportSurfaceTextFallback( + $"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; + } + + 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() diff --git a/godot/Himegari.csproj b/godot/Himegari.csproj index ed5a53e..e303620 100644 --- a/godot/Himegari.csproj +++ b/godot/Himegari.csproj @@ -4,9 +4,15 @@ true enable + + $(DefineConstants);AGE_WINDOWS_GDI + + + + index % 4 == 3) + .Any(alpha => alpha != 0); + bool gpuAccepted = false; + if (pixels != null) + { + _gpuRenderer.BeginFrame(false); + gpuAccepted = _gpuRenderer.DrawTexture( + pixels, int.MinValue + 997, -1, + 0, 0, pixels.Width, pixels.Height, + new Affine2D(1, 0, 0, 1, 2, 3), + 0xff8080, 255, 0.5f, true, + dynamic: true, dynamicKey: 997, BlendKind.Alpha); + GpuRetainedRenderer.FrameStats stats = _gpuRenderer.EndFrame(); + gpuAccepted &= stats.DrawItems == 1 && stats.TextureUploads == 1; + } + + _host.CreateTexture(996, 32, 24); + _host.CopySurfaceRect(new SurfaceRectCopy( + 997, 996, 0, 0, 32, 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)); + 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, + } + && cache.Count is > 0 and <= 2048 + && cache.Capacity == 2048 + && gpuAccepted + && copiedPixels + && clearedPixels; + immediateSurfaceTextMode = "exact-rgba"; + } + else + { + immediateSurfaceTextOk = + _host.CaptureSurfacePixels(997)?.Pixels.All(value => value == 0) == true + && _host.SnapshotSurfaceText(997).Count == 1 + && !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason); + immediateSurfaceTextMode = "label-fallback"; + } + _host.ReleaseSurface(996); + _host.ReleaseSurface(997); Window rootWindow = GetTree().Root; bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight) && _screen.GetWidth() == _screenWidth @@ -2464,6 +2579,7 @@ public partial class Main : Godot.Control && firstRiffBoundaryOk && bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk && bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk + && immediateSurfaceTextOk && logicalCanvasOk && backbufferPreservationOk; if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " + $"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " + @@ -2471,6 +2587,7 @@ public partial class Main : Godot.Control $"first-riff-boundary=ok; " + $"bgm-fade-replacement=ok; bgm-start-modes-stop=ok; " + $"text-effect-modes=ok; font-calibration=ok; " + + $"immediate-surface-text={immediateSurfaceTextMode}; " + $"backbuffer-preservation=ok; " + $"logical-canvas={_screenWidth}x{_screenHeight}; " + $"window-request={_windowOptions.Width}x{_windowOptions.Height}"); @@ -2482,6 +2599,7 @@ public partial class Main : Godot.Control $"bgm-one-shot={bgmOneShotModeOk}; bgm-loop={bgmLoopModeOk}; " + $"bgm-stop-release={bgmStopReleaseOk}; " + $"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " + + $"immediate-surface-text={immediateSurfaceTextOk}({immediateSurfaceTextMode}); " + $"backbuffer-preservation={backbufferPreservationOk}; " + $"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " + $"window-request={_windowOptions.Width}x{_windowOptions.Height}");