engine: render immediate text into surfaces

This commit is contained in:
gamer147
2026-07-30 18:47:35 -04:00
parent 6a8c919df1
commit c2b72cd4b9
9 changed files with 563 additions and 22 deletions

View File

@@ -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, │ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest,
│ and bootstrap/build scripts (outputs stay under disposable build/) │ and bootstrap/build scripts (outputs stay under disposable build/)
├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md) ├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md)
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine), └── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine
including the TITLE-only F4 debug scene launcher plus the optional exact Windows text adapter), including the
TITLE-only F4 debug scene launcher
``` ```
The disposable `build/page-map-<SCENE>.jsonl` files are produced by normal Godot runs and map runtime ADV The disposable `build/page-map-<SCENE>.jsonl` files are produced by normal Godot runs and map runtime ADV

View File

@@ -2961,14 +2961,14 @@ pixels differ. The screenshot is useful evidence that the difference is visible,
behavioral source. behavioral source.
The fidelity correction is consequently a decoded glyph-mask backend, not screenshot-driven embolden 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` calibration. The Windows implementation calls the same GDI APIs with the decoded `LOGFONTA` and reproduces
and reproduce AGE's integer compositor. A portable backend must expose the same mask/metrics/compositing AGE's integer compositor. A portable backend must expose the same mask/metrics/compositing contract while
contract while explicitly defining its font-substitution and rasterizer policy; FreeType output should explicitly defining its font-substitution and rasterizer policy; FreeType output should be treated as that
be treated as that backend's result, not claimed to be GDI-equivalent. Godot's one-pixel line-box backend's result, not claimed to be GDI-equivalent. Godot's one-pixel line-box compensation remains
compensation remains independently supported by the matching viewport placement. Portable configurable independently supported by the matching viewport placement. Portable configurable face
face substitutions/defaults remain deferred to the broader runtime configuration design. Implementation substitutions/defaults remain deferred to the broader runtime configuration design. The scoped architecture
is explicitly backlogged until gameplay settles; the scoped architecture and acceptance gates live in and acceptance gates live in `docs/remake-architecture-and-roadmap.md` under “AGE-exact glyph-mask text
`docs/remake-architecture-and-roadmap.md` under “AGE-exact glyph-mask text renderer.” renderer.”
The platform-neutral half of that correction landed on 2026-07-30 under `Age.Engine.Text`. 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, `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 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 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 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 core is not yet connected to live ADV or History; those paths still use Labels pending their retained-glyph
visible behavior remain unchanged until the later integration steps. materialization steps.
The independent Windows reference backend landed alongside, but outside, the neutral engine as The independent Windows reference backend landed alongside, but outside, the neutral engine as
`Age.Engine.Text.Windows.WindowsGdiGlyphMaskRasterizer`. It holds one `CreateICA("DISPLAY")` information `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 ``, 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 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 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) #### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)

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 | | 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 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 | | 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 ` 明朝`/` ゴシック` 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 ` 明朝`/` ゴシック` 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 | | 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 | | 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 | | 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

@@ -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 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 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. 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 4. **Move immediate surface strings first.** Completed 2026-07-30 for the exact Windows path. Ops
RGBA surface instead of appending `SurfaceTextDraw` metadata. Remove the separate surface-label projection `0x204`/`0x205` now convert the complete Unicode string to explicit CP932 glyph identities before changing
only after GPU and software paths prove that ordinary handle order, object alpha/tint, affine transforms, any pixels, derive AGE's rebuilt negative height/half-width and weight, and composite the exact masks into
source clipping, offscreen `0x222` captures, fills/copies, and backbuffer preservation all consume the same a cloned numbered `RgbaImage` snapshot. Successful draws publish no `SurfaceTextDraw`, so both retained
pixels. This is the smallest integration slice and directly fixes the reported surface-fade defect. 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 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 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 ABI. Build the complete line before reveal, update the canonical live cursor from measured metrics, then

View File

@@ -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<GlyphRasterRequest> 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 = " ゴシック",
};
ImmediateSurfaceTextResult result =
renderer.Render(destination, 1, 2, "Aあ", style);
Assert.Equal(new ImmediateSurfaceTextResult(2, 7, 2), result);
Assert.Collection(rasterizer.Requests,
ascii => Assert.Equal(
(" ゴシック", 4, -2, 700, 0x41, (ushort)0x41),
RequestIdentity(ascii)),
japanese => Assert.Equal(
(" ゴシック", 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<ArgumentException>(
() => 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<RenderObject> 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<RenderObject> 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]);
}
}

View File

@@ -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);
/// <summary>
/// Converts one native CP932 string into exact glyph requests and composites it directly into an
/// AGE RGBA surface. Request construction completes before any pixels change, so an unsupported
/// character cannot leave a partially drawn string behind.
/// </summary>
public sealed class ImmediateSurfaceTextRenderer
{
public const string DefaultFontFace = " 明朝";
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<GlyphRasterRequest> 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<GlyphRasterRequest> 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<GlyphRasterRequest>(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);
}
}

View File

@@ -5,6 +5,7 @@ using System.Threading;
using Age.Engine.Hosting; using Age.Engine.Hosting;
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Sys4; using Age.Engine.Sys4;
using Age.Engine.Text;
[Flags] [Flags]
public enum HostPresentationReason public enum HostPresentationReason
@@ -65,6 +66,11 @@ public sealed class GodotAdvHost : IHost
private volatile bool _stopping; private volatile bool _stopping;
private readonly object _textLock = new(); private readonly object _textLock = new();
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new(); private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
private readonly CachedGlyphMaskRasterizer? _surfaceTextMaskCache;
private readonly ImmediateSurfaceTextRenderer? _surfaceTextPixelRenderer;
private readonly GlyphRasterizerBackendInfo? _surfaceTextBackendInfo;
private string _surfaceTextFallbackReason;
private bool _surfaceTextFallbackWarningReported;
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new(); private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
private sealed class LiveTextState 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, public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas, PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
GodotTimelineLog? timeline = null, GodotTimelineLog? timeline = null,
bool synchronizeExplicitPresentation = true) bool synchronizeExplicitPresentation = true,
IGlyphMaskRasterizer? surfaceTextRasterizer = null,
string? surfaceTextFallbackReason = null)
{ {
_main = main; _res = res; _rootScene = scene; _clock = clock; _main = main; _res = res; _rootScene = scene; _clock = clock;
_locator = locator; _timeline = timeline; _locator = locator; _timeline = timeline;
@@ -127,9 +135,33 @@ public sealed class GodotAdvHost : IHost
_screenHeight = logicalCanvas.Height; _screenHeight = logicalCanvas.Height;
_slotDims[0] = (_screenWidth, _screenHeight); _slotDims[0] = (_screenWidth, _screenHeight);
_synchronizeExplicitPresentation = synchronizeExplicitPresentation; _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 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); 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) 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) lock (_textLock)
{ {
if (!_surfaceText.TryGetValue(surfaceSlot, out var draws)) 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.RemoveAll(draw => draw.X == x && draw.Y == y);
draws.Add(new SurfaceTextDraw(x, y, text, style)); 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() public (string Text, int X, int Y, int VisibleGlyphs, bool Revealing) SnapshotAdvText()

View File

@@ -4,9 +4,15 @@
<EnableDynamicLoading>true</EnableDynamicLoading> <EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<DefineConstants>$(DefineConstants);AGE_WINDOWS_GDI</DefineConstants>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\engine\Age.Engine\Age.Engine.csproj" /> <ProjectReference Include="..\engine\Age.Engine\Age.Engine.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<ProjectReference Include="..\engine\Age.Engine.Text.Windows\Age.Engine.Text.Windows.csproj" />
</ItemGroup>
<ItemGroup Condition="Exists('..\build\native\win-x64\age_movie_ffmpeg.dll')"> <ItemGroup Condition="Exists('..\build\native\win-x64\age_movie_ffmpeg.dll')">
<None Include="..\build\native\win-x64\*.dll" <None Include="..\build\native\win-x64\*.dll"
Link="%(Filename)%(Extension)" Link="%(Filename)%(Extension)"

View File

@@ -11,6 +11,10 @@ using Age.Engine.Hosting;
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Persistence; using Age.Engine.Persistence;
using Age.Engine.Sys4; using Age.Engine.Sys4;
using Age.Engine.Text;
#if AGE_WINDOWS_GDI
using Age.Engine.Text.Windows;
#endif
using Age.Engine.Vm; using Age.Engine.Vm;
using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
@@ -69,6 +73,9 @@ public partial class Main : Godot.Control
private Sys4RegIniStore? _sys4RegIniStore; private Sys4RegIniStore? _sys4RegIniStore;
private VirtualMachine _vm = null!; private VirtualMachine _vm = null!;
private GodotAdvHost _host = null!; private GodotAdvHost _host = null!;
#if AGE_WINDOWS_GDI
private WindowsGdiGlyphMaskRasterizer? _exactGlyphRasterizer;
#endif
private FullwidthTextEditorDialog? _fullwidthTextEditor; private FullwidthTextEditorDialog? _fullwidthTextEditor;
private Sys4ScriptProvider? _scripts; private Sys4ScriptProvider? _scripts;
private DebugSceneLauncher? _debugSceneLauncher; private DebugSceneLauncher? _debugSceneLauncher;
@@ -374,9 +381,44 @@ public partial class Main : Godot.Control
var resources = scripts != null var resources = scripts != null
? new ResourceMap(scripts.Catalog, trackedAssetStore) ? new ResourceMap(scripts.Catalog, trackedAssetStore)
: new ResourceMap(catalog, _assetStore); : new ResourceMap(catalog, _assetStore);
IGlyphMaskRasterizer? surfaceTextRasterizer = null;
string surfaceTextFallbackReason;
#if AGE_WINDOWS_GDI
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
{
try
{
_exactGlyphRasterizer = new WindowsGdiGlyphMaskRasterizer();
surfaceTextRasterizer = _exactGlyphRasterizer;
surfaceTextFallbackReason = "";
GD.Print(
$"[text] immediate surface strings use {_exactGlyphRasterizer.BackendInfo.Id}: " +
_exactGlyphRasterizer.BackendInfo.Detail);
}
catch (Exception error)
{
surfaceTextFallbackReason =
$"Exact Windows GDI glyph backend failed to initialize: {error.Message}";
GD.PushWarning($"[text] {surfaceTextFallbackReason}");
}
}
else
{
surfaceTextFallbackReason = availability;
GD.PushWarning(
$"[text] immediate surface strings retain the Label fallback: {availability}");
}
#else
surfaceTextFallbackReason =
"The exact Windows GDI glyph adapter is not part of this platform build.";
GD.PushWarning(
$"[text] immediate surface strings retain the Label fallback: {surfaceTextFallbackReason}");
#endif
_host = new GodotAdvHost( _host = new GodotAdvHost(
this, resources, scene, _clock, _locator, logicalCanvas, _timeline, this, resources, scene, _clock, _locator, logicalCanvas, _timeline,
synchronizeExplicitPresentation: !_selftest) synchronizeExplicitPresentation: !_selftest,
surfaceTextRasterizer: surfaceTextRasterizer,
surfaceTextFallbackReason: surfaceTextFallbackReason)
{ {
SleepScale = sleepScale, SleepScale = sleepScale,
TraceOps = _gfxLogPath != null, TraceOps = _gfxLogPath != null,
@@ -1013,6 +1055,10 @@ public partial class Main : Godot.Control
DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose(); DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose();
_gpuRenderer?.Dispose(); _gpuRenderer?.Dispose();
#if AGE_WINDOWS_GDI
_exactGlyphRasterizer?.Dispose();
_exactGlyphRasterizer = null;
#endif
if (_perf != null) if (_perf != null)
{ {
_perf.Dispose(); _perf.Dispose();
@@ -2426,6 +2472,75 @@ public partial class Main : Godot.Control
&& textEffectSmoke.GetThemeConstant("line_spacing") && textEffectSmoke.GetThemeConstant("line_spacing")
== CalibratedLineSpacing( == CalibratedLineSpacing(
8, 24, calibratedBold.GetHeight(24)); 8, 24, calibratedBold.GetHeight(24));
bool immediateSurfaceTextOk;
string immediateSurfaceTextMode;
_host.CreateTexture(997, 32, 24);
var immediateStyle = AdvTextStyle.Default with
{
PrimaryFontSize = 16,
TextColor = 0xffffff,
FontFace = ImmediateSurfaceTextRenderer.DefaultFontFace,
};
_host.DrawStringToSurface(997, 1, 1, "A", immediateStyle);
if (_host.UsesSurfaceTextPixels)
{
RgbaImage? pixels = _host.CaptureSurfacePixels(997);
GlyphRasterizerBackendInfo? backend = _host.SurfaceTextBackendInfo;
var cache = _host.SurfaceTextMaskCacheStats;
bool pixelsPresent = pixels != null
&& pixels.Pixels.Where((_, index) => 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; Window rootWindow = GetTree().Root;
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight) bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
&& _screen.GetWidth() == _screenWidth && _screen.GetWidth() == _screenWidth
@@ -2464,6 +2579,7 @@ public partial class Main : Godot.Control
&& firstRiffBoundaryOk && firstRiffBoundaryOk
&& bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk && bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk
&& bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk && bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk
&& immediateSurfaceTextOk
&& logicalCanvasOk && backbufferPreservationOk; && logicalCanvasOk && backbufferPreservationOk;
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " + if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " +
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " + $"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " +
@@ -2471,6 +2587,7 @@ public partial class Main : Godot.Control
$"first-riff-boundary=ok; " + $"first-riff-boundary=ok; " +
$"bgm-fade-replacement=ok; bgm-start-modes-stop=ok; " + $"bgm-fade-replacement=ok; bgm-start-modes-stop=ok; " +
$"text-effect-modes=ok; font-calibration=ok; " + $"text-effect-modes=ok; font-calibration=ok; " +
$"immediate-surface-text={immediateSurfaceTextMode}; " +
$"backbuffer-preservation=ok; " + $"backbuffer-preservation=ok; " +
$"logical-canvas={_screenWidth}x{_screenHeight}; " + $"logical-canvas={_screenWidth}x{_screenHeight}; " +
$"window-request={_windowOptions.Width}x{_windowOptions.Height}"); $"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-one-shot={bgmOneShotModeOk}; bgm-loop={bgmLoopModeOk}; " +
$"bgm-stop-release={bgmStopReleaseOk}; " + $"bgm-stop-release={bgmStopReleaseOk}; " +
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " + $"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " +
$"immediate-surface-text={immediateSurfaceTextOk}({immediateSurfaceTextMode}); " +
$"backbuffer-preservation={backbufferPreservationOk}; " + $"backbuffer-preservation={backbufferPreservationOk}; " +
$"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " + $"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " +
$"window-request={_windowOptions.Width}x{_windowOptions.Height}"); $"window-request={_windowOptions.Width}x{_windowOptions.Height}");