feat: add affine rotation rendering and timeline diagnostics

This commit is contained in:
gamer147
2026-07-10 17:54:33 -04:00
parent 4d225618f9
commit 768863b48e
16 changed files with 686 additions and 150 deletions

View File

@@ -445,26 +445,45 @@ independent matrix channels, not two encodings of one vec3 property.
- `0x220` passes raw operands 46 to `gfx_object_set_translation_channel` (`0x47ecc0`), stores timing at
`obj+0x44/+0x58`, and calls `0x48afb1`, which writes them into matrix entries 1214 at
`obj+0x1ac`: a **translation matrix**.
- `gfx_object_apply_transform_channels` (`0x472f00`) supplies the timing contract. Both channels use
- `0x21f` converts operands 47 to floats and calls `gfx_object_set_rotation_channel` (`0x47eb70`). It
stores delay/duration at `obj+0x40/+0x54`, target axis at `obj+0x1f8..0x200`, target angle (degrees)
at `obj+0x208`, and the target axis-angle matrix at `obj+0x12c`. Current axis/angle are
`obj+0x1ec..0x1f4/+0x204`, with current matrix `obj+0xec`.
- `gfx_object_apply_transform_channels` (`0x472f00`) supplies the timing contract. All three channels use
shared start timestamp `obj+0x34` and global frame-time `ctx+0xb550`, but have independent delay/duration:
scale `obj+0x3c/+0x50`, translation `obj+0x44/+0x58`. Each holds its current matrix through the
scale `obj+0x3c/+0x50`, rotation `obj+0x40/+0x54`, translation `obj+0x44/+0x58`. Each holds current through the
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)`;
**Exact composition and 2D reduction (live-validated 2026-07-10).** The one-shot consumer starts from identity and
right-multiplies `T(-V18) → scale-current → rotation-current → 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.
projection test and transform-aware gfx log reproduce those values.
**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`
and `TZ/100` alpha tween are gone.
`gfx_object_composite` then right-multiplies `gfx_object_anim_interpolate`'s separately anchored product,
which contains op `0x234`'s cyclic rotation. With the other oscillating matrices at identity, adjacent anchors
cancel and the full order is
`T(-V18) * scale * one-shot-rotation * translation * cyclic-rotation * T(+V18)`. Thus cyclic rotation
also rotates the translation vector. The cyclic angle is integer degrees
`floor(((frameTime-start) % period) * 360 / period)`; it wraps to zero without ping-pong. Positive Z produces
`m01=+sin, m10=-sin`, clockwise on the Y-down screen.
Native matrix oracle: handle `0xcb8e`, anchor `(700,600)`, scale current `0.9`, op `0x21f` target axis
`(0,0,1)`/30° after 500 ms for 390 ms, sampled 11 ms into the ramp as
`[0.9055,0.0134;-0.0134,0.9055]` with translation `(74.1449,47.3127)`. The port focused test matches
those terms. In the windowed port capture, the two SC0000 `0x234` sites (periods 9000/13000 ms, Z axes
`+1/-1`) advanced after 563 ms to integer angles `22/15`, exactly the native formula, and produced distinct
affine PNG frames. Nearest-neighbour inverse mapping is the deliberate software raster sampling policy;
native D3D9 subpixel filtering remains a possible pixel-level difference, not an uncertain matrix approximation.
**Port result (2026-07-10):** `GfxState` retains scale, one-shot rotation, translation, and cyclic rotation
with their native clocks/order. `Transform2DMath` composes the full row-vector 4×4 transform before 2D
projection. Godot uses an inverse-mapped affine RGBA8 rasterizer for textured objects and solid fills,
preserving colorkey/tint/opacity behavior and never deriving opacity from transform Z.
##### `anim_start`/`set_anim_clock` decoded + opening confirmed (2026-07-07, animation-slice Task 1)
@@ -687,8 +706,8 @@ per-frame bytecode. Reversed + annotated in Ghidra:
VM is parked at wait-for-input — no blocking present op, no VM/host frame-lockstep** (the answer to the
"frame loop" question).
Consequence: animation needs a retained per-frame compositor. That architecture is live; the 2026-07-10
matrix slice adds native one-shot scale/translation, while cyclic rotation remains a later affine step.
Consequence: animation needs a retained per-frame compositor. That architecture is live; scale,
one-shot rotation/translation, and cyclic rotation now rasterize through the affine software path.
### The full gfx render model — surfaces + objects + composite (2026-07-07)
@@ -720,9 +739,9 @@ buffers (present). **Slot 0 is NOT special** — a normal slot; several objects
(`handle → {slot, srcRect, position, anchor, scale, anim, alpha, visible}`, from draw-texture + the gfx ops) +
a host per-frame compositor that draws visible objects **in ascending-handle order** from their live surface,
interpolating animations by elapsed time. No VM/host lockstep: animations play during the wait-for-input park.
Separate scale/translation state and timing are implemented. The current anchored 2D composition is provisional:
exact anchor semantics, multiplication order, and projection still need slow native-versus-port frame comparison.
Full affine rotation remains deferred.
Separate scale/rotation/translation state and timing are implemented. Anchor semantics, multiplication order,
cyclic wrapping, 2D projection, and affine raster coverage have focused native-oracle tests. Native D3D9 filtering
and render-target command execution remain separate fidelity work.
### Blend & transparency — colorkey + `0x202`/`0x203` color/alpha (2026-07-08)
@@ -778,8 +797,15 @@ annotated in Ghidra, saved.
| `0x228` | `gfx_op_0x228_query_position` (`FUN_0047cdd0`) | **query** current computed (x,y,z) → operand slots 3/4/5 (script logic, not render) |
| `0x23f` | `gfx_op_0x23f_query_object` (`FUN_0042a520`) | **query** an object status/value → operand slot 1 |
**Deferred (own follow-ups, per scope decision):** `0x21f` (`FUN_0047eb70`, 4-float scale/matrix), `0x223`
(`FUN_0047f440`, 8-arg matrix row) → need **affine rendering**; `0x236` (`gfx_op_0x236` @`0x423ee0`) a
**Follow-up resolution (2026-07-10):** `0x21f` is the one-shot axis-angle channel and is implemented with
affine rasterization. `0x223` is **not affine**: `gfx_queue_surface_alpha_transition` (`0x47f440`) inserts
a type-0 command-map record keyed by arg 1: start `+4`, delay/duration `+8/+0xc`, target surface slot `+0x10`,
and two object handle ranges at `+0x14/+0x1c` and `+0x18/+0x20`. `gfx_render_frame` composites those ranges
into the target and ramps alpha 0→1. Its SC0000 site `0x129e7` passes `(handle+2, transition slot,
handle+1,1,handle,1,G[0x6249f],G[0x624a0])`. It remains a render-target/transition slice dependency rather
than being approximated in the affine object compositor.
**Still deferred:** `0x236` (`gfx_op_0x236` @`0x423ee0`) a
**timed/animated-surface (movie-like) op**; plus the unclassified `0x21c/0x21d/0x224/0x242/0x23d/0x20a/0x20e/0x243`
tail (2-arg flags / inline). These stay GAP until a follow-up slice or are safe-noop'd if the opening tolerates it.

View File

@@ -193,11 +193,21 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra 0x47eaa0 calls matrix builder 0x48af1d for target obj+0xac. Consumer 0x472f00 uses delay obj+0x3c, duration obj+0x50, current obj+0x6c, target obj+0xac, shared start obj+0x34, and frame-time ctx+0xb550.
### 0x21f `set-anim-rotation-axis-angle` (set-anim-rotation-axis-angle, argc 7)
- **summary:** (handle)(delay_ms)(duration_ms)(axis_x)(axis_y)(axis_z)(angle_deg) — set the delayed one-shot axis-angle rotation channel. Handler converts axis/angle integers to floats; worker stores target axis obj+0x1f8 and angle obj+0x208 and builds target matrix obj+0x12c. gfx_object_apply_transform_channels samples current axis/angle linearly on shared start obj+0x34 and composes T(-anchor)*scale*rotation*translation*T(anchor).
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x423410 -> gfx_object_set_rotation_channel@0x47eb70; consumer gfx_object_apply_transform_channels@0x472f00 uses delay +0x40, duration +0x54, current axis +0x1ec/angle +0x204, target axis +0x1f8/angle +0x208, current matrix +0xec and target +0x12c. Native SC0000 handle 0xcb8e sample at 11/390 of axis (0,0,1), 30deg matches matrix [0.9055,0.0134;-0.0134,0.9055] and translation (74.1449,47.3127).
### 0x220 `set-anim-transform-abs` (set-anim-transform-abs, argc 6)
- **summary:** (handle)(delay_ms)(duration_ms)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Target obj+0x1ac is linearly sampled from current obj+0x16c by gfx_object_apply_transform_channels@0x472f00 on frame-time ctx+0xb550, after delay and for duration, then committed. Independent of op 0x21e scale; neither Z is opacity.
- **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.
### 0x223 `queue-surface-alpha-transition` (queue-surface-alpha-transition, argc 8)
- **summary:** (command_key)(target_slot)(range_a_start)(range_a_count)(range_b_start)(range_b_count)(delay_ms)(duration_ms) — queue a type-0 timed alpha transition command in the separate ctx+0x414 command map. This is render-target/surface presentation state, not an object affine matrix. The render frame composites the two handle ranges into target_slot and ramps alpha 0->1 after delay over duration.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x423620 -> gfx_queue_surface_alpha_transition@0x47f440. Record fields: type +0=0, start +4=0, delay +8=arg7, duration +0xc=arg8, slot +0x10=arg2, range A +0x14/+0x1c=args3/4, range B +0x18/+0x20=args5/6. gfx_render_frame@0x47fbc0 initializes start from ctx+0xb550 and consumes type 0 as an alpha ramp. SC0000 executes one shared-helper site at 0x129e7.
### 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
@@ -224,9 +234,9 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r
- **grounding:** source=kelebek, confidence=low
### 0x234 `anim-start` (anim-start, argc 5)
- **summary:** (handle)(period_ms)(axis_x)(axis_y)(axis_z) — configure a cyclic ROTATION channel. Worker gfx_object_set_rotation_cycle@0x47f060 stores period obj+0x228 and axis obj+0x244..0x24c; interpolator 0x473ed0 applies 0..360 degrees from frame-time ctx+0xb550. Separate from scale, translation, opacity, and op 0x238's clock.
- **summary:** (handle)(period_ms)(axis_x)(axis_y)(axis_z) — configure cyclic rotation. Worker stores period obj+0x228, start obj+0x214=0, and float axis obj+0x244; each frame uses integer degrees floor(((now-start)%period)*360/period). gfx_object_composite right-multiplies this separately anchored transform after the one-shot scale/rotation/translation product, so cyclic rotation also rotates the translation vector.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x423da0 -> worker 0x47f060. gfx_object_anim_interpolate@0x473ed0 consumes obj+0x228/0x244 on ctx+0xb550 and builds an axis-angle rotation matrix with angle 360*((now-start)%period)/period.
- **evidence:** Ghidra handler 0x423da0 converts axis ints to floats -> worker 0x47f060. gfx_object_anim_interpolate@0x473ed0 consumes obj+0x228/+0x214/+0x244 on ctx+0xb550 and matrix4_make_axis_angle@0x48b215. gfx_object_composite@0x47f650 calls one-shot transform first, cyclic animation second.
### 0x238 `set-anim-clock` (set-anim-clock, argc 1)
- **summary:** (duration) — set the GLOBAL animation clock: native ctx+0x51b78=0 (elapsed), +0x51b7c=duration. cmd-type 3. NON-BLOCKING: only configures; the render loop advances it and interpolates all animating objects. SC0000 opening @0x123bd/@0x13858. Handler 0x4240e0; Kelebek VA 0x422390 is drift.
@@ -991,18 +1001,10 @@ 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
### 0x21f `u00421510` (u00421510, argc 7)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x222 `u004216C0` (u004216C0, argc 2)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x223 `u00421700` (u00421700, argc 8)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x22a `u00421A90` (u00421A90, argc 3)
- **summary:** —
- **grounding:** source=kelebek, confidence=low

View File

@@ -737,3 +737,161 @@ 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`.
### A2b — ADV transition/lifecycle diagnosis plan ⏳ OPEN (2026-07-10)
This is the next SC0000 correctness slice. It is driven by live A/B observations, not by static opcode
coverage alone. The direct SC0000 histogram is currently **80/129 distinct ops handled (62.0%)** and
**96.2% instruction-weighted**, but a rare query, scheduler op, or render worker can still control an
entire visible section. Coverage also excludes called scripts; this slice follows only callees actually
entered on the failing path rather than expanding into blanket subscript completion.
**Observed native ADV contract (client behavior):** foreground presentation changes are transitions even
when they are only in-place fades. SC0000 begins black and fades into the first CG; the message window then
fades in before text reveals. During a CG swap, the window transitions out, one or more CG transitions run,
then the window transitions back in. A click during an active foreground transition completes it immediately
and lets the next presentation step start; a click at a stable `wait-for-input` advances the script. Ambient
retained animation is a separate class and must not all be completed by that click.
**Current port failures (2026-07-10):**
- The initial black state holds and then the first CG pops in instead of fading. This is consistent with the
port applying op `0x202`'s endpoint as a static tint while its native animated-color consumer remains
unmodeled.
- The retained textbox artwork appears to begin fading in, then disappears. The Godot `Label` shortcut stays
visible; it is deliberately out of scope for this slice because native `draw-string`/text presentation will
replace it before SC0000 is called complete. Diagnose the **box object's** lifetime, not the shortcut text.
- The late animation burst before the first music change is badly wrong, then the screen becomes white and
interactive play does not proceed to the music change.
**Concrete failing boundary.** `BGM005` begins at SC0000 offset `0x7fa`; the expected first change to
`BGM008` is `play-bgm 0x8` at **`0x1728`**. The immediately preceding block `0x133f..0x1725` exercises
repeated `0x202/0x203` color operations and the implemented geometry/scale family, but also directly executes
three still-stubbed gfx ops: **`0x236`** at `0x13c8`, **`0x1fd`** at `0x14f3`, and **`0x21f`** at `0x159a`.
It calls the shared animation finalizer `label_1235a` several times, including at `0x1725`; that finalizer
sets op `0x238`'s duration, then reads still-unimplemented **`0x1c7 get-message-skip`** and
**`0x1cc get-adv-service-state`** to choose its present/yield path. Therefore the white stall could be an
object/compositor error, an unmodeled foreground-transition gate, or wrong control flow caused by a stubbed
output—not safely assumed to be “just interpolation.”
#### Investigation order
1. **Make the failure boundary deterministic before changing semantics.** Reproduce from `--boot` with
auto-input and a long enough `--shot-sequence`, plus `--gfx-log`. Add a single synchronized diagnostic
timeline if the existing logs cannot answer the boundary: frame/virtual time; active script + PC/opcode;
VM state (`running`, sleep, transition, input wait, halt); BGM event; and every changed visible object's
handle, surface slot/resId, tint/alpha, transform, and lifecycle event. Use `play-bgm 0x8 @ 0x1728` as the
reachability sentinel. Do not judge progress from the white pixels alone.
2. **Classify before fixing.** If the VM reaches/passes `0x1728` while the frame stays white, identify the
topmost white/fill object and whether its handle remains visible, loses/rebinds its live surface, or has a
stuck color endpoint. If the VM never reaches `0x1728`, record the last PC and whether it is sleeping,
input-waiting, transition-waiting, polling, halted, or still executing. If the executed path itself is
suspect, capture the same native passage with `trace_engine_ops.py` and use `diff_optrace.py` to find the
first engine/port offset divergence.
3. **Track the textbox artwork as an AGE object.** From its first visible frame, identify its retained handle
and follow bind, color/animation, present, erase, release, and surface-rebind events through the first CG
swap. The key result is one of: `GONE` (premature erase), still present but covered (z/lifecycle input),
still present but transparent/tinted (color channel), or bound to a replaced slot (surface lifetime).
Compare only those corresponding native object events; do not spend this slice synchronizing the Godot
text overlay.
4. **Recover the foreground ADV transition contract.** Reverse/capture the producer behind
`get-adv-service-state` (`0x1cc`), implement the already-known `get-message-skip` output (`0x1c7`), and
observe what a click changes during the initial fade and the pre-`0x1728` burst. Establish an explicit
host-level foreground transition with `start → per-frame progress → natural/forced completion → resume`.
Click completes and consumes the active foreground transition; only a stable input wait advances content.
The wall clock remains the progress source and opcode pacing remains a guard within runnable bursts, not
the mechanism that decides how long a presentation state lives.
5. **Reverse only the executed missing gfx dependency that remains causal.** Triage the three direct gaps in
failing-order: `0x236` (timed/animated-surface worker), `0x1fd` (scaled vector/animation setter), and
`0x21f` (affine/matrix channel). For each, capture native inputs, retained fields, and sampled output at the
exact SC0000 site; implement it with a focused VM/state/compositor test. Do not declare a stub harmless
merely because it is rare, and do not implement the whole remaining opcode list without evidence.
6. **Validate as presentation checkpoints.** Native/manual observation remains the final visual oracle, but
each check should first have machine evidence (PC reached, object identity/lifetime, transition progress,
and final state). Required checkpoints: black visibly ramps into the first CG; the textbox artwork survives
until its intended transition-out; a CG swap orders window-out → CG transition(s) → window-in; clicking an
active transition snaps to its endpoint without also advancing a stable page; the late burst has no stuck
white owner; and execution reaches `BGM008 @ 0x1728`. Re-run engine tests, corpus sweep, Godot threaded
self-test, and the SC0000 coverage report after each landed opcode or scheduler change.
**Stop conditions / scope guard:** this slice is complete when the port reaches `0x1728` interactively and
the above foreground transitions have correct lifecycle/click behavior. Native glyph rendering, configurable
text reveal speed, and unrelated subscript opcode completeness remain separate work. Any called script proven
to own the first divergence becomes an explicit dependency of this slice; otherwise it stays out of scope.
#### Investigation 1 result — deterministic boundary classification (2026-07-10)
Added the observe-only Godot `--timeline-log <jsonl>` diagnostic so VM steps (real byte offsets), virtual
time/frame, host state, BGM events, and changed visible-object outcomes share one ordered stream. The
reproduction was `SC0000 --boot`, stable-wait auto-input via a long `--shot-sequence`, `--gfx-log`, and a
uniform diagnostic `--speed 8`; speed scales VM, sleeps, and animation clocks together and does not inject
input outside `wait-for-input`.
**Classification: not a VM/control-flow stall on the deterministic path.** The run executed all three direct
gaps (`0x236 @ 0x13c8`, `0x1fd @ 0x14f3`, `0x21f @ 0x159a`), called the finalizer at `0x1725`, then executed
`play-bgm 0x8 @ 0x1728` in `running` state at frame 818 / virtual `70,262 ms`. The BGM event resolved to
`BGM008.OGG` in the same synchronized event and execution continued through `0x172b` and beyond; the full
1,800-frame run reached page 80 and five BGM events. Therefore a native offset-path diff is not warranted for
this boundary unless a separately reproducible manual-input path fails to reach the sentinel.
The full-screen fill owner is retained handle **`0xcf08`**, but it is not stuck at this boundary. It was a
transparent white `800x600` fill (`a=0.00`) when `0x1728` executed. Later, `0x203 @ 0x1337f` made it solid
white for one sampled diagnostic frame (frame 911); the following `label_1235a` path executed `0x1c7`,
`0x1cc`, and `0x21c`, and the compositor sampled the same handle back at `a=0.00` on frame 912. This is
evidence of a likely incorrect flash/color presentation contract, not evidence for the reported pre-BGM
infinite stall. No opcode, scheduler, or compositor semantic fix was made in this investigation step.
Validation after adding the diagnostic: engine **92/92**, Godot build clean, threaded `SELFTEST OK`, and
`git diff --check` clean. The headless `--shot-sequence` PNG capture path emits dummy-renderer `GetImage`
errors, but the CPU compositor/timeline completed and the same path already had this limitation; use a
windowed sequence when pixel files rather than object-state evidence are required.
### A2b — cyclic rotation and affine rasterization ✅ DONE (2026-07-10)
This bounded slice followed the non-reproduced white-stall classification above; it did not resume that
investigation and does not claim the interactive symptom is fixed.
**Native contracts.** Op `0x21f` is a delayed one-shot axis-angle rotation, not a generic matrix row:
`(handle,delay,duration,axisX,axisY,axisZ,angleDegrees)`. It shares `obj+0x34`'s start with scale/translation,
uses delay/duration `+0x40/+0x54`, and linearly samples current axis/angle `+0x1ec/+0x204` to target
`+0x1f8/+0x208`. Op `0x234` is separately anchored cyclic rotation with integer-degree phase
`floor(((now-start)%period)*360/period)`. The native call order reduces to
`T(-anchor)*scale*oneShotRotation*translation*cyclicRotation*T(anchor)`, so the cycle rotates translation.
Op `0x223` was also closed out but deliberately not implemented here: it inserts a type-0 timed-alpha record
in the surface command map, containing a target surface slot and two object ranges. SC0000's shared site
`0x129e7` passes `(handle+2, slot, handle+1,1,handle,1,delay,duration)`. This belongs to render-target/
foreground-transition presentation, not affine object state, and remains a visible GAP rather than receiving
an uncertain approximation.
**Native matrix oracle.** The retained trace's handle `0xcb8e` sample (anchor `(700,600)`, scale from 0.9,
axis `(0,0,1)`, 30° target, sampled 11 ms into a 390 ms ramp after 500 ms delay) is
`[0.9055,0.0134;-0.0134,0.9055]`, translation `(74.1449,47.3127)`; the focused port test matches it.
The two executed SC0000 cycle sites use periods 9000/13000 ms and axes `+Z/-Z`. A windowed port capture
advanced 563 ms from their first sample to phase angles `22°/15°`, exactly the native integer formula.
**Port result.** `GfxState` now retains/samples the one-shot rotation and cyclic start/phase. `Transform2DMath`
composes a row-vector 4×4 matrix and projects it to an invertible 2D affine transform. The Godot compositor
uses a pure inverse-mapped nearest-neighbour RGBA8 rasterizer for both textures and solid fills, preserving
the existing colorkey, tint-strength, opacity, clipping, flipping, and z-order paths. Native D3D9 filtering
can still differ at subpixels; the matrix/order is oracle-backed rather than approximated.
The windowed `--shot-sequence` run wrote 454 PNGs with 102 pixel-state transitions; the first cyclic passage
produced distinct affine frames as `0xcb8e/0xcb98` advanced. There is no corresponding native PNG sequence in
the workspace, so validation is matrix/phase exact plus port-pixel coverage—not a false claim of pixel-perfect
native frame equality.
**Ghidra.** Renamed/commented `gfx_object_set_rotation_channel` (`0x47eb70`),
`gfx_queue_surface_alpha_transition` (`0x47f440`), the surface-command map helpers, and
`matrix4_make_axis_angle` (`0x48b215`); corrected comments on the one-shot consumer, cyclic interpolator,
and composite call order; named useful parameters; saved `/v2`.
**Validation:** engine **97/97**; full sweep unchanged at **284 exit / 13 STEP-LIMIT**; Godot build clean
apart from the pre-existing nullable warning and threaded `SELFTEST OK`; opcode tooling and focused Python
tests clean; transform tool compiles; SC0000 coverage **81/129 handled (62.8%)**, 48 GAP ops / 602 GAP
instructions; windowed affine capture clean. Final whitespace/diff validation is recorded with the handoff.

View File

@@ -137,13 +137,19 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
- `--sleep-scale <f>` — multiply every `sleep` (op 0xc8) duration by `f` (default 1.0). The authentic opening burst is only ~2 s, too fast to eyeball live; `--sleep-scale 5` stretches it to ~10 s so the paced sequence (arcane `AE*` → character CGs → settled BG) is watchable. Debug-only; leave at 1.0 for real playback.
- `--speed <f>` — scale the unified runtime clock (VM cadence, sleeps, and retained animation) without auto-advancing input waits. Values 0.058 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback.
- `--gfx-log <file>`**compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page.
Matrix-channel outcomes also include `base`, `anchor`, projected `dst`, and sampled `scale`/`trans` values. Parent directories are created automatically.
Matrix-channel outcomes also include `base`, `anchor`, projected `dst`, sampled `scale`/`trans`, and
one-shot-plus-cyclic `rot`ation angles. Parent directories are created automatically.
- `--timeline-log <jsonl>` — diagnostic-only synchronized event stream for a real Godot run. Records every
executed script byte offset/opcode, virtual time/frame, VM state changes (`running`, `sleep`, `input-wait`,
`halted`), BGM events, and changed visible-object compositor outcomes in one ordered JSONL file. Combine with
`--boot --shot-sequence ... --gfx-log ...` to distinguish control-flow stalls from retained-object/compositor
failures at an exact bytecode boundary. Relative output paths are project-relative (`godot/`).
## 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` |
| `tools/frida/capture_native_transforms.py` | Capture native `0x21f`/`0x223`/`0x234` worker operands, corrected integer base/anchor coordinates, all one-shot/cyclic retained fields, the one-shot 4×4 matrix, and the final post-cyclic 4×4 matrix. 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` |

View File

@@ -39,6 +39,36 @@ public class GfxAnimationTests
Assert.True(o.RotationEnabled);
}
[Fact]
public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample()
{
var g = new GfxState();
g.SetSurface(6, 1, -1); g.BindDraw(0xcb8e, 6, 0, 0, 800, 800, 300, 200);
g.GetOrCreate(0xcb8e).V18 = (700, 600, 0);
g.SetScaleChannel(0xcb8e, 500, 390, (110, 110, 100));
g.GetOrCreate(0xcb8e).ScaleCurrent = (0.9, 0.9, 1);
g.SetRotationChannel(0xcb8e, 500, 390, (0, 0, 1), 30);
g.SnapshotVisibleObjects(1000);
var sample = g.SnapshotVisibleObjects(1511).Single();
var m = Transform2DMath.Build(sample.Transform);
Assert.Equal(0.9055, m.XX, 4);
Assert.Equal(0.0134, m.XY, 4);
Assert.Equal(-0.0134, m.YX, 4);
Assert.Equal(74.1449, m.TX, 3);
Assert.Equal(47.3127, m.TY, 3);
}
[Fact]
public void CyclicRotation_FloorsDegreesAndWrapsAtPeriod()
{
var g = new GfxState();
g.SetSurface(1, 1, -1); g.BindDraw(7, 1, 0, 0, 1, 1, 0, 0);
g.SetRotationCycle(7, 1000, (0, 0, -1));
Assert.Equal(0, g.SnapshotVisibleObjects(5000).Single().Rotation.AngleDegrees);
Assert.Equal(89, g.SnapshotVisibleObjects(5249).Single().Rotation.AngleDegrees);
Assert.Equal(0, g.SnapshotVisibleObjects(6000).Single().Rotation.AngleDegrees);
}
[Fact]
public void SetAnimClock_SetsGlobalDurationAndBumpsClockGeneration()
{
@@ -68,6 +98,8 @@ public class GfxAnimationTests
(0x220, new[] { G(1), G(2), G(3), G(4), G(5), G(6) }),
MovGI(4, 200), MovGI(5, 50), MovGI(6, 100),
(0x21e, new[] { G(1), G(2), G(3), G(4), G(5), G(6) }),
MovGI(4, 0), MovGI(5, 0), MovGI(6, 1), MovGI(7, 30),
(0x21f, new[] { G(1), G(2), G(3), G(4), G(5), G(6), G(7) }),
Exit(),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(scene, t, new RecordingHost());
@@ -77,6 +109,8 @@ public class GfxAnimationTests
Assert.Equal((2.0, 0.5, 1.0), o.ScaleTarget);
Assert.True(o.TranslationEnabled);
Assert.True(o.ScaleEnabled);
Assert.Equal((0.0, 0.0, 1.0, 30.0), o.RotationTarget);
Assert.True(o.RotationChannelEnabled);
}
[Fact]
@@ -181,4 +215,14 @@ public class GfxAnimationTests
var t = new TransformState(5, 5, 1, 0, 0, 0, 400, 1000, 0);
Assert.Equal((-1600.0, -1000.0), Transform2DMath.Apply(0, 600, t));
}
[Fact]
public void Transform2D_CyclicRotationOccursAfterTranslation()
{
var t = new TransformState(2, 1, 1, 10, 0, 0, 100, 50, 0);
var cycle = new RotationCycleState(true, 1000, 0, 0, 1, 90);
var p = Transform2DMath.Apply(120, 50, t, cycle);
Assert.Equal(100.0, p.X, 10);
Assert.Equal(100.0, p.Y, 10);
}
}

View File

@@ -0,0 +1,30 @@
using Age.Engine.Model;
using Xunit;
public class SoftwareAffineRasterizerTests
{
[Fact]
public void BlitRgba_RotatesTwoPixelsClockwiseWithNearestSampling()
{
byte[] src = { 255,0,0,255, 0,255,0,255 };
byte[] dst = new byte[4*4*4];
var world = Transform2DMath.Build(
new TransformState(1,1,1,0,0,0,1,1,0, 0,0,1,90));
SoftwareAffineRasterizer.BlitRgba(dst,4,4,src,2,1,0,0,2,1,
world.FromLocalOrigin(1,1),0xffffff,0,1);
Assert.Equal(new byte[] {255,0,0,255}, dst[16..20]);
Assert.Equal(new byte[] {0,255,0,255}, dst[32..36]);
}
[Fact]
public void FillRgba_UsesAffineShapeRatherThanBoundingBox()
{
byte[] dst = new byte[5*5*4];
var m = new Affine2D(1,0.5,-0.5,1,2,1);
SoftwareAffineRasterizer.FillRgba(dst,5,5,2,2,m,0xff0000,1);
int colored = 0;
for(int i=3;i<dst.Length;i+=4) if(dst[i]!=0) colored++;
Assert.InRange(colored, 3, 5);
Assert.Equal(0, dst[(1*5+1)*4+3]);
}
}

View File

@@ -2,13 +2,17 @@ using System.Linq;
namespace Age.Engine.Model;
/// <summary>The sampled native matrix channels carried to the compositor. Op 0x21e owns scale; op 0x220 owns
/// translation. Z is retained for model fidelity even though the current 2D compositor uses X/Y only.</summary>
/// <summary>The sampled native one-shot channels carried to the compositor: op 0x21e scale, op 0x21f
/// axis-angle rotation, and op 0x220 translation. Z is retained through full 4x4 composition.</summary>
public readonly record struct TransformState(double ScaleX, double ScaleY, double ScaleZ,
double TranslateX, double TranslateY, double TranslateZ,
double AnchorX, double AnchorY, double AnchorZ);
double AnchorX, double AnchorY, double AnchorZ,
double RotationAxisX = 0, double RotationAxisY = 0,
double RotationAxisZ = 0, double RotationAngleDegrees = 0);
public readonly record struct RotationCycleState(bool Enabled, long PeriodMs, long AxisX, long AxisY, long AxisZ);
public readonly record struct RotationCycleState(bool Enabled, long PeriodMs,
double AxisX, double AxisY, double AxisZ,
double AngleDegrees = 0);
/// <summary>A renderable view of one visible gfx object — the host composites these in ascending-handle order
/// (= the engine's z-order) each frame. Built by <see cref="GfxState.SnapshotVisibleObjects"/>; the surface
@@ -60,6 +64,10 @@ public sealed class GfxState
public (double X, double Y, double Z) TranslationCurrent, TranslationTarget;
public long TranslationDelayMs, TranslationDurationMs;
public bool TranslationEnabled;
public (double X, double Y, double Z, double Angle) RotationCurrent;
public (double X, double Y, double Z, double Angle) RotationTarget;
public long RotationDelayMs, RotationDurationMs;
public bool RotationChannelEnabled;
// Shared matrix-channel start timestamp obj+0x34, seeded from frame-time ctx+0xb550.
public long MatrixStartMs = -1;
@@ -67,6 +75,7 @@ public sealed class GfxState
public long RotationPeriodMs;
public (long X, long Y, long Z) RotationAxis;
public bool RotationEnabled;
public long RotationStartMs = -1;
}
// ---- geometry/draw object store (V18/V24/draw bind, the compositor's input) ----
@@ -217,6 +226,19 @@ public sealed class GfxState
}
}
/// <summary>Op 0x21f: delayed one-shot axis-angle rotation target, sharing obj+0x34's start timestamp.</summary>
public void SetRotationChannel(long handle, long delayMs, long durationMs,
(long X, long Y, long Z) axis, long angleDegrees)
{
lock (_lock)
{
var o = GetOrCreate(handle);
o.RotationDelayMs = delayMs; o.RotationDurationMs = durationMs;
o.RotationTarget = (axis.X, axis.Y, axis.Z, angleDegrees);
o.RotationChannelEnabled = durationMs > 0; o.MatrixStartMs = -1;
}
}
/// <summary>Op 0x234: retain the cyclic rotation period and axis separately. Native interpolation uses
/// frame-time ctx+0xb550 and rotates through 360 degrees per period; affine rendering is deferred.</summary>
public void SetRotationCycle(long handle, long periodMs, (long X, long Y, long Z) axis)
@@ -225,6 +247,7 @@ public sealed class GfxState
{
var o = GetOrCreate(handle);
o.RotationPeriodMs = periodMs; o.RotationAxis = axis; o.RotationEnabled = periodMs > 0;
o.RotationStartMs = -1;
}
}
@@ -299,22 +322,35 @@ public sealed class GfxState
}
// One-shot matrix channels: hold current through delay, then linearly sample current -> target.
if ((o.ScaleEnabled || o.TranslationEnabled) && o.MatrixStartMs < 0) o.MatrixStartMs = nowMs;
if ((o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled) && o.MatrixStartMs < 0)
o.MatrixStartMs = nowMs;
var scale = SampleMatrixChannel(ref o.ScaleCurrent, o.ScaleTarget, o.ScaleDelayMs,
o.ScaleDurationMs, o.MatrixStartMs, ref o.ScaleEnabled, nowMs);
var rotation = SampleRotationChannel(ref o.RotationCurrent, o.RotationTarget,
o.RotationDelayMs, o.RotationDurationMs,
o.MatrixStartMs, ref o.RotationChannelEnabled, nowMs);
var translation = SampleMatrixChannel(ref o.TranslationCurrent, o.TranslationTarget,
o.TranslationDelayMs, o.TranslationDurationMs,
o.MatrixStartMs, ref o.TranslationEnabled, nowMs);
if (!o.ScaleEnabled && !o.TranslationEnabled) o.MatrixStartMs = -1;
if (!o.ScaleEnabled && !o.RotationChannelEnabled && !o.TranslationEnabled) o.MatrixStartMs = -1;
double cycleAngle = 0;
if (o.RotationEnabled && o.RotationPeriodMs > 0)
{
if (o.RotationStartMs < 0) o.RotationStartMs = nowMs;
long elapsed = System.Math.Max(0, nowMs - o.RotationStartMs);
cycleAngle = ((elapsed % o.RotationPeriodMs) * 360) / o.RotationPeriodMs;
}
list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h,
(int)o.V24.X, (int)o.V24.Y,
new TransformState(scale.X, scale.Y, scale.Z,
translation.X, translation.Y, translation.Z,
o.V18.X, o.V18.Y, o.V18.Z),
o.V18.X, o.V18.Y, o.V18.Z,
rotation.X, rotation.Y, rotation.Z, rotation.Angle),
new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs,
o.RotationAxis.X, o.RotationAxis.Y,
o.RotationAxis.Z),
o.RotationAxis.Z, cycleAngle),
alpha, tint, strength, blend));
}
return list;
@@ -341,6 +377,22 @@ public sealed class GfxState
current.Z + (target.Z - current.Z) * t);
}
private static (double X, double Y, double Z, double Angle) SampleRotationChannel(
ref (double X, double Y, double Z, double Angle) current,
(double X, double Y, double Z, double Angle) target,
long delayMs, long durationMs, long startMs, ref bool enabled, long nowMs)
{
if (!enabled || durationMs <= 0 || startMs < 0) return current;
long elapsed = nowMs - startMs - delayMs;
if (elapsed <= 0) return current;
if (elapsed >= durationMs) { current = target; enabled = false; return current; }
double t = (double)elapsed / durationMs;
return (current.X + (target.X - current.X) * t,
current.Y + (target.Y - current.Y) * t,
current.Z + (target.Z - current.Z) * t,
current.Angle + (target.Angle - current.Angle) * t);
}
/// <summary>Ping-pong interpolation weight in [0,1] toward the target: 0 at cycle start, 1 at half-period.</summary>
private static double PingPongWeight(long now, long start, long period)
{

View File

@@ -0,0 +1,54 @@
namespace Age.Engine.Model;
/// <summary>Nearest-neighbour inverse-mapped RGBA8 affine compositor used by the Godot host and pure tests.</summary>
public static class SoftwareAffineRasterizer
{
public static void BlitRgba(byte[] dst, int dstW, int dstH, byte[] src, int srcW, int srcH,
int srcX, int srcY, int width, int height, Affine2D localToDest,
long tint, float tintStrength, float opacity)
{
if (width <= 0 || height <= 0 || !localToDest.TryInverse(out var inv)) return;
Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
int istr = (int)(System.Math.Clamp(tintStrength, 0f, 1f) * 255);
int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255);
int tr=(int)(tint>>16&255), tg=(int)(tint>>8&255), tb=(int)(tint&255);
for (int y=y0; y<y1; y++) for (int x=x0; x<x1; x++)
{
var p = inv.Apply(x + 0.5, y + 0.5);
int u=(int)System.Math.Floor(p.X), v=(int)System.Math.Floor(p.Y);
if ((uint)u >= (uint)width || (uint)v >= (uint)height) continue;
int si=((srcY+v)*srcW+(srcX+u))*4, di=(y*dstW+x)*4;
int sa=src[si+3]*ia/255; if(sa==0) continue;
int sr=(src[si]*(255-istr)+tr*istr)/255;
int sg=(src[si+1]*(255-istr)+tg*istr)/255;
int sb=(src[si+2]*(255-istr)+tb*istr)/255;
Blend(dst,di,sr,sg,sb,sa);
}
}
public static void FillRgba(byte[] dst, int dstW, int dstH, int width, int height,
Affine2D localToDest, long color, float opacity)
{
if (width <= 0 || height <= 0 || !localToDest.TryInverse(out var inv)) return;
Bounds(localToDest,width,height,dstW,dstH,out int x0,out int y0,out int x1,out int y1);
int a=(int)(System.Math.Clamp(opacity,0f,1f)*255); if(a==0)return;
int r=(int)(color>>16&255),g=(int)(color>>8&255),b=(int)(color&255);
for(int y=y0;y<y1;y++)for(int x=x0;x<x1;x++){
var p=inv.Apply(x+.5,y+.5);
if(p.X>=0&&p.X<width&&p.Y>=0&&p.Y<height) Blend(dst,(y*dstW+x)*4,r,g,b,a);
}
}
private static void Bounds(Affine2D m,int w,int h,int dw,int dh,out int x0,out int y0,out int x1,out int y1)
{
var a=m.Apply(0,0);var b=m.Apply(w,0);var c=m.Apply(0,h);var d=m.Apply(w,h);
x0=System.Math.Max(0,(int)System.Math.Floor(System.Math.Min(System.Math.Min(a.X,b.X),System.Math.Min(c.X,d.X))));
y0=System.Math.Max(0,(int)System.Math.Floor(System.Math.Min(System.Math.Min(a.Y,b.Y),System.Math.Min(c.Y,d.Y))));
x1=System.Math.Min(dw,(int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.X,b.X),System.Math.Max(c.X,d.X))));
y1=System.Math.Min(dh,(int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y,b.Y),System.Math.Max(c.Y,d.Y))));
}
private static void Blend(byte[] d,int i,int r,int g,int b,int a){
d[i]=(byte)((r*a+d[i]*(255-a))/255);d[i+1]=(byte)((g*a+d[i+1]*(255-a))/255);
d[i+2]=(byte)((b*a+d[i+2]*(255-a))/255);d[i+3]=(byte)System.Math.Min(255,d[i+3]+a);
}
}

View File

@@ -1,11 +1,74 @@
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>
/// <summary>A projected row-vector affine transform: x'=x*XX+y*YX+TX, y'=x*XY+y*YY+TY.</summary>
public readonly record struct Affine2D(double XX, double XY, double YX, double YY, double TX, double TY)
{
public (double X, double Y) Apply(double x, double y)
=> (x * XX + y * YX + TX, x * XY + y * YY + TY);
public Affine2D FromLocalOrigin(double worldX, double worldY)
{
var p = Apply(worldX, worldY);
return new(XX, XY, YX, YY, p.X, p.Y);
}
public bool TryInverse(out Affine2D inverse)
{
double det = XX * YY - XY * YX;
if (System.Math.Abs(det) < 1e-12) { inverse = default; return false; }
double xx = YY / det, xy = -XY / det, yx = -YX / det, yy = XX / det;
inverse = new(xx, xy, yx, yy, -(TX * xx + TY * yx), -(TX * xy + TY * yy));
return true;
}
}
/// <summary>Exact 2D projection of AGE's row-vector retained-object matrix. Native call order is anchored
/// scale, one-shot axis-angle rotation, translation, then separately anchored cyclic rotation. The adjacent
/// anchor translations cancel, yielding T(-a)*S*R1*T*Rcycle*T(+a). Z is projected away only afterward.</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);
public static Affine2D Build(TransformState t, RotationCycleState cycle = default)
{
double[] m = Identity();
m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ));
m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ));
m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees));
m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ));
if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees));
m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ));
return new(m[0], m[1], m[4], m[5], m[12], m[13]);
}
public static (double X, double Y) Apply(double x, double y, TransformState transform,
RotationCycleState cycle = default)
=> Build(transform, cycle).Apply(x, y);
private static double[] Identity() => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 };
private static double[] Scale(double x, double y, double z)
=> new double[] { x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1 };
private static double[] Translation(double x, double y, double z)
=> new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 };
private static double[] AxisAngle(double x, double y, double z, double degrees)
{
double len = System.Math.Sqrt(x*x + y*y + z*z);
if (len < 1e-12 || System.Math.Abs(degrees) < 1e-12) return Identity();
x /= len; y /= len; z /= len;
double r = degrees * System.Math.PI / 180.0, c = System.Math.Cos(r), s = System.Math.Sin(r), q = 1-c;
return new double[] {
x*x*q+c, x*y*q+z*s, x*z*q-y*s, 0,
x*y*q-z*s, y*y*q+c, y*z*q+x*s, 0,
x*z*q+y*s, y*z*q-x*s, z*z*q+c, 0,
0,0,0,1
};
}
private static double[] Mul(double[] a, double[] b)
{
var o = new double[16];
for (int row=0; row<4; row++)
for (int col=0; col<4; col++)
for (int k=0; k<4; k++) o[row*4+col] += a[row*4+k] * b[k*4+col];
return o;
}
}

View File

@@ -373,6 +373,9 @@ public sealed class VirtualMachine
case "set-anim-transform-norm": // 0x21e (handle)(delay)(duration)(sx%)(sy%)(sz%)
Gfx.SetScaleChannel(Read(a[0]), Read(a[1]), Read(a[2]),
(Read(a[3]), Read(a[4]), Read(a[5]))); return pc + 1;
case "set-anim-rotation-axis-angle": // 0x21f (handle)(delay)(duration)(axis x/y/z)(angle deg)
Gfx.SetRotationChannel(Read(a[0]), Read(a[1]), Read(a[2]),
(Read(a[3]), Read(a[4]), Read(a[5])), Read(a[6])); return pc + 1;
case "anim-start": // 0x234 legacy name: (handle)(period)(axis x/y/z), cyclic rotation channel
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)

View File

@@ -15,14 +15,17 @@ public sealed class GodotAdvHost : IHost
private readonly SemaphoreSlim _gate = new(0, 1);
private readonly Age.Engine.Hosting.FrameClock _clock;
private readonly Age.Engine.Hosting.WallClockOpPacer _opPacer;
private readonly GodotTimelineLog? _timeline;
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
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)
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
GodotTimelineLog? timeline = null)
{
_main = main; _res = res; _scene = scene; _clock = clock;
_timeline = timeline;
_opPacer = new Age.Engine.Hosting.WallClockOpPacer(clock);
}
@@ -39,8 +42,10 @@ public sealed class GodotAdvHost : IHost
Pages++;
_main.CallDeferred("PageBreak");
IsWaiting = true;
_timeline?.State("input-wait", new() { ["page"] = Pages });
_gate.Wait();
IsWaiting = false;
_timeline?.State("running", new() { ["input"] = "auto-or-user" });
_opPacer.Reset();
_main.CallDeferred("ClearPage");
}
@@ -80,12 +85,14 @@ 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;
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
while (_clock.NowMs < deadline)
{
if (_stopping) break;
_frameSignal.WaitOne(50);
}
_opPacer.Reset();
_timeline?.State("running", new() { ["sleep_complete"] = true });
}
// ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ----
@@ -128,6 +135,7 @@ public sealed class GodotAdvHost : IHost
public void PlayBgm(long id)
{
var path = _res.BgmPathById(id);
_timeline?.Event("bgm", new() { ["id"] = id, ["file"] = path != null ? System.IO.Path.GetFileName(path) : null });
if (path != null) _main.CallDeferred("PlayBgm", path);
}

72
godot/GodotTimelineLog.cs Normal file
View File

@@ -0,0 +1,72 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
/// <summary>Diagnostic-only synchronized JSONL stream for correlating VM execution, host waits/audio,
/// and compositor object changes. All producers share one lock, so event order is unambiguous even though
/// the VM and compositor run on different threads.</summary>
public sealed class GodotTimelineLog : System.IDisposable
{
private readonly object _lock = new();
private readonly StreamWriter _writer;
private long _sequence;
private int _frame;
private long _nowMs;
private string _script = "<startup>";
private int _offset = -1;
private int _opcode = -1;
private string _state = "starting";
private bool _disposed;
public GodotTimelineLog(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
_writer = new StreamWriter(path) { AutoFlush = true };
Record("start", new() { ["transition"] = "unmodeled" });
}
public void SetFrame(int frame, long nowMs)
{
lock (_lock) { _frame = frame; _nowMs = nowMs; }
}
public void Step(string script, int offset, int opcode, int depth)
{
lock (_lock)
{
_script = script; _offset = offset; _opcode = opcode; _state = "running";
WriteLocked("step", new() { ["depth"] = depth });
}
}
public void State(string state, Dictionary<string, object?>? detail = null)
{
lock (_lock) { _state = state; WriteLocked(state, detail); }
}
public void Event(string kind, Dictionary<string, object?>? detail = null)
{
lock (_lock) WriteLocked(kind, detail);
}
private void Record(string kind, Dictionary<string, object?>? detail)
{
lock (_lock) WriteLocked(kind, detail);
}
private void WriteLocked(string kind, Dictionary<string, object?>? detail)
{
if (_disposed) return;
var row = new Dictionary<string, object?>
{
["seq"] = ++_sequence, ["kind"] = kind, ["frame"] = _frame, ["now_ms"] = _nowMs,
["script"] = _script, ["offset"] = _offset < 0 ? null : $"0x{_offset:x}",
["opcode"] = _opcode < 0 ? null : $"0x{_opcode:x}", ["vm_state"] = _state,
};
if (detail != null) foreach (var kv in detail) row[kv.Key] = kv.Value;
_writer.WriteLine(JsonSerializer.Serialize(row));
}
public void Dispose() { lock (_lock) { if (_disposed) return; _disposed = true; _writer.Dispose(); } }
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using Age.Engine.Diagnostics;
// Frontend-side trace consumer. Runs on the VM background thread, so it just queues the dispatched
@@ -7,10 +8,20 @@ using Age.Engine.Diagnostics;
// engine fact delivered over the trace seam.
public sealed class GodotTraceSink : ITraceSink
{
public bool TracingSteps => false;
private readonly GodotTimelineLog? _timeline;
private readonly Stack<string> _scripts = new();
public GodotTraceSink(GodotTimelineLog? timeline = null) => _timeline = timeline;
public bool TracingSteps => _timeline != null;
public readonly ConcurrentQueue<long> CallScripts = new();
public void Emit(in TraceEvent e)
{
if (e.Kind == TraceEventKind.CallScript) CallScripts.Enqueue(e.Id);
if (_timeline == null) return;
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null) _scripts.Push(e.Name);
else if (e.Kind == TraceEventKind.FrameExit && _scripts.Count > 0) _scripts.Pop();
else if (e.Kind == TraceEventKind.Step && e.Ins != null)
_timeline.Step(_scripts.Count > 0 ? _scripts.Peek() : "<unknown>", e.Ins.Offset, e.Opcode, e.Depth);
else if (e.Kind == TraceEventKind.Halt)
_timeline.State("halted", new() { ["reason"] = e.Text, ["steps"] = e.Steps });
}
}

View File

@@ -42,6 +42,9 @@ public partial class Main : Godot.Control
private System.IO.StreamWriter? _gfxLog;
private readonly System.Collections.Generic.Dictionary<long, string> _lastGfxDecision = new();
private int _gfxLogFrame;
private string? _timelineLogPath; // --timeline-log <jsonl>: synchronized VM/host/compositor evidence
private GodotTimelineLog? _timeline;
private int _timelineFrame;
public override void _Ready()
{
@@ -104,6 +107,7 @@ public partial class Main : Godot.Control
if (userArgs[i] == "--shot-settle" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _shotSettleTarget);
if (userArgs[i] == "--shot-sequence" && i + 1 < userArgs.Length) _seqDir = userArgs[i + 1];
if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1];
if (userArgs[i] == "--timeline-log" && i + 1 < userArgs.Length) _timelineLogPath = 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);
@@ -130,8 +134,9 @@ public partial class Main : Godot.Control
IScriptProvider provider;
if (_selftest) (script, provider) = BuildSelfTestScene(table);
else { script = Sys4Loader.Load(Paths.Scripts()[scene.ToUpperInvariant() + ".BIN"], table); provider = Sys4ScriptProvider.Load(table); }
_host = new GodotAdvHost(this, ResourceMap.Load(), scene, _clock) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
_trace = new GodotTraceSink();
if (_timelineLogPath != null) _timeline = new GodotTimelineLog(_timelineLogPath);
_host = new GodotAdvHost(this, ResourceMap.Load(), scene, _clock, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
_trace = new GodotTraceSink(_timeline);
// --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow
// diverges — wait-for-input is a no-op there — so this is the only way to profile the live path).
_table = table;
@@ -167,6 +172,7 @@ public partial class Main : Godot.Control
public override void _Process(double delta)
{
_clock.Advance(delta);
_timeline?.SetFrame(++_timelineFrame, _clock.NowMs);
_host?.PulseFrame();
if (!_selftest && _vm != null) Recomposite(); // retained per-frame compositor (surface+object model)
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
@@ -216,7 +222,7 @@ public partial class Main : Godot.Control
_host.SignalInput();
}
public override void _ExitTree() { DumpHistogram(); _host?.Stop(); }
public override void _ExitTree() { DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); }
// 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,12 +251,14 @@ public partial class Main : Godot.Control
private void Recomposite()
{
_screen.Fill(new Color(0, 0, 0, 0));
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null ? new() : null;
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
int z = 0;
foreach (var v in _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs)) // interpolate at the throttled clock
{
var t = v.Transform;
var projected = Age.Engine.Model.Transform2DMath.Apply(v.DstX, v.DstY, t);
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation);
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
var projected = localToDest.Apply(0, 0);
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
@@ -264,16 +272,12 @@ public partial class Main : Godot.Control
if (v.Blend != Age.Engine.Model.BlendKind.Opaque)
{
int baseW = v.W > 0 ? v.W : 800, baseH = v.H > 0 ? v.H : 600;
int fw = (int)System.Math.Round(System.Math.Abs(t.ScaleX) * baseW);
int fh = (int)System.Math.Round(System.Math.Abs(t.ScaleY) * baseH);
int fillX = t.ScaleX >= 0 ? dstX : dstX - fw;
int fillY = t.ScaleY >= 0 ? dstY : dstY - fh;
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}) " +
FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA);
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
$"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})";
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}";
}
else outcome = "SKIP(no-resId, opaque render-target)";
}
@@ -284,12 +288,13 @@ public partial class Main : Godot.Control
else
{
BlitLayer(bmp, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
dstX, dstY, t.ScaleX, t.ScaleY, opacity);
localToDest, 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}) 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}) " +
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
$"op={opacity:0.00} tintStr={strength:0.00}";
}
}
@@ -305,7 +310,7 @@ public partial class Main : Godot.Control
// the frame where the background drops out — and WHY — stands out. See systematic-debugging of the grey-BG.
private void LogGfxDecisionChanges(System.Collections.Generic.Dictionary<long, string> curr)
{
if (_gfxLog == null)
if (_gfxLogPath != null && _gfxLog == null)
{
var dir = System.IO.Path.GetDirectoryName(_gfxLogPath);
if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir);
@@ -319,11 +324,13 @@ public partial class Main : Godot.Control
foreach (var kv in _lastGfxDecision)
if (!curr.ContainsKey(kv.Key))
lines.Add($" 0x{kv.Key:x}: GONE (was {kv.Value})");
if (lines.Count > 0)
if (lines.Count > 0 && _gfxLog != null)
{
_gfxLog.WriteLine($"[frame {_gfxLogFrame} nowMs={_clock.NowMs} page={_pageCount}] {curr.Count} visible, {lines.Count} changes:");
foreach (var l in lines) _gfxLog.WriteLine(l);
}
if (lines.Count > 0)
_timeline?.Event("objects", new() { ["visible_count"] = curr.Count, ["changes"] = lines.ToArray() });
_lastGfxDecision.Clear();
foreach (var kv in curr) _lastGfxDecision[kv.Key] = kv.Value;
}
@@ -333,7 +340,7 @@ public partial class Main : Godot.Control
// tintStrength (0..1, the op 0x202/0x203 alpha) LERPs the texel RGB toward tint (0=keep texel, 1=full tint;
// fade-to-black uses tint=black, strength=1); alpha is the object's OPACITY (independent of the tint).
private void BlitLayer(string bmpPath, long colorKey, long tint, float tintStrength, int srcX, int srcY, int w, int h,
int dstX, int dstY, double scaleX = 1, double scaleY = 1, float alpha = 1f)
Age.Engine.Model.Affine2D localToDest, float alpha = 1f)
{
var cacheKey = (bmpPath, colorKey);
if (!_imgCache.TryGetValue(cacheKey, out var src))
@@ -355,51 +362,19 @@ public partial class Main : Godot.Control
sw = System.Math.Min(sw, src.GetWidth() - srcX);
sh = System.Math.Min(sh, src.GetHeight() - srcY);
if (sw <= 0 || sh <= 0) return;
double absScaleX = System.Math.Abs(scaleX), absScaleY = System.Math.Abs(scaleY);
int outW = (int)System.Math.Round(sw * absScaleX), outH = (int)System.Math.Round(sh * absScaleY);
if (outW <= 0 || outH <= 0) return;
int outX = scaleX >= 0 ? dstX : dstX - outW;
int outY = scaleY >= 0 ? dstY : dstY - outH;
int istr = (int)(System.Math.Clamp(tintStrength, 0f, 1f) * 255);
bool unscaled = System.Math.Abs(scaleX - 1) < 0.0001 && System.Math.Abs(scaleY - 1) < 0.0001;
bool plainOpaque = unscaled && alpha >= 0.999f && istr == 0 &&
!Age.Engine.Model.BlendMath.HasColorKey(colorKey);
if (plainOpaque) // fast path: opaque, un-keyed, un-tinted layer (the common CG case)
{
_screen.BlitRect(src, new Rect2I(srcX, srcY, sw, sh), new Vector2I(dstX, dstY));
return;
}
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
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);
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;
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
if (sa == 0) continue;
// tint = LERP texel toward tint by strength (0=keep texel, 255=full tint), NOT a multiply
int sr = (ss[si] * (255 - istr) + tr * istr) / 255;
int sg = (ss[si + 1] * (255 - istr) + tg * istr) / 255;
int sb = (ss[si + 2] * (255 - istr) + tb * istr) / 255;
dst[di] = (byte)((sr * sa + dst[di] * (255 - sa)) / 255);
dst[di + 1] = (byte)((sg * sa + dst[di + 1] * (255 - sa)) / 255);
dst[di + 2] = (byte)((sb * sa + dst[di + 2] * (255 - sa)) / 255);
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + sa);
}
_screen.SetData(dw, dh, false, _screen.GetFormat(), dst);
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
dst, _screen.GetWidth(), _screen.GetHeight(), ss, src.GetWidth(), src.GetHeight(),
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha);
_screen.SetData(_screen.GetWidth(), _screen.GetHeight(), false, _screen.GetFormat(), dst);
}
private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha)
{
byte[] dst = _screen.GetData();
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
dst, _screen.GetWidth(), _screen.GetHeight(), w, h, localToDest, tint, alpha);
_screen.SetData(_screen.GetWidth(), _screen.GetHeight(), false, _screen.GetFormat(), dst);
}
// Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill.

View File

@@ -5,9 +5,9 @@ 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.
For every changed matrix it records frame-time, integer base/anchor coordinates, sampled 4x4 matrix,
current/target scale, one-shot axis-angle rotation, translation, and cyclic-rotation state. The apply hook
sees the one-shot composition; the composite hook's leave captures the final matrix after cyclic rotation.
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]
@@ -24,12 +24,16 @@ OUT = REPO / "build" / "native-transform-trace.jsonl"
COMPOSITE_OFF = 0x7F650
APPLY_OFF = 0x72F00
OP21F_WORKER_OFF = 0x7EB70
OP223_WORKER_OFF = 0x7F440
OP234_WORKER_OFF = 0x7F060
JS = r"""
const COMPOSITE_OFF=%d, APPLY_OFF=%d, HANDLE_FILTER=%s;
const COMPOSITE_OFF=%d, APPLY_OFF=%d, OP21F=%d, OP223=%d, OP234=%d, HANDLE_FILTER=%s;
const mod = Process.getModuleByName('AGE.EXE');
const activeHandle = new Map();
const last = new Map();
const lastFinal = new Map();
function s32(p, off) { return p.add(off).readS32(); }
function u32(p, off) { return p.add(off).readU32(); }
@@ -39,7 +43,8 @@ function mat(p, off) {
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 v3f(p, off) { return [f32(p,off), f32(p,off+4), f32(p,off+8)]; }
function v3i(p, off) { return [s32(p,off), s32(p,off+4), s32(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); }
@@ -47,11 +52,28 @@ 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());
this.handle = args[0].toUInt32(); this.ctx = this.context.ecx;
activeHandle.set(tid, this.handle);
},
onLeave() { activeHandle.delete(Process.getCurrentThreadId()); }
onLeave() {
activeHandle.delete(Process.getCurrentThreadId());
if (HANDLE_FILTER !== null && this.handle !== HANDLE_FILTER) return;
const m = rounded(mat(this.ctx,0xb574)), key=this.handle.toString(16), sig=JSON.stringify(m);
if (lastFinal.get(key) !== sig) {
lastFinal.set(key,sig);
send({kind:'transform-final',t:Date.now(),handle:this.handle,frameTime:u32(this.ctx,0xb550),matrix:m});
}
}
});
function stackI(ctx,n) { return ctx.esp.add(4+n*4).readS32(); }
function stackF(ctx,n) { return ctx.esp.add(4+n*4).readFloat(); }
Interceptor.attach(mod.base.add(OP21F), { onEnter() { send({kind:'op',op:'0x21f',handle:stackI(this.context,0),
delay:stackI(this.context,1),duration:stackI(this.context,2),axis:[stackF(this.context,3),stackF(this.context,4),stackF(this.context,5)],angle:stackF(this.context,6)}); } });
Interceptor.attach(mod.base.add(OP223), { onEnter() { send({kind:'op',op:'0x223',args:Array.from({length:8},(_,i)=>stackI(this.context,i))}); } });
Interceptor.attach(mod.base.add(OP234), { onEnter() { send({kind:'op',op:'0x234',handle:stackI(this.context,0),
period:stackI(this.context,1),axis:[stackF(this.context,2),stackF(this.context,3),stackF(this.context,4)]}); } });
Interceptor.attach(mod.base.add(APPLY_OFF), {
onEnter(args) {
this.tid = Process.getCurrentThreadId();
@@ -69,24 +91,32 @@ Interceptor.attach(mod.base.add(APPLY_OFF), {
if (last.get(key) === sig) return;
last.set(key, sig);
send({
kind:'transform',
kind:'transform', stage:'one-shot',
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),
anchor:v3i(this.obj,0x18),
base:v3i(this.obj,0x24),
start:u32(this.obj,0x34),
scaleDelay:s32(this.obj,0x3c),
transDelay:s32(this.obj,0x44),
scaleDuration:s32(this.obj,0x50),
rotationDuration:s32(this.obj,0x54),
transDuration:s32(this.obj,0x58),
scaleCurrent:rounded(diag(this.obj,0x6c)),
scaleTarget:rounded(diag(this.obj,0xac)),
rotationCurrentAxis:rounded(v3f(this.obj,0x1ec)),
rotationCurrentAngle:f32(this.obj,0x204),
rotationTargetAxis:rounded(v3f(this.obj,0x1f8)),
rotationTargetAngle:f32(this.obj,0x208),
transCurrent:rounded(trans(this.obj,0x16c)),
transTarget:rounded(trans(this.obj,0x1ac)),
cycleStart:u32(this.obj,0x214),
cyclePeriod:u32(this.obj,0x228),
cycleAxis:rounded(v3f(this.obj,0x244)),
matrix:m
});
} catch(e) {
@@ -96,7 +126,7 @@ Interceptor.attach(mod.base.add(APPLY_OFF), {
});
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")
""" % (COMPOSITE_OFF, APPLY_OFF, OP21F_WORKER_OFF, OP223_WORKER_OFF, OP234_WORKER_OFF, "%s")
def main():
@@ -135,11 +165,13 @@ def main():
kind = payload.get("kind")
if kind == "ready":
print(f"[frida] native transform hooks live: composite={payload['composite']} apply={payload['apply']}")
elif kind == "transform":
elif kind in ("transform", "transform-final"):
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]}")
f"stage={payload.get('stage', 'final')}")
elif kind == "op":
rows.append(payload)
print(f" {payload['op']} {payload}")
elif kind == "error":
print("[capture-error]", payload.get("message"))

View File

@@ -5549,53 +5549,53 @@ observed_types = ["imm"]
[[opcode]]
op = 0x21f
label = "u00421510"
label = "set-anim-rotation-axis-angle"
argc = 7
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00421510"
category = "unknown"
summary = ""
name = "set-anim-rotation-axis-angle"
category = "draw"
summary = "(handle)(delay_ms)(duration_ms)(axis_x)(axis_y)(axis_z)(angle_deg) — set the delayed one-shot axis-angle rotation channel. Handler converts axis/angle integers to floats; worker stores target axis obj+0x1f8 and angle obj+0x208 and builds target matrix obj+0x12c. gfx_object_apply_transform_channels samples current axis/angle linearly on shared start obj+0x34 and composes T(-anchor)*scale*rotation*translation*T(anchor)."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
evidence = "Ghidra handler 0x423410 -> gfx_object_set_rotation_channel@0x47eb70; consumer gfx_object_apply_transform_channels@0x472f00 uses delay +0x40, duration +0x54, current axis +0x1ec/angle +0x204, target axis +0x1f8/angle +0x208, current matrix +0xec and target +0x12c. Native SC0000 handle 0xcb8e sample at 11/390 of axis (0,0,1), 30deg matches matrix [0.9055,0.0134;-0.0134,0.9055] and translation (74.1449,47.3127)."
[[opcode.semantics.args]]
i = 1
role = ""
role = "object handle"
observed_types = ["imm", "g-int"]
[[opcode.semantics.args]]
i = 2
role = ""
role = "delay milliseconds"
observed_types = ["imm"]
[[opcode.semantics.args]]
i = 3
role = ""
role = "duration milliseconds"
observed_types = ["imm"]
[[opcode.semantics.args]]
i = 4
role = ""
role = "rotation axis X"
observed_types = ["imm"]
[[opcode.semantics.args]]
i = 5
role = ""
role = "rotation axis Y"
observed_types = ["imm"]
[[opcode.semantics.args]]
i = 6
role = ""
role = "rotation axis Z"
observed_types = ["imm"]
[[opcode.semantics.args]]
i = 7
role = ""
role = "target angle degrees"
observed_types = ["imm", "l-int"]
[[opcode]]
@@ -5672,58 +5672,58 @@ observed_types = ["imm", "l-int"]
[[opcode]]
op = 0x223
label = "u00421700"
label = "queue-surface-alpha-transition"
argc = 8
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00421700"
category = "unknown"
summary = ""
name = "queue-surface-alpha-transition"
category = "draw"
summary = "(command_key)(target_slot)(range_a_start)(range_a_count)(range_b_start)(range_b_count)(delay_ms)(duration_ms) — queue a type-0 timed alpha transition command in the separate ctx+0x414 command map. This is render-target/surface presentation state, not an object affine matrix. The render frame composites the two handle ranges into target_slot and ramps alpha 0->1 after delay over duration."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
evidence = "Ghidra handler 0x423620 -> gfx_queue_surface_alpha_transition@0x47f440. Record fields: type +0=0, start +4=0, delay +8=arg7, duration +0xc=arg8, slot +0x10=arg2, range A +0x14/+0x1c=args3/4, range B +0x18/+0x20=args5/6. gfx_render_frame@0x47fbc0 initializes start from ctx+0xb550 and consumes type 0 as an alpha ramp. SC0000 executes one shared-helper site at 0x129e7."
[[opcode.semantics.args]]
i = 1
role = ""
role = "command record key"
observed_types = ["imm", "l-int"]
[[opcode.semantics.args]]
i = 2
role = ""
role = "target surface slot"
observed_types = ["imm", "l-ptr"]
[[opcode.semantics.args]]
i = 3
role = ""
role = "first object range start handle"
observed_types = ["imm", "l-int"]
[[opcode.semantics.args]]
i = 4
role = ""
role = "first object range count"
observed_types = ["imm", "l-int"]
[[opcode.semantics.args]]
i = 5
role = ""
role = "second object range start handle"
observed_types = ["imm", "l-ptr"]
[[opcode.semantics.args]]
i = 6
role = ""
role = "second object range count"
observed_types = ["imm", "l-int"]
[[opcode.semantics.args]]
i = 7
role = ""
role = "delay milliseconds"
observed_types = ["imm", "g-int"]
[[opcode.semantics.args]]
i = 8
role = ""
role = "duration milliseconds"
observed_types = ["imm", "g-int"]
[[opcode]]
@@ -6111,12 +6111,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "anim-start"
category = "draw"
summary = "(handle)(period_ms)(axis_x)(axis_y)(axis_z) — configure a cyclic ROTATION channel. Worker gfx_object_set_rotation_cycle@0x47f060 stores period obj+0x228 and axis obj+0x244..0x24c; interpolator 0x473ed0 applies 0..360 degrees from frame-time ctx+0xb550. Separate from scale, translation, opacity, and op 0x238's clock."
summary = "(handle)(period_ms)(axis_x)(axis_y)(axis_z) — configure cyclic rotation. Worker stores period obj+0x228, start obj+0x214=0, and float axis obj+0x244; each frame uses integer degrees floor(((now-start)%period)*360/period). gfx_object_composite right-multiplies this separately anchored transform after the one-shot scale/rotation/translation product, so cyclic rotation also rotates the translation vector."
noop_headless = false
source = "investigation"
confidence = "high"
depends_on = []
evidence = "Ghidra handler 0x423da0 -> worker 0x47f060. gfx_object_anim_interpolate@0x473ed0 consumes obj+0x228/0x244 on ctx+0xb550 and builds an axis-angle rotation matrix with angle 360*((now-start)%period)/period."
evidence = "Ghidra handler 0x423da0 converts axis ints to floats -> worker 0x47f060. gfx_object_anim_interpolate@0x473ed0 consumes obj+0x228/+0x214/+0x244 on ctx+0xb550 and matrix4_make_axis_angle@0x48b215. gfx_object_composite@0x47f650 calls one-shot transform first, cyclic animation second."
[[opcode.semantics.args]]
i = 1