engine: materialize live ADV glyphs

This commit is contained in:
gamer147
2026-07-30 19:04:22 -04:00
parent c2b72cd4b9
commit 1367563485
15 changed files with 758 additions and 27 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -33,7 +33,7 @@ or replaced before claiming portable exports.
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
| 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 ` 明朝`/` ゴシック` 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 ` 明朝`/` ゴシック` 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 |

View File

@@ -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,

View File

@@ -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()
{

View File

@@ -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));
}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View 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));
}
}

View File

@@ -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);

View File

@@ -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)
{

View File

@@ -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));
}