engine: materialize live ADV glyphs
This commit is contained in:
@@ -105,7 +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
|
||||
│ text compositor/layout, and bounded font/glyph cache primitives
|
||||
│ compositor/layout, retained live-glyph 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
|
||||
|
||||
@@ -2982,8 +2982,8 @@ and returns the five-dword edge records plus final cursor, observed overflow, wr
|
||||
state. It preserves native horizontal precedence: vertical-only overflow stops before compositing, while a
|
||||
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 is not yet connected to live ADV or History; those paths still use Labels pending their retained-glyph
|
||||
materialization steps.
|
||||
core now drives immediate surface strings and live ADV; retained History remains on its Label pending the
|
||||
next migration step.
|
||||
|
||||
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,8 +3014,31 @@ 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.
|
||||
Live ADV, History, and the wait indicator remain on their existing presentation paths for the later staged
|
||||
migration.
|
||||
History and the wait indicator remain on their existing presentation paths for the later staged migration.
|
||||
|
||||
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`),
|
||||
first object handle, capacity, wait-indicator handle, and reset cursor without changing the persisted
|
||||
History record layout. The VM passes that binding and its `GfxState` to the host for each `show-text` run,
|
||||
then writes the returned GDI-measured cursor into the canonical live layout before processing another
|
||||
literal/dynamic run.
|
||||
|
||||
`RetainedAdvTextLayoutPresentation` stores each five-dword record together with the layout origin active
|
||||
when that run was built. Publication binds `first_handle+index` from the record's source edges to
|
||||
`run_origin+(left,top)`, so MAMES-style origin changes do not move earlier glyphs. The complete run is
|
||||
rasterized once into the layout RGBA surface; timed reveal only adds another `GfxState` binding and never
|
||||
rerasterizes or reuploads the complete line. The configured capacity bounds timed publication. Click or
|
||||
Skip sets the existing force-complete flag, binds every remaining publishable record, synchronizes that
|
||||
endpoint, and returns without releasing the subsequent stable input wait.
|
||||
|
||||
Because these are normal handles, a partial `0x1f7` removes only the selected glyph. Op `0x20a` walks the
|
||||
retained records through the current reveal count and recreates missing bindings. HIDEWIN suspension erases
|
||||
the active page's published range and restore republishes it; layout reset erases the configured interval,
|
||||
drops transient records, and zeroes the source surface. Whole scene reset/release drops the transient layout
|
||||
state. Numbered History serialization still owns none of these pixels or records: the saved-frame script
|
||||
path reconstructs them, and a reconstruction test proves identical handle/source/destination bindings.
|
||||
Exact runs are omitted from `SnapshotLiveAdvText`, eliminating the detached always-on-top Label while the
|
||||
unavailable-backend fallback continues to use that snapshot.
|
||||
|
||||
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ or replaced before claiming portable exports.
|
||||
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
|
||||
| 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 now selects the separate `Age.Engine.Text.Windows` backend for immediate `0x204`/`0x205` strings when Windows ACP 932 is available; exact masks are cached and composited into numbered RGBA surfaces. Live ADV and History still use the existing `Label` path, which loads Windows `MS 明朝`/`MS ゴシック` when available and otherwise chooses the Japanese/default fallback with FreeType embolden and a Godot outline | Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle. Immediate surface text now obeys retained ordering, alpha/tint/fade, transforms, clipping, capture, and surface mutation in both render paths. Non-Windows/non-932 runs report why exact selection failed and atomically retain the old surface-label projection; they do not claim GDI parity | Add an explicitly non-identical portable backend and face-substitution policy, then migrate live ADV/History and remove Label compensations. Keep the GDI project out of non-Windows deliverables rather than hiding failed exact selection |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -612,13 +612,22 @@ primitive, which cannot be made equivalent by choosing another embolden constant
|
||||
dynamic GPU upload, and surface mutation path. Backend absence, an unrepresentable glyph, or an unresolved
|
||||
surface falls back atomically to the prior metadata/Label projection with an explicit diagnostic; that
|
||||
projection remains only for this temporary unavailable-backend case until the portable backend lands.
|
||||
5. **Materialize live ADV glyphs as ordinary retained objects.** Extend the transient layout-presentation
|
||||
binding with its source surface, first handle, and capacity without changing the persisted History record
|
||||
ABI. Build the complete line before reveal, update the canonical live cursor from measured metrics, then
|
||||
bind one normal `GfxState` object per revealed record. Click/Skip publishes the remaining records and keeps
|
||||
the existing consumed-click boundary. Layout reset erases its range and clears its surface; partial
|
||||
`0x1f7` erases work per handle naturally; `0x20a` republishes from retained records. Consecutive
|
||||
literal/dynamic runs advance by returned metrics rather than relying on Label concatenation.
|
||||
5. **Materialize live ADV glyphs as ordinary retained objects.** Completed 2026-07-30 for the exact Windows
|
||||
path. The VM passes `GfxState` plus the transient layout binding into presentation and accepts the measured
|
||||
final cursor in return; the persisted History ABI is unchanged. `RetainedAdvTextLayoutPresentation`
|
||||
retains each native edge record with the layout origin active for that run, builds the complete line into
|
||||
the layout surface once, and binds `first_handle + glyph_index` in reveal order. Consecutive
|
||||
literal/dynamic runs therefore begin at GDI's returned cursor instead of relying on Label concatenation.
|
||||
Timed reveal stops at capacity, while click/Skip publishes the remaining available handles through the
|
||||
existing consumed-click branch. Partial `0x1f7` erases ordinary handles; `0x20a` recreates all records
|
||||
through the current reveal count. HIDEWIN suspension erases that range and restoration republishes it.
|
||||
Layout reset erases the interval, clears its RGBA surface and transient records, and saved-frame-style
|
||||
reconstruction deterministically rebuilds the same bindings. Exact runs are excluded from the live Label
|
||||
snapshot; unavailable-backend runs retain it unchanged. Focused tests cover handle order/capacity,
|
||||
per-run origins, partial erase/republication, suspension, measured cursor handoff, reset, and
|
||||
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,
|
||||
|
||||
@@ -216,6 +216,48 @@ public class AdvTextOpsTests
|
||||
host.LiveTextRuns.Select(emitted => emitted.Run.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedGlyphMetricsAdvanceCanonicalCursorBetweenRuns()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
static Operand I(long value) => new(0, value);
|
||||
static Operand S(int index) => new(2, index);
|
||||
var script = ScriptAssembler.Assemble(table, "RETAINED_CURSOR",
|
||||
new List<(int, Operand[])>
|
||||
{
|
||||
(0x70, new[] { I(1), I(100), I(50), I(10), I(20) }),
|
||||
(0x79, new[] { I(1), I(2), I(3) }),
|
||||
(0x71, new[] { I(1) }),
|
||||
(0x213, new[] { I(1), I(100), I(10) }),
|
||||
(0x6e, new[] { I(0), S(0) }),
|
||||
(0x6e, new[] { I(0), S(1) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
},
|
||||
new[] { "A", "BC" });
|
||||
var host = new RecordingHost
|
||||
{
|
||||
OnRetainedText = run => new AdvRetainedTextRunResult(
|
||||
run.Layout.Slot,
|
||||
FirstGlyphIndex: 0,
|
||||
GlyphCount: run.Text.Length,
|
||||
CursorX: run.Layout.CursorX + run.Text.Length * 4,
|
||||
CursorY: run.Layout.CursorY),
|
||||
};
|
||||
var vm = new VirtualMachine(script, table, host);
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal(new[] { (2, 3), (6, 3) },
|
||||
host.LiveTextRuns.Select(run =>
|
||||
(run.Run.Layout.CursorX, run.Run.Layout.CursorY)));
|
||||
Assert.Equal(new[] { (2, 3), (6, 3) },
|
||||
vm.TextHistory.Records.Select(record =>
|
||||
(record.Layout.CursorX, record.Layout.CursorY)));
|
||||
Assert.Equal((14, 3),
|
||||
(vm.TextHistory.GetLayoutSnapshot(1).CursorX,
|
||||
vm.TextHistory.GetLayoutSnapshot(1).CursorY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealMamesResearchDescriptionUsesImmediateRetainedTextPath()
|
||||
{
|
||||
|
||||
@@ -116,7 +116,7 @@ public class NativeNumberedSaveCodecTests
|
||||
new AdvTextLayoutSnapshot(3, 320, 90, 20, 400, 0, 0, 300, 80),
|
||||
liveHistory.GetLayoutSnapshot(3));
|
||||
Assert.Equal(
|
||||
new AdvTextLayoutPresentationBinding(3, 0x17, 0x2000, 500, 0x1234),
|
||||
new AdvTextLayoutPresentationBinding(3, 0x17, 0x2000, 500, 0x1234, 45, 42),
|
||||
liveHistory.GetPresentationBinding(3));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Text;
|
||||
|
||||
public class RetainedAdvTextLayoutPresentationTests
|
||||
{
|
||||
[Fact]
|
||||
public void PublishesNativeRectsInHandleOrderAndStopsAtCapacity()
|
||||
{
|
||||
var binding = new AdvTextLayoutPresentationBinding(
|
||||
1, 21, 100, 2, 900, 10, 20);
|
||||
var presentation = new RetainedAdvTextLayoutPresentation(binding);
|
||||
presentation.Append(
|
||||
[
|
||||
new AdvRetainedGlyphRecord(0, 1, 2, 5, 8),
|
||||
new AdvRetainedGlyphRecord(0, 5, 2, 9, 8),
|
||||
],
|
||||
layoutOriginX: 30,
|
||||
layoutOriginY: 40);
|
||||
presentation.Append(
|
||||
[new AdvRetainedGlyphRecord(0, 10, 3, 14, 9)],
|
||||
layoutOriginX: 50,
|
||||
layoutOriginY: 60);
|
||||
var gfx = new GfxState();
|
||||
gfx.CreateSurface(21);
|
||||
|
||||
Assert.Equal(2, presentation.PublishThrough(gfx, 3));
|
||||
|
||||
Assert.Collection(
|
||||
gfx.SnapshotVisibleObjects(),
|
||||
first => Assert.Equal(
|
||||
(100L, 1, 2, 4, 6, 31, 42),
|
||||
(first.Handle, first.SrcX, first.SrcY, first.W, first.H,
|
||||
first.DstX, first.DstY)),
|
||||
second => Assert.Equal(
|
||||
(101L, 5, 2, 4, 6, 35, 42),
|
||||
(second.Handle, second.SrcX, second.SrcY, second.W, second.H,
|
||||
second.DstX, second.DstY)));
|
||||
Assert.Null(gfx.TryGet(102));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepublishRestoresPartialEraseWithoutAdvancingReveal()
|
||||
{
|
||||
var presentation = Presentation(capacity: 4);
|
||||
var gfx = new GfxState();
|
||||
gfx.CreateSurface(21);
|
||||
presentation.PublishThrough(gfx, 2);
|
||||
gfx.EraseRange(101, 1);
|
||||
|
||||
Assert.Single(gfx.SnapshotVisibleObjects());
|
||||
Assert.Equal(2, presentation.Republish(gfx));
|
||||
Assert.Equal(new long[] { 100, 101 },
|
||||
gfx.SnapshotVisibleObjects().Select(item => item.Handle));
|
||||
|
||||
presentation.ErasePublished(gfx);
|
||||
Assert.Empty(gfx.SnapshotVisibleObjects());
|
||||
Assert.Equal(2, presentation.PublishedGlyphCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveRunsRetainTheirOwnLayoutOrigins()
|
||||
{
|
||||
var binding = new AdvTextLayoutPresentationBinding(
|
||||
7, 27, 200, 10, -1, 53, 10);
|
||||
var presentation = new RetainedAdvTextLayoutPresentation(binding);
|
||||
presentation.Append(
|
||||
[new AdvRetainedGlyphRecord(0, 3, 4, 7, 10)],
|
||||
275, 90);
|
||||
presentation.Append(
|
||||
[new AdvRetainedGlyphRecord(0, 7, 4, 11, 10)],
|
||||
275, 135);
|
||||
var gfx = new GfxState();
|
||||
gfx.CreateSurface(27);
|
||||
|
||||
presentation.PublishThrough(gfx, 2);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { (278, 94), (282, 139) },
|
||||
gfx.SnapshotVisibleObjects().Select(item => (item.DstX, item.DstY)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavedFrameStyleReconstructionRebuildsTheSameBindings()
|
||||
{
|
||||
RetainedAdvTextLayoutPresentation before = Presentation(capacity: 4);
|
||||
var gfx = new GfxState();
|
||||
gfx.CreateSurface(21);
|
||||
before.PublishThrough(gfx, 3);
|
||||
RenderObject[] expected = gfx.SnapshotVisibleObjects().ToArray();
|
||||
|
||||
gfx.EraseRange(
|
||||
before.Binding.FirstObjectHandle,
|
||||
before.Binding.ObjectCapacity);
|
||||
RetainedAdvTextLayoutPresentation reconstructed =
|
||||
Presentation(capacity: 4);
|
||||
reconstructed.PublishThrough(gfx, 3);
|
||||
|
||||
Assert.Equal(expected, gfx.SnapshotVisibleObjects());
|
||||
}
|
||||
|
||||
private static RetainedAdvTextLayoutPresentation Presentation(int capacity)
|
||||
{
|
||||
var presentation = new RetainedAdvTextLayoutPresentation(
|
||||
new AdvTextLayoutPresentationBinding(
|
||||
1, 21, 100, capacity, -1, 10, 20));
|
||||
presentation.Append(
|
||||
[
|
||||
new AdvRetainedGlyphRecord(0, 1, 2, 5, 8),
|
||||
new AdvRetainedGlyphRecord(0, 5, 2, 9, 8),
|
||||
new AdvRetainedGlyphRecord(0, 9, 2, 13, 8),
|
||||
],
|
||||
30,
|
||||
40);
|
||||
return presentation;
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,16 @@ internal class RecordingHost : IHost
|
||||
Lines.Add((run.SourceOffset, run.Text));
|
||||
LiveTextRuns.Add((run, glyphDelayMilliseconds));
|
||||
}
|
||||
public Func<AdvLiveTextRun, AdvRetainedTextRunResult?>? OnRetainedText;
|
||||
public virtual AdvRetainedTextRunResult? ShowText(
|
||||
GfxState gfx,
|
||||
AdvTextLayoutPresentationBinding binding,
|
||||
AdvLiveTextRun run,
|
||||
int glyphDelayMilliseconds)
|
||||
{
|
||||
ShowText(run, glyphDelayMilliseconds);
|
||||
return OnRetainedText?.Invoke(run);
|
||||
}
|
||||
public void SetMessageGlyphDelayMilliseconds(int milliseconds)
|
||||
=> MessageGlyphDelayMilliseconds = milliseconds;
|
||||
public RgbaImage? CaptureSurfacePixels(int slot)
|
||||
|
||||
@@ -64,6 +64,15 @@ public interface IHost
|
||||
void ShowText(int offset, string text);
|
||||
void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds)
|
||||
=> ShowText(run.SourceOffset, run.Text);
|
||||
AdvRetainedTextRunResult? ShowText(
|
||||
GfxState gfx,
|
||||
AdvTextLayoutPresentationBinding binding,
|
||||
AdvLiveTextRun run,
|
||||
int glyphDelayMilliseconds)
|
||||
{
|
||||
ShowText(run, glyphDelayMilliseconds);
|
||||
return null;
|
||||
}
|
||||
int MessageGlyphDelayMilliseconds => 50;
|
||||
void SetMessageGlyphDelayMilliseconds(int milliseconds) { }
|
||||
// Native ADV text subsystem: op 0x7a updates the selected layout's last 20-byte cursor record;
|
||||
@@ -73,6 +82,9 @@ public interface IHost
|
||||
void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style)
|
||||
=> DrawStringToSurface(surfaceSlot, x, y, text);
|
||||
void ClearRenderedAdvTextLayout(int layoutSlot) { }
|
||||
void ResetRenderedAdvTextLayout(
|
||||
GfxState gfx, AdvTextLayoutPresentationBinding binding)
|
||||
=> ClearRenderedAdvTextLayout(binding.LayoutSlot);
|
||||
void RenderTextHistory(AdvTextHistoryRenderBatch batch) { }
|
||||
// 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.
|
||||
@@ -87,10 +99,15 @@ public interface IHost
|
||||
void SetAdvWaitIndicatorEnabled(bool enabled) { }
|
||||
// Op 0x20a republishes one retained ADV text layout and includes the current marker frame when active.
|
||||
void PublishAdvTextLayout(int layoutSlot) { }
|
||||
void PublishAdvTextLayout(
|
||||
GfxState gfx, AdvTextLayoutPresentationBinding binding)
|
||||
=> PublishAdvTextLayout(binding.LayoutSlot);
|
||||
// Op 0x199 temporarily yields the active ADV page into its registered hide-window coroutine.
|
||||
// The retained scene continues to render, but the text layout and its wait marker are suspended
|
||||
// until op 0x7c restores the saved page PC.
|
||||
void SetAdvPagePresentationSuspended(bool suspended) { }
|
||||
void SetAdvPagePresentationSuspended(GfxState gfx, bool suspended)
|
||||
=> SetAdvPagePresentationSuspended(suspended);
|
||||
void WaitForInput();
|
||||
void WaitForInput(int layoutSlot) => WaitForInput();
|
||||
// Interactive hosts service script callbacks on the VM thread while the enclosing ADV page remains
|
||||
|
||||
@@ -30,7 +30,21 @@ public readonly record struct AdvTextLayoutPresentationBinding(
|
||||
int SourceSurfaceSlot,
|
||||
long FirstObjectHandle,
|
||||
long ObjectCapacity,
|
||||
long WaitIndicatorObjectHandle);
|
||||
long WaitIndicatorObjectHandle,
|
||||
int ResetCursorX,
|
||||
int ResetCursorY);
|
||||
|
||||
public readonly record struct AdvRetainedGlyphPlacement(
|
||||
AdvRetainedGlyphRecord Record,
|
||||
int LayoutOriginX,
|
||||
int LayoutOriginY);
|
||||
|
||||
public readonly record struct AdvRetainedTextRunResult(
|
||||
int LayoutSlot,
|
||||
int FirstGlyphIndex,
|
||||
int GlyphCount,
|
||||
int CursorX,
|
||||
int CursorY);
|
||||
|
||||
/// <summary>Platform-neutral rules established from AGE's native retained-glyph workers.</summary>
|
||||
public static class AdvRetainedTextContract
|
||||
|
||||
@@ -201,7 +201,8 @@ public sealed class AdvTextHistory
|
||||
var layout = GetOrCreateLayout(slot);
|
||||
return new AdvTextLayoutPresentationBinding(
|
||||
slot, checked(slot + 0x14), layout.TextObjectRangeFirst,
|
||||
layout.TextObjectRangeCount, layout.WaitIndicatorObjectHandle);
|
||||
layout.TextObjectRangeCount, layout.WaitIndicatorObjectHandle,
|
||||
layout.ResetCursorX, layout.ResetCursorY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
86
engine/Age.Engine/Text/RetainedAdvTextLayoutPresentation.cs
Normal file
86
engine/Age.Engine/Text/RetainedAdvTextLayoutPresentation.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Age.Engine.Model;
|
||||
|
||||
namespace Age.Engine.Text;
|
||||
|
||||
/// <summary>
|
||||
/// Transient live-layout presentation state. History persistence owns neither this list nor its Gfx
|
||||
/// bindings; saved-frame script reconstruction rebuilds both.
|
||||
/// </summary>
|
||||
public sealed class RetainedAdvTextLayoutPresentation
|
||||
{
|
||||
private readonly List<AdvRetainedGlyphPlacement> _glyphs = new();
|
||||
|
||||
public RetainedAdvTextLayoutPresentation(AdvTextLayoutPresentationBinding binding)
|
||||
{
|
||||
if (binding.LayoutSlot <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(binding));
|
||||
if (binding.SourceSurfaceSlot < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(binding));
|
||||
if (binding.FirstObjectHandle < 0 || binding.ObjectCapacity <= 0)
|
||||
throw new ArgumentException(
|
||||
"A retained ADV layout requires a nonnegative first handle and positive capacity.",
|
||||
nameof(binding));
|
||||
Binding = binding;
|
||||
}
|
||||
|
||||
public AdvTextLayoutPresentationBinding Binding { get; }
|
||||
public IReadOnlyList<AdvRetainedGlyphPlacement> Glyphs => _glyphs;
|
||||
public int PublishedGlyphCount { get; private set; }
|
||||
public int PublishableGlyphCount
|
||||
=> (int)Math.Min(_glyphs.Count, Binding.ObjectCapacity);
|
||||
|
||||
public int Append(
|
||||
IReadOnlyList<AdvRetainedGlyphRecord> records,
|
||||
int layoutOriginX,
|
||||
int layoutOriginY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(records);
|
||||
int first = _glyphs.Count;
|
||||
foreach (AdvRetainedGlyphRecord record in records)
|
||||
_glyphs.Add(new AdvRetainedGlyphPlacement(
|
||||
record, layoutOriginX, layoutOriginY));
|
||||
return first;
|
||||
}
|
||||
|
||||
/// <summary>Publish newly revealed glyphs through a layout-global target count.</summary>
|
||||
public int PublishThrough(GfxState gfx, int targetGlyphCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gfx);
|
||||
int target = Math.Clamp(targetGlyphCount, 0, PublishableGlyphCount);
|
||||
for (int index = PublishedGlyphCount; index < target; index++)
|
||||
Bind(gfx, index);
|
||||
PublishedGlyphCount = Math.Max(PublishedGlyphCount, target);
|
||||
return PublishedGlyphCount;
|
||||
}
|
||||
|
||||
/// <summary>Recreate every currently revealed binding after op 0x20a or temporary suspension.</summary>
|
||||
public int Republish(GfxState gfx)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gfx);
|
||||
for (int index = 0; index < PublishedGlyphCount; index++)
|
||||
Bind(gfx, index);
|
||||
return PublishedGlyphCount;
|
||||
}
|
||||
|
||||
public void ErasePublished(GfxState gfx)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gfx);
|
||||
gfx.EraseRange(Binding.FirstObjectHandle, Binding.ObjectCapacity);
|
||||
}
|
||||
|
||||
private void Bind(GfxState gfx, int index)
|
||||
{
|
||||
AdvRetainedGlyphPlacement placement = _glyphs[index];
|
||||
AdvRetainedGlyphRecord record = placement.Record;
|
||||
if (record.Width <= 0 || record.Height <= 0) return;
|
||||
gfx.BindDraw(
|
||||
checked(Binding.FirstObjectHandle + index),
|
||||
Binding.SourceSurfaceSlot,
|
||||
record.Left,
|
||||
record.Top,
|
||||
record.Width,
|
||||
record.Height,
|
||||
checked(placement.LayoutOriginX + record.Left),
|
||||
checked(placement.LayoutOriginY + record.Top));
|
||||
}
|
||||
}
|
||||
@@ -1809,7 +1809,7 @@ public sealed class VirtualMachine
|
||||
{
|
||||
_cur.CoroutineResumePc = pc + 1;
|
||||
_cur.CoroutineYieldActive = true;
|
||||
_host.SetAdvPagePresentationSuspended(true);
|
||||
_host.SetAdvPagePresentationSuspended(Gfx, true);
|
||||
targetOffset = _cur.CoroutineYieldHandlerA;
|
||||
}
|
||||
else targetOffset = _cur.CoroutineYieldHandlerB;
|
||||
@@ -1824,7 +1824,7 @@ public sealed class VirtualMachine
|
||||
{
|
||||
_cur.CoroutineResumePc = null;
|
||||
_cur.CoroutineYieldActive = false;
|
||||
_host.SetAdvPagePresentationSuspended(false);
|
||||
_host.SetAdvPagePresentationSuspended(Gfx, false);
|
||||
return resumePc;
|
||||
}
|
||||
return pc + 1; // cold bounded scene-entry path
|
||||
@@ -1991,7 +1991,13 @@ public sealed class VirtualMachine
|
||||
var liveRun = new AdvLiveTextRun(
|
||||
off, TextHistory.GetLayoutSnapshot(layoutSlot), _advTextStyle, text, scriptStack);
|
||||
TextHistory.AppendText(layoutSlot, off, text, _advTextStyle);
|
||||
_host.ShowText(liveRun, _messageGlyphDelayMilliseconds);
|
||||
AdvTextLayoutPresentationBinding binding =
|
||||
TextHistory.GetPresentationBinding(liveRun.Layout.Slot);
|
||||
AdvRetainedTextRunResult? retained = _host.ShowText(
|
||||
Gfx, binding, liveRun, _messageGlyphDelayMilliseconds);
|
||||
if (retained is { } result)
|
||||
TextHistory.SetCursor(
|
||||
result.LayoutSlot, result.CursorX, result.CursorY);
|
||||
}
|
||||
return pc + 1;
|
||||
case "define-adv-text-layout": // 0x70: configure layout and begin a logical retained group
|
||||
@@ -2003,8 +2009,12 @@ public sealed class VirtualMachine
|
||||
int requestedSlot = (int)Read(a[0]);
|
||||
TextHistory.ResetLayout(requestedSlot);
|
||||
var layout = TextHistory.GetLayoutSnapshot(requestedSlot);
|
||||
AdvTextLayoutPresentationBinding binding =
|
||||
TextHistory.GetPresentationBinding(layout.Slot);
|
||||
if (binding.FirstObjectHandle >= 0 && binding.ObjectCapacity > 0)
|
||||
Gfx.EraseRange(binding.FirstObjectHandle, binding.ObjectCapacity);
|
||||
_host.SetAdvTextCursor(layout.Slot, layout.CursorX, layout.CursorY);
|
||||
_host.ClearRenderedAdvTextLayout(layout.Slot);
|
||||
_host.ResetRenderedAdvTextLayout(Gfx, binding);
|
||||
_cur.ReadMessageOffset = ins.Offset;
|
||||
_sharedProfile.ReadText.CommitPending();
|
||||
RefreshAdvReadSkipState();
|
||||
@@ -2031,8 +2041,13 @@ public sealed class VirtualMachine
|
||||
return pc + 1;
|
||||
case "u00420CE0":
|
||||
case "publish-adv-text-layout": // 0x20a: publish layout and current marker frame if active
|
||||
_host.PublishAdvTextLayout((int)Read(a[0]));
|
||||
{
|
||||
int requestedSlot = (int)Read(a[0]);
|
||||
AdvTextLayoutPresentationBinding binding =
|
||||
TextHistory.GetPresentationBinding(requestedSlot);
|
||||
_host.PublishAdvTextLayout(Gfx, binding);
|
||||
return pc + 1;
|
||||
}
|
||||
case "draw-string": // 0x204 (surface slot, x, y, string)
|
||||
_host.DrawStringToSurface((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), ReadStr(a[3]),
|
||||
_advTextStyle);
|
||||
|
||||
@@ -68,6 +68,7 @@ public sealed class GodotAdvHost : IHost
|
||||
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
|
||||
private readonly CachedGlyphMaskRasterizer? _surfaceTextMaskCache;
|
||||
private readonly ImmediateSurfaceTextRenderer? _surfaceTextPixelRenderer;
|
||||
private readonly RetainedGlyphLayoutEngine? _retainedGlyphLayoutEngine;
|
||||
private readonly GlyphRasterizerBackendInfo? _surfaceTextBackendInfo;
|
||||
private string _surfaceTextFallbackReason;
|
||||
private bool _surfaceTextFallbackWarningReported;
|
||||
@@ -77,9 +78,15 @@ public sealed class GodotAdvHost : IHost
|
||||
public required AdvLiveTextRun Run;
|
||||
public required long StartedMs;
|
||||
public required int GlyphDelayMilliseconds;
|
||||
public bool RetainedPixels;
|
||||
public int FirstGlyphIndex;
|
||||
public int GlyphCount;
|
||||
}
|
||||
private readonly List<LiveTextState> _liveText = new();
|
||||
private readonly Dictionary<int, RetainedAdvTextLayoutPresentation>
|
||||
_retainedTextLayouts = new();
|
||||
private LiveTextState? _activeLiveText;
|
||||
private int _suspendedRetainedTextLayoutSlot;
|
||||
private string _advText = "";
|
||||
private int _advTextX = 100, _advTextY = 47;
|
||||
private int _currentAdvLayout = 1; // SYSTEM4's ordinary SC0000 ADV layout
|
||||
@@ -143,6 +150,8 @@ public sealed class GodotAdvHost : IHost
|
||||
surfaceTextRasterizer, capacity: 2048);
|
||||
_surfaceTextPixelRenderer =
|
||||
new ImmediateSurfaceTextRenderer(_surfaceTextMaskCache);
|
||||
_retainedGlyphLayoutEngine =
|
||||
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
|
||||
}
|
||||
_surfaceTextFallbackReason = surfaceTextFallbackReason
|
||||
?? (surfaceTextRasterizer == null
|
||||
@@ -330,6 +339,205 @@ public sealed class GodotAdvHost : IHost
|
||||
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
|
||||
}
|
||||
|
||||
public AdvRetainedTextRunResult? ShowText(
|
||||
GfxState gfx,
|
||||
AdvTextLayoutPresentationBinding binding,
|
||||
AdvLiveTextRun run,
|
||||
int glyphDelayMilliseconds)
|
||||
{
|
||||
if (_retainedGlyphLayoutEngine == null
|
||||
|| binding.LayoutSlot != run.Layout.Slot
|
||||
|| binding.FirstObjectHandle < 0
|
||||
|| binding.ObjectCapacity <= 0
|
||||
|| !TryPrepareRetainedTextRun(
|
||||
gfx, binding, run,
|
||||
out RetainedAdvTextLayoutPresentation? presentation,
|
||||
out AdvRetainedTextRunResult result))
|
||||
{
|
||||
ShowText(run, glyphDelayMilliseconds);
|
||||
return null;
|
||||
}
|
||||
|
||||
Captured.Add((run.SourceOffset, run.Text));
|
||||
_locator.Text(run.SourceOffset, run.Text);
|
||||
int delay = System.Math.Max(0, glyphDelayMilliseconds);
|
||||
int revealGlyphCount = System.Math.Max(
|
||||
0, System.Math.Min(
|
||||
result.GlyphCount,
|
||||
presentation.PublishableGlyphCount - result.FirstGlyphIndex));
|
||||
var state = new LiveTextState
|
||||
{
|
||||
Run = run,
|
||||
StartedMs = _clock.NowMs,
|
||||
GlyphDelayMilliseconds = delay,
|
||||
RetainedPixels = true,
|
||||
FirstGlyphIndex = result.FirstGlyphIndex,
|
||||
GlyphCount = revealGlyphCount,
|
||||
};
|
||||
lock (_textLock)
|
||||
{
|
||||
_liveText.Add(state);
|
||||
_activeLiveText = state;
|
||||
_advText = run.Text;
|
||||
_advTextX = run.Layout.CursorX;
|
||||
_advTextY = run.Layout.CursorY;
|
||||
_currentAdvLayout = run.Layout.Slot;
|
||||
_advTextStartedMs = state.StartedMs;
|
||||
_activeGlyphDelayMilliseconds = delay;
|
||||
_advTextForceComplete = _messageSkipActive;
|
||||
IsTextRevealing =
|
||||
revealGlyphCount > 0 && delay > 0 && !_messageSkipActive;
|
||||
}
|
||||
_timeline?.State("text-reveal", new()
|
||||
{
|
||||
["offset"] = $"0x{run.SourceOffset:x}",
|
||||
["layout"] = run.Layout.Slot,
|
||||
["x"] = run.Layout.OriginX + run.Layout.CursorX,
|
||||
["y"] = run.Layout.OriginY + run.Layout.CursorY,
|
||||
["glyphs"] = revealGlyphCount,
|
||||
["records"] = result.GlyphCount,
|
||||
["delay_ms"] = delay,
|
||||
["presentation"] = "retained-glyphs",
|
||||
});
|
||||
|
||||
int initiallyVisible = IsTextRevealing ? 1 : revealGlyphCount;
|
||||
presentation.PublishThrough(
|
||||
gfx, checked(result.FirstGlyphIndex + initiallyVisible));
|
||||
Interlocked.Exchange(ref _presentRequested, 1);
|
||||
if (!IsTextRevealing)
|
||||
{
|
||||
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
|
||||
return result;
|
||||
}
|
||||
|
||||
bool scriptSuspended = SuspendScriptForPresentation();
|
||||
try
|
||||
{
|
||||
RequestSynchronizedPresentation();
|
||||
while (IsTextRevealing && !_stopping)
|
||||
{
|
||||
int visible;
|
||||
lock (_textLock)
|
||||
{
|
||||
visible = _advTextForceComplete
|
||||
? revealGlyphCount
|
||||
: (int)System.Math.Clamp(
|
||||
(_clock.NowMs - state.StartedMs) / delay + 1,
|
||||
0, revealGlyphCount);
|
||||
if (visible >= revealGlyphCount) IsTextRevealing = false;
|
||||
}
|
||||
int before = presentation.PublishedGlyphCount;
|
||||
presentation.PublishThrough(
|
||||
gfx, checked(result.FirstGlyphIndex + visible));
|
||||
if (presentation.PublishedGlyphCount != before)
|
||||
{
|
||||
Interlocked.Exchange(ref _presentRequested, 1);
|
||||
RequestSynchronizedPresentation();
|
||||
}
|
||||
if (IsTextRevealing) _frameSignal.WaitOne(50);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ResumeScriptAfterPresentation(scriptSuspended);
|
||||
}
|
||||
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool TryPrepareRetainedTextRun(
|
||||
GfxState gfx,
|
||||
AdvTextLayoutPresentationBinding binding,
|
||||
AdvLiveTextRun run,
|
||||
out RetainedAdvTextLayoutPresentation presentation,
|
||||
out AdvRetainedTextRunResult result)
|
||||
{
|
||||
presentation = null!;
|
||||
result = default;
|
||||
if (run.Layout.Width <= 0 || run.Layout.Height <= 0)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
$"ADV layout {run.Layout.Slot} has invalid dimensions " +
|
||||
$"{run.Layout.Width}x{run.Layout.Height}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
RgbaImage destination = ResolveSurfacePixels(binding.SourceSurfaceSlot)
|
||||
?? new RgbaImage(
|
||||
run.Layout.Width,
|
||||
run.Layout.Height,
|
||||
new byte[checked(run.Layout.Width * run.Layout.Height * 4)]);
|
||||
if (destination.Width != run.Layout.Width
|
||||
|| destination.Height != run.Layout.Height)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
$"ADV layout {run.Layout.Slot} surface {binding.SourceSurfaceSlot} is " +
|
||||
$"{destination.Width}x{destination.Height}; expected " +
|
||||
$"{run.Layout.Width}x{run.Layout.Height}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var updated = new RgbaImage(
|
||||
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
|
||||
GlyphTextLayoutResult rendered;
|
||||
try
|
||||
{
|
||||
IReadOnlyList<GlyphRasterRequest> requests =
|
||||
ImmediateSurfaceTextRenderer.CreateRequests(run.Text, run.Style);
|
||||
rendered = _retainedGlyphLayoutEngine!.Render(
|
||||
updated,
|
||||
new GlyphTextLayoutOptions(
|
||||
run.Layout.CursorX,
|
||||
run.Layout.CursorY,
|
||||
binding.ResetCursorX,
|
||||
run.Layout.Right,
|
||||
run.Layout.Bottom,
|
||||
WrapHorizontally: true,
|
||||
run.Style),
|
||||
requests);
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is ArgumentException
|
||||
or InvalidOperationException
|
||||
or PlatformNotSupportedException
|
||||
or System.ComponentModel.Win32Exception)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
$"{error.GetType().Name}: {error.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_textLock)
|
||||
{
|
||||
if (!_retainedTextLayouts.TryGetValue(
|
||||
binding.LayoutSlot, out presentation!))
|
||||
{
|
||||
presentation = new RetainedAdvTextLayoutPresentation(binding);
|
||||
_retainedTextLayouts.Add(binding.LayoutSlot, presentation);
|
||||
}
|
||||
else if (presentation.Binding != binding)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
$"ADV layout {binding.LayoutSlot} changed its retained binding without reset.");
|
||||
return false;
|
||||
}
|
||||
int first = presentation.Append(
|
||||
rendered.Records, run.Layout.OriginX, run.Layout.OriginY);
|
||||
result = new AdvRetainedTextRunResult(
|
||||
binding.LayoutSlot,
|
||||
first,
|
||||
rendered.ConsumedGlyphs,
|
||||
rendered.CursorX,
|
||||
rendered.CursorY);
|
||||
}
|
||||
|
||||
lock (_imageLock)
|
||||
_surfaceImages[binding.SourceSurfaceSlot] = updated;
|
||||
_slotDims[binding.SourceSurfaceSlot] = (updated.Width, updated.Height);
|
||||
gfx.CreateSurface(binding.SourceSurfaceSlot);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetAdvTextCursor(int layoutSlot, int x, int y)
|
||||
{
|
||||
lock (_textLock)
|
||||
@@ -450,17 +658,17 @@ public sealed class GodotAdvHost : IHost
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
var snapshot = new LiveAdvTextSnapshot[_liveText.Count];
|
||||
for (int i = 0; i < _liveText.Count; i++)
|
||||
var snapshot = new List<LiveAdvTextSnapshot>(_liveText.Count);
|
||||
foreach (LiveTextState state in _liveText)
|
||||
{
|
||||
var state = _liveText[i];
|
||||
if (state.RetainedPixels) continue;
|
||||
bool revealing = ReferenceEquals(state, _activeLiveText) && IsTextRevealing;
|
||||
int visible = !revealing || _advTextForceComplete || state.GlyphDelayMilliseconds == 0
|
||||
? state.Run.Text.Length
|
||||
: (int)System.Math.Clamp(
|
||||
(_clock.NowMs - state.StartedMs) / state.GlyphDelayMilliseconds + 1,
|
||||
0, state.Run.Text.Length);
|
||||
snapshot[i] = new LiveAdvTextSnapshot(state.Run, visible, revealing);
|
||||
snapshot.Add(new LiveAdvTextSnapshot(state.Run, visible, revealing));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@@ -502,6 +710,7 @@ public sealed class GodotAdvHost : IHost
|
||||
{
|
||||
int slot = layoutSlot == 0 ? _currentAdvLayout : layoutSlot;
|
||||
_historyText.Remove(slot);
|
||||
if (_retainedTextLayouts.ContainsKey(slot)) return;
|
||||
_liveText.RemoveAll(state => state.Run.Layout.Slot == slot);
|
||||
if (_activeLiveText?.Run.Layout.Slot == slot)
|
||||
{
|
||||
@@ -513,6 +722,47 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetRenderedAdvTextLayout(
|
||||
GfxState gfx, AdvTextLayoutPresentationBinding binding)
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
_historyText.Remove(binding.LayoutSlot);
|
||||
_retainedTextLayouts.Remove(binding.LayoutSlot);
|
||||
_liveText.RemoveAll(
|
||||
state => state.Run.Layout.Slot == binding.LayoutSlot);
|
||||
if (_activeLiveText?.Run.Layout.Slot == binding.LayoutSlot)
|
||||
{
|
||||
_activeLiveText = null;
|
||||
_advText = "";
|
||||
_advTextForceComplete = false;
|
||||
IsTextRevealing = false;
|
||||
}
|
||||
if (_suspendedRetainedTextLayoutSlot == binding.LayoutSlot)
|
||||
_suspendedRetainedTextLayoutSlot = 0;
|
||||
}
|
||||
|
||||
if (_slotDims.TryGetValue(
|
||||
binding.SourceSurfaceSlot, out var dimensions)
|
||||
&& dimensions.W > 0
|
||||
&& dimensions.H > 0)
|
||||
{
|
||||
lock (_imageLock)
|
||||
_surfaceImages[binding.SourceSurfaceSlot] = new RgbaImage(
|
||||
dimensions.W,
|
||||
dimensions.H,
|
||||
new byte[checked(dimensions.W * dimensions.H * 4)]);
|
||||
}
|
||||
Interlocked.Exchange(ref _presentRequested, 1);
|
||||
_timeline?.Event("adv-text-layout-reset", new()
|
||||
{
|
||||
["layout"] = binding.LayoutSlot,
|
||||
["surface"] = binding.SourceSurfaceSlot,
|
||||
["first_handle"] = binding.FirstObjectHandle,
|
||||
["capacity"] = binding.ObjectCapacity,
|
||||
});
|
||||
}
|
||||
|
||||
public void RenderTextHistory(AdvTextHistoryRenderBatch batch)
|
||||
{
|
||||
lock (_textLock) _historyText[batch.LayoutSlot] = batch;
|
||||
@@ -666,6 +916,17 @@ public sealed class GodotAdvHost : IHost
|
||||
_timeline?.Event("adv-text-layout-publish", new() { ["layout"] = layoutSlot });
|
||||
}
|
||||
|
||||
public void PublishAdvTextLayout(
|
||||
GfxState gfx, AdvTextLayoutPresentationBinding binding)
|
||||
{
|
||||
lock (_textLock)
|
||||
if (_retainedTextLayouts.TryGetValue(
|
||||
binding.LayoutSlot, out RetainedAdvTextLayoutPresentation? presentation)
|
||||
&& presentation.Binding == binding)
|
||||
presentation.Republish(gfx);
|
||||
PublishAdvTextLayout(binding.LayoutSlot);
|
||||
}
|
||||
|
||||
public AdvWaitIndicatorSnapshot? SnapshotAdvWaitIndicator()
|
||||
{
|
||||
if (!IsWaiting || _advPagePresentationSuspended) return null;
|
||||
@@ -894,6 +1155,35 @@ public sealed class GodotAdvHost : IHost
|
||||
_timeline?.State(suspended ? "adv-page-suspended" : "adv-page-restored", new());
|
||||
}
|
||||
|
||||
public void SetAdvPagePresentationSuspended(GfxState gfx, bool suspended)
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
if (suspended)
|
||||
{
|
||||
int slot = IsWaiting && _activeWaitLayout != 0
|
||||
? _activeWaitLayout
|
||||
: _currentAdvLayout;
|
||||
if (_retainedTextLayouts.TryGetValue(
|
||||
slot, out RetainedAdvTextLayoutPresentation? presentation))
|
||||
{
|
||||
presentation.ErasePublished(gfx);
|
||||
_suspendedRetainedTextLayoutSlot = slot;
|
||||
}
|
||||
}
|
||||
else if (_suspendedRetainedTextLayoutSlot != 0)
|
||||
{
|
||||
if (_retainedTextLayouts.TryGetValue(
|
||||
_suspendedRetainedTextLayoutSlot,
|
||||
out RetainedAdvTextLayoutPresentation? presentation))
|
||||
presentation.Republish(gfx);
|
||||
_suspendedRetainedTextLayoutSlot = 0;
|
||||
}
|
||||
}
|
||||
Interlocked.Exchange(ref _presentRequested, 1);
|
||||
SetAdvPagePresentationSuspended(suspended);
|
||||
}
|
||||
|
||||
public void InputCallbackCompleted(GfxState gfx)
|
||||
{
|
||||
// Callback completion itself is not native graphics dirtiness. Any retained writes made by the
|
||||
@@ -1200,7 +1490,9 @@ public sealed class GodotAdvHost : IHost
|
||||
_surfaceResources.Clear();
|
||||
_historyText.Clear();
|
||||
_liveText.Clear();
|
||||
_retainedTextLayouts.Clear();
|
||||
_activeLiveText = null;
|
||||
_suspendedRetainedTextLayoutSlot = 0;
|
||||
_advText = "";
|
||||
_advTextX = 100;
|
||||
_advTextY = 47;
|
||||
@@ -1839,6 +2131,13 @@ public sealed class GodotAdvHost : IHost
|
||||
_surfaceText.Remove(slot);
|
||||
_surfaceResources.Remove(slot);
|
||||
}
|
||||
foreach (int layoutSlot in _retainedTextLayouts
|
||||
.Where(pair =>
|
||||
pair.Value.Binding.SourceSurfaceSlot >= firstSlot
|
||||
&& pair.Value.Binding.SourceSurfaceSlot < end)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
_retainedTextLayouts.Remove(layoutSlot);
|
||||
}
|
||||
foreach (MovieSurfaceBinding binding in stoppedMovies)
|
||||
{
|
||||
|
||||
102
godot/Main.cs
102
godot/Main.cs
@@ -2541,6 +2541,75 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
_host.ReleaseSurface(996);
|
||||
_host.ReleaseSurface(997);
|
||||
bool liveRetainedTextOk;
|
||||
string liveRetainedTextMode;
|
||||
AdvTextLayoutPresentationBinding liveBinding =
|
||||
_vm.TextHistory.GetPresentationBinding(1);
|
||||
if (_host.UsesSurfaceTextPixels)
|
||||
{
|
||||
static bool InLiveRange(
|
||||
RenderObject item, AdvTextLayoutPresentationBinding binding)
|
||||
=> item.Handle >= binding.FirstObjectHandle
|
||||
&& item.Handle - binding.FirstObjectHandle < binding.ObjectCapacity;
|
||||
IReadOnlyList<RenderObject> liveObjects = _vm.Gfx.SnapshotVisibleObjects()
|
||||
.Where(item => InLiveRange(item, liveBinding))
|
||||
.ToArray();
|
||||
RgbaImage? liveSurface =
|
||||
_host.CaptureSurfacePixels(liveBinding.SourceSurfaceSlot);
|
||||
bool builtCompleteLine =
|
||||
liveObjects.Count == "HelloWorldSub".Length
|
||||
&& liveObjects.Select(item => item.Handle)
|
||||
.SequenceEqual(Enumerable.Range(0, liveObjects.Count)
|
||||
.Select(index => liveBinding.FirstObjectHandle + index))
|
||||
&& liveSurface != null
|
||||
&& liveSurface.Pixels.Where((_, index) => index % 4 == 3)
|
||||
.Any(alpha => alpha != 0)
|
||||
&& _host.SnapshotLiveAdvText().Count == 0
|
||||
&& _vm.TextHistory.GetLayoutSnapshot(1).CursorX
|
||||
> liveBinding.ResetCursorX;
|
||||
|
||||
_vm.Gfx.EraseRange(liveBinding.FirstObjectHandle + 1, 1);
|
||||
_host.PublishAdvTextLayout(_vm.Gfx, liveBinding);
|
||||
bool republishedPartialErase =
|
||||
_vm.Gfx.SnapshotVisibleObjects()
|
||||
.Count(item => InLiveRange(item, liveBinding))
|
||||
== liveObjects.Count;
|
||||
|
||||
_host.SetAdvPagePresentationSuspended(_vm.Gfx, true);
|
||||
bool suspended =
|
||||
!_vm.Gfx.SnapshotVisibleObjects()
|
||||
.Any(item => InLiveRange(item, liveBinding));
|
||||
_host.SetAdvPagePresentationSuspended(_vm.Gfx, false);
|
||||
bool restored =
|
||||
_vm.Gfx.SnapshotVisibleObjects()
|
||||
.Count(item => InLiveRange(item, liveBinding))
|
||||
== liveObjects.Count;
|
||||
|
||||
_vm.Gfx.EraseRange(
|
||||
liveBinding.FirstObjectHandle, liveBinding.ObjectCapacity);
|
||||
_host.ResetRenderedAdvTextLayout(_vm.Gfx, liveBinding);
|
||||
RgbaImage? resetSurface =
|
||||
_host.CaptureSurfacePixels(liveBinding.SourceSurfaceSlot);
|
||||
bool reset =
|
||||
resetSurface != null
|
||||
&& resetSurface.Pixels.All(value => value == 0)
|
||||
&& !_vm.Gfx.SnapshotVisibleObjects()
|
||||
.Any(item => InLiveRange(item, liveBinding));
|
||||
liveRetainedTextOk =
|
||||
builtCompleteLine
|
||||
&& republishedPartialErase
|
||||
&& suspended
|
||||
&& restored
|
||||
&& reset;
|
||||
liveRetainedTextMode = "retained-glyphs";
|
||||
}
|
||||
else
|
||||
{
|
||||
liveRetainedTextOk =
|
||||
_host.SnapshotLiveAdvText().Count == 3
|
||||
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
|
||||
liveRetainedTextMode = "label-fallback";
|
||||
}
|
||||
Window rootWindow = GetTree().Root;
|
||||
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
|
||||
&& _screen.GetWidth() == _screenWidth
|
||||
@@ -2580,6 +2649,7 @@ public partial class Main : Godot.Control
|
||||
&& bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk
|
||||
&& bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk
|
||||
&& immediateSurfaceTextOk
|
||||
&& liveRetainedTextOk
|
||||
&& 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); " +
|
||||
@@ -2588,6 +2658,7 @@ public partial class Main : Godot.Control
|
||||
$"bgm-fade-replacement=ok; bgm-start-modes-stop=ok; " +
|
||||
$"text-effect-modes=ok; font-calibration=ok; " +
|
||||
$"immediate-surface-text={immediateSurfaceTextMode}; " +
|
||||
$"live-adv-text={liveRetainedTextMode}; " +
|
||||
$"backbuffer-preservation=ok; " +
|
||||
$"logical-canvas={_screenWidth}x{_screenHeight}; " +
|
||||
$"window-request={_windowOptions.Width}x{_windowOptions.Height}");
|
||||
@@ -2600,6 +2671,7 @@ public partial class Main : Godot.Control
|
||||
$"bgm-stop-release={bgmStopReleaseOk}; " +
|
||||
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " +
|
||||
$"immediate-surface-text={immediateSurfaceTextOk}({immediateSurfaceTextMode}); " +
|
||||
$"live-adv-text={liveRetainedTextOk}({liveRetainedTextMode}); " +
|
||||
$"backbuffer-preservation={backbufferPreservationOk}; " +
|
||||
$"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " +
|
||||
$"window-request={_windowOptions.Width}x{_windowOptions.Height}");
|
||||
@@ -2610,7 +2682,7 @@ public partial class Main : Godot.Control
|
||||
// nested call-script into a synthetic subroutine (exercises call-script handling), shared globals.
|
||||
private static (Script, IScriptProvider) BuildSelfTestScene(OpcodeTable table)
|
||||
{
|
||||
(int, Operand[]) ShowText(int s) => (0x6e, new[] { new Operand(2, s), new Operand(0, 0) });
|
||||
(int, Operand[]) ShowText(int s) => (0x6e, new[] { new Operand(0, 0), new Operand(2, s) });
|
||||
(int, Operand[]) Wait() => (0x72, new[] { new Operand(0, 0) });
|
||||
(int, Operand[]) CallScript(long id) => (0x3, new[] { new Operand(0, id) });
|
||||
(int, Operand[]) MovGG(int d, int s) => (0x55, new[] { new Operand(3, d), new Operand(3, s) });
|
||||
@@ -2620,7 +2692,33 @@ public partial class Main : Godot.Control
|
||||
var callee = ScriptAssembler.Assemble(table, "SUBSCENE",
|
||||
new List<(int, Operand[])> { ShowText(0), MovGI(0x31, 42), Exit() }, new[] { "Sub" });
|
||||
var caller = ScriptAssembler.Assemble(table, "SELFTEST",
|
||||
new List<(int, Operand[])> { ShowText(0), Wait(), ShowText(1), CallScript(5), MovGG(0x30, 0x31), Exit() },
|
||||
new List<(int, Operand[])>
|
||||
{
|
||||
(0x70, new[]
|
||||
{
|
||||
new Operand(0, 1), new Operand(0, 512), new Operand(0, 64),
|
||||
new Operand(0, 0), new Operand(0, 0),
|
||||
}),
|
||||
(0x79, new[]
|
||||
{
|
||||
new Operand(0, 1), new Operand(0, 1), new Operand(0, 1),
|
||||
}),
|
||||
(0x71, new[] { new Operand(0, 1) }),
|
||||
(0x1c1, new[]
|
||||
{
|
||||
new Operand(0, 1), new Operand(0, 511), new Operand(0, 63),
|
||||
}),
|
||||
(0x213, new[]
|
||||
{
|
||||
new Operand(0, 1), new Operand(0, 700000), new Operand(0, 64),
|
||||
}),
|
||||
ShowText(0),
|
||||
Wait(),
|
||||
ShowText(1),
|
||||
CallScript(5),
|
||||
MovGG(0x30, 0x31),
|
||||
Exit(),
|
||||
},
|
||||
new[] { "Hello", "World" });
|
||||
return (caller, new SelfTestProvider(callee));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user