Fix AE001H spritesheet animation

This commit is contained in:
gamer147
2026-07-11 20:37:26 -04:00
parent 593a24b510
commit 0bf6b43a32
9 changed files with 93 additions and 51 deletions

View File

@@ -840,8 +840,8 @@ annotated in Ghidra, saved.
|---|---|---|
| `0x22f` | `gfx_op_0x22f_set_position_anim` → `gfx_worker_set_translation` | set object **position** (translation vec `obj+0x5d4`); base transform, not a ping-pong channel |
| `0x229` | `gfx_op_0x229_set_position` (`FUN_00472bb0`+`FUN_00472be0`) | set object **position/geometry** immediately (`obj+0x420/0x424` + vec `obj+0x440..0x448`) |
| `0x239` | `gfx_op_0x239_set_srcrect_cell` → `gfx_worker_set_srcrect_cell` | set **spritesheet** grid `obj+0x238/0x23c` + static cell `obj+0x234` |
| `0x231` | `gfx_op_0x231_anim_srcrect` → `gfx_worker_anim_srcrect` | **animate spritesheet cell**: bit2 active, period `obj+0x230`, grid `obj+0x238/0x23c` → interpolator SRC-RECT SCROLL channel (ping-pong across the row) |
| `0x239` | `gfx_op_0x239_set_srcrect_cell` → `gfx_worker_set_srcrect_cell` | one-shot **spritesheet-cell** channel: delay/duration `obj+0x48/+0x5c`, total frames/columns `obj+0x238/+0x23c`, target frame `obj+0x234` |
| `0x231` | `gfx_op_0x231_anim_srcrect` → `gfx_worker_anim_srcrect` | looping **spritesheet-cell** channel: milliseconds per frame `obj+0x230`, total frames `obj+0x238`, columns `obj+0x23c`; row-major and wraps, not ping-pong |
| `0x232` | `gfx_op_0x232_anim_color` → `gfx_worker_anim_color` | **animate color** (pulsing GLOW): bit2 active, period `obj+0x220`, target `obj+0x240` → interpolator COLOR channel (ping-pong). Distinct from static `0x202`/`0x203` (`obj+0x60/0x64`) |
| `0x228` | `gfx_op_0x228_query_position` (`FUN_0047cdd0`) | **query** current computed (x,y,z) → operand slots 3/4/5 (script logic, not render) |
| `0x23f` | `gfx_op_0x23f_query_object` (`FUN_0042a520`) | **query** an object status/value → operand slot 1 |
@@ -879,6 +879,13 @@ while `0x21c` is parked completes only the active foreground surface transition
not pre-arm the following `wait-for-input`, and it does not complete independent retained rotation,
matrix, spritesheet, or color-animation channels.
**Source-rectangle correction (2026-07-11):** `gfx_object_anim_interpolate` preserves the source rectangle
set by `draw-texture`; `0x231` does not divide that rectangle by its operands. It computes
`frame = floor(elapsed / frame_period) % frame_count`, then offsets both X bounds by
`rect_width * (frame % columns)` and both Y bounds by `rect_height * (frame / columns)`. SC0000 passes
`(100, 8, 4)` for AE001H: eight 200x200 cells in an 800x400, 4x2 sheet. Treating 8 and 4 as grid width
and height shrank the crop to 25x50 and made the intended cave spirit effectively disappear.
**Resolved 2026-07-11:** `0x236` is the movie-to-retained-surface path described below. The unclassified
`0x242/0x23d/0x20a/0x20e` tail (2-arg flags / inline) remains GAP and outside this slice.

View File

@@ -271,8 +271,9 @@ Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is
- **grounding:** source=kelebek, confidence=low
### 0x231 `u00421EA0` (u00421EA0, argc 4)
- **summary:** 0x231 anim-srcrect (handle)(period)(gridW)(gridH): ping-pong the spritesheet cell across the grid over period ms. Worker gfx_worker_anim_srcrect @0x47eec0 -> interpolator SRC-RECT SCROLL channel. C# VM: GfxState.SetSrcRect. See docs/engine-re.md §SC0000 anim cluster.
- **grounding:** source=kelebek, confidence=low
- **summary:** (handle)(frame_period_ms)(frame_count)(column_count) — loop row-major through the spritesheet. Every frame preserves draw-texture's source-rectangle width/height; frame=floor(elapsed/frame_period)%frame_count, src offset=(frame%columns*width, frame/columns*height). Worker gfx_worker_anim_srcrect @0x47eec0; consumer gfx_object_anim_interpolate @0x473ed0.
- **grounding:** source=investigation, confidence=high
- **evidence:** Native /v2 decompile: worker stores period at obj+0x230, frame_count at +0x238, columns at +0x23c. Interpolator computes ((now-start)/period)%frame_count, then offsets both source-rect X bounds by rect_width*(frame%columns) and Y bounds by rect_height*(frame/columns). SC0000 uses (100,8,4) with AE001H's eight 200x200 cells in a 4x2 800x400 sheet.
### 0x232 `u00421EF0` (u00421EF0, argc 4)
- **summary:** 0x232 anim-color (handle)(period)(alpha)(color): ping-pong the object color/alpha toward the packed target over period ms (pulsing GLOW). Worker gfx_worker_anim_color @0x47ef50 -> interpolator COLOR channel. C# VM: GfxState.SetColorAnim. See docs/engine-re.md §SC0000 anim cluster.
@@ -295,8 +296,9 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b
- **grounding:** source=investigation, confidence=high
### 0x239 `u004223C0` (u004223C0, argc 6)
- **summary:** 0x239 set-srcrect-cell (handle)(p3)(p4)(gridW)(gridH)(cell): set the spritesheet grid + static visible cell. Worker gfx_worker_set_srcrect_cell @0x47ed90. C# VM: GfxState.SetSrcRect (period 0). See docs/engine-re.md §SC0000 anim cluster.
- **grounding:** source=kelebek, confidence=low
- **summary:** (handle)(delay_ms)(duration_ms)(frame_count)(column_count)(target_frame) — one-shot row-major source-rectangle cell channel. Worker gfx_worker_set_srcrect_cell @0x47ed90 stores timing at obj+0x48/+0x5c, layout at +0x238/+0x23c, and target at +0x234. C# currently retains the endpoint cell immediately.
- **grounding:** source=investigation, confidence=high
- **evidence:** Native /v2 worker and gfx_object_apply_transform_channels decompile. The consumer advances target_frame cells over duration after delay, preserves the existing source-rect dimensions, and commits the endpoint.
### 0x23f `u00422930` (u00422930, argc 2)
- **summary:** 0x23f query-object (out)(handle): return object status (FUN_0042a520; -1 if none). C# VM: 0 if the object exists else -1. See docs/engine-re.md §SC0000 anim cluster.

View File

@@ -646,7 +646,7 @@ plan `.../plans/2026-07-08-sc0000-anim-transform-cluster.md`, branch `feat/anim-
**Built (hybrid: engine resolves, host blits):**
- `GfxObject` gains src-rect (spritesheet) + animated-color channels; `GfxState.SetSrcRect` / `SetColorAnim`;
`BlendMath.PingPong`; `SnapshotVisibleObjects(long nowMs)` = a port of `gfx_object_anim_interpolate`
(ping-pong the spritesheet cell + color/glow), driven by the **`FrameClock`** (the "mach 5" pacing fix).
(looping row-major spritesheet cells + ping-pong color/glow), driven by the **`FrameClock`** (the "mach 5" pacing fix).
- Wired 7 ops: `0x22f`/`0x229` position (direct V24 set), `0x239`/`0x231` spritesheet (static cell / animate),
`0x232` color glow (ping-pong), `0x228`/`0x23f` queries (geometry back to script vars).
@@ -1380,5 +1380,16 @@ distinct from `0x21e`'s one-shot target. SC0000 scales the two AE001D circles to
them at 100%, almost entirely below the viewport. `GfxState.SetCurrentScale` and VM dispatch now reproduce
the native matrices, with focused geometry/dispatch tests. Validation: engine **145/145**, opcode build/lint,
Godot build/capture, Ghidra annotations saved, and user confirmation that the CG and animated glow are both
visible. This AE001D path is a retained scaled/rotating texture; later missing `0x231` spritesheet-cell
effects remain separately scoped.
visible. This AE001D path is a retained scaled/rotating texture; the later `0x231` spritesheet-cell issue
was separately resolved by preserving draw-texture's 200x200 cell and interpreting `(8,4)` as total
frames/columns rather than a grid divisor.
### A2b -- SC0000 post-movie AE001H spritesheet correction ✅ DONE (2026-07-11)
The reported missing cave spirit was the `0x231` path, independent of the earlier AE001D retained glow.
Native interpolation proved `(period, 8, 4)` means milliseconds per frame, total frames, and columns;
the existing 200x200 draw-texture rectangle is one cell and must not be divided again. The retained model
now cycles the eight AE001H cells row-major and wraps. Focused tests cover the exact 800x400/4x2 layout.
Validation: engine **146/146**, opcode sources regenerated, Godot zero-warning build and threaded selftest,
real-scene compositor trace across all eight cells, Ghidra `/v2` annotations saved, and user confirmation
on the normal client path. The automated-shot wrong-CG state remains the pre-existing separate gotcha.

View File

@@ -62,7 +62,8 @@ public class AnimChannelTests
vm.Run();
var o = vm.Gfx.TryGet(0x1000)!;
Assert.True(o.SrcAnim);
Assert.Equal(4, o.SrcGridW);
Assert.Equal(4, o.SrcFrameCount);
Assert.Equal(1, o.SrcColumns);
Assert.Equal(2, o.SrcCell);
}
@@ -78,9 +79,9 @@ public class AnimChannelTests
public void SetSrcRect_StoresGridCellPeriod()
{
var g = VisibleObj(0x100);
g.SetSrcRect(0x100, gridW: 4, gridH: 1, cell: 2, period: 800);
g.SetSrcRect(0x100, frameCount: 4, columns: 1, cell: 2, period: 800);
var o = g.TryGet(0x100)!;
Assert.Equal(4, o.SrcGridW);
Assert.Equal(4, o.SrcFrameCount);
Assert.Equal(2, o.SrcCell);
Assert.Equal(800, o.SrcPeriod);
Assert.True(o.SrcAnim);
@@ -91,10 +92,10 @@ public class AnimChannelTests
public void SetSrcRect_ClampsGridToAtLeastOne()
{
var g = VisibleObj(0x100);
g.SetSrcRect(0x100, gridW: 0, gridH: 0, cell: 0, period: 0);
g.SetSrcRect(0x100, frameCount: 0, columns: 0, cell: 0, period: 0);
var o = g.TryGet(0x100)!;
Assert.Equal(1, o.SrcGridW);
Assert.Equal(1, o.SrcGridH);
Assert.Equal(1, o.SrcFrameCount);
Assert.Equal(1, o.SrcColumns);
}
[Fact]

View File

@@ -8,7 +8,7 @@ public class AnimInterpolatorTests
{
var g = new GfxState();
g.SetSurface(1, resId: 5, colorKey: -1);
g.BindDraw(handle, 1, 0, 0, 256, 64, 100, 100); // 256x64 sheet, base pos (100,100)
g.BindDraw(handle, 1, 0, 0, 64, 64, 100, 100); // one 64x64 cell, base pos (100,100)
return g;
}
@@ -24,22 +24,24 @@ public class AnimInterpolatorTests
public void SrcRect_StaticCell_SelectsSubRect()
{
var g = VisibleObj(0x100);
g.SetSrcRect(0x100, gridW: 4, gridH: 1, cell: 2, period: 0); // static cell 2 of 4 across 256px
g.SetSrcRect(0x100, frameCount: 4, columns: 4, cell: 2, period: 0);
var ro = g.SnapshotVisibleObjects(0).Single();
Assert.Equal(128, ro.SrcX); // cell 2 * 64
Assert.Equal(64, ro.W);
}
[Fact]
public void SrcRect_Animated_PingPongsCellAcrossGrid()
public void SrcRect_Animated_AdvancesOneCellPerPeriodAndWraps()
{
var g = VisibleObj(0x100);
g.SetSrcRect(0x100, gridW: 4, gridH: 1, cell: 0, period: 1000);
g.SetSrcRect(0x100, frameCount: 4, columns: 4, cell: 0, period: 100);
g.SnapshotVisibleObjects(0); // seeds start=0
var half = g.SnapshotVisibleObjects(500).Single();
Assert.Equal(192, half.SrcX); // t=1 -> last cell (3) * 64
var back = g.SnapshotVisibleObjects(1000).Single();
Assert.Equal(0, back.SrcX); // ping-ponged back to cell 0
var second = g.SnapshotVisibleObjects(100).Single();
Assert.Equal(64, second.SrcX);
var fourth = g.SnapshotVisibleObjects(300).Single();
Assert.Equal(192, fourth.SrcX);
var wrapped = g.SnapshotVisibleObjects(400).Single();
Assert.Equal(0, wrapped.SrcX);
}
[Fact]

View File

@@ -82,6 +82,27 @@ public class GfxAnimationTests
Assert.True(rotation.HasActiveVisualPresentation(1000));
}
[Fact]
public void LoopingSpritesheet_PreservesCellSize_AdvancesRowMajor_AndWraps()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(0xcf3a, 4, 0, 0, 200, 200, 300, 100); // AE001H: 800x400, eight 200x200 cells
g.SetSrcRect(0xcf3a, frameCount: 8, columns: 4, cell: 0, period: 100);
var first = g.SnapshotVisibleObjects(1000).Single();
var second = g.SnapshotVisibleObjects(1100).Single();
var fifth = g.SnapshotVisibleObjects(1400).Single();
var eighth = g.SnapshotVisibleObjects(1700).Single();
var wrapped = g.SnapshotVisibleObjects(1800).Single();
Assert.Equal((0, 0, 200, 200), (first.SrcX, first.SrcY, first.W, first.H));
Assert.Equal((200, 0, 200, 200), (second.SrcX, second.SrcY, second.W, second.H));
Assert.Equal((0, 200, 200, 200), (fifth.SrcX, fifth.SrcY, fifth.W, fifth.H));
Assert.Equal((600, 200, 200, 200), (eighth.SrcX, eighth.SrcY, eighth.W, eighth.H));
Assert.Equal((0, 0, 200, 200), (wrapped.SrcX, wrapped.SrcY, wrapped.W, wrapped.H));
}
[Fact]
public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample()
{

View File

@@ -71,9 +71,10 @@ public sealed class GfxState
// fades from a static mode-0 0x203 such as a CG initialized with 0x00ffffff (opaque identity).
public bool OneShotColorBlend;
// ---- src-rect / spritesheet-cell channel (ops 0x239 static cell, 0x231 animate). Interpolator
// SRC-RECT SCROLL channel: period obj+0x230, start obj+0x21c, grid obj+0x238/0x23c. ----
public long SrcGridW = 1, SrcGridH = 1, SrcCell, SrcPeriod, SrcStart = -1;
// ---- src-rect / spritesheet-cell channel (ops 0x239 one-shot cell, 0x231 looping animation).
// Native obj+0x238 is the TOTAL FRAME COUNT and +0x23c is the COLUMN COUNT. Each frame keeps
// draw-texture's original source-rect size; those operands are not a grid to divide it by. ----
public long SrcFrameCount = 1, SrcColumns = 1, SrcCell, SrcPeriod, SrcStart = -1;
public bool SrcAnim;
// ---- animated color/glow channel (op 0x232). Interpolator COLOR channel: period obj+0x220,
// start obj+0x20c, target obj+0x240 — PING-PONG (distinct from static 0x202/0x203). ----
@@ -158,7 +159,7 @@ public sealed class GfxState
OneShotColorTarget = s.OneShotColorTarget, ColorDelayMs = s.ColorDelayMs,
ColorDurationMs = s.ColorDurationMs, OneShotColorEnabled = s.OneShotColorEnabled,
OneShotColorBlend = s.OneShotColorBlend,
SrcGridW = s.SrcGridW, SrcGridH = s.SrcGridH, SrcCell = s.SrcCell,
SrcFrameCount = s.SrcFrameCount, SrcColumns = s.SrcColumns, SrcCell = s.SrcCell,
SrcPeriod = s.SrcPeriod, SrcStart = s.SrcStart, SrcAnim = s.SrcAnim,
ColorPeriod = s.ColorPeriod, ColorStart = s.ColorStart, ColorTarget = s.ColorTarget,
ColorAnim = s.ColorAnim, SourceSlot = s.SourceSlot, SrcRect = s.SrcRect, Visible = s.Visible,
@@ -282,14 +283,16 @@ public sealed class GfxState
lock (_lock) GetOrCreate(handle).StaticColorMode = mode;
}
/// <summary>Ops 0x239 (static cell, period=0) / 0x231 (animate, period&gt;0): set the spritesheet grid +
/// the visible cell. When animated, the interpolator ping-pongs the cell across the grid over the period.</summary>
public void SetSrcRect(long handle, long gridW, long gridH, long cell, long period)
/// <summary>Ops 0x239 (one-shot cell endpoint, period=0 in this retained model) / 0x231 (looping):
/// set total frame count, column count, and visible cell. Every frame retains draw-texture's source
/// rectangle dimensions. For 0x231, <paramref name="period"/> is milliseconds per frame.</summary>
public void SetSrcRect(long handle, long frameCount, long columns, long cell, long period)
{
lock (_lock)
{
var o = GetOrCreate(handle);
o.SrcGridW = gridW < 1 ? 1 : gridW; o.SrcGridH = gridH < 1 ? 1 : gridH;
o.SrcFrameCount = frameCount < 1 ? 1 : frameCount;
o.SrcColumns = columns < 1 ? 1 : columns;
o.SrcCell = cell; o.SrcPeriod = period; o.SrcStart = -1; o.SrcAnim = true;
}
}
@@ -555,23 +558,18 @@ public sealed class GfxState
blend = BlendKind.Alpha;
}
// ---- src-rect: pick the spritesheet cell (static or ping-ponged across the grid) ----
// ---- src-rect: preserve cell dimensions and offset it row-major through the sheet ----
int srcX = o.SrcRect.X, srcY = o.SrcRect.Y, w = o.SrcRect.W, h = o.SrcRect.H;
if (o.SrcAnim && o.SrcGridW >= 1)
if (o.SrcAnim && o.SrcFrameCount >= 1)
{
int cellW = (int)(o.SrcRect.W / o.SrcGridW);
int cellH = o.SrcGridH >= 1 ? (int)(o.SrcRect.H / o.SrcGridH) : o.SrcRect.H;
long cell = o.SrcCell;
if (o.SrcPeriod > 0)
{
if (o.SrcStart < 0) o.SrcStart = nowMs;
double t = PingPongWeight(nowMs, o.SrcStart, o.SrcPeriod);
cell = (long)System.Math.Round(t * (o.SrcGridW - 1));
cell = ((nowMs - o.SrcStart) / o.SrcPeriod) % o.SrcFrameCount;
}
long cols = o.SrcGridW;
srcX = o.SrcRect.X + (int)(cell % cols) * cellW;
srcY = o.SrcRect.Y + (int)(cell / cols) * cellH;
w = cellW; h = cellH;
srcX = o.SrcRect.X + (int)(cell % o.SrcColumns) * o.SrcRect.W;
srcY = o.SrcRect.Y + (int)(cell / o.SrcColumns) * o.SrcRect.H;
}
// One-shot matrix channels: hold current through delay, then linearly sample current -> target.

View File

@@ -387,9 +387,9 @@ public sealed class VirtualMachine
case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set)
case "u004219E0": // 0x229 set-position2: same shape, direct position
Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[2]), Read(a[3]), Read(a[4])); return pc + 1;
case "u004223C0": // 0x239 spritesheet cell: (handle)(p3)(p4)(gridW)(gridH)(cell) — static cell
case "u004223C0": // 0x239 spritesheet cell: (handle)(delay)(duration)(frame count)(columns)(cell)
Gfx.SetSrcRect(Read(a[0]), Read(a[3]), Read(a[4]), Read(a[5]), 0); return pc + 1;
case "u00421EA0": // 0x231 anim spritesheet: (handle)(period)(gridW)(gridH) — ping-pong the cell
case "u00421EA0": // 0x231 looping spritesheet: (handle)(ms per frame)(frame count)(columns)
Gfx.SetSrcRect(Read(a[0]), Read(a[2]), Read(a[3]), 0, Read(a[1])); return pc + 1;
case "u00421EF0": // 0x232 anim color/glow: (handle)(period)(alpha)(color) — ping-pong the color
Gfx.SetColorAnim(Read(a[0]), Read(a[1]), GfxState.PackColor(Read(a[2]), Read(a[3]))); return pc + 1;

View File

@@ -5999,12 +5999,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00421EA0"
category = "draw"
summary = "0x231 anim-srcrect (handle)(period)(gridW)(gridH): ping-pong the spritesheet cell across the grid over period ms. Worker gfx_worker_anim_srcrect @0x47eec0 -> interpolator SRC-RECT SCROLL channel. C# VM: GfxState.SetSrcRect. See docs/engine-re.md §SC0000 anim cluster."
summary = "(handle)(frame_period_ms)(frame_count)(column_count) — loop row-major through the spritesheet. Every frame preserves draw-texture's source-rectangle width/height; frame=floor(elapsed/frame_period)%frame_count, src offset=(frame%columns*width, frame/columns*height). Worker gfx_worker_anim_srcrect @0x47eec0; consumer gfx_object_anim_interpolate @0x473ed0."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
evidence = "Native /v2 decompile: worker stores period at obj+0x230, frame_count at +0x238, columns at +0x23c. Interpolator computes ((now-start)/period)%frame_count, then offsets both source-rect X bounds by rect_width*(frame%columns) and Y bounds by rect_height*(frame/columns). SC0000 uses (100,8,4) with AE001H's eight 200x200 cells in a 4x2 800x400 sheet."
[[opcode.semantics.args]]
i = 1
@@ -6211,12 +6211,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u004223C0"
category = "draw"
summary = "0x239 set-srcrect-cell (handle)(p3)(p4)(gridW)(gridH)(cell): set the spritesheet grid + static visible cell. Worker gfx_worker_set_srcrect_cell @0x47ed90. C# VM: GfxState.SetSrcRect (period 0). See docs/engine-re.md §SC0000 anim cluster."
summary = "(handle)(delay_ms)(duration_ms)(frame_count)(column_count)(target_frame) — one-shot row-major source-rectangle cell channel. Worker gfx_worker_set_srcrect_cell @0x47ed90 stores timing at obj+0x48/+0x5c, layout at +0x238/+0x23c, and target at +0x234. C# currently retains the endpoint cell immediately."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
evidence = "Native /v2 worker and gfx_object_apply_transform_channels decompile. The consumer advances target_frame cells over duration after delay, preserves the existing source-rect dimensions, and commits the endpoint."
[[opcode.semantics.args]]
i = 1