diff --git a/docs/engine-re.md b/docs/engine-re.md index c76b54b..ee766e1 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -843,7 +843,7 @@ annotated in Ghidra, saved. | `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) | +| `0x228` | `gfx_op_0x228_query_position` → `gfx_object_query_translation_target` (`0x47cdd0`) | **query** the decomposed target-translation matrix (x,y,z), `obj+0x1ac/+0x1b0/+0x1b4`, → operand slots 3/4/5; success is 0 and missing is 1 | | `0x23f` | `gfx_op_0x23f_query_object` (`FUN_0042a520`) | **query** an object status/value → operand slot 1 | **Follow-up resolution (2026-07-10):** `0x21f` is the one-shot axis-angle channel and is implemented with @@ -886,6 +886,19 @@ set by `draw-texture`; `0x231` does not divide that rectangle by its operands. I `(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. +**AE001H travel-path correction (2026-07-11):** the three post-movie movement legs exposed a separate +port bug from the source-rectangle correction. Native `0x228` copies the full retained-object record and +decomposes the target matrix beginning at `obj+0x17c`; the returned translation comes from +`obj+0x1ac/+0x1b0/+0x1b4`. It does **not** return the draw/base position at `obj+0x24` (`V24`). SC0000 uses +that query before each `0x220` to derive the next translation target. AE001H's draw base is `(360,20)` and +its initial translation target is `(0,0)`, so the first native leg targets `(40,-20)`. The current C# +`0x228` implementation instead returns `(360,20)`, producing `(400,0)`; the compositor then adds that +translation to the unchanged draw base, placing the object near `(760,20)` before its cyclic rotation. +That explained the observed immediate off-screen movement and why later legs never visibly returned. The +port now queries `TranslationTarget` independently of `V24`, returns the native status convention, and +leaves output operands untouched when the object is absent. A bytecode-level regression reproduces the +three SC0000 targets `(40,-20)`, `(50,-80)`, and `(130,-100)` while retaining base `V24=(360,20)`. + **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. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index b85c71a..8aa993f 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -269,8 +269,9 @@ Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is - **evidence:** Ghidra handler 0x417550 -> gfx_command_queue_clear 0x47cb10, which destroys queued nodes and restores the sentinel links/count. ### 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. -- **grounding:** source=kelebek, confidence=low +- **summary:** 0x228 query translation target (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its target translation matrix at obj+0x17c, and return matrix translation obj+0x1ac/+0x1b0/+0x1b4. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. The C# VM queries TranslationTarget independently of V24. See docs/engine-re.md §SC0000 anim cluster. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2 handler gfx_op_0x228_query_position@0x42a3a0 calls gfx_object_query_translation_target@0x47cdd0. The worker copies the complete 0xb5-dword object record, passes copied obj+0x17c to matrix4_decompose_affine@0x48d7c8, and returns its translation outputs; the decomposition reads matrix elements +0x30/+0x34/+0x38, corresponding to obj+0x1ac/+0x1b0/+0x1b4. SC0000 AE001H queries this before each 0x220 leg. C# regression covers targets (40,-20), (50,-80), (130,-100), plus the missing-object output-preservation path. ### 0x229 `u004219E0` (u004219E0, argc 5) - **summary:** 0x229 set-position2 (handle)(op2)(x)(y)(z): set object position/geometry directly (FUN_00472bb0/be0). C# VM: sets V24. See docs/engine-re.md §SC0000 anim cluster. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index a44ed1a..76c9944 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1405,3 +1405,17 @@ now cycles the eight AE001H cells row-major and wraps. Focused tests cover the e 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. + +### A2b -- SC0000 post-movie AE001H travel-path correction ✅ DONE (2026-07-11) + +The remaining off-screen motion is a separate `0x228` query-model bug, not part of the spritesheet crop. +Native `0x228` decomposes and returns the retained object's target translation (`obj+0x1ac..+0x1b4`), while +the C# VM currently returns its static draw/base position `V24`. For AE001H this changes the first `0x220` +target from `(40,-20)` to `(400,0)`; because the compositor also retains the `(360,20)` draw base, the +sprite was displaced to approximately x=760 before cyclic rotation. `GfxState.TryQueryTranslationTarget` +now exposes the retained target independently of `V24`; VM dispatch matches the native 0/1 status convention +and preserves output operands on a miss. Bytecode-level tests cover the exact three targets `(40,-20)`, +`(50,-80)`, `(130,-100)` plus missing-object behavior. Validation: engine **149/149**, zero-warning Godot +build, and threaded `SELFTEST OK`. Current static SC0000 coverage is **94/129 distinct ops handled +(72.9%)**: 82 implemented + 12 safe no-ops, leaving 35 GAP ops / 88 GAP instructions. By instruction +frequency, 16,169/16,257 are handled (**99.5%**); this gauge does not replace manual end-to-end fidelity. diff --git a/engine/Age.Engine.Tests/AnimChannelTests.cs b/engine/Age.Engine.Tests/AnimChannelTests.cs index baf4fa2..b6c30dc 100644 --- a/engine/Age.Engine.Tests/AnimChannelTests.cs +++ b/engine/Age.Engine.Tests/AnimChannelTests.cs @@ -8,26 +8,83 @@ public class AnimChannelTests { private static OpcodeTable T() => OpcodeTableJson.Load(Paths.OpcodesJson); private static Operand G(int addr) => new(3, addr); + private static Operand I(long value) => new(0, value); private static (int, Operand[]) Exit() => (0x2, System.Array.Empty()); [Fact] - public void Op0x22f_SetsPosition_Op0x228_QueriesItBack() + public void Op0x228_QueriesTranslationTarget_NotBasePosition() { var t = T(); - // g1=handle, g3=x=50, g4=y=60, g5=z=0; op 0x22f sets V24; op 0x228 reads it into g11/g12/g13. + // AE001H has draw/base V24=(360,20), independent of its op-0x220 translation target (40,-20). var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> { - (0x55, new[]{G(1), new Operand(0,0x1000)}), (0x55, new[]{G(2), new Operand(0,0)}), - (0x55, new[]{G(3), new Operand(0,50)}), (0x55, new[]{G(4), new Operand(0,60)}), (0x55, new[]{G(5), new Operand(0,0)}), - (0x22f, new[]{G(1), G(2), G(3), G(4), G(5)}), // set position - (0x228, new[]{G(10), G(1), G(11), G(12), G(13)}), // query position -> g11,g12,g13 + (0x55, new[]{G(1), I(0xcf3a)}), (0x55, new[]{G(2), I(0)}), + (0x55, new[]{G(3), I(360)}), (0x55, new[]{G(4), I(20)}), (0x55, new[]{G(5), I(0)}), + (0x22f, new[]{G(1), G(2), G(3), G(4), G(5)}), + (0x55, new[]{G(6), I(300)}), (0x55, new[]{G(7), I(40)}), + (0x55, new[]{G(8), I(20)}), (0x51, new[]{G(8), I(0), G(8)}), + (0x220, new[]{G(1), G(2), G(6), G(7), G(8), G(5)}), + (0x228, new[]{G(10), G(1), G(11), G(12), G(13)}), Exit(), }, System.Array.Empty()); var vm = new VirtualMachine(scene, t, new RecordingHost()); vm.Run(); - Assert.Equal(50, vm.Globals[11]); - Assert.Equal(60, vm.Globals[12]); + Assert.Equal(40, vm.Globals[11]); + Assert.Equal(-20, vm.Globals[12]); + Assert.Equal(0, vm.Globals[13]); Assert.Equal(0, vm.Globals[10]); // success flag + Assert.Equal((360L, 20L, 0L), vm.Gfx.TryGet(0xcf3a)!.V24); + } + + [Fact] + public void Op0x228_MissingObject_SetsFailureAndPreservesOutputs() + { + var t = T(); + var scene = ScriptAssembler.Assemble(t, "GFX_MISSING", new List<(int, Operand[])> + { + (0x55, new[]{G(1), I(0xdead)}), (0x55, new[]{G(11), I(7)}), + (0x55, new[]{G(12), I(8)}), (0x55, new[]{G(13), I(9)}), + (0x228, new[]{G(10), G(1), G(11), G(12), G(13)}), Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + Assert.Equal(1, vm.Globals[10]); + Assert.Equal((7L, 8L, 9L), (vm.Globals[11], vm.Globals[12], vm.Globals[13])); + } + + [Fact] + public void Ae001hThreeLegSequence_BuildsEachTargetFromPreviousTranslationTarget() + { + var t = T(); + var ops = new List<(int, Operand[])> + { + (0x55, new[]{G(1), I(0xcf3a)}), (0x55, new[]{G(2), I(0)}), (0x55, new[]{G(3), I(300)}), + (0x55, new[]{G(4), I(360)}), (0x55, new[]{G(5), I(20)}), (0x55, new[]{G(6), I(0)}), + (0x55, new[]{G(40), I(20)}), (0x51, new[]{G(40), I(0), G(40)}), + (0x55, new[]{G(41), I(60)}), (0x51, new[]{G(41), I(0), G(41)}), + (0x22f, new[]{G(1), G(2), G(4), G(5), G(6)}), + + (0x228, new[]{G(10), G(1), G(11), G(12), G(13)}), + (0x50, new[]{G(11), G(11), I(40)}), (0x50, new[]{G(12), G(12), G(40)}), + (0x220, new[]{G(1), G(2), G(3), G(11), G(12), G(13)}), + + (0x228, new[]{G(20), G(1), G(21), G(22), G(23)}), + (0x50, new[]{G(21), G(21), I(10)}), (0x50, new[]{G(22), G(22), G(41)}), + (0x220, new[]{G(1), G(2), G(3), G(21), G(22), G(23)}), + + (0x228, new[]{G(30), G(1), G(31), G(32), G(33)}), + (0x50, new[]{G(31), G(31), I(80)}), (0x50, new[]{G(32), G(32), G(40)}), + (0x220, new[]{G(1), G(2), G(3), G(31), G(32), G(33)}), + Exit(), + }; + var vm = new VirtualMachine(ScriptAssembler.Assemble(t, "AE001H_TRAVEL", ops, + System.Array.Empty()), t, new RecordingHost()); + vm.Run(); + + Assert.Equal((40L, -20L, 0L), (vm.Globals[11], vm.Globals[12], vm.Globals[13])); + Assert.Equal((50L, -80L, 0L), (vm.Globals[21], vm.Globals[22], vm.Globals[23])); + Assert.Equal((130L, -100L, 0L), (vm.Globals[31], vm.Globals[32], vm.Globals[33])); + Assert.Equal((130.0, -100.0, 0.0), vm.Gfx.TryGet(0xcf3a)!.TranslationTarget); } [Fact] diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 808c3eb..aac1f94 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -448,6 +448,22 @@ public sealed class GfxState } } + /// Op 0x228: query the translation target decomposed from the native target matrix at + /// obj+0x17c (translation obj+0x1ac/+0x1b0/+0x1b4). This is independent of draw/base position V24. + public bool TryQueryTranslationTarget(long handle, out (double X, double Y, double Z) target) + { + lock (_lock) + { + if (_objects.TryGetValue(handle, out var o)) + { + target = o.TranslationTarget; + return true; + } + target = default; + return false; + } + } + /// Op 0x21f: delayed one-shot axis-angle rotation target, sharing obj+0x34's start timestamp. public void SetRotationChannel(long handle, long delayMs, long durationMs, (long X, long Y, long Z) axis, long angleDegrees) diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 3764d7f..3c13d5d 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -399,12 +399,15 @@ public sealed class VirtualMachine 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; - case "u00421940": // 0x228 query-position: (succ)(handle)(outX)(outY)(outZ) <- current V24 + case "u00421940": // 0x228: (succ)(handle)(outX)(outY)(outZ) <- target translation matrix { - var obj = Gfx.TryGet(Read(a[1])); - var v = obj?.V24 ?? default; - Write(a[2], v.X); Write(a[3], v.Y); Write(a[4], v.Z); - Write(a[0], obj != null ? 0 : 1); return pc + 1; + if (Gfx.TryQueryTranslationTarget(Read(a[1]), out var v)) + { + Write(a[2], (long)v.X); Write(a[3], (long)v.Y); Write(a[4], (long)v.Z); + Write(a[0], 0); + } + else Write(a[0], 1); // native missing-object path leaves output operands untouched + return pc + 1; } case "u00422930": // 0x23f query-object: (out)(handle) <- 0 if the object exists, else -1 Write(a[0], Gfx.TryGet(Read(a[1])) != null ? 0 : -1); return pc + 1; diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 4282d10..e8da3a0 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -5752,12 +5752,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "u00421940" category = "draw" -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." +summary = "0x228 query translation target (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its target translation matrix at obj+0x17c, and return matrix translation obj+0x1ac/+0x1b0/+0x1b4. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. The C# VM queries TranslationTarget independently of V24. See docs/engine-re.md §SC0000 anim cluster." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2 handler gfx_op_0x228_query_position@0x42a3a0 calls gfx_object_query_translation_target@0x47cdd0. The worker copies the complete 0xb5-dword object record, passes copied obj+0x17c to matrix4_decompose_affine@0x48d7c8, and returns its translation outputs; the decomposition reads matrix elements +0x30/+0x34/+0x38, corresponding to obj+0x1ac/+0x1b0/+0x1b4. SC0000 AE001H queries this before each 0x220 leg. C# regression covers targets (40,-20), (50,-80), (130,-100), plus the missing-object output-preservation path." [[opcode.semantics.args]] i = 1