diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..22af217 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docs/opcode-reference.md whitespace=trailing-space,space-before-tab,cr-at-eol diff --git a/docs/engine-re.md b/docs/engine-re.md index 7154fb5..a3b5d10 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index abe8020..07b2974 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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 diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 2a289f7..d0150d0 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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`. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 9873e84..7ea9849 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -135,13 +135,15 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after - `--shot [--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 [--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 ` — 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 ` — scale the unified runtime clock (VM cadence, sleeps, and retained animation) without auto-advancing input waits. Values 0.05–8 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback. - `--gfx-log ` — **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 → 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 [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__` 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 2–3× 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=(pc−codebase)/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) | diff --git a/engine/Age.Engine.Tests/FrameClockTests.cs b/engine/Age.Engine.Tests/FrameClockTests.cs index 6a30797..4c98646 100644 --- a/engine/Age.Engine.Tests/FrameClockTests.cs +++ b/engine/Age.Engine.Tests/FrameClockTests.cs @@ -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); } } diff --git a/engine/Age.Engine.Tests/GfxAnimationTests.cs b/engine/Age.Engine.Tests/GfxAnimationTests.cs index f2ab241..06f695d 100644 --- a/engine/Age.Engine.Tests/GfxAnimationTests.cs +++ b/engine/Age.Engine.Tests/GfxAnimationTests.cs @@ -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()), + Exit(), + }, System.Array.Empty()); + 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)); + } } diff --git a/engine/Age.Engine/Hosting/FrameClock.cs b/engine/Age.Engine/Hosting/FrameClock.cs index 3ff0433..c8a791e 100644 --- a/engine/Age.Engine/Hosting/FrameClock.cs +++ b/engine/Age.Engine/Hosting/FrameClock.cs @@ -1,23 +1,75 @@ namespace Age.Engine.Hosting; -/// 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 -/// 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. +/// 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. public sealed class FrameClock { - /// Monotonic virtual time in milliseconds (scaled by Speed). - public long NowMs { get; private set; } + private long _nowMs; + private double _fractionalMs; - /// Speed multiplier. 1.0 = normal. The future Ctrl hook (ADV-scoped); leave at 1.0 for now. + /// Monotonic virtual time in milliseconds (scaled by Speed). + public long NowMs => System.Threading.Interlocked.Read(ref _nowMs); + + /// 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. public double Speed = 1.0; - /// Base per-frame interpreter op budget (tunable by eye; ~30 ≈ 1,800 ops/sec at 60fps). - public int OpsPerFrame = 30; + /// 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. + public double OpsPerSecond = 200.0; /// Advance the clock by one rendered frame's real delta (seconds), scaled by Speed. - public void Advance(double realDeltaSeconds) => NowMs += (long)(realDeltaSeconds * 1000.0 * Speed); - - /// Ops the VM may run before yielding a frame, scaled by Speed (min 1). - 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); + } +} + +/// Pure wall-clock opcode rate limiter. The VM thread records completed opcodes and waits whenever +/// it has consumed the allowance earned from . Reset after a blocking wait so +/// parked time never turns into a catch-up burst. +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++; + } + + /// Whether the next opcode may execute at the clock's current time. + 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; + } } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index fddd4a1..5c8625e 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -234,6 +234,12 @@ public sealed class GfxState lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; } } + /// Op 0x243: reset the separate global animation-service clock. + public void ResetAnimClock() + { + lock (_lock) { AnimClockDurationTicks = 0; AnimClockGeneration++; } + } + /// Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless /// callers and existing tests. public IReadOnlyList SnapshotVisibleObjects() => SnapshotVisibleObjects(0); diff --git a/engine/Age.Engine/Model/Transform2DMath.cs b/engine/Age.Engine/Model/Transform2DMath.cs new file mode 100644 index 0000000..7a94184 --- /dev/null +++ b/engine/Age.Engine/Model/Transform2DMath.cs @@ -0,0 +1,11 @@ +namespace Age.Engine.Model; + +/// 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. +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); +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 1e4869b..0ecab74 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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. diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index c85c4ca..8298afa 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -14,14 +14,16 @@ public sealed class GodotAdvHost : IHost private readonly Dictionary _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) ---- diff --git a/godot/Main.cs b/godot/Main.cs index 0f26e8a..38e4aec 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -94,6 +94,7 @@ public partial class Main : Godot.Control string scene = "SC0000"; // --scene : 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 : slow/speed the paced opening for inspection + double speed = 1.0; // --speed : whole-runtime diagnostic speed string? histFile = null; // --trace-histogram : 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 . 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); diff --git a/tools/frida/capture_native_transforms.py b/tools/frida/capture_native_transforms.py new file mode 100644 index 0000000..e44f188 --- /dev/null +++ b/tools/frida/capture_native_transforms.py @@ -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()) diff --git a/tools/frida/probe_frame_cadence.py b/tools/frida/probe_frame_cadence.py index a0bccb1..0d4113d 100644 --- a/tools/frida/probe_frame_cadence.py +++ b/tools/frida/probe_frame_cadence.py @@ -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 diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 5897a5c..4ea192a 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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