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)
|
├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md)
|
||||||
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine
|
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine
|
||||||
plus the optional exact Windows text adapter), including the
|
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
|
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
|
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.
|
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)
|
## Persistence native-format reconnaissance complete (2026-07-24)
|
||||||
|
|
||||||
The deferred save/profile ownership question now has a compatibility-mode answer. The remaining native
|
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 |
|
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
|
||||||
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
|
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
|
||||||
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
|
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
|
||||||
| ADV font discovery/raster fidelity | `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 |
|
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
|
||||||
| Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target |
|
| Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target |
|
||||||
| Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS |
|
| Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS |
|
||||||
|
|||||||
@@ -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
|
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,
|
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
|
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
|
visibility exception are gone. At this checkpoint exact History no longer entered the layout-keyed Label
|
||||||
the live/immediate Label pools remain solely as the explicit unavailable-rasterizer fallback until step 7
|
pool; step 7 subsequently supplied the portable backend and removed every remaining gameplay Label pool.
|
||||||
supplies the portable glyph backend. Focused lifecycle tests, all 579 engine tests, the exact History
|
Focused lifecycle tests, all 579 engine tests, the exact History
|
||||||
Godot self-test, and a real SC0000 Vulkan capture cover the retained path.
|
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
|
7. **Complete and select the portable backend — completed 2026-07-30.** Godot's TextServer atlas is the
|
||||||
font masks/metrics (Godot/TextServer offscreen extraction versus a bounded FreeType-compatible adapter)
|
portable mask/metrics source, avoiding another native dependency. Profile data in
|
||||||
before choosing a dependency. Define face substitution and raster policy as profile/configuration data,
|
`godot/config/himegari-text-rendering.json` owns ordered Mincho/Gothic substitutions and bounded cache
|
||||||
keep its output explicitly non-identical to GDI, and run the same layout/compositor tests. Retain a
|
sizes. `auto` selects exact GDI when its ACP-932 gate passes and otherwise selects the explicitly
|
||||||
development-only backend switch through acceptance; remove the old Label fallback only when both exact
|
non-pixel-exact Unicode backend; development runs can force either with `--text-backend`. Both modes pass
|
||||||
Windows and portable startup/legibility gates 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:**
|
**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.
|
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).
|
- `--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.
|
- `--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
|
- `--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
|
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,
|
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));
|
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]
|
[Theory]
|
||||||
[InlineData(24, 24)]
|
[InlineData(24, 24)]
|
||||||
[InlineData(25, 24)]
|
[InlineData(25, 24)]
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ public readonly record struct ImmediateSurfaceTextResult(
|
|||||||
int CursorY);
|
int CursorY);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts one native CP932 string into exact glyph requests and composites it directly into an
|
/// Converts one string into backend-policy glyph requests and composites it directly into an AGE
|
||||||
/// AGE RGBA surface. Request construction completes before any pixels change, so an unsupported
|
/// RGBA surface. Request construction completes before any pixels change, so an unsupported
|
||||||
/// character cannot leave a partially drawn string behind.
|
/// character cannot leave a partially drawn string behind.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ImmediateSurfaceTextRenderer
|
public sealed class ImmediateSurfaceTextRenderer
|
||||||
@@ -20,9 +20,15 @@ public sealed class ImmediateSurfaceTextRenderer
|
|||||||
|
|
||||||
private static readonly Encoding Cp932 = CreateCp932();
|
private static readonly Encoding Cp932 = CreateCp932();
|
||||||
private readonly IGlyphMaskRasterizer _rasterizer;
|
private readonly IGlyphMaskRasterizer _rasterizer;
|
||||||
|
private readonly GlyphRasterPolicy _policy;
|
||||||
|
|
||||||
public ImmediateSurfaceTextRenderer(IGlyphMaskRasterizer rasterizer)
|
public ImmediateSurfaceTextRenderer(
|
||||||
=> _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
|
IGlyphMaskRasterizer rasterizer,
|
||||||
|
GlyphRasterPolicy policy = GlyphRasterPolicy.NativeCp932Gray4)
|
||||||
|
{
|
||||||
|
_rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
|
||||||
|
_policy = policy;
|
||||||
|
}
|
||||||
|
|
||||||
public ImmediateSurfaceTextResult Render(
|
public ImmediateSurfaceTextResult Render(
|
||||||
RgbaImage destination,
|
RgbaImage destination,
|
||||||
@@ -33,7 +39,7 @@ public sealed class ImmediateSurfaceTextRenderer
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(destination);
|
ArgumentNullException.ThrowIfNull(destination);
|
||||||
ArgumentNullException.ThrowIfNull(text);
|
ArgumentNullException.ThrowIfNull(text);
|
||||||
IReadOnlyList<GlyphRasterRequest> requests = CreateRequests(text, style);
|
IReadOnlyList<GlyphRasterRequest> requests = CreateRequests(text, style, _policy);
|
||||||
int cursorX = x;
|
int cursorX = x;
|
||||||
int cursorY = y;
|
int cursorY = y;
|
||||||
foreach (GlyphRasterRequest request in requests)
|
foreach (GlyphRasterRequest request in requests)
|
||||||
@@ -49,7 +55,8 @@ public sealed class ImmediateSurfaceTextRenderer
|
|||||||
|
|
||||||
public static IReadOnlyList<GlyphRasterRequest> CreateRequests(
|
public static IReadOnlyList<GlyphRasterRequest> CreateRequests(
|
||||||
string text,
|
string text,
|
||||||
AdvTextStyle style)
|
AdvTextStyle style,
|
||||||
|
GlyphRasterPolicy policy = GlyphRasterPolicy.NativeCp932Gray4)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(text);
|
ArgumentNullException.ThrowIfNull(text);
|
||||||
int requestedHeight = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
|
int requestedHeight = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
|
||||||
@@ -63,26 +70,28 @@ public sealed class ImmediateSurfaceTextRenderer
|
|||||||
|
|
||||||
foreach (Rune rune in text.EnumerateRunes())
|
foreach (Rune rune in text.EnumerateRunes())
|
||||||
{
|
{
|
||||||
byte[] encoded;
|
ushort? cp932Code = null;
|
||||||
try
|
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)
|
catch (EncoderFallbackException error)
|
||||||
{
|
{
|
||||||
throw new ArgumentException(
|
if (policy == GlyphRasterPolicy.NativeCp932Gray4)
|
||||||
$"U+{rune.Value:X4} is not representable in native CP932 text.", nameof(text), error);
|
throw new ArgumentException(
|
||||||
|
$"U+{rune.Value:X4} is not representable in native CP932 text.",
|
||||||
|
nameof(text), error);
|
||||||
}
|
}
|
||||||
if (encoded.Length is < 1 or > 2)
|
|
||||||
throw new ArgumentException(
|
|
||||||
$"U+{rune.Value:X4} encoded to unsupported CP932 length {encoded.Length}.",
|
|
||||||
nameof(text));
|
|
||||||
ushort cp932Code = encoded.Length == 1
|
|
||||||
? encoded[0]
|
|
||||||
: (ushort)((encoded[0] << 8) | encoded[1]);
|
|
||||||
requests.Add(new GlyphRasterRequest(
|
requests.Add(new GlyphRasterRequest(
|
||||||
fontFace, pixelHeight, requestedWidth, weight,
|
fontFace, pixelHeight, requestedWidth, weight,
|
||||||
rune.Value, cp932Code, GlyphRasterPolicy.NativeCp932Gray4));
|
rune.Value, cp932Code, policy));
|
||||||
}
|
}
|
||||||
return requests;
|
return requests;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,21 +65,16 @@ public sealed class GodotAdvHost : IHost
|
|||||||
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
|
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
|
||||||
private volatile bool _stopping;
|
private volatile bool _stopping;
|
||||||
private readonly object _textLock = new();
|
private readonly object _textLock = new();
|
||||||
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
|
private readonly CachedGlyphMaskRasterizer _surfaceTextMaskCache;
|
||||||
private readonly CachedGlyphMaskRasterizer? _surfaceTextMaskCache;
|
private readonly ImmediateSurfaceTextRenderer _surfaceTextPixelRenderer;
|
||||||
private readonly ImmediateSurfaceTextRenderer? _surfaceTextPixelRenderer;
|
private readonly RetainedGlyphLayoutEngine _retainedGlyphLayoutEngine;
|
||||||
private readonly RetainedGlyphLayoutEngine? _retainedGlyphLayoutEngine;
|
private readonly GlyphRasterizerBackendInfo _surfaceTextBackendInfo;
|
||||||
private readonly GlyphRasterizerBackendInfo? _surfaceTextBackendInfo;
|
|
||||||
private string _surfaceTextFallbackReason;
|
|
||||||
private bool _surfaceTextFallbackWarningReported;
|
|
||||||
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
|
|
||||||
private readonly HashSet<int> _retainedHistoryLayouts = new();
|
private readonly HashSet<int> _retainedHistoryLayouts = new();
|
||||||
private sealed class LiveTextState
|
private sealed class LiveTextState
|
||||||
{
|
{
|
||||||
public required AdvLiveTextRun Run;
|
public required AdvLiveTextRun Run;
|
||||||
public required long StartedMs;
|
public required long StartedMs;
|
||||||
public required int GlyphDelayMilliseconds;
|
public required int GlyphDelayMilliseconds;
|
||||||
public bool RetainedPixels;
|
|
||||||
public int FirstGlyphIndex;
|
public int FirstGlyphIndex;
|
||||||
public int GlyphCount;
|
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,
|
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
|
||||||
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
|
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
|
||||||
|
IGlyphMaskRasterizer surfaceTextRasterizer,
|
||||||
GodotTimelineLog? timeline = null,
|
GodotTimelineLog? timeline = null,
|
||||||
bool synchronizeExplicitPresentation = true,
|
bool synchronizeExplicitPresentation = true,
|
||||||
IGlyphMaskRasterizer? surfaceTextRasterizer = null,
|
int surfaceTextMaskCacheCapacity = 2048)
|
||||||
string? surfaceTextFallbackReason = null)
|
|
||||||
{
|
{
|
||||||
_main = main; _res = res; _rootScene = scene; _clock = clock;
|
_main = main; _res = res; _rootScene = scene; _clock = clock;
|
||||||
_locator = locator; _timeline = timeline;
|
_locator = locator; _timeline = timeline;
|
||||||
@@ -148,35 +143,30 @@ public sealed class GodotAdvHost : IHost
|
|||||||
_screenHeight = logicalCanvas.Height;
|
_screenHeight = logicalCanvas.Height;
|
||||||
_slotDims[0] = (_screenWidth, _screenHeight);
|
_slotDims[0] = (_screenWidth, _screenHeight);
|
||||||
_synchronizeExplicitPresentation = synchronizeExplicitPresentation;
|
_synchronizeExplicitPresentation = synchronizeExplicitPresentation;
|
||||||
|
ArgumentNullException.ThrowIfNull(surfaceTextRasterizer);
|
||||||
_surfaceTextBackendInfo =
|
_surfaceTextBackendInfo =
|
||||||
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo;
|
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo
|
||||||
if (surfaceTextRasterizer != null)
|
?? throw new ArgumentException(
|
||||||
{
|
"Gameplay glyph rasterizers must identify their policy.",
|
||||||
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
|
nameof(surfaceTextRasterizer));
|
||||||
surfaceTextRasterizer, capacity: 2048);
|
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
|
||||||
_surfaceTextPixelRenderer =
|
surfaceTextRasterizer, capacity: surfaceTextMaskCacheCapacity);
|
||||||
new ImmediateSurfaceTextRenderer(_surfaceTextMaskCache);
|
_surfaceTextPixelRenderer =
|
||||||
_retainedGlyphLayoutEngine =
|
new ImmediateSurfaceTextRenderer(
|
||||||
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
|
_surfaceTextMaskCache, _surfaceTextBackendInfo.Policy);
|
||||||
}
|
_retainedGlyphLayoutEngine =
|
||||||
_surfaceTextFallbackReason = surfaceTextFallbackReason
|
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
|
||||||
?? (surfaceTextRasterizer == null
|
|
||||||
? "No glyph-mask rasterizer was selected."
|
|
||||||
: "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
|
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
|
||||||
public bool UsesSurfaceTextPixels => _surfaceTextPixelRenderer != null;
|
public bool UsesSurfaceTextPixels => true;
|
||||||
public GlyphRasterizerBackendInfo? SurfaceTextBackendInfo => _surfaceTextBackendInfo;
|
public GlyphRasterizerBackendInfo SurfaceTextBackendInfo => _surfaceTextBackendInfo;
|
||||||
public string SurfaceTextFallbackReason => _surfaceTextFallbackReason;
|
|
||||||
public (int Count, int Capacity, long Hits, long Misses) SurfaceTextMaskCacheStats
|
public (int Count, int Capacity, long Hits, long Misses) SurfaceTextMaskCacheStats
|
||||||
=> _surfaceTextMaskCache == null
|
=> (
|
||||||
? (0, 0, 0, 0)
|
_surfaceTextMaskCache.Count,
|
||||||
: (
|
_surfaceTextMaskCache.Capacity,
|
||||||
_surfaceTextMaskCache.Count,
|
_surfaceTextMaskCache.Hits,
|
||||||
_surfaceTextMaskCache.Capacity,
|
_surfaceTextMaskCache.Misses);
|
||||||
_surfaceTextMaskCache.Hits,
|
|
||||||
_surfaceTextMaskCache.Misses);
|
|
||||||
|
|
||||||
public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
|
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)
|
public void ShowText(int offset, string text)
|
||||||
{
|
=> throw new NotSupportedException(
|
||||||
AdvTextLayoutSnapshot layout;
|
"Godot gameplay text requires a retained layout binding.");
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds)
|
public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds)
|
||||||
{
|
=> throw new NotSupportedException(
|
||||||
Captured.Add((run.SourceOffset, run.Text));
|
"Godot gameplay text requires a retained layout binding.");
|
||||||
_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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
public AdvRetainedTextRunResult? ShowText(
|
public AdvRetainedTextRunResult? ShowText(
|
||||||
GfxState gfx,
|
GfxState gfx,
|
||||||
@@ -351,18 +274,15 @@ public sealed class GodotAdvHost : IHost
|
|||||||
AdvLiveTextRun run,
|
AdvLiveTextRun run,
|
||||||
int glyphDelayMilliseconds)
|
int glyphDelayMilliseconds)
|
||||||
{
|
{
|
||||||
if (_retainedGlyphLayoutEngine == null
|
if (binding.LayoutSlot != run.Layout.Slot
|
||||||
|| binding.LayoutSlot != run.Layout.Slot
|
|
||||||
|| binding.FirstObjectHandle < 0
|
|| binding.FirstObjectHandle < 0
|
||||||
|| binding.ObjectCapacity <= 0
|
|| binding.ObjectCapacity <= 0)
|
||||||
|| !TryPrepareRetainedTextRun(
|
throw new InvalidOperationException(
|
||||||
gfx, binding, run,
|
$"ADV layout {run.Layout.Slot} has no retained presentation binding.");
|
||||||
out RetainedAdvTextLayoutPresentation? presentation,
|
PrepareRetainedTextRun(
|
||||||
out AdvRetainedTextRunResult result))
|
gfx, binding, run,
|
||||||
{
|
out RetainedAdvTextLayoutPresentation? presentation,
|
||||||
ShowText(run, glyphDelayMilliseconds);
|
out AdvRetainedTextRunResult result);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Captured.Add((run.SourceOffset, run.Text));
|
Captured.Add((run.SourceOffset, run.Text));
|
||||||
_locator.Text(run.SourceOffset, run.Text);
|
_locator.Text(run.SourceOffset, run.Text);
|
||||||
@@ -376,7 +296,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
Run = run,
|
Run = run,
|
||||||
StartedMs = _clock.NowMs,
|
StartedMs = _clock.NowMs,
|
||||||
GlyphDelayMilliseconds = delay,
|
GlyphDelayMilliseconds = delay,
|
||||||
RetainedPixels = true,
|
|
||||||
FirstGlyphIndex = result.FirstGlyphIndex,
|
FirstGlyphIndex = result.FirstGlyphIndex,
|
||||||
GlyphCount = revealGlyphCount,
|
GlyphCount = revealGlyphCount,
|
||||||
};
|
};
|
||||||
@@ -451,7 +370,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryPrepareRetainedTextRun(
|
private void PrepareRetainedTextRun(
|
||||||
GfxState gfx,
|
GfxState gfx,
|
||||||
AdvTextLayoutPresentationBinding binding,
|
AdvTextLayoutPresentationBinding binding,
|
||||||
AdvLiveTextRun run,
|
AdvLiveTextRun run,
|
||||||
@@ -461,12 +380,9 @@ public sealed class GodotAdvHost : IHost
|
|||||||
presentation = null!;
|
presentation = null!;
|
||||||
result = default;
|
result = default;
|
||||||
if (run.Layout.Width <= 0 || run.Layout.Height <= 0)
|
if (run.Layout.Width <= 0 || run.Layout.Height <= 0)
|
||||||
{
|
throw new InvalidOperationException(
|
||||||
ReportSurfaceTextFallback(
|
|
||||||
$"ADV layout {run.Layout.Slot} has invalid dimensions " +
|
$"ADV layout {run.Layout.Slot} has invalid dimensions " +
|
||||||
$"{run.Layout.Width}x{run.Layout.Height}.");
|
$"{run.Layout.Width}x{run.Layout.Height}.");
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
RgbaImage destination = ResolveSurfacePixels(binding.SourceSurfaceSlot)
|
RgbaImage destination = ResolveSurfacePixels(binding.SourceSurfaceSlot)
|
||||||
?? new RgbaImage(
|
?? new RgbaImage(
|
||||||
@@ -475,43 +391,27 @@ public sealed class GodotAdvHost : IHost
|
|||||||
new byte[checked(run.Layout.Width * run.Layout.Height * 4)]);
|
new byte[checked(run.Layout.Width * run.Layout.Height * 4)]);
|
||||||
if (destination.Width != run.Layout.Width
|
if (destination.Width != run.Layout.Width
|
||||||
|| destination.Height != run.Layout.Height)
|
|| destination.Height != run.Layout.Height)
|
||||||
{
|
throw new InvalidOperationException(
|
||||||
ReportSurfaceTextFallback(
|
|
||||||
$"ADV layout {run.Layout.Slot} surface {binding.SourceSurfaceSlot} is " +
|
$"ADV layout {run.Layout.Slot} surface {binding.SourceSurfaceSlot} is " +
|
||||||
$"{destination.Width}x{destination.Height}; expected " +
|
$"{destination.Width}x{destination.Height}; expected " +
|
||||||
$"{run.Layout.Width}x{run.Layout.Height}.");
|
$"{run.Layout.Width}x{run.Layout.Height}.");
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var updated = new RgbaImage(
|
var updated = new RgbaImage(
|
||||||
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
|
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
|
||||||
GlyphTextLayoutResult rendered;
|
IReadOnlyList<GlyphRasterRequest> requests =
|
||||||
try
|
ImmediateSurfaceTextRenderer.CreateRequests(
|
||||||
{
|
run.Text, run.Style, _surfaceTextBackendInfo.Policy);
|
||||||
IReadOnlyList<GlyphRasterRequest> requests =
|
GlyphTextLayoutResult rendered = _retainedGlyphLayoutEngine.Render(
|
||||||
ImmediateSurfaceTextRenderer.CreateRequests(run.Text, run.Style);
|
updated,
|
||||||
rendered = _retainedGlyphLayoutEngine!.Render(
|
new GlyphTextLayoutOptions(
|
||||||
updated,
|
run.Layout.CursorX,
|
||||||
new GlyphTextLayoutOptions(
|
run.Layout.CursorY,
|
||||||
run.Layout.CursorX,
|
binding.ResetCursorX,
|
||||||
run.Layout.CursorY,
|
run.Layout.Right,
|
||||||
binding.ResetCursorX,
|
run.Layout.Bottom,
|
||||||
run.Layout.Right,
|
WrapHorizontally: true,
|
||||||
run.Layout.Bottom,
|
run.Style),
|
||||||
WrapHorizontally: true,
|
requests);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
@@ -522,11 +422,8 @@ public sealed class GodotAdvHost : IHost
|
|||||||
_retainedTextLayouts.Add(binding.LayoutSlot, presentation);
|
_retainedTextLayouts.Add(binding.LayoutSlot, presentation);
|
||||||
}
|
}
|
||||||
else if (presentation.Binding != binding)
|
else if (presentation.Binding != binding)
|
||||||
{
|
throw new InvalidOperationException(
|
||||||
ReportSurfaceTextFallback(
|
|
||||||
$"ADV layout {binding.LayoutSlot} changed its retained binding without reset.");
|
$"ADV layout {binding.LayoutSlot} changed its retained binding without reset.");
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int first = presentation.Append(
|
int first = presentation.Append(
|
||||||
rendered.Records, run.Layout.OriginX, run.Layout.OriginY);
|
rendered.Records, run.Layout.OriginX, run.Layout.OriginY);
|
||||||
result = new AdvRetainedTextRunResult(
|
result = new AdvRetainedTextRunResult(
|
||||||
@@ -541,7 +438,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
_surfaceImages[binding.SourceSurfaceSlot] = updated;
|
_surfaceImages[binding.SourceSurfaceSlot] = updated;
|
||||||
_slotDims[binding.SourceSurfaceSlot] = (updated.Width, updated.Height);
|
_slotDims[binding.SourceSurfaceSlot] = (updated.Width, updated.Height);
|
||||||
gfx.CreateSurface(binding.SourceSurfaceSlot);
|
gfx.CreateSurface(binding.SourceSurfaceSlot);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetAdvTextCursor(int layoutSlot, int x, int y)
|
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)
|
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style)
|
||||||
{
|
{
|
||||||
if (_surfaceTextPixelRenderer != null
|
DrawStringPixels(surfaceSlot, x, y, text, style);
|
||||||
&& 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));
|
|
||||||
}
|
|
||||||
_timeline?.Event("draw-string", new()
|
_timeline?.Event("draw-string", new()
|
||||||
{
|
{
|
||||||
["surface"] = surfaceSlot,
|
["surface"] = surfaceSlot,
|
||||||
["x"] = x,
|
["x"] = x,
|
||||||
["y"] = y,
|
["y"] = y,
|
||||||
["text"] = text,
|
["text"] = text,
|
||||||
["presentation"] = "label-fallback",
|
["presentation"] = "rgba-glyph-mask",
|
||||||
["reason"] = _surfaceTextFallbackReason,
|
["backend"] = _surfaceTextBackendInfo.Id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryDrawStringPixels(
|
private void DrawStringPixels(
|
||||||
int surfaceSlot, int x, int y, string text, AdvTextStyle style)
|
int surfaceSlot, int x, int y, string text, AdvTextStyle style)
|
||||||
{
|
{
|
||||||
RgbaImage? destination = ResolveSurfacePixels(surfaceSlot);
|
RgbaImage? destination = ResolveSurfacePixels(surfaceSlot);
|
||||||
@@ -611,40 +480,15 @@ public sealed class GodotAdvHost : IHost
|
|||||||
dimensions.W, dimensions.H,
|
dimensions.W, dimensions.H,
|
||||||
new byte[checked(dimensions.W * dimensions.H * 4)]);
|
new byte[checked(dimensions.W * dimensions.H * 4)]);
|
||||||
if (destination == null || destination.Width <= 0 || destination.Height <= 0)
|
if (destination == null || destination.Width <= 0 || destination.Height <= 0)
|
||||||
{
|
throw new InvalidOperationException(
|
||||||
ReportSurfaceTextFallback(
|
|
||||||
$"Surface {surfaceSlot} has no rasterizable pixel allocation.");
|
$"Surface {surfaceSlot} has no rasterizable pixel allocation.");
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var updated = new RgbaImage(
|
var updated = new RgbaImage(
|
||||||
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
|
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
|
||||||
try
|
_surfaceTextPixelRenderer.Render(updated, x, y, text, style);
|
||||||
{
|
|
||||||
_surfaceTextPixelRenderer!.Render(updated, x, y, text, style);
|
|
||||||
}
|
|
||||||
catch (Exception error) when (
|
|
||||||
error is ArgumentException
|
|
||||||
or InvalidOperationException
|
|
||||||
or PlatformNotSupportedException
|
|
||||||
or System.ComponentModel.Win32Exception)
|
|
||||||
{
|
|
||||||
ReportSurfaceTextFallback(
|
|
||||||
$"{error.GetType().Name}: {error.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_imageLock) _surfaceImages[surfaceSlot] = updated;
|
lock (_imageLock) _surfaceImages[surfaceSlot] = updated;
|
||||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReportSurfaceTextFallback(string reason)
|
|
||||||
{
|
|
||||||
_surfaceTextFallbackReason = reason;
|
|
||||||
if (_surfaceTextFallbackWarningReported) return;
|
|
||||||
_surfaceTextFallbackWarningReported = true;
|
|
||||||
ReportWarning($"surface text fell back to Label metadata: {reason}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public (string Text, int X, int Y, int VisibleGlyphs, bool Revealing) SnapshotAdvText()
|
public (string Text, int X, int Y, int VisibleGlyphs, bool Revealing) SnapshotAdvText()
|
||||||
@@ -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>
|
/// <summary>
|
||||||
/// Layout that owns the ordinary ADV overlay. Nested callback scripts such as HISTORY can select and
|
/// Layout that owns the ordinary retained ADV page. Nested callback scripts such as HISTORY can select
|
||||||
/// mutate other layouts while the parent wait remains parked; those transient selections must not move
|
/// and mutate other layouts while the parent wait remains parked; those transient selections must not
|
||||||
/// the parent page when its overlay becomes visible again.
|
/// move the parent page when its retained range is restored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int AdvPageLayoutSlot
|
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)
|
public void ClearRenderedAdvTextLayout(int layoutSlot)
|
||||||
{
|
{
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
int slot = layoutSlot == 0 ? _currentAdvLayout : layoutSlot;
|
int slot = layoutSlot == 0 ? _currentAdvLayout : layoutSlot;
|
||||||
_historyText.Remove(slot);
|
|
||||||
if (_retainedTextLayouts.ContainsKey(slot)) return;
|
if (_retainedTextLayouts.ContainsKey(slot)) return;
|
||||||
_liveText.RemoveAll(state => state.Run.Layout.Slot == slot);
|
_liveText.RemoveAll(state => state.Run.Layout.Slot == slot);
|
||||||
if (_activeLiveText?.Run.Layout.Slot == slot)
|
if (_activeLiveText?.Run.Layout.Slot == slot)
|
||||||
@@ -733,7 +540,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
{
|
{
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
_historyText.Remove(binding.LayoutSlot);
|
|
||||||
_retainedHistoryLayouts.Remove(binding.LayoutSlot);
|
_retainedHistoryLayouts.Remove(binding.LayoutSlot);
|
||||||
_retainedTextLayouts.Remove(binding.LayoutSlot);
|
_retainedTextLayouts.Remove(binding.LayoutSlot);
|
||||||
_liveText.RemoveAll(
|
_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(
|
public bool RenderTextHistory(
|
||||||
GfxState gfx,
|
GfxState gfx,
|
||||||
AdvTextLayoutPresentationBinding binding,
|
AdvTextLayoutPresentationBinding binding,
|
||||||
@@ -793,25 +587,21 @@ public sealed class GodotAdvHost : IHost
|
|||||||
batch.Style,
|
batch.Style,
|
||||||
batch.Text,
|
batch.Text,
|
||||||
Array.Empty<string>());
|
Array.Empty<string>());
|
||||||
if (_retainedGlyphLayoutEngine == null
|
if (binding.LayoutSlot != batch.LayoutSlot
|
||||||
|| binding.LayoutSlot != batch.LayoutSlot
|
|
||||||
|| binding.FirstObjectHandle < 0
|
|| binding.FirstObjectHandle < 0
|
||||||
|| binding.ObjectCapacity <= 0
|
|| binding.ObjectCapacity <= 0)
|
||||||
|| !TryPrepareRetainedTextRun(
|
throw new InvalidOperationException(
|
||||||
gfx, binding, run,
|
$"History layout {batch.LayoutSlot} has no retained presentation binding.");
|
||||||
out RetainedAdvTextLayoutPresentation? presentation,
|
PrepareRetainedTextRun(
|
||||||
out AdvRetainedTextRunResult result))
|
gfx, binding, run,
|
||||||
{
|
out RetainedAdvTextLayoutPresentation? presentation,
|
||||||
RenderTextHistory(batch);
|
out AdvRetainedTextRunResult result);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
presentation.PublishThrough(
|
presentation.PublishThrough(
|
||||||
gfx,
|
gfx,
|
||||||
checked(result.FirstGlyphIndex + result.GlyphCount));
|
checked(result.FirstGlyphIndex + result.GlyphCount));
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
_historyText.Remove(batch.LayoutSlot);
|
|
||||||
_retainedHistoryLayouts.Add(batch.LayoutSlot);
|
_retainedHistoryLayouts.Add(batch.LayoutSlot);
|
||||||
}
|
}
|
||||||
Interlocked.Exchange(ref _presentRequested, 1);
|
Interlocked.Exchange(ref _presentRequested, 1);
|
||||||
@@ -828,16 +618,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void EndTextHistoryPresentation()
|
|
||||||
{
|
|
||||||
lock (_textLock)
|
|
||||||
{
|
|
||||||
_historyText.Clear();
|
|
||||||
_retainedHistoryLayouts.Clear();
|
|
||||||
}
|
|
||||||
_timeline?.Event("history-presentation-end");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void EndTextHistoryPresentation(GfxState gfx)
|
public void EndTextHistoryPresentation(GfxState gfx)
|
||||||
{
|
{
|
||||||
RetainedAdvTextLayoutPresentation[] retained;
|
RetainedAdvTextLayoutPresentation[] retained;
|
||||||
@@ -856,7 +636,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
foreach (int slot in _retainedHistoryLayouts)
|
foreach (int slot in _retainedHistoryLayouts)
|
||||||
_retainedTextLayouts.Remove(slot);
|
_retainedTextLayouts.Remove(slot);
|
||||||
_retainedHistoryLayouts.Clear();
|
_retainedHistoryLayouts.Clear();
|
||||||
_historyText.Clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (RetainedAdvTextLayoutPresentation presentation in retained)
|
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)
|
private void ClearAllocatedSurfacePixels(int surfaceSlot)
|
||||||
{
|
{
|
||||||
if (!_slotDims.TryGetValue(surfaceSlot, out var dimensions)
|
if (!_slotDims.TryGetValue(surfaceSlot, out var dimensions)
|
||||||
@@ -898,17 +672,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
|
|
||||||
public void FillSurfaceRect(SurfaceRectFill fill)
|
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);
|
RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot);
|
||||||
if (destination == null && _slotDims.TryGetValue(fill.SurfaceSlot, out var dimensions)
|
if (destination == null && _slotDims.TryGetValue(fill.SurfaceSlot, out var dimensions)
|
||||||
&& dimensions.W >= 0 && dimensions.H >= 0)
|
&& dimensions.W >= 0 && dimensions.H >= 0)
|
||||||
@@ -1655,9 +1418,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
}
|
}
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
_surfaceText.Clear();
|
|
||||||
_surfaceResources.Clear();
|
_surfaceResources.Clear();
|
||||||
_historyText.Clear();
|
|
||||||
_retainedHistoryLayouts.Clear();
|
_retainedHistoryLayouts.Clear();
|
||||||
_liveText.Clear();
|
_liveText.Clear();
|
||||||
_retainedTextLayouts.Clear();
|
_retainedTextLayouts.Clear();
|
||||||
@@ -1792,7 +1553,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
|
|
||||||
public void CreateTexture(int slot, int width, int height)
|
public void CreateTexture(int slot, int width, int height)
|
||||||
{
|
{
|
||||||
lock (_textLock) _surfaceText.Remove(slot);
|
|
||||||
lock (_textLock) _surfaceResources.Remove(slot);
|
lock (_textLock) _surfaceResources.Remove(slot);
|
||||||
int safeWidth = System.Math.Max(0, width);
|
int safeWidth = System.Math.Max(0, width);
|
||||||
int safeHeight = System.Math.Max(0, height);
|
int safeHeight = System.Math.Max(0, height);
|
||||||
@@ -1817,7 +1577,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
}
|
}
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
_surfaceText.Remove(slot);
|
|
||||||
_surfaceResources[slot] = resourceId;
|
_surfaceResources[slot] = resourceId;
|
||||||
}
|
}
|
||||||
var asset = _res.ResolveTexture(resourceId);
|
var asset = _res.ResolveTexture(resourceId);
|
||||||
@@ -1851,7 +1610,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
}
|
}
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
_surfaceText.Remove(slot);
|
|
||||||
_surfaceResources.Remove(slot);
|
_surfaceResources.Remove(slot);
|
||||||
foreach (int layoutSlot in _retainedTextLayouts
|
foreach (int layoutSlot in _retainedTextLayouts
|
||||||
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
|
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
|
||||||
@@ -2152,7 +1910,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
}
|
}
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
_surfaceText.Remove(slot);
|
|
||||||
_surfaceResources.Remove(slot);
|
_surfaceResources.Remove(slot);
|
||||||
foreach (int layoutSlot in _retainedTextLayouts
|
foreach (int layoutSlot in _retainedTextLayouts
|
||||||
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
|
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
|
||||||
@@ -2187,9 +1944,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
}
|
}
|
||||||
else
|
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)
|
lock (_imageLock)
|
||||||
{
|
{
|
||||||
if (_surfaceImages.TryGetValue(surfaceSlot, out var image))
|
if (_surfaceImages.TryGetValue(surfaceSlot, out var image))
|
||||||
@@ -2235,8 +1989,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
});
|
});
|
||||||
|
|
||||||
lock (_imageLock) _surfaceImages[targetSlot] = destination;
|
lock (_imageLock) _surfaceImages[targetSlot] = destination;
|
||||||
PublishSurfaceTextRangeToSurface(
|
|
||||||
gfx, visible, firstHandle, count, targetSlot, dimensions.W, dimensions.H);
|
|
||||||
IReadOnlyList<RenderObject> retained = visible
|
IReadOnlyList<RenderObject> retained = visible
|
||||||
.Where(item => item.Handle >= firstHandle && item.Handle - firstHandle < count)
|
.Where(item => item.Handle >= firstHandle && item.Handle - firstHandle < count)
|
||||||
.ToArray();
|
.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)
|
public void ReleaseSurfaceRange(int firstSlot, int count)
|
||||||
{
|
{
|
||||||
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
|
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
|
||||||
@@ -2316,7 +2022,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
{
|
{
|
||||||
for (int slot = firstSlot; slot < end; slot++)
|
for (int slot = firstSlot; slot < end; slot++)
|
||||||
{
|
{
|
||||||
_surfaceText.Remove(slot);
|
|
||||||
_surfaceResources.Remove(slot);
|
_surfaceResources.Remove(slot);
|
||||||
}
|
}
|
||||||
foreach (int layoutSlot in _retainedTextLayouts
|
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(
|
public sealed record GodotHostDiagnosticSnapshot(
|
||||||
string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping,
|
string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping,
|
||||||
bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended,
|
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
|
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 _screenWidth = Sys4LogicalCanvas.DefaultWidth;
|
||||||
private int _screenHeight = Sys4LogicalCanvas.DefaultHeight;
|
private int _screenHeight = Sys4LogicalCanvas.DefaultHeight;
|
||||||
private WindowLaunchOptions _windowOptions;
|
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
|
// 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.
|
// completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer.
|
||||||
private byte[] _screenPixels = [];
|
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 _status = null!;
|
||||||
private Label _locatorHud = null!;
|
private Label _locatorHud = null!;
|
||||||
private AudioStreamPlayer _bgm = null!; // looping background music
|
private AudioStreamPlayer _bgm = null!; // looping background music
|
||||||
@@ -69,9 +51,7 @@ public partial class Main : Godot.Control
|
|||||||
private Sys4RegIniStore? _sys4RegIniStore;
|
private Sys4RegIniStore? _sys4RegIniStore;
|
||||||
private VirtualMachine _vm = null!;
|
private VirtualMachine _vm = null!;
|
||||||
private GodotAdvHost _host = null!;
|
private GodotAdvHost _host = null!;
|
||||||
#if AGE_WINDOWS_GDI
|
private IDisposable? _glyphRasterizerOwner;
|
||||||
private WindowsGdiGlyphMaskRasterizer? _exactGlyphRasterizer;
|
|
||||||
#endif
|
|
||||||
private FullwidthTextEditorDialog? _fullwidthTextEditor;
|
private FullwidthTextEditorDialog? _fullwidthTextEditor;
|
||||||
private Sys4ScriptProvider? _scripts;
|
private Sys4ScriptProvider? _scripts;
|
||||||
private DebugSceneLauncher? _debugSceneLauncher;
|
private DebugSceneLauncher? _debugSceneLauncher;
|
||||||
@@ -191,14 +171,6 @@ public partial class Main : Godot.Control
|
|||||||
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
_gpuRenderer = new GpuRetainedRenderer(this);
|
_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();
|
_status = new Label();
|
||||||
AddChild(_status);
|
AddChild(_status);
|
||||||
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
|
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
|
||||||
@@ -207,7 +179,7 @@ public partial class Main : Godot.Control
|
|||||||
_locatorHud.Position = new Vector2(8, 8);
|
_locatorHud.Position = new Vector2(8, 8);
|
||||||
AddChild(_locatorHud);
|
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",
|
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" })
|
"C:/Windows/Fonts/YuGothR.ttc", "C:/Windows/Fonts/meiryo.ttc" })
|
||||||
{
|
{
|
||||||
@@ -215,18 +187,8 @@ public partial class Main : Godot.Control
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) };
|
var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) };
|
||||||
_presentationFallbackFont = ff;
|
|
||||||
_text.AddThemeFontOverride("font", ff);
|
|
||||||
_speaker.AddThemeFontOverride("font", ff);
|
|
||||||
_status.AddThemeFontOverride("font", ff);
|
_status.AddThemeFontOverride("font", ff);
|
||||||
_locatorHud.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;
|
break;
|
||||||
}
|
}
|
||||||
catch { /* fall back to the default font */ }
|
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
|
double speed = 1.0; // --speed <f>: sleeps + retained presentation clocks
|
||||||
long transitionClickMs = -1; // --transition-click-ms <n>: force active transitions after n virtual ms
|
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
|
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? 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
|
string? pageMapPath = null; // --page-map <jsonl>: override default build/page-map-SCxxxx.jsonl
|
||||||
for (int i = 0; i < userArgs.Length; i++)
|
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] == "--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] == "--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] == "--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] == "--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] == "--page-map" && i + 1 < userArgs.Length) pageMapPath = userArgs[i + 1];
|
||||||
if (userArgs[i] == "--locator-hud") _locatorHudVisible = true;
|
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 (!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);
|
_clock.Speed = System.Math.Clamp(speed, 0.05, 8.0);
|
||||||
GD.Print($"[renderer] retained backend={(_useGpuBackend ? "gpu" : "software")}");
|
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(scripts.Catalog, trackedAssetStore)
|
||||||
: new ResourceMap(catalog, _assetStore);
|
: new ResourceMap(catalog, _assetStore);
|
||||||
IGlyphMaskRasterizer? surfaceTextRasterizer = null;
|
IGlyphMaskRasterizer? surfaceTextRasterizer = null;
|
||||||
string surfaceTextFallbackReason;
|
PortableTextRenderingPolicy? portableTextPolicy = null;
|
||||||
|
string? exactUnavailable = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
#if AGE_WINDOWS_GDI
|
#if AGE_WINDOWS_GDI
|
||||||
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
|
if (textBackend != "portable")
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
_exactGlyphRasterizer = new WindowsGdiGlyphMaskRasterizer();
|
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
|
||||||
surfaceTextRasterizer = _exactGlyphRasterizer;
|
{
|
||||||
surfaceTextFallbackReason = "";
|
try
|
||||||
GD.Print(
|
{
|
||||||
$"[text] immediate surface strings use {_exactGlyphRasterizer.BackendInfo.Id}: " +
|
var exact = new WindowsGdiGlyphMaskRasterizer();
|
||||||
_exactGlyphRasterizer.BackendInfo.Detail);
|
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
|
#else
|
||||||
surfaceTextFallbackReason =
|
if (textBackend == "gdi")
|
||||||
"The exact Windows GDI glyph adapter is not part of this platform build.";
|
exactUnavailable =
|
||||||
GD.PushWarning(
|
"The exact Windows GDI glyph adapter is not part of this platform build.";
|
||||||
$"[text] immediate surface strings retain the Label fallback: {surfaceTextFallbackReason}");
|
|
||||||
#endif
|
#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(
|
_host = new GodotAdvHost(
|
||||||
this, resources, scene, _clock, _locator, logicalCanvas, _timeline,
|
this, resources, scene, _clock, _locator, logicalCanvas,
|
||||||
|
surfaceTextRasterizer,
|
||||||
|
timeline: _timeline,
|
||||||
synchronizeExplicitPresentation: !_selftest,
|
synchronizeExplicitPresentation: !_selftest,
|
||||||
surfaceTextRasterizer: surfaceTextRasterizer,
|
surfaceTextMaskCacheCapacity:
|
||||||
surfaceTextFallbackReason: surfaceTextFallbackReason)
|
portableTextPolicy?.GlyphMaskCacheCapacity ?? 2048)
|
||||||
{
|
{
|
||||||
SleepScale = sleepScale,
|
SleepScale = sleepScale,
|
||||||
TraceOps = _gfxLogPath != null,
|
TraceOps = _gfxLogPath != null,
|
||||||
@@ -567,13 +565,6 @@ public partial class Main : Godot.Control
|
|||||||
}
|
}
|
||||||
perf?.RecordRecomposeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
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
|
// --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.
|
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
|
||||||
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
|
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
|
||||||
@@ -1044,10 +1035,8 @@ public partial class Main : Godot.Control
|
|||||||
|
|
||||||
DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose();
|
DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose();
|
||||||
_gpuRenderer?.Dispose();
|
_gpuRenderer?.Dispose();
|
||||||
#if AGE_WINDOWS_GDI
|
_glyphRasterizerOwner?.Dispose();
|
||||||
_exactGlyphRasterizer?.Dispose();
|
_glyphRasterizerOwner = null;
|
||||||
_exactGlyphRasterizer = null;
|
|
||||||
#endif
|
|
||||||
if (_perf != null)
|
if (_perf != null)
|
||||||
{
|
{
|
||||||
_perf.Dispose();
|
_perf.Dispose();
|
||||||
@@ -1087,7 +1076,6 @@ public partial class Main : Godot.Control
|
|||||||
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
|
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.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
|
||||||
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
|
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
|
||||||
private readonly System.Collections.Generic.List<SurfaceTextDraw> _surfaceTextSnapshot = new();
|
|
||||||
|
|
||||||
private void Recomposite()
|
private void Recomposite()
|
||||||
{
|
{
|
||||||
@@ -1134,11 +1122,6 @@ public partial class Main : Godot.Control
|
|||||||
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
|
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
|
||||||
}
|
}
|
||||||
_perf?.BeginRecomposite(screenTransition: false);
|
_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);
|
_gpuRenderer.BeginFrame(publicationPolicy.AppendGpuLayers);
|
||||||
foreach (var v in _visibleSnapshot)
|
foreach (var v in _visibleSnapshot)
|
||||||
{
|
{
|
||||||
@@ -1197,24 +1180,6 @@ public partial class Main : Godot.Control
|
|||||||
resolved.IsDynamic, v.Blend);
|
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();
|
var stats = _gpuRenderer.EndFrame();
|
||||||
@@ -1327,25 +1292,22 @@ public partial class Main : Godot.Control
|
|||||||
{
|
{
|
||||||
System.Array.Clear(_screenPixels);
|
System.Array.Clear(_screenPixels);
|
||||||
}
|
}
|
||||||
foreach (var label in _surfaceTextLabels) label.Visible = false;
|
|
||||||
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
|
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
|
||||||
int surfaceTextLabelIndex = 0;
|
|
||||||
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
|
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
|
||||||
if (hasScreenTransition)
|
if (hasScreenTransition)
|
||||||
{
|
{
|
||||||
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
|
// 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.
|
// 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);
|
FillQuad(0, 0, _screenWidth, _screenHeight, 0, (float)transition.Progress);
|
||||||
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
|
CompositeVisibleObjects(transition.Target, (float)transition.Progress, decisions);
|
||||||
ref surfaceTextLabelIndex, decisions, false);
|
|
||||||
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true);
|
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, decisions);
|
||||||
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
}
|
}
|
||||||
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
@@ -1360,10 +1322,10 @@ public partial class Main : Godot.Control
|
|||||||
_perf?.EndRecomposite();
|
_perf?.EndRecomposite();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
|
private void CompositeVisibleObjects(
|
||||||
ref int surfaceTextLabelIndex,
|
IReadOnlyList<RenderObject> visible,
|
||||||
System.Collections.Generic.Dictionary<long, string>? decisions,
|
float globalOpacity,
|
||||||
bool includeSurfaceText)
|
System.Collections.Generic.Dictionary<long, string>? decisions)
|
||||||
{
|
{
|
||||||
int z = 0;
|
int z = 0;
|
||||||
foreach (var v in visible) // interpolate at the retained-presentation clock
|
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 (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++;
|
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)
|
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
|
||||||
=> state is { } c
|
=> state is { } c
|
||||||
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
|
? $" 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()
|
public void PageBreak()
|
||||||
{
|
{
|
||||||
_pageCount++;
|
_pageCount++;
|
||||||
@@ -2298,7 +2022,6 @@ public partial class Main : Godot.Control
|
|||||||
}
|
}
|
||||||
public void ClearPage()
|
public void ClearPage()
|
||||||
{
|
{
|
||||||
foreach (var label in _advTextLabels) label.Text = "";
|
|
||||||
_status.Text = "";
|
_status.Text = "";
|
||||||
}
|
}
|
||||||
public void ShowEnd() => _status.Text = "— end —";
|
public void ShowEnd() => _status.Text = "— end —";
|
||||||
@@ -2372,71 +2095,29 @@ public partial class Main : Godot.Control
|
|||||||
&& !_bgm.Playing
|
&& !_bgm.Playing
|
||||||
&& _bgmFadeTween == null
|
&& _bgmFadeTween == null
|
||||||
&& System.Math.Abs(_bgm.VolumeDb) < 0.001f;
|
&& System.Math.Abs(_bgm.VolumeDb) < 0.001f;
|
||||||
AdvTextEffectTheme mode1 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
|
IReadOnlyList<(int X, int Y)> outline =
|
||||||
{
|
AgeGlyphMaskCompositor.GetMode3OutlineOffsets(1, 1);
|
||||||
RenderMode = 1,
|
bool textEffectModesOk =
|
||||||
EffectOffsetX = 0,
|
outline.Count == 12
|
||||||
EffectOffsetY = 0,
|
&& outline.Contains((1, 0))
|
||||||
});
|
&& outline.Contains((-1, 0))
|
||||||
AdvTextEffectTheme mode3 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
|
&& outline.Contains((0, 1))
|
||||||
{
|
&& outline.Contains((0, -1));
|
||||||
RenderMode = 3,
|
bool fontCalibrationOk =
|
||||||
EffectOffsetX = 1,
|
ImmediateSurfaceTextRenderer.NativePixelHeight(24) == 24
|
||||||
EffectOffsetY = 1,
|
&& ImmediateSurfaceTextRenderer.NativePixelHeight(25) == 24
|
||||||
});
|
&& ImmediateSurfaceTextRenderer.NativePixelHeight(32) == 31
|
||||||
AdvTextEffectTheme mode0 = ResolveAdvTextEffectTheme(AdvTextStyle.Default);
|
&& ImmediateSurfaceTextRenderer.NativePixelHeight(33) == 31;
|
||||||
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));
|
|
||||||
bool immediateSurfaceTextOk;
|
bool immediateSurfaceTextOk;
|
||||||
string immediateSurfaceTextMode;
|
string immediateSurfaceTextMode;
|
||||||
_host.CreateTexture(997, 32, 24);
|
_host.CreateTexture(997, 64, 24);
|
||||||
var immediateStyle = AdvTextStyle.Default with
|
var immediateStyle = AdvTextStyle.Default with
|
||||||
{
|
{
|
||||||
PrimaryFontSize = 16,
|
PrimaryFontSize = 16,
|
||||||
TextColor = 0xffffff,
|
TextColor = 0xffffff,
|
||||||
FontFace = ImmediateSurfaceTextRenderer.DefaultFontFace,
|
FontFace = ImmediateSurfaceTextRenderer.DefaultFontFace,
|
||||||
};
|
};
|
||||||
_host.DrawStringToSurface(997, 1, 1, "A", immediateStyle);
|
_host.DrawStringToSurface(997, 1, 1, "A姫", immediateStyle);
|
||||||
if (_host.UsesSurfaceTextPixels)
|
if (_host.UsesSurfaceTextPixels)
|
||||||
{
|
{
|
||||||
RgbaImage? pixels = _host.CaptureSurfacePixels(997);
|
RgbaImage? pixels = _host.CaptureSurfacePixels(997);
|
||||||
@@ -2459,40 +2140,44 @@ public partial class Main : Godot.Control
|
|||||||
gpuAccepted &= stats.DrawItems == 1 && stats.TextureUploads == 1;
|
gpuAccepted &= stats.DrawItems == 1 && stats.TextureUploads == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
_host.CreateTexture(996, 32, 24);
|
_host.CreateTexture(996, 64, 24);
|
||||||
_host.CopySurfaceRect(new SurfaceRectCopy(
|
_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);
|
RgbaImage? copied = _host.CaptureSurfacePixels(996);
|
||||||
bool copiedPixels = pixels != null
|
bool copiedPixels = pixels != null
|
||||||
&& copied != null
|
&& copied != null
|
||||||
&& pixels.Pixels.SequenceEqual(copied.Pixels);
|
&& pixels.Pixels.SequenceEqual(copied.Pixels);
|
||||||
_host.FillSurfaceRect(new SurfaceRectFill(
|
_host.FillSurfaceRect(new SurfaceRectFill(
|
||||||
996, 0, 0, 32, 24, 0, 0));
|
996, 0, 0, 64, 24, 0, 0));
|
||||||
RgbaImage? cleared = _host.CaptureSurfacePixels(996);
|
RgbaImage? cleared = _host.CaptureSurfacePixels(996);
|
||||||
bool clearedPixels = cleared != null && cleared.Pixels.All(value => value == 0);
|
bool clearedPixels = cleared != null && cleared.Pixels.All(value => value == 0);
|
||||||
immediateSurfaceTextOk =
|
immediateSurfaceTextOk =
|
||||||
pixelsPresent
|
pixelsPresent
|
||||||
&& _host.SnapshotSurfaceText(997).Count == 0
|
&& backend is not null
|
||||||
&& backend is
|
&& (backend.Policy == GlyphRasterPolicy.NativeCp932Gray4
|
||||||
{
|
? backend is
|
||||||
Id: "windows-gdi-gray4",
|
{
|
||||||
Policy: GlyphRasterPolicy.NativeCp932Gray4,
|
Id: "windows-gdi-gray4",
|
||||||
NativePixelExact: true,
|
NativePixelExact: true,
|
||||||
}
|
}
|
||||||
|
: backend is
|
||||||
|
{
|
||||||
|
Id: "portable-godot-textserver",
|
||||||
|
Policy: GlyphRasterPolicy.PortableUnicode,
|
||||||
|
NativePixelExact: false,
|
||||||
|
})
|
||||||
&& cache.Count is > 0 and <= 2048
|
&& cache.Count is > 0 and <= 2048
|
||||||
&& cache.Capacity == 2048
|
&& cache.Capacity == 2048
|
||||||
&& gpuAccepted
|
&& gpuAccepted
|
||||||
&& copiedPixels
|
&& copiedPixels
|
||||||
&& clearedPixels;
|
&& clearedPixels;
|
||||||
immediateSurfaceTextMode = "exact-rgba";
|
immediateSurfaceTextMode =
|
||||||
|
$"rgba:{backend?.Id}";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
immediateSurfaceTextOk =
|
immediateSurfaceTextOk = false;
|
||||||
_host.CaptureSurfacePixels(997)?.Pixels.All(value => value == 0) == true
|
immediateSurfaceTextMode = "unavailable";
|
||||||
&& _host.SnapshotSurfaceText(997).Count == 1
|
|
||||||
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
|
|
||||||
immediateSurfaceTextMode = "label-fallback";
|
|
||||||
}
|
}
|
||||||
_host.ReleaseSurface(996);
|
_host.ReleaseSurface(996);
|
||||||
_host.ReleaseSurface(997);
|
_host.ReleaseSurface(997);
|
||||||
@@ -2519,7 +2204,6 @@ public partial class Main : Godot.Control
|
|||||||
&& liveSurface != null
|
&& liveSurface != null
|
||||||
&& liveSurface.Pixels.Where((_, index) => index % 4 == 3)
|
&& liveSurface.Pixels.Where((_, index) => index % 4 == 3)
|
||||||
.Any(alpha => alpha != 0)
|
.Any(alpha => alpha != 0)
|
||||||
&& _host.SnapshotLiveAdvText().Count == 0
|
|
||||||
&& _vm.TextHistory.GetLayoutSnapshot(1).CursorX
|
&& _vm.TextHistory.GetLayoutSnapshot(1).CursorX
|
||||||
> liveBinding.ResetCursorX;
|
> liveBinding.ResetCursorX;
|
||||||
|
|
||||||
@@ -2556,14 +2240,15 @@ public partial class Main : Godot.Control
|
|||||||
&& suspended
|
&& suspended
|
||||||
&& restored
|
&& restored
|
||||||
&& reset;
|
&& reset;
|
||||||
liveRetainedTextMode = "retained-glyphs";
|
liveRetainedTextMode =
|
||||||
|
$"retained-glyphs;built={builtCompleteLine};objects={liveObjects.Count};" +
|
||||||
|
$"republished={republishedPartialErase};suspended={suspended};" +
|
||||||
|
$"restored={restored};reset={reset}";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
liveRetainedTextOk =
|
liveRetainedTextOk = false;
|
||||||
_host.SnapshotLiveAdvText().Count == 3
|
liveRetainedTextMode = "unavailable";
|
||||||
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
|
|
||||||
liveRetainedTextMode = "label-fallback";
|
|
||||||
}
|
}
|
||||||
_vm.TextHistory.DefineLayout(2, 256, 64, 10, 100);
|
_vm.TextHistory.DefineLayout(2, 256, 64, 10, 100);
|
||||||
_vm.TextHistory.SetResetCursor(2, 1, 1);
|
_vm.TextHistory.SetResetCursor(2, 1, 1);
|
||||||
@@ -2603,8 +2288,7 @@ public partial class Main : Godot.Control
|
|||||||
== historyBatch.Text.Length
|
== historyBatch.Text.Length
|
||||||
&& historySurface != null
|
&& historySurface != null
|
||||||
&& historySurface.Pixels.Where((_, index) => index % 4 == 3)
|
&& historySurface.Pixels.Where((_, index) => index % 4 == 3)
|
||||||
.Any(alpha => alpha != 0)
|
.Any(alpha => alpha != 0);
|
||||||
&& _host.SnapshotRenderedTextHistory().Count == 0;
|
|
||||||
_vm.Gfx.EraseRange(
|
_vm.Gfx.EraseRange(
|
||||||
historyBinding.FirstObjectHandle,
|
historyBinding.FirstObjectHandle,
|
||||||
historyBinding.ObjectCapacity);
|
historyBinding.ObjectCapacity);
|
||||||
@@ -2618,15 +2302,14 @@ public partial class Main : Godot.Control
|
|||||||
< historyBinding.ObjectCapacity)
|
< historyBinding.ObjectCapacity)
|
||||||
&& _host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot)
|
&& _host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot)
|
||||||
?.Pixels.All(value => value == 0) == true;
|
?.Pixels.All(value => value == 0) == true;
|
||||||
historyRetainedTextMode = "retained-glyphs";
|
historyRetainedTextMode =
|
||||||
|
$"retained-glyphs;used={historyUsedRetained}";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
historyRetainedTextOk =
|
historyRetainedTextOk = false;
|
||||||
!historyUsedRetained
|
|
||||||
&& _host.SnapshotRenderedTextHistory().Count == 1;
|
|
||||||
_host.EndTextHistoryPresentation(_vm.Gfx);
|
_host.EndTextHistoryPresentation(_vm.Gfx);
|
||||||
historyRetainedTextMode = "label-fallback";
|
historyRetainedTextMode = "unavailable";
|
||||||
}
|
}
|
||||||
Window rootWindow = GetTree().Root;
|
Window rootWindow = GetTree().Root;
|
||||||
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
|
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
|
||||||
@@ -2661,7 +2344,6 @@ public partial class Main : Godot.Control
|
|||||||
_vm.Gfx, _clock.NowMs, backbufferSnapshot);
|
_vm.Gfx, _clock.NowMs, backbufferSnapshot);
|
||||||
backbufferPreservationOk &=
|
backbufferPreservationOk &=
|
||||||
clearedPolicy == new BackbufferPublicationPolicy(false, false);
|
clearedPolicy == new BackbufferPublicationPolicy(false, false);
|
||||||
textEffectSmoke.QueueFree();
|
|
||||||
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
|
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
|
||||||
&& firstRiffBoundaryOk
|
&& firstRiffBoundaryOk
|
||||||
&& bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk
|
&& 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