From 5ba16505d6a1ede12629fb3819e56fae3e80444d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 19 Jul 2026 21:15:15 -0400 Subject: [PATCH] Implement History timed callback scrolling --- docs/engine-ctx-reference.md | 11 ++++ docs/engine-re.md | 32 ++++++++- docs/opcode-reference.md | 27 ++++---- docs/phase-a-slice-plan.md | 25 +++++++ .../Age.Engine.Tests/TimedCallbackOpsTests.cs | 66 +++++++++++++++++++ engine/Age.Engine/Vm/ExecFrame.cs | 6 ++ engine/Age.Engine/Vm/VirtualMachine.cs | 57 ++++++++++++++++ tools/age_opcodes_himegari.py | 3 + vm-map/engine-ctx.toml | 55 ++++++++++++++++ vm-map/opcodes.toml | 46 ++++++------- 10 files changed, 291 insertions(+), 37 deletions(-) create mode 100644 engine/Age.Engine.Tests/TimedCallbackOpsTests.cs diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index 7010bad..063f5e6 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -36,6 +36,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) | +| `0x5f3ac` | `timed_callback_timer` | `int` | elapsed timer object armed by op 0xd5 and sampled by the timed callback scheduler | +| `0x5f690` | `timed_callback_abort_pc` | `int` | optional local fallback PC supplied to op 0xd5; -1 disables abort redirection | +| `0x5f694` | `timed_callback_entries_vector` | `int` | MSVC vector object base for 16-byte schedule entries; begin/end/capacity pointers follow | +| `0x5f698` | `timed_callback_entries_begin` | `void*` | begin pointer for 16-byte entries {deadline_ms, field_100, primary_pc, catchup_pc} | +| `0x5f69c` | `timed_callback_entries_end` | `void*` | end pointer for the timed callback entry vector | +| `0x5f6a0` | `timed_callback_entries_capacity` | `void*` | capacity-end pointer for the timed callback entry vector | +| `0x5f6a4` | `timed_callback_last_index` | `int` | last appended schedule index; op 0xd3 resets to -1 and op 0xd4 increments per entry | +| `0x5f6a8` | `timed_callback_cursor` | `int` | next schedule entry consumed by timed_callback_sequence_tick; op 0xd3 resets to zero | +| `0x5f6b4` | `timed_callback_script_resource_id` | `uint` | active frame script resource retained by op 0xd5 for local callback dispatch safety | +| `0x5f6b8` | `timed_callback_script_resource_guard` | `uint` | companion current-script identity checked before redirecting to a scheduled callback | | `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) | @@ -51,3 +61,4 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0xa0cc4` | `screen_h` | `int` | screen height (480) | | `0xa0cc8` | `screen_bpp` | `int` | screen bpp (8) | | `0xa0ce4` | `run_state_flags` | `uint` | interpreter run-state flags (bit1 sleeping; 0x8000000 skip/fast-forward) | +| `0xa0ce8` | `timed_callback_abort_signal` | `int` | scheduler-polled abort/input signal cleared when op 0xd5 starts; redirects to abort PC when configured | diff --git a/docs/engine-re.md b/docs/engine-re.md index 7f92dbe..b328f9d 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1416,8 +1416,36 @@ A live replay trace exposed a corrected prerequisite on that path. The clicked S and type at `+0x18`. Thus SC0000's `0x1d2(2,0x11)` means type 2, character/value `0x11`, not the reverse. The port now writes that pair in the native order, allowing HISTORY's type-2 gate to reach voice playback. -History's remaining work is the `0xd3/0xd4/0xd5` smooth-scroll callback scheduler and its small supporting -gaps. None changes backlog ownership or requires choosing a save/profile backend. +#### Timed local-callback scheduler and History smooth scrolling (2026-07-19) + +Ops `0xd3/0xd4/0xd5` are a generic frame-local timed callback sequence, not a History-specific animation +primitive. The native state is a vector of 16-byte entries at `ctx+0x5f694`, with begin/end/capacity at +`+0x5f698/+0x5f69c/+0x5f6a0`; each entry is `{deadline_ms,aux,primary_pc,catchup_pc}`. The playback cursor is +`ctx+0x5f6a8`, the last appended index is `ctx+0x5f6a4`, and op `0xd5` uses the elapsed timer rooted at +`ctx+0x5f3ac`. These names are canonical in `vm-map/engine-ctx.toml` and applied to the `/v2` `EngineCtx`. + +- op `0xd3` clears the vector, resets the cursor to zero, and initializes the last index and optional abort + PC to `-1`; +- op `0xd4(interval,count,primary,catchup)` appends `count` entries, accumulating each deadline relative to + the preceding one and storing the two local callback PCs; +- op `0xd5(abort_pc)` retains the active script identity, starts the timer, sorts the entries, and enables + scheduler run-state bit `0x40` while `cursor < last_index`. The final entry is therefore a non-dispatched + look-ahead sentinel, not another callback. + +`timed_callback_sequence_tick@0x408170` sleeps until an entry is due. If the following entry's deadline is +already behind elapsed time, it dispatches the current entry's catch-up PC; otherwise it dispatches the +primary PC. It validates that the active frame still owns the recorded script, redirects that frame's PC, +and advances the cursor. The optional abort signal at `ctx+0xa0ce8` redirects to the op-`0xd5` fallback PC; +HISTORY passes `0xffffffff`, so that branch is outside its live route. + +HISTORY builds deadlines 10, 20, 30, 40, 50, 51, and 52 ms. The first five advance a five-step scrollbar +interpolation; the catch-up form omits the expensive redraw/present call when the engine is late. The 51 ms +entry is a no-op callback and the 52 ms entry is its look-ahead sentinel. The port keeps the sequence on the +script frame, blocks only at these scheduler service boundaries using the host clock, and resumes op `0xd5` +after each local callback returns. Focused tests cover exact relative deadlines and the late catch-up choice. + +Smooth-scroll scheduling is now implemented without changing History backlog ownership or choosing a +save/profile backend. HISTORY's remaining static gaps are five supporting opcodes across seven instructions. The original dependency order was **Hide Window first** to establish reusable callback/coroutine input, then Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 3834b0a..e1e4a2e 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -233,6 +233,21 @@ This also names the whole call graph statically (build/callscript-names.json). Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_timer_arm @0x44cff0 at ctx+0x5f304 = active flag + start tick + duration) that the engine main loop polls, resuming the script when elapsed. Operand UNIT = MILLISECONDS (start = ms tick source DAT_0056f3d4, timeGetTime/GetTickCount class). duration<10 fast-paths via [0x56f0b8]; all real scene sleeps (100/750/1000) are >=10. The handler also writes gfx cmd-type 3 + runs anti-tamper checks, neither needed host-side. Port equivalent: the Godot host parks the VM thread for duration ms while the presentation compositor continues. Sleep is one proven presentation-capable service boundary; ordinary AE setup runs burst-fast to 0x21c and is not paced per opcode. Headless hosts no-op it (parity). +### 0xd3 `begin-timed-callback-sequence` (u00425960, argc 0) +- **summary:** Clear and initialize the current script frame's timed local-callback sequence. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0xd3_handler@0x41a430 clears the 16-byte-entry vector at ctx+0x5f694, sets last index ctx+0x5f6a4 and abort target ctx+0x5f690 to -1, and resets playback cursor ctx+0x5f6a8 to zero. HISTORY.BIN@0xbf9 begins its seven-event scrollbar interpolation schedule. + +### 0xd4 `append-relative-timed-callbacks` (u004266F0, argc 4) +- **summary:** Append repeated relative deadlines and their on-time/catch-up local callback targets to the current frame's timed sequence. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0xd4_handler@0x428890 loops operand 2 times, accumulates operand 1 onto the preceding deadline, stores operand 3 at entry+8 and operand 4 at entry+0xc, and appends each 16-byte entry. HISTORY.BIN@0xbfa/0xc03/0xc0c produces deadlines 10,20,30,40,50,51,52 ms. + +### 0xd5 `run-timed-callback-sequence` (u004262C0, argc 1) +- **summary:** Start or service the frame-local timed sequence, dispatching scheduled local callbacks through the penultimate entry and selecting the catch-up target when the following deadline is already late. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0xd5_handler@0x421090 retains the current script resource, starts the ctx+0x5f3ac timer, sorts the schedule, and keeps run-state bit 0x40 active only while cursor < last_index, making the final entry a non-dispatched look-ahead sentinel. timed_callback_sequence_tick@0x408170 waits to each deadline, compares the following entry deadline with elapsed time to select entry+8 versus entry+0xc, redirects the frame PC, and advances ctx+0x5f6a8. Operand 1 is an optional abort/fallback PC; HISTORY passes 0xffffffff. + ### 0xd9 `clear-run-state-0x1000` (u00415880, argc 0) - **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. - **grounding:** source=investigation, confidence=high, noop_headless=True @@ -847,18 +862,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0xd3 `u00425960` (u00425960, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0xd4 `u004266F0` (u004266F0, argc 4) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0xd5 `u004262C0` (u004262C0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0xfe `u0041E360` (u0041E360, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index a4d751f..e303fd3 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1849,3 +1849,28 @@ voice request emitted by the original page, clicks the actual visible voiced His request for SC0000 `0x24`/`MAN999.OGG`; this prevents a pre-History voice from producing a false positive. The `/v2` handler and helper comments are corrected and saved. Manual replay of that same retained row now plays audibly, confirming the full click-to-Godot path. + +### ADV History smooth-scroll scheduler implemented (2026-07-19) + +Native RE resolves `0xd3/0xd4/0xd5` as a generic frame-local timed local-callback sequence. Op `0xd3` +initializes it, op `0xd4` appends repeated relative deadlines with separate primary and catch-up targets, +and op `0xd5` starts the elapsed timer and services callbacks through the penultimate entry. The final entry +is a look-ahead sentinel. The scheduler chooses the catch-up target when the next deadline is already late, +which lets HISTORY advance interpolation state without performing every intermediate redraw. + +HISTORY's concrete schedule is 10/20/30/40/50/51/52 ms: five scrollbar interpolation steps, a no-op +terminal callback, and the look-ahead sentinel. The VM now stores this state on the active script frame, +uses the host's existing clock/sleep boundary, redirects into the shipped local callbacks, and resumes the +same `0xd5` after `ret`. Focused regressions cover on-time execution and forced catch-up behavior. + +The canonical opcode and `EngineCtx` sources are updated and regenerated; the `/v2` handlers, vector append +helper, timer helper, and main-loop scheduler are named/commented, the expanded 56-field `EngineCtx` is +reapplied, and the program is saved. HISTORY is now 73/78 distinct opcodes and 847/854 instructions handled +or safe-noop. Its five remaining effectful gaps total seven instructions: `0x10d` and `0x8b` twice each, +plus one each of `0x1ce`, `0x1cb`, and `0x20a`. + +**Next:** investigate the five remaining HISTORY support opcodes together, then select the smallest coherent +implementation slice from their actual responsibilities rather than treating them as one feature by count. + +Validation: all 203 engine tests pass, opcode and EngineCtx tests/lints are clean, vm0 RECOVER passes, the +Godot build has zero warnings, and threaded `SELFTEST OK`. diff --git a/engine/Age.Engine.Tests/TimedCallbackOpsTests.cs b/engine/Age.Engine.Tests/TimedCallbackOpsTests.cs new file mode 100644 index 0000000..93eb5ee --- /dev/null +++ b/engine/Age.Engine.Tests/TimedCallbackOpsTests.cs @@ -0,0 +1,66 @@ +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class TimedCallbackOpsTests +{ + private const int T_IMM = 0, T_GINT = 3; + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand I(long value) => new(T_IMM, value); + private static Operand G(int address) => new(T_GINT, address); + + private sealed class ClockHost(long overshootMs = 0) : RecordingHost + { + private long _now; + public override long InputClockMilliseconds => _now; + + public override void Sleep(long duration) + { + base.Sleep(duration); + _now += duration + overshootMs; + } + } + + private static Script BuildTwoEventSequence() + => ScriptAssembler.Assemble(Table, "TIMED_CALLBACKS", + new List<(int, Operand[])> + { + (0xd3, Array.Empty()), // 0 + (0xd4, new[] { I(10), I(3), I(14), I(22) }), // 1: two events + look-ahead sentinel + (0xd5, new[] { I(-1) }), // 10 + (0x2, Array.Empty()), // 13 + (0x50, new[] { G(0x100), G(0x100), I(1) }), // 14: on-time callback + (0x5, Array.Empty()), // 21: ret + (0x50, new[] { G(0x101), G(0x101), I(1) }), // 22: catch-up callback + (0x5, Array.Empty()), // 29: ret + }, Array.Empty()); + + [Fact] + public void RelativeSequenceRunsEveryPrimaryCallbackAtItsDeadline() + { + var host = new ClockHost(); + var vm = new VirtualMachine(BuildTwoEventSequence(), Table, host); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(2, vm.Globals[0x100]); + Assert.Equal(0, vm.Globals.GetValueOrDefault(0x101)); + Assert.Equal(new long[] { 10, 10 }, host.SleptDurations); + } + + [Fact] + public void SequenceUsesCatchUpTargetWhenTheFollowingDeadlineHasPassed() + { + var host = new ClockHost(overshootMs: 15); + var vm = new VirtualMachine(BuildTwoEventSequence(), Table, host); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(1, vm.Globals[0x100]); + Assert.Equal(1, vm.Globals[0x101]); + Assert.Equal(new long[] { 10 }, host.SleptDurations); + } +} diff --git a/engine/Age.Engine/Vm/ExecFrame.cs b/engine/Age.Engine/Vm/ExecFrame.cs index 7840801..7991041 100644 --- a/engine/Age.Engine/Vm/ExecFrame.cs +++ b/engine/Age.Engine/Vm/ExecFrame.cs @@ -6,6 +6,8 @@ namespace Age.Engine.Vm; /// are shared across frames; everything here is per-call and discarded on return. internal sealed class ExecFrame { + internal readonly record struct TimedCallback(long DeadlineMs, int PrimaryOffset, int CatchUpOffset); + public readonly Script Script; public int Pc; // entry instruction index public readonly Frame Locals = new(); @@ -22,6 +24,10 @@ internal sealed class ExecFrame public int MouseCallbackTarget = -1; // op 0xcc target dword offset public long MouseCallbackIntervalMs; public long MouseCallbackNextAtMs; + public readonly List TimedCallbacks = new(); // ops 0xd3/0xd4/0xd5 + public int TimedCallbackCursor; + public long? TimedCallbackStartedAtMs; + public int TimedCallbackAbortOffset = -1; public readonly HotspotRegistry Hotspots = new(); public ExecFrame(Script script, int pc) { Script = script; Pc = pc; } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 607026f..01ae6c1 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -739,6 +739,63 @@ public sealed class VirtualMachine UpdatePointer((int)Read(a[0]), (int)Read(a[1])); 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 "u00425960": + case "begin-timed-callback-sequence": // 0xd3: clear the frame-local relative schedule + _cur.TimedCallbacks.Clear(); + _cur.TimedCallbackCursor = 0; + _cur.TimedCallbackStartedAtMs = null; + _cur.TimedCallbackAbortOffset = -1; + return pc + 1; + case "u004266F0": + case "append-relative-timed-callbacks": // 0xd4: (interval ms, count, on-time PC, catch-up PC) + { + long intervalMs = Read(a[0]); + int count = System.Math.Max(0, unchecked((int)Read(a[1]))); + int primaryOffset = unchecked((int)Read(a[2])); + int catchUpOffset = unchecked((int)Read(a[3])); + long deadlineMs = _cur.TimedCallbacks.Count == 0 ? 0 : _cur.TimedCallbacks[^1].DeadlineMs; + for (int i = 0; i < count; i++) + { + deadlineMs += intervalMs; + _cur.TimedCallbacks.Add(new ExecFrame.TimedCallback( + deadlineMs, primaryOffset, catchUpOffset)); + } + return pc + 1; + } + case "u004262C0": + case "run-timed-callback-sequence": // 0xd5: dispatch each scheduled local callback, resuming here after ret + { + if (_cur.TimedCallbackStartedAtMs == null) + { + _cur.TimedCallbackStartedAtMs = _host.InputClockMilliseconds; + _cur.TimedCallbackAbortOffset = unchecked((int)Read(a[0])); + } + // Native op 0xd5 stops at last_index rather than count. The final entry is a + // look-ahead sentinel: it supplies the next deadline for the preceding event but + // is not itself dispatched. + if (_cur.TimedCallbackCursor >= _cur.TimedCallbacks.Count - 1) + { + _cur.TimedCallbackStartedAtMs = null; + return pc + 1; + } + + var callback = _cur.TimedCallbacks[_cur.TimedCallbackCursor]; + long elapsedMs = _host.InputClockMilliseconds - _cur.TimedCallbackStartedAtMs.Value; + if (elapsedMs < callback.DeadlineMs) + { + _host.Sleep(callback.DeadlineMs - elapsedMs); + elapsedMs = _host.InputClockMilliseconds - _cur.TimedCallbackStartedAtMs.Value; + } + + bool fellBehind = _cur.TimedCallbackCursor + 1 < _cur.TimedCallbacks.Count + && _cur.TimedCallbacks[_cur.TimedCallbackCursor + 1].DeadlineMs < elapsedMs; + int targetOffset = fellBehind ? callback.CatchUpOffset : callback.PrimaryOffset; + _cur.TimedCallbackCursor++; + if (targetOffset < 0 || !_cur.Script.IndexByOffset.TryGetValue(targetOffset, out int target)) + return pc; + _cur.CallStack.Add(pc); + return target; + } case "u0041B290": case "set-message-skip": // 0x88: persistent all-message fast-forward service state _messageSkipEnabled = Read(a[0]) != 0; diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 48af3d0..080ef68 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -19,6 +19,9 @@ INFERRED: dict[int, dict] = { 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.'), 0xcc: dict(name='register-mouse-callback', category='input', noop=False, confidence='high', source='investigation', summary='(poll_interval_ms)(target_pc) - register a timed per-frame mouse callback in the current script.'), 0xcd: dict(name='dispatch-mouse-callback', category='input', noop=False, confidence='high', source='investigation', summary='Dispatch the registered mouse callback when its polling interval elapses.'), + 0xd3: dict(name='begin-timed-callback-sequence', category='control', noop=False, confidence='high', source='investigation', summary="Clear and initialize the current script frame's timed local-callback sequence."), + 0xd4: dict(name='append-relative-timed-callbacks', category='control', noop=False, confidence='high', source='investigation', summary="Append repeated relative deadlines and their on-time/catch-up local callback targets to the current frame's timed sequence."), + 0xd5: dict(name='run-timed-callback-sequence', category='control', noop=False, confidence='high', source='investigation', summary='Start or service the frame-local timed sequence, dispatching scheduled local callbacks through the penultimate entry and selecting the catch-up target when the following deadline is already late.'), 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.'), 0xfb: dict(name='register-joy-callback', category='input', noop=False, confidence='high', source='investigation', summary='(input_index)(target_pc) - register one of 32 per-frame joy/input callback targets.'), 0xff: dict(name='poll-joy-callback-input', category='input', noop=False, confidence='high', source='investigation', summary='Poll the current joy/input callback bitmask and initialize the per-dispatch scan state.'), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 93734a6..158b1eb 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -158,6 +158,56 @@ name = "sleep_timer" type = "int" note = "sleep timer object (op 0xc8; +8 active, +0x14 start-ms, +0x18 duration)" [[field]] +offset = 0x5f3ac +name = "timed_callback_timer" +type = "int" +note = "elapsed timer object armed by op 0xd5 and sampled by the timed callback scheduler" +[[field]] +offset = 0x5f690 +name = "timed_callback_abort_pc" +type = "int" +note = "optional local fallback PC supplied to op 0xd5; -1 disables abort redirection" +[[field]] +offset = 0x5f694 +name = "timed_callback_entries_vector" +type = "int" +note = "MSVC vector object base for 16-byte schedule entries; begin/end/capacity pointers follow" +[[field]] +offset = 0x5f698 +name = "timed_callback_entries_begin" +type = "void*" +note = "begin pointer for 16-byte entries {deadline_ms, field_100, primary_pc, catchup_pc}" +[[field]] +offset = 0x5f69c +name = "timed_callback_entries_end" +type = "void*" +note = "end pointer for the timed callback entry vector" +[[field]] +offset = 0x5f6a0 +name = "timed_callback_entries_capacity" +type = "void*" +note = "capacity-end pointer for the timed callback entry vector" +[[field]] +offset = 0x5f6a4 +name = "timed_callback_last_index" +type = "int" +note = "last appended schedule index; op 0xd3 resets to -1 and op 0xd4 increments per entry" +[[field]] +offset = 0x5f6a8 +name = "timed_callback_cursor" +type = "int" +note = "next schedule entry consumed by timed_callback_sequence_tick; op 0xd3 resets to zero" +[[field]] +offset = 0x5f6b4 +name = "timed_callback_script_resource_id" +type = "uint" +note = "active frame script resource retained by op 0xd5 for local callback dispatch safety" +[[field]] +offset = 0x5f6b8 +name = "timed_callback_script_resource_guard" +type = "uint" +note = "companion current-script identity checked before redirecting to a scheduled callback" +[[field]] offset = 0x6da78 name = "message_skip_activation_guard" type = "int" @@ -232,3 +282,8 @@ offset = 0xa0ce4 name = "run_state_flags" type = "uint" note = "interpreter run-state flags (bit1 sleeping; 0x8000000 skip/fast-forward)" +[[field]] +offset = 0xa0ce8 +name = "timed_callback_abort_signal" +type = "int" +note = "scheduler-polled abort/input signal cleared when op 0xd5 starts; redirects to abort PC when configured" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 63af5f8..e02eb15 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2128,14 +2128,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00425960" -category = "unknown" -summary = "" +name = "begin-timed-callback-sequence" +category = "control" +summary = "Clear and initialize the current script frame's timed local-callback sequence." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0xd3_handler@0x41a430 clears the 16-byte-entry vector at ctx+0x5f694, sets last index ctx+0x5f6a4 and abort target ctx+0x5f690 to -1, and resets playback cursor ctx+0x5f6a8 to zero. HISTORY.BIN@0xbf9 begins its seven-event scrollbar interpolation schedule." [[opcode]] op = 0xd4 @@ -2144,33 +2144,33 @@ argc = 4 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004266F0" -category = "unknown" -summary = "" +name = "append-relative-timed-callbacks" +category = "control" +summary = "Append repeated relative deadlines and their on-time/catch-up local callback targets to the current frame's timed sequence." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0xd4_handler@0x428890 loops operand 2 times, accumulates operand 1 onto the preceding deadline, stores operand 3 at entry+8 and operand 4 at entry+0xc, and appends each 16-byte entry. HISTORY.BIN@0xbfa/0xc03/0xc0c produces deadlines 10,20,30,40,50,51,52 ms." [[opcode.semantics.args]] i = 1 -role = "" +role = "relative interval (ms)" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "repeat count" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "on-time local callback code target" observed_types = ["imm"] [[opcode.semantics.args]] i = 4 -role = "" +role = "catch-up local callback code target" observed_types = ["imm"] [[opcode]] @@ -2180,18 +2180,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004262C0" -category = "unknown" -summary = "" +name = "run-timed-callback-sequence" +category = "control" +summary = "Start or service the frame-local timed sequence, dispatching scheduled local callbacks through the penultimate entry and selecting the catch-up target when the following deadline is already late." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0xd5_handler@0x421090 retains the current script resource, starts the ctx+0x5f3ac timer, sorts the schedule, and keeps run-state bit 0x40 active only while cursor < last_index, making the final entry a non-dispatched look-ahead sentinel. timed_callback_sequence_tick@0x408170 waits to each deadline, compares the following entry deadline with elapsed time to select entry+8 versus entry+0xc, redirects the frame PC, and advances ctx+0x5f6a8. Operand 1 is an optional abort/fallback PC; HISTORY passes 0xffffffff." [[opcode.semantics.args]] i = 1 -role = "" +role = "abort/fallback local code target (-1 disables)" observed_types = ["imm"] [[opcode]]