From bb227ea10605a6912a0fdf2ade3fe726e721bbe5 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 30 Jul 2026 19:21:38 -0400 Subject: [PATCH] engine: retain History and wait presentation --- docs/PROJECT-STRUCTURE.md | 4 +- docs/engine-re.md | 32 ++- docs/platform-portability.md | 2 +- docs/remake-architecture-and-roadmap.md | 16 +- engine/Age.Engine.Tests/AdvTextOpsTests.cs | 28 +++ .../HistoryPresentationOpsTests.cs | 4 + ...tainedAdvWaitIndicatorPresentationTests.cs | 94 +++++++ engine/Age.Engine.Tests/TestSupport.cs | 18 ++ engine/Age.Engine/Hosting/IHost.cs | 13 + engine/Age.Engine/Model/AdvTextHistory.cs | 4 +- .../RetainedAdvWaitIndicatorPresentation.cs | 95 ++++++++ engine/Age.Engine/Vm/VirtualMachine.cs | 20 +- godot/GodotAdvHost.cs | 229 ++++++++++++++++-- godot/Main.cs | 121 +++++---- 14 files changed, 585 insertions(+), 95 deletions(-) create mode 100644 engine/Age.Engine.Tests/RetainedAdvWaitIndicatorPresentationTests.cs create mode 100644 engine/Age.Engine/Text/RetainedAdvWaitIndicatorPresentation.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index c1965ba..5138e12 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -105,8 +105,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ ALF asset store, script provider, AGF/LZSS and Windows CUR decoders, │ and resource facade │ ├── Age.Engine/Text/ backend-neutral glyph-mask requests/results, deterministic AGE - │ compositor/layout, retained live-glyph publication, and bounded - │ font/glyph cache primitives + │ compositor/layout, retained live/History glyph and wait-atlas + │ publication, and bounded font/glyph cache primitives │ ├── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB, │ layout-3 numbered state/history/gfx, DAT/STH pair + BMP codec, │ and profile-owned state diff --git a/docs/engine-re.md b/docs/engine-re.md index 4120e73..8336c3c 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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 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 -game call site. Godot owns only replaceable labels keyed by target layout, applies the retained font/color/ -effect snapshot, and clears stale labels whenever `0x71` resets that layout. +game call site. The initial port used replaceable labels keyed by target layout. The retained-glyph migration +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 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 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 -core now drives immediate surface strings and live ADV; retained History remains on its Label pending the -next migration step. +core now drives immediate surface strings, live ADV, and retained History. The independent Windows reference backend landed alongside, but outside, the neutral engine as `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, 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. -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. `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` 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 -Godot atlas overlay advances on the 48 ms host clock only while `IsWaiting`, then hides immediately when -input releases the wait. A 2026-07-27 polish fix removed the port's erroneous `TerminalFrame + 1` +`GodotAdvHost` retains configurations by layout and pairs them with op `0x212`'s script-owned handle and +layout origin. `RetainedAdvWaitIndicatorPresentation` binds the current atlas cell as an ordinary +`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 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. -Full SYSTEM4 replay remains Phase-B work; the shortcut carries the same state in the meantime. +The retained presenter requests a new frame only when that source cell changes, preserving the static-wait +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) diff --git a/docs/platform-portability.md b/docs/platform-portability.md index 081e019..ec4b913 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -33,7 +33,7 @@ or replaced before claiming portable exports. | AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging | | Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates | | Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug | -| ADV font discovery/raster fidelity | `Age.Engine.Text` remains OS-neutral. Godot selects the separate `Age.Engine.Text.Windows` backend for immediate `0x204`/`0x205` strings 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 `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 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 `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 | | Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules | | Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target | | Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS | diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 4a3cf8c..e2f74c4 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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 `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. -6. **Migrate History and the ADV wait indicator through the same retained path.** Op `0x1d1` rasterizes and - binds into its target layout instead of creating a layout-keyed Label. Bind the configured wait atlas at - the op-`0x212` handle so it follows native z-order and source-cell animation. Once SC0000, HISTORY, - STUDY/MAMES, HIDEWIN, and BUNKI pass, delete the game-text Label pools and the modal/raw-callback visibility - exceptions whose only purpose was compensating for detached overlays. +6. **Migrate History and the ADV wait indicator through the same retained path. — Completed + 2026-07-30.** Op `0x1d1` now rasterizes each HISTORY row into its target layout surface and publishes its + native glyph interval through ordinary `GfxState` bindings. Row reset, wheel redraw, the broad native exit + erase, recording re-enable, and surface release all remove the transient presentation without changing + 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 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, diff --git a/engine/Age.Engine.Tests/AdvTextOpsTests.cs b/engine/Age.Engine.Tests/AdvTextOpsTests.cs index 0ceaf07..82af3e5 100644 --- a/engine/Age.Engine.Tests/AdvTextOpsTests.cs +++ b/engine/Age.Engine.Tests/AdvTextOpsTests.cs @@ -388,6 +388,34 @@ public class AdvTextOpsTests 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()), + }, Array.Empty()); + 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] public void WaitIndicatorTerminalFrameIsExclusive() { diff --git a/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs b/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs index fbc2eab..8131e8a 100644 --- a/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs +++ b/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs @@ -45,6 +45,10 @@ public class HistoryPresentationOpsTests Assert.Equal((24, 0xffffffL, 0x606060L, 8, "MS 明朝"), (render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor, 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] diff --git a/engine/Age.Engine.Tests/RetainedAdvWaitIndicatorPresentationTests.cs b/engine/Age.Engine.Tests/RetainedAdvWaitIndicatorPresentationTests.cs new file mode 100644 index 0000000..b7ed697 --- /dev/null +++ b/engine/Age.Engine.Tests/RetainedAdvWaitIndicatorPresentationTests.cs @@ -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(() => + new RetainedAdvWaitIndicatorPresentation( + Config, + Binding with { WaitIndicatorObjectHandle = -1 }, + 0, + 430)); + Assert.Throws(() => + new RetainedAdvWaitIndicatorPresentation( + Config with { LayoutSlot = 2 }, + Binding, + 0, + 430)); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 2ffcfbf..a0d5005 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -21,6 +21,8 @@ internal class RecordingHost : IHost 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 HistoryRenders = new(); + public readonly List<(AdvTextLayoutPresentationBinding Binding, AdvTextHistoryRenderBatch Batch)> + RetainedHistoryRenders = new(); public readonly Dictionary ActiveHistoryRenders = new(); public int HistoryPresentationEnds; public readonly List 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)> TextureDraws = new(); public readonly List WaitIndicators = new(); + public readonly List<(AdvTextLayoutPresentationBinding Binding, AdvTextLayoutSnapshot Layout)> + WaitIndicatorBindings = new(); public readonly List WaitIndicatorEnabledChanges = new(); public readonly List PublishedAdvTextLayouts = new(); public readonly List SleptDurations = new(); @@ -129,11 +133,21 @@ internal class RecordingHost : IHost HistoryRenders.Add(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() { HistoryPresentationEnds++; ActiveHistoryRenders.Clear(); } + public void EndTextHistoryPresentation(GfxState gfx) => EndTextHistoryPresentation(); public int MessageWindowAlphaSetting { get; set; } public void SetMessageWindowAlphaSetting(int value) => MessageWindowAlphaSetting = value; 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) => PresentedRanges.Add((firstHandle, count)); 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 PublishAdvTextLayout(int layoutSlot) => PublishedAdvTextLayouts.Add(layoutSlot); public void SetAdvPagePresentationSuspended(bool suspended) diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index f51d738..b85c0a2 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -86,15 +86,28 @@ public interface IHost GfxState gfx, AdvTextLayoutPresentationBinding binding) => ClearRenderedAdvTextLayout(binding.LayoutSlot); 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 // recording re-enable at exit ends that presentation and drops every bound target layout. void EndTextHistoryPresentation() { } + void EndTextHistoryPresentation(GfxState gfx) => EndTextHistoryPresentation(); int MessageWindowAlphaSetting => 0; void SetMessageWindowAlphaSetting(int value) { } void FillSurfaceRect(SurfaceRectFill fill) { } void CopySurfaceRect(SurfaceRectCopy copy) { } void PresentObjectRange(GfxState gfx, long firstHandle, long count) { } 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. void SetAdvWaitIndicatorEnabled(bool enabled) { } // Op 0x20a republishes one retained ADV text layout and includes the current marker frame when active. diff --git a/engine/Age.Engine/Model/AdvTextHistory.cs b/engine/Age.Engine/Model/AdvTextHistory.cs index f9aea44..6cd7b48 100644 --- a/engine/Age.Engine/Model/AdvTextHistory.cs +++ b/engine/Age.Engine/Model/AdvTextHistory.cs @@ -207,8 +207,8 @@ public sealed class AdvTextHistory /// /// 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 - /// teardown removes the corresponding detached presentation atomically. + /// erase. Hosts use this notification to discard fallback metadata or preserve exact transient records + /// for a following native op-0x20a republication, according to the active presentation path. /// public IReadOnlyList LayoutsCoveredByTextObjectErase(long firstHandle, long count) { diff --git a/engine/Age.Engine/Text/RetainedAdvWaitIndicatorPresentation.cs b/engine/Age.Engine/Text/RetainedAdvWaitIndicatorPresentation.cs new file mode 100644 index 0000000..a928df3 --- /dev/null +++ b/engine/Age.Engine/Text/RetainedAdvWaitIndicatorPresentation.cs @@ -0,0 +1,95 @@ +using Age.Engine.Hosting; +using Age.Engine.Model; + +namespace Age.Engine.Text; + +/// +/// 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. +/// +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; + + /// Publish a changed source cell, or erase the retained handle when inactive. + 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; + } + + /// Recreate the current frame after script-owned range teardown/publication. + 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; + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index be66f18..fe7af03 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -2347,7 +2347,7 @@ public sealed class VirtualMachine { bool enabled = Read(a[0]) == 1; TextHistory.SetRecordingEnabled(enabled); - if (enabled) _host.EndTextHistoryPresentation(); + if (enabled) _host.EndTextHistoryPresentation(Gfx); } return pc + 1; case "append-text-history-metadata": // 0x1d2: (metadata type, value) @@ -2370,11 +2370,17 @@ public sealed class VirtualMachine int flags = (int)Read(a[2]); if ((flags & 4) == 0 && TextHistory.TryBuildRenderBatch( (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. Style = batch.Style with { LineSpacing = _advTextStyle.LineSpacing } - }); + }; + _host.RenderTextHistory( + Gfx, + TextHistory.GetPresentationBinding(batch.LayoutSlot), + batch); + } return pc + 1; } case "u0041BB90": @@ -2820,7 +2826,13 @@ public sealed class VirtualMachine return pc + 1; 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; } case "set-adv-text-object-range": // 0x213 (layout)(first handle)(count) diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index f67659f..7cd9c48 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -73,6 +73,7 @@ public sealed class GodotAdvHost : IHost private string _surfaceTextFallbackReason; private bool _surfaceTextFallbackWarningReported; private readonly Dictionary _historyText = new(); + private readonly HashSet _retainedHistoryLayouts = new(); private sealed class LiveTextState { public required AdvLiveTextRun Run; @@ -96,6 +97,11 @@ public sealed class GodotAdvHost : IHost private volatile int _messageWindowAlphaSetting; private bool _advTextForceComplete; private readonly Dictionary _waitIndicators = new(); + private readonly Dictionary _waitIndicatorBindings = new(); + private readonly Dictionary + _retainedWaitIndicators = new(); private readonly object _messageSkipLock = new(); private bool _scriptMessageSkipActive; private bool _physicalMessageSkipActive; @@ -728,6 +734,7 @@ public sealed class GodotAdvHost : IHost lock (_textLock) { _historyText.Remove(binding.LayoutSlot); + _retainedHistoryLayouts.Remove(binding.LayoutSlot); _retainedTextLayouts.Remove(binding.LayoutSlot); _liveText.RemoveAll( 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()); + 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() { - lock (_textLock) _historyText.Clear(); + lock (_textLock) + { + _historyText.Clear(); + _retainedHistoryLayouts.Clear(); + } _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() + .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 SnapshotRenderedTextHistory() { 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 void SetMessageWindowAlphaSetting(int value) @@ -890,7 +992,11 @@ public sealed class GodotAdvHost : IHost 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() { ["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) { lock (_textLock) @@ -907,6 +1051,7 @@ public sealed class GodotAdvHost : IHost if (enabled && !_waitIndicatorEnabled) _waitIndicatorStartedMs = _clock.NowMs; _waitIndicatorEnabled = enabled; } + Interlocked.Exchange(ref _presentRequested, 1); _timeline?.Event("wait-indicator-enabled", new() { ["enabled"] = enabled }); } @@ -920,29 +1065,39 @@ public sealed class GodotAdvHost : IHost GfxState gfx, AdvTextLayoutPresentationBinding binding) { lock (_textLock) + { if (_retainedTextLayouts.TryGetValue( binding.LayoutSlot, out RetainedAdvTextLayoutPresentation? presentation) && presentation.Binding == binding) presentation.Republish(gfx); + if (_retainedWaitIndicators.TryGetValue( + binding.LayoutSlot, + out RetainedAdvWaitIndicatorPresentation? indicator)) + indicator.Republish( + gfx, + _clock.NowMs - _waitIndicatorStartedMs, + WaitIndicatorShouldBeVisible(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) { - if (!_waitIndicatorEnabled) return null; - if (!_waitIndicators.TryGetValue(_activeWaitLayout, out config)) return null; - if (!_surfaceResources.TryGetValue(config.SurfaceSlot, out resourceId)) return null; + foreach ((int layoutSlot, RetainedAdvWaitIndicatorPresentation presentation) + in _retainedWaitIndicators) + 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) @@ -1029,6 +1184,7 @@ public sealed class GodotAdvHost : IHost } IsWaiting = false; lock (_textLock) _waitIndicatorEnabled = false; + Interlocked.Exchange(ref _presentRequested, 1); _timeline?.State("running", new() { ["input"] = messageSkipped ? "message-skip" : autoAdvanced ? "auto" : "user", @@ -1166,10 +1322,13 @@ public sealed class GodotAdvHost : IHost : _currentAdvLayout; if (_retainedTextLayouts.TryGetValue( slot, out RetainedAdvTextLayoutPresentation? presentation)) - { presentation.ErasePublished(gfx); + if (_retainedWaitIndicators.TryGetValue( + slot, out RetainedAdvWaitIndicatorPresentation? indicator)) + indicator.Erase(gfx); + if (_retainedTextLayouts.ContainsKey(slot) + || _retainedWaitIndicators.ContainsKey(slot)) _suspendedRetainedTextLayoutSlot = slot; - } } else if (_suspendedRetainedTextLayoutSlot != 0) { @@ -1177,6 +1336,15 @@ public sealed class GodotAdvHost : IHost _suspendedRetainedTextLayoutSlot, out RetainedAdvTextLayoutPresentation? presentation)) presentation.Republish(gfx); + if (_retainedWaitIndicators.TryGetValue( + _suspendedRetainedTextLayoutSlot, + out RetainedAdvWaitIndicatorPresentation? indicator)) + indicator.Republish( + gfx, + _clock.NowMs - _waitIndicatorStartedMs, + IsWaiting + && _waitIndicatorEnabled + && _activeWaitLayout == _suspendedRetainedTextLayoutSlot); _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 - // once, then continue only while the sampled retained scene can actually change. Text reveal is a separate - // Godot Label; waiting/sleeping alone do not alter background pixels. + // once, then continue only while the sampled retained scene can actually change. The wait atlas advances + // its ordinary retained source cell here so it contributes only when the frame actually changes. public HostPresentationReason ConsumePresentationReasons(GfxState gfx) { + UpdateRetainedWaitIndicator(gfx); bool screenTransitionActive; lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null; var reasons = HostPresentationReason.None; @@ -1489,6 +1658,7 @@ public sealed class GodotAdvHost : IHost _surfaceText.Clear(); _surfaceResources.Clear(); _historyText.Clear(); + _retainedHistoryLayouts.Clear(); _liveText.Clear(); _retainedTextLayouts.Clear(); _activeLiveText = null; @@ -1498,6 +1668,8 @@ public sealed class GodotAdvHost : IHost _advTextY = 47; _advTextForceComplete = false; _waitIndicators.Clear(); + _waitIndicatorBindings.Clear(); + _retainedWaitIndicators.Clear(); _activeWaitLayout = 0; _waitIndicatorEnabled = false; } @@ -1681,6 +1853,14 @@ public sealed class GodotAdvHost : IHost { _surfaceText.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); return true; @@ -1974,6 +2154,14 @@ public sealed class GodotAdvHost : IHost { _surfaceText.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); if (movieRelease.Kind == MovieSurfaceReleaseKind.Released) @@ -2138,6 +2326,8 @@ public sealed class GodotAdvHost : IHost .Select(pair => pair.Key) .ToArray()) _retainedTextLayouts.Remove(layoutSlot); + _retainedHistoryLayouts.RemoveWhere(layoutSlot => + !_retainedTextLayouts.ContainsKey(layoutSlot)); } foreach (MovieSurfaceBinding binding in stoppedMovies) { @@ -2493,6 +2683,3 @@ internal sealed class LegacyScreenTransition DurationMs = durationMs; } } - -public readonly record struct AdvWaitIndicatorSnapshot( - RgbaImage Image, string Name, int AssetId, AdvWaitIndicatorConfig Config, int Frame); diff --git a/godot/Main.cs b/godot/Main.cs index e22e3f9..25550e1 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -37,10 +37,6 @@ public partial class Main : Godot.Control private GpuRetainedRenderer _gpuRenderer = null!; private bool _useGpuBackend = true; 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 // completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer. private byte[] _screenPixels = []; @@ -195,17 +191,6 @@ public partial class Main : Godot.Control _screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _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 }; AddChild(_text); _text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); @@ -471,6 +456,11 @@ public partial class Main : Godot.Control _vm.Gfx.SetSurface(0x0c, waitIndicator.PackedId, 0xff00); _host.ConfigureAdvWaitIndicator(new AdvWaitIndicatorConfig( 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 // 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; allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; if (!_selftest && _host != null) UpdateAdvTextPresentation(); - if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation(); if (!_selftest && _host != null) UpdateHistoryTextPresentation(); perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); 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); } - 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) => state is { } c ? $" 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); 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; bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight) && _screen.GetWidth() == _screenWidth @@ -2650,6 +2668,7 @@ public partial class Main : Godot.Control && bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk && immediateSurfaceTextOk && liveRetainedTextOk + && historyRetainedTextOk && logicalCanvasOk && backbufferPreservationOk; if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " + $"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; " + $"immediate-surface-text={immediateSurfaceTextMode}; " + $"live-adv-text={liveRetainedTextMode}; " + + $"history-text={historyRetainedTextMode}; " + $"backbuffer-preservation=ok; " + $"logical-canvas={_screenWidth}x{_screenHeight}; " + $"window-request={_windowOptions.Width}x{_windowOptions.Height}"); @@ -2672,6 +2692,7 @@ public partial class Main : Godot.Control $"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " + $"immediate-surface-text={immediateSurfaceTextOk}({immediateSurfaceTextMode}); " + $"live-adv-text={liveRetainedTextOk}({liveRetainedTextMode}); " + + $"history-text={historyRetainedTextOk}({historyRetainedTextMode}); " + $"backbuffer-preservation={backbufferPreservationOk}; " + $"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " + $"window-request={_windowOptions.Width}x{_windowOptions.Height}");