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.
|
delay, linearly interpolates current→target for its duration, then commits the target and clears its own timing.
|
||||||
Neither third component is opacity.
|
Neither third component is opacity.
|
||||||
|
|
||||||
|
**Exact composition and 2D reduction (live-validated 2026-07-10).** The consumer starts from identity and
|
||||||
|
right-multiplies `T(-V18) → scale-current → middle/rotation → translation-current → T(+V18)`;
|
||||||
|
`matrix4_multiply` at `0x4ee2a4` computes `out = left * right`. AGE uses row vectors. With no
|
||||||
|
rotation/perspective, the screen projection is therefore exactly
|
||||||
|
`V18.xy + (point.xy - V18.xy) * scale.xy + translation.xy`. The captured SC0000 handle `0xcbc0`
|
||||||
|
has base `(0,600)`, anchor `(400,1000)`, and final scale `(5,5)`; native matrix translation
|
||||||
|
terms are `(-1600,-4000)`, projecting the base point to `(-1600,-1000)`. The port's focused
|
||||||
|
projection test and transform-aware gfx log reproduce those values. Rotation projection and final D3D
|
||||||
|
raster/rounding details remain deferred; the axis-aligned anchor/order/projection no longer are provisional.
|
||||||
|
|
||||||
**Port result (2026-07-10):** `GfxState` now retains separate current/target scale and translation
|
**Port result (2026-07-10):** `GfxState` now retains separate current/target scale and translation
|
||||||
channels with the native shared-start/independent-timing model. Godot scales around `V18` (anchor),
|
channels with the native shared-start/independent-timing model. Godot scales around `V18` (anchor),
|
||||||
applies translation independently, and never derives opacity from transform Z. The shared `AnimTarget`
|
applies translation independently, and never derives opacity from transform Z. The shared `AnimTarget`
|
||||||
@@ -483,6 +493,28 @@ both annotated) and grepping the SC0000 opening settles the animation model and
|
|||||||
Op `0x234` is the independent rotation cycle above. Op `0x238` still configures the separate
|
Op `0x234` is the independent rotation cycle above. Op `0x238` still configures the separate
|
||||||
`ctx+0x51b78/+0x51b7c` animation service used by its own family.
|
`ctx+0x51b78/+0x51b7c` animation service used by its own family.
|
||||||
|
|
||||||
|
##### `label_1235a` animation-section boundary (2026-07-10)
|
||||||
|
|
||||||
|
The section helper computes the maximum configured duration and arms it with `0x238`, then reads
|
||||||
|
message-skip through `0x1c7` and ADV service state through `0x1cc`. Normal playback (both zero)
|
||||||
|
executes `0x243` (reset the separate global animation-service clock) followed by `0x20c` present;
|
||||||
|
the skip/service branch executes `0x21c` (set run-state bit `0x400`). Both branches finish with
|
||||||
|
`0x224`, which clears the native gfx command queue at `ctx+0x418`. These handlers are now named,
|
||||||
|
commented, and saved in the Ghidra image.
|
||||||
|
|
||||||
|
None of `0x21c/0x224/0x243` waits for the per-object transform duration. The normal-path boundary is
|
||||||
|
the engine's rate-limited one-op interpreter cadence plus continuous retained compositing. This matters because
|
||||||
|
the earlier Frida probe hooked `vm_operand_fetch`: its ~1,788/s result counts **operand reads**, commonly
|
||||||
|
several per completed opcode. Feeding that number to the port's per-completed-opcode `FrameYield` made
|
||||||
|
the `0xcbc0` section reach only scale 1.44 in 226 ms before teardown.
|
||||||
|
|
||||||
|
The corrected host limiter is refresh-independent and runs at 200 completed opcodes/s. It resets accumulated
|
||||||
|
credit after sleep/input parking, and clicks are accepted only while actually waiting, so clicks during a
|
||||||
|
visible animation cannot pre-arm the next wait. A normal-clock replay retained `0xcbc0` for 1,798 ms at
|
||||||
|
the intermediate 215/s calibration; the final 200/s replay kept it alive for 2,014 virtual ms, beyond the native
|
||||||
|
1,890 ms endpoint. `--speed` scales VM, sleep, and animation clocks together for comparison without
|
||||||
|
changing these virtual-time relationships or auto-advancing waits.
|
||||||
|
|
||||||
##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction)
|
##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction)
|
||||||
|
|
||||||
A working note in the animation slice mis-called the SC0000 opening a set of "immediate-mode slot-0 blits." That
|
A working note in the animation slice mis-called the SC0000 opening a set of "immediate-mode slot-0 blits." That
|
||||||
@@ -612,10 +644,11 @@ Findings:
|
|||||||
full ~119-method table) fires ~**1,908/sec** with **no vsync**; `BeginScene`/`EndScene` never fire → a **2D
|
full ~119-method table) fires ~**1,908/sec** with **no vsync**; `BeginScene`/`EndScene` never fire → a **2D
|
||||||
StretchRect-style compositor**, not a 3D scene. So there is **no fixed display-frame rate**; `Present` rate
|
StretchRect-style compositor**, not a 3D scene. So there is **no fixed display-frame rate**; `Present` rate
|
||||||
≈ op rate (~1 op per present). ⇒ the pacing quantity is the **wall-clock op rate**, not a per-frame budget.
|
≈ op rate (~1 op per present). ⇒ the pacing quantity is the **wall-clock op rate**, not a per-frame budget.
|
||||||
- **Implication for the port:** throttle our VM to ~**1,800 ops/sec** wall-clock (≈30 ops per 60 fps Godot
|
- **Corrected implication for the port (2026-07-10):** 1,788/s is an operand-fetch rate, not an opcode rate.
|
||||||
`_Process`, tunable), ~4× under a future Ctrl multiplier; Godot's 60 fps compositor + wall-clock tweens then
|
`IHost.FrameYield` runs once per completed opcode, so matching those quantities directly overclocked the
|
||||||
show the smoothly-advancing state. This *measured* mechanism replaces the earlier present-driven guess
|
port by roughly ninefold. The native SC0000 transform lifetime pins the corresponding completed-op cadence
|
||||||
(present is rare in our path and uncapped natively).
|
at about **200/s**. The limiter must be wall-clock based (not a per-render callback budget), with a future
|
||||||
|
ADV Ctrl multiplier applied to the same unified clock.
|
||||||
|
|
||||||
### The render drift's SECOND half: missing system-boot state (2026-07-07, resolved)
|
### The render drift's SECOND half: missing system-boot state (2026-07-07, resolved)
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t
|
|||||||
- **grounding:** source=investigation, confidence=med
|
- **grounding:** source=investigation, confidence=med
|
||||||
- **evidence:** Ghidra: handler 0x4299c0 (dispatch ctx[0x9b74c]=0x4299c0; created+typed EngineCtx*+annotated; Kelebek u0041F9C0 = VA-drift). Writes gfx cmd-type 9; op2→local_204, op3→local_104, op4→local_208; (*DAT_005c6018)(8, ctx[0x54fe8], &local_210) → FUN_00425fb0(1,ret). DAT_005c6018: 6 xrefs all READ, no static writer; FUN_00405740 (screen-fade) calls it w/ cmd 3, branches on ret 1/2 = transition progress = native video service.
|
- **evidence:** Ghidra: handler 0x4299c0 (dispatch ctx[0x9b74c]=0x4299c0; created+typed EngineCtx*+annotated; Kelebek u0041F9C0 = VA-drift). Writes gfx cmd-type 9; op2→local_204, op3→local_104, op4→local_208; (*DAT_005c6018)(8, ctx[0x54fe8], &local_210) → FUN_00425fb0(1,ret). DAT_005c6018: 6 xrefs all READ, no static writer; FUN_00405740 (screen-fade) calls it w/ cmd 3, branches on ret 1/2 = transition progress = native video service.
|
||||||
|
|
||||||
|
### 0x1cc `get-adv-service-state` (get-adv-service-state, argc 1)
|
||||||
|
- **summary:** (out) - copy native ADV service state ctx+0x6dbd4; label_1235a ORs it with message-skip to select its yield branch.
|
||||||
|
- **grounding:** source=investigation, confidence=high
|
||||||
|
- **evidence:** Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Exact service-state producer remains outside this opcode.
|
||||||
|
|
||||||
|
### 0x21c `mark-frame-yield` (mark-frame-yield, argc 0)
|
||||||
|
- **summary:** Set native run-state bit 0x400. Host-implicit: the port already offers a scheduler yield after every completed opcode.
|
||||||
|
- **grounding:** source=investigation, confidence=high, noop_headless=True
|
||||||
|
- **evidence:** Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a reaches it only when op 0x1c7 or 0x1cc is nonzero.
|
||||||
|
|
||||||
## draw
|
## draw
|
||||||
|
|
||||||
### 0x1a2 `gfx-cmd-register` (gfx-cmd-register, argc 1)
|
### 0x1a2 `gfx-cmd-register` (gfx-cmd-register, argc 1)
|
||||||
@@ -188,6 +198,11 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r
|
|||||||
- **grounding:** source=investigation, confidence=high
|
- **grounding:** source=investigation, confidence=high
|
||||||
- **evidence:** Ghidra 0x47ecc0 calls matrix builder 0x48afb1 for target obj+0x1ac. Consumer 0x472f00 uses delay obj+0x44, duration obj+0x58, current obj+0x16c, target obj+0x1ac, shared start obj+0x34, and frame-time ctx+0xb550.
|
- **evidence:** Ghidra 0x47ecc0 calls matrix builder 0x48afb1 for target obj+0x1ac. Consumer 0x472f00 uses delay obj+0x44, duration obj+0x58, current obj+0x16c, target obj+0x1ac, shared start obj+0x34, and frame-time ctx+0xb550.
|
||||||
|
|
||||||
|
### 0x224 `clear-gfx-command-queue` (clear-gfx-command-queue, argc 0)
|
||||||
|
- **summary:** Clear the native gfx command queue rooted at ctx+0x418. Host-implicit because the port composites retained state directly.
|
||||||
|
- **grounding:** source=investigation, confidence=high, noop_headless=True
|
||||||
|
- **evidence:** Ghidra handler 0x417550 -> gfx_command_queue_clear 0x47cb10, which destroys queued nodes and restores the sentinel links/count.
|
||||||
|
|
||||||
### 0x228 `u00421940` (u00421940, argc 5)
|
### 0x228 `u00421940` (u00421940, argc 5)
|
||||||
- **summary:** 0x228 query-position (succ)(handle)(outX)(outY)(outZ): read the object's current computed position into vars (worker FUN_0047cdd0). C# VM: writes V24 + success flag. See docs/engine-re.md §SC0000 anim cluster.
|
- **summary:** 0x228 query-position (succ)(handle)(outX)(outY)(outZ): read the object's current computed position into vars (worker FUN_0047cdd0). C# VM: writes V24 + success flag. See docs/engine-re.md §SC0000 anim cluster.
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
@@ -225,6 +240,11 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r
|
|||||||
- **summary:** 0x23f query-object (out)(handle): return object status (FUN_0042a520; -1 if none). C# VM: 0 if the object exists else -1. See docs/engine-re.md §SC0000 anim cluster.
|
- **summary:** 0x23f query-object (out)(handle): return object status (FUN_0042a520; -1 if none). C# VM: 0 if the object exists else -1. See docs/engine-re.md §SC0000 anim cluster.
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|
||||||
|
### 0x243 `reset-anim-clock` (reset-anim-clock, argc 0)
|
||||||
|
- **summary:** Reset the native global animation-service elapsed and duration fields to zero when service flag bit 1 is clear.
|
||||||
|
- **grounding:** source=investigation, confidence=high
|
||||||
|
- **evidence:** Ghidra handler 0x4182d0: if !(ctx+0x51b80 & 2), set ctx+0x51b70=1 and zero ctx+0x51b78/+0x51b7c. Normal SC0000 label_1235a calls it before present-frame.
|
||||||
|
|
||||||
## input
|
## input
|
||||||
|
|
||||||
### 0x90 `hotspot-branch` (u0041BEB0, argc 7)
|
### 0x90 `hotspot-branch` (u0041BEB0, argc 7)
|
||||||
@@ -253,6 +273,11 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
|||||||
- **depends on:** 0x90
|
- **depends on:** 0x90
|
||||||
- **evidence:** interleaves with 0x90 in the shared ADV-chrome subroutine; trailing imm = action id 0x0/0x7/0x8; same widget cluster as 0x90/0x91/0x92/0x95; confirm via frida
|
- **evidence:** interleaves with 0x90 in the shared ADV-chrome subroutine; trailing imm = action id 0x0/0x7/0x8; same widget cluster as 0x90/0x91/0x92/0x95; confirm via frida
|
||||||
|
|
||||||
|
### 0x1c7 `get-message-skip` (get-message-skip, argc 1)
|
||||||
|
- **summary:** (out) - write 1 iff ADV message-skip run-state bit 0x08000000 is set, otherwise 0.
|
||||||
|
- **grounding:** source=investigation, confidence=high
|
||||||
|
- **evidence:** Ghidra handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). SC0000 label_1235a ORs it with op 0x1cc.
|
||||||
|
|
||||||
## marker
|
## marker
|
||||||
|
|
||||||
### 0x1bc `block-mark` (u00415670, argc 0)
|
### 0x1bc `block-mark` (u00415670, argc 0)
|
||||||
@@ -882,10 +907,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
|||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|
||||||
### 0x1c7 `u00414F90` (u00414F90, argc 1)
|
|
||||||
- **summary:** —
|
|
||||||
- **grounding:** source=kelebek, confidence=low
|
|
||||||
|
|
||||||
### 0x1c8 `toString` (toString, argc 2)
|
### 0x1c8 `toString` (toString, argc 2)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=med
|
- **grounding:** source=kelebek, confidence=med
|
||||||
@@ -898,10 +919,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
|||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|
||||||
### 0x1cc `u00415010` (u00415010, argc 1)
|
|
||||||
- **summary:** —
|
|
||||||
- **grounding:** source=kelebek, confidence=low
|
|
||||||
|
|
||||||
### 0x1ce `u0041B9F0` (u0041B9F0, argc 1)
|
### 0x1ce `u0041B9F0` (u0041B9F0, argc 1)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
@@ -970,10 +987,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
|||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|
||||||
### 0x21c `u00416270` (u00416270, argc 0)
|
|
||||||
- **summary:** —
|
|
||||||
- **grounding:** source=kelebek, confidence=low
|
|
||||||
|
|
||||||
### 0x21d `u00421410` (u00421410, argc 2)
|
### 0x21d `u00421410` (u00421410, argc 2)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
@@ -990,10 +1003,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
|||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|
||||||
### 0x224 `u00416290` (u00416290, argc 0)
|
|
||||||
- **summary:** —
|
|
||||||
- **grounding:** source=kelebek, confidence=low
|
|
||||||
|
|
||||||
### 0x22a `u00421A90` (u00421A90, argc 3)
|
### 0x22a `u00421A90` (u00421A90, argc 3)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
@@ -1042,10 +1051,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
|||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|
||||||
### 0x243 `u00417070` (u00417070, argc 0)
|
|
||||||
- **summary:** —
|
|
||||||
- **grounding:** source=kelebek, confidence=low
|
|
||||||
|
|
||||||
### 0x248 `u00422E80` (u00422E80, argc 1)
|
### 0x248 `u00422E80` (u00422E80, argc 1)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|||||||
@@ -715,3 +715,25 @@ geometry in the capture. It does **not** prove the port's exact matrix calculati
|
|||||||
multiplication order, or 2D projection: normal playback still races past these sections too quickly for a
|
multiplication order, or 2D projection: normal playback still races past these sections too quickly for a
|
||||||
reliable visual judgment. Treat that math as provisional until the pacing slice enables slow normal playback
|
reliable visual judgment. Treat that math as provisional until the pacing slice enables slow normal playback
|
||||||
and a native-versus-port frame comparison.
|
and a native-versus-port frame comparison.
|
||||||
|
|
||||||
|
### A2b — animation pacing + matrix validation ✅ (2026-07-10)
|
||||||
|
|
||||||
|
The remaining race was a unit mismatch at the scheduler boundary. Native
|
||||||
|
`adv_interpreter_tick` advances one opcode, while the live cadence probe counted calls to
|
||||||
|
`vm_operand_fetch` (about 1,788 operand reads/s). The port's `FrameYield` runs once per completed
|
||||||
|
opcode, so using 1,800 there overclocked script teardown by roughly ninefold. `FrameClock` now supplies a
|
||||||
|
refresh-independent 200 completed-opcode/s allowance; sleep and input waits discard parked-time credit, and
|
||||||
|
input is ignored unless the VM is actually at `wait-for-input`. `--speed` scales the unified VM,
|
||||||
|
sleep, and animation clock for inspection without `--shot-sequence` auto-advance.
|
||||||
|
|
||||||
|
Live native capture recorded the complete `0xcbc0` scale ramp (1→5 over 1,890 ms), including the exact
|
||||||
|
composed matrices. The port previously deleted the object at scale 1.44 after 226 ms. At 215/s calibration it
|
||||||
|
survived 1,798 ms to scale 4.72; the final 200/s replay retained it for 2,014 virtual ms, past the endpoint.
|
||||||
|
Native matrix terms and the port's focused tests agree on row-vector
|
||||||
|
`anchor + (point-anchor)*scale + translation`; for base `(0,600)`, anchor `(400,1000)`,
|
||||||
|
scale 5, both project to `(-1600,-1000)`. Exact axis-aligned anchor/order/projection is validated;
|
||||||
|
cyclic-rotation rasterization remains the next affine-rendering slice.
|
||||||
|
|
||||||
|
Verification: engine **92/92** after the opcode-clock reset test, corpus sweep unchanged at
|
||||||
|
**284 exit / 13 STEP-LIMIT**, Godot build and threaded `SELFTEST OK`. The transform capture tool and
|
||||||
|
transform-aware `--gfx-log` are documented in `docs/tools-reference.md`.
|
||||||
|
|||||||
@@ -135,13 +135,15 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
|
|||||||
- `--shot <png> [--shot-page N]` — capture page N to a PNG then quit (dev screenshot). At scene end it also prints the call-scripts executed as nested frames.
|
- `--shot <png> [--shot-page N]` — capture page N to a PNG then quit (dev screenshot). At scene end it also prints the call-scripts executed as nested frames.
|
||||||
- `--shot-sequence <dir> [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies **time-based (sleep-paced) effects** — e.g. the opening `AE*` burst — as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --boot --shot-sequence out/seq --frames 300`.
|
- `--shot-sequence <dir> [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies **time-based (sleep-paced) effects** — e.g. the opening `AE*` burst — as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --boot --shot-sequence out/seq --frames 300`.
|
||||||
- `--sleep-scale <f>` — multiply every `sleep` (op 0xc8) duration by `f` (default 1.0). The authentic opening burst is only ~2 s, too fast to eyeball live; `--sleep-scale 5` stretches it to ~10 s so the paced sequence (arcane `AE*` → character CGs → settled BG) is watchable. Debug-only; leave at 1.0 for real playback.
|
- `--sleep-scale <f>` — multiply every `sleep` (op 0xc8) duration by `f` (default 1.0). The authentic opening burst is only ~2 s, too fast to eyeball live; `--sleep-scale 5` stretches it to ~10 s so the paced sequence (arcane `AE*` → character CGs → settled BG) is watchable. Debug-only; leave at 1.0 for real playback.
|
||||||
|
- `--speed <f>` — scale the unified runtime clock (VM cadence, sleeps, and retained animation) without auto-advancing input waits. Values 0.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.
|
- `--gfx-log <file>` — **compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page.
|
||||||
Matrix-channel outcomes also include sampled `scale`/`trans` values. Parent directories are created automatically.
|
Matrix-channel outcomes also include `base`, `anchor`, projected `dst`, and sampled `scale`/`trans` values. Parent directories are created automatically.
|
||||||
|
|
||||||
## Asset resolution / graphics
|
## Asset resolution / graphics
|
||||||
|
|
||||||
| Tool | Purpose | Run | Reads → Writes |
|
| Tool | Purpose | Run | Reads → Writes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| `tools/frida/capture_native_transforms.py` | Capture the native retained-object transform input fields and the exact composed 4×4 matrix at `gfx_object_apply_transform_channels`. Optional handle filter; read-only. | `py -3.11 -u -X utf8 tools/frida/capture_native_transforms.py [secs] [pid|AGE.EXE] [--handle 0xHANDLE]` | running game → `build/native-transform-trace.jsonl` |
|
||||||
| `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). Each entry carries `raw_index` (its 0-based position in the SYS4INI record table incl. `@` placeholders) = the engine's universal file id. Also emits the **`call-script <id> → name`** map (id = `raw_index`; see `engine-re.md`). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN` → `build/asset-index.json` + `build/callscript-names.json` |
|
| `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). Each entry carries `raw_index` (its 0-based position in the SYS4INI record table incl. `@` placeholders) = the engine's universal file id. Also emits the **`call-script <id> → name`** map (id = `raw_index`; see `engine-re.md`). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN` → `build/asset-index.json` + `build/callscript-names.json` |
|
||||||
| `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py <SCENE> [resId]` | `build/asset-index.json` → `build/asset-sections.json`; resolves any (scene, resId) |
|
| `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py <SCENE> [resId]` | `build/asset-index.json` → `build/asset-sections.json`; resolves any (scene, resId) |
|
||||||
| `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json` → `build/frida-asset-loads.json` |
|
| `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json` → `build/frida-asset-loads.json` |
|
||||||
@@ -161,7 +163,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
|
|||||||
| `tools/frida/map_imports.py` (+ `map_imports_full.py`) | ★ **Name dynamically-resolved Win32 APIs** in the Ghidra image. Read-only: maps live-process module exports → `{addr→dll!Func}`, scans the `0x400000` module for pointer matches → `RVA→name` (ASLR-stable). `--recon` = clustering report (the gate); default writes the map. Applied to `/v2` via a `run_script_inline` pass → 248 `imp_<dll>_<func>` labels at the RVA `0x16f000` IAT (validated: CreateFileA/SetFilePointer/timeGetTime). Pure scan/cluster logic unit-tested (`test_map_imports.py`). | `py -3.11 -u -X utf8 tools/frida/map_imports.py [--recon]` | running game → `build/import-map.json` (+ `-singletons.json`) |
|
| `tools/frida/map_imports.py` (+ `map_imports_full.py`) | ★ **Name dynamically-resolved Win32 APIs** in the Ghidra image. Read-only: maps live-process module exports → `{addr→dll!Func}`, scans the `0x400000` module for pointer matches → `RVA→name` (ASLR-stable). `--recon` = clustering report (the gate); default writes the map. Applied to `/v2` via a `run_script_inline` pass → 248 `imp_<dll>_<func>` labels at the RVA `0x16f000` IAT (validated: CreateFileA/SetFilePointer/timeGetTime). Pure scan/cluster logic unit-tested (`test_map_imports.py`). | `py -3.11 -u -X utf8 tools/frida/map_imports.py [--recon]` | running game → `build/import-map.json` (+ `-singletons.json`) |
|
||||||
| `tools/frida/probe_handlers.py` | Probe which region the interpreter executes from (module vs heap). Confirmed: **operand-fetch `+0x1b940` fires ~8500/s ⇒ interpreter runs from the module `0x400000`** (handlers hookable by dump address). | `py -3.11 -u -X utf8 tools/frida/probe_handlers.py [pid]` | running game → stdout (per-hook fire counts) |
|
| `tools/frida/probe_handlers.py` | Probe which region the interpreter executes from (module vs heap). Confirmed: **operand-fetch `+0x1b940` fires ~8500/s ⇒ interpreter runs from the module `0x400000`** (handlers hookable by dump address). | `py -3.11 -u -X utf8 tools/frida/probe_handlers.py [pid]` | running game → stdout (per-hook fire counts) |
|
||||||
| `tools/frida/capture_gfx_objects.py` | Capture the native gfx object-manager state: grab engine ctx (`esi` via operand-fetch `ecx`), poll the object-record array `[esi+0x53d64]` (20×120B; `field[0]=0xffffffff`=free, cmd-type at rec+0x24). **⚠ Its "0 CG records ⇒ drift is state-divergence" reading was DISPROVEN** (Ghidra: op 0x215 read settles the drift as a native command-buffer op — `docs/engine-re.md`; the poll observed the record array, not the lookup map that drives the branch, and cmd-buffer records are transient). Kept as a runtime-observation tool. | `py -3.11 -u -X utf8 tools/frida/capture_gfx_objects.py [pid] [secs]` | running game → `build/gfx-objects.jsonl` |
|
| `tools/frida/capture_gfx_objects.py` | Capture the native gfx object-manager state: grab engine ctx (`esi` via operand-fetch `ecx`), poll the object-record array `[esi+0x53d64]` (20×120B; `field[0]=0xffffffff`=free, cmd-type at rec+0x24). **⚠ Its "0 CG records ⇒ drift is state-divergence" reading was DISPROVEN** (Ghidra: op 0x215 read settles the drift as a native command-buffer op — `docs/engine-re.md`; the poll observed the record array, not the lookup map that drives the branch, and cmd-buffer records are transient). Kept as a runtime-observation tool. | `py -3.11 -u -X utf8 tools/frida/capture_gfx_objects.py [pid] [secs]` | running game → `build/gfx-objects.jsonl` |
|
||||||
| `tools/frida/probe_frame_cadence.py` | **Frame-cadence probe** (`docs/engine-re.md` "Frame cadence — live measurement"): plain-JS hook on operand-fetch `0x41b940` (grab ctx + count exec rate) + system-DLL message/timing hooks; auto-buckets by Ctrl/skip-bit. Measured: exec **rate-limited** ~1788 ops/sec normal, ~4× fast-forward. **Read-only/import-only — never CModule-hook the hot interpreter (crashes the game).** Play actively during capture; hold Ctrl the back half. | `py -3.11 -u -X utf8 tools/frida/probe_frame_cadence.py [secs] [proc]` | running game → `build/frida-frame-cadence.jsonl` + stdout report |
|
| `tools/frida/probe_frame_cadence.py` | **Frame-cadence probe** (`docs/engine-re.md` "Frame cadence — live measurement"): plain-JS hook on operand-fetch `0x41b940` (grab ctx + count operand reads) + system-DLL message/timing hooks; auto-buckets by Ctrl/skip-bit. Measured ~1,788 **operand fetches/sec** normal, ~4× fast-forward; this is not an opcode count. **Read-only/import-only — never CModule-hook the hot interpreter (crashes the game).** Play actively during capture; hold Ctrl the back half. | `py -3.11 -u -X utf8 tools/frida/probe_frame_cadence.py [secs] [proc]` | running game → `build/frida-frame-cadence.jsonl` + stdout report |
|
||||||
| `tools/frida/probe_present.py` | **Present-rate probe:** grab ctx, scan it for the D3D9 device (d3d9-vtable object with a full ~119-method table), hook `IDirect3DDevice9::Present`/`EndScene` (+ GDI-blit fallback). Found: **D3D9, UNCAPPED** (`Present` ~1908/sec, no vsync; no `ddraw`; 2D StretchRect compositor) ⇒ no fixed frame rate. Click 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/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/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) |
|
| `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]
|
[Fact]
|
||||||
public void EffectiveBudget_ScalesBySpeed_AndFloorsAtOne()
|
public void Advance_RetainsFractionalMillisecondsAtSlowSpeed()
|
||||||
{
|
{
|
||||||
Assert.Equal(30, new FrameClock { OpsPerFrame = 30, Speed = 1.0 }.EffectiveBudget);
|
var c = new FrameClock { Speed = 0.1 };
|
||||||
Assert.Equal(120, new FrameClock { OpsPerFrame = 30, Speed = 4.0 }.EffectiveBudget);
|
for (int i = 0; i < 10; i++) c.Advance(1.0 / 144.0);
|
||||||
Assert.Equal(1, new FrameClock { OpsPerFrame = 0, Speed = 1.0 }.EffectiveBudget);
|
Assert.Equal(6, c.NowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WallClockPacer_RateDoesNotDependOnRenderCallbackCount()
|
||||||
|
{
|
||||||
|
static long Simulate(int callbacks)
|
||||||
|
{
|
||||||
|
var c = new FrameClock();
|
||||||
|
var p = new WallClockOpPacer(c);
|
||||||
|
long ops = 0;
|
||||||
|
p.OpcodeCompleted(); ops++;
|
||||||
|
for (int frame = 0; frame < callbacks; frame++)
|
||||||
|
{
|
||||||
|
c.Advance(1.0 / callbacks);
|
||||||
|
while (p.CanRunNext) { p.OpcodeCompleted(); ops++; }
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
long at60 = Simulate(60), at144 = Simulate(144), at240 = Simulate(240);
|
||||||
|
Assert.InRange(at60, 198, 202);
|
||||||
|
Assert.InRange(at144, 198, 202);
|
||||||
|
Assert.InRange(at240, 198, 202);
|
||||||
|
Assert.InRange(System.Math.Abs(at60 - at144), 0, 2);
|
||||||
|
Assert.InRange(System.Math.Abs(at60 - at240), 0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WallClockPacer_ResetDropsParkedTimeCredit()
|
||||||
|
{
|
||||||
|
var c = new FrameClock();
|
||||||
|
var p = new WallClockOpPacer(c);
|
||||||
|
p.OpcodeCompleted();
|
||||||
|
c.Advance(10);
|
||||||
|
p.Reset();
|
||||||
|
p.OpcodeCompleted();
|
||||||
|
Assert.False(p.CanRunNext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,23 @@ public class GfxAnimationTests
|
|||||||
Assert.Equal(1, vm.Gfx.AnimClockGeneration);
|
Assert.Equal(1, vm.Gfx.AnimClockGeneration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetAnimClock_DispatchClearsOnlyGlobalServiceClock()
|
||||||
|
{
|
||||||
|
var t = T();
|
||||||
|
var scene = ScriptAssembler.Assemble(t, "CLOCKRESET", new List<(int, Operand[])>
|
||||||
|
{
|
||||||
|
MovGI(1, 400),
|
||||||
|
(0x238, new[] { G(1) }),
|
||||||
|
(0x243, System.Array.Empty<Operand>()),
|
||||||
|
Exit(),
|
||||||
|
}, System.Array.Empty<string>());
|
||||||
|
var vm = new VirtualMachine(scene, t, new RecordingHost());
|
||||||
|
vm.Run();
|
||||||
|
Assert.Equal(0, vm.Gfx.AnimClockDurationTicks);
|
||||||
|
Assert.Equal(2, vm.Gfx.AnimClockGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SnapshotSamplesDelayedMatrixChannels_WithoutUsingZAsOpacity()
|
public void SnapshotSamplesDelayedMatrixChannels_WithoutUsingZAsOpacity()
|
||||||
{
|
{
|
||||||
@@ -138,4 +155,30 @@ public class GfxAnimationTests
|
|||||||
Assert.True(done.Rotation.Enabled);
|
Assert.True(done.Rotation.Enabled);
|
||||||
Assert.Equal(255, done.Alpha); // scale-Z=3, translation-Z=99, rotation-axis-Z=5: still opaque
|
Assert.Equal(255, done.Alpha); // scale-Z=3, translation-Z=99, rotation-axis-Z=5: still opaque
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transform2D_UsesNativeAnchoredRowVectorOrder_AndDirectProjection()
|
||||||
|
{
|
||||||
|
var t = new TransformState(2, 3, 99, 10, -7, 1234, 100, 50, 888);
|
||||||
|
var p = Transform2DMath.Apply(120, 60, t);
|
||||||
|
Assert.Equal((150.0, 73.0), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transform2D_NegativeScaleMovesFarEdgeAcrossAnchor()
|
||||||
|
{
|
||||||
|
var t = new TransformState(-2, 1, 1, 0, 0, 0, 100, 0, 0);
|
||||||
|
var left = Transform2DMath.Apply(90, 0, t);
|
||||||
|
var right = Transform2DMath.Apply(110, 0, t);
|
||||||
|
Assert.Equal((120.0, 0.0), left);
|
||||||
|
Assert.Equal((80.0, 0.0), right);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transform2D_MatchesCapturedNativeSc0000ScaleEndpoint()
|
||||||
|
{
|
||||||
|
// Native handle 0xcbc0: base=(0,600), anchor=(400,1000), scale=5.
|
||||||
|
var t = new TransformState(5, 5, 1, 0, 0, 0, 400, 1000, 0);
|
||||||
|
Assert.Equal((-1600.0, -1000.0), Transform2DMath.Apply(0, 600, t));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,75 @@
|
|||||||
namespace Age.Engine.Hosting;
|
namespace Age.Engine.Hosting;
|
||||||
|
|
||||||
/// <summary>Host-owned virtual clock + per-frame op budget. Pure (no threading): the Godot host
|
/// <summary>Host-owned virtual clock. Godot advances it from real elapsed time; VM pacing, sleeps, and
|
||||||
/// advances it once per rendered frame and consults it to pace the VM. The one <see cref="Speed"/>
|
/// retained graphics all consume this same timebase. Fractional milliseconds are retained so diagnostic
|
||||||
/// factor is the future (unwired) Ctrl fast-forward multiplier — scaling it scales the throttle
|
/// slow motion does not stall on high-refresh displays.</summary>
|
||||||
/// budget, sleeps, and the anim tween together. See docs/superpowers/specs/2026-07-08-frame-stepped-vm-design.md.</summary>
|
|
||||||
public sealed class FrameClock
|
public sealed class FrameClock
|
||||||
{
|
{
|
||||||
/// <summary>Monotonic virtual time in milliseconds (scaled by Speed).</summary>
|
private long _nowMs;
|
||||||
public long NowMs { get; private set; }
|
private double _fractionalMs;
|
||||||
|
|
||||||
/// <summary>Speed multiplier. 1.0 = normal. The future Ctrl hook (ADV-scoped); leave at 1.0 for now.</summary>
|
/// <summary>Monotonic virtual time in milliseconds (scaled by Speed).</summary>
|
||||||
|
public long NowMs => System.Threading.Interlocked.Read(ref _nowMs);
|
||||||
|
|
||||||
|
/// <summary>Speed multiplier. 1.0 = normal. A lower diagnostic value slows VM progress, sleeps, and
|
||||||
|
/// graphics together; a future ADV-scoped Ctrl hook can drive the same seam.</summary>
|
||||||
public double Speed = 1.0;
|
public double Speed = 1.0;
|
||||||
|
|
||||||
/// <summary>Base per-frame interpreter op budget (tunable by eye; ~30 ≈ 1,800 ops/sec at 60fps).</summary>
|
/// <summary>Native normal-playback interpreter cadence. The old 1,800 figure counted calls to
|
||||||
public int OpsPerFrame = 30;
|
/// vm_operand_fetch, not completed opcodes. A live 1,890 ms transform section executes about 407
|
||||||
|
/// port opcodes. A normal-speed replay at 215/s retained the object for 1,798 ms; 200/s reaches
|
||||||
|
/// the native 1,890 ms endpoint before the same teardown path.</summary>
|
||||||
|
public double OpsPerSecond = 200.0;
|
||||||
|
|
||||||
/// <summary>Advance the clock by one rendered frame's real delta (seconds), scaled by Speed.</summary>
|
/// <summary>Advance the clock by one rendered frame's real delta (seconds), scaled by Speed.</summary>
|
||||||
public void Advance(double realDeltaSeconds) => NowMs += (long)(realDeltaSeconds * 1000.0 * Speed);
|
public void Advance(double realDeltaSeconds)
|
||||||
|
{
|
||||||
/// <summary>Ops the VM may run before yielding a frame, scaled by Speed (min 1).</summary>
|
double scaled = realDeltaSeconds * 1000.0 * Speed + _fractionalMs;
|
||||||
public int EffectiveBudget => System.Math.Max(1, (int)System.Math.Round(OpsPerFrame * Speed));
|
long whole = (long)System.Math.Floor(scaled);
|
||||||
|
_fractionalMs = scaled - whole;
|
||||||
|
if (whole > 0) System.Threading.Interlocked.Add(ref _nowMs, whole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pure wall-clock opcode rate limiter. The VM thread records completed opcodes and waits whenever
|
||||||
|
/// it has consumed the allowance earned from <see cref=FrameClock.NowMs/>. Reset after a blocking wait so
|
||||||
|
/// parked time never turns into a catch-up burst.</summary>
|
||||||
|
public sealed class WallClockOpPacer
|
||||||
|
{
|
||||||
|
private readonly FrameClock _clock;
|
||||||
|
private bool _started;
|
||||||
|
private long _epochMs;
|
||||||
|
private long _completed;
|
||||||
|
|
||||||
|
public WallClockOpPacer(FrameClock clock) => _clock = clock;
|
||||||
|
|
||||||
|
public void OpcodeCompleted()
|
||||||
|
{
|
||||||
|
if (!_started)
|
||||||
|
{
|
||||||
|
_started = true;
|
||||||
|
_epochMs = _clock.NowMs;
|
||||||
|
_completed = 0;
|
||||||
|
}
|
||||||
|
_completed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the next opcode may execute at the clock's current time.</summary>
|
||||||
|
public bool CanRunNext
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!_started) return true;
|
||||||
|
long elapsed = System.Math.Max(0, _clock.NowMs - _epochMs);
|
||||||
|
long allowance = 1 + (long)System.Math.Floor(elapsed * _clock.OpsPerSecond / 1000.0);
|
||||||
|
return _completed < allowance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_started = false;
|
||||||
|
_epochMs = 0;
|
||||||
|
_completed = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,12 @@ public sealed class GfxState
|
|||||||
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
|
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Op 0x243: reset the separate global animation-service clock.</summary>
|
||||||
|
public void ResetAnimClock()
|
||||||
|
{
|
||||||
|
lock (_lock) { AnimClockDurationTicks = 0; AnimClockGeneration++; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
|
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
|
||||||
/// callers and existing tests.</summary>
|
/// callers and existing tests.</summary>
|
||||||
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
|
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
|
||||||
|
|||||||
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;
|
Gfx.SetRotationCycle(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1;
|
||||||
case "set-anim-clock": // 0x238 (duration) — global, non-blocking (host advances it per-frame)
|
case "set-anim-clock": // 0x238 (duration) — global, non-blocking (host advances it per-frame)
|
||||||
Gfx.SetAnimClock(Read(a[0])); return pc + 1;
|
Gfx.SetAnimClock(Read(a[0])); return pc + 1;
|
||||||
|
case "reset-anim-clock": // 0x243: reset the separate global animation-service clock
|
||||||
|
Gfx.ResetAnimClock(); return pc + 1;
|
||||||
|
case "mark-frame-yield": // 0x21c: host already yields after every completed opcode
|
||||||
|
case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue
|
||||||
|
return pc + 1;
|
||||||
default:
|
default:
|
||||||
// Stub is per-instruction frequency (the VM handles ~30 ops; the rest hit here, e.g.
|
// Stub is per-instruction frequency (the VM handles ~30 ops; the rest hit here, e.g.
|
||||||
// 0x258/0x259 stmt markers appear en masse), so gate it with Step — else --trace floods.
|
// 0x258/0x259 stmt markers appear en masse), so gate it with Step — else --trace floods.
|
||||||
|
|||||||
@@ -14,14 +14,16 @@ public sealed class GodotAdvHost : IHost
|
|||||||
private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } };
|
private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } };
|
||||||
private readonly SemaphoreSlim _gate = new(0, 1);
|
private readonly SemaphoreSlim _gate = new(0, 1);
|
||||||
private readonly Age.Engine.Hosting.FrameClock _clock;
|
private readonly Age.Engine.Hosting.FrameClock _clock;
|
||||||
|
private readonly Age.Engine.Hosting.WallClockOpPacer _opPacer;
|
||||||
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
|
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
|
||||||
private int _opsSinceYield;
|
private volatile bool _stopping;
|
||||||
public volatile bool IsWaiting;
|
public volatile bool IsWaiting;
|
||||||
public readonly List<(int Offset, string Text)> Captured = new();
|
public readonly List<(int Offset, string Text)> Captured = new();
|
||||||
|
|
||||||
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock)
|
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock)
|
||||||
{
|
{
|
||||||
_main = main; _res = res; _scene = scene; _clock = clock;
|
_main = main; _res = res; _scene = scene; _clock = clock;
|
||||||
|
_opPacer = new Age.Engine.Hosting.WallClockOpPacer(clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowText(int offset, string text)
|
public void ShowText(int offset, string text)
|
||||||
@@ -39,11 +41,19 @@ public sealed class GodotAdvHost : IHost
|
|||||||
IsWaiting = true;
|
IsWaiting = true;
|
||||||
_gate.Wait();
|
_gate.Wait();
|
||||||
IsWaiting = false;
|
IsWaiting = false;
|
||||||
|
_opPacer.Reset();
|
||||||
_main.CallDeferred("ClearPage");
|
_main.CallDeferred("ClearPage");
|
||||||
}
|
}
|
||||||
|
|
||||||
// called from the main thread (click) or the selftest auto-clicker
|
// called from the main thread (click) or the selftest auto-clicker
|
||||||
public void SignalInput() { if (_gate.CurrentCount == 0) _gate.Release(); }
|
public void SignalInput() { if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); }
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_stopping = true;
|
||||||
|
if (_gate.CurrentCount == 0) _gate.Release();
|
||||||
|
_frameSignal.Set();
|
||||||
|
}
|
||||||
|
|
||||||
// Main thread, once per rendered frame: releases a VM thread parked in FrameYield/Sleep.
|
// Main thread, once per rendered frame: releases a VM thread parked in FrameYield/Sleep.
|
||||||
public void PulseFrame() => _frameSignal.Set();
|
public void PulseFrame() => _frameSignal.Set();
|
||||||
@@ -53,11 +63,9 @@ public sealed class GodotAdvHost : IHost
|
|||||||
// interpreter to ~budget ops per rendered frame (the native engine's rate-limited cadence).
|
// interpreter to ~budget ops per rendered frame (the native engine's rate-limited cadence).
|
||||||
public void FrameYield()
|
public void FrameYield()
|
||||||
{
|
{
|
||||||
if (++_opsSinceYield < _clock.EffectiveBudget) return;
|
_opPacer.OpcodeCompleted();
|
||||||
_opsSinceYield = 0;
|
while (!_opPacer.CanRunNext && !_stopping)
|
||||||
long start = _clock.NowMs;
|
_frameSignal.WaitOne(50);
|
||||||
while (_clock.NowMs == start) // wait until a real _Process advanced the clock
|
|
||||||
if (!_frameSignal.WaitOne(50)) break; // 50ms safety cap: never hang if _Process stalls
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// op 0xc8: block the VM background thread so the main-thread compositor (Main.Recomposite in _Process)
|
// op 0xc8: block the VM background thread so the main-thread compositor (Main.Recomposite in _Process)
|
||||||
@@ -73,7 +81,11 @@ public sealed class GodotAdvHost : IHost
|
|||||||
long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window
|
long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window
|
||||||
long deadline = _clock.NowMs + ms;
|
long deadline = _clock.NowMs + ms;
|
||||||
while (_clock.NowMs < deadline)
|
while (_clock.NowMs < deadline)
|
||||||
if (!_frameSignal.WaitOne(2000)) break; // safety cap
|
{
|
||||||
|
if (_stopping) break;
|
||||||
|
_frameSignal.WaitOne(50);
|
||||||
|
}
|
||||||
|
_opPacer.Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ----
|
// ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ----
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ public partial class Main : Godot.Control
|
|||||||
string scene = "SC0000"; // --scene <NAME>: which scene to play (default SC0000)
|
string scene = "SC0000"; // --scene <NAME>: which scene to play (default SC0000)
|
||||||
var seeds = new List<(int Addr, long Val)>(); // --seed 0xADDR=VAL (repeatable) — initial global state
|
var seeds = new List<(int Addr, long Val)>(); // --seed 0xADDR=VAL (repeatable) — initial global state
|
||||||
double sleepScale = 1.0; // --sleep-scale <f>: slow/speed the paced opening for inspection
|
double sleepScale = 1.0; // --sleep-scale <f>: slow/speed the paced opening for inspection
|
||||||
|
double speed = 1.0; // --speed <f>: whole-runtime diagnostic speed
|
||||||
string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run
|
string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run
|
||||||
for (int i = 0; i < userArgs.Length; i++)
|
for (int i = 0; i < userArgs.Length; i++)
|
||||||
{
|
{
|
||||||
@@ -105,6 +106,7 @@ public partial class Main : Godot.Control
|
|||||||
if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1];
|
if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1];
|
||||||
if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames);
|
if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames);
|
||||||
if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale);
|
if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale);
|
||||||
|
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
|
||||||
if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1];
|
if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1];
|
||||||
if (userArgs[i] == "--seed" && i + 1 < userArgs.Length)
|
if (userArgs[i] == "--seed" && i + 1 < userArgs.Length)
|
||||||
{
|
{
|
||||||
@@ -118,6 +120,9 @@ public partial class Main : Godot.Control
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!double.IsFinite(speed) || speed <= 0) speed = 1.0;
|
||||||
|
_clock.Speed = System.Math.Clamp(speed, 0.05, 8.0);
|
||||||
|
|
||||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||||
// Full op handling everywhere: the provider lets call-script load & run subroutines. Selftest
|
// Full op handling everywhere: the provider lets call-script load & run subroutines. Selftest
|
||||||
// runs a SYNTHESIZED scene (not a real scene in a crippled mode) so its output is deterministic.
|
// runs a SYNTHESIZED scene (not a real scene in a crippled mode) so its output is deterministic.
|
||||||
@@ -211,7 +216,7 @@ public partial class Main : Godot.Control
|
|||||||
_host.SignalInput();
|
_host.SignalInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void _ExitTree() { DumpHistogram(); _host?.SignalInput(); }
|
public override void _ExitTree() { DumpHistogram(); _host?.Stop(); }
|
||||||
|
|
||||||
// Write the real-run op/call-site histogram to --trace-histogram <file>. Idempotent; called when the
|
// Write the real-run op/call-site histogram to --trace-histogram <file>. Idempotent; called when the
|
||||||
// scene ends or the window closes (the opening parks at wait-for-input, so closing is the usual trigger).
|
// scene ends or the window closes (the opening parks at wait-for-input, so closing is the usual trigger).
|
||||||
@@ -245,8 +250,9 @@ public partial class Main : Godot.Control
|
|||||||
foreach (var v in _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs)) // interpolate at the throttled clock
|
foreach (var v in _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs)) // interpolate at the throttled clock
|
||||||
{
|
{
|
||||||
var t = v.Transform;
|
var t = v.Transform;
|
||||||
int dstX = (int)System.Math.Round(t.AnchorX + (v.DstX - t.AnchorX) * t.ScaleX + t.TranslateX);
|
var projected = Age.Engine.Model.Transform2DMath.Apply(v.DstX, v.DstY, t);
|
||||||
int dstY = (int)System.Math.Round(t.AnchorY + (v.DstY - t.AnchorY) * t.ScaleY + t.TranslateY);
|
int dstX = (int)System.Math.Round(projected.X);
|
||||||
|
int dstY = (int)System.Math.Round(projected.Y);
|
||||||
float opacity = v.Alpha / 255f; // transform Z is never opacity
|
float opacity = v.Alpha / 255f; // transform Z is never opacity
|
||||||
float strength = v.TintStrength / 255f; // tint-blend / fill strength
|
float strength = v.TintStrength / 255f; // tint-blend / fill strength
|
||||||
string outcome;
|
string outcome;
|
||||||
@@ -265,6 +271,7 @@ public partial class Main : Godot.Control
|
|||||||
float fillA = opacity * strength;
|
float fillA = opacity * strength;
|
||||||
FillQuad(fillX, fillY, fw, fh, v.Tint, fillA);
|
FillQuad(fillX, fillY, fw, fh, v.Tint, fillA);
|
||||||
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {fw}x{fh}@({fillX},{fillY}) " +
|
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {fw}x{fh}@({fillX},{fillY}) " +
|
||||||
|
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
|
||||||
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
|
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
|
||||||
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0})";
|
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0})";
|
||||||
}
|
}
|
||||||
@@ -280,7 +287,8 @@ public partial class Main : Godot.Control
|
|||||||
dstX, dstY, t.ScaleX, t.ScaleY, opacity);
|
dstX, dstY, t.ScaleX, t.ScaleY, opacity);
|
||||||
var raw = _vm.Gfx.TryGet(v.Handle);
|
var raw = _vm.Gfx.TryGet(v.Handle);
|
||||||
outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {System.IO.Path.GetFileName(bmp)} " +
|
outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {System.IO.Path.GetFileName(bmp)} " +
|
||||||
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) dst=({dstX},{dstY}) " +
|
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
|
||||||
|
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
|
||||||
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
|
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
|
||||||
$"op={opacity:0.00} tintStr={strength:0.00}";
|
$"op={opacity:0.00} tintStr={strength:0.00}";
|
||||||
}
|
}
|
||||||
@@ -367,15 +375,17 @@ public partial class Main : Godot.Control
|
|||||||
byte[] dst = _screen.GetData(); byte[] ss = src.GetData();
|
byte[] dst = _screen.GetData(); byte[] ss = src.GetData();
|
||||||
int dw = _screen.GetWidth(), dh = _screen.GetHeight(), sfw = src.GetWidth();
|
int dw = _screen.GetWidth(), dh = _screen.GetHeight(), sfw = src.GetWidth();
|
||||||
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
|
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
|
||||||
for (int y = 0; y < outH; y++)
|
int x0 = System.Math.Max(0, -outX), x1 = System.Math.Min(outW, dw - outX);
|
||||||
for (int x = 0; x < outW; x++)
|
int y0 = System.Math.Max(0, -outY), y1 = System.Math.Min(outH, dh - outY);
|
||||||
|
if (x1 <= x0 || y1 <= y0) return;
|
||||||
|
for (int y = y0; y < y1; y++)
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
{
|
{
|
||||||
int sampleX = System.Math.Min(sw - 1, (int)(x / absScaleX));
|
int sampleX = System.Math.Min(sw - 1, (int)(x / absScaleX));
|
||||||
int sampleY = System.Math.Min(sh - 1, (int)(y / absScaleY));
|
int sampleY = System.Math.Min(sh - 1, (int)(y / absScaleY));
|
||||||
if (scaleX < 0) sampleX = sw - 1 - sampleX;
|
if (scaleX < 0) sampleX = sw - 1 - sampleX;
|
||||||
if (scaleY < 0) sampleY = sh - 1 - sampleY;
|
if (scaleY < 0) sampleY = sh - 1 - sampleY;
|
||||||
int dxp = outX + x, dyp = outY + y;
|
int dxp = outX + x, dyp = outY + y;
|
||||||
if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue;
|
|
||||||
int di = (dyp * dw + dxp) * 4;
|
int di = (dyp * dw + dxp) * 4;
|
||||||
int si = ((srcY + sampleY) * sfw + (srcX + sampleX)) * 4;
|
int si = ((srcY + sampleY) * sfw + (srcX + sampleX)) * 4;
|
||||||
int sa = ss[si + 3] * ia / 255; // texel alpha (colorkey already 0) × object opacity
|
int sa = ss[si + 3] * ia / 255; // texel alpha (colorkey already 0) × object opacity
|
||||||
@@ -400,11 +410,13 @@ public partial class Main : Godot.Control
|
|||||||
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
|
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
|
||||||
byte[] dst = _screen.GetData();
|
byte[] dst = _screen.GetData();
|
||||||
int dw = _screen.GetWidth(), dh = _screen.GetHeight();
|
int dw = _screen.GetWidth(), dh = _screen.GetHeight();
|
||||||
for (int y = 0; y < h; y++)
|
int x0 = System.Math.Max(0, -dstX), x1 = System.Math.Min(w, dw - dstX);
|
||||||
for (int x = 0; x < w; x++)
|
int y0 = System.Math.Max(0, -dstY), y1 = System.Math.Min(h, dh - dstY);
|
||||||
|
if (x1 <= x0 || y1 <= y0) return;
|
||||||
|
for (int y = y0; y < y1; y++)
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
{
|
{
|
||||||
int dxp = dstX + x, dyp = dstY + y;
|
int dxp = dstX + x, dyp = dstY + y;
|
||||||
if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue;
|
|
||||||
int di = (dyp * dw + dxp) * 4;
|
int di = (dyp * dw + dxp) * 4;
|
||||||
dst[di] = (byte)((tr * ia + dst[di] * (255 - ia)) / 255);
|
dst[di] = (byte)((tr * ia + dst[di] * (255 - ia)) / 255);
|
||||||
dst[di + 1] = (byte)((tg * ia + dst[di + 1] * (255 - ia)) / 255);
|
dst[di + 1] = (byte)((tg * ia + dst[di + 1] * (255 - ia)) / 255);
|
||||||
|
|||||||
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.
|
data instead of by feel.
|
||||||
|
|
||||||
SAFE pattern (matches capture_gfx_objects.py, which runs without crashing): plain-JS hooks only, no
|
SAFE pattern (matches capture_gfx_objects.py, which runs without crashing): plain-JS hooks only, no
|
||||||
CModule; the only engine-code hook is the PROVEN operand-fetch helper `0x41b940` (fires per opcode,
|
CModule; the only engine-code hook is the PROVEN operand-fetch helper `0x41b940` (fires once per
|
||||||
ecx = context) used to (a) grab the context pointer once and (b) count execution rate. Frame timing
|
operand read, often several times per opcode; ecx = context) used to (a) grab the context pointer once
|
||||||
|
and (b) count operand-fetch rate. Frame timing
|
||||||
comes from SYSTEM-DLL hooks (user32 message pump — never engine code, never anti-tamper). Engine state
|
comes from SYSTEM-DLL hooks (user32 message pump — never engine code, never anti-tamper). Engine state
|
||||||
(coroutine PC, run-state flags, sleep timer) is READ-ONLY polled. Nothing patches engine code beyond the
|
(coroutine PC, run-state flags, sleep timer) is READ-ONLY polled. Nothing patches engine code beyond the
|
||||||
one address our other scripts already prove is safe.
|
one address our other scripts already prove is safe.
|
||||||
@@ -29,7 +30,7 @@ from pathlib import Path
|
|||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
OUT = REPO / "build" / "frida-frame-cadence.jsonl"
|
OUT = REPO / "build" / "frida-frame-cadence.jsonl"
|
||||||
|
|
||||||
OPFETCH_OFF = 0x1b940 # operand-fetch helper (0x41b940); per-op, ecx=ctx. PROVEN-safe hook.
|
OPFETCH_OFF = 0x1b940 # operand-fetch helper (0x41b940); per operand read, ecx=ctx. PROVEN-safe hook.
|
||||||
IDX_OFF = 0x53d14 # current coroutine index
|
IDX_OFF = 0x53d14 # current coroutine index
|
||||||
PC_BASE = 0x53d2c # per-coroutine record base; +idx*0x78 holds the PC pointer (deref = opcode)
|
PC_BASE = 0x53d2c # per-coroutine record base; +idx*0x78 holds the PC pointer (deref = opcode)
|
||||||
PC_STRIDE = 0x78
|
PC_STRIDE = 0x78
|
||||||
|
|||||||
@@ -4110,19 +4110,19 @@ observed_types = ["imm", "l-int"]
|
|||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x1c7
|
op = 0x1c7
|
||||||
label = "u00414F90"
|
label = "get-message-skip"
|
||||||
argc = 1
|
argc = 1
|
||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "u00414F90"
|
name = "get-message-skip"
|
||||||
category = "unknown"
|
category = "input"
|
||||||
summary = ""
|
summary = "(out) - write 1 iff ADV message-skip run-state bit 0x08000000 is set, otherwise 0."
|
||||||
noop_headless = false
|
noop_headless = false
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "low"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = []
|
||||||
evidence = ""
|
evidence = "Ghidra handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). SC0000 label_1235a ORs it with op 0x1cc."
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
@@ -4199,19 +4199,19 @@ observed_types = ["g-int"]
|
|||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x1cc
|
op = 0x1cc
|
||||||
label = "u00415010"
|
label = "get-adv-service-state"
|
||||||
argc = 1
|
argc = 1
|
||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "u00415010"
|
name = "get-adv-service-state"
|
||||||
category = "unknown"
|
category = "control"
|
||||||
summary = ""
|
summary = "(out) - copy native ADV service state ctx+0x6dbd4; label_1235a ORs it with message-skip to select its yield branch."
|
||||||
noop_headless = false
|
noop_headless = false
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "low"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = []
|
||||||
evidence = ""
|
evidence = "Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Exact service-state producer remains outside this opcode."
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
@@ -5461,19 +5461,19 @@ observed_types = ["imm"]
|
|||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x21c
|
op = 0x21c
|
||||||
label = "u00416270"
|
label = "mark-frame-yield"
|
||||||
argc = 0
|
argc = 0
|
||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "u00416270"
|
name = "mark-frame-yield"
|
||||||
category = "unknown"
|
category = "control"
|
||||||
summary = ""
|
summary = "Set native run-state bit 0x400. Host-implicit: the port already offers a scheduler yield after every completed opcode."
|
||||||
noop_headless = false
|
noop_headless = true
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "low"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = []
|
||||||
evidence = ""
|
evidence = "Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a reaches it only when op 0x1c7 or 0x1cc is nonzero."
|
||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x21d
|
op = 0x21d
|
||||||
@@ -5728,19 +5728,19 @@ observed_types = ["imm", "g-int"]
|
|||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x224
|
op = 0x224
|
||||||
label = "u00416290"
|
label = "clear-gfx-command-queue"
|
||||||
argc = 0
|
argc = 0
|
||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "u00416290"
|
name = "clear-gfx-command-queue"
|
||||||
category = "unknown"
|
category = "draw"
|
||||||
summary = ""
|
summary = "Clear the native gfx command queue rooted at ctx+0x418. Host-implicit because the port composites retained state directly."
|
||||||
noop_headless = false
|
noop_headless = true
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "low"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = []
|
||||||
evidence = ""
|
evidence = "Ghidra handler 0x417550 -> gfx_command_queue_clear 0x47cb10, which destroys queued nodes and restores the sentinel links/count."
|
||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x228
|
op = 0x228
|
||||||
@@ -6450,19 +6450,19 @@ observed_types = ["imm"]
|
|||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x243
|
op = 0x243
|
||||||
label = "u00417070"
|
label = "reset-anim-clock"
|
||||||
argc = 0
|
argc = 0
|
||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "u00417070"
|
name = "reset-anim-clock"
|
||||||
category = "unknown"
|
category = "draw"
|
||||||
summary = ""
|
summary = "Reset the native global animation-service elapsed and duration fields to zero when service flag bit 1 is clear."
|
||||||
noop_headless = false
|
noop_headless = false
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "low"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = []
|
||||||
evidence = ""
|
evidence = "Ghidra handler 0x4182d0: if !(ctx+0x51b80 & 2), set ctx+0x51b70=1 and zero ctx+0x51b78/+0x51b7c. Normal SC0000 label_1235a calls it before present-frame."
|
||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x248
|
op = 0x248
|
||||||
|
|||||||
Reference in New Issue
Block a user