fix: align animation pacing and transforms with native

This commit is contained in:
gamer147
2026-07-10 15:41:17 -04:00
parent 29b2dfca27
commit 014d128ccd
16 changed files with 515 additions and 101 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
docs/opcode-reference.md whitespace=trailing-space,space-before-tab,cr-at-eol

View File

@@ -451,6 +451,16 @@ independent matrix channels, not two encodings of one vec3 property.
delay, linearly interpolates current→target for its duration, then commits the target and clears its own timing. delay, linearly interpolates current→target for its duration, then commits the target and clears its own timing.
Neither third component is opacity. Neither third component is opacity.
**Exact composition and 2D reduction (live-validated 2026-07-10).** The consumer starts from identity and
right-multiplies `T(-V18) → scale-current → middle/rotation → translation-current → T(+V18)`;
`matrix4_multiply` at `0x4ee2a4` computes `out = left * right`. AGE uses row vectors. With no
rotation/perspective, the screen projection is therefore exactly
`V18.xy + (point.xy - V18.xy) * scale.xy + translation.xy`. The captured SC0000 handle `0xcbc0`
has base `(0,600)`, anchor `(400,1000)`, and final scale `(5,5)`; native matrix translation
terms are `(-1600,-4000)`, projecting the base point to `(-1600,-1000)`. The port's focused
projection test and transform-aware gfx log reproduce those values. Rotation projection and final D3D
raster/rounding details remain deferred; the axis-aligned anchor/order/projection no longer are provisional.
**Port result (2026-07-10):** `GfxState` now retains separate current/target scale and translation **Port result (2026-07-10):** `GfxState` now retains separate current/target scale and translation
channels with the native shared-start/independent-timing model. Godot scales around `V18` (anchor), channels with the native shared-start/independent-timing model. Godot scales around `V18` (anchor),
applies translation independently, and never derives opacity from transform Z. The shared `AnimTarget` applies translation independently, and never derives opacity from transform Z. The shared `AnimTarget`
@@ -483,6 +493,28 @@ both annotated) and grepping the SC0000 opening settles the animation model and
Op `0x234` is the independent rotation cycle above. Op `0x238` still configures the separate Op `0x234` is the independent rotation cycle above. Op `0x238` still configures the separate
`ctx+0x51b78/+0x51b7c` animation service used by its own family. `ctx+0x51b78/+0x51b7c` animation service used by its own family.
##### `label_1235a` animation-section boundary (2026-07-10)
The section helper computes the maximum configured duration and arms it with `0x238`, then reads
message-skip through `0x1c7` and ADV service state through `0x1cc`. Normal playback (both zero)
executes `0x243` (reset the separate global animation-service clock) followed by `0x20c` present;
the skip/service branch executes `0x21c` (set run-state bit `0x400`). Both branches finish with
`0x224`, which clears the native gfx command queue at `ctx+0x418`. These handlers are now named,
commented, and saved in the Ghidra image.
None of `0x21c/0x224/0x243` waits for the per-object transform duration. The normal-path boundary is
the engine's rate-limited one-op interpreter cadence plus continuous retained compositing. This matters because
the earlier Frida probe hooked `vm_operand_fetch`: its ~1,788/s result counts **operand reads**, commonly
several per completed opcode. Feeding that number to the port's per-completed-opcode `FrameYield` made
the `0xcbc0` section reach only scale 1.44 in 226 ms before teardown.
The corrected host limiter is refresh-independent and runs at 200 completed opcodes/s. It resets accumulated
credit after sleep/input parking, and clicks are accepted only while actually waiting, so clicks during a
visible animation cannot pre-arm the next wait. A normal-clock replay retained `0xcbc0` for 1,798 ms at
the intermediate 215/s calibration; the final 200/s replay kept it alive for 2,014 virtual ms, beyond the native
1,890 ms endpoint. `--speed` scales VM, sleep, and animation clocks together for comparison without
changing these virtual-time relationships or auto-advancing waits.
##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction) ##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction)
A working note in the animation slice mis-called the SC0000 opening a set of "immediate-mode slot-0 blits." That A working note in the animation slice mis-called the SC0000 opening a set of "immediate-mode slot-0 blits." That
@@ -612,10 +644,11 @@ Findings:
full ~119-method table) fires ~**1,908/sec** with **no vsync**; `BeginScene`/`EndScene` never fire → a **2D full ~119-method table) fires ~**1,908/sec** with **no vsync**; `BeginScene`/`EndScene` never fire → a **2D
StretchRect-style compositor**, not a 3D scene. So there is **no fixed display-frame rate**; `Present` rate StretchRect-style compositor**, not a 3D scene. So there is **no fixed display-frame rate**; `Present` rate
≈ op rate (~1 op per present). ⇒ the pacing quantity is the **wall-clock op rate**, not a per-frame budget. ≈ op rate (~1 op per present). ⇒ the pacing quantity is the **wall-clock op rate**, not a per-frame budget.
- **Implication for the port:** throttle our VM to ~**1,800 ops/sec** wall-clock (≈30 ops per 60 fps Godot - **Corrected implication for the port (2026-07-10):** 1,788/s is an operand-fetch rate, not an opcode rate.
`_Process`, tunable), ~4× under a future Ctrl multiplier; Godot's 60 fps compositor + wall-clock tweens then `IHost.FrameYield` runs once per completed opcode, so matching those quantities directly overclocked the
show the smoothly-advancing state. This *measured* mechanism replaces the earlier present-driven guess port by roughly ninefold. The native SC0000 transform lifetime pins the corresponding completed-op cadence
(present is rare in our path and uncapped natively). at about **200/s**. The limiter must be wall-clock based (not a per-render callback budget), with a future
ADV Ctrl multiplier applied to the same unified clock.
### The render drift's SECOND half: missing system-boot state (2026-07-07, resolved) ### The render drift's SECOND half: missing system-boot state (2026-07-07, resolved)

View File

@@ -79,6 +79,16 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t
- **grounding:** source=investigation, confidence=med - **grounding:** source=investigation, confidence=med
- **evidence:** Ghidra: handler 0x4299c0 (dispatch ctx[0x9b74c]=0x4299c0; created+typed EngineCtx*+annotated; Kelebek u0041F9C0 = VA-drift). Writes gfx cmd-type 9; op2→local_204, op3→local_104, op4→local_208; (*DAT_005c6018)(8, ctx[0x54fe8], &local_210) → FUN_00425fb0(1,ret). DAT_005c6018: 6 xrefs all READ, no static writer; FUN_00405740 (screen-fade) calls it w/ cmd 3, branches on ret 1/2 = transition progress = native video service. - **evidence:** Ghidra: handler 0x4299c0 (dispatch ctx[0x9b74c]=0x4299c0; created+typed EngineCtx*+annotated; Kelebek u0041F9C0 = VA-drift). Writes gfx cmd-type 9; op2→local_204, op3→local_104, op4→local_208; (*DAT_005c6018)(8, ctx[0x54fe8], &local_210) → FUN_00425fb0(1,ret). DAT_005c6018: 6 xrefs all READ, no static writer; FUN_00405740 (screen-fade) calls it w/ cmd 3, branches on ret 1/2 = transition progress = native video service.
### 0x1cc `get-adv-service-state` (get-adv-service-state, argc 1)
- **summary:** (out) - copy native ADV service state ctx+0x6dbd4; label_1235a ORs it with message-skip to select its yield branch.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Exact service-state producer remains outside this opcode.
### 0x21c `mark-frame-yield` (mark-frame-yield, argc 0)
- **summary:** Set native run-state bit 0x400. Host-implicit: the port already offers a scheduler yield after every completed opcode.
- **grounding:** source=investigation, confidence=high, noop_headless=True
- **evidence:** Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a reaches it only when op 0x1c7 or 0x1cc is nonzero.
## draw ## draw
### 0x1a2 `gfx-cmd-register` (gfx-cmd-register, argc 1) ### 0x1a2 `gfx-cmd-register` (gfx-cmd-register, argc 1)
@@ -188,6 +198,11 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra 0x47ecc0 calls matrix builder 0x48afb1 for target obj+0x1ac. Consumer 0x472f00 uses delay obj+0x44, duration obj+0x58, current obj+0x16c, target obj+0x1ac, shared start obj+0x34, and frame-time ctx+0xb550. - **evidence:** Ghidra 0x47ecc0 calls matrix builder 0x48afb1 for target obj+0x1ac. Consumer 0x472f00 uses delay obj+0x44, duration obj+0x58, current obj+0x16c, target obj+0x1ac, shared start obj+0x34, and frame-time ctx+0xb550.
### 0x224 `clear-gfx-command-queue` (clear-gfx-command-queue, argc 0)
- **summary:** Clear the native gfx command queue rooted at ctx+0x418. Host-implicit because the port composites retained state directly.
- **grounding:** source=investigation, confidence=high, noop_headless=True
- **evidence:** Ghidra handler 0x417550 -> gfx_command_queue_clear 0x47cb10, which destroys queued nodes and restores the sentinel links/count.
### 0x228 `u00421940` (u00421940, argc 5) ### 0x228 `u00421940` (u00421940, argc 5)
- **summary:** 0x228 query-position (succ)(handle)(outX)(outY)(outZ): read the object's current computed position into vars (worker FUN_0047cdd0). C# VM: writes V24 + success flag. See docs/engine-re.md §SC0000 anim cluster. - **summary:** 0x228 query-position (succ)(handle)(outX)(outY)(outZ): read the object's current computed position into vars (worker FUN_0047cdd0). C# VM: writes V24 + success flag. See docs/engine-re.md §SC0000 anim cluster.
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
@@ -225,6 +240,11 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r
- **summary:** 0x23f query-object (out)(handle): return object status (FUN_0042a520; -1 if none). C# VM: 0 if the object exists else -1. See docs/engine-re.md §SC0000 anim cluster. - **summary:** 0x23f query-object (out)(handle): return object status (FUN_0042a520; -1 if none). C# VM: 0 if the object exists else -1. See docs/engine-re.md §SC0000 anim cluster.
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x243 `reset-anim-clock` (reset-anim-clock, argc 0)
- **summary:** Reset the native global animation-service elapsed and duration fields to zero when service flag bit 1 is clear.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x4182d0: if !(ctx+0x51b80 & 2), set ctx+0x51b70=1 and zero ctx+0x51b78/+0x51b7c. Normal SC0000 label_1235a calls it before present-frame.
## input ## input
### 0x90 `hotspot-branch` (u0041BEB0, argc 7) ### 0x90 `hotspot-branch` (u0041BEB0, argc 7)
@@ -253,6 +273,11 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **depends on:** 0x90 - **depends on:** 0x90
- **evidence:** interleaves with 0x90 in the shared ADV-chrome subroutine; trailing imm = action id 0x0/0x7/0x8; same widget cluster as 0x90/0x91/0x92/0x95; confirm via frida - **evidence:** interleaves with 0x90 in the shared ADV-chrome subroutine; trailing imm = action id 0x0/0x7/0x8; same widget cluster as 0x90/0x91/0x92/0x95; confirm via frida
### 0x1c7 `get-message-skip` (get-message-skip, argc 1)
- **summary:** (out) - write 1 iff ADV message-skip run-state bit 0x08000000 is set, otherwise 0.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). SC0000 label_1235a ORs it with op 0x1cc.
## marker ## marker
### 0x1bc `block-mark` (u00415670, argc 0) ### 0x1bc `block-mark` (u00415670, argc 0)
@@ -882,10 +907,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x1c7 `u00414F90` (u00414F90, argc 1)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x1c8 `toString` (toString, argc 2) ### 0x1c8 `toString` (toString, argc 2)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=med - **grounding:** source=kelebek, confidence=med
@@ -898,10 +919,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x1cc `u00415010` (u00415010, argc 1)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x1ce `u0041B9F0` (u0041B9F0, argc 1) ### 0x1ce `u0041B9F0` (u0041B9F0, argc 1)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
@@ -970,10 +987,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x21c `u00416270` (u00416270, argc 0)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x21d `u00421410` (u00421410, argc 2) ### 0x21d `u00421410` (u00421410, argc 2)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
@@ -990,10 +1003,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x224 `u00416290` (u00416290, argc 0)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x22a `u00421A90` (u00421A90, argc 3) ### 0x22a `u00421A90` (u00421A90, argc 3)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
@@ -1042,10 +1051,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x243 `u00417070` (u00417070, argc 0)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x248 `u00422E80` (u00422E80, argc 1) ### 0x248 `u00422E80` (u00422E80, argc 1)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low

View File

@@ -715,3 +715,25 @@ geometry in the capture. It does **not** prove the port's exact matrix calculati
multiplication order, or 2D projection: normal playback still races past these sections too quickly for a multiplication order, or 2D projection: normal playback still races past these sections too quickly for a
reliable visual judgment. Treat that math as provisional until the pacing slice enables slow normal playback reliable visual judgment. Treat that math as provisional until the pacing slice enables slow normal playback
and a native-versus-port frame comparison. and a native-versus-port frame comparison.
### A2b — animation pacing + matrix validation ✅ (2026-07-10)
The remaining race was a unit mismatch at the scheduler boundary. Native
`adv_interpreter_tick` advances one opcode, while the live cadence probe counted calls to
`vm_operand_fetch` (about 1,788 operand reads/s). The port's `FrameYield` runs once per completed
opcode, so using 1,800 there overclocked script teardown by roughly ninefold. `FrameClock` now supplies a
refresh-independent 200 completed-opcode/s allowance; sleep and input waits discard parked-time credit, and
input is ignored unless the VM is actually at `wait-for-input`. `--speed` scales the unified VM,
sleep, and animation clock for inspection without `--shot-sequence` auto-advance.
Live native capture recorded the complete `0xcbc0` scale ramp (1→5 over 1,890 ms), including the exact
composed matrices. The port previously deleted the object at scale 1.44 after 226 ms. At 215/s calibration it
survived 1,798 ms to scale 4.72; the final 200/s replay retained it for 2,014 virtual ms, past the endpoint.
Native matrix terms and the port's focused tests agree on row-vector
`anchor + (point-anchor)*scale + translation`; for base `(0,600)`, anchor `(400,1000)`,
scale 5, both project to `(-1600,-1000)`. Exact axis-aligned anchor/order/projection is validated;
cyclic-rotation rasterization remains the next affine-rendering slice.
Verification: engine **92/92** after the opcode-clock reset test, corpus sweep unchanged at
**284 exit / 13 STEP-LIMIT**, Godot build and threaded `SELFTEST OK`. The transform capture tool and
transform-aware `--gfx-log` are documented in `docs/tools-reference.md`.

View File

@@ -135,13 +135,15 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
- `--shot <png> [--shot-page N]` — capture page N to a PNG then quit (dev screenshot). At scene end it also prints the call-scripts executed as nested frames. - `--shot <png> [--shot-page N]` — capture page N to a PNG then quit (dev screenshot). At scene end it also prints the call-scripts executed as nested frames.
- `--shot-sequence <dir> [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies **time-based (sleep-paced) effects** — e.g. the opening `AE*` burst — as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --boot --shot-sequence out/seq --frames 300`. - `--shot-sequence <dir> [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies **time-based (sleep-paced) effects** — e.g. the opening `AE*` burst — as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --boot --shot-sequence out/seq --frames 300`.
- `--sleep-scale <f>` — multiply every `sleep` (op 0xc8) duration by `f` (default 1.0). The authentic opening burst is only ~2 s, too fast to eyeball live; `--sleep-scale 5` stretches it to ~10 s so the paced sequence (arcane `AE*` → character CGs → settled BG) is watchable. Debug-only; leave at 1.0 for real playback. - `--sleep-scale <f>` — multiply every `sleep` (op 0xc8) duration by `f` (default 1.0). The authentic opening burst is only ~2 s, too fast to eyeball live; `--sleep-scale 5` stretches it to ~10 s so the paced sequence (arcane `AE*` → character CGs → settled BG) is watchable. Debug-only; leave at 1.0 for real playback.
- `--speed <f>` — scale the unified runtime clock (VM cadence, sleeps, and retained animation) without auto-advancing input waits. Values 0.058 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback.
- `--gfx-log <file>`**compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page. - `--gfx-log <file>`**compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page.
Matrix-channel outcomes also include sampled `scale`/`trans` values. Parent directories are created automatically. Matrix-channel outcomes also include `base`, `anchor`, projected `dst`, and sampled `scale`/`trans` values. Parent directories are created automatically.
## Asset resolution / graphics ## Asset resolution / graphics
| Tool | Purpose | Run | Reads → Writes | | Tool | Purpose | Run | Reads → Writes |
|---|---|---|---| |---|---|---|---|
| `tools/frida/capture_native_transforms.py` | Capture the native retained-object transform input fields and the exact composed 4×4 matrix at `gfx_object_apply_transform_channels`. Optional handle filter; read-only. | `py -3.11 -u -X utf8 tools/frida/capture_native_transforms.py [secs] [pid|AGE.EXE] [--handle 0xHANDLE]` | running game → `build/native-transform-trace.jsonl` |
| `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). Each entry carries `raw_index` (its 0-based position in the SYS4INI record table incl. `@` placeholders) = the engine's universal file id. Also emits the **`call-script <id> → name`** map (id = `raw_index`; see `engine-re.md`). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN``build/asset-index.json` + `build/callscript-names.json` | | `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). Each entry carries `raw_index` (its 0-based position in the SYS4INI record table incl. `@` placeholders) = the engine's universal file id. Also emits the **`call-script <id> → name`** map (id = `raw_index`; see `engine-re.md`). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN``build/asset-index.json` + `build/callscript-names.json` |
| `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py <SCENE> [resId]` | `build/asset-index.json``build/asset-sections.json`; resolves any (scene, resId) | | `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py <SCENE> [resId]` | `build/asset-index.json``build/asset-sections.json`; resolves any (scene, resId) |
| `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json``build/frida-asset-loads.json` | | `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json``build/frida-asset-loads.json` |
@@ -161,7 +163,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
| `tools/frida/map_imports.py` (+ `map_imports_full.py`) | ★ **Name dynamically-resolved Win32 APIs** in the Ghidra image. Read-only: maps live-process module exports → `{addr→dll!Func}`, scans the `0x400000` module for pointer matches → `RVA→name` (ASLR-stable). `--recon` = clustering report (the gate); default writes the map. Applied to `/v2` via a `run_script_inline` pass → 248 `imp_<dll>_<func>` labels at the RVA `0x16f000` IAT (validated: CreateFileA/SetFilePointer/timeGetTime). Pure scan/cluster logic unit-tested (`test_map_imports.py`). | `py -3.11 -u -X utf8 tools/frida/map_imports.py [--recon]` | running game → `build/import-map.json` (+ `-singletons.json`) | | `tools/frida/map_imports.py` (+ `map_imports_full.py`) | ★ **Name dynamically-resolved Win32 APIs** in the Ghidra image. Read-only: maps live-process module exports → `{addr→dll!Func}`, scans the `0x400000` module for pointer matches → `RVA→name` (ASLR-stable). `--recon` = clustering report (the gate); default writes the map. Applied to `/v2` via a `run_script_inline` pass → 248 `imp_<dll>_<func>` labels at the RVA `0x16f000` IAT (validated: CreateFileA/SetFilePointer/timeGetTime). Pure scan/cluster logic unit-tested (`test_map_imports.py`). | `py -3.11 -u -X utf8 tools/frida/map_imports.py [--recon]` | running game → `build/import-map.json` (+ `-singletons.json`) |
| `tools/frida/probe_handlers.py` | Probe which region the interpreter executes from (module vs heap). Confirmed: **operand-fetch `+0x1b940` fires ~8500/s ⇒ interpreter runs from the module `0x400000`** (handlers hookable by dump address). | `py -3.11 -u -X utf8 tools/frida/probe_handlers.py [pid]` | running game → stdout (per-hook fire counts) | | `tools/frida/probe_handlers.py` | Probe which region the interpreter executes from (module vs heap). Confirmed: **operand-fetch `+0x1b940` fires ~8500/s ⇒ interpreter runs from the module `0x400000`** (handlers hookable by dump address). | `py -3.11 -u -X utf8 tools/frida/probe_handlers.py [pid]` | running game → stdout (per-hook fire counts) |
| `tools/frida/capture_gfx_objects.py` | Capture the native gfx object-manager state: grab engine ctx (`esi` via operand-fetch `ecx`), poll the object-record array `[esi+0x53d64]` (20×120B; `field[0]=0xffffffff`=free, cmd-type at rec+0x24). **⚠ Its "0 CG records ⇒ drift is state-divergence" reading was DISPROVEN** (Ghidra: op 0x215 read settles the drift as a native command-buffer op — `docs/engine-re.md`; the poll observed the record array, not the lookup map that drives the branch, and cmd-buffer records are transient). Kept as a runtime-observation tool. | `py -3.11 -u -X utf8 tools/frida/capture_gfx_objects.py [pid] [secs]` | running game → `build/gfx-objects.jsonl` | | `tools/frida/capture_gfx_objects.py` | Capture the native gfx object-manager state: grab engine ctx (`esi` via operand-fetch `ecx`), poll the object-record array `[esi+0x53d64]` (20×120B; `field[0]=0xffffffff`=free, cmd-type at rec+0x24). **⚠ Its "0 CG records ⇒ drift is state-divergence" reading was DISPROVEN** (Ghidra: op 0x215 read settles the drift as a native command-buffer op — `docs/engine-re.md`; the poll observed the record array, not the lookup map that drives the branch, and cmd-buffer records are transient). Kept as a runtime-observation tool. | `py -3.11 -u -X utf8 tools/frida/capture_gfx_objects.py [pid] [secs]` | running game → `build/gfx-objects.jsonl` |
| `tools/frida/probe_frame_cadence.py` | **Frame-cadence probe** (`docs/engine-re.md` "Frame cadence — live measurement"): plain-JS hook on operand-fetch `0x41b940` (grab ctx + count exec rate) + system-DLL message/timing hooks; auto-buckets by Ctrl/skip-bit. Measured: exec **rate-limited** ~1788 ops/sec normal, ~4× fast-forward. **Read-only/import-only — never CModule-hook the hot interpreter (crashes the game).** Play actively during capture; hold Ctrl the back half. | `py -3.11 -u -X utf8 tools/frida/probe_frame_cadence.py [secs] [proc]` | running game → `build/frida-frame-cadence.jsonl` + stdout report | | `tools/frida/probe_frame_cadence.py` | **Frame-cadence probe** (`docs/engine-re.md` "Frame cadence — live measurement"): plain-JS hook on operand-fetch `0x41b940` (grab ctx + count operand reads) + system-DLL message/timing hooks; auto-buckets by Ctrl/skip-bit. Measured ~1,788 **operand fetches/sec** normal, ~4× fast-forward; this is not an opcode count. **Read-only/import-only — never CModule-hook the hot interpreter (crashes the game).** Play actively during capture; hold Ctrl the back half. | `py -3.11 -u -X utf8 tools/frida/probe_frame_cadence.py [secs] [proc]` | running game → `build/frida-frame-cadence.jsonl` + stdout report |
| `tools/frida/probe_present.py` | **Present-rate probe:** grab ctx, scan it for the D3D9 device (d3d9-vtable object with a full ~119-method table), hook `IDirect3DDevice9::Present`/`EndScene` (+ GDI-blit fallback). Found: **D3D9, UNCAPPED** (`Present` ~1908/sec, no vsync; no `ddraw`; 2D StretchRect compositor) ⇒ no fixed frame rate. Click 23× at start to grab ctx. | `py -3.11 -u -X utf8 tools/frida/probe_present.py [secs]` | running game → `build/frida-present.jsonl` + stdout report | | `tools/frida/probe_present.py` | **Present-rate probe:** grab ctx, scan it for the D3D9 device (d3d9-vtable object with a full ~119-method table), hook `IDirect3DDevice9::Present`/`EndScene` (+ GDI-blit fallback). Found: **D3D9, UNCAPPED** (`Present` ~1908/sec, no vsync; no `ddraw`; 2D StretchRect compositor) ⇒ no fixed frame rate. Click 23× at start to grab ctx. | `py -3.11 -u -X utf8 tools/frida/probe_present.py [secs]` | running game → `build/frida-present.jsonl` + stdout report |
| `tools/frida/trace_engine_ops.py` | **Engine op-path tracer** for the differential oracle (`docs/engine-re.md` "Differential offset-path oracle"): per executed op, read `cur_ctx_index@0x53d14`/`frame_pc@0x53d2c`/`frame_codebase@0x53d28` → emit `(codebase, offset=(pccodebase)/4)`. **Use `--hook operand` (0x41b940, proven-safe)**`--hook tick` (0x410fb0) sees `ecx≠ctx` (0 entries). Writes `build/tracer-live.flag` when the hook is installed → launch in the background, gate the New-Game trigger on the flag (else the scene-entry burst is missed). | `py -3.11 -u -X utf8 tools/frida/trace_engine_ops.py [--hook operand\|tick] [secs]` | running game → `build/engine-optrace.jsonl` | | `tools/frida/trace_engine_ops.py` | **Engine op-path tracer** for the differential oracle (`docs/engine-re.md` "Differential offset-path oracle"): per executed op, read `cur_ctx_index@0x53d14`/`frame_pc@0x53d2c`/`frame_codebase@0x53d28` → emit `(codebase, offset=(pccodebase)/4)`. **Use `--hook operand` (0x41b940, proven-safe)**`--hook tick` (0x410fb0) sees `ecx≠ctx` (0 entries). Writes `build/tracer-live.flag` when the hook is installed → launch in the background, gate the New-Game trigger on the flag (else the scene-entry burst is missed). | `py -3.11 -u -X utf8 tools/frida/trace_engine_ops.py [--hook operand\|tick] [secs]` | running game → `build/engine-optrace.jsonl` |
| `tools/frida/capture_global_writes.py` | **Scene-entry state capture** → auto-seed for single-scene runs (`docs/engine-re.md` "Scene-entry state snapshot"). Hooks `vm_operand_write@0x425fb0` and logs `(codebase, index, PLAINTEXT value)` for global-ints (the helper sees the value before the obfuscated store — no de-obfuscation needed). **`--spawn` captures from boot** (packer-aware: polls until `0x425fb0` unpacks, then attaches; kills the spawned pid on setup failure so no suspended orphan). `--attach` = partial (misses pre-attach writes). Validated: a real boot→New-Game→SC0000 capture seeds the VM to match the engine's whole opening. | `py -3.11 -u -X utf8 tools/frida/capture_global_writes.py --spawn [secs]` | running/spawned game → `build/global-writes.jsonl` (raw) + `build/scene-entry-state.json` (GameSession snapshot) | | `tools/frida/capture_global_writes.py` | **Scene-entry state capture** → auto-seed for single-scene runs (`docs/engine-re.md` "Scene-entry state snapshot"). Hooks `vm_operand_write@0x425fb0` and logs `(codebase, index, PLAINTEXT value)` for global-ints (the helper sees the value before the obfuscated store — no de-obfuscation needed). **`--spawn` captures from boot** (packer-aware: polls until `0x425fb0` unpacks, then attaches; kills the spawned pid on setup failure so no suspended orphan). `--attach` = partial (misses pre-attach writes). Validated: a real boot→New-Game→SC0000 capture seeds the VM to match the engine's whole opening. | `py -3.11 -u -X utf8 tools/frida/capture_global_writes.py --spawn [secs]` | running/spawned game → `build/global-writes.jsonl` (raw) + `build/scene-entry-state.json` (GameSession snapshot) |

View File

@@ -20,10 +20,47 @@ public class FrameClockTests
} }
[Fact] [Fact]
public void EffectiveBudget_ScalesBySpeed_AndFloorsAtOne() public void Advance_RetainsFractionalMillisecondsAtSlowSpeed()
{ {
Assert.Equal(30, new FrameClock { OpsPerFrame = 30, Speed = 1.0 }.EffectiveBudget); var c = new FrameClock { Speed = 0.1 };
Assert.Equal(120, new FrameClock { OpsPerFrame = 30, Speed = 4.0 }.EffectiveBudget); for (int i = 0; i < 10; i++) c.Advance(1.0 / 144.0);
Assert.Equal(1, new FrameClock { OpsPerFrame = 0, Speed = 1.0 }.EffectiveBudget); Assert.Equal(6, c.NowMs);
}
[Fact]
public void WallClockPacer_RateDoesNotDependOnRenderCallbackCount()
{
static long Simulate(int callbacks)
{
var c = new FrameClock();
var p = new WallClockOpPacer(c);
long ops = 0;
p.OpcodeCompleted(); ops++;
for (int frame = 0; frame < callbacks; frame++)
{
c.Advance(1.0 / callbacks);
while (p.CanRunNext) { p.OpcodeCompleted(); ops++; }
}
return ops;
}
long at60 = Simulate(60), at144 = Simulate(144), at240 = Simulate(240);
Assert.InRange(at60, 198, 202);
Assert.InRange(at144, 198, 202);
Assert.InRange(at240, 198, 202);
Assert.InRange(System.Math.Abs(at60 - at144), 0, 2);
Assert.InRange(System.Math.Abs(at60 - at240), 0, 2);
}
[Fact]
public void WallClockPacer_ResetDropsParkedTimeCredit()
{
var c = new FrameClock();
var p = new WallClockOpPacer(c);
p.OpcodeCompleted();
c.Advance(10);
p.Reset();
p.OpcodeCompleted();
Assert.False(p.CanRunNext);
} }
} }

View File

@@ -103,6 +103,23 @@ public class GfxAnimationTests
Assert.Equal(1, vm.Gfx.AnimClockGeneration); Assert.Equal(1, vm.Gfx.AnimClockGeneration);
} }
[Fact]
public void ResetAnimClock_DispatchClearsOnlyGlobalServiceClock()
{
var t = T();
var scene = ScriptAssembler.Assemble(t, "CLOCKRESET", new List<(int, Operand[])>
{
MovGI(1, 400),
(0x238, new[] { G(1) }),
(0x243, System.Array.Empty<Operand>()),
Exit(),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(scene, t, new RecordingHost());
vm.Run();
Assert.Equal(0, vm.Gfx.AnimClockDurationTicks);
Assert.Equal(2, vm.Gfx.AnimClockGeneration);
}
[Fact] [Fact]
public void SnapshotSamplesDelayedMatrixChannels_WithoutUsingZAsOpacity() public void SnapshotSamplesDelayedMatrixChannels_WithoutUsingZAsOpacity()
{ {
@@ -138,4 +155,30 @@ public class GfxAnimationTests
Assert.True(done.Rotation.Enabled); Assert.True(done.Rotation.Enabled);
Assert.Equal(255, done.Alpha); // scale-Z=3, translation-Z=99, rotation-axis-Z=5: still opaque Assert.Equal(255, done.Alpha); // scale-Z=3, translation-Z=99, rotation-axis-Z=5: still opaque
} }
[Fact]
public void Transform2D_UsesNativeAnchoredRowVectorOrder_AndDirectProjection()
{
var t = new TransformState(2, 3, 99, 10, -7, 1234, 100, 50, 888);
var p = Transform2DMath.Apply(120, 60, t);
Assert.Equal((150.0, 73.0), p);
}
[Fact]
public void Transform2D_NegativeScaleMovesFarEdgeAcrossAnchor()
{
var t = new TransformState(-2, 1, 1, 0, 0, 0, 100, 0, 0);
var left = Transform2DMath.Apply(90, 0, t);
var right = Transform2DMath.Apply(110, 0, t);
Assert.Equal((120.0, 0.0), left);
Assert.Equal((80.0, 0.0), right);
}
[Fact]
public void Transform2D_MatchesCapturedNativeSc0000ScaleEndpoint()
{
// Native handle 0xcbc0: base=(0,600), anchor=(400,1000), scale=5.
var t = new TransformState(5, 5, 1, 0, 0, 0, 400, 1000, 0);
Assert.Equal((-1600.0, -1000.0), Transform2DMath.Apply(0, 600, t));
}
} }

View File

@@ -1,23 +1,75 @@
namespace Age.Engine.Hosting; namespace Age.Engine.Hosting;
/// <summary>Host-owned virtual clock + per-frame op budget. Pure (no threading): the Godot host /// <summary>Host-owned virtual clock. Godot advances it from real elapsed time; VM pacing, sleeps, and
/// advances it once per rendered frame and consults it to pace the VM. The one <see cref="Speed"/> /// retained graphics all consume this same timebase. Fractional milliseconds are retained so diagnostic
/// factor is the future (unwired) Ctrl fast-forward multiplier — scaling it scales the throttle /// slow motion does not stall on high-refresh displays.</summary>
/// budget, sleeps, and the anim tween together. See docs/superpowers/specs/2026-07-08-frame-stepped-vm-design.md.</summary>
public sealed class FrameClock public sealed class FrameClock
{ {
/// <summary>Monotonic virtual time in milliseconds (scaled by Speed).</summary> private long _nowMs;
public long NowMs { get; private set; } private double _fractionalMs;
/// <summary>Speed multiplier. 1.0 = normal. The future Ctrl hook (ADV-scoped); leave at 1.0 for now.</summary> /// <summary>Monotonic virtual time in milliseconds (scaled by Speed).</summary>
public long NowMs => System.Threading.Interlocked.Read(ref _nowMs);
/// <summary>Speed multiplier. 1.0 = normal. A lower diagnostic value slows VM progress, sleeps, and
/// graphics together; a future ADV-scoped Ctrl hook can drive the same seam.</summary>
public double Speed = 1.0; public double Speed = 1.0;
/// <summary>Base per-frame interpreter op budget (tunable by eye; ~30 ≈ 1,800 ops/sec at 60fps).</summary> /// <summary>Native normal-playback interpreter cadence. The old 1,800 figure counted calls to
public int OpsPerFrame = 30; /// vm_operand_fetch, not completed opcodes. A live 1,890 ms transform section executes about 407
/// port opcodes. A normal-speed replay at 215/s retained the object for 1,798 ms; 200/s reaches
/// the native 1,890 ms endpoint before the same teardown path.</summary>
public double OpsPerSecond = 200.0;
/// <summary>Advance the clock by one rendered frame's real delta (seconds), scaled by Speed.</summary> /// <summary>Advance the clock by one rendered frame's real delta (seconds), scaled by Speed.</summary>
public void Advance(double realDeltaSeconds) => NowMs += (long)(realDeltaSeconds * 1000.0 * Speed); public void Advance(double realDeltaSeconds)
{
/// <summary>Ops the VM may run before yielding a frame, scaled by Speed (min 1).</summary> double scaled = realDeltaSeconds * 1000.0 * Speed + _fractionalMs;
public int EffectiveBudget => System.Math.Max(1, (int)System.Math.Round(OpsPerFrame * Speed)); long whole = (long)System.Math.Floor(scaled);
_fractionalMs = scaled - whole;
if (whole > 0) System.Threading.Interlocked.Add(ref _nowMs, whole);
}
}
/// <summary>Pure wall-clock opcode rate limiter. The VM thread records completed opcodes and waits whenever
/// it has consumed the allowance earned from <see cref=FrameClock.NowMs/>. Reset after a blocking wait so
/// parked time never turns into a catch-up burst.</summary>
public sealed class WallClockOpPacer
{
private readonly FrameClock _clock;
private bool _started;
private long _epochMs;
private long _completed;
public WallClockOpPacer(FrameClock clock) => _clock = clock;
public void OpcodeCompleted()
{
if (!_started)
{
_started = true;
_epochMs = _clock.NowMs;
_completed = 0;
}
_completed++;
}
/// <summary>Whether the next opcode may execute at the clock's current time.</summary>
public bool CanRunNext
{
get
{
if (!_started) return true;
long elapsed = System.Math.Max(0, _clock.NowMs - _epochMs);
long allowance = 1 + (long)System.Math.Floor(elapsed * _clock.OpsPerSecond / 1000.0);
return _completed < allowance;
}
}
public void Reset()
{
_started = false;
_epochMs = 0;
_completed = 0;
}
} }

View File

@@ -234,6 +234,12 @@ public sealed class GfxState
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; } lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
} }
/// <summary>Op 0x243: reset the separate global animation-service clock.</summary>
public void ResetAnimClock()
{
lock (_lock) { AnimClockDurationTicks = 0; AnimClockGeneration++; }
}
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless /// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
/// callers and existing tests.</summary> /// callers and existing tests.</summary>
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0); public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);

View File

@@ -0,0 +1,11 @@
namespace Age.Engine.Model;
/// <summary>The axis-aligned 2D reduction of AGE's row-vector object matrix. Native composition is
/// T(-anchor) * scale * middle(rotation) * translation * T(anchor). With rotation deferred, a point is
/// therefore anchor + (point-anchor)*scale + translation; Z remains a retained 3D channel, not opacity.</summary>
public static class Transform2DMath
{
public static (double X, double Y) Apply(double x, double y, TransformState transform)
=> (transform.AnchorX + (x - transform.AnchorX) * transform.ScaleX + transform.TranslateX,
transform.AnchorY + (y - transform.AnchorY) * transform.ScaleY + transform.TranslateY);
}

View File

@@ -377,6 +377,11 @@ public sealed class VirtualMachine
Gfx.SetRotationCycle(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1; Gfx.SetRotationCycle(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1;
case "set-anim-clock": // 0x238 (duration) — global, non-blocking (host advances it per-frame) case "set-anim-clock": // 0x238 (duration) — global, non-blocking (host advances it per-frame)
Gfx.SetAnimClock(Read(a[0])); return pc + 1; Gfx.SetAnimClock(Read(a[0])); return pc + 1;
case "reset-anim-clock": // 0x243: reset the separate global animation-service clock
Gfx.ResetAnimClock(); return pc + 1;
case "mark-frame-yield": // 0x21c: host already yields after every completed opcode
case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue
return pc + 1;
default: default:
// Stub is per-instruction frequency (the VM handles ~30 ops; the rest hit here, e.g. // Stub is per-instruction frequency (the VM handles ~30 ops; the rest hit here, e.g.
// 0x258/0x259 stmt markers appear en masse), so gate it with Step — else --trace floods. // 0x258/0x259 stmt markers appear en masse), so gate it with Step — else --trace floods.

View File

@@ -14,14 +14,16 @@ public sealed class GodotAdvHost : IHost
private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } }; private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } };
private readonly SemaphoreSlim _gate = new(0, 1); private readonly SemaphoreSlim _gate = new(0, 1);
private readonly Age.Engine.Hosting.FrameClock _clock; private readonly Age.Engine.Hosting.FrameClock _clock;
private readonly Age.Engine.Hosting.WallClockOpPacer _opPacer;
private readonly System.Threading.AutoResetEvent _frameSignal = new(false); private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private int _opsSinceYield; private volatile bool _stopping;
public volatile bool IsWaiting; public volatile bool IsWaiting;
public readonly List<(int Offset, string Text)> Captured = new(); public readonly List<(int Offset, string Text)> Captured = new();
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock) public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock)
{ {
_main = main; _res = res; _scene = scene; _clock = clock; _main = main; _res = res; _scene = scene; _clock = clock;
_opPacer = new Age.Engine.Hosting.WallClockOpPacer(clock);
} }
public void ShowText(int offset, string text) public void ShowText(int offset, string text)
@@ -39,11 +41,19 @@ public sealed class GodotAdvHost : IHost
IsWaiting = true; IsWaiting = true;
_gate.Wait(); _gate.Wait();
IsWaiting = false; IsWaiting = false;
_opPacer.Reset();
_main.CallDeferred("ClearPage"); _main.CallDeferred("ClearPage");
} }
// called from the main thread (click) or the selftest auto-clicker // called from the main thread (click) or the selftest auto-clicker
public void SignalInput() { if (_gate.CurrentCount == 0) _gate.Release(); } public void SignalInput() { if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); }
public void Stop()
{
_stopping = true;
if (_gate.CurrentCount == 0) _gate.Release();
_frameSignal.Set();
}
// Main thread, once per rendered frame: releases a VM thread parked in FrameYield/Sleep. // Main thread, once per rendered frame: releases a VM thread parked in FrameYield/Sleep.
public void PulseFrame() => _frameSignal.Set(); public void PulseFrame() => _frameSignal.Set();
@@ -53,11 +63,9 @@ public sealed class GodotAdvHost : IHost
// interpreter to ~budget ops per rendered frame (the native engine's rate-limited cadence). // interpreter to ~budget ops per rendered frame (the native engine's rate-limited cadence).
public void FrameYield() public void FrameYield()
{ {
if (++_opsSinceYield < _clock.EffectiveBudget) return; _opPacer.OpcodeCompleted();
_opsSinceYield = 0; while (!_opPacer.CanRunNext && !_stopping)
long start = _clock.NowMs; _frameSignal.WaitOne(50);
while (_clock.NowMs == start) // wait until a real _Process advanced the clock
if (!_frameSignal.WaitOne(50)) break; // 50ms safety cap: never hang if _Process stalls
} }
// op 0xc8: block the VM background thread so the main-thread compositor (Main.Recomposite in _Process) // op 0xc8: block the VM background thread so the main-thread compositor (Main.Recomposite in _Process)
@@ -73,7 +81,11 @@ public sealed class GodotAdvHost : IHost
long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window
long deadline = _clock.NowMs + ms; long deadline = _clock.NowMs + ms;
while (_clock.NowMs < deadline) while (_clock.NowMs < deadline)
if (!_frameSignal.WaitOne(2000)) break; // safety cap {
if (_stopping) break;
_frameSignal.WaitOne(50);
}
_opPacer.Reset();
} }
// ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ---- // ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ----

View File

@@ -94,6 +94,7 @@ public partial class Main : Godot.Control
string scene = "SC0000"; // --scene <NAME>: which scene to play (default SC0000) string scene = "SC0000"; // --scene <NAME>: which scene to play (default SC0000)
var seeds = new List<(int Addr, long Val)>(); // --seed 0xADDR=VAL (repeatable) — initial global state var seeds = new List<(int Addr, long Val)>(); // --seed 0xADDR=VAL (repeatable) — initial global state
double sleepScale = 1.0; // --sleep-scale <f>: slow/speed the paced opening for inspection double sleepScale = 1.0; // --sleep-scale <f>: slow/speed the paced opening for inspection
double speed = 1.0; // --speed <f>: whole-runtime diagnostic speed
string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run
for (int i = 0; i < userArgs.Length; i++) for (int i = 0; i < userArgs.Length; i++)
{ {
@@ -105,6 +106,7 @@ public partial class Main : Godot.Control
if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1]; if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1];
if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames); if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames);
if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale); if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale);
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1]; if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1];
if (userArgs[i] == "--seed" && i + 1 < userArgs.Length) if (userArgs[i] == "--seed" && i + 1 < userArgs.Length)
{ {
@@ -118,6 +120,9 @@ public partial class Main : Godot.Control
} }
} }
if (!double.IsFinite(speed) || speed <= 0) speed = 1.0;
_clock.Speed = System.Math.Clamp(speed, 0.05, 8.0);
var table = OpcodeTableJson.Load(Paths.OpcodesJson); var table = OpcodeTableJson.Load(Paths.OpcodesJson);
// Full op handling everywhere: the provider lets call-script load & run subroutines. Selftest // Full op handling everywhere: the provider lets call-script load & run subroutines. Selftest
// runs a SYNTHESIZED scene (not a real scene in a crippled mode) so its output is deterministic. // runs a SYNTHESIZED scene (not a real scene in a crippled mode) so its output is deterministic.
@@ -211,7 +216,7 @@ public partial class Main : Godot.Control
_host.SignalInput(); _host.SignalInput();
} }
public override void _ExitTree() { DumpHistogram(); _host?.SignalInput(); } public override void _ExitTree() { DumpHistogram(); _host?.Stop(); }
// Write the real-run op/call-site histogram to --trace-histogram <file>. Idempotent; called when the // Write the real-run op/call-site histogram to --trace-histogram <file>. Idempotent; called when the
// scene ends or the window closes (the opening parks at wait-for-input, so closing is the usual trigger). // scene ends or the window closes (the opening parks at wait-for-input, so closing is the usual trigger).
@@ -245,8 +250,9 @@ public partial class Main : Godot.Control
foreach (var v in _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs)) // interpolate at the throttled clock foreach (var v in _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs)) // interpolate at the throttled clock
{ {
var t = v.Transform; var t = v.Transform;
int dstX = (int)System.Math.Round(t.AnchorX + (v.DstX - t.AnchorX) * t.ScaleX + t.TranslateX); var projected = Age.Engine.Model.Transform2DMath.Apply(v.DstX, v.DstY, t);
int dstY = (int)System.Math.Round(t.AnchorY + (v.DstY - t.AnchorY) * t.ScaleY + t.TranslateY); int dstX = (int)System.Math.Round(projected.X);
int dstY = (int)System.Math.Round(projected.Y);
float opacity = v.Alpha / 255f; // transform Z is never opacity float opacity = v.Alpha / 255f; // transform Z is never opacity
float strength = v.TintStrength / 255f; // tint-blend / fill strength float strength = v.TintStrength / 255f; // tint-blend / fill strength
string outcome; string outcome;
@@ -265,6 +271,7 @@ public partial class Main : Godot.Control
float fillA = opacity * strength; float fillA = opacity * strength;
FillQuad(fillX, fillY, fw, fh, v.Tint, fillA); FillQuad(fillX, fillY, fw, fh, v.Tint, fillA);
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {fw}x{fh}@({fillX},{fillY}) " + outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {fw}x{fh}@({fillX},{fillY}) " +
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0})"; $"trans=({t.TranslateX:0.0},{t.TranslateY:0.0})";
} }
@@ -280,7 +287,8 @@ public partial class Main : Godot.Control
dstX, dstY, t.ScaleX, t.ScaleY, opacity); dstX, dstY, t.ScaleX, t.ScaleY, opacity);
var raw = _vm.Gfx.TryGet(v.Handle); var raw = _vm.Gfx.TryGet(v.Handle);
outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {System.IO.Path.GetFileName(bmp)} " + outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {System.IO.Path.GetFileName(bmp)} " +
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) dst=({dstX},{dstY}) " + $"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
$"op={opacity:0.00} tintStr={strength:0.00}"; $"op={opacity:0.00} tintStr={strength:0.00}";
} }
@@ -367,15 +375,17 @@ public partial class Main : Godot.Control
byte[] dst = _screen.GetData(); byte[] ss = src.GetData(); byte[] dst = _screen.GetData(); byte[] ss = src.GetData();
int dw = _screen.GetWidth(), dh = _screen.GetHeight(), sfw = src.GetWidth(); int dw = _screen.GetWidth(), dh = _screen.GetHeight(), sfw = src.GetWidth();
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255); int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
for (int y = 0; y < outH; y++) int x0 = System.Math.Max(0, -outX), x1 = System.Math.Min(outW, dw - outX);
for (int x = 0; x < outW; x++) int y0 = System.Math.Max(0, -outY), y1 = System.Math.Min(outH, dh - outY);
if (x1 <= x0 || y1 <= y0) return;
for (int y = y0; y < y1; y++)
for (int x = x0; x < x1; x++)
{ {
int sampleX = System.Math.Min(sw - 1, (int)(x / absScaleX)); int sampleX = System.Math.Min(sw - 1, (int)(x / absScaleX));
int sampleY = System.Math.Min(sh - 1, (int)(y / absScaleY)); int sampleY = System.Math.Min(sh - 1, (int)(y / absScaleY));
if (scaleX < 0) sampleX = sw - 1 - sampleX; if (scaleX < 0) sampleX = sw - 1 - sampleX;
if (scaleY < 0) sampleY = sh - 1 - sampleY; if (scaleY < 0) sampleY = sh - 1 - sampleY;
int dxp = outX + x, dyp = outY + y; int dxp = outX + x, dyp = outY + y;
if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue;
int di = (dyp * dw + dxp) * 4; int di = (dyp * dw + dxp) * 4;
int si = ((srcY + sampleY) * sfw + (srcX + sampleX)) * 4; int si = ((srcY + sampleY) * sfw + (srcX + sampleX)) * 4;
int sa = ss[si + 3] * ia / 255; // texel alpha (colorkey already 0) × object opacity int sa = ss[si + 3] * ia / 255; // texel alpha (colorkey already 0) × object opacity
@@ -400,11 +410,13 @@ public partial class Main : Godot.Control
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff); int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
byte[] dst = _screen.GetData(); byte[] dst = _screen.GetData();
int dw = _screen.GetWidth(), dh = _screen.GetHeight(); int dw = _screen.GetWidth(), dh = _screen.GetHeight();
for (int y = 0; y < h; y++) int x0 = System.Math.Max(0, -dstX), x1 = System.Math.Min(w, dw - dstX);
for (int x = 0; x < w; x++) int y0 = System.Math.Max(0, -dstY), y1 = System.Math.Min(h, dh - dstY);
if (x1 <= x0 || y1 <= y0) return;
for (int y = y0; y < y1; y++)
for (int x = x0; x < x1; x++)
{ {
int dxp = dstX + x, dyp = dstY + y; int dxp = dstX + x, dyp = dstY + y;
if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue;
int di = (dyp * dw + dxp) * 4; int di = (dyp * dw + dxp) * 4;
dst[di] = (byte)((tr * ia + dst[di] * (255 - ia)) / 255); dst[di] = (byte)((tr * ia + dst[di] * (255 - ia)) / 255);
dst[di + 1] = (byte)((tg * ia + dst[di + 1] * (255 - ia)) / 255); dst[di + 1] = (byte)((tg * ia + dst[di + 1] * (255 - ia)) / 255);

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Capture native retained-object matrices for port comparison.
Hooks the already-reversed object composite/apply path with plain JavaScript:
gfx_object_composite AGE.EXE+0x7f650 (tracks current handle)
gfx_object_apply_transform_channels AGE.EXE+0x72f00
For every changed matrix it records frame-time, base position, anchor, sampled 4x4 matrix, current/target
scale and translation channels, and their timing fields. The apply hook sees the exact native composition
after T(-anchor) * scale * middle * translation * T(anchor), before the later viewport matrices.
Run while the game is already at an ADV passage, then drive the relevant animation manually:
py -3.11 -u -X utf8 tools/frida/capture_native_transforms.py [seconds] [pid|AGE.EXE] [--handle 0xHANDLE]
Writes build/native-transform-trace.jsonl. Read-only state capture; it does not patch values or alter speed.
"""
import json
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "native-transform-trace.jsonl"
COMPOSITE_OFF = 0x7F650
APPLY_OFF = 0x72F00
JS = r"""
const COMPOSITE_OFF=%d, APPLY_OFF=%d, HANDLE_FILTER=%s;
const mod = Process.getModuleByName('AGE.EXE');
const activeHandle = new Map();
const last = new Map();
function s32(p, off) { return p.add(off).readS32(); }
function u32(p, off) { return p.add(off).readU32(); }
function f32(p, off) { return p.add(off).readFloat(); }
function mat(p, off) {
const a = [];
for (let i=0; i<16; i++) a.push(f32(p, off + i*4));
return a;
}
function v3(p, off) { return [f32(p,off), f32(p,off+4), f32(p,off+8)]; }
function diag(p, off) { return [f32(p,off), f32(p,off+20), f32(p,off+40)]; }
function trans(p, off) { return [f32(p,off+48), f32(p,off+52), f32(p,off+56)]; }
function rounded(a) { return a.map(x => Math.round(x * 10000) / 10000); }
Interceptor.attach(mod.base.add(COMPOSITE_OFF), {
onEnter(args) {
const tid = Process.getCurrentThreadId();
activeHandle.set(tid, args[0].toUInt32());
},
onLeave() { activeHandle.delete(Process.getCurrentThreadId()); }
});
Interceptor.attach(mod.base.add(APPLY_OFF), {
onEnter(args) {
this.tid = Process.getCurrentThreadId();
this.handle = activeHandle.has(this.tid) ? activeHandle.get(this.tid) : null;
this.ctx = this.context.ecx;
this.obj = args[0];
this.outMatrix = args[1];
},
onLeave() {
if (this.handle === null || (HANDLE_FILTER !== null && this.handle !== HANDLE_FILTER)) return;
try {
const m = rounded(mat(this.outMatrix, 0));
const key = this.handle.toString(16);
const sig = JSON.stringify(m);
if (last.get(key) === sig) return;
last.set(key, sig);
send({
kind:'transform',
t:Date.now(),
handle:this.handle,
frameTime:u32(this.ctx,0xb550),
flags:u32(this.obj,0),
slot:s32(this.obj,4),
src:[s32(this.obj,8),s32(this.obj,12),s32(this.obj,16),s32(this.obj,20)],
anchor:v3(this.obj,0x18),
base:v3(this.obj,0x24),
start:u32(this.obj,0x34),
scaleDelay:s32(this.obj,0x3c),
transDelay:s32(this.obj,0x44),
scaleDuration:s32(this.obj,0x50),
transDuration:s32(this.obj,0x58),
scaleCurrent:rounded(diag(this.obj,0x6c)),
scaleTarget:rounded(diag(this.obj,0xac)),
transCurrent:rounded(trans(this.obj,0x16c)),
transTarget:rounded(trans(this.obj,0x1ac)),
matrix:m
});
} catch(e) {
send({kind:'error', message:e.toString()});
}
}
});
send({kind:'ready', base:mod.base.toString(),
composite:mod.base.add(COMPOSITE_OFF).toString(), apply:mod.base.add(APPLY_OFF).toString()});
""" % (COMPOSITE_OFF, APPLY_OFF, "%s")
def main():
import frida
args = sys.argv[1:]
seconds = 20
proc = "AGE.EXE"
handle = None
positional = []
i = 0
while i < len(args):
if args[i] == "--handle" and i + 1 < len(args):
handle = int(args[i + 1], 0)
i += 2
else:
positional.append(args[i])
i += 1
if positional and positional[0].isdigit():
seconds = int(positional.pop(0))
if positional:
proc = positional[0]
handle_js = "null" if handle is None else str(handle)
source = JS % handle_js
OUT.parent.mkdir(parents=True, exist_ok=True)
rows = []
def on_message(msg, data):
if msg.get("type") == "error":
print("[frida-error]", msg.get("description"))
return
if msg.get("type") != "send":
return
payload = msg["payload"]
kind = payload.get("kind")
if kind == "ready":
print(f"[frida] native transform hooks live: composite={payload['composite']} apply={payload['apply']}")
elif kind == "transform":
rows.append(payload)
print(f" t={payload['frameTime']:>10} handle=0x{payload['handle']:x} "
f"base={payload['base'][:2]} anchor={payload['anchor'][:2]} "
f"scale={payload['scaleCurrent'][:2]} trans={payload['transCurrent'][:2]}")
elif kind == "error":
print("[capture-error]", payload.get("message"))
target = int(proc) if str(proc).isdigit() else proc
try:
session = frida.attach(target)
except frida.ProcessNotFoundError:
print("[frida] AGE.EXE not found; start the native game and enter the target ADV passage first.")
return 2
script = session.create_script(source)
script.on("message", on_message)
script.load()
print(f"[frida] capturing {seconds}s; drive the target animation now.")
try:
time.sleep(seconds)
except KeyboardInterrupt:
pass
try:
session.detach()
except Exception:
pass
with OUT.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"[frida] wrote {len(rows)} changed matrices -> {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -4,8 +4,9 @@ rate vs displayed-frame rate, and frame timing — so the frame-stepped-VM fix p
data instead of by feel. data instead of by feel.
SAFE pattern (matches capture_gfx_objects.py, which runs without crashing): plain-JS hooks only, no SAFE pattern (matches capture_gfx_objects.py, which runs without crashing): plain-JS hooks only, no
CModule; the only engine-code hook is the PROVEN operand-fetch helper `0x41b940` (fires per opcode, CModule; the only engine-code hook is the PROVEN operand-fetch helper `0x41b940` (fires once per
ecx = context) used to (a) grab the context pointer once and (b) count execution rate. Frame timing operand read, often several times per opcode; ecx = context) used to (a) grab the context pointer once
and (b) count operand-fetch rate. Frame timing
comes from SYSTEM-DLL hooks (user32 message pump — never engine code, never anti-tamper). Engine state comes from SYSTEM-DLL hooks (user32 message pump — never engine code, never anti-tamper). Engine state
(coroutine PC, run-state flags, sleep timer) is READ-ONLY polled. Nothing patches engine code beyond the (coroutine PC, run-state flags, sleep timer) is READ-ONLY polled. Nothing patches engine code beyond the
one address our other scripts already prove is safe. one address our other scripts already prove is safe.
@@ -29,7 +30,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "frida-frame-cadence.jsonl" OUT = REPO / "build" / "frida-frame-cadence.jsonl"
OPFETCH_OFF = 0x1b940 # operand-fetch helper (0x41b940); per-op, ecx=ctx. PROVEN-safe hook. OPFETCH_OFF = 0x1b940 # operand-fetch helper (0x41b940); per operand read, ecx=ctx. PROVEN-safe hook.
IDX_OFF = 0x53d14 # current coroutine index IDX_OFF = 0x53d14 # current coroutine index
PC_BASE = 0x53d2c # per-coroutine record base; +idx*0x78 holds the PC pointer (deref = opcode) PC_BASE = 0x53d2c # per-coroutine record base; +idx*0x78 holds the PC pointer (deref = opcode)
PC_STRIDE = 0x78 PC_STRIDE = 0x78

View File

@@ -4110,19 +4110,19 @@ observed_types = ["imm", "l-int"]
[[opcode]] [[opcode]]
op = 0x1c7 op = 0x1c7
label = "u00414F90" label = "get-message-skip"
argc = 1 argc = 1
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00414F90" name = "get-message-skip"
category = "unknown" category = "input"
summary = "" summary = "(out) - write 1 iff ADV message-skip run-state bit 0x08000000 is set, otherwise 0."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "Ghidra handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). SC0000 label_1235a ORs it with op 0x1cc."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
@@ -4199,19 +4199,19 @@ observed_types = ["g-int"]
[[opcode]] [[opcode]]
op = 0x1cc op = 0x1cc
label = "u00415010" label = "get-adv-service-state"
argc = 1 argc = 1
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00415010" name = "get-adv-service-state"
category = "unknown" category = "control"
summary = "" summary = "(out) - copy native ADV service state ctx+0x6dbd4; label_1235a ORs it with message-skip to select its yield branch."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Exact service-state producer remains outside this opcode."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
@@ -5461,19 +5461,19 @@ observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0x21c op = 0x21c
label = "u00416270" label = "mark-frame-yield"
argc = 0 argc = 0
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00416270" name = "mark-frame-yield"
category = "unknown" category = "control"
summary = "" summary = "Set native run-state bit 0x400. Host-implicit: the port already offers a scheduler yield after every completed opcode."
noop_headless = false noop_headless = true
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a reaches it only when op 0x1c7 or 0x1cc is nonzero."
[[opcode]] [[opcode]]
op = 0x21d op = 0x21d
@@ -5728,19 +5728,19 @@ observed_types = ["imm", "g-int"]
[[opcode]] [[opcode]]
op = 0x224 op = 0x224
label = "u00416290" label = "clear-gfx-command-queue"
argc = 0 argc = 0
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00416290" name = "clear-gfx-command-queue"
category = "unknown" category = "draw"
summary = "" summary = "Clear the native gfx command queue rooted at ctx+0x418. Host-implicit because the port composites retained state directly."
noop_headless = false noop_headless = true
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "Ghidra handler 0x417550 -> gfx_command_queue_clear 0x47cb10, which destroys queued nodes and restores the sentinel links/count."
[[opcode]] [[opcode]]
op = 0x228 op = 0x228
@@ -6450,19 +6450,19 @@ observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0x243 op = 0x243
label = "u00417070" label = "reset-anim-clock"
argc = 0 argc = 0
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00417070" name = "reset-anim-clock"
category = "unknown" category = "draw"
summary = "" summary = "Reset the native global animation-service elapsed and duration fields to zero when service flag bit 1 is clear."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "Ghidra handler 0x4182d0: if !(ctx+0x51b80 & 2), set ctx+0x51b70=1 and zero ctx+0x51b78/+0x51b7c. Normal SC0000 label_1235a calls it before present-frame."
[[opcode]] [[opcode]]
op = 0x248 op = 0x248