engine: complete portable glyph text backend
This commit is contained in:
@@ -118,7 +118,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
||||
├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md)
|
||||
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine
|
||||
plus the optional exact Windows text adapter), including the
|
||||
TITLE-only F4 debug scene launcher
|
||||
portable TextServer glyph adapter, per-profile font-substitution
|
||||
configuration, and TITLE-only F4 debug scene launcher
|
||||
```
|
||||
|
||||
The disposable `build/page-map-<SCENE>.jsonl` files are produced by normal Godot runs and map runtime ADV
|
||||
|
||||
@@ -3650,6 +3650,30 @@ The required fidelity follow-up is implementation of that decoded mask contract;
|
||||
validation evidence, not the source of rendering behavior. That work is deferred until gameplay settles
|
||||
and is scoped in `docs/remake-architecture-and-roadmap.md`; it is not the next Phase A/B slice.
|
||||
|
||||
## ADV portable glyph backend and Label retirement (2026-07-30)
|
||||
|
||||
Godot TextServer is now the portable glyph-mask source. It exposes rasterized atlas coverage plus glyph
|
||||
offset and horizontal advance without adding a second native font library; AGE's existing deterministic
|
||||
compositor still owns effects, integer blending, layout, surfaces, and retained publication. Portable
|
||||
requests use Unicode directly and carry CP932 only when representable so the existing Japanese wrap rules
|
||||
remain available. TextServer's second advance component is intentionally ignored because it describes
|
||||
vertical-layout advance rather than GDI's horizontal `gmCellIncY`.
|
||||
|
||||
`godot/config/himegari-text-rendering.json` owns the policy id, bounded font/glyph cache capacities, and
|
||||
ordered Mincho/Gothic/default system-family substitutions. The backend reports itself as explicitly
|
||||
non-GDI-exact. Startup option `--text-backend auto|gdi|portable` retains the acceptance seam: `auto` chooses
|
||||
the exact ACP-932 GDI path when available and otherwise the portable policy, while forced `gdi` fails
|
||||
clearly instead of changing fidelity silently.
|
||||
|
||||
After forced portable and forced exact modes both passed immediate-surface, live-layout, History,
|
||||
complete-line, bounded-cache, retained publication/reset, GPU-upload, surface-copy, and clear gates, the
|
||||
gameplay `Label` pools were deleted. Immediate, live, and History text no longer has a top-level overlay or
|
||||
metadata projection path; mask/backend failures are explicit, and all gameplay text inherits the bound
|
||||
surface's z-order, alpha, tint, fade, transform, clipping, capture, and lifetime.
|
||||
|
||||
Validation: engine **580/580**, zero-warning Godot build, and threaded `SELFTEST OK` in both forced
|
||||
`portable-godot-textserver` and forced `windows-gdi-gray4` modes.
|
||||
|
||||
## Persistence native-format reconnaissance complete (2026-07-24)
|
||||
|
||||
The deferred save/profile ownership question now has a compatibility-mode answer. The remaining native
|
||||
|
||||
@@ -33,7 +33,7 @@ or replaced before claiming portable exports.
|
||||
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
|
||||
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
|
||||
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
|
||||
| ADV font discovery/raster fidelity | `Age.Engine.Text` remains OS-neutral. Godot selects the separate `Age.Engine.Text.Windows` backend for immediate `0x204`/`0x205` strings, live ADV, and retained History when Windows ACP 932 is available; exact masks are cached, composited into layout/numbered RGBA surfaces, and published through ordinary retained handles. The wait atlas is likewise bound at its script-configured handle and no longer uses a detached Godot node. The fallback loads Windows `MS 明朝`/`MS ゴシック` when available and otherwise chooses the Japanese/default font with FreeType embolden and a Godot outline | Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle. Immediate, live, History, and wait-marker presentation now obey retained ordering, alpha/tint/fade, transforms, clipping, capture, HIDEWIN suspension, partial erase/republication, reset, and History teardown. Non-Windows/non-932 runs report why exact text selection failed and atomically retain the old Label presentations; they do not claim GDI parity | Add an explicitly non-identical portable backend and face-substitution policy, then remove the unavailable-rasterizer Label fallback and its remaining overlay compensations. Keep the GDI project out of non-Windows deliverables rather than hiding failed exact selection |
|
||||
| ADV font discovery/raster fidelity | `Age.Engine.Text` remains OS-neutral. On compatible ACP-932 Windows, `auto` selects the separate exact `Age.Engine.Text.Windows` GDI gray-4 backend. Otherwise Godot TextServer supplies Unicode masks and metrics from ordered system-font substitutions in `godot/config/himegari-text-rendering.json`; this policy is explicitly not GDI pixel-exact. Immediate `0x204`/`0x205`, live ADV, retained History, and the wait atlas all enter ordinary RGBA surfaces/retained handles | Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle. Forced exact and forced portable self-tests cover immediate, live, and History rasterization, bounded caching, complete-line materialization, retained ordering, alpha/tint/fade, transforms, clipping, capture, HIDEWIN suspension, partial erase/republication, reset, and teardown. Gameplay `Label` pools and overlay projection are gone | Produce and smoke-test Linux/macOS exports, confirm installed family availability/fallback selection there, and keep the GDI project out of non-Windows deliverables |
|
||||
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
|
||||
| Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target |
|
||||
| Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS |
|
||||
|
||||
@@ -635,16 +635,17 @@ primitive, which cannot be made equivalent by choosing another embolden constant
|
||||
the persisted backlog. The configured wait atlas now binds at op `0x212`'s handle, advances source cells
|
||||
only on the configured frame boundary, participates in op-`0x20a` republication and HIDEWIN suspension,
|
||||
and disappears when the wait service stops. The standalone Godot wait `TextureRect` and its raw-callback
|
||||
visibility exception are gone. Exact History no longer enters the layout-keyed Label pool; that code and
|
||||
the live/immediate Label pools remain solely as the explicit unavailable-rasterizer fallback until step 7
|
||||
supplies the portable glyph backend. Focused lifecycle tests, all 579 engine tests, the exact History
|
||||
visibility exception are gone. At this checkpoint exact History no longer entered the layout-keyed Label
|
||||
pool; step 7 subsequently supplied the portable backend and removed every remaining gameplay Label pool.
|
||||
Focused lifecycle tests, all 579 engine tests, the exact History
|
||||
Godot self-test, and a real SC0000 Vulkan capture cover the retained path.
|
||||
7. **Complete and select the portable backend.** Spike the smallest maintainable way to obtain portable
|
||||
font masks/metrics (Godot/TextServer offscreen extraction versus a bounded FreeType-compatible adapter)
|
||||
before choosing a dependency. Define face substitution and raster policy as profile/configuration data,
|
||||
keep its output explicitly non-identical to GDI, and run the same layout/compositor tests. Retain a
|
||||
development-only backend switch through acceptance; remove the old Label fallback only when both exact
|
||||
Windows and portable startup/legibility gates pass.
|
||||
7. **Complete and select the portable backend — completed 2026-07-30.** Godot's TextServer atlas is the
|
||||
portable mask/metrics source, avoiding another native dependency. Profile data in
|
||||
`godot/config/himegari-text-rendering.json` owns ordered Mincho/Gothic substitutions and bounded cache
|
||||
sizes. `auto` selects exact GDI when its ACP-932 gate passes and otherwise selects the explicitly
|
||||
non-pixel-exact Unicode backend; development runs can force either with `--text-backend`. Both modes pass
|
||||
the same immediate/live/History retained-glyph self-test. Gameplay `Label` pools and surface-text overlay
|
||||
projection are removed, so text always participates in the ordinary surface compositor.
|
||||
|
||||
**Acceptance gates:**
|
||||
|
||||
|
||||
@@ -346,6 +346,11 @@ use the last `input-wait` event in `timeline.jsonl` as the authoritative manual
|
||||
are current-working-directory-relative. This is the stable handoff for future game-profile launchers.
|
||||
- `--scene <NAME>` — override the default `SYSTEM4` root with a direct diagnostic scene, e.g. `--scene SC0240` (executes 29 nested subroutines).
|
||||
- `--selftest` — headless; runs a **synthesized** scene through the thread/suspend/`CallDeferred` plumbing and asserts it matches a live headless run (full handling; no vm0/frozen golden). Exits.
|
||||
- `--text-backend auto|gdi|portable` — select gameplay glyph-mask rasterization. `auto` (default) uses the
|
||||
exact GDI gray-4 backend only when the Windows ACP-932 gate passes, otherwise the explicitly non-identical
|
||||
Godot TextServer Unicode policy. `gdi` fails startup when exact selection is unavailable; `portable`
|
||||
never initializes GDI and is the cross-platform acceptance/debug mode. The selected backend and portable
|
||||
policy id are printed at startup.
|
||||
- `--window-width <pixels>` / `--window-height <pixels>` — override only the physical windowed client
|
||||
dimensions; each omitted dimension defaults independently to the SYS4INI logical canvas. Godot keeps
|
||||
that logical canvas and aspect-preserving letterboxing, so these options do not change VM coordinates,
|
||||
|
||||
@@ -64,6 +64,30 @@ public class ImmediateSurfaceTextRendererTests
|
||||
Assert.All(destination.Pixels, value => Assert.Equal(0, value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortableRequestsPreserveUnicodeOutsideCp932()
|
||||
{
|
||||
IReadOnlyList<GlyphRasterRequest> requests =
|
||||
ImmediateSurfaceTextRenderer.CreateRequests(
|
||||
"A😀",
|
||||
Style(),
|
||||
GlyphRasterPolicy.PortableUnicode);
|
||||
|
||||
Assert.Collection(
|
||||
requests,
|
||||
request =>
|
||||
{
|
||||
Assert.Equal(GlyphRasterPolicy.PortableUnicode, request.Policy);
|
||||
Assert.Equal((ushort)0x41, request.Cp932Code);
|
||||
},
|
||||
request =>
|
||||
{
|
||||
Assert.Equal(GlyphRasterPolicy.PortableUnicode, request.Policy);
|
||||
Assert.Equal(0x1f600, request.UnicodeScalar);
|
||||
Assert.Null(request.Cp932Code);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(24, 24)]
|
||||
[InlineData(25, 24)]
|
||||
|
||||
@@ -10,8 +10,8 @@ public readonly record struct ImmediateSurfaceTextResult(
|
||||
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
|
||||
/// Converts one string into backend-policy 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
|
||||
@@ -20,9 +20,15 @@ public sealed class ImmediateSurfaceTextRenderer
|
||||
|
||||
private static readonly Encoding Cp932 = CreateCp932();
|
||||
private readonly IGlyphMaskRasterizer _rasterizer;
|
||||
private readonly GlyphRasterPolicy _policy;
|
||||
|
||||
public ImmediateSurfaceTextRenderer(IGlyphMaskRasterizer rasterizer)
|
||||
=> _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
|
||||
public ImmediateSurfaceTextRenderer(
|
||||
IGlyphMaskRasterizer rasterizer,
|
||||
GlyphRasterPolicy policy = GlyphRasterPolicy.NativeCp932Gray4)
|
||||
{
|
||||
_rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
|
||||
_policy = policy;
|
||||
}
|
||||
|
||||
public ImmediateSurfaceTextResult Render(
|
||||
RgbaImage destination,
|
||||
@@ -33,7 +39,7 @@ public sealed class ImmediateSurfaceTextRenderer
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
IReadOnlyList<GlyphRasterRequest> requests = CreateRequests(text, style);
|
||||
IReadOnlyList<GlyphRasterRequest> requests = CreateRequests(text, style, _policy);
|
||||
int cursorX = x;
|
||||
int cursorY = y;
|
||||
foreach (GlyphRasterRequest request in requests)
|
||||
@@ -49,7 +55,8 @@ public sealed class ImmediateSurfaceTextRenderer
|
||||
|
||||
public static IReadOnlyList<GlyphRasterRequest> CreateRequests(
|
||||
string text,
|
||||
AdvTextStyle style)
|
||||
AdvTextStyle style,
|
||||
GlyphRasterPolicy policy = GlyphRasterPolicy.NativeCp932Gray4)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
int requestedHeight = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
|
||||
@@ -63,26 +70,28 @@ public sealed class ImmediateSurfaceTextRenderer
|
||||
|
||||
foreach (Rune rune in text.EnumerateRunes())
|
||||
{
|
||||
byte[] encoded;
|
||||
ushort? cp932Code = null;
|
||||
try
|
||||
{
|
||||
encoded = Cp932.GetBytes(rune.ToString());
|
||||
byte[] encoded = Cp932.GetBytes(rune.ToString());
|
||||
if (encoded.Length is < 1 or > 2)
|
||||
throw new ArgumentException(
|
||||
$"U+{rune.Value:X4} encoded to unsupported CP932 length {encoded.Length}.",
|
||||
nameof(text));
|
||||
cp932Code = encoded.Length == 1
|
||||
? encoded[0]
|
||||
: (ushort)((encoded[0] << 8) | encoded[1]);
|
||||
}
|
||||
catch (EncoderFallbackException error)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"U+{rune.Value:X4} is not representable in native CP932 text.", nameof(text), error);
|
||||
if (policy == GlyphRasterPolicy.NativeCp932Gray4)
|
||||
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));
|
||||
rune.Value, cp932Code, policy));
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
|
||||
@@ -65,21 +65,16 @@ public sealed class GodotAdvHost : IHost
|
||||
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
|
||||
private volatile bool _stopping;
|
||||
private readonly object _textLock = new();
|
||||
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
|
||||
private readonly CachedGlyphMaskRasterizer? _surfaceTextMaskCache;
|
||||
private readonly ImmediateSurfaceTextRenderer? _surfaceTextPixelRenderer;
|
||||
private readonly RetainedGlyphLayoutEngine? _retainedGlyphLayoutEngine;
|
||||
private readonly GlyphRasterizerBackendInfo? _surfaceTextBackendInfo;
|
||||
private string _surfaceTextFallbackReason;
|
||||
private bool _surfaceTextFallbackWarningReported;
|
||||
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
|
||||
private readonly CachedGlyphMaskRasterizer _surfaceTextMaskCache;
|
||||
private readonly ImmediateSurfaceTextRenderer _surfaceTextPixelRenderer;
|
||||
private readonly RetainedGlyphLayoutEngine _retainedGlyphLayoutEngine;
|
||||
private readonly GlyphRasterizerBackendInfo _surfaceTextBackendInfo;
|
||||
private readonly HashSet<int> _retainedHistoryLayouts = new();
|
||||
private sealed class LiveTextState
|
||||
{
|
||||
public required AdvLiveTextRun Run;
|
||||
public required long StartedMs;
|
||||
public required int GlyphDelayMilliseconds;
|
||||
public bool RetainedPixels;
|
||||
public int FirstGlyphIndex;
|
||||
public int GlyphCount;
|
||||
}
|
||||
@@ -137,10 +132,10 @@ public sealed class GodotAdvHost : IHost
|
||||
|
||||
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
|
||||
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
|
||||
IGlyphMaskRasterizer surfaceTextRasterizer,
|
||||
GodotTimelineLog? timeline = null,
|
||||
bool synchronizeExplicitPresentation = true,
|
||||
IGlyphMaskRasterizer? surfaceTextRasterizer = null,
|
||||
string? surfaceTextFallbackReason = null)
|
||||
int surfaceTextMaskCacheCapacity = 2048)
|
||||
{
|
||||
_main = main; _res = res; _rootScene = scene; _clock = clock;
|
||||
_locator = locator; _timeline = timeline;
|
||||
@@ -148,35 +143,30 @@ public sealed class GodotAdvHost : IHost
|
||||
_screenHeight = logicalCanvas.Height;
|
||||
_slotDims[0] = (_screenWidth, _screenHeight);
|
||||
_synchronizeExplicitPresentation = synchronizeExplicitPresentation;
|
||||
ArgumentNullException.ThrowIfNull(surfaceTextRasterizer);
|
||||
_surfaceTextBackendInfo =
|
||||
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo;
|
||||
if (surfaceTextRasterizer != null)
|
||||
{
|
||||
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
|
||||
surfaceTextRasterizer, capacity: 2048);
|
||||
_surfaceTextPixelRenderer =
|
||||
new ImmediateSurfaceTextRenderer(_surfaceTextMaskCache);
|
||||
_retainedGlyphLayoutEngine =
|
||||
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
|
||||
}
|
||||
_surfaceTextFallbackReason = surfaceTextFallbackReason
|
||||
?? (surfaceTextRasterizer == null
|
||||
? "No glyph-mask rasterizer was selected."
|
||||
: "");
|
||||
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo
|
||||
?? throw new ArgumentException(
|
||||
"Gameplay glyph rasterizers must identify their policy.",
|
||||
nameof(surfaceTextRasterizer));
|
||||
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
|
||||
surfaceTextRasterizer, capacity: surfaceTextMaskCacheCapacity);
|
||||
_surfaceTextPixelRenderer =
|
||||
new ImmediateSurfaceTextRenderer(
|
||||
_surfaceTextMaskCache, _surfaceTextBackendInfo.Policy);
|
||||
_retainedGlyphLayoutEngine =
|
||||
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
|
||||
}
|
||||
|
||||
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
|
||||
public bool UsesSurfaceTextPixels => _surfaceTextPixelRenderer != null;
|
||||
public GlyphRasterizerBackendInfo? SurfaceTextBackendInfo => _surfaceTextBackendInfo;
|
||||
public string SurfaceTextFallbackReason => _surfaceTextFallbackReason;
|
||||
public bool UsesSurfaceTextPixels => true;
|
||||
public GlyphRasterizerBackendInfo SurfaceTextBackendInfo => _surfaceTextBackendInfo;
|
||||
public (int Count, int Capacity, long Hits, long Misses) SurfaceTextMaskCacheStats
|
||||
=> _surfaceTextMaskCache == null
|
||||
? (0, 0, 0, 0)
|
||||
: (
|
||||
_surfaceTextMaskCache.Count,
|
||||
_surfaceTextMaskCache.Capacity,
|
||||
_surfaceTextMaskCache.Hits,
|
||||
_surfaceTextMaskCache.Misses);
|
||||
=> (
|
||||
_surfaceTextMaskCache.Count,
|
||||
_surfaceTextMaskCache.Capacity,
|
||||
_surfaceTextMaskCache.Hits,
|
||||
_surfaceTextMaskCache.Misses);
|
||||
|
||||
public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
|
||||
|
||||
@@ -271,79 +261,12 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
|
||||
public void ShowText(int offset, string text)
|
||||
{
|
||||
AdvTextLayoutSnapshot layout;
|
||||
int delay;
|
||||
lock (_textLock)
|
||||
{
|
||||
layout = new AdvTextLayoutSnapshot(
|
||||
_currentAdvLayout, _screenWidth, _screenHeight, 0, 0,
|
||||
_advTextX, _advTextY, _screenWidth, _screenHeight);
|
||||
delay = _messageGlyphDelayMilliseconds;
|
||||
}
|
||||
ShowText(new AdvLiveTextRun(
|
||||
offset, layout, AdvTextStyle.Default, text, Array.Empty<string>()), delay);
|
||||
}
|
||||
=> throw new NotSupportedException(
|
||||
"Godot gameplay text requires a retained layout binding.");
|
||||
|
||||
public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds)
|
||||
{
|
||||
Captured.Add((run.SourceOffset, run.Text));
|
||||
_locator.Text(run.SourceOffset, run.Text);
|
||||
int delay = System.Math.Max(0, glyphDelayMilliseconds);
|
||||
var state = new LiveTextState
|
||||
{
|
||||
Run = run,
|
||||
StartedMs = _clock.NowMs,
|
||||
GlyphDelayMilliseconds = delay,
|
||||
};
|
||||
lock (_textLock)
|
||||
{
|
||||
_liveText.Add(state);
|
||||
_activeLiveText = state;
|
||||
_advText = run.Text;
|
||||
_advTextX = run.Layout.CursorX;
|
||||
_advTextY = run.Layout.CursorY;
|
||||
_currentAdvLayout = run.Layout.Slot;
|
||||
_advTextStartedMs = state.StartedMs;
|
||||
_activeGlyphDelayMilliseconds = delay;
|
||||
_advTextForceComplete = _messageSkipActive;
|
||||
IsTextRevealing = run.Text.Length > 0 && delay > 0 && !_messageSkipActive;
|
||||
}
|
||||
_timeline?.State("text-reveal", new()
|
||||
{
|
||||
["offset"] = $"0x{run.SourceOffset:x}",
|
||||
["layout"] = run.Layout.Slot,
|
||||
["x"] = run.Layout.OriginX + run.Layout.CursorX,
|
||||
["y"] = run.Layout.OriginY + run.Layout.CursorY,
|
||||
["glyphs"] = run.Text.Length, ["delay_ms"] = delay,
|
||||
});
|
||||
if (!IsTextRevealing)
|
||||
{
|
||||
Interlocked.Exchange(ref _presentRequested, 1);
|
||||
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
|
||||
return;
|
||||
}
|
||||
bool scriptSuspended = SuspendScriptForPresentation();
|
||||
try
|
||||
{
|
||||
RequestSynchronizedPresentation();
|
||||
while (IsTextRevealing && !_stopping)
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
if (_advTextForceComplete
|
||||
|| _clock.NowMs - _advTextStartedMs >= run.Text.Length * (long)delay)
|
||||
IsTextRevealing = false;
|
||||
}
|
||||
if (IsTextRevealing) _frameSignal.WaitOne(50);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ResumeScriptAfterPresentation(scriptSuspended);
|
||||
}
|
||||
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
|
||||
}
|
||||
=> throw new NotSupportedException(
|
||||
"Godot gameplay text requires a retained layout binding.");
|
||||
|
||||
public AdvRetainedTextRunResult? ShowText(
|
||||
GfxState gfx,
|
||||
@@ -351,18 +274,15 @@ public sealed class GodotAdvHost : IHost
|
||||
AdvLiveTextRun run,
|
||||
int glyphDelayMilliseconds)
|
||||
{
|
||||
if (_retainedGlyphLayoutEngine == null
|
||||
|| binding.LayoutSlot != run.Layout.Slot
|
||||
if (binding.LayoutSlot != run.Layout.Slot
|
||||
|| binding.FirstObjectHandle < 0
|
||||
|| binding.ObjectCapacity <= 0
|
||||
|| !TryPrepareRetainedTextRun(
|
||||
gfx, binding, run,
|
||||
out RetainedAdvTextLayoutPresentation? presentation,
|
||||
out AdvRetainedTextRunResult result))
|
||||
{
|
||||
ShowText(run, glyphDelayMilliseconds);
|
||||
return null;
|
||||
}
|
||||
|| binding.ObjectCapacity <= 0)
|
||||
throw new InvalidOperationException(
|
||||
$"ADV layout {run.Layout.Slot} has no retained presentation binding.");
|
||||
PrepareRetainedTextRun(
|
||||
gfx, binding, run,
|
||||
out RetainedAdvTextLayoutPresentation? presentation,
|
||||
out AdvRetainedTextRunResult result);
|
||||
|
||||
Captured.Add((run.SourceOffset, run.Text));
|
||||
_locator.Text(run.SourceOffset, run.Text);
|
||||
@@ -376,7 +296,6 @@ public sealed class GodotAdvHost : IHost
|
||||
Run = run,
|
||||
StartedMs = _clock.NowMs,
|
||||
GlyphDelayMilliseconds = delay,
|
||||
RetainedPixels = true,
|
||||
FirstGlyphIndex = result.FirstGlyphIndex,
|
||||
GlyphCount = revealGlyphCount,
|
||||
};
|
||||
@@ -451,7 +370,7 @@ public sealed class GodotAdvHost : IHost
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool TryPrepareRetainedTextRun(
|
||||
private void PrepareRetainedTextRun(
|
||||
GfxState gfx,
|
||||
AdvTextLayoutPresentationBinding binding,
|
||||
AdvLiveTextRun run,
|
||||
@@ -461,12 +380,9 @@ public sealed class GodotAdvHost : IHost
|
||||
presentation = null!;
|
||||
result = default;
|
||||
if (run.Layout.Width <= 0 || run.Layout.Height <= 0)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
throw new InvalidOperationException(
|
||||
$"ADV layout {run.Layout.Slot} has invalid dimensions " +
|
||||
$"{run.Layout.Width}x{run.Layout.Height}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
RgbaImage destination = ResolveSurfacePixels(binding.SourceSurfaceSlot)
|
||||
?? new RgbaImage(
|
||||
@@ -475,43 +391,27 @@ public sealed class GodotAdvHost : IHost
|
||||
new byte[checked(run.Layout.Width * run.Layout.Height * 4)]);
|
||||
if (destination.Width != run.Layout.Width
|
||||
|| destination.Height != run.Layout.Height)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
throw new InvalidOperationException(
|
||||
$"ADV layout {run.Layout.Slot} surface {binding.SourceSurfaceSlot} is " +
|
||||
$"{destination.Width}x{destination.Height}; expected " +
|
||||
$"{run.Layout.Width}x{run.Layout.Height}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var updated = new RgbaImage(
|
||||
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
|
||||
GlyphTextLayoutResult rendered;
|
||||
try
|
||||
{
|
||||
IReadOnlyList<GlyphRasterRequest> requests =
|
||||
ImmediateSurfaceTextRenderer.CreateRequests(run.Text, run.Style);
|
||||
rendered = _retainedGlyphLayoutEngine!.Render(
|
||||
updated,
|
||||
new GlyphTextLayoutOptions(
|
||||
run.Layout.CursorX,
|
||||
run.Layout.CursorY,
|
||||
binding.ResetCursorX,
|
||||
run.Layout.Right,
|
||||
run.Layout.Bottom,
|
||||
WrapHorizontally: true,
|
||||
run.Style),
|
||||
requests);
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is ArgumentException
|
||||
or InvalidOperationException
|
||||
or PlatformNotSupportedException
|
||||
or System.ComponentModel.Win32Exception)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
$"{error.GetType().Name}: {error.Message}");
|
||||
return false;
|
||||
}
|
||||
IReadOnlyList<GlyphRasterRequest> requests =
|
||||
ImmediateSurfaceTextRenderer.CreateRequests(
|
||||
run.Text, run.Style, _surfaceTextBackendInfo.Policy);
|
||||
GlyphTextLayoutResult rendered = _retainedGlyphLayoutEngine.Render(
|
||||
updated,
|
||||
new GlyphTextLayoutOptions(
|
||||
run.Layout.CursorX,
|
||||
run.Layout.CursorY,
|
||||
binding.ResetCursorX,
|
||||
run.Layout.Right,
|
||||
run.Layout.Bottom,
|
||||
WrapHorizontally: true,
|
||||
run.Style),
|
||||
requests);
|
||||
|
||||
lock (_textLock)
|
||||
{
|
||||
@@ -522,11 +422,8 @@ public sealed class GodotAdvHost : IHost
|
||||
_retainedTextLayouts.Add(binding.LayoutSlot, presentation);
|
||||
}
|
||||
else if (presentation.Binding != binding)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
throw new InvalidOperationException(
|
||||
$"ADV layout {binding.LayoutSlot} changed its retained binding without reset.");
|
||||
return false;
|
||||
}
|
||||
int first = presentation.Append(
|
||||
rendered.Records, run.Layout.OriginX, run.Layout.OriginY);
|
||||
result = new AdvRetainedTextRunResult(
|
||||
@@ -541,7 +438,6 @@ public sealed class GodotAdvHost : IHost
|
||||
_surfaceImages[binding.SourceSurfaceSlot] = updated;
|
||||
_slotDims[binding.SourceSurfaceSlot] = (updated.Width, updated.Height);
|
||||
gfx.CreateSurface(binding.SourceSurfaceSlot);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetAdvTextCursor(int layoutSlot, int x, int y)
|
||||
@@ -560,46 +456,19 @@ public sealed class GodotAdvHost : IHost
|
||||
|
||||
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style)
|
||||
{
|
||||
if (_surfaceTextPixelRenderer != null
|
||||
&& TryDrawStringPixels(surfaceSlot, x, y, text, style))
|
||||
{
|
||||
lock (_textLock)
|
||||
if (_surfaceText.TryGetValue(surfaceSlot, out var fallbackDraws))
|
||||
{
|
||||
fallbackDraws.RemoveAll(draw => draw.X == x && draw.Y == y);
|
||||
if (fallbackDraws.Count == 0) _surfaceText.Remove(surfaceSlot);
|
||||
}
|
||||
_timeline?.Event("draw-string", new()
|
||||
{
|
||||
["surface"] = surfaceSlot,
|
||||
["x"] = x,
|
||||
["y"] = y,
|
||||
["text"] = text,
|
||||
["presentation"] = "rgba-glyph-mask",
|
||||
["backend"] = _surfaceTextBackendInfo?.Id ?? "unidentified",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_textLock)
|
||||
{
|
||||
if (!_surfaceText.TryGetValue(surfaceSlot, out var draws))
|
||||
_surfaceText[surfaceSlot] = draws = new List<SurfaceTextDraw>();
|
||||
draws.RemoveAll(draw => draw.X == x && draw.Y == y);
|
||||
draws.Add(new SurfaceTextDraw(x, y, text, style));
|
||||
}
|
||||
DrawStringPixels(surfaceSlot, x, y, text, style);
|
||||
_timeline?.Event("draw-string", new()
|
||||
{
|
||||
["surface"] = surfaceSlot,
|
||||
["x"] = x,
|
||||
["y"] = y,
|
||||
["text"] = text,
|
||||
["presentation"] = "label-fallback",
|
||||
["reason"] = _surfaceTextFallbackReason,
|
||||
["presentation"] = "rgba-glyph-mask",
|
||||
["backend"] = _surfaceTextBackendInfo.Id,
|
||||
});
|
||||
}
|
||||
|
||||
private bool TryDrawStringPixels(
|
||||
private void DrawStringPixels(
|
||||
int surfaceSlot, int x, int y, string text, AdvTextStyle style)
|
||||
{
|
||||
RgbaImage? destination = ResolveSurfacePixels(surfaceSlot);
|
||||
@@ -611,40 +480,15 @@ public sealed class GodotAdvHost : IHost
|
||||
dimensions.W, dimensions.H,
|
||||
new byte[checked(dimensions.W * dimensions.H * 4)]);
|
||||
if (destination == null || destination.Width <= 0 || destination.Height <= 0)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
throw new InvalidOperationException(
|
||||
$"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;
|
||||
}
|
||||
_surfaceTextPixelRenderer.Render(updated, x, y, text, style);
|
||||
|
||||
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()
|
||||
@@ -660,30 +504,10 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<LiveAdvTextSnapshot> SnapshotLiveAdvText()
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
var snapshot = new List<LiveAdvTextSnapshot>(_liveText.Count);
|
||||
foreach (LiveTextState state in _liveText)
|
||||
{
|
||||
if (state.RetainedPixels) continue;
|
||||
bool revealing = ReferenceEquals(state, _activeLiveText) && IsTextRevealing;
|
||||
int visible = !revealing || _advTextForceComplete || state.GlyphDelayMilliseconds == 0
|
||||
? state.Run.Text.Length
|
||||
: (int)System.Math.Clamp(
|
||||
(_clock.NowMs - state.StartedMs) / state.GlyphDelayMilliseconds + 1,
|
||||
0, state.Run.Text.Length);
|
||||
snapshot.Add(new LiveAdvTextSnapshot(state.Run, visible, revealing));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layout that owns the ordinary ADV overlay. Nested callback scripts such as HISTORY can select and
|
||||
/// mutate other layouts while the parent wait remains parked; those transient selections must not move
|
||||
/// the parent page when its overlay becomes visible again.
|
||||
/// Layout that owns the ordinary retained ADV page. Nested callback scripts such as HISTORY can select
|
||||
/// and mutate other layouts while the parent wait remains parked; those transient selections must not
|
||||
/// move the parent page when its retained range is restored.
|
||||
/// </summary>
|
||||
public int AdvPageLayoutSlot
|
||||
{
|
||||
@@ -694,28 +518,11 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SurfaceTextDraw> SnapshotSurfaceText(int surfaceSlot)
|
||||
{
|
||||
lock (_textLock)
|
||||
return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>();
|
||||
}
|
||||
|
||||
public void SnapshotSurfaceText(int surfaceSlot, List<SurfaceTextDraw> snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
lock (_textLock)
|
||||
{
|
||||
snapshot.Clear();
|
||||
if (_surfaceText.TryGetValue(surfaceSlot, out var draws)) snapshot.AddRange(draws);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearRenderedAdvTextLayout(int layoutSlot)
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
int slot = layoutSlot == 0 ? _currentAdvLayout : layoutSlot;
|
||||
_historyText.Remove(slot);
|
||||
if (_retainedTextLayouts.ContainsKey(slot)) return;
|
||||
_liveText.RemoveAll(state => state.Run.Layout.Slot == slot);
|
||||
if (_activeLiveText?.Run.Layout.Slot == slot)
|
||||
@@ -733,7 +540,6 @@ public sealed class GodotAdvHost : IHost
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
_historyText.Remove(binding.LayoutSlot);
|
||||
_retainedHistoryLayouts.Remove(binding.LayoutSlot);
|
||||
_retainedTextLayouts.Remove(binding.LayoutSlot);
|
||||
_liveText.RemoveAll(
|
||||
@@ -770,18 +576,6 @@ public sealed class GodotAdvHost : IHost
|
||||
});
|
||||
}
|
||||
|
||||
public void RenderTextHistory(AdvTextHistoryRenderBatch batch)
|
||||
{
|
||||
lock (_textLock) _historyText[batch.LayoutSlot] = batch;
|
||||
_timeline?.Event("history-render", new()
|
||||
{
|
||||
["layout"] = batch.LayoutSlot, ["record"] = batch.FirstRecordIndex,
|
||||
["x"] = batch.Layout.OriginX + batch.Layout.CursorX,
|
||||
["y"] = batch.Layout.OriginY + batch.Layout.CursorY,
|
||||
["text"] = batch.Text,
|
||||
});
|
||||
}
|
||||
|
||||
public bool RenderTextHistory(
|
||||
GfxState gfx,
|
||||
AdvTextLayoutPresentationBinding binding,
|
||||
@@ -793,25 +587,21 @@ public sealed class GodotAdvHost : IHost
|
||||
batch.Style,
|
||||
batch.Text,
|
||||
Array.Empty<string>());
|
||||
if (_retainedGlyphLayoutEngine == null
|
||||
|| binding.LayoutSlot != batch.LayoutSlot
|
||||
if (binding.LayoutSlot != batch.LayoutSlot
|
||||
|| binding.FirstObjectHandle < 0
|
||||
|| binding.ObjectCapacity <= 0
|
||||
|| !TryPrepareRetainedTextRun(
|
||||
gfx, binding, run,
|
||||
out RetainedAdvTextLayoutPresentation? presentation,
|
||||
out AdvRetainedTextRunResult result))
|
||||
{
|
||||
RenderTextHistory(batch);
|
||||
return false;
|
||||
}
|
||||
|| binding.ObjectCapacity <= 0)
|
||||
throw new InvalidOperationException(
|
||||
$"History layout {batch.LayoutSlot} has no retained presentation binding.");
|
||||
PrepareRetainedTextRun(
|
||||
gfx, binding, run,
|
||||
out RetainedAdvTextLayoutPresentation? presentation,
|
||||
out AdvRetainedTextRunResult result);
|
||||
|
||||
presentation.PublishThrough(
|
||||
gfx,
|
||||
checked(result.FirstGlyphIndex + result.GlyphCount));
|
||||
lock (_textLock)
|
||||
{
|
||||
_historyText.Remove(batch.LayoutSlot);
|
||||
_retainedHistoryLayouts.Add(batch.LayoutSlot);
|
||||
}
|
||||
Interlocked.Exchange(ref _presentRequested, 1);
|
||||
@@ -828,16 +618,6 @@ public sealed class GodotAdvHost : IHost
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EndTextHistoryPresentation()
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
_historyText.Clear();
|
||||
_retainedHistoryLayouts.Clear();
|
||||
}
|
||||
_timeline?.Event("history-presentation-end");
|
||||
}
|
||||
|
||||
public void EndTextHistoryPresentation(GfxState gfx)
|
||||
{
|
||||
RetainedAdvTextLayoutPresentation[] retained;
|
||||
@@ -856,7 +636,6 @@ public sealed class GodotAdvHost : IHost
|
||||
foreach (int slot in _retainedHistoryLayouts)
|
||||
_retainedTextLayouts.Remove(slot);
|
||||
_retainedHistoryLayouts.Clear();
|
||||
_historyText.Clear();
|
||||
}
|
||||
|
||||
foreach (RetainedAdvTextLayoutPresentation presentation in retained)
|
||||
@@ -870,11 +649,6 @@ public sealed class GodotAdvHost : IHost
|
||||
});
|
||||
}
|
||||
|
||||
public IReadOnlyList<AdvTextHistoryRenderBatch> SnapshotRenderedTextHistory()
|
||||
{
|
||||
lock (_textLock) return _historyText.Values.OrderBy(batch => batch.LayoutSlot).ToArray();
|
||||
}
|
||||
|
||||
private void ClearAllocatedSurfacePixels(int surfaceSlot)
|
||||
{
|
||||
if (!_slotDims.TryGetValue(surfaceSlot, out var dimensions)
|
||||
@@ -898,17 +672,6 @@ public sealed class GodotAdvHost : IHost
|
||||
|
||||
public void FillSurfaceRect(SurfaceRectFill fill)
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
if (_surfaceText.TryGetValue(fill.SurfaceSlot, out var draws))
|
||||
{
|
||||
long right = (long)fill.X + System.Math.Max(0, fill.Width);
|
||||
long bottom = (long)fill.Y + System.Math.Max(0, fill.Height);
|
||||
draws.RemoveAll(draw => draw.X >= fill.X && draw.X < right
|
||||
&& draw.Y >= fill.Y && draw.Y < bottom);
|
||||
}
|
||||
}
|
||||
|
||||
RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot);
|
||||
if (destination == null && _slotDims.TryGetValue(fill.SurfaceSlot, out var dimensions)
|
||||
&& dimensions.W >= 0 && dimensions.H >= 0)
|
||||
@@ -1655,9 +1418,7 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
lock (_textLock)
|
||||
{
|
||||
_surfaceText.Clear();
|
||||
_surfaceResources.Clear();
|
||||
_historyText.Clear();
|
||||
_retainedHistoryLayouts.Clear();
|
||||
_liveText.Clear();
|
||||
_retainedTextLayouts.Clear();
|
||||
@@ -1792,7 +1553,6 @@ public sealed class GodotAdvHost : IHost
|
||||
|
||||
public void CreateTexture(int slot, int width, int height)
|
||||
{
|
||||
lock (_textLock) _surfaceText.Remove(slot);
|
||||
lock (_textLock) _surfaceResources.Remove(slot);
|
||||
int safeWidth = System.Math.Max(0, width);
|
||||
int safeHeight = System.Math.Max(0, height);
|
||||
@@ -1817,7 +1577,6 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
lock (_textLock)
|
||||
{
|
||||
_surfaceText.Remove(slot);
|
||||
_surfaceResources[slot] = resourceId;
|
||||
}
|
||||
var asset = _res.ResolveTexture(resourceId);
|
||||
@@ -1851,7 +1610,6 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
lock (_textLock)
|
||||
{
|
||||
_surfaceText.Remove(slot);
|
||||
_surfaceResources.Remove(slot);
|
||||
foreach (int layoutSlot in _retainedTextLayouts
|
||||
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
|
||||
@@ -2152,7 +1910,6 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
lock (_textLock)
|
||||
{
|
||||
_surfaceText.Remove(slot);
|
||||
_surfaceResources.Remove(slot);
|
||||
foreach (int layoutSlot in _retainedTextLayouts
|
||||
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
|
||||
@@ -2187,9 +1944,6 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
else
|
||||
{
|
||||
// For an offscreen target, discard separately retained text draws so its modeled pixel
|
||||
// contents observe the native D3D clear as well.
|
||||
lock (_textLock) _surfaceText.Remove(surfaceSlot);
|
||||
lock (_imageLock)
|
||||
{
|
||||
if (_surfaceImages.TryGetValue(surfaceSlot, out var image))
|
||||
@@ -2235,8 +1989,6 @@ public sealed class GodotAdvHost : IHost
|
||||
});
|
||||
|
||||
lock (_imageLock) _surfaceImages[targetSlot] = destination;
|
||||
PublishSurfaceTextRangeToSurface(
|
||||
gfx, visible, firstHandle, count, targetSlot, dimensions.W, dimensions.H);
|
||||
IReadOnlyList<RenderObject> retained = visible
|
||||
.Where(item => item.Handle >= firstHandle && item.Handle - firstHandle < count)
|
||||
.ToArray();
|
||||
@@ -2251,52 +2003,6 @@ public sealed class GodotAdvHost : IHost
|
||||
});
|
||||
}
|
||||
|
||||
private void PublishSurfaceTextRangeToSurface(
|
||||
GfxState gfx, IReadOnlyList<RenderObject> visible, long firstHandle, long count,
|
||||
int targetSlot, int targetWidth, int targetHeight)
|
||||
{
|
||||
List<SurfaceTextDraw> projected;
|
||||
lock (_textLock)
|
||||
projected = _surfaceText.TryGetValue(targetSlot, out var retained)
|
||||
? new List<SurfaceTextDraw>(retained)
|
||||
: new List<SurfaceTextDraw>();
|
||||
|
||||
foreach (RenderObject item in visible)
|
||||
{
|
||||
if (item.Handle < firstHandle || item.Handle - firstHandle >= count) continue;
|
||||
var raw = gfx.TryGet(item.Handle);
|
||||
if (raw == null) continue;
|
||||
List<SurfaceTextDraw>? source;
|
||||
lock (_textLock)
|
||||
source = _surfaceText.TryGetValue(raw.SourceSlot, out var draws)
|
||||
? new List<SurfaceTextDraw>(draws)
|
||||
: null;
|
||||
if (source == null) continue;
|
||||
|
||||
Affine2D localToTarget =
|
||||
Transform2DMath.Build(item.Transform, item.Rotation, item.ScaleCycle)
|
||||
.FromLocalOrigin(item.DstX, item.DstY);
|
||||
if (item.RangeTransform is { } rangeTransform)
|
||||
localToTarget = localToTarget.Then(rangeTransform);
|
||||
foreach (SurfaceTextDraw draw in source)
|
||||
{
|
||||
if (draw.X < item.SrcX || draw.X >= item.SrcX + item.W ||
|
||||
draw.Y < item.SrcY || draw.Y >= item.SrcY + item.H) continue;
|
||||
var position = localToTarget.Apply(draw.X - item.SrcX, draw.Y - item.SrcY);
|
||||
int x = (int)System.Math.Round(position.X);
|
||||
int y = (int)System.Math.Round(position.Y);
|
||||
if (x < 0 || x >= targetWidth || y < 0 || y >= targetHeight) continue;
|
||||
projected.Add(new SurfaceTextDraw(x, y, draw.Text, draw.Style));
|
||||
}
|
||||
}
|
||||
|
||||
lock (_textLock)
|
||||
{
|
||||
if (projected.Count == 0) _surfaceText.Remove(targetSlot);
|
||||
else _surfaceText[targetSlot] = projected;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReleaseSurfaceRange(int firstSlot, int count)
|
||||
{
|
||||
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
|
||||
@@ -2316,7 +2022,6 @@ public sealed class GodotAdvHost : IHost
|
||||
{
|
||||
for (int slot = firstSlot; slot < end; slot++)
|
||||
{
|
||||
_surfaceText.Remove(slot);
|
||||
_surfaceResources.Remove(slot);
|
||||
}
|
||||
foreach (int layoutSlot in _retainedTextLayouts
|
||||
@@ -2655,9 +2360,6 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style);
|
||||
public readonly record struct LiveAdvTextSnapshot(
|
||||
AdvLiveTextRun Run, int VisibleGlyphs, bool Revealing);
|
||||
public sealed record GodotHostDiagnosticSnapshot(
|
||||
string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping,
|
||||
bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended,
|
||||
|
||||
180
godot/GodotTextServerGlyphMaskRasterizer.cs
Normal file
180
godot/GodotTextServerGlyphMaskRasterizer.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using Age.Engine.Text;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Portable, explicitly non-pixel-exact glyph masks obtained from Godot's TextServer atlas.
|
||||
/// AGE still owns layout, effects, blending, retained ordering, and surface lifetime.
|
||||
/// </summary>
|
||||
public sealed class GodotTextServerGlyphMaskRasterizer :
|
||||
IIdentifiedGlyphMaskRasterizer, IDisposable
|
||||
{
|
||||
private readonly record struct FontKey(string RequestedFace, int Weight);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly PortableTextRenderingPolicy _policy;
|
||||
private readonly TextServer _textServer;
|
||||
private readonly BoundedLruCache<FontKey, SystemFont> _fonts;
|
||||
private bool _disposed;
|
||||
|
||||
public GodotTextServerGlyphMaskRasterizer(PortableTextRenderingPolicy policy)
|
||||
{
|
||||
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
|
||||
_textServer = TextServerManager.GetPrimaryInterface()
|
||||
?? throw new InvalidOperationException("Godot has no primary TextServer interface.");
|
||||
_fonts = new BoundedLruCache<FontKey, SystemFont>(
|
||||
policy.FontCacheCapacity, font => font.Dispose());
|
||||
BackendInfo = new GlyphRasterizerBackendInfo(
|
||||
"portable-godot-textserver",
|
||||
"Godot TextServer system fonts",
|
||||
GlyphRasterPolicy.PortableUnicode,
|
||||
NativePixelExact: false,
|
||||
$"policy={policy.Id}; raster={policy.Raster.Antialiasing}/" +
|
||||
$"{policy.Raster.Hinting}/{policy.Raster.SubpixelPositioning}; " +
|
||||
"Unicode system-font substitution; not GDI pixel-exact");
|
||||
}
|
||||
|
||||
public GlyphRasterizerBackendInfo BackendInfo { get; }
|
||||
|
||||
public GlyphMask Rasterize(GlyphRasterRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.Policy != GlyphRasterPolicy.PortableUnicode)
|
||||
throw new ArgumentException(
|
||||
"Godot TextServer accepts only portable Unicode glyph requests.",
|
||||
nameof(request));
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
SystemFont font = ResolveFont(request);
|
||||
// Asking the high-level Font for the character primes both the selected system face
|
||||
// and any system fallback RID before we inspect the TextServer cache directly.
|
||||
_ = font.GetCharSize(request.UnicodeScalar, request.PixelHeight);
|
||||
Rid rid = FindGlyphRid(font, request);
|
||||
long glyph = _textServer.FontGetGlyphIndex(
|
||||
rid, request.PixelHeight, request.UnicodeScalar, 0);
|
||||
if (glyph == 0 && request.UnicodeScalar != 0)
|
||||
throw MissingGlyph(request);
|
||||
|
||||
var sizeKey = new Vector2I(request.PixelHeight, 0);
|
||||
_textServer.FontRenderGlyph(rid, sizeKey, glyph);
|
||||
Vector2 glyphSize = _textServer.FontGetGlyphSize(rid, sizeKey, glyph);
|
||||
Vector2 glyphOffset = _textServer.FontGetGlyphOffset(rid, sizeKey, glyph);
|
||||
Vector2 advance = _textServer.FontGetGlyphAdvance(
|
||||
rid, request.PixelHeight, glyph);
|
||||
Rect2 uv = _textServer.FontGetGlyphUVRect(rid, sizeKey, glyph);
|
||||
|
||||
int width = Math.Max(0, Mathf.RoundToInt(glyphSize.X));
|
||||
int height = Math.Max(0, Mathf.RoundToInt(glyphSize.Y));
|
||||
byte[] coverage = width == 0 || height == 0
|
||||
? []
|
||||
: ExtractCoverage(rid, sizeKey, glyph, uv, width, height);
|
||||
int advanceX = Mathf.RoundToInt(advance.X);
|
||||
return new GlyphMask(
|
||||
width,
|
||||
height,
|
||||
width,
|
||||
Mathf.RoundToInt(glyphOffset.X),
|
||||
-Mathf.RoundToInt(glyphOffset.Y),
|
||||
advanceX,
|
||||
0,
|
||||
Math.Max(width, advanceX),
|
||||
request.PixelHeight,
|
||||
coverage);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_fonts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private SystemFont ResolveFont(GlyphRasterRequest request)
|
||||
{
|
||||
PortableFontRasterPolicy raster = _policy.Raster;
|
||||
int weight = request.Weight >= raster.BoldThreshold
|
||||
? raster.BoldWeight
|
||||
: raster.RegularWeight;
|
||||
var key = new FontKey(request.FontFace, weight);
|
||||
if (_fonts.TryGetValue(key, out SystemFont? cached)) return cached;
|
||||
|
||||
var font = new SystemFont
|
||||
{
|
||||
FontNames = _policy.ResolveFamilies(request.FontFace),
|
||||
FontWeight = weight,
|
||||
AllowSystemFallback = raster.AllowSystemFallback,
|
||||
Antialiasing = raster.Antialiasing switch
|
||||
{
|
||||
"none" => TextServer.FontAntialiasing.None,
|
||||
"lcd" => TextServer.FontAntialiasing.Lcd,
|
||||
_ => TextServer.FontAntialiasing.Gray,
|
||||
},
|
||||
Hinting = raster.Hinting switch
|
||||
{
|
||||
"none" => TextServer.Hinting.None,
|
||||
"light" => TextServer.Hinting.Light,
|
||||
_ => TextServer.Hinting.Normal,
|
||||
},
|
||||
SubpixelPositioning = raster.SubpixelPositioning switch
|
||||
{
|
||||
"auto" => TextServer.SubpixelPositioning.Auto,
|
||||
"one-half" => TextServer.SubpixelPositioning.OneHalf,
|
||||
"one-quarter" => TextServer.SubpixelPositioning.OneQuarter,
|
||||
_ => TextServer.SubpixelPositioning.Disabled,
|
||||
},
|
||||
MultichannelSignedDistanceField =
|
||||
raster.MultichannelSignedDistanceField,
|
||||
};
|
||||
_fonts.Set(key, font);
|
||||
return font;
|
||||
}
|
||||
|
||||
private Rid FindGlyphRid(SystemFont font, GlyphRasterRequest request)
|
||||
{
|
||||
foreach (Rid rid in font.GetRids())
|
||||
if (_textServer.FontHasChar(rid, request.UnicodeScalar))
|
||||
return rid;
|
||||
throw MissingGlyph(request);
|
||||
}
|
||||
|
||||
private byte[] ExtractCoverage(
|
||||
Rid rid,
|
||||
Vector2I sizeKey,
|
||||
long glyph,
|
||||
Rect2 uv,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
long textureIndex = _textServer.FontGetGlyphTextureIdx(rid, sizeKey, glyph);
|
||||
Image atlas = _textServer.FontGetTextureImage(rid, sizeKey, textureIndex)
|
||||
?? throw new InvalidOperationException("TextServer returned no glyph atlas image.");
|
||||
int left = Mathf.RoundToInt(uv.Position.X);
|
||||
int top = Mathf.RoundToInt(uv.Position.Y);
|
||||
var result = new byte[checked(width * height)];
|
||||
for (int y = 0; y < height; y++)
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
Color pixel = atlas.GetPixel(left + x, top + y);
|
||||
float mask = atlas.GetFormat() switch
|
||||
{
|
||||
Image.Format.L8 => pixel.R,
|
||||
Image.Format.Rgb8 => Math.Max(pixel.R, Math.Max(pixel.G, pixel.B)),
|
||||
_ => pixel.A,
|
||||
};
|
||||
result[y * width + x] =
|
||||
(byte)Math.Clamp(Mathf.RoundToInt(mask * 16f), 0, 16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Exception MissingGlyph(GlyphRasterRequest request)
|
||||
=> new InvalidOperationException(
|
||||
$"Portable font policy could not resolve U+{request.UnicodeScalar:X4} " +
|
||||
$"for authored face '{request.FontFace}'.");
|
||||
}
|
||||
564
godot/Main.cs
564
godot/Main.cs
@@ -20,12 +20,6 @@ using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
|
||||
|
||||
public partial class Main : Godot.Control
|
||||
{
|
||||
// Provisional approximation only: AGE asks GDI to synthesize LOGFONT weight 700, grid-fit a
|
||||
// GGO_GRAY4 mask, and composites that mask itself. Godot instead applies FreeType embolden plus
|
||||
// spacing. Do not retune these values from screenshots; replace this approximation from the decoded
|
||||
// native glyph-mask contract. See docs/engine-re.md.
|
||||
private const float NativeBoldEmbolden = 0.53f;
|
||||
private const int NativeBoldGlyphSpacing = 1;
|
||||
private int _screenWidth = Sys4LogicalCanvas.DefaultWidth;
|
||||
private int _screenHeight = Sys4LogicalCanvas.DefaultHeight;
|
||||
private WindowLaunchOptions _windowOptions;
|
||||
@@ -40,18 +34,6 @@ public partial class Main : Godot.Control
|
||||
// One managed composition target for the entire frame. Layer helpers mutate it in place; only the
|
||||
// completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer.
|
||||
private byte[] _screenPixels = [];
|
||||
private Label _text = null!;
|
||||
private Label _speaker = null!;
|
||||
private readonly System.Collections.Generic.List<Label> _advTextLabels = new();
|
||||
private readonly System.Collections.Generic.List<Label> _surfaceTextLabels = new();
|
||||
private readonly System.Collections.Generic.Dictionary<int, Label> _historyTextLabels = new();
|
||||
private Font? _presentationFallbackFont;
|
||||
private readonly Dictionary<string, Font> _presentationFaceFonts =
|
||||
new(System.StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FontVariation> _presentationBoldFonts =
|
||||
new(System.StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _unavailablePresentationFaces =
|
||||
new(System.StringComparer.OrdinalIgnoreCase);
|
||||
private Label _status = null!;
|
||||
private Label _locatorHud = null!;
|
||||
private AudioStreamPlayer _bgm = null!; // looping background music
|
||||
@@ -69,9 +51,7 @@ public partial class Main : Godot.Control
|
||||
private Sys4RegIniStore? _sys4RegIniStore;
|
||||
private VirtualMachine _vm = null!;
|
||||
private GodotAdvHost _host = null!;
|
||||
#if AGE_WINDOWS_GDI
|
||||
private WindowsGdiGlyphMaskRasterizer? _exactGlyphRasterizer;
|
||||
#endif
|
||||
private IDisposable? _glyphRasterizerOwner;
|
||||
private FullwidthTextEditorDialog? _fullwidthTextEditor;
|
||||
private Sys4ScriptProvider? _scripts;
|
||||
private DebugSceneLauncher? _debugSceneLauncher;
|
||||
@@ -191,14 +171,6 @@ public partial class Main : Godot.Control
|
||||
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
_gpuRenderer = new GpuRetainedRenderer(this);
|
||||
|
||||
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore };
|
||||
AddChild(_text);
|
||||
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
_advTextLabels.Add(_text);
|
||||
_speaker = new Label { MouseFilter = MouseFilterEnum.Ignore, Visible = false };
|
||||
AddChild(_speaker);
|
||||
_speaker.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
_surfaceTextLabels.Add(_speaker);
|
||||
_status = new Label();
|
||||
AddChild(_status);
|
||||
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
|
||||
@@ -207,7 +179,7 @@ public partial class Main : Godot.Control
|
||||
_locatorHud.Position = new Vector2(8, 8);
|
||||
AddChild(_locatorHud);
|
||||
|
||||
// Best-effort CJK font so the visual isn't tofu (headless self-test doesn't depend on it).
|
||||
// Best-effort CJK font for diagnostic/status UI. Gameplay text is always surface pixels.
|
||||
foreach (var fp in new[] { "C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/YuGothM.ttc",
|
||||
"C:/Windows/Fonts/YuGothR.ttc", "C:/Windows/Fonts/meiryo.ttc" })
|
||||
{
|
||||
@@ -215,18 +187,8 @@ public partial class Main : Godot.Control
|
||||
try
|
||||
{
|
||||
var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) };
|
||||
_presentationFallbackFont = ff;
|
||||
_text.AddThemeFontOverride("font", ff);
|
||||
_speaker.AddThemeFontOverride("font", ff);
|
||||
_status.AddThemeFontOverride("font", ff);
|
||||
_locatorHud.AddThemeFontOverride("font", ff);
|
||||
_text.AddThemeFontSizeOverride("font_size", 25);
|
||||
_speaker.AddThemeFontSizeOverride("font_size", 25);
|
||||
_text.AddThemeConstantOverride("outline_size", 1);
|
||||
_speaker.AddThemeConstantOverride("outline_size", 1);
|
||||
var outline = new Color(0x60 / 255f, 0x60 / 255f, 0x60 / 255f, 1);
|
||||
_text.AddThemeColorOverride("font_outline_color", outline);
|
||||
_speaker.AddThemeColorOverride("font_outline_color", outline);
|
||||
break;
|
||||
}
|
||||
catch { /* fall back to the default font */ }
|
||||
@@ -258,6 +220,7 @@ public partial class Main : Godot.Control
|
||||
double speed = 1.0; // --speed <f>: sleeps + retained presentation clocks
|
||||
long transitionClickMs = -1; // --transition-click-ms <n>: force active transitions after n virtual ms
|
||||
bool holdMessageSkip = false; // --hold-message-skip: hold native logical action 6 for diagnostics
|
||||
string textBackend = "auto"; // --text-backend auto|gdi|portable
|
||||
string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run
|
||||
string? pageMapPath = null; // --page-map <jsonl>: override default build/page-map-SCxxxx.jsonl
|
||||
for (int i = 0; i < userArgs.Length; i++)
|
||||
@@ -283,6 +246,8 @@ public partial class Main : Godot.Control
|
||||
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
|
||||
if (userArgs[i] == "--transition-click-ms" && i + 1 < userArgs.Length) long.TryParse(userArgs[i + 1], out transitionClickMs);
|
||||
if (userArgs[i] == "--hold-message-skip") holdMessageSkip = true;
|
||||
if (userArgs[i] == "--text-backend" && i + 1 < userArgs.Length)
|
||||
textBackend = userArgs[i + 1].ToLowerInvariant();
|
||||
if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1];
|
||||
if (userArgs[i] == "--page-map" && i + 1 < userArgs.Length) pageMapPath = userArgs[i + 1];
|
||||
if (userArgs[i] == "--locator-hud") _locatorHudVisible = true;
|
||||
@@ -299,6 +264,14 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
|
||||
if (!double.IsFinite(speed) || speed <= 0) speed = 1.0;
|
||||
if (textBackend is not ("auto" or "gdi" or "portable"))
|
||||
{
|
||||
GD.PushError(
|
||||
$"[startup] unknown --text-backend '{textBackend}'; " +
|
||||
"expected auto, gdi, or portable");
|
||||
GetTree().Quit(2);
|
||||
return;
|
||||
}
|
||||
_clock.Speed = System.Math.Clamp(speed, 0.05, 8.0);
|
||||
GD.Print($"[renderer] retained backend={(_useGpuBackend ? "gpu" : "software")}");
|
||||
|
||||
@@ -367,43 +340,68 @@ public partial class Main : Godot.Control
|
||||
? new ResourceMap(scripts.Catalog, trackedAssetStore)
|
||||
: new ResourceMap(catalog, _assetStore);
|
||||
IGlyphMaskRasterizer? surfaceTextRasterizer = null;
|
||||
string surfaceTextFallbackReason;
|
||||
PortableTextRenderingPolicy? portableTextPolicy = null;
|
||||
string? exactUnavailable = null;
|
||||
try
|
||||
{
|
||||
#if AGE_WINDOWS_GDI
|
||||
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
|
||||
{
|
||||
try
|
||||
if (textBackend != "portable")
|
||||
{
|
||||
_exactGlyphRasterizer = new WindowsGdiGlyphMaskRasterizer();
|
||||
surfaceTextRasterizer = _exactGlyphRasterizer;
|
||||
surfaceTextFallbackReason = "";
|
||||
GD.Print(
|
||||
$"[text] immediate surface strings use {_exactGlyphRasterizer.BackendInfo.Id}: " +
|
||||
_exactGlyphRasterizer.BackendInfo.Detail);
|
||||
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
|
||||
{
|
||||
try
|
||||
{
|
||||
var exact = new WindowsGdiGlyphMaskRasterizer();
|
||||
surfaceTextRasterizer = exact;
|
||||
_glyphRasterizerOwner = exact;
|
||||
}
|
||||
catch (Exception error) when (textBackend == "auto")
|
||||
{
|
||||
exactUnavailable =
|
||||
$"Exact Windows GDI glyph backend failed to initialize: {error.Message}";
|
||||
}
|
||||
}
|
||||
else exactUnavailable = availability;
|
||||
}
|
||||
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}");
|
||||
if (textBackend == "gdi")
|
||||
exactUnavailable =
|
||||
"The exact Windows GDI glyph adapter is not part of this platform build.";
|
||||
#endif
|
||||
if (surfaceTextRasterizer == null)
|
||||
{
|
||||
if (textBackend == "gdi")
|
||||
throw new PlatformNotSupportedException(
|
||||
exactUnavailable ?? "Exact Windows GDI text is unavailable.");
|
||||
portableTextPolicy = PortableTextRenderingPolicy.Load();
|
||||
var portable =
|
||||
new GodotTextServerGlyphMaskRasterizer(portableTextPolicy);
|
||||
surfaceTextRasterizer = portable;
|
||||
_glyphRasterizerOwner = portable;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
GD.PushError($"[startup] text backend selection failed: {error.Message}");
|
||||
GetTree().Quit(2);
|
||||
return;
|
||||
}
|
||||
var selectedTextBackend =
|
||||
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo
|
||||
?? throw new InvalidOperationException(
|
||||
"Selected text backend does not report its policy.");
|
||||
GD.Print(
|
||||
$"[text] gameplay strings use {selectedTextBackend.Id}: " +
|
||||
selectedTextBackend.Detail);
|
||||
if (textBackend == "auto" && exactUnavailable != null)
|
||||
GD.Print($"[text] exact backend unavailable; selected portable: {exactUnavailable}");
|
||||
_host = new GodotAdvHost(
|
||||
this, resources, scene, _clock, _locator, logicalCanvas, _timeline,
|
||||
this, resources, scene, _clock, _locator, logicalCanvas,
|
||||
surfaceTextRasterizer,
|
||||
timeline: _timeline,
|
||||
synchronizeExplicitPresentation: !_selftest,
|
||||
surfaceTextRasterizer: surfaceTextRasterizer,
|
||||
surfaceTextFallbackReason: surfaceTextFallbackReason)
|
||||
surfaceTextMaskCacheCapacity:
|
||||
portableTextPolicy?.GlyphMaskCacheCapacity ?? 2048)
|
||||
{
|
||||
SleepScale = sleepScale,
|
||||
TraceOps = _gfxLogPath != null,
|
||||
@@ -567,13 +565,6 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
perf?.RecordRecomposeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||
|
||||
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||
if (!_selftest && _host != null) UpdateAdvTextPresentation();
|
||||
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
|
||||
perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||
perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase);
|
||||
|
||||
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
|
||||
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
|
||||
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
|
||||
@@ -1044,10 +1035,8 @@ public partial class Main : Godot.Control
|
||||
|
||||
DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose();
|
||||
_gpuRenderer?.Dispose();
|
||||
#if AGE_WINDOWS_GDI
|
||||
_exactGlyphRasterizer?.Dispose();
|
||||
_exactGlyphRasterizer = null;
|
||||
#endif
|
||||
_glyphRasterizerOwner?.Dispose();
|
||||
_glyphRasterizerOwner = null;
|
||||
if (_perf != null)
|
||||
{
|
||||
_perf.Dispose();
|
||||
@@ -1087,7 +1076,6 @@ public partial class Main : Godot.Control
|
||||
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
|
||||
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
|
||||
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
|
||||
private readonly System.Collections.Generic.List<SurfaceTextDraw> _surfaceTextSnapshot = new();
|
||||
|
||||
private void Recomposite()
|
||||
{
|
||||
@@ -1134,11 +1122,6 @@ public partial class Main : Godot.Control
|
||||
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
|
||||
}
|
||||
_perf?.BeginRecomposite(screenTransition: false);
|
||||
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
foreach (var label in _surfaceTextLabels) label.Visible = false;
|
||||
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
|
||||
|
||||
int surfaceTextLabelIndex = 0;
|
||||
_gpuRenderer.BeginFrame(publicationPolicy.AppendGpuLayers);
|
||||
foreach (var v in _visibleSnapshot)
|
||||
{
|
||||
@@ -1197,24 +1180,6 @@ public partial class Main : Godot.Control
|
||||
resolved.IsDynamic, v.Blend);
|
||||
}
|
||||
}
|
||||
|
||||
if (rawObject != null)
|
||||
{
|
||||
_host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
|
||||
foreach (var surfaceText in _surfaceTextSnapshot)
|
||||
{
|
||||
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
|
||||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
|
||||
var label = GetSurfaceTextLabel(surfaceTextLabelIndex++);
|
||||
ApplySurfaceTextTransform(
|
||||
label, affine, surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY);
|
||||
label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)),
|
||||
System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY)));
|
||||
label.Text = surfaceText.Text;
|
||||
ApplyAdvTextStyle(label, surfaceText.Style);
|
||||
label.Visible = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stats = _gpuRenderer.EndFrame();
|
||||
@@ -1327,25 +1292,22 @@ public partial class Main : Godot.Control
|
||||
{
|
||||
System.Array.Clear(_screenPixels);
|
||||
}
|
||||
foreach (var label in _surfaceTextLabels) label.Visible = false;
|
||||
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
|
||||
int surfaceTextLabelIndex = 0;
|
||||
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
|
||||
if (hasScreenTransition)
|
||||
{
|
||||
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
|
||||
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
|
||||
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
|
||||
CompositeVisibleObjects(transition.Source, 1f, decisions);
|
||||
FillQuad(0, 0, _screenWidth, _screenHeight, 0, (float)transition.Progress);
|
||||
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
|
||||
ref surfaceTextLabelIndex, decisions, false);
|
||||
CompositeVisibleObjects(transition.Target, (float)transition.Progress, decisions);
|
||||
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||
}
|
||||
else
|
||||
{
|
||||
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true);
|
||||
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, decisions);
|
||||
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||
}
|
||||
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
@@ -1360,10 +1322,10 @@ public partial class Main : Godot.Control
|
||||
_perf?.EndRecomposite();
|
||||
}
|
||||
|
||||
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
|
||||
ref int surfaceTextLabelIndex,
|
||||
System.Collections.Generic.Dictionary<long, string>? decisions,
|
||||
bool includeSurfaceText)
|
||||
private void CompositeVisibleObjects(
|
||||
IReadOnlyList<RenderObject> visible,
|
||||
float globalOpacity,
|
||||
System.Collections.Generic.Dictionary<long, string>? decisions)
|
||||
{
|
||||
int z = 0;
|
||||
foreach (var v in visible) // interpolate at the retained-presentation clock
|
||||
@@ -1454,247 +1416,10 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
}
|
||||
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
|
||||
if (includeSurfaceText && rawObject != null)
|
||||
{
|
||||
_host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
|
||||
foreach (var surfaceText in _surfaceTextSnapshot)
|
||||
{
|
||||
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
|
||||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
|
||||
var label = GetSurfaceTextLabel(surfaceTextLabelIndex++);
|
||||
ApplySurfaceTextTransform(
|
||||
label, localToDest, surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY);
|
||||
label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)),
|
||||
System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY)));
|
||||
label.Text = surfaceText.Text;
|
||||
ApplyAdvTextStyle(label, surfaceText.Style);
|
||||
label.Visible = true;
|
||||
}
|
||||
}
|
||||
z++;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAdvTextPresentation()
|
||||
{
|
||||
// Modal callback scripts composite their own full-screen UI while the enclosing ADV wait remains
|
||||
// parked. Live layout text is a Godot overlay rather than part of the retained surface. During a
|
||||
// raw-input callback, keep runs owned by that callback's script stack (STUDY -> MAMES) while hiding
|
||||
// enclosing ADV runs that would otherwise leak above a nested screen such as HISTORY. Native
|
||||
// retained-glyph erases remove ordinary live runs through the host before this presentation pass.
|
||||
foreach (var label in _advTextLabels) label.Visible = false;
|
||||
if (_host.IsAdvPagePresentationSuspended) return;
|
||||
|
||||
int labelIndex = 0;
|
||||
string? rawInputOwner = _vm.RawInputCallbackScriptName;
|
||||
var snapshots = _host.SnapshotLiveAdvText();
|
||||
for (int i = 0; i < snapshots.Count; i++)
|
||||
{
|
||||
var snapshot = snapshots[i];
|
||||
var run = snapshot.Run;
|
||||
if (run.Text.Length == 0 || !ShouldShow(run)) continue;
|
||||
var visibleText = new System.Text.StringBuilder();
|
||||
AppendVisible(snapshot);
|
||||
while (i + 1 < snapshots.Count
|
||||
&& snapshots[i + 1].Run.Layout == run.Layout
|
||||
&& snapshots[i + 1].Run.Style == run.Style
|
||||
&& ShouldShow(snapshots[i + 1].Run))
|
||||
{
|
||||
i++;
|
||||
AppendVisible(snapshots[i]);
|
||||
}
|
||||
|
||||
var label = GetAdvTextLabel(labelIndex++);
|
||||
var layout = run.Layout;
|
||||
label.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
|
||||
label.Size = new Vector2(System.Math.Max(1, layout.Right - layout.CursorX),
|
||||
System.Math.Max(1, layout.Bottom - layout.CursorY));
|
||||
label.Text = visibleText.ToString();
|
||||
ApplyAdvTextStyle(label, run.Style);
|
||||
label.Visible = true;
|
||||
|
||||
void AppendVisible(LiveAdvTextSnapshot item)
|
||||
{
|
||||
int count = System.Math.Clamp(item.VisibleGlyphs, 0, item.Run.Text.Length);
|
||||
if (count != 0) visibleText.Append(item.Run.Text, 0, count);
|
||||
}
|
||||
|
||||
bool ShouldShow(AdvLiveTextRun item)
|
||||
=> rawInputOwner == null || item.BelongsToScript(rawInputOwner);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateHistoryTextPresentation()
|
||||
{
|
||||
foreach (var label in _historyTextLabels.Values) label.Visible = false;
|
||||
foreach (var batch in _host.SnapshotRenderedTextHistory())
|
||||
{
|
||||
if (batch.Text.Length == 0) continue;
|
||||
if (!_historyTextLabels.TryGetValue(batch.LayoutSlot, out var label))
|
||||
{
|
||||
label = CreateAdvPresentationLabel();
|
||||
_historyTextLabels.Add(batch.LayoutSlot, label);
|
||||
}
|
||||
var layout = batch.Layout;
|
||||
label.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
|
||||
label.Size = new Vector2(System.Math.Max(1, layout.Right - layout.CursorX),
|
||||
System.Math.Max(1, layout.Bottom - layout.CursorY));
|
||||
label.Text = batch.Text;
|
||||
ApplyAdvTextStyle(label, batch.Style);
|
||||
label.Visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private Label GetSurfaceTextLabel(int index)
|
||||
{
|
||||
while (_surfaceTextLabels.Count <= index) _surfaceTextLabels.Add(CreateAdvPresentationLabel());
|
||||
return _surfaceTextLabels[index];
|
||||
}
|
||||
|
||||
private Label GetAdvTextLabel(int index)
|
||||
{
|
||||
while (_advTextLabels.Count <= index) _advTextLabels.Add(CreateAdvPresentationLabel());
|
||||
return _advTextLabels[index];
|
||||
}
|
||||
|
||||
private static void ApplySurfaceTextTransform(Label label, Affine2D localToDestination,
|
||||
int localX, int localY)
|
||||
{
|
||||
var position = localToDestination.Apply(localX, localY);
|
||||
var (rotation, scaleX, scaleY) = localToDestination.DecomposeCanvasAxes();
|
||||
label.Position = new Vector2((float)position.X, (float)position.Y);
|
||||
label.PivotOffset = Vector2.Zero;
|
||||
label.Rotation = (float)rotation;
|
||||
label.Scale = new Vector2((float)scaleX, (float)scaleY);
|
||||
}
|
||||
|
||||
private Label CreateAdvPresentationLabel()
|
||||
{
|
||||
var label = new Label
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
Visible = false,
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
ClipText = true,
|
||||
};
|
||||
label.AddThemeFontOverride("font", _text.GetThemeFont("font"));
|
||||
AddChild(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
private void ApplyAdvTextStyle(Label label, AdvTextStyle style)
|
||||
{
|
||||
int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
|
||||
Font regularFont = ResolvePresentationFont(style.FontFace, out string fontKey);
|
||||
Font presentationFont = regularFont;
|
||||
if (style.Bold)
|
||||
{
|
||||
if (!_presentationBoldFonts.TryGetValue(fontKey, out FontVariation? boldFont))
|
||||
{
|
||||
boldFont = new FontVariation
|
||||
{
|
||||
BaseFont = regularFont,
|
||||
VariationEmbolden = NativeBoldEmbolden,
|
||||
SpacingGlyph = NativeBoldGlyphSpacing,
|
||||
};
|
||||
_presentationBoldFonts.Add(fontKey, boldFont);
|
||||
}
|
||||
presentationFont = boldFont;
|
||||
}
|
||||
label.AddThemeFontOverride("font", presentationFont);
|
||||
label.AddThemeFontSizeOverride("font_size", fontSize);
|
||||
label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White));
|
||||
Color effectColor = RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f));
|
||||
AdvTextEffectTheme effect = ResolveAdvTextEffectTheme(style);
|
||||
label.AddThemeColorOverride("font_outline_color", effectColor);
|
||||
label.AddThemeColorOverride("font_shadow_color", effect.ShadowEnabled ? effectColor : Colors.Transparent);
|
||||
label.AddThemeConstantOverride("line_spacing",
|
||||
CalibratedLineSpacing(style.LineSpacing, fontSize, presentationFont.GetHeight(fontSize)));
|
||||
label.AddThemeConstantOverride("outline_size", effect.OutlineSize);
|
||||
label.AddThemeConstantOverride("shadow_offset_x", effect.ShadowOffsetX);
|
||||
label.AddThemeConstantOverride("shadow_offset_y", effect.ShadowOffsetY);
|
||||
label.AddThemeConstantOverride("shadow_outline_size", 0);
|
||||
}
|
||||
|
||||
private static int CalibratedLineSpacing(int nativeSpacing, int nativeFontHeight, float backendFontHeight)
|
||||
=> nativeSpacing + nativeFontHeight - System.Math.Max(1, (int)System.MathF.Round(backendFontHeight));
|
||||
|
||||
private readonly record struct AdvTextEffectTheme(
|
||||
int OutlineSize,
|
||||
bool ShadowEnabled,
|
||||
int ShadowOffsetX,
|
||||
int ShadowOffsetY);
|
||||
|
||||
private static AdvTextEffectTheme ResolveAdvTextEffectTheme(AdvTextStyle style)
|
||||
=> style.RenderMode switch
|
||||
{
|
||||
// AGE mode 1 draws one effect-color glyph at (+x,+y), then the primary glyph.
|
||||
1 => new(0, true, style.EffectOffsetX, style.EffectOffsetY),
|
||||
// AGE mode 3 samples an ellipse. Himegari always uses equal (1,1) radii, for which
|
||||
// Godot's symmetric outline is the closest backend-native presentation.
|
||||
3 => new(System.Math.Max(System.Math.Abs(style.EffectOffsetX),
|
||||
System.Math.Abs(style.EffectOffsetY)),
|
||||
false, 0, 0),
|
||||
_ => new(0, false, 0, 0),
|
||||
};
|
||||
|
||||
private Font ResolvePresentationFont(string requestedFace, out string fontKey)
|
||||
{
|
||||
_presentationFallbackFont ??= _text.GetThemeFont("font");
|
||||
string face = requestedFace?.Trim() ?? "";
|
||||
if (face.Length == 0)
|
||||
{
|
||||
fontKey = "";
|
||||
return _presentationFallbackFont;
|
||||
}
|
||||
if (_presentationFaceFonts.TryGetValue(face, out Font? cached))
|
||||
{
|
||||
fontKey = face;
|
||||
return cached;
|
||||
}
|
||||
if (!_unavailablePresentationFaces.Contains(face))
|
||||
{
|
||||
foreach (string path in PresentationFontPaths(face))
|
||||
{
|
||||
if (!System.IO.File.Exists(path)) continue;
|
||||
try
|
||||
{
|
||||
var loaded = new FontFile { Data = System.IO.File.ReadAllBytes(path) };
|
||||
_presentationFaceFonts.Add(face, loaded);
|
||||
fontKey = face;
|
||||
return loaded;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try the next known file before falling back to the presentation default.
|
||||
}
|
||||
}
|
||||
_unavailablePresentationFaces.Add(face);
|
||||
}
|
||||
fontKey = "";
|
||||
return _presentationFallbackFont;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> PresentationFontPaths(string face)
|
||||
{
|
||||
if (face.Equals("MS 明朝", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| face.Equals("MS Mincho", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return "C:/Windows/Fonts/msmincho.ttc";
|
||||
}
|
||||
else if (face.Equals("MS ゴシック", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| face.Equals("MS Gothic", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return "C:/Windows/Fonts/msgothic.ttc";
|
||||
}
|
||||
}
|
||||
|
||||
private static Color RgbColor(long rgb, Color fallback)
|
||||
{
|
||||
if ((rgb & 0x00ff_ffff) == 0) return fallback;
|
||||
return new Color(((rgb >> 16) & 0xff) / 255f, ((rgb >> 8) & 0xff) / 255f, (rgb & 0xff) / 255f, 1);
|
||||
}
|
||||
|
||||
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
|
||||
=> state is { } c
|
||||
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
|
||||
@@ -2289,7 +2014,6 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
}
|
||||
|
||||
public void AppendLine(string text) => _text.Text += text + "\n";
|
||||
public void PageBreak()
|
||||
{
|
||||
_pageCount++;
|
||||
@@ -2298,7 +2022,6 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
public void ClearPage()
|
||||
{
|
||||
foreach (var label in _advTextLabels) label.Text = "";
|
||||
_status.Text = "";
|
||||
}
|
||||
public void ShowEnd() => _status.Text = "— end —";
|
||||
@@ -2372,71 +2095,29 @@ public partial class Main : Godot.Control
|
||||
&& !_bgm.Playing
|
||||
&& _bgmFadeTween == null
|
||||
&& System.Math.Abs(_bgm.VolumeDb) < 0.001f;
|
||||
AdvTextEffectTheme mode1 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
|
||||
{
|
||||
RenderMode = 1,
|
||||
EffectOffsetX = 0,
|
||||
EffectOffsetY = 0,
|
||||
});
|
||||
AdvTextEffectTheme mode3 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
|
||||
{
|
||||
RenderMode = 3,
|
||||
EffectOffsetX = 1,
|
||||
EffectOffsetY = 1,
|
||||
});
|
||||
AdvTextEffectTheme mode0 = ResolveAdvTextEffectTheme(AdvTextStyle.Default);
|
||||
bool textEffectModesOk = mode1 == new AdvTextEffectTheme(0, true, 0, 0)
|
||||
&& mode3 == new AdvTextEffectTheme(1, false, 0, 0)
|
||||
&& mode0 == new AdvTextEffectTheme(0, false, 0, 0);
|
||||
var textEffectSmoke = new Label();
|
||||
AddChild(textEffectSmoke);
|
||||
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
|
||||
{
|
||||
RenderMode = 1,
|
||||
EffectColor = 0x123456,
|
||||
EffectOffsetX = 2,
|
||||
EffectOffsetY = -1,
|
||||
});
|
||||
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 0
|
||||
&& textEffectSmoke.GetThemeConstant("shadow_offset_x") == 2
|
||||
&& textEffectSmoke.GetThemeConstant("shadow_offset_y") == -1
|
||||
&& textEffectSmoke.GetThemeColor("font_shadow_color").A > 0.99f;
|
||||
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
|
||||
{
|
||||
RenderMode = 3,
|
||||
EffectColor = 0x123456,
|
||||
EffectOffsetX = 1,
|
||||
EffectOffsetY = 1,
|
||||
});
|
||||
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 1
|
||||
&& textEffectSmoke.GetThemeColor("font_shadow_color").A < 0.01f;
|
||||
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
|
||||
{
|
||||
PrimaryFontSize = 24,
|
||||
Bold = true,
|
||||
FontFace = "MS 明朝",
|
||||
LineSpacing = 8,
|
||||
});
|
||||
var calibratedBold = textEffectSmoke.GetThemeFont("font") as FontVariation;
|
||||
bool fontCalibrationOk = calibratedBold != null
|
||||
&& System.Math.Abs(calibratedBold.VariationEmbolden
|
||||
- NativeBoldEmbolden) < 0.001f
|
||||
&& calibratedBold.SpacingGlyph == NativeBoldGlyphSpacing
|
||||
&& CalibratedLineSpacing(8, 24, 25) == 7
|
||||
&& CalibratedLineSpacing(9, 16, 17) == 8
|
||||
&& textEffectSmoke.GetThemeConstant("line_spacing")
|
||||
== CalibratedLineSpacing(
|
||||
8, 24, calibratedBold.GetHeight(24));
|
||||
IReadOnlyList<(int X, int Y)> outline =
|
||||
AgeGlyphMaskCompositor.GetMode3OutlineOffsets(1, 1);
|
||||
bool textEffectModesOk =
|
||||
outline.Count == 12
|
||||
&& outline.Contains((1, 0))
|
||||
&& outline.Contains((-1, 0))
|
||||
&& outline.Contains((0, 1))
|
||||
&& outline.Contains((0, -1));
|
||||
bool fontCalibrationOk =
|
||||
ImmediateSurfaceTextRenderer.NativePixelHeight(24) == 24
|
||||
&& ImmediateSurfaceTextRenderer.NativePixelHeight(25) == 24
|
||||
&& ImmediateSurfaceTextRenderer.NativePixelHeight(32) == 31
|
||||
&& ImmediateSurfaceTextRenderer.NativePixelHeight(33) == 31;
|
||||
bool immediateSurfaceTextOk;
|
||||
string immediateSurfaceTextMode;
|
||||
_host.CreateTexture(997, 32, 24);
|
||||
_host.CreateTexture(997, 64, 24);
|
||||
var immediateStyle = AdvTextStyle.Default with
|
||||
{
|
||||
PrimaryFontSize = 16,
|
||||
TextColor = 0xffffff,
|
||||
FontFace = ImmediateSurfaceTextRenderer.DefaultFontFace,
|
||||
};
|
||||
_host.DrawStringToSurface(997, 1, 1, "A", immediateStyle);
|
||||
_host.DrawStringToSurface(997, 1, 1, "A姫", immediateStyle);
|
||||
if (_host.UsesSurfaceTextPixels)
|
||||
{
|
||||
RgbaImage? pixels = _host.CaptureSurfacePixels(997);
|
||||
@@ -2459,40 +2140,44 @@ public partial class Main : Godot.Control
|
||||
gpuAccepted &= stats.DrawItems == 1 && stats.TextureUploads == 1;
|
||||
}
|
||||
|
||||
_host.CreateTexture(996, 32, 24);
|
||||
_host.CreateTexture(996, 64, 24);
|
||||
_host.CopySurfaceRect(new SurfaceRectCopy(
|
||||
997, 996, 0, 0, 32, 24, 0, 0));
|
||||
997, 996, 0, 0, 64, 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));
|
||||
996, 0, 0, 64, 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,
|
||||
}
|
||||
&& backend is not null
|
||||
&& (backend.Policy == GlyphRasterPolicy.NativeCp932Gray4
|
||||
? backend is
|
||||
{
|
||||
Id: "windows-gdi-gray4",
|
||||
NativePixelExact: true,
|
||||
}
|
||||
: backend is
|
||||
{
|
||||
Id: "portable-godot-textserver",
|
||||
Policy: GlyphRasterPolicy.PortableUnicode,
|
||||
NativePixelExact: false,
|
||||
})
|
||||
&& cache.Count is > 0 and <= 2048
|
||||
&& cache.Capacity == 2048
|
||||
&& gpuAccepted
|
||||
&& copiedPixels
|
||||
&& clearedPixels;
|
||||
immediateSurfaceTextMode = "exact-rgba";
|
||||
immediateSurfaceTextMode =
|
||||
$"rgba:{backend?.Id}";
|
||||
}
|
||||
else
|
||||
{
|
||||
immediateSurfaceTextOk =
|
||||
_host.CaptureSurfacePixels(997)?.Pixels.All(value => value == 0) == true
|
||||
&& _host.SnapshotSurfaceText(997).Count == 1
|
||||
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
|
||||
immediateSurfaceTextMode = "label-fallback";
|
||||
immediateSurfaceTextOk = false;
|
||||
immediateSurfaceTextMode = "unavailable";
|
||||
}
|
||||
_host.ReleaseSurface(996);
|
||||
_host.ReleaseSurface(997);
|
||||
@@ -2519,7 +2204,6 @@ public partial class Main : Godot.Control
|
||||
&& liveSurface != null
|
||||
&& liveSurface.Pixels.Where((_, index) => index % 4 == 3)
|
||||
.Any(alpha => alpha != 0)
|
||||
&& _host.SnapshotLiveAdvText().Count == 0
|
||||
&& _vm.TextHistory.GetLayoutSnapshot(1).CursorX
|
||||
> liveBinding.ResetCursorX;
|
||||
|
||||
@@ -2556,14 +2240,15 @@ public partial class Main : Godot.Control
|
||||
&& suspended
|
||||
&& restored
|
||||
&& reset;
|
||||
liveRetainedTextMode = "retained-glyphs";
|
||||
liveRetainedTextMode =
|
||||
$"retained-glyphs;built={builtCompleteLine};objects={liveObjects.Count};" +
|
||||
$"republished={republishedPartialErase};suspended={suspended};" +
|
||||
$"restored={restored};reset={reset}";
|
||||
}
|
||||
else
|
||||
{
|
||||
liveRetainedTextOk =
|
||||
_host.SnapshotLiveAdvText().Count == 3
|
||||
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
|
||||
liveRetainedTextMode = "label-fallback";
|
||||
liveRetainedTextOk = false;
|
||||
liveRetainedTextMode = "unavailable";
|
||||
}
|
||||
_vm.TextHistory.DefineLayout(2, 256, 64, 10, 100);
|
||||
_vm.TextHistory.SetResetCursor(2, 1, 1);
|
||||
@@ -2603,8 +2288,7 @@ public partial class Main : Godot.Control
|
||||
== historyBatch.Text.Length
|
||||
&& historySurface != null
|
||||
&& historySurface.Pixels.Where((_, index) => index % 4 == 3)
|
||||
.Any(alpha => alpha != 0)
|
||||
&& _host.SnapshotRenderedTextHistory().Count == 0;
|
||||
.Any(alpha => alpha != 0);
|
||||
_vm.Gfx.EraseRange(
|
||||
historyBinding.FirstObjectHandle,
|
||||
historyBinding.ObjectCapacity);
|
||||
@@ -2618,15 +2302,14 @@ public partial class Main : Godot.Control
|
||||
< historyBinding.ObjectCapacity)
|
||||
&& _host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot)
|
||||
?.Pixels.All(value => value == 0) == true;
|
||||
historyRetainedTextMode = "retained-glyphs";
|
||||
historyRetainedTextMode =
|
||||
$"retained-glyphs;used={historyUsedRetained}";
|
||||
}
|
||||
else
|
||||
{
|
||||
historyRetainedTextOk =
|
||||
!historyUsedRetained
|
||||
&& _host.SnapshotRenderedTextHistory().Count == 1;
|
||||
historyRetainedTextOk = false;
|
||||
_host.EndTextHistoryPresentation(_vm.Gfx);
|
||||
historyRetainedTextMode = "label-fallback";
|
||||
historyRetainedTextMode = "unavailable";
|
||||
}
|
||||
Window rootWindow = GetTree().Root;
|
||||
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
|
||||
@@ -2661,7 +2344,6 @@ public partial class Main : Godot.Control
|
||||
_vm.Gfx, _clock.NowMs, backbufferSnapshot);
|
||||
backbufferPreservationOk &=
|
||||
clearedPolicy == new BackbufferPublicationPolicy(false, false);
|
||||
textEffectSmoke.QueueFree();
|
||||
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
|
||||
&& firstRiffBoundaryOk
|
||||
&& bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk
|
||||
|
||||
104
godot/PortableTextRenderingPolicy.cs
Normal file
104
godot/PortableTextRenderingPolicy.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
public sealed class PortableTextRenderingPolicy
|
||||
{
|
||||
public const string ResourcePath = "res://config/himegari-text-rendering.json";
|
||||
|
||||
public string Id { get; set; } = "";
|
||||
public int FontCacheCapacity { get; set; } = 8;
|
||||
public int GlyphMaskCacheCapacity { get; set; } = 2048;
|
||||
public PortableFontRasterPolicy Raster { get; set; } = new();
|
||||
public string[] DefaultSubstitutes { get; set; } = [];
|
||||
public PortableFontFamilyPolicy[] Families { get; set; } = [];
|
||||
|
||||
public static PortableTextRenderingPolicy Load()
|
||||
{
|
||||
string json = Godot.FileAccess.GetFileAsString(ResourcePath);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
throw new InvalidOperationException(
|
||||
$"Portable text policy '{ResourcePath}' is missing or empty.");
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
PortableTextRenderingPolicy policy =
|
||||
JsonSerializer.Deserialize<PortableTextRenderingPolicy>(json, options)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Portable text policy '{ResourcePath}' decoded to null.");
|
||||
policy.Validate();
|
||||
return policy;
|
||||
}
|
||||
|
||||
public string[] ResolveFamilies(string requestedFace)
|
||||
{
|
||||
string requested = requestedFace?.Trim() ?? "";
|
||||
PortableFontFamilyPolicy? mapped = Families.FirstOrDefault(
|
||||
family => family.Requested.Any(
|
||||
alias => alias.Equals(requested, StringComparison.OrdinalIgnoreCase)));
|
||||
IEnumerable<string> candidates = mapped?.Substitutes ?? DefaultSubstitutes;
|
||||
// The authored face remains the last candidate. This preserves a useful match on systems
|
||||
// that happen to provide it without making that Windows font a portable dependency.
|
||||
return candidates
|
||||
.Append(requested)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Id))
|
||||
throw new InvalidOperationException("Portable text policy requires a non-empty id.");
|
||||
if (FontCacheCapacity <= 0)
|
||||
throw new InvalidOperationException("Portable text fontCacheCapacity must be positive.");
|
||||
if (GlyphMaskCacheCapacity <= 0)
|
||||
throw new InvalidOperationException(
|
||||
"Portable text glyphMaskCacheCapacity must be positive.");
|
||||
if (DefaultSubstitutes.Length == 0)
|
||||
throw new InvalidOperationException(
|
||||
"Portable text policy requires at least one default substitute.");
|
||||
Raster.Validate();
|
||||
foreach (PortableFontFamilyPolicy family in Families)
|
||||
{
|
||||
if (family.Requested.Length == 0 || family.Substitutes.Length == 0)
|
||||
throw new InvalidOperationException(
|
||||
"Every portable font family mapping requires requested aliases and substitutes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PortableFontFamilyPolicy
|
||||
{
|
||||
public string[] Requested { get; set; } = [];
|
||||
public string[] Substitutes { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PortableFontRasterPolicy
|
||||
{
|
||||
public string Antialiasing { get; set; } = "gray";
|
||||
public string Hinting { get; set; } = "normal";
|
||||
public string SubpixelPositioning { get; set; } = "disabled";
|
||||
public bool AllowSystemFallback { get; set; } = true;
|
||||
public bool MultichannelSignedDistanceField { get; set; }
|
||||
public int RegularWeight { get; set; } = 400;
|
||||
public int BoldWeight { get; set; } = 700;
|
||||
public int BoldThreshold { get; set; } = 700;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Antialiasing is not ("none" or "gray" or "lcd"))
|
||||
throw new InvalidOperationException(
|
||||
"Portable raster antialiasing must be none, gray, or lcd.");
|
||||
if (Hinting is not ("none" or "light" or "normal"))
|
||||
throw new InvalidOperationException(
|
||||
"Portable raster hinting must be none, light, or normal.");
|
||||
if (SubpixelPositioning is not ("disabled" or "auto" or "one-half" or "one-quarter"))
|
||||
throw new InvalidOperationException(
|
||||
"Portable raster subpixelPositioning has an unsupported value.");
|
||||
if (RegularWeight is < 100 or > 999
|
||||
|| BoldWeight is < 100 or > 999
|
||||
|| BoldThreshold is < 0 or > 1000)
|
||||
throw new InvalidOperationException(
|
||||
"Portable raster weights are outside their valid ranges.");
|
||||
}
|
||||
}
|
||||
47
godot/config/himegari-text-rendering.json
Normal file
47
godot/config/himegari-text-rendering.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"id": "himegari-godot-system-font-v1",
|
||||
"fontCacheCapacity": 8,
|
||||
"glyphMaskCacheCapacity": 2048,
|
||||
"raster": {
|
||||
"antialiasing": "gray",
|
||||
"hinting": "normal",
|
||||
"subpixelPositioning": "disabled",
|
||||
"allowSystemFallback": true,
|
||||
"multichannelSignedDistanceField": false,
|
||||
"regularWeight": 400,
|
||||
"boldWeight": 700,
|
||||
"boldThreshold": 700
|
||||
},
|
||||
"defaultSubstitutes": [
|
||||
"Noto Sans CJK JP",
|
||||
"Noto Sans JP",
|
||||
"Yu Gothic",
|
||||
"Hiragino Kaku Gothic ProN",
|
||||
"IPAGothic",
|
||||
"sans-serif"
|
||||
],
|
||||
"families": [
|
||||
{
|
||||
"requested": [ "MS 明朝", "MS Mincho" ],
|
||||
"substitutes": [
|
||||
"Noto Serif CJK JP",
|
||||
"Noto Serif JP",
|
||||
"Yu Mincho",
|
||||
"Hiragino Mincho ProN",
|
||||
"IPAMincho",
|
||||
"serif"
|
||||
]
|
||||
},
|
||||
{
|
||||
"requested": [ "MS ゴシック", "MS Gothic" ],
|
||||
"substitutes": [
|
||||
"Noto Sans CJK JP",
|
||||
"Noto Sans JP",
|
||||
"Yu Gothic",
|
||||
"Hiragino Kaku Gothic ProN",
|
||||
"IPAGothic",
|
||||
"sans-serif"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user