diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index 4f226e6..dfe7502 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -10,6 +10,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x40c` | `sys4ini_count` | `int` | SYS4INI record count | | `0x410` | `archive_name_table` | `void*` | archive-name table base (arc_id*0x100 indexes it) | | `0x414` | `sys4ini_records` | `void*` | SYS4INI 80-byte record base {name[64],arc_id,file_number,offset,size}; record = base + id*0x50 | +| `0x13dc` | `message_skip_enabled` | `int` | persistent all-message Skip flag; op 0x88 writes it and adv_interpreter_tick injects input bit 0x40 while nonzero | | `0x3028` | `alt_pack_table` | `int` | call-script high-byte alternate pack table (unused by corpus) | | `0xb558` | `gfx_dirty_a` | `int` | gfx dirty flag (anim set raises) | | `0xb560` | `gfx_dirty_b` | `int` | gfx dirty flag | @@ -26,6 +27,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x53d60` | `ctx_record_base` | `void*` | 0x78-byte context-record array base (coroutine/script contexts) | | `0x53d64` | `gfx_obj_record_array` | `void*` | gfx object-record array (field[0]=0xffffffff free; cmd-type at rec+0x24) | | `0x53d88` | `cmd_type_table` | `int` | per-object cmd-type column base (write *(0x53d88 + curCtx*0x78)) | +| `0x550fc` | `message_skip_display_enabled` | `int` | persistent all-message Skip state returned by op 0x19a for the ADV control-strip active overlay | | `0x55104` | `auto_message_enabled` | `int` | ADV Auto mode flag; op 0x1b6 reads, op 0x1b7 writes, adv_input_service_poll consumes | | `0x55120` | `anti_tamper_a` | `int` | anti-tamper checksum operand | | `0x55124` | `anti_tamper_b` | `int` | anti-tamper checksum operand | @@ -33,12 +35,16 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x55248` | `ret_stack_a` | `void*` | per-frame return stack (op 0x8f call pushes) | | `0x552e8` | `ret_stack_b` | `void*` | per-frame return stack (companion) | | `0x5f304` | `sleep_timer` | `int` | sleep timer object (op 0xc8; +8 active, +0x14 start-ms, +0x18 duration) | +| `0x6da78` | `message_skip_activation_guard` | `int` | set when op 0x88 enables Skip; suppresses the enabling click from immediately becoming a hotspot activation | +| `0x6da80` | `message_skip_cancel_click_phase` | `int` | press/release phase used only when set:CancelMesSkipOnClick is nonzero | | `0x6da88` | `coroutine_yield_a` | `void*` | op 0x7b yield-state save (op1 -> +ctxidx*4) | | `0x6db28` | `coroutine_yield_b` | `void*` | op 0x7b yield-state save (op2 -> +ctxidx*4) | | `0x6dbc8` | `coroutine_runstate` | `int` | op 0x7c resume gate (run-state bit 0x2000000) | | `0x6dbcc` | `coroutine_resume_off` | `int` | op 0x7c resume PC offset | | `0x6dbd4` | `adv_read_skip_state` | `int` | current ADV read/click-skip service state; queried by op 0x1cc, maintained from ReadTextSkip + per-PC read history | | `0x6dbe4` | `adv_auto_voice_pending` | `int` | set by play-voice ops when voice service exists; Auto waits for voice completion then arms AutoMessageTime0; op 0x1bc resets | +| `0x6dbf4` | `message_skip_queued_voice_id` | `int` | latest voice resource deferred by op 0xc4 while run_state_flags message-skip bit is active | +| `0x6dbf8` | `message_skip_queued_voice_arg` | `int` | second argument retained with message_skip_queued_voice_id; Himegari op 0xc4 stores zero | | `0x9b24c` | `dispatch_table` | `void*` | opcode->handler table base [0x400]; handler(op) = *(0x9b24c + op*4) | | `0xa0cc0` | `screen_w` | `int` | screen width (640) | | `0xa0cc4` | `screen_h` | `int` | screen height (480) | diff --git a/docs/engine-re.md b/docs/engine-re.md index 05da95c..55b6cab 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1156,6 +1156,43 @@ injection. Timer and VM regressions cover both paths, exact deadlines, disable/r fallback, `0x1b8`/`0x1b9`, and the `0xc4`/`0x1bc` voice-state lifecycle. The `/v2` handlers and input poller are named/commented and saved. +### ADV all-message Skip service (2026-07-18) + +The x=728 control enables a persistent interpreter service; it is not a one-page advance and its active +overlay is not the transient run-state bit. `op_0x88_set_message_skip@0x41f130` writes the requested value to +`ctx+0x13dc` (`message_skip_enabled`) and `ctx+0x550fc` (`message_skip_display_enabled`). While the former is +nonzero, `adv_interpreter_tick@0x410fb0` injects input bit `0x40` on every interpreter tick. The input service +turns that into `run_state_flags & 0x08000000`, completing text/input waits and selecting the already-reversed +skip endpoints for retained transitions. + +The state queries are deliberately different: + +- op `0x19a` returns persistent `message_skip_display_enabled` for SO001's active x=728 overlay; +- op `0x1c7` returns the transient skip run-state bit, which may be driven by persistent op-`0x88` state or + a physical fast-forward input such as Ctrl; +- op `0x101` clears transient input/run-state fields after an ADV chrome action, but does not touch either + persistent op-`0x88` field. The following interpreter tick therefore re-arms Skip. + +All 301 ordinary ADV button callbacks call `0x88(1)`. `CALLBACK_LOAD.BIN` contains the corpus's only +`0x88(0)` reset. The engine supports an optional click-cancel state machine, but +`engine_settings_register_defaults@0x46be30` registers `set:CancelMesSkipOnClick=0`; a nonzero loaded engine +configuration is required to enable its press/release cancellation path. The port preserves that default +instead of unconditionally inventing click-to-cancel. + +Voice playback also has a native Skip queue. `op_0xc4_handler@0x420610` plays immediately while the skip bit +is clear. While it is set, the handler replaces `ctx+0x6dbf4/+0x6dbf8` with the latest requested voice instead +of starting it. When Skip/read-skip input ends, `adv_interpreter_tick` starts that latest voice and clears the +queue; skipped voices do not accumulate. + +**Port implementation.** The VM owns persistent op-`0x88` state and keeps op `0x19a` separate from the +combined op-`0x1c7` persistent/host-input query. `GodotAdvHost` completes text reveal and stable message waits +while Skip is active; the existing `0x1c7` transition branches continue to publish their completed endpoints. +Voice requests replace one host-side deferred payload during Skip and the latest payload starts when +`0x88(0)` arrives. Enabling Skip through the real SC0000 hotspot callback wakes the parked wait without +creating a synthetic pointer click. Focused tests cover `0x88` enable/disable, `0x19a`, `0x1c7`, transient +`0x101`, retained host state, and the actual x=728 callback. The new EngineCtx fields are applied to `/v2`; +the affected handlers, interpreter tick, and settings-default initializer are named/commented and saved. + ### ADV retained text — ops `0x7a` / `0x204` and show-text publication (2026-07-10) The SC0000 textbox uses two related native paths under the text manager at `ctx+0x14940`: diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 4f2f213..431f743 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -53,9 +53,9 @@ - **evidence:** Ghidra op 0xc2 handler 0x4204c0 sets run-state 0x200, arms the service timer, and calls bgm_fade_arm@0x464830; bgm_fade_tick@0x464960 interpolates current/target percent and applies volume, releasing at target 0. Native SC0000 trace at 0x7c1/0x126c shows target 0, duration 3000, 1% ticks at about 30 ms. ### 0xc4 `play-voice` (play-voice, argc 1) -- **summary:** Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). Same rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d}). +- **summary:** Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). While all-message Skip is active, retain/replace the queued voice id instead of starting it; playback resumes from the latest queued id after Skip clears. Same resolver rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d}). - **grounding:** source=investigation, confidence=high -- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. +- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it starts the voice immediately; while set it stores the latest id/zero arg at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active. ## control @@ -334,9 +334,9 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b ## input ### 0x88 `set-message-skip` (u0041B290, argc 1) -- **summary:** (enabled) - set the all-message skip state. Nonzero arms ADV fast-forward; zero clears the associated native run-state bit. +- **summary:** (enabled) - set persistent all-message Skip state. Nonzero makes the interpreter inject ADV fast-forward input every tick; zero stops injection and clears the transient skip run-state bit. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x88_set_message_skip@0x41f130 writes operand 1 to ctx+0x13dc and ctx+0x550fc; nonzero also sets ctx+0x6da78=1, while zero clears run-state bit 0x08000000. adv_interpreter_tick injects input bit 0x40 while ctx+0x13dc is nonzero and cancels the state according to set:CancelMesSkipOnClick. SC0000's Message-skip button passes 1. +- **evidence:** Ghidra /v2: op_0x88_set_message_skip@0x41f130 writes operand 1 to ctx+0x13dc and ctx+0x550fc; nonzero also sets ctx+0x6da78=1, while zero clears run-state bit 0x08000000. adv_interpreter_tick injects input bit 0x40 on every tick while ctx+0x13dc is nonzero. engine_settings_register_defaults@0x46be30 initializes set:CancelMesSkipOnClick to 0; only a nonzero override enables its press/release cancellation path. All 301 ADV button callbacks pass 1; CALLBACK_LOAD.BIN is the sole corpus 0x88(0) reset. ### 0x90 `register-hotspot-callbacks` (u0041BEB0, argc 7) - **summary:** (x)(y)(w)(h)(on_enter_pc)(on_leave_pc)(on_activate_pc) - append an inclusive cursor rectangle and three callback PCs to the current script frame's native input registry. This opcode only registers; the ADV input service dispatches callbacks later. @@ -381,9 +381,9 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **evidence:** Ghidra /v2: op_0x97_bind_hotspot_key@0x41ff30 builds the same inclusive rect as op 0x90 and calls input_hotspot_bind_key_bit@0x403f50. That worker searches registered rects for exact equality and stores operand 5 in the record's key-bit array. SC0000 binds bits 0, 8, and 7 to its three 1x1 keyed records. ### 0x101 `reset-message-skip-input` (u00415BF0, argc 0) -- **summary:** Reset transient message-skip/input service state after an ADV chrome action. +- **summary:** Reset transient message-skip/input service state after an ADV chrome action without clearing op 0x88's persistent all-message Skip flag. - **grounding:** source=investigation, confidence=med -- **evidence:** Ghidra /v2: op_0x101_reset_message_skip_input@0x4170a0 resets the input state rooted at ctx+0xa0ce8, clears run-state bit 0x08000000, zeroes ctx+0xa0ce8, and writes ctx+0x6da74=1 / ctx+0x6da80=0. The Auto, Message-skip, Read-skip, and Hide-window click callbacks invoke it after their 100 ms cursor re-arm sequence. +- **evidence:** Ghidra /v2: op_0x101_reset_message_skip_input@0x4170a0 resets the input state rooted at ctx+0xa0ce8, clears run-state bit 0x08000000, zeroes ctx+0xa0ce8, and writes ctx+0x6da74=1 / ctx+0x6da80=0. It does not touch ctx+0x13dc or ctx+0x550fc, so adv_interpreter_tick re-injects Skip on the following tick while persistent state remains enabled. The Auto, Message-skip, Read-skip, and Hide-window callbacks invoke it after their 100 ms cursor re-arm sequence. ### 0x109 `get-cursor-virtual` (u00415EC0, argc 2) - **summary:** (out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 2413123..1e4f52e 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1532,3 +1532,22 @@ warnings, and threaded `SELFTEST OK`. **Next:** manually validate Auto advancing both an unvoiced page and a voiced page, then take the remaining History/all-message-skip/read-message-skip/Hide actions as separate bounded services. + +### ADV all-message Skip implemented (2026-07-18) + +The x=728 action now enables the native persistent Skip service through op `0x88`. The VM distinguishes +persistent button/display state (op `0x19a`) from the live skip query (op `0x1c7`, persistent state OR the +existing host fast-forward channel), and op `0x101` resets only transient input state. Godot completes text +reveal and stable waits while Skip is active; the existing skip-aware transition branches publish their final +endpoints. Voice calls follow the native latest-only queue: requests replace one deferred voice while Skip is +active, and the most recent request starts when op `0x88(0)` clears the service. + +Native `set:CancelMesSkipOnClick` defaults to zero, so this slice deliberately does not turn arbitrary clicks +into a port-only cancel gesture. A future settings-profile implementation can expose the native nonzero +press/release policy without changing the Skip state seam. Regressions exercise the opcode lifecycle and the +real SC0000 x=728 callback. Validation: engine 167/167, zero-warning Godot build, opcode and EngineCtx +generators/tests/lints clean, and threaded `SELFTEST OK`. + +**Next:** manually validate that x=728 fast-forwards text and transitions and that a later `0x88(0)` boundary +returns to normal pacing. Then implement Read-message Skip on the same service seam, adding the required +per-script-offset read-history model rather than treating it as another global all-skip toggle. diff --git a/engine/Age.Engine.Tests/HotspotInputTests.cs b/engine/Age.Engine.Tests/HotspotInputTests.cs index 6ce20c5..a88dfd9 100644 --- a/engine/Age.Engine.Tests/HotspotInputTests.cs +++ b/engine/Age.Engine.Tests/HotspotInputTests.cs @@ -53,6 +53,21 @@ public class HotspotInputTests } } + private sealed class Sc0000MessageSkipHost : RecordingHost + { + public VirtualMachine Vm = null!; + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback, + Func autoWaitState) + { + Vm.UpdatePointer(728, 572); + while (serviceInputCallback()) { } + Assert.True(Vm.TryActivatePointer(728, 572)); + while (serviceInputCallback()) { } + throw new StopAtFirstWaitException(); + } + } + private sealed class AutoStateHost : RecordingHost { public AdvAutoWaitState State; @@ -209,6 +224,51 @@ public class HotspotInputTests Assert.False(resetHost.State.VoicePending); } + [Fact] + public void MessageSkipOpcodes_RetainStateAcrossTransientResetUntilExplicitlyDisabled() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "MESSAGE_SKIP", new List<(int, Operand[])> + { + (0x88, new[] { I(1) }), + (0x19a, new[] { G(0x140) }), + (0x1c7, new[] { G(0x141) }), + (0x101, Array.Empty()), + (0x1c7, new[] { G(0x142) }), + (0x88, new[] { I(0) }), + (0x19a, new[] { G(0x143) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x140)); + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x141)); + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x142)); + Assert.Equal(0, vm.Globals.GetValueOrDefault(0x143)); + Assert.False(vm.MessageSkipEnabled); + Assert.Equal(new[] { true, false }, host.MessageSkipChanges); + } + + [Fact] + public void Sc0000MessageSkipButton_EnablesPersistentServiceState() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); + var host = new Sc0000MessageSkipHost(); + var vm = new VirtualMachine(script, table, host, new VmOptions(MaxSteps: 1_000_000)); + host.Vm = vm; + vm.Globals[0x6c1] = 1; + + Assert.Throws(() => vm.Run()); + + Assert.True(vm.MessageSkipEnabled); + Assert.True(host.MessageSkip); + Assert.Contains(true, host.MessageSkipChanges); + } + private static Operand I(long value) => new(0, value); private static Operand G(long address) => new(3, address); } diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 9ed41b3..8c5d53c 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -24,6 +24,7 @@ internal class RecordingHost : IHost public readonly List SfxReleases = new(); public readonly List<(int Target, long Duration)> BgmFades = new(); public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new(); + public readonly List MessageSkipChanges = new(); public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y)); public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) @@ -42,6 +43,11 @@ internal class RecordingHost : IHost public void Sleep(long duration) => SleptDurations.Add(duration); public void FrameYield() { } public bool IsMessageSkipActive => MessageSkip; + public void SetMessageSkipActive(bool active) + { + MessageSkip = active; + MessageSkipChanges.Add(active); + } public bool IsAdvReadSkipActive => AdvReadSkip; public void PresentFrame(GfxState gfx) { diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 50a92de..fd35aa8 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -36,6 +36,7 @@ public interface IHost void FrameYield(); // Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive // hosts default to normal playback; the Godot host supplies the live interactive values. + void SetMessageSkipActive(bool active) { } bool IsMessageSkipActive => false; bool IsAdvReadSkipActive => false; // Normal playback reaches op 0x21c and parks until a queued 0x223 transition completes. The diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index b6d3191..b0704bc 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -30,6 +30,7 @@ public sealed class VirtualMachine private long _autoMessageTime0Ms = 500; private long _autoMessageTime1Ms = 2000; private bool _autoVoicePending; + private volatile bool _messageSkipEnabled; public long CallScriptDispatches { get; private set; } public Dictionary Globals { get; } = new(); @@ -41,6 +42,7 @@ public sealed class VirtualMachine public string? HaltReason { get; private set; } public long Steps { get; private set; } public bool AutoMessageEnabled => _autoMessageEnabled; + public bool MessageSkipEnabled => _messageSkipEnabled; public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, IScriptProvider? provider = null, ITraceSink? sink = null) @@ -426,8 +428,17 @@ public sealed class VirtualMachine return pc + 1; case "sleep": // 0xc8 (duration) — pause the host duration ms; headless hosts no-op (parity). Frame pacing. _host.Sleep(Read(a[0])); return pc + 1; - case "get-message-skip": // 0x1c7: Ctrl/message fast-forward run-state bit - Write(a[0], _host.IsMessageSkipActive ? 1 : 0); return pc + 1; + case "u0041B290": + case "set-message-skip": // 0x88: persistent all-message fast-forward service state + _messageSkipEnabled = Read(a[0]) != 0; + _host.SetMessageSkipActive(_messageSkipEnabled); + return pc + 1; + case "u00414E50": // 0x19a: persistent state used by the SO001 active overlay + Write(a[0], _messageSkipEnabled ? 1 : 0); return pc + 1; + case "get-message-skip": // 0x1c7: persistent Skip or host-supplied Ctrl fast-forward + // The native per-op tick continually re-arms the transient run-state bit while op 0x88's + // persistent flag is set. The host channel carries the physically held Ctrl/input source. + Write(a[0], _messageSkipEnabled || _host.IsMessageSkipActive ? 1 : 0); return pc + 1; case "get-adv-read-skip-state": // 0x1cc: per-message read/click skip service state case "get-adv-service-state": // compatibility with pre-recovery generated tables Write(a[0], _host.IsAdvReadSkipActive ? 1 : 0); return pc + 1; @@ -449,6 +460,9 @@ public sealed class VirtualMachine case "block-mark": case "reset-message-voice-state": // 0x1bc resets native per-message voice/queued-voice state _autoVoicePending = false; 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 "comment": case "display-furigana": case "dev_ukn": return pc + 1; diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 783284f..b89a81d 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -37,6 +37,8 @@ public sealed class GodotAdvHost : IHost private long _advTextStartedMs; private bool _advTextForceComplete; private readonly Dictionary _waitIndicators = new(); + private volatile bool _messageSkipActive; + private AudioPayload? _queuedSkippedVoice; private int _activeWaitLayout; private long _waitIndicatorStartedMs; private GfxState? _foregroundGfx; @@ -64,8 +66,8 @@ public sealed class GodotAdvHost : IHost { _advText = text; _advTextStartedMs = _clock.NowMs; - _advTextForceComplete = false; - IsTextRevealing = text.Length > 0; + _advTextForceComplete = _messageSkipActive; + IsTextRevealing = text.Length > 0 && !_messageSkipActive; } _timeline?.State("text-reveal", new() { @@ -171,9 +173,15 @@ public sealed class GodotAdvHost : IHost _timeline?.State("input-wait", new() { ["page"] = Pages }); var autoTimer = new AdvAutoAdvanceTimer(); bool autoAdvanced = false; + bool messageSkipped = false; while (!_stopping) { while (serviceInputCallback()) { } + if (_messageSkipActive) + { + messageSkipped = true; + break; + } if (autoTimer.Poll(autoWaitState(), _main.IsVoicePlaybackActive, _clock.NowMs)) { autoAdvanced = true; @@ -184,7 +192,10 @@ public sealed class GodotAdvHost : IHost if (_gate.Wait(0)) break; } IsWaiting = false; - _timeline?.State("running", new() { ["input"] = autoAdvanced ? "auto" : "user" }); + _timeline?.State("running", new() + { + ["input"] = messageSkipped ? "message-skip" : autoAdvanced ? "auto" : "user", + }); lock (_textLock) { _advText = ""; @@ -218,6 +229,25 @@ public sealed class GodotAdvHost : IHost if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); } + public bool IsMessageSkipActive => _messageSkipActive; + + public void SetMessageSkipActive(bool active) + { + _messageSkipActive = active; + _timeline?.State("message-skip", new() { ["enabled"] = active }); + if (active) + { + lock (_textLock) _advTextForceComplete = true; + _frameSignal.Set(); + _inputCallbackSignal.Set(); + return; + } + + var queued = _queuedSkippedVoice; + _queuedSkippedVoice = null; + if (queued != null) DispatchVoice(queued); + } + public void WakeInputCallbackService() => _inputCallbackSignal.Set(); public void InputCallbackCompleted(GfxState gfx) @@ -470,11 +500,21 @@ public sealed class GodotAdvHost : IHost { var asset = _res.Resolve(_scene, id); var audio = asset != null ? LoadAudio(asset) : null; - if (audio != null) + if (audio == null) return; + if (_messageSkipActive) { - int generation = _main.QueueVoicePlayback(); - _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation); + bool firstQueued = _queuedSkippedVoice == null; + _queuedSkippedVoice = audio; + if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip"); + return; } + DispatchVoice(audio); + } + + private void DispatchVoice(AudioPayload audio) + { + int generation = _main.QueueVoicePlayback(); + _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation); } public void LoadSoundEffect(long resourceId, int channel) diff --git a/godot/Main.cs b/godot/Main.cs index 5c4815d..5fbfb02 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -697,6 +697,12 @@ public partial class Main : Godot.Control _voice.Play(); } + public void StopVoiceForMessageSkip() + { + _voice.Stop(); + CompleteVoiceGeneration(System.Threading.Volatile.Read(ref _voiceQueuedGeneration)); + } + private void UpdateVoicePlaybackState() { int started = System.Threading.Volatile.Read(ref _voiceStartedGeneration); diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index a055e04..9be94c9 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -7,7 +7,7 @@ INFERRED: dict[int, dict] = { 0x71: dict(name='label-def', category='structural', noop=True, confidence='high', source='investigation', summary='1 imm; count == T1 table size -> the label/anchor T1 indexes. v1 no-op; revisit if menu/callback dispatch looks up by id'), 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) + gfx cmd-type 5. 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.'), - 0x88: dict(name='set-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - set the all-message skip state. Nonzero arms ADV fast-forward; zero clears the associated native run-state bit.'), + 0x88: dict(name='set-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - set persistent all-message Skip state. Nonzero makes the interpreter inject ADV fast-forward input every tick; zero stops injection and clears the transient skip run-state bit.'), 0x90: dict(name='register-hotspot-callbacks', category='input', noop=True, confidence='high', source='investigation', summary="(x)(y)(w)(h)(on_enter_pc)(on_leave_pc)(on_activate_pc) - append an inclusive cursor rectangle and three callback PCs to the current script frame's native input registry. This opcode only registers; the ADV input service dispatches callbacks later."), 0x93: dict(name='cancel-hotspot-wait', category='input', noop=True, confidence='high', source='investigation', summary="Reset the current frame's hotspot registry/input wait and clear native run-state bit 0x00800000. Used before opening History, Menu, or HIDEWIN flows."), 0x94: dict(name='arm-hotspot-wait', category='input', noop=True, confidence='high', source='investigation', summary='Arm native hotspot input processing after the script has registered its rectangles.'), @@ -17,7 +17,7 @@ INFERRED: dict[int, dict] = { 0xb6: dict(name='sfx-release', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) - stop/destroy the channel decoder and DirectSound buffer, clear its retained resource id, and leave the slot empty. Idempotent for an unused channel.'), 0xc2: dict(name='fade-bgm', category='audio', noop=False, confidence='high', source='investigation', summary='(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion.'), 0xd9: dict(name='clear-run-state-0x1000', category='control', noop=True, confidence='high', source='investigation', summary='Clear native run/service bit 0x1000; if the secondary context is active, clear the same bit there. SC0000 executes it once after the initial SFX-channel reset, with no VM-visible result.'), - 0x101: dict(name='reset-message-skip-input', category='input', noop=False, confidence='med', source='investigation', summary='Reset transient message-skip/input service state after an ADV chrome action.'), + 0x101: dict(name='reset-message-skip-input', category='input', noop=False, confidence='med', source='investigation', summary="Reset transient message-skip/input service state after an ADV chrome action without clearing op 0x88's persistent all-message Skip flag."), 0x109: dict(name='get-cursor-virtual', category='input', noop=True, confidence='high', source='investigation', summary="(out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates."), 0x10a: dict(name='set-cursor-virtual', category='input', noop=True, confidence='high', source='investigation', summary='(x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor.'), 0x140: dict(name='coroutine-label-yield', category='control', noop=False, confidence='med', source='investigation', summary="(out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework."), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 0011ed1..cba9bcb 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -28,6 +28,11 @@ name = "sys4ini_records" type = "void*" note = "SYS4INI 80-byte record base {name[64],arc_id,file_number,offset,size}; record = base + id*0x50" [[field]] +offset = 0x13dc +name = "message_skip_enabled" +type = "int" +note = "persistent all-message Skip flag; op 0x88 writes it and adv_interpreter_tick injects input bit 0x40 while nonzero" +[[field]] offset = 0x3028 name = "alt_pack_table" type = "int" @@ -108,6 +113,11 @@ name = "cmd_type_table" type = "int" note = "per-object cmd-type column base (write *(0x53d88 + curCtx*0x78))" [[field]] +offset = 0x550fc +name = "message_skip_display_enabled" +type = "int" +note = "persistent all-message Skip state returned by op 0x19a for the ADV control-strip active overlay" +[[field]] offset = 0x55104 name = "auto_message_enabled" type = "int" @@ -143,6 +153,16 @@ name = "sleep_timer" type = "int" note = "sleep timer object (op 0xc8; +8 active, +0x14 start-ms, +0x18 duration)" [[field]] +offset = 0x6da78 +name = "message_skip_activation_guard" +type = "int" +note = "set when op 0x88 enables Skip; suppresses the enabling click from immediately becoming a hotspot activation" +[[field]] +offset = 0x6da80 +name = "message_skip_cancel_click_phase" +type = "int" +note = "press/release phase used only when set:CancelMesSkipOnClick is nonzero" +[[field]] offset = 0x6da88 name = "coroutine_yield_a" type = "void*" @@ -173,6 +193,16 @@ name = "adv_auto_voice_pending" type = "int" note = "set by play-voice ops when voice service exists; Auto waits for voice completion then arms AutoMessageTime0; op 0x1bc resets" [[field]] +offset = 0x6dbf4 +name = "message_skip_queued_voice_id" +type = "int" +note = "latest voice resource deferred by op 0xc4 while run_state_flags message-skip bit is active" +[[field]] +offset = 0x6dbf8 +name = "message_skip_queued_voice_arg" +type = "int" +note = "second argument retained with message_skip_queued_voice_id; Himegari op 0xc4 stores zero" +[[field]] offset = 0x9b24c name = "dispatch_table" type = "void*" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 48ebe3d..16e20ce 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1385,12 +1385,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "set-message-skip" category = "input" -summary = "(enabled) - set the all-message skip state. Nonzero arms ADV fast-forward; zero clears the associated native run-state bit." +summary = "(enabled) - set persistent all-message Skip state. Nonzero makes the interpreter inject ADV fast-forward input every tick; zero stops injection and clears the transient skip run-state bit." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0x88_set_message_skip@0x41f130 writes operand 1 to ctx+0x13dc and ctx+0x550fc; nonzero also sets ctx+0x6da78=1, while zero clears run-state bit 0x08000000. adv_interpreter_tick injects input bit 0x40 while ctx+0x13dc is nonzero and cancels the state according to set:CancelMesSkipOnClick. SC0000's Message-skip button passes 1." +evidence = "Ghidra /v2: op_0x88_set_message_skip@0x41f130 writes operand 1 to ctx+0x13dc and ctx+0x550fc; nonzero also sets ctx+0x6da78=1, while zero clears run-state bit 0x08000000. adv_interpreter_tick injects input bit 0x40 on every tick while ctx+0x13dc is nonzero. engine_settings_register_defaults@0x46be30 initializes set:CancelMesSkipOnClick to 0; only a nonzero override enables its press/release cancellation path. All 301 ADV button callbacks pass 1; CALLBACK_LOAD.BIN is the sole corpus 0x88(0) reset." [[opcode.semantics.args]] i = 1 @@ -1945,12 +1945,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "play-voice" category = "audio" -summary = "Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). Same rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d})." +summary = "Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). While all-message Skip is active, retain/replace the queued voice id instead of starting it; playback resumes from the latest queued id after Skip clears. Same resolver rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d})." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug." +evidence = "By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it starts the voice immediately; while set it stores the latest id/zero arg at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active." [[opcode.semantics.args]] i = 1 @@ -2297,12 +2297,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "reset-message-skip-input" category = "input" -summary = "Reset transient message-skip/input service state after an ADV chrome action." +summary = "Reset transient message-skip/input service state after an ADV chrome action without clearing op 0x88's persistent all-message Skip flag." noop_headless = false source = "investigation" confidence = "med" depends_on = [] -evidence = "Ghidra /v2: op_0x101_reset_message_skip_input@0x4170a0 resets the input state rooted at ctx+0xa0ce8, clears run-state bit 0x08000000, zeroes ctx+0xa0ce8, and writes ctx+0x6da74=1 / ctx+0x6da80=0. The Auto, Message-skip, Read-skip, and Hide-window click callbacks invoke it after their 100 ms cursor re-arm sequence." +evidence = "Ghidra /v2: op_0x101_reset_message_skip_input@0x4170a0 resets the input state rooted at ctx+0xa0ce8, clears run-state bit 0x08000000, zeroes ctx+0xa0ce8, and writes ctx+0x6da74=1 / ctx+0x6da80=0. It does not touch ctx+0x13dc or ctx+0x550fc, so adv_interpreter_tick re-injects Skip on the following tick while persistent state remains enabled. The Auto, Message-skip, Read-skip, and Hide-window callbacks invoke it after their 100 ms cursor re-arm sequence." [[opcode]] op = 0x107