engine: retain History and wait presentation

This commit is contained in:
gamer147
2026-07-30 19:21:38 -04:00
parent e5b4db5eff
commit bb227ea106
14 changed files with 585 additions and 95 deletions

View File

@@ -105,8 +105,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ ALF asset store, script provider, AGF/LZSS and Windows CUR decoders, │ ALF asset store, script provider, AGF/LZSS and Windows CUR decoders,
│ and resource facade │ and resource facade
│ ├── Age.Engine/Text/ backend-neutral glyph-mask requests/results, deterministic AGE │ ├── Age.Engine/Text/ backend-neutral glyph-mask requests/results, deterministic AGE
│ compositor/layout, retained live-glyph publication, and bounded │ compositor/layout, retained live/History glyph and wait-atlas
│ font/glyph cache primitives publication, and bounded font/glyph cache primitives
│ ├── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB, │ ├── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB,
│ layout-3 numbered state/history/gfx, DAT/STH pair + BMP codec, │ layout-3 numbered state/history/gfx, DAT/STH pair + BMP codec,
│ and profile-owned state │ and profile-owned state

View File

@@ -2546,8 +2546,13 @@ Op `0x1d1` asks `AdvTextHistory` to select one retained group, skip metadata/voi
host-facing render batch using the target layout configured by `0x70/0x71`, positioned by `0x198`, and host-facing render batch using the target layout configured by `0x70/0x71`, positioned by `0x198`, and
seeded by `0x7a`. `HISTORY.BIN` passes zero for flags and both color overrides, so this is its exact ordinary seeded by `0x7a`. `HISTORY.BIN` passes zero for flags and both color overrides, so this is its exact ordinary
binding path; the native flag-4 raster-without-binding and flag-8 side-effect modes remain outside the live binding path; the native flag-4 raster-without-binding and flag-8 side-effect modes remain outside the live
game call site. Godot owns only replaceable labels keyed by target layout, applies the retained font/color/ game call site. The initial port used replaceable labels keyed by target layout. The retained-glyph migration
effect snapshot, and clears stale labels whenever `0x71` resets that layout. now passes the target's transient surface/handle binding with the batch, rasterizes its text through the
shared glyph-mask service, and publishes one ordinary object per edge record. Exact rows therefore inherit
native z-order, alpha/tint/fade, clipping, transforms, capture, and range erasure. `0x71` resets the row's
surface and records; HISTORY's broad exit erase plus recording re-enable remove all transient row bindings
without touching the persisted backlog. The layout-keyed Label remains only as the explicit
unavailable-rasterizer fallback.
The same slice implements the support calls at their natural seams. Op `0x131` reads a host configuration The same slice implements the support calls at their natural seams. Op `0x131` reads a host configuration
property (currently defaulting to zero; choosing a profile/config persistence backend remains deferred), property (currently defaulting to zero; choosing a profile/config persistence backend remains deferred),
@@ -2982,8 +2987,7 @@ and returns the five-dword edge records plus final cursor, observed overflow, wr
state. It preserves native horizontal precedence: vertical-only overflow stops before compositing, while a state. It preserves native horizontal precedence: vertical-only overflow stops before compositing, while a
simultaneous horizontal overflow follows the wrap/kinsoku path. A reusable bounded LRU provides eviction simultaneous horizontal overflow follows the wrap/kinsoku path. A reusable bounded LRU provides eviction
callbacks for future native font handles; `CachedGlyphMaskRasterizer` applies the same bound to masks. This callbacks for future native font handles; `CachedGlyphMaskRasterizer` applies the same bound to masks. This
core now drives immediate surface strings and live ADV; retained History remains on its Label pending the core now drives immediate surface strings, live ADV, and retained History.
next migration step.
The independent Windows reference backend landed alongside, but outside, the neutral engine as The independent Windows reference backend landed alongside, but outside, the neutral engine as
`Age.Engine.Text.Windows.WindowsGdiGlyphMaskRasterizer`. It holds one `CreateICA("DISPLAY")` information `Age.Engine.Text.Windows.WindowsGdiGlyphMaskRasterizer`. It holds one `CreateICA("DISPLAY")` information
@@ -3014,7 +3018,8 @@ offscreen capture, fill/copy, and preservation behavior apply without a detached
If the exact backend is unavailable, string conversion fails, or the target surface cannot be resolved, If the exact backend is unavailable, string conversion fails, or the target surface cannot be resolved,
the host records one explicit diagnostic and retains the old metadata/Label projection for that draw. The the host records one explicit diagnostic and retains the old metadata/Label projection for that draw. The
fallback is constructed before surface publication, so it cannot mix a partial new raster with the Label. fallback is constructed before surface publication, so it cannot mix a partial new raster with the Label.
History and the wait indicator remain on their existing presentation paths for the later staged migration. This fallback now covers immediate, live, and History text; the non-glyph wait indicator is always an
ordinary retained atlas object when its script binding is valid.
Live ADV now uses the same exact surface pixels through ordinary retained objects. Live ADV now uses the same exact surface pixels through ordinary retained objects.
`AdvTextLayoutPresentationBinding` is transient engine state: it carries the layout surface (`slot+0x14`), `AdvTextLayoutPresentationBinding` is transient engine state: it carries the layout surface (`slot+0x14`),
@@ -3059,13 +3064,20 @@ the following click releases the wait.
**Port implementation:** the single-scene harness now injects SYSTEM4's exact SO000 surface-12 and op-`0x73` **Port implementation:** the single-scene harness now injects SYSTEM4's exact SO000 surface-12 and op-`0x73`
configuration alongside its existing SO001 shortcut. The VM forwards all ten operands through the host ABI; configuration alongside its existing SO001 shortcut. The VM forwards all ten operands through the host ABI;
`GodotAdvHost` retains configurations by layout and resolves slot 0 to the active Phase-A layout. A separate `GodotAdvHost` retains configurations by layout and pairs them with op `0x212`'s script-owned handle and
Godot atlas overlay advances on the 48 ms host clock only while `IsWaiting`, then hides immediately when layout origin. `RetainedAdvWaitIndicatorPresentation` binds the current atlas cell as an ordinary
input releases the wait. A 2026-07-27 polish fix removed the port's erroneous `TerminalFrame + 1` `GfxState` object, mutating it only when the 48 ms frame boundary changes. Op `0x20a` recreates an erased
current frame; HIDEWIN suspension and wait-service shutdown erase the handle. The former Godot
`TextureRect` overlay and raw-callback visibility exception are removed, so normal handle order, source
color key, capture, and surface/scene composition apply. A real Vulkan SC0000 page-one capture places the
retained bat at `(385,570)`.
A 2026-07-27 polish fix removed the port's erroneous `TerminalFrame + 1`
interpretation, which had selected nonexistent frame `12` once per cycle and made Godot render one empty interpretation, which had selected nonexistent frame `12` once per cycle and made Godot render one empty
atlas cell. Frame selection now treats the terminal value as the exclusive frame count and loops `0..11`. atlas cell. Frame selection now treats the terminal value as the exclusive frame count and loops `0..11`.
Keeping this 30x27 overlay outside the software backbuffer preserves the static-wait compositor optimization. The retained presenter requests a new frame only when that source cell changes, preserving the static-wait
Full SYSTEM4 replay remains Phase-B work; the shortcut carries the same state in the meantime. compositor optimization. Full SYSTEM4 replay remains Phase-B work; the shortcut carries the same state in
the meantime.
### SC0000 native SFX / BGM-fade family — `0xb4`/`0xb5`/`0xb6`/`0xba`/`0xc2`/`0xd9` (2026-07-11) ### SC0000 native SFX / BGM-fade family — `0xb4`/`0xb5`/`0xb6`/`0xba`/`0xc2`/`0xd9` (2026-07-11)

View File

@@ -33,7 +33,7 @@ or replaced before claiming portable exports.
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging | | AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates | | Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug | | Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
| ADV font discovery/raster fidelity | `Age.Engine.Text` remains OS-neutral. Godot selects the separate `Age.Engine.Text.Windows` backend for immediate `0x204`/`0x205` strings and live ADV when Windows ACP 932 is available; exact masks are cached, composited into layout/numbered RGBA surfaces, and live glyphs become ordinary retained handles. History still uses the existing `Label` path. The fallback loads Windows ` 明朝`/` ゴシック` 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 and live text now obey retained ordering, alpha/tint/fade, transforms, clipping, capture, HIDEWIN suspension, partial erase/republication, and reset. Non-Windows/non-932 runs report why exact 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 migrate History/wait-indicator presentation and remove the remaining Label 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. 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 ` 明朝`/` ゴシック` 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 |
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules | | Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
| Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target | | Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target |
| Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS | | Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS |

View File

@@ -628,11 +628,17 @@ primitive, which cannot be made equivalent by choosing another embolden constant
reconstruction. The synthesized Godot VM self-test exercises timed exact reveal and reports reconstruction. The synthesized Godot VM self-test exercises timed exact reveal and reports
`live-adv-text=retained-glyphs`; real GPU captures of SC0000 pages 1 and 7 confirm ordinary and voiced `live-adv-text=retained-glyphs`; real GPU captures of SC0000 pages 1 and 7 confirm ordinary and voiced
dialogue placement/wrapping alongside the immediate speaker-name surface. dialogue placement/wrapping alongside the immediate speaker-name surface.
6. **Migrate History and the ADV wait indicator through the same retained path.** Op `0x1d1` rasterizes and 6. **Migrate History and the ADV wait indicator through the same retained path. — Completed
binds into its target layout instead of creating a layout-keyed Label. Bind the configured wait atlas at 2026-07-30.** Op `0x1d1` now rasterizes each HISTORY row into its target layout surface and publishes its
the op-`0x212` handle so it follows native z-order and source-cell animation. Once SC0000, HISTORY, native glyph interval through ordinary `GfxState` bindings. Row reset, wheel redraw, the broad native exit
STUDY/MAMES, HIDEWIN, and BUNKI pass, delete the game-text Label pools and the modal/raw-callback visibility erase, recording re-enable, and surface release all remove the transient presentation without changing
exceptions whose only purpose was compensating for detached overlays. the persisted backlog. The configured wait atlas now binds at op `0x212`'s handle, advances source cells
only on the configured frame boundary, participates in op-`0x20a` republication and HIDEWIN suspension,
and disappears when the wait service stops. The standalone Godot wait `TextureRect` and its raw-callback
visibility exception are gone. Exact History no longer enters the layout-keyed Label pool; that code and
the live/immediate Label pools remain solely as the explicit unavailable-rasterizer fallback until step 7
supplies the portable glyph backend. Focused lifecycle tests, all 579 engine tests, the exact History
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.** Spike the smallest maintainable way to obtain portable
font masks/metrics (Godot/TextServer offscreen extraction versus a bounded FreeType-compatible adapter) font masks/metrics (Godot/TextServer offscreen extraction versus a bounded FreeType-compatible adapter)
before choosing a dependency. Define face substitution and raster policy as profile/configuration data, before choosing a dependency. Define face substitution and raster policy as profile/configuration data,

View File

@@ -388,6 +388,34 @@ public class AdvTextOpsTests
Assert.Single(host.WaitIndicators)); Assert.Single(host.WaitIndicators));
} }
[Fact]
public void WaitIndicatorHandlePublishesItsResolvedLayoutBindingToHost()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
static Operand I(long value) => new(0, value);
var script = ScriptAssembler.Assemble(table, "WAITMARK_BINDING",
new List<(int, Operand[])>
{
(0x70, new[] { I(1), I(800), I(160), I(0), I(430) }),
(0x79, new[] { I(1), I(100), I(47) }),
(0x213, new[] { I(1), I(0xd6d8), I(500) }),
(0x212, new[] { I(1), I(0xd674) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost();
new VirtualMachine(script, table, host).Run();
var retained = Assert.Single(host.WaitIndicatorBindings);
Assert.Equal(
new AdvTextLayoutPresentationBinding(
1, 21, 0xd6d8, 500, 0xd674, 100, 47),
retained.Binding);
Assert.Equal(
new AdvTextLayoutSnapshot(1, 800, 160, 0, 430, 0, 0, 800, 160),
retained.Layout);
}
[Fact] [Fact]
public void WaitIndicatorTerminalFrameIsExclusive() public void WaitIndicatorTerminalFrameIsExclusive()
{ {

View File

@@ -45,6 +45,10 @@ public class HistoryPresentationOpsTests
Assert.Equal((24, 0xffffffL, 0x606060L, 8, " 明朝"), Assert.Equal((24, 0xffffffL, 0x606060L, 8, " 明朝"),
(render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor, (render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor,
render.Style.LineSpacing, render.Style.FontFace)); render.Style.LineSpacing, render.Style.FontFace));
var retained = Assert.Single(host.RetainedHistoryRenders);
Assert.Equal(4, retained.Binding.LayoutSlot);
Assert.Equal(0x18, retained.Binding.SourceSurfaceSlot);
Assert.Same(render, retained.Batch);
} }
[Fact] [Fact]

View File

@@ -0,0 +1,94 @@
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Text;
public class RetainedAdvWaitIndicatorPresentationTests
{
private static readonly AdvWaitIndicatorConfig Config = new(
LayoutSlot: 1,
X: 385,
Y: 140,
SurfaceSlot: 12,
SourceX: 0,
SourceY: 0,
CellWidth: 30,
CellHeight: 27,
TerminalFrame: 12,
FramePeriodMs: 48);
private static readonly AdvTextLayoutPresentationBinding Binding = new(
LayoutSlot: 1,
SourceSurfaceSlot: 21,
FirstObjectHandle: 0xd6d8,
ObjectCapacity: 500,
WaitIndicatorObjectHandle: 0xd674,
ResetCursorX: 100,
ResetCursorY: 47);
[Fact]
public void PublishesAtlasCellAtLayoutRelativePosition()
{
var presentation = new RetainedAdvWaitIndicatorPresentation(
Config, Binding, layoutOriginX: 0, layoutOriginY: 430);
var gfx = new GfxState();
gfx.SetSurface(12, 0x337c, 0xff00);
Assert.True(presentation.Update(gfx, elapsedMs: 96, visible: true));
RenderObject marker = Assert.Single(gfx.SnapshotVisibleObjects());
Assert.Equal(
(0xd674L, 12, 60, 0, 30, 27, 385, 570),
(marker.Handle, gfx.TryGet(marker.Handle)!.SourceSlot, marker.SrcX, marker.SrcY,
marker.W, marker.H, marker.DstX, marker.DstY));
Assert.Equal(2, presentation.PublishedFrame);
}
[Fact]
public void MutatesOnlyWhenFrameChangesAndErasesWhenHidden()
{
var presentation = new RetainedAdvWaitIndicatorPresentation(
Config, Binding, 0, 430);
var gfx = new GfxState();
Assert.True(presentation.Update(gfx, 0, visible: true));
Assert.False(presentation.Update(gfx, 47, visible: true));
Assert.True(presentation.Update(gfx, 48, visible: true));
Assert.Equal(30, Assert.Single(gfx.SnapshotVisibleObjects()).SrcX);
Assert.True(presentation.Update(gfx, 48, visible: false));
Assert.Empty(gfx.SnapshotVisibleObjects());
Assert.False(presentation.Update(gfx, 48, visible: false));
}
[Fact]
public void RepublishRestoresAHandleErasedByScript()
{
var presentation = new RetainedAdvWaitIndicatorPresentation(
Config, Binding, 0, 430);
var gfx = new GfxState();
presentation.Update(gfx, 144, visible: true);
gfx.EraseRange(Binding.WaitIndicatorObjectHandle, 1);
Assert.True(presentation.Republish(gfx, 144, visible: true));
RenderObject marker = Assert.Single(gfx.SnapshotVisibleObjects());
Assert.Equal((0xd674L, 90), (marker.Handle, marker.SrcX));
}
[Fact]
public void RejectsMissingHandleAndMismatchedLayout()
{
Assert.Throws<ArgumentException>(() =>
new RetainedAdvWaitIndicatorPresentation(
Config,
Binding with { WaitIndicatorObjectHandle = -1 },
0,
430));
Assert.Throws<ArgumentException>(() =>
new RetainedAdvWaitIndicatorPresentation(
Config with { LayoutSlot = 2 },
Binding,
0,
430));
}
}

View File

@@ -21,6 +21,8 @@ internal class RecordingHost : IHost
public readonly List<(int Slot, int X, int Y)> TextCursors = new(); public readonly List<(int Slot, int X, int Y)> TextCursors = new();
public readonly List<(int Surface, int X, int Y, string Text)> SurfaceStrings = new(); public readonly List<(int Surface, int X, int Y, string Text)> SurfaceStrings = new();
public readonly List<AdvTextHistoryRenderBatch> HistoryRenders = new(); public readonly List<AdvTextHistoryRenderBatch> HistoryRenders = new();
public readonly List<(AdvTextLayoutPresentationBinding Binding, AdvTextHistoryRenderBatch Batch)>
RetainedHistoryRenders = new();
public readonly Dictionary<int, AdvTextHistoryRenderBatch> ActiveHistoryRenders = new(); public readonly Dictionary<int, AdvTextHistoryRenderBatch> ActiveHistoryRenders = new();
public int HistoryPresentationEnds; public int HistoryPresentationEnds;
public readonly List<int> ClearedTextLayouts = new(); public readonly List<int> ClearedTextLayouts = new();
@@ -30,6 +32,8 @@ internal class RecordingHost : IHost
public readonly List<(int Slot, int SourceX, int SourceY, int Width, int Height, int X, int Y)> public readonly List<(int Slot, int SourceX, int SourceY, int Width, int Height, int X, int Y)>
TextureDraws = new(); TextureDraws = new();
public readonly List<AdvWaitIndicatorConfig> WaitIndicators = new(); public readonly List<AdvWaitIndicatorConfig> WaitIndicators = new();
public readonly List<(AdvTextLayoutPresentationBinding Binding, AdvTextLayoutSnapshot Layout)>
WaitIndicatorBindings = new();
public readonly List<bool> WaitIndicatorEnabledChanges = new(); public readonly List<bool> WaitIndicatorEnabledChanges = new();
public readonly List<int> PublishedAdvTextLayouts = new(); public readonly List<int> PublishedAdvTextLayouts = new();
public readonly List<long> SleptDurations = new(); public readonly List<long> SleptDurations = new();
@@ -129,11 +133,21 @@ internal class RecordingHost : IHost
HistoryRenders.Add(batch); HistoryRenders.Add(batch);
ActiveHistoryRenders[batch.LayoutSlot] = batch; ActiveHistoryRenders[batch.LayoutSlot] = batch;
} }
public bool RenderTextHistory(
GfxState gfx,
AdvTextLayoutPresentationBinding binding,
AdvTextHistoryRenderBatch batch)
{
RetainedHistoryRenders.Add((binding, batch));
RenderTextHistory(batch);
return false;
}
public void EndTextHistoryPresentation() public void EndTextHistoryPresentation()
{ {
HistoryPresentationEnds++; HistoryPresentationEnds++;
ActiveHistoryRenders.Clear(); ActiveHistoryRenders.Clear();
} }
public void EndTextHistoryPresentation(GfxState gfx) => EndTextHistoryPresentation();
public int MessageWindowAlphaSetting { get; set; } public int MessageWindowAlphaSetting { get; set; }
public void SetMessageWindowAlphaSetting(int value) => MessageWindowAlphaSetting = value; public void SetMessageWindowAlphaSetting(int value) => MessageWindowAlphaSetting = value;
public void FillSurfaceRect(SurfaceRectFill fill) => SurfaceFills.Add(fill); public void FillSurfaceRect(SurfaceRectFill fill) => SurfaceFills.Add(fill);
@@ -141,6 +155,10 @@ internal class RecordingHost : IHost
public void PresentObjectRange(GfxState gfx, long firstHandle, long count) public void PresentObjectRange(GfxState gfx, long firstHandle, long count)
=> PresentedRanges.Add((firstHandle, count)); => PresentedRanges.Add((firstHandle, count));
public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) => WaitIndicators.Add(config); public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) => WaitIndicators.Add(config);
public void BindAdvWaitIndicator(
AdvTextLayoutPresentationBinding binding,
AdvTextLayoutSnapshot layout)
=> WaitIndicatorBindings.Add((binding, layout));
public void SetAdvWaitIndicatorEnabled(bool enabled) => WaitIndicatorEnabledChanges.Add(enabled); public void SetAdvWaitIndicatorEnabled(bool enabled) => WaitIndicatorEnabledChanges.Add(enabled);
public void PublishAdvTextLayout(int layoutSlot) => PublishedAdvTextLayouts.Add(layoutSlot); public void PublishAdvTextLayout(int layoutSlot) => PublishedAdvTextLayouts.Add(layoutSlot);
public void SetAdvPagePresentationSuspended(bool suspended) public void SetAdvPagePresentationSuspended(bool suspended)

View File

@@ -86,15 +86,28 @@ public interface IHost
GfxState gfx, AdvTextLayoutPresentationBinding binding) GfxState gfx, AdvTextLayoutPresentationBinding binding)
=> ClearRenderedAdvTextLayout(binding.LayoutSlot); => ClearRenderedAdvTextLayout(binding.LayoutSlot);
void RenderTextHistory(AdvTextHistoryRenderBatch batch) { } void RenderTextHistory(AdvTextHistoryRenderBatch batch) { }
bool RenderTextHistory(
GfxState gfx,
AdvTextLayoutPresentationBinding binding,
AdvTextHistoryRenderBatch batch)
{
RenderTextHistory(batch);
return false;
}
// History render batches are transient bindings, unlike the retained backlog itself. HISTORY.BIN's // History render batches are transient bindings, unlike the retained backlog itself. HISTORY.BIN's
// recording re-enable at exit ends that presentation and drops every bound target layout. // recording re-enable at exit ends that presentation and drops every bound target layout.
void EndTextHistoryPresentation() { } void EndTextHistoryPresentation() { }
void EndTextHistoryPresentation(GfxState gfx) => EndTextHistoryPresentation();
int MessageWindowAlphaSetting => 0; int MessageWindowAlphaSetting => 0;
void SetMessageWindowAlphaSetting(int value) { } void SetMessageWindowAlphaSetting(int value) { }
void FillSurfaceRect(SurfaceRectFill fill) { } void FillSurfaceRect(SurfaceRectFill fill) { }
void CopySurfaceRect(SurfaceRectCopy copy) { } void CopySurfaceRect(SurfaceRectCopy copy) { }
void PresentObjectRange(GfxState gfx, long firstHandle, long count) { } void PresentObjectRange(GfxState gfx, long firstHandle, long count) { }
void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { } void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { }
// Op 0x212 supplies the ordinary retained handle used to publish the configured atlas cell.
void BindAdvWaitIndicator(
AdvTextLayoutPresentationBinding binding,
AdvTextLayoutSnapshot layout) { }
// Op 0x1ce explicitly starts/stops the same animated marker that op 0x72 starts for an ADV wait. // Op 0x1ce explicitly starts/stops the same animated marker that op 0x72 starts for an ADV wait.
void SetAdvWaitIndicatorEnabled(bool enabled) { } void SetAdvWaitIndicatorEnabled(bool enabled) { }
// Op 0x20a republishes one retained ADV text layout and includes the current marker frame when active. // Op 0x20a republishes one retained ADV text layout and includes the current marker frame when active.

View File

@@ -207,8 +207,8 @@ public sealed class AdvTextHistory
/// <summary> /// <summary>
/// Resolve layouts whose complete native retained-glyph handle interval is covered by one op-0x1f7 /// Resolve layouts whose complete native retained-glyph handle interval is covered by one op-0x1f7
/// erase. The Label backend collapses those glyph objects into live text runs, so full native range /// erase. Hosts use this notification to discard fallback metadata or preserve exact transient records
/// teardown removes the corresponding detached presentation atomically. /// for a following native op-0x20a republication, according to the active presentation path.
/// </summary> /// </summary>
public IReadOnlyList<int> LayoutsCoveredByTextObjectErase(long firstHandle, long count) public IReadOnlyList<int> LayoutsCoveredByTextObjectErase(long firstHandle, long count)
{ {

View File

@@ -0,0 +1,95 @@
using Age.Engine.Hosting;
using Age.Engine.Model;
namespace Age.Engine.Text;
/// <summary>
/// Transient retained-object projection of one ADV layout's animated input-wait atlas.
/// The script owns the source surface and object handle; this object owns only the current binding.
/// </summary>
public sealed class RetainedAdvWaitIndicatorPresentation
{
private int _publishedFrame = -1;
public RetainedAdvWaitIndicatorPresentation(
AdvWaitIndicatorConfig config,
AdvTextLayoutPresentationBinding binding,
int layoutOriginX,
int layoutOriginY)
{
if (config.LayoutSlot != binding.LayoutSlot)
throw new ArgumentException(
"Wait-indicator configuration and retained binding must select the same layout.");
if (binding.WaitIndicatorObjectHandle < 0)
throw new ArgumentException(
"A retained ADV wait indicator requires a nonnegative object handle.",
nameof(binding));
if (config.SurfaceSlot < 0 || config.CellWidth <= 0 || config.CellHeight <= 0)
throw new ArgumentOutOfRangeException(nameof(config));
Config = config;
Binding = binding;
LayoutOriginX = layoutOriginX;
LayoutOriginY = layoutOriginY;
}
public AdvWaitIndicatorConfig Config { get; }
public AdvTextLayoutPresentationBinding Binding { get; }
public int LayoutOriginX { get; }
public int LayoutOriginY { get; }
public int PublishedFrame => _publishedFrame;
public bool IsPublished => _publishedFrame >= 0;
/// <summary>Publish a changed source cell, or erase the retained handle when inactive.</summary>
public bool Update(GfxState gfx, long elapsedMs, bool visible)
{
ArgumentNullException.ThrowIfNull(gfx);
if (!visible)
{
if (!IsPublished) return false;
Erase(gfx);
return true;
}
int frame = Config.FrameAt(elapsedMs);
if (frame == _publishedFrame) return false;
Bind(gfx, frame);
return true;
}
/// <summary>Recreate the current frame after script-owned range teardown/publication.</summary>
public bool Republish(GfxState gfx, long elapsedMs, bool visible)
{
ArgumentNullException.ThrowIfNull(gfx);
if (!visible)
{
if (!IsPublished) return false;
Erase(gfx);
return true;
}
Bind(gfx, Config.FrameAt(elapsedMs));
return true;
}
public void Erase(GfxState gfx)
{
ArgumentNullException.ThrowIfNull(gfx);
gfx.EraseRange(Binding.WaitIndicatorObjectHandle, 1);
_publishedFrame = -1;
}
private void Bind(GfxState gfx, int frame)
{
gfx.BindDraw(
Binding.WaitIndicatorObjectHandle,
Config.SurfaceSlot,
checked(Config.SourceX + frame * Config.CellWidth),
Config.SourceY,
Config.CellWidth,
Config.CellHeight,
checked(LayoutOriginX + Config.X),
checked(LayoutOriginY + Config.Y));
_publishedFrame = frame;
}
}

View File

@@ -2347,7 +2347,7 @@ public sealed class VirtualMachine
{ {
bool enabled = Read(a[0]) == 1; bool enabled = Read(a[0]) == 1;
TextHistory.SetRecordingEnabled(enabled); TextHistory.SetRecordingEnabled(enabled);
if (enabled) _host.EndTextHistoryPresentation(); if (enabled) _host.EndTextHistoryPresentation(Gfx);
} }
return pc + 1; return pc + 1;
case "append-text-history-metadata": // 0x1d2: (metadata type, value) case "append-text-history-metadata": // 0x1d2: (metadata type, value)
@@ -2370,11 +2370,17 @@ public sealed class VirtualMachine
int flags = (int)Read(a[2]); int flags = (int)Read(a[2]);
if ((flags & 4) == 0 && TextHistory.TryBuildRenderBatch( if ((flags & 4) == 0 && TextHistory.TryBuildRenderBatch(
(int)Read(a[0]), (int)Read(a[1]), flags, Read(a[3]), Read(a[4]), out var batch)) (int)Read(a[0]), (int)Read(a[1]), flags, Read(a[3]), Read(a[4]), out var batch))
_host.RenderTextHistory(batch with {
batch = batch with
{ {
// Native History uses the text manager's current leading, not a retained-record field. // Native History uses the text manager's current leading, not a retained-record field.
Style = batch.Style with { LineSpacing = _advTextStyle.LineSpacing } Style = batch.Style with { LineSpacing = _advTextStyle.LineSpacing }
}); };
_host.RenderTextHistory(
Gfx,
TextHistory.GetPresentationBinding(batch.LayoutSlot),
batch);
}
return pc + 1; return pc + 1;
} }
case "u0041BB90": case "u0041BB90":
@@ -2820,7 +2826,13 @@ public sealed class VirtualMachine
return pc + 1; return pc + 1;
case "set-adv-wait-indicator-handle": // 0x212 (layout)(retained handle) case "set-adv-wait-indicator-handle": // 0x212 (layout)(retained handle)
{ {
TextHistory.SetWaitIndicatorObjectHandle((int)Read(a[0]), Read(a[1])); int requestedSlot = (int)Read(a[0]);
TextHistory.SetWaitIndicatorObjectHandle(requestedSlot, Read(a[1]));
AdvTextLayoutSnapshot layout =
TextHistory.GetLayoutSnapshot(requestedSlot);
_host.BindAdvWaitIndicator(
TextHistory.GetPresentationBinding(layout.Slot),
layout);
return pc + 1; return pc + 1;
} }
case "set-adv-text-object-range": // 0x213 (layout)(first handle)(count) case "set-adv-text-object-range": // 0x213 (layout)(first handle)(count)

View File

@@ -73,6 +73,7 @@ public sealed class GodotAdvHost : IHost
private string _surfaceTextFallbackReason; private string _surfaceTextFallbackReason;
private bool _surfaceTextFallbackWarningReported; private bool _surfaceTextFallbackWarningReported;
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new(); private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
private readonly HashSet<int> _retainedHistoryLayouts = new();
private sealed class LiveTextState private sealed class LiveTextState
{ {
public required AdvLiveTextRun Run; public required AdvLiveTextRun Run;
@@ -96,6 +97,11 @@ public sealed class GodotAdvHost : IHost
private volatile int _messageWindowAlphaSetting; private volatile int _messageWindowAlphaSetting;
private bool _advTextForceComplete; private bool _advTextForceComplete;
private readonly Dictionary<int, AdvWaitIndicatorConfig> _waitIndicators = new(); private readonly Dictionary<int, AdvWaitIndicatorConfig> _waitIndicators = new();
private readonly Dictionary<int, (
AdvTextLayoutPresentationBinding Binding,
AdvTextLayoutSnapshot Layout)> _waitIndicatorBindings = new();
private readonly Dictionary<int, RetainedAdvWaitIndicatorPresentation>
_retainedWaitIndicators = new();
private readonly object _messageSkipLock = new(); private readonly object _messageSkipLock = new();
private bool _scriptMessageSkipActive; private bool _scriptMessageSkipActive;
private bool _physicalMessageSkipActive; private bool _physicalMessageSkipActive;
@@ -728,6 +734,7 @@ public sealed class GodotAdvHost : IHost
lock (_textLock) lock (_textLock)
{ {
_historyText.Remove(binding.LayoutSlot); _historyText.Remove(binding.LayoutSlot);
_retainedHistoryLayouts.Remove(binding.LayoutSlot);
_retainedTextLayouts.Remove(binding.LayoutSlot); _retainedTextLayouts.Remove(binding.LayoutSlot);
_liveText.RemoveAll( _liveText.RemoveAll(
state => state.Run.Layout.Slot == binding.LayoutSlot); state => state.Run.Layout.Slot == binding.LayoutSlot);
@@ -775,17 +782,112 @@ public sealed class GodotAdvHost : IHost
}); });
} }
public bool RenderTextHistory(
GfxState gfx,
AdvTextLayoutPresentationBinding binding,
AdvTextHistoryRenderBatch batch)
{
var run = new AdvLiveTextRun(
batch.FirstRecordIndex,
batch.Layout,
batch.Style,
batch.Text,
Array.Empty<string>());
if (_retainedGlyphLayoutEngine == null
|| binding.LayoutSlot != batch.LayoutSlot
|| binding.FirstObjectHandle < 0
|| binding.ObjectCapacity <= 0
|| !TryPrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result))
{
RenderTextHistory(batch);
return false;
}
presentation.PublishThrough(
gfx,
checked(result.FirstGlyphIndex + result.GlyphCount));
lock (_textLock)
{
_historyText.Remove(batch.LayoutSlot);
_retainedHistoryLayouts.Add(batch.LayoutSlot);
}
Interlocked.Exchange(ref _presentRequested, 1);
_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,
["glyphs"] = result.GlyphCount,
["presentation"] = "retained-glyphs",
});
return true;
}
public void EndTextHistoryPresentation() public void EndTextHistoryPresentation()
{ {
lock (_textLock) _historyText.Clear(); lock (_textLock)
{
_historyText.Clear();
_retainedHistoryLayouts.Clear();
}
_timeline?.Event("history-presentation-end"); _timeline?.Event("history-presentation-end");
} }
public void EndTextHistoryPresentation(GfxState gfx)
{
RetainedAdvTextLayoutPresentation[] retained;
int[] surfaceSlots;
lock (_textLock)
{
retained = _retainedHistoryLayouts
.Select(slot => _retainedTextLayouts.GetValueOrDefault(slot))
.Where(presentation => presentation != null)
.Cast<RetainedAdvTextLayoutPresentation>()
.ToArray();
surfaceSlots = retained
.Select(presentation => presentation.Binding.SourceSurfaceSlot)
.Distinct()
.ToArray();
foreach (int slot in _retainedHistoryLayouts)
_retainedTextLayouts.Remove(slot);
_retainedHistoryLayouts.Clear();
_historyText.Clear();
}
foreach (RetainedAdvTextLayoutPresentation presentation in retained)
presentation.ErasePublished(gfx);
foreach (int surfaceSlot in surfaceSlots)
ClearAllocatedSurfacePixels(surfaceSlot);
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.Event("history-presentation-end", new()
{
["retained_layouts"] = retained.Length,
});
}
public IReadOnlyList<AdvTextHistoryRenderBatch> SnapshotRenderedTextHistory() public IReadOnlyList<AdvTextHistoryRenderBatch> SnapshotRenderedTextHistory()
{ {
lock (_textLock) return _historyText.Values.OrderBy(batch => batch.LayoutSlot).ToArray(); lock (_textLock) return _historyText.Values.OrderBy(batch => batch.LayoutSlot).ToArray();
} }
private void ClearAllocatedSurfacePixels(int surfaceSlot)
{
if (!_slotDims.TryGetValue(surfaceSlot, out var dimensions)
|| dimensions.W <= 0
|| dimensions.H <= 0)
return;
lock (_imageLock)
_surfaceImages[surfaceSlot] = new RgbaImage(
dimensions.W,
dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
}
public int MessageWindowAlphaSetting => _messageWindowAlphaSetting; public int MessageWindowAlphaSetting => _messageWindowAlphaSetting;
public void SetMessageWindowAlphaSetting(int value) public void SetMessageWindowAlphaSetting(int value)
@@ -890,7 +992,11 @@ public sealed class GodotAdvHost : IHost
public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config)
{ {
lock (_textLock) _waitIndicators[config.LayoutSlot] = config; lock (_textLock)
{
_waitIndicators[config.LayoutSlot] = config;
RebuildRetainedWaitIndicator(config.LayoutSlot);
}
_timeline?.Event("wait-indicator-config", new() _timeline?.Event("wait-indicator-config", new()
{ {
["layout"] = config.LayoutSlot, ["x"] = config.X, ["y"] = config.Y, ["layout"] = config.LayoutSlot, ["x"] = config.X, ["y"] = config.Y,
@@ -900,6 +1006,44 @@ public sealed class GodotAdvHost : IHost
}); });
} }
public void BindAdvWaitIndicator(
AdvTextLayoutPresentationBinding binding,
AdvTextLayoutSnapshot layout)
{
lock (_textLock)
{
_waitIndicatorBindings[binding.LayoutSlot] = (binding, layout);
RebuildRetainedWaitIndicator(binding.LayoutSlot);
}
_timeline?.Event("wait-indicator-bind", new()
{
["layout"] = binding.LayoutSlot,
["handle"] = binding.WaitIndicatorObjectHandle,
["origin_x"] = layout.OriginX,
["origin_y"] = layout.OriginY,
});
}
private void RebuildRetainedWaitIndicator(int layoutSlot)
{
if (!_waitIndicators.TryGetValue(layoutSlot, out AdvWaitIndicatorConfig config)
|| !_waitIndicatorBindings.TryGetValue(
layoutSlot,
out (AdvTextLayoutPresentationBinding Binding, AdvTextLayoutSnapshot Layout) retained)
|| retained.Binding.WaitIndicatorObjectHandle < 0)
{
_retainedWaitIndicators.Remove(layoutSlot);
return;
}
_retainedWaitIndicators[layoutSlot] =
new RetainedAdvWaitIndicatorPresentation(
config,
retained.Binding,
retained.Layout.OriginX,
retained.Layout.OriginY);
}
public void SetAdvWaitIndicatorEnabled(bool enabled) public void SetAdvWaitIndicatorEnabled(bool enabled)
{ {
lock (_textLock) lock (_textLock)
@@ -907,6 +1051,7 @@ public sealed class GodotAdvHost : IHost
if (enabled && !_waitIndicatorEnabled) _waitIndicatorStartedMs = _clock.NowMs; if (enabled && !_waitIndicatorEnabled) _waitIndicatorStartedMs = _clock.NowMs;
_waitIndicatorEnabled = enabled; _waitIndicatorEnabled = enabled;
} }
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.Event("wait-indicator-enabled", new() { ["enabled"] = enabled }); _timeline?.Event("wait-indicator-enabled", new() { ["enabled"] = enabled });
} }
@@ -920,29 +1065,39 @@ public sealed class GodotAdvHost : IHost
GfxState gfx, AdvTextLayoutPresentationBinding binding) GfxState gfx, AdvTextLayoutPresentationBinding binding)
{ {
lock (_textLock) lock (_textLock)
{
if (_retainedTextLayouts.TryGetValue( if (_retainedTextLayouts.TryGetValue(
binding.LayoutSlot, out RetainedAdvTextLayoutPresentation? presentation) binding.LayoutSlot, out RetainedAdvTextLayoutPresentation? presentation)
&& presentation.Binding == binding) && presentation.Binding == binding)
presentation.Republish(gfx); presentation.Republish(gfx);
if (_retainedWaitIndicators.TryGetValue(
binding.LayoutSlot,
out RetainedAdvWaitIndicatorPresentation? indicator))
indicator.Republish(
gfx,
_clock.NowMs - _waitIndicatorStartedMs,
WaitIndicatorShouldBeVisible(binding.LayoutSlot));
}
PublishAdvTextLayout(binding.LayoutSlot); PublishAdvTextLayout(binding.LayoutSlot);
} }
public AdvWaitIndicatorSnapshot? SnapshotAdvWaitIndicator() private bool WaitIndicatorShouldBeVisible(int layoutSlot)
=> IsWaiting
&& !_advPagePresentationSuspended
&& _waitIndicatorEnabled
&& _activeWaitLayout == layoutSlot;
private void UpdateRetainedWaitIndicator(GfxState gfx)
{ {
if (!IsWaiting || _advPagePresentationSuspended) return null;
AdvWaitIndicatorConfig config;
long resourceId;
lock (_textLock) lock (_textLock)
{ {
if (!_waitIndicatorEnabled) return null; foreach ((int layoutSlot, RetainedAdvWaitIndicatorPresentation presentation)
if (!_waitIndicators.TryGetValue(_activeWaitLayout, out config)) return null; in _retainedWaitIndicators)
if (!_surfaceResources.TryGetValue(config.SurfaceSlot, out resourceId)) return null; presentation.Update(
gfx,
_clock.NowMs - _waitIndicatorStartedMs,
WaitIndicatorShouldBeVisible(layoutSlot));
} }
var asset = _res.ResolveTexture(resourceId);
var image = asset != null ? Decode(asset) : null;
if (asset == null || image == null || config.CellWidth <= 0 || config.CellHeight <= 0) return null;
int frame = config.FrameAt(_clock.NowMs - _waitIndicatorStartedMs);
return new AdvWaitIndicatorSnapshot(image, asset.Name, asset.PackedId, config, frame);
} }
public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race) public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race)
@@ -1029,6 +1184,7 @@ public sealed class GodotAdvHost : IHost
} }
IsWaiting = false; IsWaiting = false;
lock (_textLock) _waitIndicatorEnabled = false; lock (_textLock) _waitIndicatorEnabled = false;
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.State("running", new() _timeline?.State("running", new()
{ {
["input"] = messageSkipped ? "message-skip" : autoAdvanced ? "auto" : "user", ["input"] = messageSkipped ? "message-skip" : autoAdvanced ? "auto" : "user",
@@ -1166,17 +1322,29 @@ public sealed class GodotAdvHost : IHost
: _currentAdvLayout; : _currentAdvLayout;
if (_retainedTextLayouts.TryGetValue( if (_retainedTextLayouts.TryGetValue(
slot, out RetainedAdvTextLayoutPresentation? presentation)) slot, out RetainedAdvTextLayoutPresentation? presentation))
{
presentation.ErasePublished(gfx); presentation.ErasePublished(gfx);
if (_retainedWaitIndicators.TryGetValue(
slot, out RetainedAdvWaitIndicatorPresentation? indicator))
indicator.Erase(gfx);
if (_retainedTextLayouts.ContainsKey(slot)
|| _retainedWaitIndicators.ContainsKey(slot))
_suspendedRetainedTextLayoutSlot = slot; _suspendedRetainedTextLayoutSlot = slot;
} }
}
else if (_suspendedRetainedTextLayoutSlot != 0) else if (_suspendedRetainedTextLayoutSlot != 0)
{ {
if (_retainedTextLayouts.TryGetValue( if (_retainedTextLayouts.TryGetValue(
_suspendedRetainedTextLayoutSlot, _suspendedRetainedTextLayoutSlot,
out RetainedAdvTextLayoutPresentation? presentation)) out RetainedAdvTextLayoutPresentation? presentation))
presentation.Republish(gfx); presentation.Republish(gfx);
if (_retainedWaitIndicators.TryGetValue(
_suspendedRetainedTextLayoutSlot,
out RetainedAdvWaitIndicatorPresentation? indicator))
indicator.Republish(
gfx,
_clock.NowMs - _waitIndicatorStartedMs,
IsWaiting
&& _waitIndicatorEnabled
&& _activeWaitLayout == _suspendedRetainedTextLayoutSlot);
_suspendedRetainedTextLayoutSlot = 0; _suspendedRetainedTextLayoutSlot = 0;
} }
} }
@@ -1436,10 +1604,11 @@ public sealed class GodotAdvHost : IHost
} }
// Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness // Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness
// once, then continue only while the sampled retained scene can actually change. Text reveal is a separate // once, then continue only while the sampled retained scene can actually change. The wait atlas advances
// Godot Label; waiting/sleeping alone do not alter background pixels. // its ordinary retained source cell here so it contributes only when the frame actually changes.
public HostPresentationReason ConsumePresentationReasons(GfxState gfx) public HostPresentationReason ConsumePresentationReasons(GfxState gfx)
{ {
UpdateRetainedWaitIndicator(gfx);
bool screenTransitionActive; bool screenTransitionActive;
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null; lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
var reasons = HostPresentationReason.None; var reasons = HostPresentationReason.None;
@@ -1489,6 +1658,7 @@ public sealed class GodotAdvHost : IHost
_surfaceText.Clear(); _surfaceText.Clear();
_surfaceResources.Clear(); _surfaceResources.Clear();
_historyText.Clear(); _historyText.Clear();
_retainedHistoryLayouts.Clear();
_liveText.Clear(); _liveText.Clear();
_retainedTextLayouts.Clear(); _retainedTextLayouts.Clear();
_activeLiveText = null; _activeLiveText = null;
@@ -1498,6 +1668,8 @@ public sealed class GodotAdvHost : IHost
_advTextY = 47; _advTextY = 47;
_advTextForceComplete = false; _advTextForceComplete = false;
_waitIndicators.Clear(); _waitIndicators.Clear();
_waitIndicatorBindings.Clear();
_retainedWaitIndicators.Clear();
_activeWaitLayout = 0; _activeWaitLayout = 0;
_waitIndicatorEnabled = false; _waitIndicatorEnabled = false;
} }
@@ -1681,6 +1853,14 @@ public sealed class GodotAdvHost : IHost
{ {
_surfaceText.Remove(slot); _surfaceText.Remove(slot);
_surfaceResources.Remove(slot); _surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
.Select(pair => pair.Key)
.ToArray())
{
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.Remove(layoutSlot);
}
} }
_slotDims[slot] = (image.Width, image.Height); _slotDims[slot] = (image.Width, image.Height);
return true; return true;
@@ -1974,6 +2154,14 @@ public sealed class GodotAdvHost : IHost
{ {
_surfaceText.Remove(slot); _surfaceText.Remove(slot);
_surfaceResources.Remove(slot); _surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
.Select(pair => pair.Key)
.ToArray())
{
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.Remove(layoutSlot);
}
} }
_slotDims.Remove(slot); _slotDims.Remove(slot);
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released) if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
@@ -2138,6 +2326,8 @@ public sealed class GodotAdvHost : IHost
.Select(pair => pair.Key) .Select(pair => pair.Key)
.ToArray()) .ToArray())
_retainedTextLayouts.Remove(layoutSlot); _retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.RemoveWhere(layoutSlot =>
!_retainedTextLayouts.ContainsKey(layoutSlot));
} }
foreach (MovieSurfaceBinding binding in stoppedMovies) foreach (MovieSurfaceBinding binding in stoppedMovies)
{ {
@@ -2493,6 +2683,3 @@ internal sealed class LegacyScreenTransition
DurationMs = durationMs; DurationMs = durationMs;
} }
} }
public readonly record struct AdvWaitIndicatorSnapshot(
RgbaImage Image, string Name, int AssetId, AdvWaitIndicatorConfig Config, int Frame);

View File

@@ -37,10 +37,6 @@ public partial class Main : Godot.Control
private GpuRetainedRenderer _gpuRenderer = null!; private GpuRetainedRenderer _gpuRenderer = null!;
private bool _useGpuBackend = true; private bool _useGpuBackend = true;
private ImageTexture? _ageCursorTexture; private ImageTexture? _ageCursorTexture;
private TextureRect _waitIndicator = null!;
private ImageTexture? _waitIndicatorSheet;
private AtlasTexture? _waitIndicatorAtlas;
private int _waitIndicatorAssetId = -1;
// 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 = [];
@@ -195,17 +191,6 @@ public partial class Main : Godot.Control
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_gpuRenderer = new GpuRetainedRenderer(this); _gpuRenderer = new GpuRetainedRenderer(this);
// Native ADV wait marker: a tiny independently animated atlas region. Keeping it separate from the
// logical software backbuffer avoids recompositing the entire retained scene throughout static waits.
_waitIndicator = new TextureRect
{
Visible = false,
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.Keep,
MouseFilter = MouseFilterEnum.Ignore,
};
AddChild(_waitIndicator);
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore }; _text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore };
AddChild(_text); AddChild(_text);
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
@@ -471,6 +456,11 @@ public partial class Main : Godot.Control
_vm.Gfx.SetSurface(0x0c, waitIndicator.PackedId, 0xff00); _vm.Gfx.SetSurface(0x0c, waitIndicator.PackedId, 0xff00);
_host.ConfigureAdvWaitIndicator(new AdvWaitIndicatorConfig( _host.ConfigureAdvWaitIndicator(new AdvWaitIndicatorConfig(
1, 385, 140, 0x0c, 0, 0, 30, 27, 12, 48)); 1, 385, 140, 0x0c, 0, 0, 30, 27, 12, 48));
AdvTextLayoutSnapshot waitLayout =
_vm.TextHistory.GetLayoutSnapshot(1);
_host.BindAdvWaitIndicator(
_vm.TextHistory.GetPresentationBinding(1),
waitLayout);
} }
// --boot: run SYSTEM4's state prefix (INITCONFIG/INIT2/INIT) so the scene sees boot state — chiefly // --boot: run SYSTEM4's state prefix (INITCONFIG/INIT2/INIT) so the scene sees boot state — chiefly
// INIT2's gfx handle array 0x62455.. (skips the UI scripts LOGO/OP/TITLE). State carries via globals. // INIT2's gfx handle array 0x62455.. (skips the UI scripts LOGO/OP/TITLE). State carries via globals.
@@ -580,7 +570,6 @@ public partial class Main : Godot.Control
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0; phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (!_selftest && _host != null) UpdateAdvTextPresentation(); if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation(); if (!_selftest && _host != null) UpdateHistoryTextPresentation();
perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase); perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase);
@@ -1706,40 +1695,6 @@ public partial class Main : Godot.Control
return new Color(((rgb >> 16) & 0xff) / 255f, ((rgb >> 8) & 0xff) / 255f, (rgb & 0xff) / 255f, 1); return new Color(((rgb >> 16) & 0xff) / 255f, ((rgb >> 8) & 0xff) / 255f, (rgb & 0xff) / 255f, 1);
} }
private void UpdateAdvWaitIndicatorPresentation()
{
// As with the ordinary dialogue Label, the enclosing page's separately animated marker must sit
// out while HISTORY/HIDEWIN owns raw input; native retained composition naturally covers it.
if (_vm.IsRawInputCallbackActive)
{
_waitIndicator.Visible = false;
return;
}
var snapshot = _host.SnapshotAdvWaitIndicator();
if (snapshot == null)
{
_waitIndicator.Visible = false;
return;
}
var s = snapshot.Value;
if (_waitIndicatorAssetId != s.AssetId)
{
var image = Image.CreateFromData(s.Image.Width, s.Image.Height, false, Image.Format.Rgba8, s.Image.Pixels);
_waitIndicatorSheet = ImageTexture.CreateFromImage(image);
_waitIndicatorAtlas = new AtlasTexture { Atlas = _waitIndicatorSheet };
_waitIndicator.Texture = _waitIndicatorAtlas;
_waitIndicatorAssetId = s.AssetId;
}
var c = s.Config;
_waitIndicatorAtlas!.Region = new Rect2(
c.SourceX + s.Frame * c.CellWidth, c.SourceY, c.CellWidth, c.CellHeight);
_waitIndicator.Position = new Vector2(c.X, 430 + c.Y);
_waitIndicator.Size = new Vector2(c.CellWidth, c.CellHeight);
_waitIndicator.Visible = true;
}
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}"
@@ -2610,6 +2565,69 @@ public partial class Main : Godot.Control
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason); && !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
liveRetainedTextMode = "label-fallback"; liveRetainedTextMode = "label-fallback";
} }
_vm.TextHistory.DefineLayout(2, 256, 64, 10, 100);
_vm.TextHistory.SetResetCursor(2, 1, 1);
_vm.TextHistory.SetBounds(2, 255, 63);
_vm.TextHistory.SetTextObjectRange(2, 710000, 64);
_vm.TextHistory.ResetLayout(2);
AdvTextLayoutSnapshot historyLayout =
_vm.TextHistory.GetLayoutSnapshot(2);
AdvTextLayoutPresentationBinding historyBinding =
_vm.TextHistory.GetPresentationBinding(2);
_host.ResetRenderedAdvTextLayout(_vm.Gfx, historyBinding);
var historyBatch = new AdvTextHistoryRenderBatch(
2,
0,
0,
historyLayout,
"History",
immediateStyle);
bool historyUsedRetained =
_host.RenderTextHistory(
_vm.Gfx,
historyBinding,
historyBatch);
bool historyRetainedTextOk;
string historyRetainedTextMode;
if (_host.UsesSurfaceTextPixels)
{
RgbaImage? historySurface =
_host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot);
historyRetainedTextOk =
historyUsedRetained
&& _vm.Gfx.SnapshotVisibleObjects()
.Count(item =>
item.Handle >= historyBinding.FirstObjectHandle
&& item.Handle - historyBinding.FirstObjectHandle
< historyBinding.ObjectCapacity)
== historyBatch.Text.Length
&& historySurface != null
&& historySurface.Pixels.Where((_, index) => index % 4 == 3)
.Any(alpha => alpha != 0)
&& _host.SnapshotRenderedTextHistory().Count == 0;
_vm.Gfx.EraseRange(
historyBinding.FirstObjectHandle,
historyBinding.ObjectCapacity);
_host.ClearRenderedAdvTextLayout(historyBinding.LayoutSlot);
_host.EndTextHistoryPresentation(_vm.Gfx);
historyRetainedTextOk &=
!_vm.Gfx.SnapshotVisibleObjects()
.Any(item =>
item.Handle >= historyBinding.FirstObjectHandle
&& item.Handle - historyBinding.FirstObjectHandle
< historyBinding.ObjectCapacity)
&& _host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot)
?.Pixels.All(value => value == 0) == true;
historyRetainedTextMode = "retained-glyphs";
}
else
{
historyRetainedTextOk =
!historyUsedRetained
&& _host.SnapshotRenderedTextHistory().Count == 1;
_host.EndTextHistoryPresentation(_vm.Gfx);
historyRetainedTextMode = "label-fallback";
}
Window rootWindow = GetTree().Root; Window rootWindow = GetTree().Root;
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight) bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
&& _screen.GetWidth() == _screenWidth && _screen.GetWidth() == _screenWidth
@@ -2650,6 +2668,7 @@ public partial class Main : Godot.Control
&& bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk && bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk
&& immediateSurfaceTextOk && immediateSurfaceTextOk
&& liveRetainedTextOk && liveRetainedTextOk
&& historyRetainedTextOk
&& logicalCanvasOk && backbufferPreservationOk; && logicalCanvasOk && backbufferPreservationOk;
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " + if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " +
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " + $"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " +
@@ -2659,6 +2678,7 @@ public partial class Main : Godot.Control
$"text-effect-modes=ok; font-calibration=ok; " + $"text-effect-modes=ok; font-calibration=ok; " +
$"immediate-surface-text={immediateSurfaceTextMode}; " + $"immediate-surface-text={immediateSurfaceTextMode}; " +
$"live-adv-text={liveRetainedTextMode}; " + $"live-adv-text={liveRetainedTextMode}; " +
$"history-text={historyRetainedTextMode}; " +
$"backbuffer-preservation=ok; " + $"backbuffer-preservation=ok; " +
$"logical-canvas={_screenWidth}x{_screenHeight}; " + $"logical-canvas={_screenWidth}x{_screenHeight}; " +
$"window-request={_windowOptions.Width}x{_windowOptions.Height}"); $"window-request={_windowOptions.Width}x{_windowOptions.Height}");
@@ -2672,6 +2692,7 @@ public partial class Main : Godot.Control
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " + $"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " +
$"immediate-surface-text={immediateSurfaceTextOk}({immediateSurfaceTextMode}); " + $"immediate-surface-text={immediateSurfaceTextOk}({immediateSurfaceTextMode}); " +
$"live-adv-text={liveRetainedTextOk}({liveRetainedTextMode}); " + $"live-adv-text={liveRetainedTextOk}({liveRetainedTextMode}); " +
$"history-text={historyRetainedTextOk}({historyRetainedTextMode}); " +
$"backbuffer-preservation={backbufferPreservationOk}; " + $"backbuffer-preservation={backbufferPreservationOk}; " +
$"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " + $"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " +
$"window-request={_windowOptions.Width}x{_windowOptions.Height}"); $"window-request={_windowOptions.Width}x{_windowOptions.Height}");