engine: render immediate text into surfaces
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 `MS 明朝`/`MS ゴシック` when available and otherwise choose the existing Japanese-font/default fallback; its `Label` path substitutes FreeType embolden and a Godot outline. `Age.Engine.Text` remains OS-neutral. The separate optional `Age.Engine.Text.Windows` reference library now owns the authored GDI calls: `CreateICA`, bounded `CreateFontIndirectA` handles, `GetTextExtentPoint32A`, and `GetGlyphOutlineA(GGO_GRAY4_BITMAP)` | Visible behavior is unchanged because the reference backend is not wired into Godot. It explicitly requires Windows ACP 932 and reports unavailable elsewhere. Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle; synthetic masks continue to test the common compositor on every platform | Integrate immediate surface pixels against this exact Windows reference, then add an explicitly non-identical portable backend/face-substitution policy. Keep the GDI project optional and out of non-Windows deliverables rather than hiding failed exact selection |
|
| ADV font discovery/raster fidelity | `Age.Engine.Text` remains OS-neutral. Godot now selects the separate `Age.Engine.Text.Windows` backend for immediate `0x204`/`0x205` strings when Windows ACP 932 is available; exact masks are cached and composited into numbered RGBA surfaces. Live ADV and History still use the existing `Label` path, which loads Windows `MS 明朝`/`MS ゴシック` when available and otherwise chooses the Japanese/default fallback with FreeType embolden and a Godot outline | Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle. Immediate surface text now obeys retained ordering, alpha/tint/fade, transforms, clipping, capture, and surface mutation in both render paths. Non-Windows/non-932 runs report why exact selection failed and atomically retain the old surface-label projection; they do not claim GDI parity | Add an explicitly non-identical portable backend and face-substitution policy, then migrate live ADV/History and remove Label compensations. Keep the GDI project out of non-Windows deliverables rather than hiding failed exact selection |
|
||||||
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
|
| 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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
181
engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs
Normal file
181
engine/Age.Engine.Tests/ImmediateSurfaceTextRendererTests.cs
Normal 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 = "MS ゴシック",
|
||||||
|
};
|
||||||
|
|
||||||
|
ImmediateSurfaceTextResult result =
|
||||||
|
renderer.Render(destination, 1, 2, "Aあ", style);
|
||||||
|
|
||||||
|
Assert.Equal(new ImmediateSurfaceTextResult(2, 7, 2), result);
|
||||||
|
Assert.Collection(rasterizer.Requests,
|
||||||
|
ascii => Assert.Equal(
|
||||||
|
("MS ゴシック", 4, -2, 700, 0x41, (ushort)0x41),
|
||||||
|
RequestIdentity(ascii)),
|
||||||
|
japanese => Assert.Equal(
|
||||||
|
("MS ゴシック", 4, -2, 700, 0x3042, (ushort)0x82a0),
|
||||||
|
RequestIdentity(japanese)));
|
||||||
|
Assert.Equal((255, 255, 255, 255), Pixel(destination, 1, 2));
|
||||||
|
Assert.Equal((255, 255, 255, 255), Pixel(destination, 3, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsupportedCp932CharacterFailsBeforeRasterizationOrPixelMutation()
|
||||||
|
{
|
||||||
|
var rasterizer = new RecordingRasterizer();
|
||||||
|
var renderer = new ImmediateSurfaceTextRenderer(rasterizer);
|
||||||
|
var destination = Image(8, 8);
|
||||||
|
|
||||||
|
Assert.Throws<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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs
Normal file
103
engine/Age.Engine/Text/ImmediateSurfaceTextRenderer.cs
Normal 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 = "MS 明朝";
|
||||||
|
|
||||||
|
private static readonly Encoding Cp932 = CreateCp932();
|
||||||
|
private readonly IGlyphMaskRasterizer _rasterizer;
|
||||||
|
|
||||||
|
public ImmediateSurfaceTextRenderer(IGlyphMaskRasterizer rasterizer)
|
||||||
|
=> _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
|
||||||
|
|
||||||
|
public ImmediateSurfaceTextResult Render(
|
||||||
|
RgbaImage destination,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
string text,
|
||||||
|
AdvTextStyle style)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(destination);
|
||||||
|
ArgumentNullException.ThrowIfNull(text);
|
||||||
|
IReadOnlyList<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
120
godot/Main.cs
120
godot/Main.cs
@@ -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}");
|
||||||
|
|||||||
Reference in New Issue
Block a user