engine: add deterministic glyph mask core

This commit is contained in:
gamer147
2026-07-30 18:25:46 -04:00
parent fd2acb262b
commit fe06618439
9 changed files with 818 additions and 8 deletions

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ or replaced before claiming portable exports.
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
| ADV font discovery/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows ` 明朝`/` ゴシック` 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 ` 明朝`/` ゴシック` 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 |

View File

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

View File

@@ -0,0 +1,321 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Text;
public class GlyphMaskCompositorTests
{
private sealed class CountingRasterizer(Func<GlyphRasterRequest, GlyphMask> factory)
: IGlyphMaskRasterizer
{
public int Calls { get; private set; }
public GlyphMask Rasterize(GlyphRasterRequest request)
{
Calls++;
return factory(request);
}
}
[Fact]
public void NativeRequestRequiresCp932IdentityAndValidScalar()
{
Assert.Throws<ArgumentException>(() => new GlyphRasterRequest(
"face", 24, -12, 700, 0x3042, null, GlyphRasterPolicy.NativeCp932Gray4));
Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentException>(
() => new GlyphMask(1, 1, 1, 0, 0, 1, 0, 1, 1, [17]));
Assert.Throws<ArgumentOutOfRangeException>(
() => 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<string>();
var cache = new BoundedLruCache<int, string>(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]);
}
}

View File

@@ -0,0 +1,135 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
namespace Age.Engine.Text;
/// <summary>AGE's deterministic 32-bit glyph-mask compositor, independent of mask provenance.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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<byte> 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));
}
}

View File

@@ -0,0 +1,120 @@
namespace Age.Engine.Text;
/// <summary>A small thread-safe LRU used by glyph masks and future backend-owned font handles.</summary>
public sealed class BoundedLruCache<TKey, TValue> where TKey : notnull
{
private sealed record Entry(TKey Key, TValue Value);
private readonly object _gate = new();
private readonly int _capacity;
private readonly Action<TValue>? _onEvicted;
private readonly Dictionary<TKey, LinkedListNode<Entry>> _entries = new();
private readonly LinkedList<Entry> _recency = new();
public BoundedLruCache(int capacity, Action<TValue>? 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<Entry>? 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<TValue>? evicted = null;
lock (_gate)
{
if (_entries.Remove(key, out LinkedListNode<Entry>? existing))
{
_recency.Remove(existing);
(evicted ??= new List<TValue>(2)).Add(existing.Value.Value);
}
var node = new LinkedListNode<Entry>(new Entry(key, value));
_recency.AddFirst(node);
_entries.Add(key, node);
if (_entries.Count > _capacity)
{
LinkedListNode<Entry> oldest = _recency.Last!;
_recency.RemoveLast();
_entries.Remove(oldest.Value.Key);
(evicted ??= new List<TValue>(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);
}
}
/// <summary>Bounded caching decorator shared by exact and portable glyph-mask backends.</summary>
public sealed class CachedGlyphMaskRasterizer : IGlyphMaskRasterizer
{
private readonly IGlyphMaskRasterizer _inner;
private readonly BoundedLruCache<GlyphRasterRequest, GlyphMask> _cache;
private long _hits;
private long _misses;
public CachedGlyphMaskRasterizer(IGlyphMaskRasterizer inner, int capacity)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_cache = new BoundedLruCache<GlyphRasterRequest, GlyphMask>(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();
}

View File

@@ -0,0 +1,112 @@
using System.Text;
namespace Age.Engine.Text;
public enum GlyphRasterPolicy
{
NativeCp932Gray4,
PortableUnicode,
}
/// <summary>
/// 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.
/// </summary>
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; }
}
/// <summary>
/// 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.
/// </summary>
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<byte> 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<byte> Coverage => _coverage;
}
public interface IGlyphMaskRasterizer
{
GlyphMask Rasterize(GlyphRasterRequest request);
}

View File

@@ -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<AdvRetainedGlyphRecord> Records,
int CursorX,
int CursorY,
int ConsumedGlyphs,
int WrappedLines,
AdvTextOverflowFlags ObservedOverflow,
bool StoppedOnVerticalOverflow);
/// <summary>
/// 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.
/// </summary>
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<GlyphRasterRequest> glyphs)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentNullException.ThrowIfNull(glyphs);
var records = new List<AdvRetainedGlyphRecord>(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);
}
}