feat: add affine rotation rendering and timeline diagnostics
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.05–8 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback.
|
||||
- `--gfx-log <file>` — **compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page.
|
||||
Matrix-channel outcomes also include `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` |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
30
engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs
Normal file
30
engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
54
engine/Age.Engine/Model/SoftwareAffineRasterizer.cs
Normal file
54
engine/Age.Engine/Model/SoftwareAffineRasterizer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
72
godot/GodotTimelineLog.cs
Normal 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(); } }
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user