fix: align animation pacing and transforms with native
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
docs/opcode-reference.md whitespace=trailing-space,space-before-tab,cr-at-eol
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.05–8 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 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) |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
engine/Age.Engine/Model/Transform2DMath.cs
Normal file
11
engine/Age.Engine/Model/Transform2DMath.cs
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
@@ -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);
|
||||
|
||||
172
tools/frida/capture_native_transforms.py
Normal file
172
tools/frida/capture_native_transforms.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user