fix: align animation pacing and transforms with native

This commit is contained in:
gamer147
2026-07-10 15:41:17 -04:00
parent 21b2328ee8
commit 4d225618f9
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.
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
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`
@@ -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
`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)
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
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.
- **Implication for the port:** throttle our VM to ~**1,800 ops/sec** wall-clock (≈30 ops per 60 fps Godot
`_Process`, tunable), ~4× under a future Ctrl multiplier; Godot's 60 fps compositor + wall-clock tweens then
show the smoothly-advancing state. This *measured* mechanism replaces the earlier present-driven guess
(present is rare in our path and uncapped natively).
- **Corrected implication for the port (2026-07-10):** 1,788/s is an operand-fetch rate, not an opcode rate.
`IHost.FrameYield` runs once per completed opcode, so matching those quantities directly overclocked the
port by roughly ninefold. The native SC0000 transform lifetime pins the corresponding completed-op cadence
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)

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
- **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
### 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
- **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)
- **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
@@ -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.
- **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
### 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
- **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
### 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:** —
- **grounding:** source=kelebek, confidence=low
### 0x1c7 `u00414F90` (u00414F90, argc 1)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x1c8 `toString` (toString, argc 2)
- **summary:** —
- **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:** —
- **grounding:** source=kelebek, confidence=low
### 0x1cc `u00415010` (u00415010, argc 1)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x1ce `u0041B9F0` (u0041B9F0, argc 1)
- **summary:** —
- **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:** —
- **grounding:** source=kelebek, confidence=low
### 0x21c `u00416270` (u00416270, argc 0)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x21d `u00421410` (u00421410, argc 2)
- **summary:** —
- **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:** —
- **grounding:** source=kelebek, confidence=low
### 0x224 `u00416290` (u00416290, argc 0)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x22a `u00421A90` (u00421A90, argc 3)
- **summary:** —
- **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:** —
- **grounding:** source=kelebek, confidence=low
### 0x243 `u00417070` (u00417070, argc 0)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x248 `u00422E80` (u00422E80, argc 1)
- **summary:** —
- **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
reliable visual judgment. Treat that math as provisional until the pacing slice enables slow normal playback
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-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.
- `--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.
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
| 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` |
| `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` |
@@ -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/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/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/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) |

View File

@@ -20,10 +20,47 @@ public class FrameClockTests
}
[Fact]
public void EffectiveBudget_ScalesBySpeed_AndFloorsAtOne()
public void Advance_RetainsFractionalMillisecondsAtSlowSpeed()
{
Assert.Equal(30, new FrameClock { OpsPerFrame = 30, Speed = 1.0 }.EffectiveBudget);
Assert.Equal(120, new FrameClock { OpsPerFrame = 30, Speed = 4.0 }.EffectiveBudget);
Assert.Equal(1, new FrameClock { OpsPerFrame = 0, Speed = 1.0 }.EffectiveBudget);
var c = new FrameClock { Speed = 0.1 };
for (int i = 0; i < 10; i++) c.Advance(1.0 / 144.0);
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);
}
[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]
public void SnapshotSamplesDelayedMatrixChannels_WithoutUsingZAsOpacity()
{
@@ -138,4 +155,30 @@ public class GfxAnimationTests
Assert.True(done.Rotation.Enabled);
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;
/// <summary>Host-owned virtual clock + per-frame op budget. Pure (no threading): the Godot host
/// advances it once per rendered frame and consults it to pace the VM. The one <see cref="Speed"/>
/// factor is the future (unwired) Ctrl fast-forward multiplier — scaling it scales the throttle
/// budget, sleeps, and the anim tween together. See docs/superpowers/specs/2026-07-08-frame-stepped-vm-design.md.</summary>
/// <summary>Host-owned virtual clock. Godot advances it from real elapsed time; VM pacing, sleeps, and
/// retained graphics all consume this same timebase. Fractional milliseconds are retained so diagnostic
/// slow motion does not stall on high-refresh displays.</summary>
public sealed class FrameClock
{
/// <summary>Monotonic virtual time in milliseconds (scaled by Speed).</summary>
public long NowMs { get; private set; }
private long _nowMs;
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;
/// <summary>Base per-frame interpreter op budget (tunable by eye; ~30 ≈ 1,800 ops/sec at 60fps).</summary>
public int OpsPerFrame = 30;
/// <summary>Native normal-playback interpreter cadence. The old 1,800 figure counted calls to
/// 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>
public void Advance(double realDeltaSeconds) => NowMs += (long)(realDeltaSeconds * 1000.0 * Speed);
/// <summary>Ops the VM may run before yielding a frame, scaled by Speed (min 1).</summary>
public int EffectiveBudget => System.Math.Max(1, (int)System.Math.Round(OpsPerFrame * Speed));
public void Advance(double realDeltaSeconds)
{
double scaled = realDeltaSeconds * 1000.0 * Speed + _fractionalMs;
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++; }
}
/// <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
/// callers and existing tests.</summary>
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;
case "set-anim-clock": // 0x238 (duration) — global, non-blocking (host advances it per-frame)
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:
// 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.

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 SemaphoreSlim _gate = new(0, 1);
private readonly Age.Engine.Hosting.FrameClock _clock;
private readonly Age.Engine.Hosting.WallClockOpPacer _opPacer;
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private int _opsSinceYield;
private volatile bool _stopping;
public volatile bool IsWaiting;
public readonly List<(int Offset, string Text)> Captured = new();
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock)
{
_main = main; _res = res; _scene = scene; _clock = clock;
_opPacer = new Age.Engine.Hosting.WallClockOpPacer(clock);
}
public void ShowText(int offset, string text)
@@ -39,11 +41,19 @@ public sealed class GodotAdvHost : IHost
IsWaiting = true;
_gate.Wait();
IsWaiting = false;
_opPacer.Reset();
_main.CallDeferred("ClearPage");
}
// 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.
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).
public void FrameYield()
{
if (++_opsSinceYield < _clock.EffectiveBudget) return;
_opsSinceYield = 0;
long start = _clock.NowMs;
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
_opPacer.OpcodeCompleted();
while (!_opPacer.CanRunNext && !_stopping)
_frameSignal.WaitOne(50);
}
// 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 deadline = _clock.NowMs + ms;
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) ----

View File

@@ -94,6 +94,7 @@ public partial class Main : Godot.Control
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
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
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] == "--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] == "--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] == "--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);
// 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.
@@ -211,7 +216,7 @@ public partial class Main : Godot.Control
_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
// 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
{
var t = v.Transform;
int dstX = (int)System.Math.Round(t.AnchorX + (v.DstX - t.AnchorX) * t.ScaleX + t.TranslateX);
int dstY = (int)System.Math.Round(t.AnchorY + (v.DstY - t.AnchorY) * t.ScaleY + t.TranslateY);
var projected = Age.Engine.Model.Transform2DMath.Apply(v.DstX, v.DstY, t);
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 strength = v.TintStrength / 255f; // tint-blend / fill strength
string outcome;
@@ -265,6 +271,7 @@ public partial class Main : Godot.Control
float fillA = opacity * strength;
FillQuad(fillX, fillY, fw, fh, v.Tint, fillA);
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}) " +
$"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);
var raw = _vm.Gfx.TryGet(v.Handle);
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}) " +
$"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();
int dw = _screen.GetWidth(), dh = _screen.GetHeight(), sfw = src.GetWidth();
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
for (int y = 0; y < outH; y++)
for (int x = 0; x < outW; x++)
int x0 = System.Math.Max(0, -outX), x1 = System.Math.Min(outW, dw - outX);
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 sampleY = System.Math.Min(sh - 1, (int)(y / absScaleY));
if (scaleX < 0) sampleX = sw - 1 - sampleX;
if (scaleY < 0) sampleY = sh - 1 - sampleY;
int dxp = outX + x, dyp = outY + y;
if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue;
int di = (dyp * dw + dxp) * 4;
int si = ((srcY + sampleY) * sfw + (srcX + sampleX)) * 4;
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);
byte[] dst = _screen.GetData();
int dw = _screen.GetWidth(), dh = _screen.GetHeight();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
int x0 = System.Math.Max(0, -dstX), x1 = System.Math.Min(w, dw - dstX);
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;
if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue;
int di = (dyp * dw + dxp) * 4;
dst[di] = (byte)((tr * ia + dst[di] * (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.
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,
ecx = context) used to (a) grab the context pointer once and (b) count execution rate. Frame timing
CModule; the only engine-code hook is the PROVEN operand-fetch helper `0x41b940` (fires once per
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
(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.
@@ -29,7 +30,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
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
PC_BASE = 0x53d2c # per-coroutine record base; +idx*0x78 holds the PC pointer (deref = opcode)
PC_STRIDE = 0x78

View File

@@ -4110,19 +4110,19 @@ observed_types = ["imm", "l-int"]
[[opcode]]
op = 0x1c7
label = "u00414F90"
label = "get-message-skip"
argc = 1
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00414F90"
category = "unknown"
summary = ""
name = "get-message-skip"
category = "input"
summary = "(out) - write 1 iff ADV message-skip run-state bit 0x08000000 is set, otherwise 0."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
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]]
i = 1
@@ -4199,19 +4199,19 @@ observed_types = ["g-int"]
[[opcode]]
op = 0x1cc
label = "u00415010"
label = "get-adv-service-state"
argc = 1
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00415010"
category = "unknown"
summary = ""
name = "get-adv-service-state"
category = "control"
summary = "(out) - copy native ADV service state ctx+0x6dbd4; label_1235a ORs it with message-skip to select its yield branch."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
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]]
i = 1
@@ -5461,19 +5461,19 @@ observed_types = ["imm"]
[[opcode]]
op = 0x21c
label = "u00416270"
label = "mark-frame-yield"
argc = 0
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00416270"
category = "unknown"
summary = ""
noop_headless = false
source = "kelebek"
confidence = "low"
name = "mark-frame-yield"
category = "control"
summary = "Set native run-state bit 0x400. Host-implicit: the port already offers a scheduler yield after every completed opcode."
noop_headless = true
source = "investigation"
confidence = "high"
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]]
op = 0x21d
@@ -5728,19 +5728,19 @@ observed_types = ["imm", "g-int"]
[[opcode]]
op = 0x224
label = "u00416290"
label = "clear-gfx-command-queue"
argc = 0
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00416290"
category = "unknown"
summary = ""
noop_headless = false
source = "kelebek"
confidence = "low"
name = "clear-gfx-command-queue"
category = "draw"
summary = "Clear the native gfx command queue rooted at ctx+0x418. Host-implicit because the port composites retained state directly."
noop_headless = true
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
evidence = "Ghidra handler 0x417550 -> gfx_command_queue_clear 0x47cb10, which destroys queued nodes and restores the sentinel links/count."
[[opcode]]
op = 0x228
@@ -6450,19 +6450,19 @@ observed_types = ["imm"]
[[opcode]]
op = 0x243
label = "u00417070"
label = "reset-anim-clock"
argc = 0
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00417070"
category = "unknown"
summary = ""
name = "reset-anim-clock"
category = "draw"
summary = "Reset the native global animation-service elapsed and duration fields to zero when service flag bit 1 is clear."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
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]]
op = 0x248