Fix retained ADV text in menu layouts

This commit is contained in:
gamer147
2026-07-27 13:29:17 -04:00
parent 70ffb3df55
commit ac2416e2d0
11 changed files with 446 additions and 70 deletions

View File

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

View File

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

View File

@@ -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<Operand>()),
}, 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<Operand>()),
}, 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<Operand>()),
}, Array.Empty<string>());
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()
{

View File

@@ -57,11 +57,20 @@ internal class RecordingHost : IHost
public readonly List<string> Warnings = new();
public readonly List<long> CursorResources = new();
public readonly List<bool> AdvPagePresentationSuspended = new();
public readonly List<(AdvLiveTextRun Run, int GlyphDelayMilliseconds)> LiveTextRuns = new();
public readonly Dictionary<int, RgbaImage> 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)

View File

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

View File

@@ -44,6 +44,26 @@ public readonly record struct AdvTextLayoutSnapshot(
int Right,
int Bottom);
/// <summary>
/// One live show-text run with the layout and raster state captured when the opcode executes.
/// Unlike <see cref="AdvTextHistoryRecord"/>, this presentation record exists even while History
/// backlog recording is suppressed.
/// </summary>
public sealed record AdvLiveTextRun(
int SourceOffset,
AdvTextLayoutSnapshot Layout,
AdvTextStyle Style,
string Text,
IReadOnlyList<string> ScriptStack)
{
public bool BelongsToScript(string scriptName)
{
foreach (string frame in ScriptStack)
if (string.Equals(frame, scriptName, StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
}
/// <summary>
/// One semantic counterpart of AGE's 0x48-byte retained text record. Metadata uses
/// <see cref="Value"/> plus <see cref="AuxValue"/> as value/type; voice uses them as id/argument.
@@ -157,6 +177,19 @@ public sealed class AdvTextHistory
layout.OriginY = y;
}
/// <summary>
/// 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.
/// </summary>
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));

View File

@@ -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<string, int> _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; }
/// <summary>The currently executing recursive script frame and stack, or null outside VM execution.</summary>
@@ -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

View File

@@ -51,10 +51,20 @@ public sealed class GodotAdvHost : IHost
private readonly object _textLock = new();
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
private sealed class LiveTextState
{
public required AdvLiveTextRun Run;
public required long StartedMs;
public required int GlyphDelayMilliseconds;
}
private readonly List<LiveTextState> _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<int, AdvWaitIndicatorConfig> _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<string>()), 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<LiveAdvTextSnapshot> 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;
}
}
/// <summary>
/// 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,

View File

@@ -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<Label> _advTextLabels = new();
private readonly System.Collections.Generic.List<Label> _surfaceTextLabels = new();
private readonly System.Collections.Generic.Dictionary<int, Label> _historyTextLabels = new();
private Font? _presentationRegularFont;
@@ -123,6 +124,7 @@ public partial class Main : Godot.Control
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore };
AddChild(_text);
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_advTextLabels.Add(_text);
_speaker = new Label { MouseFilter = MouseFilterEnum.Ignore, Visible = false };
AddChild(_speaker);
_speaker.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
@@ -1238,17 +1240,49 @@ public partial class Main : Godot.Control
private void UpdateAdvTextPresentation()
{
// Modal callback scripts composite their own full-screen UI while the enclosing ADV wait remains
// parked. The ordinary dialogue Label is a Godot overlay rather than part of the retained surface,
// so hide it while that nested input owner is active or it leaks above HISTORY's background.
_text.Visible = !_host.IsAdvPagePresentationSuspended && !_vm.IsRawInputCallbackActive;
if (!_text.Visible) return;
var t = _host.SnapshotAdvText();
var layout = _vm.TextHistory.GetLayoutSnapshot(_host.AdvPageLayoutSlot);
_text.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
_text.Size = new Vector2(System.Math.Max(1, layout.Right - layout.CursorX),
System.Math.Max(1, layout.Bottom - layout.CursorY));
int count = System.Math.Clamp(t.VisibleGlyphs, 0, t.Text.Length);
_text.Text = count == 0 ? "" : t.Text[..count];
// parked. Live layout text is a Godot overlay rather than part of the retained surface. During a
// raw-input callback, keep runs owned by that callback's script stack (STUDY -> MAMES) while hiding
// enclosing ADV runs that would otherwise leak above a nested screen such as HISTORY.
foreach (var label in _advTextLabels) label.Visible = false;
if (_host.IsAdvPagePresentationSuspended) return;
int labelIndex = 0;
string? rawInputOwner = _vm.RawInputCallbackScriptName;
var snapshots = _host.SnapshotLiveAdvText();
for (int i = 0; i < snapshots.Count; i++)
{
var snapshot = snapshots[i];
var run = snapshot.Run;
if (run.Text.Length == 0 || !ShouldShow(run)) continue;
var visibleText = new System.Text.StringBuilder();
AppendVisible(snapshot);
while (i + 1 < snapshots.Count
&& snapshots[i + 1].Run.Layout == run.Layout
&& snapshots[i + 1].Run.Style == run.Style
&& ShouldShow(snapshots[i + 1].Run))
{
i++;
AppendVisible(snapshots[i]);
}
var label = GetAdvTextLabel(labelIndex++);
var layout = run.Layout;
label.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
label.Size = new Vector2(System.Math.Max(1, layout.Right - layout.CursorX),
System.Math.Max(1, layout.Bottom - layout.CursorY));
label.Text = visibleText.ToString();
ApplyAdvTextStyle(label, run.Style);
label.Visible = true;
void AppendVisible(LiveAdvTextSnapshot item)
{
int count = System.Math.Clamp(item.VisibleGlyphs, 0, item.Run.Text.Length);
if (count != 0) visibleText.Append(item.Run.Text, 0, count);
}
bool ShouldShow(AdvLiveTextRun item)
=> rawInputOwner == null || item.BelongsToScript(rawInputOwner);
}
}
private void UpdateHistoryTextPresentation()
@@ -1278,6 +1312,12 @@ public partial class Main : Godot.Control
return _surfaceTextLabels[index];
}
private Label GetAdvTextLabel(int index)
{
while (_advTextLabels.Count <= index) _advTextLabels.Add(CreateAdvPresentationLabel());
return _advTextLabels[index];
}
private static void ApplySurfaceTextTransform(Label label, Affine2D localToDestination,
int localX, int localY)
{
@@ -1825,7 +1865,11 @@ public partial class Main : Godot.Control
_status.Text = "";
if (_locatorHudVisible) _locatorHud.Text = _locator.CurrentDisplay;
}
public void ClearPage() { _text.Text = ""; _status.Text = ""; }
public void ClearPage()
{
foreach (var label in _advTextLabels) label.Text = "";
_status.Text = "";
}
public void ShowEnd() => _status.Text = "— end —";
// The selftest verifies the GODOT PLUMBING (background thread + semaphore suspend on wait-for-input

View File

@@ -9,6 +9,7 @@ INFERRED: dict[int, dict] = {
0x6c: dict(name='zero-int-range', category='compute', noop=False, confidence='high', source='investigation', summary='(destination)(count) — write logical integer zero to count consecutive VM cells beginning at the resolved destination.'),
0x7b: dict(name='coroutine-save-yield-handlers', category='control', noop=False, confidence='high', source='investigation', summary='(handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index); its generic handler prologue records the 5-dword instruction length. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator).'),
0x7c: dict(name='coroutine-resume', category='control', noop=False, confidence='high', source='investigation', summary='() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework.'),
0x7f: dict(name='get-message-glyph-delay', category='adv', noop=False, confidence='high', source='investigation', summary='(out) - read the current per-glyph ADV text reveal delay in milliseconds.'),
0x80: dict(name='set-default-gfx-object-slot', category='draw', noop=False, confidence='high', source='investigation', summary='(slot) - select the retained graphics-object slot used when op 0x1d9 receives explicit slot zero.'),
0x86: dict(name='set-cursor-resource', category='input', noop=False, confidence='high', source='investigation', summary='(resource_id) - load an indexed cursor asset and install it as the active custom cursor.'),
0x87: dict(name='clear-cursor-resource', category='input', noop=False, confidence='high', source='investigation', summary='Clear the active custom cursor and refresh the OS cursor when the game window is active.'),
@@ -49,6 +50,7 @@ INFERRED: dict[int, dict] = {
0x19c: dict(name='resume-adv-skip-service', category='input', noop=False, confidence='high', source='investigation', summary='() - enable the ADV skip service and recompute active fast-forward from persistent all-message Skip or the live read-skip channel.'),
0x1a6: dict(name='half-byte-string-length', category='compute', noop=False, confidence='high', source='investigation', summary="Write half the resolved string's byte length, using integer truncation."),
0x1a8: dict(name='instruction-marker-noop', category='marker', noop=True, confidence='high', source='investigation', summary="Zero-operand structural marker. The native shared 0xaf/0x1a8 handler only records this instruction's one-dword length and returns."),
0x1b5: dict(name='set-message-glyph-delay', category='adv', noop=False, confidence='high', source='investigation', summary='(milliseconds) - set the per-glyph ADV text reveal delay; zero makes retained UI text publish immediately.'),
0x1b6: dict(name='get-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return whether automatic message advance is enabled.'),
0x1b7: dict(name='set-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - enable or disable automatic message advance.'),
0x1b8: dict(name='get-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(out) - read an Auto-message delay from engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'),

View File

@@ -912,22 +912,22 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "show-text"
category = "unknown"
summary = ""
category = "adv"
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."
noop_headless = false
source = "kelebek"
confidence = "med"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
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."
[[opcode.semantics.args]]
i = 1
role = ""
role = "ADV layout slot; zero selects the current layout"
observed_types = ["imm"]
[[opcode.semantics.args]]
i = 2
role = ""
role = "text source"
observed_types = ["string", "g-str"]
[[opcode]]
@@ -938,17 +938,17 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "end-text-line"
category = "unknown"
summary = ""
category = "adv"
summary = "(layout_slot) - reset the selected layout's horizontal cursor and advance vertically by the primary font height plus configured line spacing."
noop_headless = false
source = "kelebek"
confidence = "med"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
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."
[[opcode.semantics.args]]
i = 1
role = ""
role = "ADV layout slot; zero selects the current layout"
observed_types = ["imm"]
[[opcode]]
@@ -1297,18 +1297,18 @@ argc = 1
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00414C60"
category = "unknown"
summary = ""
name = "get-message-glyph-delay"
category = "adv"
summary = "(out) - read the current per-glyph ADV text reveal delay in milliseconds."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
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)."
[[opcode.semantics.args]]
i = 1
role = ""
role = "output: current per-glyph reveal delay in milliseconds"
observed_types = ["l-int"]
[[opcode]]
@@ -3948,18 +3948,18 @@ argc = 1
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u0041B5F0"
category = "unknown"
summary = ""
name = "set-message-glyph-delay"
category = "adv"
summary = "(milliseconds) - set the per-glyph ADV text reveal delay; zero makes retained UI text publish immediately."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
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."
[[opcode.semantics.args]]
i = 1
role = ""
role = "per-glyph reveal delay in milliseconds"
observed_types = ["imm", "l-int"]
[[opcode]]