diff --git a/docs/engine-re.md b/docs/engine-re.md index 7509069..92e7c4a 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -764,6 +764,19 @@ retained `(0,550)` translation, BG004D lands at `(0,50)`. A synchronized run at background and character layers together. This path also established that op `0x1ff` directly replaces the current translation matrix at `obj+0x16c`; it is now modeled rather than stored in an inert side vector. +**SYSTEM4 P102 / SC0000 `wait@0x85de` current-translation correction (2026-08-16).** The missing +`BG001A.AGF` was not an asset or compositor failure. The retained log showed the new background animate +from translation zero to `+1200`, then settle at `+1150`; with base Y `-500`, it remained below the +600-pixel viewport. Native `0x228` had been misidentified as a query of the delayed target matrix. +`gfx_object_query_translation_current@0x47cdd0` copies the complete object record to stack local +`-0x2e0` and passes `local_174`—exactly `+0x16c` within that copy—to +`matrix4_decompose_affine@0x48d7c8`. It therefore queries the **current** translation matrix, not the +target at `obj+0x1ac` and not base position V24. SC0000 makes the consequence explicit: `0x1ff@0x84e8` +sets current translation to zero, `0x228@0x84fa` reads that zero, and `0x220@0x8521` adds the intended +`+600`; the following leg reads the completed current `+600` and subtracts 50 to settle at `+550` +(screen Y 50). The C# VM now queries `TranslationCurrent`; its focused handle-reuse regression preserves a +previous `+600` target, resets current to zero, and verifies the next SC0000 leg targets exactly `+600`. + #### 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 diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 6994322..8434c4e 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -999,10 +999,12 @@ Implemented through IHost.PlayModalMovieToSurface. Its operand uses the same nat - **grounding:** source=investigation, confidence=high, noop_headless=True - **evidence:** Ghidra handler 0x417550 -> gfx_command_queue_clear 0x47cb10, which destroys queued nodes and restores the sentinel links/count. -### 0x228 `query-gfx-translation-target` (u00421940, argc 5) -- **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. +### 0x228 `query-gfx-translation-current` (u00421940, argc 5) +- **summary:** 0x228 query current translation (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its current translation matrix at obj+0x16c, and return its x/y/z translation. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. This is independent of both delayed target obj+0x1ac and base draw position 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. +- **evidence:** Ghidra /v2 handler gfx_op_0x228_query_translation_current@0x42a3a0 calls gfx_object_query_translation_current@0x47cdd0. The worker copies the complete 0xb5-dword object record to stack local -0x2e0, then passes local_174, exactly +0x16c within that copy, to matrix4_decompose_affine@0x48d7c8. Thus it decomposes the current matrix obj+0x16c, not target obj+0x1ac. SYSTEM4 P102 supplies the runtime proof: SC0000@0x84e8 resets BG001A current translation to zero, 0x228@0x84fa must return zero, and 0x220@0x8521 then targets +600. The port's target query returned the preceding CG endpoint +600, producing +1200 and leaving BG001A below the viewport. Missing-object output preservation remains valid. Functions renamed, commented, and /v2 saved 2026-08-16. + +Implemented by querying TranslationCurrent while preserving the missing-object output contract. The focused regression seeds a reused handle with current/target +600, rebinds it, applies SC0000's 0x1ff current-zero reset, verifies 0x228 returns zero, and confirms the following 0x220 target is +600 rather than +1200. An exact GPU SYSTEM4 P102 capture at wait SC0000@0x85de shows BG001A visible at its intended endpoint. ### 0x229 `set-gfx-range-transform` (set-gfx-range-transform, argc 5) - **summary:** (first_handle)(count)(anchor_x)(anchor_y)(anchor_z) — reset and select the retained-gfx range transform applied after each ordinary object matrix for handles in [first, first+count), then set its anchor/pivot. A zero count disables it. diff --git a/engine/Age.Engine.Tests/AnimChannelTests.cs b/engine/Age.Engine.Tests/AnimChannelTests.cs index c5e85f1..294a85e 100644 --- a/engine/Age.Engine.Tests/AnimChannelTests.cs +++ b/engine/Age.Engine.Tests/AnimChannelTests.cs @@ -12,15 +12,17 @@ public class AnimChannelTests private static (int, Operand[]) Exit() => (0x2, System.Array.Empty()); [Fact] - public void Op0x228_QueriesTranslationTarget_NotBasePosition() + public void Op0x228_QueriesCurrentTranslation_NotTargetOrBasePosition() { var t = T(); - // AE001H has draw/base V24=(360,20), independent of its op-0x220 translation target (40,-20). + // V24=(360,20), current translation=(12,34,5), and delayed target=(40,-20,0) are independent. var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> { (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)}), (0x1fb, new[]{G(1), I(0), I(0), I(0), I(1), I(1), G(3), G(4)}), + (0x55, new[]{G(14), I(12)}), (0x55, new[]{G(15), I(34)}), (0x55, new[]{G(16), I(5)}), + (0x1ff, new[]{G(1), G(14), G(15), G(16)}), (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)}), @@ -29,9 +31,9 @@ public class AnimChannelTests }, System.Array.Empty()); var vm = new VirtualMachine(scene, t, new RecordingHost()); vm.Run(); - Assert.Equal(40, vm.Globals[11]); - Assert.Equal(-20, vm.Globals[12]); - Assert.Equal(0, vm.Globals[13]); + Assert.Equal(12, vm.Globals[11]); + Assert.Equal(34, vm.Globals[12]); + Assert.Equal(5, vm.Globals[13]); Assert.Equal(0, vm.Globals[10]); // success flag Assert.Equal((360L, 20L, 0L), vm.Gfx.TryGet(0xcf3a)!.V24); } @@ -53,38 +55,28 @@ public class AnimChannelTests } [Fact] - public void Ae001hThreeLegSequence_BuildsEachTargetFromPreviousTranslationTarget() + public void Op0x228_ReusedHandleReadsResetCurrentInsteadOfPreviousTarget() { var t = T(); - var ops = new List<(int, Operand[])> + var scene = ScriptAssembler.Assemble(t, "REUSED_TRANSLATION", 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)}), - (0x1fb, new[]{G(1), I(0), I(0), I(0), I(1), I(1), G(4), G(5)}), - + (0x55, new[]{G(1), I(0xcb20)}), (0x55, new[]{G(2), I(0)}), + // SC0000 rebinds the handle, resets only current translation, then queries current. + (0x1fb, new[]{G(1), I(4), I(0), I(0), I(800), I(500), I(0), I(-500)}), + (0x1ff, new[]{G(1), I(0), I(0), I(0)}), (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)}), + (0x50, new[]{G(22), G(12), I(600)}), + (0x220, new[]{G(1), G(2), I(500), G(11), G(22), G(13)}), Exit(), - }; - var vm = new VirtualMachine(ScriptAssembler.Assemble(t, "AE001H_TRAVEL", ops, - System.Array.Empty()), t, new RecordingHost()); + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Gfx.BindDraw(0xcb20, 4, 0, 0, 800, 500, 0, 50); + vm.Gfx.SetCurrentTranslation(0xcb20, (0, 600, 0)); + vm.Gfx.SetTranslationChannel(0xcb20, 0, 0, (0, 600, 0)); 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); + Assert.Equal((0L, 0L, 0L), (vm.Globals[11], vm.Globals[12], vm.Globals[13])); + Assert.Equal((0.0, 600.0, 0.0), vm.Gfx.TryGet(0xcb20)!.TranslationTarget); } [Fact] diff --git a/engine/Age.Engine/Model/GfxState.Animation.cs b/engine/Age.Engine/Model/GfxState.Animation.cs index 164cd80..ac73da6 100644 --- a/engine/Age.Engine/Model/GfxState.Animation.cs +++ b/engine/Age.Engine/Model/GfxState.Animation.cs @@ -221,18 +221,18 @@ public sealed partial 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) + /// Op 0x228: query the current translation decomposed from the native matrix at obj+0x16c. + /// This is independent of both the delayed target at obj+0x1ac and draw/base position V24. + public bool TryQueryTranslationCurrent(long handle, out (double X, double Y, double Z) current) { lock (_lock) { if (_objects.TryGetValue(handle, out var o)) { - target = o.TranslationTarget; + current = o.TranslationCurrent; return true; } - target = default; + current = default; return false; } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.RetainedObjects.cs b/engine/Age.Engine/Vm/VirtualMachine.RetainedObjects.cs index 31a83e4..197fc4a 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.RetainedObjects.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.RetainedObjects.cs @@ -57,9 +57,11 @@ public sealed partial class VirtualMachine case "set-gfx-range-scale-target": // 0x22d (delay)(duration)(sx%)(sy%)(sz%) Gfx.SetRangeScaleChannel(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1; - case "u00421940": // 0x228: (succ)(handle)(outX)(outY)(outZ) <- target translation matrix + case "query-gfx-translation-target": // pre-correction generated-name compatibility + case "u00421940": + case "query-gfx-translation-current": // 0x228: (succ)(handle)(outX)(outY)(outZ) <- current matrix { - if (Gfx.TryQueryTranslationTarget(Read(a[1]), out var v)) + if (Gfx.TryQueryTranslationCurrent(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); diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 863f370..17b5fbc 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1546,7 +1546,9 @@ public sealed partial class VirtualMachine case "u00421EF0": // 0x232 cyclic packed ARGB; negative alpha/RGB preserve static obj color case "set-scale-cycle": // 0x233 (handle)(period ms)(target scale x/y/z percent) return StepAnimation(label, a, pc); - case "u00421940": // 0x228: (succ)(handle)(outX)(outY)(outZ) <- target translation matrix + case "query-gfx-translation-target": // pre-correction generated-name compatibility + case "query-gfx-translation-current": + case "u00421940": // 0x228: (succ)(handle)(outX)(outY)(outZ) <- current translation matrix return StepRetainedObject(label, a, pc); case "u00422930": case "query-surface-stop-time-ms": diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 5f2af75..98bd0ee 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -77,7 +77,7 @@ INFERRED: dict[int, dict] = { 0x205: dict(name='draw-formatted-integer', category='draw', noop=False, confidence='high', source='investigation', summary='Format an integer into a fixed-width decimal field and rasterize it immediately into a numbered graphics surface using the current font/color/effect state.'), 0x20a: dict(name='publish-adv-text-layout', category='adv', noop=False, confidence='high', source='investigation', summary='(layout_slot) - republish one ADV text layout and, while active, its current wait-indicator frame.'), 0x21b: dict(name='line-id?', category='marker', noop=True, confidence='med', source='harness', summary='1 imm; mov->0x21b->stmt-end; near save/load-messkip — likely line/stmt id, verify not msg-control'), - 0x228: dict(name='query-gfx-translation-target', category='draw', noop=False, confidence='high', source='investigation', 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.'), + 0x228: dict(name='query-gfx-translation-current', category='draw', noop=False, confidence='high', source='investigation', summary='0x228 query current translation (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its current translation matrix at obj+0x16c, and return its x/y/z translation. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. This is independent of both delayed target obj+0x1ac and base draw position V24. See docs/engine-re.md §SC0000 anim cluster.'), 0x22f: dict(name='set-gfx-range-translation-target', category='draw', noop=False, confidence='high', source='investigation', summary="(delay_ms)(duration_ms)(target_x)(target_y)(target_z) — animate the selected retained-gfx range transform's translation from its current matrix to the target. FIELD pairs this with an ordinary-object tween for the minimap viewport marker so both camera representations pan together."), 0x230: dict(name='reset-gfx-cyclic-animations', category='draw', noop=False, confidence='high', source='investigation', summary='(handle) — get or create a retained gfx object, clear its cyclic-animation active flag, and zero all five looping-channel start/period pairs without changing current/base transforms.'), 0x231: dict(name='animate-gfx-srcrect-loop', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(frame_period_ms)(frame_count)(column_count) — loop row-major through the spritesheet. Every sample preserves draw-texture's source-rectangle width/height; frame=floor((shared_frame_time-object_start)/frame_period)%frame_count, src offset=(frame%columns*width, frame/columns*height). All objects use the retained manager's shared current/previous millisecond timestamps but retain their own start and period. The native consumer raises redraw dirty only when the current and previous samples select different cells. Worker gfx_worker_anim_srcrect @0x47eec0; consumer gfx_object_anim_interpolate @0x473ed0."), diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index eea3775..ed21965 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -5886,14 +5886,15 @@ argc = 5 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "query-gfx-translation-target" +name = "query-gfx-translation-current" category = "draw" -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." +summary = "0x228 query current translation (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its current translation matrix at obj+0x16c, and return its x/y/z translation. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. This is independent of both delayed target obj+0x1ac and base draw position V24. See docs/engine-re.md §SC0000 anim cluster." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -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." +evidence = "Ghidra /v2 handler gfx_op_0x228_query_translation_current@0x42a3a0 calls gfx_object_query_translation_current@0x47cdd0. The worker copies the complete 0xb5-dword object record to stack local -0x2e0, then passes local_174, exactly +0x16c within that copy, to matrix4_decompose_affine@0x48d7c8. Thus it decomposes the current matrix obj+0x16c, not target obj+0x1ac. SYSTEM4 P102 supplies the runtime proof: SC0000@0x84e8 resets BG001A current translation to zero, 0x228@0x84fa must return zero, and 0x220@0x8521 then targets +600. The port's target query returned the preceding CG endpoint +600, producing +1200 and leaving BG001A below the viewport. Missing-object output preservation remains valid. Functions renamed, commented, and /v2 saved 2026-08-16." +details = "Implemented by querying TranslationCurrent while preserving the missing-object output contract. The focused regression seeds a reused handle with current/target +600, rebinds it, applies SC0000's 0x1ff current-zero reset, verifies 0x228 returns zero, and confirms the following 0x220 target is +600 rather than +1200. An exact GPU SYSTEM4 P102 capture at wait SC0000@0x85de shows BG001A visible at its intended endpoint." [[opcode.semantics.args]] i = 1