diff --git a/docs/engine-re.md b/docs/engine-re.md index 4bf1346..e482c52 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -406,7 +406,7 @@ Worker functions decoded + annotated in the Ghidra project (updated 2026-07-09): `gfx_object_init_default`@`0x472810`), the setters `gfx_set_vec18/24/16c`(`0x47e960/e910/e800`), the getters `gfx_get_vec18/24`(`0x47f360/f2e0`). -#### The `0x21c–0x243` sprite transform / ANIMATION cluster (2026-07-07, recon — implementation pending) +#### The `0x21c–0x243` sprite transform / ANIMATION cluster (2026-07-10, partial implementation) The scene-completeness tracker (`tools/scene_opcode_coverage.py`) flagged a dense band of GAP ops in `0x21c–0x243` (+ `0x2bd/0x2bf`) — the **largest remaining rendering unknown** in SC0000 (e.g. `0x220`×66, @@ -429,14 +429,14 @@ animation/tween** — and two members were already named in prior RE (`0x234 gfx | `0x224` | `0x417550` (417xxx) | `0x231` | `0x423be0` | `0x23e` | `0x42a4a0` | | `0x225` | `0x4236a0` | `0x232` | `0x423c30` | `0x23f` | `0x42a520` | | `0x226` | `0x42a230` | `0x233` | `0x423cf0` | `0x240` | `0x4245f0` | -| `0x227` | `0x42a2e0` | `0x234` | **anim_start ✎** | `0x241` | `0x4247e0` | +| `0x227` | `0x42a2e0` | `0x234` | **set_rotation_cycle ✎** | `0x241` | `0x4247e0` | | `0x228` | `0x42a3a0` | `0x235` | `0x423e40` | `0x242` | `0x4249d0` | | | | | | `0x243` | `0x4182d0` (417xxx) | (`0x2bd`→`0x4251c0`, `0x2bf`→`0x425240`. The handful of `0x417xxx` handlers are trivial/marker-shaped — the default-handler neighbourhood — and are almost certainly no-ops or arg-poppers; triage before modelling.) -**Contract (corrected 2026-07-09, representative ops `0x21e`/`0x220`, both `argc 6`):** these are +**Contract (completed 2026-07-10, representative ops `0x21e`/`0x220`, both `argc 6`):** these are independent matrix channels, not two encodings of one vec3 property. - `0x21e` normalizes operands 4–6, then `gfx_object_set_scale_channel` (`0x47eaa0`) stores timing at @@ -445,13 +445,16 @@ 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`) interpolates and combines both matrices separately. +- `gfx_object_apply_transform_channels` (`0x472f00`) supplies the timing contract. Both 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 + delay, linearly interpolates current→target for its duration, then commits the target and clears its own timing. Neither third component is opacity. -**Port implication:** `GfxState` must ultimately retain separate scale and translation matrices/timing. -The current single `AnimTarget` plus Godot `TZ/100 = opacity` approximation is native-inaccurate and is -now tracked as transform-compositor debt. It was not the cause of the lingering circle: that was the skipped -`0x215`/`0x1f7`/`0x1fa` teardown above. +**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. ##### `anim_start`/`set_anim_clock` decoded + opening confirmed (2026-07-07, animation-slice Task 1) @@ -464,22 +467,21 @@ both annotated) and grepping the SC0000 opening settles the animation model and objects. Its own plate comment states the payoff: "our port can drive animation in the host's per-frame loop while the VM is parked at wait-for-input; no VM/host frame-lockstep." → **validates the wall-clock-tween architecture directly.** SC0000: `set-anim-clock(G[0x624bb])` @`0x123bd`, `set-anim-clock(0x190=400)` @`0x13858`. -- **`0x234 anim_start` (argc 5, cmd-type 0xb):** `gfx_anim_start(op1=handle, op2, (float)op3, (float)op4, - (float)op5)`. `op2` = *this object's* animation duration (`label_1235a` maxes the per-object durations into the - global clock); `(op3,op4,op5)` = the target transform vec3 the object animates **toward**. SC0000 opening - @`0x00daf`: `anim_start(G[0x62457], 0x2328, 0, 0, local2)` on the INIT2 CG handles, then `call label_1235a`. +- **`0x234 anim_start` (legacy mnemonic; argc 5, cmd-type 0xb):** following worker + `gfx_object_set_rotation_cycle` (`0x47f060`) into `gfx_object_anim_interpolate` (`0x473ed0`) + corrects its ABI to `(handle)(period_ms)(axis_x)(axis_y)(axis_z)`. Period is `obj+0x228`, axis is + `obj+0x244..0x24c`, and the frame-clock consumer applies + `360*((now-start)%period)/period` degrees. This is a cyclic **rotation** channel, not a target vec3 + for scale/translation and not opacity. - **The opening path uses the whole subsystem, early.** `0x21e`/`0x220` transform-sets fire from `0x00f73` onward (`0x21e (G[0x6245b]) 0 0x12c l0 l1 0x64`, `0x220 (G[0x62457]) 0x96 0x3e8 l1 l3 0`), on the same INIT2 CG handles (`G[0x62457]`,`0x6245b`,`0x6245c`) — this is the opening, **not** battle/debug. So the slice's ops are real and verifiable on screen. -**Corrected host model (supersedes the "per-object clock" wording above):** -- **Global clock** (from `0x238`): one `AnimClockDurationTicks` + a generation/reset marker the host watches to - reset its wall-clock `elapsed` to 0. The host tweens all armed objects over this duration. -- **Per-object correction:** `0x21e` is scale and `0x220` is translation; they occupy distinct matrices and - timing fields. No component of either channel is opacity. -- **Residual:** finish separating these channels in `GfxState`/Godot and identify the remaining color/alpha - channel consumers. Do not reuse transform Z as alpha. +**Corrected host model:** `0x21e` scale and `0x220` translation run directly from frame-time +`ctx+0xb550` using their own delay/duration; they do not use op `0x238` as their duration. +Op `0x234` is the independent rotation cycle above. Op `0x238` still configures the separate +`ctx+0x51b78/+0x51b7c` animation service used by its own family. ##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction) @@ -637,11 +639,11 @@ missing-default bug). See `docs/phase-a-slice-plan.md` A2b-Geometry. The `AE*` flash/glow effects (and sprite motion) are a **native time-animated retained render loop**, not per-frame bytecode. Reversed + annotated in Ghidra: -- **Retained objects carry animation state:** an *active* flag (obj`+0` bit 4), a **progress** counter - (obj`+0x214`, starts 0), a **duration** (obj`+0x228`), and a **target vector** (obj`+0x244/248/24c`). -- **`gfx_anim_start`** (`0x47f060`, worker for op **`0x234`**) configures a per-object animation: sets the - flag, resets progress, stores duration + target. Op **`0x1fd`** (`gfx_op_0x1fd_set_vec_scaled`) sets a - scaled 3-vector. +- **Retained objects carry cyclic rotation state:** flag value `4` at `obj+0`, start timestamp + `obj+0x214`, period `obj+0x228`, and axis `obj+0x244/248/24c`. +- **`gfx_object_set_rotation_cycle`** (`0x47f060`, worker for legacy op **`0x234`**) + configures that channel. `gfx_object_anim_interpolate` consumes it from frame-time `ctx+0xb550` + as a repeating 0..360-degree axis rotation. Op **`0x1fd`** is a separate scaled 3-vector setter. - **Op `0x238`** (`gfx_op_0x238_set_anim_clock`) sets a **global animation clock**, **non-blocking**: `ctx+0x51b78 = 0` (elapsed), `ctx+0x51b7c = duration` (the max per-object duration; SC0000 `label_1235a` maxes a table to compute it). It does **not** loop/wait. @@ -652,9 +654,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: reproducing the fades needs a **retained per-frame animated compositor** — the current -immediate-mode permanent canvas can neither fade nor clear. Design: `docs/superpowers/specs/2026-07-07- -animated-compositor-design.md`. +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. ### The full gfx render model — surfaces + objects + composite (2026-07-07) @@ -672,8 +673,8 @@ and snapshotted textures at draw time; symptoms: alternating grey, glow over bac op `0x1fb` draw-texture) → `gfx_object_bind_draw` (`0x47e870`): sets the object's **source slot** (`obj+4`), **source rect** (`obj+8..0x14` = left,top,right,bottom), **position** (`obj+0x24/28/2c` = V24), and the **visible** flag (bit 0). The object references its surface **by slot index, live** (re-resolved each frame), - NOT a snapshot. Objects also carry anchor V18 (`obj+0x18`), animation (flag bit 2 + progress `obj+0x214` / - duration `obj+0x228` / target `obj+0x244..`), and color/alpha (`0x202/0x203`). + NOT a snapshot. Objects also carry anchor V18 (`obj+0x18`), independent scale and translation + matrices/timing, cyclic rotation state, and color/alpha (`0x202/0x203`). **Render frame** — `gfx_render_frame` (`0x4820b0`), driven by op `0x20c` present (`gfx_op_0x20c_present_frame` `0x4174a0`, which also updates the frame timer `ctx+0x51b64/68`): iterate the object registry **in ascending @@ -685,9 +686,8 @@ buffers (present). **Slot 0 is NOT special** — a normal slot; several objects **⇒ Faithful port:** a `SurfaceStore` (`slot → {image, colorkey}`, from create/set-texture) + an `ObjectStore` (`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 (op `0x238` clock is non-blocking; animations -play during the wait-for-input park). Open detail for implementation: the exact scale/transform math in -`gfx_object_composite` (`FUN_00472f00`/`FUN_00473ed0`). +interpolating animations by elapsed time. No VM/host lockstep: animations play during the wait-for-input park. +Scale/translation timing and anchored composition are implemented; full affine rotation remains deferred. ### Blend & transparency — colorkey + `0x202`/`0x203` color/alpha (2026-07-08) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index dee8da5..abe8020 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -179,14 +179,14 @@ Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_r - **evidence:** Ghidra handler 0x42a1b0; FUN_0047f2e0(obj op1) + 3x→FUN_00425fb0(2/3/4). label_12649 site 0x00c86 handle=G[0x62457] → G[0x62498/9/a]. ### 0x21e `set-anim-transform-norm` (set-anim-transform-norm, argc 6) -- **summary:** (handle)(delay)(duration)(sx)(sy)(sz) — set the normalized SCALE-matrix channel. Handler 0x423350 normalizes sx/sy/sz; gfx_object_set_scale_channel@0x47eaa0 stores timing at obj+0x3c/+0x50 and constructs a diagonal 4x4 scale matrix at obj+0xac. Separate from op 0x220 translation; neither channel is opacity. +- **summary:** (handle)(delay_ms)(duration_ms)(sx)(sy)(sz) — set the normalized SCALE-matrix channel (100=identity). Target obj+0xac is linearly sampled from current obj+0x6c by gfx_object_apply_transform_channels@0x472f00 on frame-time ctx+0xb550, after delay and for duration, then committed. Shares only start timestamp obj+0x34 with op 0x220; neither Z is opacity. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra 0x47eaa0 calls matrix builder 0x48af1d, which writes sx/sy/sz to diagonal entries 0/5/10 and identity entry 15. gfx_object_apply_transform_channels@0x472f00 consumes this independently of obj+0x1ac. +- **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. ### 0x220 `set-anim-transform-abs` (set-anim-transform-abs, argc 6) -- **summary:** (handle)(delay)(duration)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Handler 0x4234e0 passes raw floats to gfx_object_set_translation_channel@0x47ecc0, which stores timing at obj+0x44/+0x58 and constructs an identity 4x4 matrix with translation at obj+0x1ac. Separate from op 0x21e scale; neither channel is opacity. +- **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, which writes identity diagonal and tx/ty/tz to entries 12/13/14. gfx_object_apply_transform_channels@0x472f00 combines this independently of obj+0xac. +- **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. ### 0x228 `u00421940` (u00421940, argc 5) - **summary:** 0x228 query-position (succ)(handle)(outX)(outY)(outZ): read the object's current computed position into vars (worker FUN_0047cdd0). C# VM: writes V24 + success flag. See docs/engine-re.md §SC0000 anim cluster. @@ -209,8 +209,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)(duration)(x)(y)(z) — animate object toward target vec3 (x,y,z) over the GLOBAL clock; cmd-type 0xb, worker gfx_anim_start. op2=this object's duration (label_1235a maxes into the clock). SC0000 opening @0xdaf on INIT2 CG handles. Handler 0x423da0; Kelebek VA 0x422060 is drift. +- **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. - **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. ### 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. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 53c60fa..f7729ff 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -348,17 +348,12 @@ Spec `docs/superpowers/specs/2026-07-07-gfx-animation-subsystem-design.md`; plan completeness gauge flagged. **Implemented (VM records, engine 50/50, full parity — sweep 284 exit/13 STEP-LIMIT unchanged):** the four -opening-path anim ops — `0x21e set-anim-transform-norm` / `0x220 set-anim-transform-abs` (per-object transform -channel: target vec3 + 2 params + enable), `0x234 anim-start` (animate toward a target vec3 over the clock), -`0x238 set-anim-clock` (**GLOBAL, non-blocking** duration). `GfxState` gained the per-object anim channel + a -global clock (passive — non-Godot hosts read none of it, so trace parity holds); `RenderObject.AnimState` carries -it to the compositor. **RE correction:** the clock is global 1-operand (not per-object), and `anim-start` carries -the target — the `set_anim_clock` handler's own comment confirms the wall-clock design ("drive animation in the -host's per-frame loop while the VM is parked at wait-for-input"). +opening-path anim ops — `0x21e`, `0x220`, `0x234`, and `0x238` — were first retained in +the port here. **Superseded by the 2026-07-10 matrix slice below:** the original shared-target interpretation was +wrong; `0x21e` is scale, `0x220` is translation, and `0x234` is cyclic rotation. -**Compositor (Godot):** a per-handle wall-clock alpha tween over the global clock + an alpha-aware `BlitLayer` -(3rd vec component = opacity), plus `--shot-settle ` to capture mid-tween. **Non-regressing:** SC0000 -opening page 2 is pixel-identical to baseline at settle 3 and 300; Godot selftest OK. +**Historical compositor:** this slice temporarily used transform Z as alpha. The 2026-07-10 follow-up removes +that approximation and applies the native separate scale/translation channels instead. **Tracker delta (`scene_opcode_coverage.py SC0000`):** GAP 68→**64** ops (836→**741** instrs), impl 49→**53**, correctly-handled 60→**65/129 (50.4%)**. @@ -371,8 +366,8 @@ engine-re.md "opening render path is RETAINED"). `draw-texture` binds a retained handle array `G[0x62455..]`) and a per-object working slot `G[0x62452]`, with `sleep` (`0x64`/`0x3e8`/`0x2ee`) between steps. Our VM executes the whole load/draw/`sleep` burst **instantly** (no timing, no per-frame present), so we only ever see the *final* retained state; the intermediate `AE*` frames never get a frame to display. This -per-object alpha channel is the correct foundation for **retained-sprite** animation (character fades/scales via -the handle system), but the opening explosion needs **frame-pacing** — modeling the scene-coroutine / `sleep 0xc8` +retained-object channel was the correct architectural seam, but the opening explosion needs **frame-pacing** — +modeling the scene-coroutine / `sleep 0xc8` timing (`0x7b`/`0x140`/`0xc8`, still GAP) so the burst is *not* collapsed. That is the clearly-scoped next chunk for the visible opening animation, and a genuinely different subsystem than the transform/alpha channel landed here. (**Correction:** an earlier draft of this note called it "immediate-mode slot-0 blits" — wrong; the engine @@ -695,3 +690,24 @@ handles, so SC0000 skipped its explicit `0x1f7(handle,10)` + `0x1fa(slot)` clean `0x1f7` erases retained objects, and `0x1fa` clears the surface. A booted SC0000 integration regression asserts no visible resource `0x37` remains; engine suite **86/86**. **Live clicked-path validation confirmed the fix on 2026-07-10:** the magic circle now disappears at the intended transition. + +### A2b — native scale/translation matrix channels ✅ DONE (2026-07-10) + +Replaced the legacy shared `AnimTarget` / transform-Z-as-opacity approximation with the native channel +split proven in Ghidra. Op `0x21e` now owns normalized scale (100 = identity; current `obj+0x6c`, +target `obj+0xac`, delay/duration `obj+0x3c/+0x50`). Op `0x220` owns absolute translation +(current `obj+0x16c`, target `obj+0x1ac`, delay/duration `obj+0x44/+0x58`). Consumer +`gfx_object_apply_transform_channels` (`0x472f00`) establishes a shared first-frame start +timestamp, independent delayed linear interpolation, and target commit on completion. + +`GfxState` samples both channels from `FrameClock`; Godot applies scale around V18 plus independent +translation, including nearest-neighbor scaling/flips in the software blitter. Op `0x234` no longer +overwrites either matrix: the completed consumer RE corrected it to a separate cyclic rotation period+axis +channel (retained now; affine rendering deferred). No transform Z value contributes to opacity. + +**Focused validation:** six matrix/rotation tests cover channel independence, VM dispatch, shared-start +delay/duration sampling, target commit, and non-opacity Z values. Engine **86/86** and Godot build pass. +Booted SC0000 with `--shot-sequence` + `--gfx-log`: 180/180 PNGs, 152 log lines, and zero +unresolved/error/NaN/Infinity outcomes. The visible circle expands around its anchor through sampled scales +`1.00 → 1.22 → 1.44 → 1.66` while remaining `op=1.00`, then the retained object is logged +`GONE`. This directly validates that scale is rendered as geometry rather than opacity. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index f031aca..9873e84 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -136,6 +136,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after - `--shot-sequence [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies **time-based (sleep-paced) effects** — e.g. the opening `AE*` burst — as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --boot --shot-sequence out/seq --frames 300`. - `--sleep-scale ` — 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. - `--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 sampled `scale`/`trans` values. Parent directories are created automatically. ## Asset resolution / graphics diff --git a/engine/Age.Engine.Tests/GfxAnimationTests.cs b/engine/Age.Engine.Tests/GfxAnimationTests.cs index 6b15b11..f2ab241 100644 --- a/engine/Age.Engine.Tests/GfxAnimationTests.cs +++ b/engine/Age.Engine.Tests/GfxAnimationTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Age.Engine.Model; using Age.Engine.Sys4; using Age.Engine.Vm; @@ -6,31 +7,36 @@ using Xunit; public class GfxAnimationTests { - // ---- Task 2: GfxState animation-channel data model (revised global-clock model, see engine-re.md) ---- + // ---- Native independent matrix channels + separate cyclic rotation (see engine-re.md). ---- [Fact] - public void SetAnimTransform_RecordsTargetParamsAndEnables() + public void ScaleAndTranslationChannels_AreIndependent() { var g = new GfxState(); - g.SetAnimTransform(0x1000, p1: 7, p2: 9, target: (100, 100, 100), normalized: true); + g.SetScaleChannel(0x1000, delayMs: 7, durationMs: 900, percent: (200, 50, 100)); + g.SetTranslationChannel(0x1000, delayMs: 9, durationMs: 1200, target: (80, -25, 6)); var o = g.TryGet(0x1000)!; - Assert.Equal((100L, 100L, 100L), o.AnimTarget); - Assert.Equal(7, o.AnimParam1); - Assert.Equal(9, o.AnimParam2); - Assert.True(o.AnimNormalized); - Assert.True(o.AnimEnabled); + Assert.Equal((2.0, 0.5, 1.0), o.ScaleTarget); + Assert.Equal((80.0, -25.0, 6.0), o.TranslationTarget); + Assert.Equal((7, 900), (o.ScaleDelayMs, o.ScaleDurationMs)); + Assert.Equal((9, 1200), (o.TranslationDelayMs, o.TranslationDurationMs)); + Assert.True(o.ScaleEnabled); + Assert.True(o.TranslationEnabled); } [Fact] - public void StartAnim_SetsTargetDurationEnablesAndBumpsGeneration() + public void RotationCycle_DoesNotOverwriteMatrixChannels() { var g = new GfxState(); - g.StartAnim(0x1000, durationTicks: 30, target: (0, 0, 5)); + g.SetScaleChannel(0x1000, 0, 100, (150, 150, 100)); + g.SetTranslationChannel(0x1000, 0, 100, (10, 20, 0)); + g.SetRotationCycle(0x1000, periodMs: 30, axis: (0, 0, 5)); var o = g.TryGet(0x1000)!; - Assert.Equal((0L, 0L, 5L), o.AnimTarget); - Assert.Equal(30, o.AnimDurationTicks); - Assert.True(o.AnimEnabled); - Assert.Equal(1, o.AnimGeneration); // fresh object starts at 0, one anim-start -> 1 + Assert.Equal((1.5, 1.5, 1.0), o.ScaleTarget); + Assert.Equal((10.0, 20.0, 0.0), o.TranslationTarget); + Assert.Equal((0L, 0L, 5L), o.RotationAxis); + Assert.Equal(30, o.RotationPeriodMs); + Assert.True(o.RotationEnabled); } [Fact] @@ -52,29 +58,32 @@ public class GfxAnimationTests private static (int, Operand[]) Exit() => (0x2, System.Array.Empty()); [Fact] - public void SetAnimTransformAbs_DispatchRecordsChannel() + public void MatrixOpcodeDispatch_RecordsSeparateChannels() { var t = T(); - // handle g[1]=0x1000; p1 g[2]=7; p2 g[3]=9; target g[4,5,6]=(800,500,0) + // 0x220 sets translation; 0x21e sets scale percentages without overwriting it. var scene = ScriptAssembler.Assemble(t, "ANIM", new List<(int, Operand[])> { MovGI(1, 0x1000), MovGI(2, 7), MovGI(3, 9), MovGI(4, 800), MovGI(5, 500), MovGI(6, 0), (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) }), Exit(), }, System.Array.Empty()); var vm = new VirtualMachine(scene, t, new RecordingHost()); vm.Run(); var o = vm.Gfx.TryGet(0x1000)!; - Assert.Equal((800L, 500L, 0L), o.AnimTarget); - Assert.False(o.AnimNormalized); - Assert.True(o.AnimEnabled); + Assert.Equal((800.0, 500.0, 0.0), o.TranslationTarget); + Assert.Equal((2.0, 0.5, 1.0), o.ScaleTarget); + Assert.True(o.TranslationEnabled); + Assert.True(o.ScaleEnabled); } [Fact] - public void AnimStartAndClock_DispatchSetTargetDurationClockAndGeneration() + public void RotationCycleAndClock_DispatchRemainSeparate() { var t = T(); - // anim-start(handle=0x1000, duration=30, target=(0,0,5)); set-anim-clock(400) + // The legacy anim-start name is a rotation period+axis; 0x238 remains its own clock service. var scene = ScriptAssembler.Assemble(t, "ANIM", new List<(int, Operand[])> { MovGI(1, 0x1000), MovGI(2, 30), MovGI(3, 0), MovGI(4, 0), MovGI(5, 5), MovGI(6, 400), @@ -85,35 +94,48 @@ public class GfxAnimationTests var vm = new VirtualMachine(scene, t, new RecordingHost()); vm.Run(); var o = vm.Gfx.TryGet(0x1000)!; - Assert.Equal((0L, 0L, 5L), o.AnimTarget); - Assert.Equal(30, o.AnimDurationTicks); - Assert.Equal(1, o.AnimGeneration); - Assert.True(o.AnimEnabled); + Assert.Equal((0L, 0L, 5L), o.RotationAxis); + Assert.Equal(30, o.RotationPeriodMs); + Assert.True(o.RotationEnabled); + Assert.Equal((1.0, 1.0, 1.0), o.ScaleCurrent); + Assert.Equal((0.0, 0.0, 0.0), o.TranslationCurrent); Assert.Equal(400, vm.Gfx.AnimClockDurationTicks); Assert.Equal(1, vm.Gfx.AnimClockGeneration); } [Fact] - public void SnapshotCarriesAnimStateForVisibleObject() + public void SnapshotSamplesDelayedMatrixChannels_WithoutUsingZAsOpacity() { - var t = T(); - // make object 0xA visible via set/draw-texture, then anim-start it toward (800,600,0) over 30 ticks. - var scene = ScriptAssembler.Assemble(t, "ANIM", new List<(int, Operand[])> - { - MovGI(1, 0xA), MovGI(2, 4), MovGI(7, 0x25), MovGI(3, 800), MovGI(4, 600), MovGI(5, 0), MovGI(6, 0), - (0x1f9, new[] { G(7), G(2), I(0) }), // set-texture resId 0x25 -> slot 4 - (0x1fb, new[] { G(1), G(2), I(0), I(0), G(3), G(4), G(5), G(6) }), // draw-texture: object 0xA visible - MovGI(8, 30), - (0x234, new[] { G(1), G(8), G(3), G(4), G(5) }), // anim-start(0xA, dur=30, (800,600,0)) - Exit(), - }, System.Array.Empty()); - var vm = new VirtualMachine(scene, t, new RecordingHost()); - vm.Run(); - var vis = vm.Gfx.SnapshotVisibleObjects(); - Assert.Single(vis); - Assert.True(vis[0].Anim.Enabled); - Assert.Equal((800L, 600L, 0L), (vis[0].Anim.TX, vis[0].Anim.TY, vis[0].Anim.TZ)); - Assert.Equal(30, vis[0].Anim.DurationTicks); - Assert.Equal(1, vis[0].Anim.Generation); + var g = new GfxState(); + g.SetSurface(4, 0x25, -1); + g.BindDraw(0xA, 4, 0, 0, 100, 80, 120, 70); + g.GetOrCreate(0xA).V18 = (100, 50, 0); + g.SetScaleChannel(0xA, delayMs: 100, durationMs: 200, percent: (200, 50, 300)); + g.SetTranslationChannel(0xA, delayMs: 100, durationMs: 200, target: (40, -10, 99)); + g.SetRotationCycle(0xA, periodMs: 30, axis: (0, 0, 5)); + + var start = g.SnapshotVisibleObjects(1000).Single(); + Assert.Equal((1.0, 1.0, 1.0), (start.Transform.ScaleX, start.Transform.ScaleY, start.Transform.ScaleZ)); + Assert.Equal((0.0, 0.0, 0.0), + (start.Transform.TranslateX, start.Transform.TranslateY, start.Transform.TranslateZ)); + + var held = g.SnapshotVisibleObjects(1100).Single(); + Assert.Equal((1.0, 1.0, 1.0), (held.Transform.ScaleX, held.Transform.ScaleY, held.Transform.ScaleZ)); + Assert.Equal((0.0, 0.0, 0.0), + (held.Transform.TranslateX, held.Transform.TranslateY, held.Transform.TranslateZ)); + + var halfway = g.SnapshotVisibleObjects(1200).Single(); + Assert.Equal((1.5, 0.75, 2.0), + (halfway.Transform.ScaleX, halfway.Transform.ScaleY, halfway.Transform.ScaleZ)); + Assert.Equal((20.0, -5.0, 49.5), + (halfway.Transform.TranslateX, halfway.Transform.TranslateY, halfway.Transform.TranslateZ)); + Assert.Equal(255, halfway.Alpha); + + var done = g.SnapshotVisibleObjects(1300).Single(); + Assert.Equal((2.0, 0.5, 3.0), (done.Transform.ScaleX, done.Transform.ScaleY, done.Transform.ScaleZ)); + Assert.Equal((40.0, -10.0, 99.0), + (done.Transform.TranslateX, done.Transform.TranslateY, done.Transform.TranslateZ)); + Assert.True(done.Rotation.Enabled); + Assert.Equal(255, done.Alpha); // scale-Z=3, translation-Z=99, rotation-axis-Z=5: still opaque } } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 7d0ef2e..fddd4a1 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -2,13 +2,13 @@ using System.Linq; namespace Age.Engine.Model; -/// Per-object animation channel snapshot for the compositor (cluster 0x21c-0x243). Enabled = the -/// object has an active anim channel; (TX,TY,TZ) = the transform target it animates toward; Normalized = the -/// 0x21e ~percent variant; DurationTicks = the object's own duration (anim-start op2). The GLOBAL clock timebase -/// (duration + generation) is read separately off . Generation bumps -/// on each anim-start — the compositor re-triggers its wall-clock tween when it changes. -public readonly record struct AnimState(bool Enabled, bool Normalized, long TX, long TY, long TZ, - long DurationTicks, long Generation); +/// 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. +public readonly record struct TransformState(double ScaleX, double ScaleY, double ScaleZ, + double TranslateX, double TranslateY, double TranslateZ, + double AnchorX, double AnchorY, double AnchorZ); + +public readonly record struct RotationCycleState(bool Enabled, long PeriodMs, long AxisX, long AxisY, long AxisZ); /// 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 @@ -20,7 +20,8 @@ public readonly record struct AnimState(bool Enabled, bool Normalized, long TX, /// vanish — the grey-background bug). public readonly record struct RenderObject(long Handle, long SurfaceResId, long ColorKey, int SrcX, int SrcY, int W, int H, int DstX, int DstY, - AnimState Anim, int Alpha, long Tint, int TintStrength, BlendKind Blend); + TransformState Transform, RotationCycleState Rotation, + int Alpha, long Tint, int TintStrength, BlendKind Blend); /// Host-agnostic model of the AGE native gfx command-buffer (reversed in /// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the @@ -50,16 +51,22 @@ public sealed class GfxState public (int X, int Y, int W, int H) SrcRect; public bool Visible; - // ---- animation channel (cluster 0x21c-0x243; see docs/engine-re.md "sprite transform / ANIMATION"). - // 0x21e/0x220 set the transform directly (worker gfx_anim_set_channel@0x47eaa0: obj+0x3c=p1, +0x50=p2, - // +0xac=target, +0x68=enable); 0x234 anim-start animates toward a target over the GLOBAL clock (0x238). - // Passive: recorded here, interpolated by the Godot compositor over wall-clock. ---- - public (long X, long Y, long Z) AnimTarget; - public long AnimParam1, AnimParam2; - public bool AnimNormalized; // 0x21e (operands ~percent, /_DAT_00571c28) vs 0x220 (absolute) - public bool AnimEnabled; // obj+0x68 - public long AnimDurationTicks; // 0x234 anim-start op2 (this object's duration; maxed into the clock) - public long AnimGeneration; // bumped by anim-start (0x234); the compositor's per-object re-trigger + // ---- independent one-shot matrix channels (gfx_object_apply_transform_channels@0x472f00). ---- + // Scale: current obj+0x6c, target obj+0xac, delay obj+0x3c, duration obj+0x50. + public (double X, double Y, double Z) ScaleCurrent = (1, 1, 1), ScaleTarget = (1, 1, 1); + public long ScaleDelayMs, ScaleDurationMs; + public bool ScaleEnabled; + // Translation: current obj+0x16c, target obj+0x1ac, delay obj+0x44, duration obj+0x58. + public (double X, double Y, double Z) TranslationCurrent, TranslationTarget; + public long TranslationDelayMs, TranslationDurationMs; + public bool TranslationEnabled; + // Shared matrix-channel start timestamp obj+0x34, seeded from frame-time ctx+0xb550. + public long MatrixStartMs = -1; + + // Op 0x234 is a separate cyclic rotation channel (period obj+0x228, axis obj+0x244..0x24c). + public long RotationPeriodMs; + public (long X, long Y, long Z) RotationAxis; + public bool RotationEnabled; } // ---- geometry/draw object store (V18/V24/draw bind, the compositor's input) ---- @@ -75,10 +82,8 @@ public sealed class GfxState private readonly Dictionary _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0 public long CurrentObject { get; private set; } - // ---- GLOBAL animation clock (op 0x238 set-anim-clock; native ctx+0x51b7c total / +0x51b78 elapsed). - // Non-blocking: the op only configures duration; the host advances elapsed per-frame and tweens all armed - // objects over it (docs/engine-re.md, "anim_start/set_anim_clock decoded"). Generation bumps on each set so - // the compositor resets its wall-clock elapsed. ---- + // ---- Separate global animation service clock (op 0x238; ctx+0x51b7c total / +0x51b78 elapsed). + // Retained for its opcode family; 0x21e scale and 0x220 translation use frame-time directly instead. ---- public long AnimClockDurationTicks { get; private set; } public long AnimClockGeneration { get; private set; } @@ -188,35 +193,42 @@ public sealed class GfxState } } - /// Op 0x21e/0x220 (set-anim-transform): record the transform target + two scalar params on the - /// object and enable its animation channel. normalized = 0x21e (operands ~percent, /_DAT_00571c28); - /// absolute = 0x220. Native worker gfx_anim_set_channel@0x47eaa0 sets obj+0x3c=p1, +0x50=p2, +0xac=target, - /// +0x68=1. - public void SetAnimTransform(long handle, long p1, long p2, (long X, long Y, long Z) target, bool normalized) + /// Op 0x21e: normalized scale target (100 = identity), with independent delay/duration. + public void SetScaleChannel(long handle, long delayMs, long durationMs, (long X, long Y, long Z) percent) { lock (_lock) { var o = GetOrCreate(handle); - o.AnimParam1 = p1; o.AnimParam2 = p2; o.AnimTarget = target; - o.AnimNormalized = normalized; o.AnimEnabled = true; + o.ScaleDelayMs = delayMs; o.ScaleDurationMs = durationMs; + o.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0); + o.ScaleEnabled = durationMs > 0; o.MatrixStartMs = -1; } } - /// Op 0x234 (anim-start): animate the object toward over the global - /// clock; is this object's duration (native label_1235a maxes them into - /// the clock). Bumps AnimGeneration — the compositor's per-object re-trigger. - public void StartAnim(long handle, long durationTicks, (long X, long Y, long Z) target) + /// Op 0x220: absolute translation target, with independent delay/duration. + public void SetTranslationChannel(long handle, long delayMs, long durationMs, (long X, long Y, long Z) target) { lock (_lock) { var o = GetOrCreate(handle); - o.AnimTarget = target; o.AnimDurationTicks = durationTicks; - o.AnimEnabled = true; o.AnimGeneration++; + o.TranslationDelayMs = delayMs; o.TranslationDurationMs = durationMs; + o.TranslationTarget = target; + o.TranslationEnabled = durationMs > 0; o.MatrixStartMs = -1; } } - /// Op 0x238 (set-anim-clock): set the GLOBAL animation duration (game ticks) and bump the clock - /// generation so the host resets its wall-clock elapsed. Non-blocking (the render loop advances it). + /// 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) + { + lock (_lock) + { + var o = GetOrCreate(handle); + o.RotationPeriodMs = periodMs; o.RotationAxis = axis; o.RotationEnabled = periodMs > 0; + } + } + + /// Op 0x238: set its separate global animation-service duration and reset marker. public void SetAnimClock(long durationTicks) { lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; } @@ -227,8 +239,8 @@ public sealed class GfxState public IReadOnlyList SnapshotVisibleObjects() => SnapshotVisibleObjects(0); /// Visible objects in ascending-handle order (= z-order), each with its source surface resolved - /// and its active anim channels interpolated at (the port of - /// gfx_object_anim_interpolate). Position is the base V24 (a direct transform, ops 0x22f/0x229). The + /// and its active channels interpolated at . Position is the base V24 (a direct + /// transform, ops 0x22f/0x229); scale and translation are independent one-shot matrix channels. The /// src-rect channel (0x239/0x231) selects the spritesheet cell; the color channel (0x232) ping-pongs the /// alpha/tint. Channel Start fields seed to nowMs on first sight. public IReadOnlyList SnapshotVisibleObjects(long nowMs) @@ -280,17 +292,49 @@ public sealed class GfxState w = cellW; h = cellH; } + // One-shot matrix channels: hold current through delay, then linearly sample current -> target. + if ((o.ScaleEnabled || 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 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; + list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h, (int)o.V24.X, (int)o.V24.Y, - new AnimState(o.AnimEnabled, o.AnimNormalized, - o.AnimTarget.X, o.AnimTarget.Y, o.AnimTarget.Z, - o.AnimDurationTicks, o.AnimGeneration), + new TransformState(scale.X, scale.Y, scale.Z, + translation.X, translation.Y, translation.Z, + o.V18.X, o.V18.Y, o.V18.Z), + new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs, + o.RotationAxis.X, o.RotationAxis.Y, + o.RotationAxis.Z), alpha, tint, strength, blend)); } return list; } } + private static (double X, double Y, double Z) SampleMatrixChannel( + ref (double X, double Y, double Z) current, + (double X, double Y, double Z) 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); + } + /// 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/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 644ba65..1e4869b 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -367,14 +367,14 @@ public sealed class VirtualMachine case "gfx-draw-color": // 0x203 (handle)(v)(alpha)(color) — static alpha/tint Gfx.SetObjectColor(Read(a[0]), GfxState.PackColor(Read(a[2]), Read(a[3]))); return pc + 1; // ---- sprite transform / animation cluster (docs/engine-re.md "0x21c-0x243 ... ANIMATION") ---- - case "set-anim-transform-abs": // 0x220 (handle)(p1)(p2)(x)(y)(z) — set transform directly - Gfx.SetAnimTransform(Read(a[0]), Read(a[1]), Read(a[2]), - (Read(a[3]), Read(a[4]), Read(a[5])), normalized: false); return pc + 1; - case "set-anim-transform-norm": // 0x21e — same, operands are ~percent (/_DAT_00571c28) - Gfx.SetAnimTransform(Read(a[0]), Read(a[1]), Read(a[2]), - (Read(a[3]), Read(a[4]), Read(a[5])), normalized: true); return pc + 1; - case "anim-start": // 0x234 (handle)(duration)(x)(y)(z) — animate toward target over the global clock - Gfx.StartAnim(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1; + case "set-anim-transform-abs": // 0x220 (handle)(delay)(duration)(tx)(ty)(tz) + Gfx.SetTranslationChannel(Read(a[0]), Read(a[1]), Read(a[2]), + (Read(a[3]), Read(a[4]), Read(a[5]))); return pc + 1; + 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 "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) Gfx.SetAnimClock(Read(a[0])); return pc + 1; default: diff --git a/godot/Main.cs b/godot/Main.cs index f6c6055..0f26e8a 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -161,7 +161,6 @@ public partial class Main : Godot.Control public override void _Process(double delta) { - _lastDelta = delta; _clock.Advance(delta); _host?.PulseFrame(); if (!_selftest && _vm != null) Recomposite(); // retained per-frame compositor (surface+object model) @@ -234,39 +233,21 @@ public partial class Main : Godot.Control // ---- retained per-frame compositor (main thread, from _Process) ---- // Clear the screen and composite the VM's current VISIBLE gfx objects in ascending-handle order (= the // engine's z-order), each blitting its live surface's rect at its position. Surfaces are cached by BMP - // path (this runs every frame). Animated objects tween over the global anim-clock (0x238); their opacity - // is applied by the alpha-aware BlitLayer. See docs/engine-re.md "sprite transform / ANIMATION cluster". + // path (this runs every frame). Native scale/translation matrix channels are sampled independently by + // GfxState and applied here; object opacity comes only from the actual blend/color path. private readonly System.Collections.Generic.Dictionary<(string Path, long Key), Image?> _imgCache = new(); - // Legacy host approximation: per-handle wall-clock tween using Anim.TZ as opacity. Native RE now proves - // op 0x21e is a scale matrix and 0x220 is a separate translation matrix, so TZ is NOT opacity. Keep this - // behavior isolated here until the transform compositor is split; it is unrelated to retained-object - // teardown (0x215/0x1f7/0x1fa), which now removes the magic-circle object correctly. - private sealed class TweenState - { - public long Generation = long.MinValue; - public bool Initialized; - public double Elapsed, Duration; - public double StartA, TargetA, CurrentA = 1.0; - } - private readonly System.Collections.Generic.Dictionary _tweens = new(); - private long _lastClockGen = long.MinValue; - private double _lastDelta; - private const double GameTickSeconds = 1.0 / 60.0; // anim-clock ticks -> seconds (game runs ~60fps) - private void Recomposite() { _screen.Fill(new Color(0, 0, 0, 0)); - long clockGen = _vm.Gfx.AnimClockGeneration; - double clockDur = System.Math.Max(1, _vm.Gfx.AnimClockDurationTicks) * GameTickSeconds; - bool clockReset = clockGen != _lastClockGen; - _lastClockGen = clockGen; System.Collections.Generic.Dictionary? decisions = _gfxLogPath != null ? new() : null; int z = 0; foreach (var v in _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs)) // interpolate at the throttled clock { - float animA = AlphaFor(v, clockReset, clockDur); - float opacity = animA * (v.Alpha / 255f); // object opacity (color-op alpha is NOT opacity) + var t = v.Transform; + int dstX = (int)System.Math.Round(t.AnchorX + (v.DstX - t.AnchorX) * t.ScaleX + t.TranslateX); + int dstY = (int)System.Math.Round(t.AnchorY + (v.DstY - t.AnchorY) * t.ScaleY + t.TranslateY); + float opacity = v.Alpha / 255f; // transform Z is never opacity float strength = v.TintStrength / 255f; // tint-blend / fill strength string outcome; if (v.SurfaceResId == 0) @@ -276,10 +257,16 @@ public partial class Main : Godot.Control // objects are render targets — still skipped (slice C). if (v.Blend != Age.Engine.Model.BlendKind.Opaque) { - int fw = v.W > 0 ? v.W : 800, fh = v.H > 0 ? v.H : 600; + 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(v.DstX, v.DstY, fw, fh, v.Tint, fillA); - outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {fw}x{fh}@({v.DstX},{v.DstY})"; + FillQuad(fillX, fillY, fw, fh, v.Tint, fillA); + outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {fw}x{fh}@({fillX},{fillY}) " + + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " + + $"trans=({t.TranslateX:0.0},{t.TranslateY:0.0})"; } else outcome = "SKIP(no-resId, opaque render-target)"; } @@ -289,10 +276,12 @@ public partial class Main : Godot.Control if (bmp == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)"; else { - BlitLayer(bmp, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H, v.DstX, v.DstY, opacity); + BlitLayer(bmp, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H, + dstX, dstY, t.ScaleX, t.ScaleY, opacity); var raw = _vm.Gfx.TryGet(v.Handle); outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {System.IO.Path.GetFileName(bmp)} " + - $"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) dst=({v.DstX},{v.DstY}) " + + $"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) dst=({dstX},{dstY}) " + + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " + $"op={opacity:0.00} tintStr={strength:0.00}"; } } @@ -308,7 +297,12 @@ 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) { - _gfxLog ??= new System.IO.StreamWriter(_gfxLogPath!) { AutoFlush = true }; + if (_gfxLog == null) + { + var dir = System.IO.Path.GetDirectoryName(_gfxLogPath); + if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir); + _gfxLog = new System.IO.StreamWriter(_gfxLogPath!) { AutoFlush = true }; + } _gfxLogFrame++; var lines = new System.Collections.Generic.List(); foreach (var kv in curr) @@ -326,33 +320,12 @@ public partial class Main : Godot.Control foreach (var kv in curr) _lastGfxDecision[kv.Key] = kv.Value; } - // Current legacy opacity approximation. TODO(transform compositor): replace this with independent native - // scale/translation matrices and source opacity only from the actual color/blend channel. - private float AlphaFor(Age.Engine.Model.RenderObject v, bool clockReset, double clockDur) - { - if (!v.Anim.Enabled) return 1f; - var tw = _tweens.TryGetValue(v.Handle, out var t) ? t : (_tweens[v.Handle] = new TweenState()); - double targetA = System.Math.Clamp(v.Anim.TZ / 100.0, 0, 1); - if (clockReset || tw.Generation != v.Anim.Generation) - { - tw.Generation = v.Anim.Generation; - tw.Elapsed = 0; tw.Duration = clockDur; - tw.StartA = tw.Initialized ? tw.CurrentA : 1.0; // hold previous on-screen alpha; first sight opaque - tw.TargetA = targetA; - tw.Initialized = true; - } - tw.Elapsed += _lastDelta * _clock.Speed; // Speed==1 now => identical; future Ctrl scales the tween - double p = tw.Duration > 0 ? System.Math.Clamp(tw.Elapsed / tw.Duration, 0, 1) : 1; - tw.CurrentA = tw.StartA + (tw.TargetA - tw.StartA) * p; - return (float)tw.CurrentA; - } - // Blit one object's surface rect. The source Image is cached per (path, colorKey): on first load, texels // matching the surface colorkey are made transparent (native bakes the key at load — engine-re.md §Blend). // 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, float alpha = 1f) + int dstX, int dstY, double scaleX = 1, double scaleY = 1, float alpha = 1f) { var cacheKey = (bmpPath, colorKey); if (!_imgCache.TryGetValue(cacheKey, out var src)) @@ -374,9 +347,16 @@ 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 plainOpaque = alpha >= 0.999f && istr == 0 && !Age.Engine.Model.BlendMath.HasColorKey(colorKey); + 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)); @@ -387,13 +367,17 @@ public partial class Main : Godot.Control byte[] dst = _screen.GetData(); byte[] ss = src.GetData(); int dw = _screen.GetWidth(), dh = _screen.GetHeight(), sfw = src.GetWidth(); int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255); - for (int y = 0; y < sh; y++) - for (int x = 0; x < sw; x++) + for (int y = 0; y < outH; y++) + for (int x = 0; x < outW; x++) { - int dxp = dstX + x, dyp = dstY + y; + int sampleX = System.Math.Min(sw - 1, (int)(x / absScaleX)); + int sampleY = System.Math.Min(sh - 1, (int)(y / absScaleY)); + if (scaleX < 0) sampleX = sw - 1 - sampleX; + if (scaleY < 0) sampleY = sh - 1 - sampleY; + int dxp = outX + x, dyp = outY + y; if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue; int di = (dyp * dw + dxp) * 4; - int si = ((srcY + y) * sfw + (srcX + x)) * 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 diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 1f51d1d..5897a5c 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -5510,12 +5510,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "set-anim-transform-norm" category = "draw" -summary = "(handle)(delay)(duration)(sx)(sy)(sz) — set the normalized SCALE-matrix channel. Handler 0x423350 normalizes sx/sy/sz; gfx_object_set_scale_channel@0x47eaa0 stores timing at obj+0x3c/+0x50 and constructs a diagonal 4x4 scale matrix at obj+0xac. Separate from op 0x220 translation; neither channel is opacity." +summary = "(handle)(delay_ms)(duration_ms)(sx)(sy)(sz) — set the normalized SCALE-matrix channel (100=identity). Target obj+0xac is linearly sampled from current obj+0x6c by gfx_object_apply_transform_channels@0x472f00 on frame-time ctx+0xb550, after delay and for duration, then committed. Shares only start timestamp obj+0x34 with op 0x220; neither Z is opacity." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra 0x47eaa0 calls matrix builder 0x48af1d, which writes sx/sy/sz to diagonal entries 0/5/10 and identity entry 15. gfx_object_apply_transform_channels@0x472f00 consumes this independently of obj+0x1ac." +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." [[opcode.semantics.args]] i = 1 @@ -5607,12 +5607,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "set-anim-transform-abs" category = "draw" -summary = "(handle)(delay)(duration)(tx)(ty)(tz) — set the absolute TRANSLATION-matrix channel. Handler 0x4234e0 passes raw floats to gfx_object_set_translation_channel@0x47ecc0, which stores timing at obj+0x44/+0x58 and constructs an identity 4x4 matrix with translation at obj+0x1ac. Separate from op 0x21e scale; neither channel is opacity." +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." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra 0x47ecc0 calls matrix builder 0x48afb1, which writes identity diagonal and tx/ty/tz to entries 12/13/14. gfx_object_apply_transform_channels@0x472f00 combines this independently of obj+0xac." +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." [[opcode.semantics.args]] i = 1 @@ -6111,12 +6111,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "anim-start" category = "draw" -summary = "(handle)(duration)(x)(y)(z) — animate object toward target vec3 (x,y,z) over the GLOBAL clock; cmd-type 0xb, worker gfx_anim_start. op2=this object's duration (label_1235a maxes into the clock). SC0000 opening @0xdaf on INIT2 CG handles. Handler 0x423da0; Kelebek VA 0x422060 is drift." +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." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "" +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." [[opcode.semantics.args]] i = 1