feat: split native scale and translation channels

This commit is contained in:
gamer147
2026-07-10 10:28:11 -04:00
parent e899d06fd8
commit c14c7fdced
9 changed files with 276 additions and 208 deletions

View File

@@ -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 `0x21c0x243` sprite transform / ANIMATION cluster (2026-07-07, recon — implementation pending)
#### The `0x21c0x243` 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
`0x21c0x243` (+ `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 46, 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 46 to `gfx_object_set_translation_channel` (`0x47ecc0`), stores timing at
`obj+0x44/+0x58`, and calls `0x48afb1`, which writes them into matrix entries 1214 at
`obj+0x1ac`: a **translation matrix**.
- `gfx_object_apply_transform_channels` (`0x472f00`) 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)

View File

@@ -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.

View File

@@ -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 <frames>` 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.

View File

@@ -136,6 +136,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
- `--shot-sequence <dir> [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies **time-based (sleep-paced) effects** — e.g. the opening `AE*` burst — as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --boot --shot-sequence out/seq --frames 300`.
- `--sleep-scale <f>` — multiply every `sleep` (op 0xc8) duration by `f` (default 1.0). The authentic opening burst is only ~2 s, too fast to eyeball live; `--sleep-scale 5` stretches it to ~10 s so the paced sequence (arcane `AE*` → character CGs → settled BG) is watchable. Debug-only; leave at 1.0 for real playback.
- `--gfx-log <file>`**compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page.
Matrix-channel outcomes also include sampled `scale`/`trans` values. Parent directories are created automatically.
## Asset resolution / graphics

View File

@@ -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<Operand>());
[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<string>());
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<string>());
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
}
}

View File

@@ -2,13 +2,13 @@ using System.Linq;
namespace Age.Engine.Model;
/// <summary>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 <see cref="GfxState.AnimClockDurationTicks"/>. Generation bumps
/// on each anim-start — the compositor re-triggers its wall-clock tween when it changes.</summary>
public readonly record struct AnimState(bool Enabled, bool Normalized, long TX, long TY, long TZ,
long DurationTicks, long Generation);
/// <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>
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);
/// <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
@@ -20,7 +20,8 @@ public readonly record struct AnimState(bool Enabled, bool Normalized, long TX,
/// vanish — the grey-background bug).</summary>
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);
/// <summary>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<long, long> _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
}
}
/// <summary>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.</summary>
public void SetAnimTransform(long handle, long p1, long p2, (long X, long Y, long Z) target, bool normalized)
/// <summary>Op 0x21e: normalized scale target (100 = identity), with independent delay/duration.</summary>
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;
}
}
/// <summary>Op 0x234 (anim-start): animate the object toward <paramref name="target"/> over the global
/// clock; <paramref name="durationTicks"/> is this object's duration (native label_1235a maxes them into
/// the clock). Bumps AnimGeneration — the compositor's per-object re-trigger.</summary>
public void StartAnim(long handle, long durationTicks, (long X, long Y, long Z) target)
/// <summary>Op 0x220: absolute translation target, with independent delay/duration.</summary>
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;
}
}
/// <summary>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).</summary>
/// <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)
{
lock (_lock)
{
var o = GetOrCreate(handle);
o.RotationPeriodMs = periodMs; o.RotationAxis = axis; o.RotationEnabled = periodMs > 0;
}
}
/// <summary>Op 0x238: set its separate global animation-service duration and reset marker.</summary>
public void SetAnimClock(long durationTicks)
{
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
@@ -227,8 +239,8 @@ public sealed class GfxState
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
/// <summary>Visible objects in ascending-handle order (= z-order), each with its source surface resolved
/// and its active anim channels interpolated at <paramref name="nowMs"/> (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 <paramref name="nowMs"/>. 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.</summary>
public IReadOnlyList<RenderObject> 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);
}
/// <summary>Ping-pong interpolation weight in [0,1] toward the target: 0 at cycle start, 1 at half-period.</summary>
private static double PingPongWeight(long now, long start, long period)
{

View File

@@ -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:

View File

@@ -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<long, TweenState> _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<long, string>? 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<long, string> 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<string>();
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

View File

@@ -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