From ac2416e2d0341be6154dba83134aa98bb48d6723 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 27 Jul 2026 13:29:17 -0400 Subject: [PATCH] Fix retained ADV text in menu layouts --- docs/engine-re.md | 15 +++ docs/opcode-reference.md | 36 ++--- engine/Age.Engine.Tests/AdvTextOpsTests.cs | 146 +++++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 9 ++ engine/Age.Engine/Hosting/IHost.cs | 4 + engine/Age.Engine/Model/AdvTextHistory.cs | 33 +++++ engine/Age.Engine/Vm/VirtualMachine.cs | 30 ++++- godot/GodotAdvHost.cs | 119 +++++++++++++++-- godot/Main.cs | 68 ++++++++-- tools/age_opcodes_himegari.py | 2 + vm-map/opcodes.toml | 54 ++++---- 11 files changed, 446 insertions(+), 70 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index dd8ffd7..f18ce33 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2332,6 +2332,21 @@ VM until the 50 ms/glyph service completes or a click forces completion, so the pre-arm the following `wait-for-input`. Local/global string-pointer operand tags (8/14) are now resolved by the VM, which is required for SC0000's `lookup-array local-string-ptr -> draw-string` name path. +The 2026-07-27 retained-layout polish closes the non-dialogue half of this contract. `STUDY.BIN` selects +layout 7, changes its origin for each research row, and calls `MAMES.BIN`; the helper saves the current +glyph delay with op `0x7f`, sets zero with op `0x1b5`, emits two `0x6e/0x6f` lines, and restores the saved +delay. Native therefore publishes all six descriptions immediately and retains them until op `0x71` +resets layout 7, even though op `0x1bb(0)` suppresses History-backlog recording for the menu. The port now +keeps that live presentation stream separate from the persisted History backlog, captures layout/style +and execution-stack ownership per run, implements the delay pair and the font-height-plus-leading line +advance, and draws every retained run rather than replacing a singleton dialogue Label. Raw-input menus +retain runs owned by their own script stack while suppressing unrelated enclosing ADV runs, so STUDY's +MAMES descriptions remain visible without leaking the parent page above HISTORY. Consecutive same-line +literal/global-string runs are joined for presentation, preserving the common +`"literal" + dynamic name + "literal"` ADV idiom. +The same generic path covers `ITMES.BIN`, `SKMES.BIN`, and `INFOMES.BIN`; no STUDY-specific coordinates or +strings live in the runtime. + **Deliberate Phase-A fidelity gap — Label rendering instead of native glyph objects.** The native engine GDI-rasterizes CP932 glyph bitmaps and publishes retained 20-byte records one glyph at a time; the port collapses that representation into Godot `Label` nodes for the ADV body and surface-bound speaker name. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index be7ebe3..9f1afb7 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -5,6 +5,16 @@ ## adv +### 0x6e `show-text` (show-text, argc 2) +- **summary:** (layout_slot)(text) - build and publish a retained text run in the selected ADV layout, using the current glyph delay and raster style; slot zero selects the current layout. +- **grounding:** source=investigation, confidence=high +- **evidence:** Native op 0x6e enters adv_text_build_glyph_records@0x4576c0, which snapshots the selected layout cursor and active font/color state, rasterizes one record per CP932 glyph, advances the live cursor, and publishes records through adv_text_publish_next_glyph@0x451220 using text-manager charDelay. Native SC0000 traces show a 50-ms ordinary delay and retained layout publication; MAMES/ITMES/SKMES/INFOMES set delay zero for immediate UI descriptions. + +### 0x6f `end-text-line` (end-text-line, argc 1) +- **summary:** (layout_slot) - reset the selected layout's horizontal cursor and advance vertically by the primary font height plus configured line spacing. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2 newline paths at 0x456fd0/0x452970 reset x to the layout's configured line origin and advance y by text-manager line spacing at +0x560 minus the negative primary LOGFONT height. The corpus pairs op 0x6f after show-text throughout ADV and retained UI helpers; STUDY's 16-pixel font plus 9-pixel spacing yields the observed 25-pixel MAMES line step. + ### 0x70 `define-adv-text-layout` (define-adv-text-layout, argc 5) - **summary:** (layout_slot)(width)(height)(x)(y) - configure an ADV text layout/surface and, when history recording is enabled, append its logical history index and arm the next retained record as a group start. - **grounding:** source=investigation, confidence=high @@ -63,6 +73,11 @@ Port status (2026-07-24): after the blocking host releases this wait, the VM que - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x7a_handler@0x41eba0 fetches operands 3,2,1 and calls adv_text_set_cursor@0x4530f0 on text manager ctx+0x14940. Slot 0 resolves manager+0x4c8; manager+0x414[slot] selects the layout; text_layout_set_cursor@0x452530 writes x/y to +4/+8 of its last 0x14-byte record. SC0000 0x9d3 computes slot 1, x=75, y=47 before voiced show-text. +### 0x7f `get-message-glyph-delay` (u00414C60, argc 1) +- **summary:** (out) - read the current per-glyph ADV text reveal delay in milliseconds. +- **grounding:** source=investigation, confidence=high +- **evidence:** Complete corpus consistency across all 7 uses in 5 scripts: MAMES, ITMES, SKMES, and INFOMES save this value immediately before op 0x1b5 sets zero for retained UI-description text, then restore it on exit; CONFIG reads it for the message-speed slider, whose paired op 0x1b5 adjustments use 5-ms steps over 5..100. Native ADV traces identify the consumed text-manager field as the per-glyph charDelay (normally 50 ms). + ### 0x85 `clear-text-history` (clear-text-history, argc 0) - **summary:** Clear both the retained ADV text-record vector and its logical layout/message index. - **grounding:** source=investigation, confidence=high @@ -88,6 +103,11 @@ Port status (2026-07-24): after the blocking host releases this wait, the VM que - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1a4_handler@0x41f440 writes operands 1/2 to text-manager+0x564/+0x568. Native raster/bounds paths expand or shift text by these values when an effect mode is active; mode 3 subtracts both. HISTORY.BIN uses (1,1). +### 0x1b5 `set-message-glyph-delay` (u0041B5F0, argc 1) +- **summary:** (milliseconds) - set the per-glyph ADV text reveal delay; zero makes retained UI text publish immediately. +- **grounding:** source=investigation, confidence=high +- **evidence:** Complete corpus consistency across all 11 uses in 5 scripts: CONFIG initializes the value to 50 ms and changes it in 5-ms steps over 5..100; MAMES, ITMES, SKMES, and INFOMES bracket retained show-text descriptions with get op 0x7f, set 0, and restore the saved value. Native ADV traces show ordinary show-text consuming the corresponding charDelay with a 50-ms default. + ### 0x1bb `set-text-history-recording` (set-text-history-recording, argc 1) - **summary:** (enabled) - enable or suppress retained ADV text-history recording. Zero suppresses recording; one enables it. - **grounding:** source=investigation, confidence=high @@ -1239,18 +1259,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting - **summary:** — - **grounding:** source=kelebek, confidence=med -### 0x6e `show-text` (show-text, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - -### 0x6f `end-text-line` (end-text-line, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - -### 0x7f `u00414C60` (u00414C60, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x8c `jmp` (jmp, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=med @@ -1347,10 +1355,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x1b5 `u0041B5F0` (u0041B5F0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x1ba `u0041D850` (u0041D850, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/engine/Age.Engine.Tests/AdvTextOpsTests.cs b/engine/Age.Engine.Tests/AdvTextOpsTests.cs index 443cac9..97d76aa 100644 --- a/engine/Age.Engine.Tests/AdvTextOpsTests.cs +++ b/engine/Age.Engine.Tests/AdvTextOpsTests.cs @@ -33,6 +33,37 @@ public class AdvTextOpsTests Assert.Equal((13, 1, 1, "speaker"), Assert.Single(host.SurfaceStrings)); } + [Fact] + public void ShowTextPublishesDynamicStringOperandsAsAdjacentLiveRuns() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "DYNAMIC_ADV_TEXT", + new List<(int, Operand[])> + { + (0x70, new[] { new Operand(0, 1), new Operand(0, 640), new Operand(0, 160), + new Operand(0, 0), new Operand(0, 430) }), + (0x79, new[] { new Operand(0, 1), new Operand(0, 100), new Operand(0, 47) }), + (0x71, new[] { new Operand(0, 1) }), + (0x1b5, new[] { new Operand(0, 0) }), + (0x6e, new[] { new Operand(0, 0), new Operand(2, 0) }), + (0x6e, new[] { new Operand(0, 0), new Operand(5, 0x279) }), + (0x6e, new[] { new Operand(0, 0), new Operand(2, 1) }), + (0x2, Array.Empty()), + }, new[] { "「", "!」" }); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, table, host); + vm.GlobalStrings[0x279] = "リリィ"; + + vm.Run(); + + Assert.Equal(new[] { "「", "リリィ", "!」" }, + host.LiveTextRuns.Select(emitted => emitted.Run.Text)); + Assert.All(host.LiveTextRuns, + emitted => Assert.Equal((0, 430, 100, 47), + (emitted.Run.Layout.OriginX, emitted.Run.Layout.OriginY, + emitted.Run.Layout.CursorX, emitted.Run.Layout.CursorY))); + } + [Fact] public void LayoutResetRestoresConfiguredCursorAndPreservesConfiguredBounds() { @@ -60,6 +91,121 @@ public class AdvTextOpsTests vm.TextHistory.GetLayoutSnapshot(1)); } + [Fact] + public void SuppressedUiTextPublishesRetainedRunsImmediatelyAndAdvancesLines() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + static Operand I(long value) => new(0, value); + static Operand L(int index) => new(9, index); + static Operand S(int index) => new(2, index); + var script = ScriptAssembler.Assemble(table, "UI_RETAINED_TEXT", + new List<(int, Operand[])> + { + (0x1bb, new[] { I(0) }), + (0x70, new[] { I(7), I(500), I(180), I(0), I(0) }), + (0x79, new[] { I(7), I(53), I(10) }), + (0x71, new[] { I(7) }), + (0x75, new[] { I(16) }), + (0x8b, new[] { I(9) }), + (0x7f, new[] { L(0) }), + (0x1b5, new[] { I(0) }), + (0x198, new[] { I(7), I(275), I(90) }), + (0x6e, new[] { I(0), S(0) }), + (0x6f, new[] { I(0) }), + (0x6e, new[] { I(0), S(1) }), + (0x6f, new[] { I(0) }), + (0x198, new[] { I(7), I(275), I(135) }), + (0x6e, new[] { I(0), S(2) }), + (0x6f, new[] { I(0) }), + (0x6e, new[] { I(0), S(3) }), + (0x6f, new[] { I(0) }), + (0x198, new[] { I(7), I(275), I(180) }), + (0x6e, new[] { I(0), S(4) }), + (0x6f, new[] { I(0) }), + (0x6e, new[] { I(0), S(5) }), + (0x6f, new[] { I(0) }), + (0x1b5, new[] { L(0) }), + (0x1bb, new[] { I(1) }), + (0x2, Array.Empty()), + }, new[] { "row1a", "row1b", "row2a", "row2b", "row3a", "row3b" }); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(50, host.MessageGlyphDelayMilliseconds); + Assert.Empty(vm.TextHistory.Entries); + Assert.Empty(vm.TextHistory.Records); + Assert.All(host.LiveTextRuns, emitted => Assert.Equal(0, emitted.GlyphDelayMilliseconds)); + Assert.Equal( + new[] + { + (275, 90, 53, 10), + (275, 90, 53, 35), + (275, 135, 53, 60), + (275, 135, 53, 85), + (275, 180, 53, 110), + (275, 180, 53, 135), + }, + host.LiveTextRuns.Select(emitted => + (emitted.Run.Layout.OriginX, emitted.Run.Layout.OriginY, + emitted.Run.Layout.CursorX, emitted.Run.Layout.CursorY))); + Assert.Equal(new[] { "row1a", "row1b", "row2a", "row2b", "row3a", "row3b" }, + host.LiveTextRuns.Select(emitted => emitted.Run.Text)); + } + + [Fact] + public void RealMamesResearchDescriptionUsesImmediateRetainedTextPath() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var scripts = Sys4ScriptProvider.Load(table); + var mames = scripts.RequireByName("MAMES.BIN"); + static Operand I(long value) => new(0, value); + var caller = ScriptAssembler.Assemble(table, "MAMES_RESEARCH_CALLER", + new List<(int, Operand[])> + { + (0x1bb, new[] { I(0) }), + (0x70, new[] { I(7), I(500), I(180), I(0), I(0) }), + (0x79, new[] { I(7), I(53), I(10) }), + (0x71, new[] { I(7) }), + (0x75, new[] { I(16) }), + (0x8b, new[] { I(9) }), + (0x198, new[] { I(7), I(275), I(90) }), + (0x3, new[] { I(mames.PackedId) }), + (0x1bb, new[] { I(1) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(caller, table, host, provider: scripts); + vm.Globals[0x1560e7] = 7; + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(50, host.MessageGlyphDelayMilliseconds); + Assert.Empty(vm.TextHistory.Records); + Assert.Collection(host.LiveTextRuns, + first => + { + Assert.Equal("錬成の研究をして知識を高める", first.Run.Text); + Assert.Equal(0, first.GlyphDelayMilliseconds); + Assert.True(first.Run.BelongsToScript("MAMES_RESEARCH_CALLER")); + Assert.True(first.Run.BelongsToScript("MAMES.BIN")); + Assert.Equal((275, 90, 53, 10), + (first.Run.Layout.OriginX, first.Run.Layout.OriginY, + first.Run.Layout.CursorX, first.Run.Layout.CursorY)); + }, + second => + { + Assert.Equal("錬成LVの熟練度が上昇", second.Run.Text); + Assert.Equal(0, second.GlyphDelayMilliseconds); + Assert.Equal((275, 90, 53, 35), + (second.Run.Layout.OriginX, second.Run.Layout.OriginY, + second.Run.Layout.CursorX, second.Run.Layout.CursorY)); + }); + } + [Fact] public void System4BootstrapReplaysAllResetCursorAndBoundsConfigurations() { diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 4d1522a..cb52bbb 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -57,11 +57,20 @@ internal class RecordingHost : IHost public readonly List Warnings = new(); public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); + public readonly List<(AdvLiveTextRun Run, int GlyphDelayMilliseconds)> LiveTextRuns = new(); public readonly Dictionary SurfacePixels = new(); + public int MessageGlyphDelayMilliseconds { get; private set; } = 50; public int CursorClearCount; public int SceneContextResets; public void ReportWarning(string message) => Warnings.Add(message); public void ShowText(int offset, string text) => Lines.Add((offset, text)); + public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds) + { + Lines.Add((run.SourceOffset, run.Text)); + LiveTextRuns.Add((run, glyphDelayMilliseconds)); + } + public void SetMessageGlyphDelayMilliseconds(int milliseconds) + => MessageGlyphDelayMilliseconds = milliseconds; public RgbaImage? CaptureSurfacePixels(int slot) => SurfacePixels.TryGetValue(slot, out var image) ? image : null; public bool ReplaceSurfacePixels(int slot, RgbaImage image) diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 8bef289..03ec362 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -36,6 +36,10 @@ public interface IHost void EnterScriptContext(string scriptName) { } void ExitScriptContext() { } void ShowText(int offset, string text); + void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds) + => ShowText(run.SourceOffset, run.Text); + int MessageGlyphDelayMilliseconds => 50; + void SetMessageGlyphDelayMilliseconds(int milliseconds) { } // Native ADV text subsystem: op 0x7a updates the selected layout's last 20-byte cursor record; // op 0x204 rasterizes a string into a numbered surface before 0x1fb binds that surface. void SetAdvTextCursor(int layoutSlot, int x, int y) { } diff --git a/engine/Age.Engine/Model/AdvTextHistory.cs b/engine/Age.Engine/Model/AdvTextHistory.cs index 6621357..c7c49ea 100644 --- a/engine/Age.Engine/Model/AdvTextHistory.cs +++ b/engine/Age.Engine/Model/AdvTextHistory.cs @@ -44,6 +44,26 @@ public readonly record struct AdvTextLayoutSnapshot( int Right, int Bottom); +/// +/// One live show-text run with the layout and raster state captured when the opcode executes. +/// Unlike , this presentation record exists even while History +/// backlog recording is suppressed. +/// +public sealed record AdvLiveTextRun( + int SourceOffset, + AdvTextLayoutSnapshot Layout, + AdvTextStyle Style, + string Text, + IReadOnlyList ScriptStack) +{ + public bool BelongsToScript(string scriptName) + { + foreach (string frame in ScriptStack) + if (string.Equals(frame, scriptName, StringComparison.OrdinalIgnoreCase)) return true; + return false; + } +} + /// /// One semantic counterpart of AGE's 0x48-byte retained text record. Metadata uses /// plus as value/type; voice uses them as id/argument. @@ -157,6 +177,19 @@ public sealed class AdvTextHistory layout.OriginY = y; } + /// + /// Advance to the next horizontal text line. Native resets x to the layout's configured reset cursor + /// and adds the primary font's pixel height plus the current extra line spacing to y. + /// + public void EndLine(int requestedSlot, AdvTextStyle style) + { + int slot = ResolveLayout(requestedSlot); + var layout = GetOrCreateLayout(slot); + int fontHeight = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24; + layout.CursorX = layout.ResetCursorX; + layout.CursorY += fontHeight + style.LineSpacing; + } + public AdvTextLayoutSnapshot GetLayoutSnapshot(int requestedSlot) => SnapshotLayout(ResolveLayout(requestedSlot)); diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index c2a6ab8..48f14d8 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -82,6 +82,7 @@ public sealed class VirtualMachine private volatile bool _advSkipServiceEnabled; private bool _advReadSkipState; private AdvTextStyle _advTextStyle = AdvTextStyle.Default; + private int _messageGlyphDelayMilliseconds; private readonly Dictionary _valueSwitchTargets = new(StringComparer.Ordinal); // Native EngineCtx owns 11 lazily allocated integer FIFOs at +0x55130. ATSEEK/MVSEEK use // slot zero as their packed-coordinate flood-fill worklist; op 0x132 replaces a slot. @@ -125,6 +126,10 @@ public sealed class VirtualMachine { get { lock (_interactiveLock) return _rawInputFrame != null; } } + public string? RawInputCallbackScriptName + { + get { lock (_interactiveLock) return _rawInputFrame?.Script.Name; } + } public AdvTextHistory TextHistory { get; } /// The currently executing recursive script frame and stack, or null outside VM execution. @@ -151,6 +156,7 @@ public sealed class VirtualMachine _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); _sharedProfile = sharedProfile ?? new SharedProfile(); _nativeDatStore = nativeDatStore; + _messageGlyphDelayMilliseconds = System.Math.Max(0, host.MessageGlyphDelayMilliseconds); _accumulatedPlaySeconds = _sharedProfile.AccumulatedPlaySeconds; _sessionStartTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); } @@ -1808,15 +1814,19 @@ public sealed class VirtualMachine RefreshAdvReadSkipState(); foreach (var o in a) { - if (o.Type != T_STR) continue; - int off = (int)o.Value; + if (!IsStr(o)) continue; + int off = o.Type == T_STR ? (int)o.Value : ins.Offset; _cur.EmitSeen.TryGetValue(off, out var c); c++; _cur.EmitSeen[off] = c; if (c > _o.EmitCap) { HaltReason = $"LOOP:line@0x{off:x}×{c}"; return HALT; } - string text = _cur.Script.GetString(off); + string text = ReadStr(o); Emitted.Add((off, text, _cur.Script.Name)); int layoutSlot = a.Count > 0 ? (int)Read(a[0]) : 0; + string[] scriptStack; + lock (_debugControlLock) scriptStack = _activeFrameNames.ToArray(); + var liveRun = new AdvLiveTextRun( + off, TextHistory.GetLayoutSnapshot(layoutSlot), _advTextStyle, text, scriptStack); TextHistory.AppendText(layoutSlot, off, text, _advTextStyle); - _host.ShowText(off, text); + _host.ShowText(liveRun, _messageGlyphDelayMilliseconds); } return pc + 1; case "define-adv-text-layout": // 0x70: configure layout and begin a logical retained group @@ -2235,10 +2245,20 @@ public sealed class VirtualMachine case "get-message-window-alpha": // 0x131: host profile/config seam; default host currently supplies 0 case "u00415F70": Write(a[0], _host.MessageWindowAlphaSetting); return pc + 1; + case "get-message-glyph-delay": // 0x7f + case "u00414C60": + Write(a[0], _messageGlyphDelayMilliseconds); return pc + 1; + case "set-message-glyph-delay": // 0x1b5 + case "u0041B5F0": + _messageGlyphDelayMilliseconds = System.Math.Max(0, (int)Read(a[0])); + _host.SetMessageGlyphDelayMilliseconds(_messageGlyphDelayMilliseconds); + return pc + 1; case "u00415BF0": case "reset-message-skip-input": // 0x101 clears transient input/run bits, not op 0x88 state return pc + 1; - case "end-text-line": case "set-font": + case "end-text-line": + TextHistory.EndLine((int)Read(a[0]), _advTextStyle); return pc + 1; + case "set-font": case "comment": case "display-furigana": case "dev_ukn": return pc + 1; case "create-texture": // 0x1f8 (slot)(w)(h) — allocate a blank surface at the slot diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index e42f89c..0a5c933 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -51,10 +51,20 @@ public sealed class GodotAdvHost : IHost private readonly object _textLock = new(); private readonly Dictionary> _surfaceText = new(); private readonly Dictionary _historyText = new(); + private sealed class LiveTextState + { + public required AdvLiveTextRun Run; + public required long StartedMs; + public required int GlyphDelayMilliseconds; + } + private readonly List _liveText = new(); + private LiveTextState? _activeLiveText; private string _advText = ""; private int _advTextX = 100, _advTextY = 47; private int _currentAdvLayout = 1; // SYSTEM4's ordinary SC0000 ADV layout private long _advTextStartedMs; + private int _activeGlyphDelayMilliseconds = 50; + private int _messageGlyphDelayMilliseconds = 50; private bool _advTextForceComplete; private readonly Dictionary _waitIndicators = new(); private readonly object _messageSkipLock = new(); @@ -138,22 +148,72 @@ public sealed class GodotAdvHost : IHost _presentationRequestConsumed.WaitOne(50); } + public int MessageGlyphDelayMilliseconds + { + get + { + lock (_textLock) return _messageGlyphDelayMilliseconds; + } + } + + public void SetMessageGlyphDelayMilliseconds(int milliseconds) + { + lock (_textLock) _messageGlyphDelayMilliseconds = System.Math.Max(0, milliseconds); + _timeline?.Event("message-glyph-delay", new() { ["delay_ms"] = milliseconds }); + } + public void ShowText(int offset, string text) { - Captured.Add((offset, text)); - _locator.Text(offset, text); + AdvTextLayoutSnapshot layout; + int delay; lock (_textLock) { - _advText = text; - _advTextStartedMs = _clock.NowMs; + layout = new AdvTextLayoutSnapshot( + _currentAdvLayout, 800, 600, 0, 0, _advTextX, _advTextY, 800, 600); + delay = _messageGlyphDelayMilliseconds; + } + ShowText(new AdvLiveTextRun( + offset, layout, AdvTextStyle.Default, text, Array.Empty()), delay); + } + + public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds) + { + Captured.Add((run.SourceOffset, run.Text)); + _locator.Text(run.SourceOffset, run.Text); + int delay = System.Math.Max(0, glyphDelayMilliseconds); + var state = new LiveTextState + { + Run = run, + StartedMs = _clock.NowMs, + GlyphDelayMilliseconds = delay, + }; + lock (_textLock) + { + _liveText.Add(state); + _activeLiveText = state; + _advText = run.Text; + _advTextX = run.Layout.CursorX; + _advTextY = run.Layout.CursorY; + _currentAdvLayout = run.Layout.Slot; + _advTextStartedMs = state.StartedMs; + _activeGlyphDelayMilliseconds = delay; _advTextForceComplete = _messageSkipActive; - IsTextRevealing = text.Length > 0 && !_messageSkipActive; + IsTextRevealing = run.Text.Length > 0 && delay > 0 && !_messageSkipActive; } _timeline?.State("text-reveal", new() { - ["offset"] = $"0x{offset:x}", ["x"] = _advTextX, ["y"] = _advTextY, - ["glyphs"] = text.Length, ["delay_ms"] = 50, + ["offset"] = $"0x{run.SourceOffset:x}", + ["layout"] = run.Layout.Slot, + ["x"] = run.Layout.OriginX + run.Layout.CursorX, + ["y"] = run.Layout.OriginY + run.Layout.CursorY, + ["glyphs"] = run.Text.Length, ["delay_ms"] = delay, }); + if (!IsTextRevealing) + { + Interlocked.Exchange(ref _presentRequested, 1); + _timeline?.State("running", new() { ["text_reveal_complete"] = true }); + return; + } bool scriptSuspended = SuspendScriptForPresentation(); try { @@ -162,7 +222,8 @@ public sealed class GodotAdvHost : IHost { lock (_textLock) { - if (_advTextForceComplete || _clock.NowMs - _advTextStartedMs >= text.Length * 50L) + if (_advTextForceComplete + || _clock.NowMs - _advTextStartedMs >= run.Text.Length * (long)delay) IsTextRevealing = false; } if (IsTextRevealing) _frameSignal.WaitOne(50); @@ -207,11 +268,33 @@ public sealed class GodotAdvHost : IHost { int visible = _advTextForceComplete || !IsTextRevealing ? _advText.Length - : (int)System.Math.Clamp((_clock.NowMs - _advTextStartedMs) / 50L + 1, 0, _advText.Length); + : (int)System.Math.Clamp( + (_clock.NowMs - _advTextStartedMs) / System.Math.Max(1, _activeGlyphDelayMilliseconds) + 1, + 0, _advText.Length); return (_advText, _advTextX, _advTextY, visible, IsTextRevealing); } } + public IReadOnlyList SnapshotLiveAdvText() + { + lock (_textLock) + { + var snapshot = new LiveAdvTextSnapshot[_liveText.Count]; + for (int i = 0; i < _liveText.Count; i++) + { + var state = _liveText[i]; + 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); + } + return snapshot; + } + } + /// /// Layout that owns the ordinary ADV overlay. Nested callback scripts such as HISTORY can select and /// mutate other layouts while the parent wait remains parked; those transient selections must not move @@ -244,7 +327,19 @@ public sealed class GodotAdvHost : IHost public void ClearRenderedAdvTextLayout(int layoutSlot) { - lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot); + lock (_textLock) + { + int slot = layoutSlot == 0 ? _currentAdvLayout : layoutSlot; + _historyText.Remove(slot); + _liveText.RemoveAll(state => state.Run.Layout.Slot == slot); + if (_activeLiveText?.Run.Layout.Slot == slot) + { + _activeLiveText = null; + _advText = ""; + _advTextForceComplete = false; + IsTextRevealing = false; + } + } } public void RenderTextHistory(AdvTextHistoryRenderBatch batch) @@ -817,6 +912,8 @@ public sealed class GodotAdvHost : IHost _surfaceText.Clear(); _surfaceResources.Clear(); _historyText.Clear(); + _liveText.Clear(); + _activeLiveText = null; _advText = ""; _advTextX = 100; _advTextY = 47; @@ -1558,6 +1655,8 @@ public sealed class GodotAdvHost : IHost } public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style); +public readonly record struct LiveAdvTextSnapshot( + AdvLiveTextRun Run, int VisibleGlyphs, bool Revealing); public sealed record GodotHostDiagnosticSnapshot( string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping, bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended, diff --git a/godot/Main.cs b/godot/Main.cs index 93899af..0162183 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -31,6 +31,7 @@ public partial class Main : Godot.Control private readonly byte[] _screenPixels = new byte[ScreenWidth * ScreenHeight * 4]; private Label _text = null!; private Label _speaker = null!; + private readonly System.Collections.Generic.List