From fe06618439c8bfd2c325f4c8348ae54018e35ae7 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 30 Jul 2026 18:25:46 -0400 Subject: [PATCH] engine: add deterministic glyph mask core --- docs/PROJECT-STRUCTURE.md | 2 + docs/engine-re.md | 26 +- docs/platform-portability.md | 2 +- docs/remake-architecture-and-roadmap.md | 17 +- .../GlyphMaskCompositorTests.cs | 321 ++++++++++++++++++ .../Age.Engine/Text/AgeGlyphMaskCompositor.cs | 135 ++++++++ engine/Age.Engine/Text/BoundedLruCache.cs | 120 +++++++ engine/Age.Engine/Text/GlyphMaskContracts.cs | 112 ++++++ .../Text/RetainedGlyphLayoutEngine.cs | 91 +++++ 9 files changed, 818 insertions(+), 8 deletions(-) create mode 100644 engine/Age.Engine.Tests/GlyphMaskCompositorTests.cs create mode 100644 engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs create mode 100644 engine/Age.Engine/Text/BoundedLruCache.cs create mode 100644 engine/Age.Engine/Text/GlyphMaskContracts.cs create mode 100644 engine/Age.Engine/Text/RetainedGlyphLayoutEngine.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index f7c7db0..93f41b8 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -104,6 +104,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ ├── Age.Engine/Sys4/ runtime game-root selection, catalog parser, loose-first bounded │ ALF asset store, script provider, AGF/LZSS and Windows CUR decoders, │ and resource facade + │ ├── Age.Engine/Text/ backend-neutral glyph-mask requests/results, deterministic AGE + │ text compositor/layout, and bounded font/glyph cache primitives │ └── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB, │ layout-3 numbered state/history/gfx, DAT/STH pair + BMP codec, │ and profile-owned state diff --git a/docs/engine-re.md b/docs/engine-re.md index 99c8fa9..54ab25f 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2938,13 +2938,20 @@ different: `GGO_GRAY4_BITMAP`. It retains GDI's `GLYPHMETRICS`, uses `gmCellIncX` as the per-glyph advance, and consumes the returned 17-level coverage mask (`0..16`) directly. 3. `text_blit_glyph_bitmap@0x458c80` converts coverage `c` to integer alpha `c*255/16`. On a 32-bit - surface it takes `max(destination alpha, glyph alpha)` and interpolates each RGB channel as - `(dst*(255-alpha)+src*alpha)/255`. + surface, an opaque glyph or a destination with alpha zero receives the source RGB and glyph alpha + directly. Otherwise destination alpha becomes `max(destination alpha, glyph alpha)` and each RGB + channel becomes `(dst*(255-alpha)+src*alpha)/255`, with integer truncation. 4. Mode 3 does not invoke a font-renderer outline. Its step is `360/(sqrt(effect_x²+effect_y²)*8)`, with nearest-integer ellipse coordinates. Radius `(1,1)` makes twelve calls covering the eight neighboring offsets: each cardinal offset is visited twice and each diagonal once, followed by one primary-color glyph blit at the unshifted position. +Mode 2 uses the same primary color for a second unshifted pass after the ordinary primary glyph, but shifts +every cached coverage value right by two first. On a transparent destination this lower-coverage duplicate +does not change the already-written source RGB/max alpha; on an existing colored pixel it performs a second +integer interpolation toward the primary color. Himegari does not select mode 2, but the shared compositor +still reproduces it. + The port does none of those raster operations. It loads the same TTC into Godot/FreeType with grayscale antialiasing, light hinting, automatic subpixel positioning, embedded bitmaps disabled, and otherwise default `FontFile` policy. Bold is the unrelated `FontVariation.VariationEmbolden=0.53` operation plus @@ -2963,6 +2970,21 @@ face substitutions/defaults remain deferred to the broader runtime configuration 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.” +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, +optional original CP932 code, and an explicit native/portable policy. `GlyphMask` owns normalized 0..16 +coverage plus stride, GDI-style origin, cell extent, and advance. `AgeGlyphMaskCompositor` implements the +32-bit branch above without calling an OS or Godot API; its synthetic oracle includes transparent and +existing-alpha pixels, negative bearings/clipping, all four effect modes, and overlapping mode-3 masks. + +`RetainedGlyphLayoutEngine` consumes any `IGlyphMaskRasterizer`, writes those masks into an `RgbaImage`, +and returns the five-dword edge records plus final cursor, observed overflow, wrap count, and vertical-stop +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. + #### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11) The small bat marker is a configured ADV-layout sprite, not a glyph or part of SO001. `SYSTEM4.BIN` diff --git a/docs/platform-portability.md b/docs/platform-portability.md index 901d3e9..4ad699e 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 | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows `MS 明朝`/`MS ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback. AGE actually uses a display information DC, weight-700 `LOGFONTA`, `GGO_GRAY4_BITMAP`, and its own integer glyph/outline compositor; the current `Label` backend substitutes FreeType embolden and a Godot outline | On the reference Windows install, regular advances and direct SC0000 placement/bounds agree, but the behaviorally different provisional bold path has 45% fewer bright pixels and much softer edge coverage. Other platforms normally lack the proprietary faces, so substitute metrics remain profile-dependent | The Phase-E retained glyph-mask polish slice is now planned in `docs/remake-architecture-and-roadmap.md`: keep the GDI reference adapter outside the platform-neutral VM project, select it explicitly on Windows, and complete an explicitly non-identical portable rasterizer/substitution policy before removing the temporary Label fallback | +| 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. The new `Age.Engine.Text` core is OS-neutral: requests select native-CP932 or portable-Unicode policy, mask results normalize to 0..16 coverage/GDI-style metrics, and managed code owns deterministic compositing/layout plus bounded caches | Visible behavior is unchanged. On the reference Windows install, regular advances and SC0000 placement agree, but provisional bold remains visibly lighter/softer. Synthetic masks now test the common compositor independently of any OS font; no authored runtime code calls GDI | Next add the independent Windows GDI adapter outside `Age.Engine`, then integrate immediate surface pixels. Keep explicit portable face substitution/raster policy and do not claim cross-backend pixel equivalence | | 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 394fea1..c75d5b9 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -579,11 +579,18 @@ primitive, which cannot be made equivalent by choosing another embolden constant decode and the ordinary saved-frame prologue/republication path reconstructs current-page presentation. `AdvRetainedTextContract` and focused engine tests pin the platform-neutral record, overflow, punctuation, capacity, SYSTEM4 binding, and restore boundaries before presentation changes. -2. **Land the backend-neutral mask and compositor core.** Add request/result records and an - `IGlyphMaskRasterizer`-style seam outside the VM. Keep CP932 byte identity available for native-compatible - requests while permitting an explicit Unicode-capable portable policy for mods. Implement AGE's coverage - conversion, modes 0–3, clipping, alpha/RGB integer blend, metrics, wrapping, final-cursor result, and - bounded font/glyph caches in managed code. Synthetic masks, not OS fonts, are the unit-test oracle. +2. **Land the backend-neutral mask and compositor core.** Completed 2026-07-30 without changing live + presentation. `Age.Engine.Text` now owns immutable `GlyphRasterRequest`/`GlyphMask` contracts and the + `IGlyphMaskRasterizer` seam outside the VM. Requests preserve Unicode scalar plus optional original CP932 + code and explicitly select native-CP932 or portable-Unicode policy; results normalize every backend to + 0..16 coverage, stride, GDI-style origin, cell extent, and cell advance. `AgeGlyphMaskCompositor` + implements AGE's coverage conversion, modes 0–3, clipping, transparent-destination rule, maximum-alpha + and integer RGB blend. `RetainedGlyphLayoutEngine` returns native-edge records, wrapping/kinsoku decisions, + overflow state, and final cursor while rasterizing into a backend-neutral RGBA surface. A generic bounded + LRU supports disposable backend font resources and `CachedGlyphMaskRasterizer` bounds masks. Fifteen + synthetic-mask tests, independent of OS fonts, pin coverage rounding, bearings/clipping, mode-1 displacement, + mode-2 quarter coverage, mode-3 sampling/overlap, wrapping, punctuation, vertical overflow, cursor results, + and cache eviction. 3. **Add the independent Windows GDI reference backend.** Keep the P/Invoke adapter outside the platform-neutral `Age.Engine` project so the VM continues to call no OS APIs. Reproduce the decoded display IC, LOGFONT, measurement, and `GGO_GRAY4_BITMAP` request, and compare masks/`GLYPHMETRICS`/advances directly diff --git a/engine/Age.Engine.Tests/GlyphMaskCompositorTests.cs b/engine/Age.Engine.Tests/GlyphMaskCompositorTests.cs new file mode 100644 index 0000000..c2a25fc --- /dev/null +++ b/engine/Age.Engine.Tests/GlyphMaskCompositorTests.cs @@ -0,0 +1,321 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Text; + +public class GlyphMaskCompositorTests +{ + private sealed class CountingRasterizer(Func factory) + : IGlyphMaskRasterizer + { + public int Calls { get; private set; } + + public GlyphMask Rasterize(GlyphRasterRequest request) + { + Calls++; + return factory(request); + } + } + + [Fact] + public void NativeRequestRequiresCp932IdentityAndValidScalar() + { + Assert.Throws(() => new GlyphRasterRequest( + "face", 24, -12, 700, 0x3042, null, GlyphRasterPolicy.NativeCp932Gray4)); + Assert.Throws(() => new GlyphRasterRequest( + "face", 24, -12, 700, 0x110000, 0x82a0, GlyphRasterPolicy.NativeCp932Gray4)); + + var request = new GlyphRasterRequest( + "face", 24, -12, 700, 0x3042, 0x82a0, GlyphRasterPolicy.NativeCp932Gray4); + + Assert.Equal(("face", 24, -12, 700, 0x3042, (ushort)0x82a0), + (request.FontFace, request.PixelHeight, request.RequestedWidth, request.Weight, + request.UnicodeScalar, request.Cp932Code!.Value)); + } + + [Fact] + public void MaskOwnsValidatedNormalizedCoverage() + { + byte[] source = [0, 8, 16, 0]; + var mask = new GlyphMask(2, 1, 4, 0, 1, 2, 0, 2, 3, source); + source[1] = 0; + + Assert.Equal(new byte[] { 0, 8, 16, 0 }, mask.Coverage.ToArray()); + Assert.Throws( + () => new GlyphMask(1, 1, 1, 0, 0, 1, 0, 1, 1, [17])); + Assert.Throws( + () => new GlyphMask(2, 1, 1, 0, 0, 1, 0, 1, 1, [0])); + } + + [Fact] + public void Gray4CoverageUsesNativeAlphaAndTransparentDestinationRule() + { + var destination = Image(1, 1); + GlyphMask mask = Mask(coverage: 8); + + Assert.True(AgeGlyphMaskCompositor.DrawMask( + destination, mask, baselineX: 0, baselineY: 1, rgb: 0x123456)); + + Assert.Equal(new byte[] { 0x12, 0x34, 0x56, 127 }, destination.Pixels); + } + + [Fact] + public void ExistingDestinationUsesIntegerRgbBlendAndMaximumAlpha() + { + var destination = new RgbaImage(1, 1, [10, 20, 30, 128]); + + AgeGlyphMaskCompositor.DrawMask( + destination, Mask(coverage: 8), baselineX: 0, baselineY: 1, rgb: 0x6edc0a); + + Assert.Equal(new byte[] { 59, 119, 20, 128 }, destination.Pixels); + } + + [Fact] + public void BearingsAndDestinationBoundsClipTheMask() + { + var destination = Image(2, 1); + var mask = new GlyphMask( + 3, 1, 4, originX: -1, originY: 1, + cellAdvanceX: 3, cellAdvanceY: 0, cellWidth: 3, cellHeight: 1, + [16, 16, 16, 0]); + + AgeGlyphMaskCompositor.DrawMask( + destination, mask, baselineX: 0, baselineY: 1, rgb: 0x010203); + + Assert.Equal(new byte[] + { + 1, 2, 3, 255, + 1, 2, 3, 255, + }, destination.Pixels); + } + + [Fact] + public void ModeOneDrawsDisplacedEffectThenPrimaryMask() + { + var destination = Image(3, 1); + AdvTextStyle style = Style(renderMode: 1) with + { + TextColor = 0xff0000, + EffectColor = 0x0000ff, + EffectOffsetX = 1, + }; + + AgeGlyphMaskCompositor.DrawGlyph( + destination, Mask(), cellX: 0, cellY: 0, topToBaseline: 1, style); + + Assert.Equal(new byte[] + { + 255, 0, 0, 255, + 0, 0, 255, 255, + 0, 0, 0, 0, + }, destination.Pixels); + } + + [Fact] + public void ModeTwoAddsQuarterCoveragePrimaryPass() + { + var destination = new RgbaImage(1, 1, [0, 0, 0, 255]); + + AgeGlyphMaskCompositor.DrawGlyph( + destination, Mask(coverage: 8), 0, 0, 1, + Style(renderMode: 2) with { TextColor = 0xffffff }); + + Assert.Equal(new byte[] { 142, 142, 142, 255 }, destination.Pixels); + } + + [Fact] + public void ModeThreeRadiusOneMatchesNativeTwelveSampleEllipse() + { + IReadOnlyList<(int X, int Y)> offsets = + AgeGlyphMaskCompositor.GetMode3OutlineOffsets(1, 1); + + Assert.Equal(12, offsets.Count); + Assert.Equal(8, offsets.Distinct().Count()); + Assert.Equal(2, offsets.Count(offset => offset == (1, 0))); + Assert.Equal(2, offsets.Count(offset => offset == (0, 1))); + Assert.Equal(2, offsets.Count(offset => offset == (-1, 0))); + Assert.Equal(2, offsets.Count(offset => offset == (0, -1))); + Assert.Equal(1, offsets.Count(offset => offset == (1, 1))); + Assert.Equal(1, offsets.Count(offset => offset == (-1, 1))); + Assert.Equal(1, offsets.Count(offset => offset == (-1, -1))); + Assert.Equal(1, offsets.Count(offset => offset == (1, -1))); + Assert.DoesNotContain((0, 0), offsets); + } + + [Fact] + public void ModeThreeOverlappingMasksUseTheSameIntegerCompositorBeforePrimary() + { + var destination = Image(5, 3); + var mask = new GlyphMask( + 2, 1, 2, originX: 0, originY: 1, + cellAdvanceX: 2, cellAdvanceY: 0, cellWidth: 2, cellHeight: 1, + [8, 16]); + AdvTextStyle style = Style(renderMode: 3) with + { + TextColor = 0xff0000, + EffectColor = 0x0000ff, + EffectOffsetX = 1, + EffectOffsetY = 1, + }; + + AgeGlyphMaskCompositor.DrawGlyph( + destination, mask, cellX: 2, cellY: 1, topToBaseline: 1, style); + + // The left horizontal outline copy puts opaque blue under the half-coverage primary texel. + // AGE retains max alpha and blends RGB with integer truncation. + Assert.Equal((127, 0, 128, 255), Pixel(destination, 2, 1)); + Assert.Equal((255, 0, 0, 255), Pixel(destination, 3, 1)); + Assert.Equal((0, 0, 255, 127), Pixel(destination, 1, 1)); + Assert.Equal((0, 0, 255, 255), Pixel(destination, 4, 1)); + } + + [Fact] + public void LayoutWrapsBeforeOrdinaryGlyphAndReturnsNativeEdgeRecords() + { + var destination = Image(16, 16); + var renderer = new CountingRasterizer(_ => CellMask()); + var engine = new RetainedGlyphLayoutEngine(renderer); + + GlyphTextLayoutResult result = engine.Render( + destination, + new GlyphTextLayoutOptions( + CursorX: 2, CursorY: 1, LineOriginX: 2, + RightBound: 8, BottomBound: 16, WrapHorizontally: true, Style()), + [NativeRequest(0x82a0, 0x3042), NativeRequest(0x82a2, 0x3044)]); + + Assert.Equal(2, result.ConsumedGlyphs); + Assert.Equal(1, result.WrappedLines); + Assert.Equal((6, 7), (result.CursorX, result.CursorY)); + Assert.Equal(AdvTextOverflowFlags.Horizontal, result.ObservedOverflow); + Assert.Collection(result.Records, + first => Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 1, 6, 6), first), + second => Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 7, 6, 12), second)); + Assert.Equal(255, Pixel(destination, 2, 1).A); + Assert.Equal(255, Pixel(destination, 2, 7).A); + } + + [Fact] + public void ClosingPunctuationStaysOnOverflowingPrecedingLine() + { + var engine = new RetainedGlyphLayoutEngine(new CountingRasterizer(_ => CellMask())); + + GlyphTextLayoutResult result = engine.Render( + Image(16, 16), + new GlyphTextLayoutOptions(2, 1, 2, 8, 16, true, Style()), + [NativeRequest(0x82a0, 0x3042), NativeRequest(0x8141, 0x3001)]); + + Assert.Equal(0, result.WrappedLines); + Assert.Equal((10, 1), (result.CursorX, result.CursorY)); + Assert.Equal(new AdvRetainedGlyphRecord(0, 6, 1, 10, 6), result.Records[1]); + } + + [Fact] + public void VerticalOnlyOverflowStopsBeforeCompositingGlyph() + { + var rasterizer = new CountingRasterizer(_ => CellMask()); + var engine = new RetainedGlyphLayoutEngine(rasterizer); + + GlyphTextLayoutResult result = engine.Render( + Image(16, 16), + new GlyphTextLayoutOptions(2, 12, 2, 8, 16, true, Style()), + [NativeRequest(0x82a0, 0x3042)]); + + Assert.True(result.StoppedOnVerticalOverflow); + Assert.Equal(0, result.ConsumedGlyphs); + Assert.Empty(result.Records); + Assert.Equal(1, rasterizer.Calls); // metrics are required before the overflow decision + } + + [Fact] + public void SimultaneousHorizontalAndVerticalOverflowTakesNativeWrapPath() + { + var engine = new RetainedGlyphLayoutEngine(new CountingRasterizer(_ => CellMask())); + + GlyphTextLayoutResult result = engine.Render( + Image(16, 24), + new GlyphTextLayoutOptions(6, 12, 2, 8, 16, true, Style()), + [NativeRequest(0x82a0, 0x3042)]); + + Assert.False(result.StoppedOnVerticalOverflow); + Assert.Equal(1, result.ConsumedGlyphs); + Assert.Equal(1, result.WrappedLines); + Assert.Equal( + AdvTextOverflowFlags.Horizontal | AdvTextOverflowFlags.Vertical, + result.ObservedOverflow); + Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 18, 6, 23), Assert.Single(result.Records)); + } + + [Fact] + public void GlyphCacheIsBoundedAndUsesLruRecency() + { + var inner = new CountingRasterizer(_ => Mask()); + var cached = new CachedGlyphMaskRasterizer(inner, capacity: 2); + GlyphRasterRequest a = NativeRequest(0x41, 0x41); + GlyphRasterRequest b = NativeRequest(0x42, 0x42); + GlyphRasterRequest c = NativeRequest(0x43, 0x43); + + GlyphMask firstA = cached.Rasterize(a); + cached.Rasterize(b); + Assert.Same(firstA, cached.Rasterize(a)); + cached.Rasterize(c); + cached.Rasterize(b); + + Assert.Equal(2, cached.Count); + Assert.Equal(1, cached.Hits); + Assert.Equal(4, cached.Misses); + Assert.Equal(4, inner.Calls); + } + + [Fact] + public void GenericCacheEvictionCanReleaseBackendFontResources() + { + var evicted = new List(); + var cache = new BoundedLruCache(2, evicted.Add); + cache.Set(1, "font-1"); + cache.Set(2, "font-2"); + Assert.True(cache.TryGetValue(1, out _)); + + cache.Set(3, "font-3"); + + Assert.Equal(["font-2"], evicted); + Assert.False(cache.TryGetValue(2, out _)); + Assert.True(cache.TryGetValue(1, out _)); + Assert.True(cache.TryGetValue(3, out _)); + } + + private static GlyphRasterRequest NativeRequest(ushort cp932, int scalar) + => new("face", 5, -2, 700, scalar, cp932, GlyphRasterPolicy.NativeCp932Gray4); + + private static GlyphMask Mask(byte coverage = 16) + => new(1, 1, 1, originX: 0, originY: 1, + cellAdvanceX: 1, cellAdvanceY: 0, cellWidth: 1, cellHeight: 1, [coverage]); + + private static GlyphMask CellMask() + => new(1, 1, 1, originX: 0, originY: 5, + cellAdvanceX: 4, cellAdvanceY: 0, cellWidth: 4, cellHeight: 5, [16]); + + private static AdvTextStyle Style(int renderMode = 0) + => new( + PrimaryFontSize: 5, + RubyFontSize: 0, + Bold: true, + TextColor: 0xffffff, + EffectColor: 0, + RenderMode: renderMode, + EffectOffsetX: 0, + EffectOffsetY: 0, + LineSpacing: 1, + FontFace: "face"); + + 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 = (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/AgeGlyphMaskCompositor.cs b/engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs new file mode 100644 index 0000000..af5b4e0 --- /dev/null +++ b/engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs @@ -0,0 +1,135 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; + +namespace Age.Engine.Text; + +/// AGE's deterministic 32-bit glyph-mask compositor, independent of mask provenance. +public static class AgeGlyphMaskCompositor +{ + public static void DrawGlyph( + RgbaImage destination, + GlyphMask mask, + int cellX, + int cellY, + int topToBaseline, + AdvTextStyle style) + { + ArgumentNullException.ThrowIfNull(destination); + ArgumentNullException.ThrowIfNull(mask); + ValidateDestination(destination); + int baselineY = checked(cellY + topToBaseline); + + if (style.RenderMode == 1) + DrawMask(destination, mask, + checked(cellX + style.EffectOffsetX), + checked(baselineY + style.EffectOffsetY), + style.EffectColor); + else if (style.RenderMode == 3) + foreach ((int x, int y) in GetMode3OutlineOffsets( + style.EffectOffsetX, style.EffectOffsetY)) + DrawMask(destination, mask, checked(cellX + x), checked(baselineY + y), + style.EffectColor); + + DrawMask(destination, mask, cellX, baselineY, style.TextColor); + if (style.RenderMode == 2) + DrawMask(destination, mask, cellX, baselineY, style.TextColor, coverageShift: 2); + } + + /// + /// Draw at a GDI-style baseline. mask.OriginX moves right from the pen and mask.OriginY moves + /// upward from the baseline. coverageShift=2 reproduces AGE's mode-2 quarter-coverage pass. + /// + public static bool DrawMask( + RgbaImage destination, + GlyphMask mask, + int baselineX, + int baselineY, + long rgb, + int coverageShift = 0) + { + ArgumentNullException.ThrowIfNull(destination); + ArgumentNullException.ThrowIfNull(mask); + ValidateDestination(destination); + if (coverageShift is < 0 or > 4) + throw new ArgumentOutOfRangeException(nameof(coverageShift)); + + int red = (int)((rgb >> 16) & 0xff); + int green = (int)((rgb >> 8) & 0xff); + int blue = (int)(rgb & 0xff); + int left = checked(baselineX + mask.OriginX); + int top = checked(baselineY - mask.OriginY); + ReadOnlySpan coverage = mask.Coverage.Span; + bool changed = false; + + for (int row = 0; row < mask.Height; row++) + { + int y = checked(top + row); + if ((uint)y >= (uint)destination.Height) continue; + int sourceRow = row * mask.Stride; + for (int column = 0; column < mask.Width; column++) + { + int x = checked(left + column); + if ((uint)x >= (uint)destination.Width) continue; + int level = coverage[sourceRow + column] >> coverageShift; + if (level == 0) continue; + int alpha = level * 255 / 16; + BlendTextPixel(destination.Pixels, + checked((y * destination.Width + x) * 4), + red, green, blue, alpha); + changed = true; + } + } + return changed; + } + + public static IReadOnlyList<(int X, int Y)> GetMode3OutlineOffsets(int radiusX, int radiusY) + { + double radius = Math.Sqrt((double)radiusX * radiusX + (double)radiusY * radiusY); + if (radius == 0) return Array.Empty<(int, int)>(); + int sampleCount = checked((int)Math.Ceiling(radius * 8.0)); + var result = new (int X, int Y)[sampleCount]; + double step = 360.0 / (radius * 8.0); + for (int index = 0; index < result.Length; index++) + { + double radians = index * step * Math.PI / 180.0; + result[index] = ( + RoundNearest(Math.Cos(radians) * radiusX), + RoundNearest(Math.Sin(radians) * radiusY)); + } + return result; + } + + private static int RoundNearest(double value) + => checked((int)(value < 0 ? value - 0.5 : value + 0.5)); + + private static void BlendTextPixel( + byte[] destination, int offset, int red, int green, int blue, int alpha) + { + int destinationAlpha = destination[offset + 3]; + if (alpha == 255 || destinationAlpha == 0) + { + destination[offset] = (byte)red; + destination[offset + 1] = (byte)green; + destination[offset + 2] = (byte)blue; + destination[offset + 3] = (byte)alpha; + return; + } + + int inverse = 255 - alpha; + destination[offset] = + (byte)((destination[offset] * inverse + red * alpha) / 255); + destination[offset + 1] = + (byte)((destination[offset + 1] * inverse + green * alpha) / 255); + destination[offset + 2] = + (byte)((destination[offset + 2] * inverse + blue * alpha) / 255); + destination[offset + 3] = (byte)Math.Max(destinationAlpha, alpha); + } + + private static void ValidateDestination(RgbaImage destination) + { + if (destination.Width <= 0 || destination.Height <= 0 + || destination.Pixels.Length != checked(destination.Width * destination.Height * 4)) + throw new ArgumentException("Destination must be a non-empty tightly packed RGBA image.", + nameof(destination)); + } +} diff --git a/engine/Age.Engine/Text/BoundedLruCache.cs b/engine/Age.Engine/Text/BoundedLruCache.cs new file mode 100644 index 0000000..3bafbec --- /dev/null +++ b/engine/Age.Engine/Text/BoundedLruCache.cs @@ -0,0 +1,120 @@ +namespace Age.Engine.Text; + +/// A small thread-safe LRU used by glyph masks and future backend-owned font handles. +public sealed class BoundedLruCache where TKey : notnull +{ + private sealed record Entry(TKey Key, TValue Value); + + private readonly object _gate = new(); + private readonly int _capacity; + private readonly Action? _onEvicted; + private readonly Dictionary> _entries = new(); + private readonly LinkedList _recency = new(); + + public BoundedLruCache(int capacity, Action? onEvicted = null) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + _capacity = capacity; + _onEvicted = onEvicted; + } + + public int Capacity => _capacity; + + public int Count + { + get { lock (_gate) return _entries.Count; } + } + + public bool TryGetValue(TKey key, out TValue value) + { + lock (_gate) + { + if (!_entries.TryGetValue(key, out LinkedListNode? node)) + { + value = default!; + return false; + } + _recency.Remove(node); + _recency.AddFirst(node); + value = node.Value.Value; + return true; + } + } + + public void Set(TKey key, TValue value) + { + List? evicted = null; + lock (_gate) + { + if (_entries.Remove(key, out LinkedListNode? existing)) + { + _recency.Remove(existing); + (evicted ??= new List(2)).Add(existing.Value.Value); + } + + var node = new LinkedListNode(new Entry(key, value)); + _recency.AddFirst(node); + _entries.Add(key, node); + if (_entries.Count > _capacity) + { + LinkedListNode oldest = _recency.Last!; + _recency.RemoveLast(); + _entries.Remove(oldest.Value.Key); + (evicted ??= new List(1)).Add(oldest.Value.Value); + } + } + if (_onEvicted != null && evicted != null) + foreach (TValue removed in evicted) _onEvicted(removed); + } + + public void Clear() + { + TValue[] removed; + lock (_gate) + { + removed = _recency.Select(entry => entry.Value).ToArray(); + _entries.Clear(); + _recency.Clear(); + } + if (_onEvicted != null) + foreach (TValue value in removed) _onEvicted(value); + } +} + +/// Bounded caching decorator shared by exact and portable glyph-mask backends. +public sealed class CachedGlyphMaskRasterizer : IGlyphMaskRasterizer +{ + private readonly IGlyphMaskRasterizer _inner; + private readonly BoundedLruCache _cache; + private long _hits; + private long _misses; + + public CachedGlyphMaskRasterizer(IGlyphMaskRasterizer inner, int capacity) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _cache = new BoundedLruCache(capacity); + } + + public int Count => _cache.Count; + public int Capacity => _cache.Capacity; + public long Hits => Interlocked.Read(ref _hits); + public long Misses => Interlocked.Read(ref _misses); + + public GlyphMask Rasterize(GlyphRasterRequest request) + { + ArgumentNullException.ThrowIfNull(request); + if (_cache.TryGetValue(request, out GlyphMask? cached)) + { + Interlocked.Increment(ref _hits); + return cached; + } + + GlyphMask result = _inner.Rasterize(request) + ?? throw new InvalidOperationException("The glyph rasterizer returned null."); + _cache.Set(request, result); + Interlocked.Increment(ref _misses); + return result; + } + + public void Clear() => _cache.Clear(); +} diff --git a/engine/Age.Engine/Text/GlyphMaskContracts.cs b/engine/Age.Engine/Text/GlyphMaskContracts.cs new file mode 100644 index 0000000..5666f83 --- /dev/null +++ b/engine/Age.Engine/Text/GlyphMaskContracts.cs @@ -0,0 +1,112 @@ +using System.Text; + +namespace Age.Engine.Text; + +public enum GlyphRasterPolicy +{ + NativeCp932Gray4, + PortableUnicode, +} + +/// +/// Backend-neutral description of one requested glyph. Native-compatible requests preserve both +/// the Unicode scalar used by authored code and the original CP932 code consumed by AGE/GDI. +/// +public sealed record GlyphRasterRequest +{ + public GlyphRasterRequest( + string fontFace, + int pixelHeight, + int requestedWidth, + int weight, + int unicodeScalar, + ushort? cp932Code, + GlyphRasterPolicy policy) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fontFace); + if (pixelHeight <= 0) throw new ArgumentOutOfRangeException(nameof(pixelHeight)); + if (weight is < 0 or > 1000) throw new ArgumentOutOfRangeException(nameof(weight)); + if (!Rune.IsValid(unicodeScalar)) throw new ArgumentOutOfRangeException(nameof(unicodeScalar)); + if (policy == GlyphRasterPolicy.NativeCp932Gray4 && cp932Code == null) + throw new ArgumentException("Native CP932 glyph requests require the original code.", nameof(cp932Code)); + + FontFace = fontFace; + PixelHeight = pixelHeight; + RequestedWidth = requestedWidth; + Weight = weight; + UnicodeScalar = unicodeScalar; + Cp932Code = cp932Code; + Policy = policy; + } + + public string FontFace { get; } + public int PixelHeight { get; } + public int RequestedWidth { get; } + public int Weight { get; } + public int UnicodeScalar { get; } + public ushort? Cp932Code { get; } + public GlyphRasterPolicy Policy { get; } +} + +/// +/// One grayscale glyph mask plus GDI-compatible placement and cell metrics. Coverage values use +/// AGE's normalized 0..16 range, regardless of which platform rasterizer produced them. +/// +public sealed class GlyphMask +{ + private readonly byte[] _coverage; + + public GlyphMask( + int width, + int height, + int stride, + int originX, + int originY, + int cellAdvanceX, + int cellAdvanceY, + int cellWidth, + int cellHeight, + ReadOnlySpan coverage) + { + if (width < 0) throw new ArgumentOutOfRangeException(nameof(width)); + if (height < 0) throw new ArgumentOutOfRangeException(nameof(height)); + if (stride < width) throw new ArgumentOutOfRangeException(nameof(stride)); + if (cellWidth < 0) throw new ArgumentOutOfRangeException(nameof(cellWidth)); + if (cellHeight < 0) throw new ArgumentOutOfRangeException(nameof(cellHeight)); + int required = checked(stride * height); + if (coverage.Length != required) + throw new ArgumentException($"Glyph coverage has {coverage.Length} bytes; expected {required}.", + nameof(coverage)); + foreach (byte value in coverage) + if (value > 16) + throw new ArgumentException("Glyph coverage values must be in AGE's 0..16 range.", + nameof(coverage)); + + Width = width; + Height = height; + Stride = stride; + OriginX = originX; + OriginY = originY; + CellAdvanceX = cellAdvanceX; + CellAdvanceY = cellAdvanceY; + CellWidth = cellWidth; + CellHeight = cellHeight; + _coverage = coverage.ToArray(); + } + + public int Width { get; } + public int Height { get; } + public int Stride { get; } + public int OriginX { get; } + public int OriginY { get; } + public int CellAdvanceX { get; } + public int CellAdvanceY { get; } + public int CellWidth { get; } + public int CellHeight { get; } + public ReadOnlyMemory Coverage => _coverage; +} + +public interface IGlyphMaskRasterizer +{ + GlyphMask Rasterize(GlyphRasterRequest request); +} diff --git a/engine/Age.Engine/Text/RetainedGlyphLayoutEngine.cs b/engine/Age.Engine/Text/RetainedGlyphLayoutEngine.cs new file mode 100644 index 0000000..5d032dc --- /dev/null +++ b/engine/Age.Engine/Text/RetainedGlyphLayoutEngine.cs @@ -0,0 +1,91 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; + +namespace Age.Engine.Text; + +public readonly record struct GlyphTextLayoutOptions( + int CursorX, + int CursorY, + int LineOriginX, + int RightBound, + int BottomBound, + bool WrapHorizontally, + AdvTextStyle Style); + +public sealed record GlyphTextLayoutResult( + IReadOnlyList Records, + int CursorX, + int CursorY, + int ConsumedGlyphs, + int WrappedLines, + AdvTextOverflowFlags ObservedOverflow, + bool StoppedOnVerticalOverflow); + +/// +/// Builds native-edge retained records while rasterizing masks into the layout's RGBA surface. +/// This is deliberately presentation-neutral: binding records to GfxState belongs to a later slice. +/// +public sealed class RetainedGlyphLayoutEngine +{ + private readonly IGlyphMaskRasterizer _rasterizer; + + public RetainedGlyphLayoutEngine(IGlyphMaskRasterizer rasterizer) + => _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer)); + + public GlyphTextLayoutResult Render( + RgbaImage destination, + GlyphTextLayoutOptions options, + IReadOnlyList glyphs) + { + ArgumentNullException.ThrowIfNull(destination); + ArgumentNullException.ThrowIfNull(glyphs); + var records = new List(glyphs.Count); + int cursorX = options.CursorX; + int cursorY = options.CursorY; + int wrappedLines = 0; + var observed = AdvTextOverflowFlags.None; + bool stopped = false; + + foreach (GlyphRasterRequest request in glyphs) + { + ArgumentNullException.ThrowIfNull(request); + GlyphMask mask = _rasterizer.Rasterize(request); + int right = checked(cursorX + mask.CellWidth); + int bottom = checked(cursorY + mask.CellHeight); + AdvTextOverflowFlags overflow = AdvRetainedTextContract.CheckOverflow( + options.RightBound, options.BottomBound, right, bottom); + observed |= overflow; + + if (overflow == AdvTextOverflowFlags.Vertical) + { + stopped = true; + break; + } + + if (overflow.HasFlag(AdvTextOverflowFlags.Horizontal) + && options.WrapHorizontally + && (request.Cp932Code == null + || !AdvRetainedTextContract.PreventsHorizontalWrapBefore( + request.Cp932Code.Value))) + { + int fontHeight = options.Style.PrimaryFontSize > 0 + ? options.Style.PrimaryFontSize + : request.PixelHeight; + cursorX = options.LineOriginX; + cursorY = checked(cursorY + fontHeight + options.Style.LineSpacing); + wrappedLines++; + right = checked(cursorX + mask.CellWidth); + bottom = checked(cursorY + mask.CellHeight); + } + + AgeGlyphMaskCompositor.DrawGlyph( + destination, mask, cursorX, cursorY, request.PixelHeight, options.Style); + records.Add(new AdvRetainedGlyphRecord(0, cursorX, cursorY, right, bottom)); + cursorX = checked(cursorX + mask.CellAdvanceX); + cursorY = checked(cursorY + mask.CellAdvanceY); + } + + return new GlyphTextLayoutResult( + records, cursorX, cursorY, records.Count, wrappedLines, observed, stopped); + } +}