From bb16a5496a68419c5627a7683e90a350f3d6d080 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 10 Jul 2026 17:54:33 -0400 Subject: [PATCH] feat: add affine rotation rendering and timeline diagnostics --- docs/engine-re.md | 60 +++++-- docs/opcode-reference.md | 22 +-- docs/phase-a-slice-plan.md | 158 ++++++++++++++++++ docs/tools-reference.md | 10 +- engine/Age.Engine.Tests/GfxAnimationTests.cs | 44 +++++ .../SoftwareAffineRasterizerTests.cs | 30 ++++ engine/Age.Engine/Model/GfxState.cs | 68 +++++++- .../Model/SoftwareAffineRasterizer.cs | 54 ++++++ engine/Age.Engine/Model/Transform2DMath.cs | 75 ++++++++- engine/Age.Engine/Vm/VirtualMachine.cs | 3 + godot/GodotAdvHost.cs | 10 +- godot/GodotTimelineLog.cs | 72 ++++++++ godot/GodotTraceSink.cs | 13 +- godot/Main.cs | 95 ++++------- tools/frida/capture_native_transforms.py | 60 +++++-- vm-map/opcodes.toml | 62 +++---- 16 files changed, 686 insertions(+), 150 deletions(-) create mode 100644 engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs create mode 100644 engine/Age.Engine/Model/SoftwareAffineRasterizer.cs create mode 100644 godot/GodotTimelineLog.cs diff --git a/docs/engine-re.md b/docs/engine-re.md index a3b5d10..2602ff8 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -445,26 +445,45 @@ independent matrix channels, not two encodings of one vec3 property. - `0x220` passes raw operands 4–6 to `gfx_object_set_translation_channel` (`0x47ecc0`), stores timing at `obj+0x44/+0x58`, and calls `0x48afb1`, which writes them into matrix entries 12–14 at `obj+0x1ac`: a **translation matrix**. -- `gfx_object_apply_transform_channels` (`0x472f00`) supplies the timing contract. Both channels use +- `0x21f` converts operands 4–7 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. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 07b2974..35252dc 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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 diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index d0150d0..a1c3eae 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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 ` 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. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 7ea9849..55d7358 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -137,13 +137,19 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after - `--sleep-scale ` — multiply every `sleep` (op 0xc8) duration by `f` (default 1.0). The authentic opening burst is only ~2 s, too fast to eyeball live; `--sleep-scale 5` stretches it to ~10 s so the paced sequence (arcane `AE*` → character CGs → settled BG) is watchable. Debug-only; leave at 1.0 for real playback. - `--speed ` — scale the unified runtime clock (VM cadence, sleeps, and retained animation) without auto-advancing input waits. Values 0.05–8 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback. - `--gfx-log ` — **compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page. - Matrix-channel outcomes also include `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 ` — 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 → name`** map (id = `raw_index`; see `engine-re.md`). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN` → `build/asset-index.json` + `build/callscript-names.json` | | `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py [resId]` | `build/asset-index.json` → `build/asset-sections.json`; resolves any (scene, resId) | | `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json` → `build/frida-asset-loads.json` | diff --git a/engine/Age.Engine.Tests/GfxAnimationTests.cs b/engine/Age.Engine.Tests/GfxAnimationTests.cs index 06f695d..888d581 100644 --- a/engine/Age.Engine.Tests/GfxAnimationTests.cs +++ b/engine/Age.Engine.Tests/GfxAnimationTests.cs @@ -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()); 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); + } } diff --git a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs new file mode 100644 index 0000000..268f9d8 --- /dev/null +++ b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs @@ -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;iThe 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. +/// 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. 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); /// 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 ; 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 } } + /// Op 0x21f: delayed one-shot axis-angle rotation target, sharing obj+0x34's start timestamp. + 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; + } + } + /// 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. 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); + } + /// Ping-pong interpolation weight in [0,1] toward the target: 0 at cycle start, 1 at half-period. private static double PingPongWeight(long now, long start, long period) { diff --git a/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs b/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs new file mode 100644 index 0000000..a809aa9 --- /dev/null +++ b/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs @@ -0,0 +1,54 @@ +namespace Age.Engine.Model; + +/// Nearest-neighbour inverse-mapped RGBA8 affine compositor used by the Godot host and pure tests. +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= (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=0&&p.X=0&&p.YThe 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. +/// A projected row-vector affine transform: x'=x*XX+y*YX+TX, y'=x*XY+y*YY+TY. +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; + } +} + +/// 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. 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; + } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 0ecab74..d81eb0c 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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) diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 8298afa..5034892 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -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); } diff --git a/godot/GodotTimelineLog.cs b/godot/GodotTimelineLog.cs new file mode 100644 index 0000000..5a8cf00 --- /dev/null +++ b/godot/GodotTimelineLog.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +/// 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. +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 = ""; + 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? detail = null) + { + lock (_lock) { _state = state; WriteLocked(state, detail); } + } + + public void Event(string kind, Dictionary? detail = null) + { + lock (_lock) WriteLocked(kind, detail); + } + + private void Record(string kind, Dictionary? detail) + { + lock (_lock) WriteLocked(kind, detail); + } + + private void WriteLocked(string kind, Dictionary? detail) + { + if (_disposed) return; + var row = new Dictionary + { + ["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(); } } +} diff --git a/godot/GodotTraceSink.cs b/godot/GodotTraceSink.cs index f34dd4b..ad4c6df 100644 --- a/godot/GodotTraceSink.cs +++ b/godot/GodotTraceSink.cs @@ -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 _scripts = new(); + public GodotTraceSink(GodotTimelineLog? timeline = null) => _timeline = timeline; + public bool TracingSteps => _timeline != null; public readonly ConcurrentQueue 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() : "", e.Ins.Offset, e.Opcode, e.Depth); + else if (e.Kind == TraceEventKind.Halt) + _timeline.State("halted", new() { ["reason"] = e.Text, ["steps"] = e.Steps }); } } diff --git a/godot/Main.cs b/godot/Main.cs index 38e4aec..c6824d9 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -42,6 +42,9 @@ public partial class Main : Godot.Control private System.IO.StreamWriter? _gfxLog; private readonly System.Collections.Generic.Dictionary _lastGfxDecision = new(); private int _gfxLogFrame; + private string? _timelineLogPath; // --timeline-log : 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 . 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? decisions = _gfxLogPath != null ? new() : null; + System.Collections.Generic.Dictionary? 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 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. diff --git a/tools/frida/capture_native_transforms.py b/tools/frida/capture_native_transforms.py index e44f188..7414e8c 100644 --- a/tools/frida/capture_native_transforms.py +++ b/tools/frida/capture_native_transforms.py @@ -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")) diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 4ea192a..8feb326 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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