From d4d9def8f429c776aed33fc1c9474248c8715836 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 16 Aug 2026 10:31:01 -0400 Subject: [PATCH] Fix FIELD minimap camera pan --- docs/engine-re.md | 20 ++++++++--- docs/opcode-reference.md | 6 ++-- engine/Age.Engine.Tests/AnimChannelTests.cs | 6 ++-- .../GfxRangeTransformTests.cs | 35 +++++++++++++++++++ .../Model/GfxState.RetainedObjects.cs | 17 +++++++++ .../Vm/VirtualMachine.RetainedObjects.cs | 6 ++-- engine/Age.Engine/Vm/VirtualMachine.cs | 3 +- tools/age_opcodes_himegari.py | 2 +- vm-map/opcodes.toml | 16 ++++----- 9 files changed, 87 insertions(+), 24 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index 08c7464..c8439a6 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1289,13 +1289,12 @@ Native `gfx_object_blit_d3d9@0x4774c0` clips the explicit source rectangle and r `right<=left || bottom<=top`; mode-1's tiled blit likewise visits no children for an empty rectangle. The port now preserves that empty draw rather than leaking the complete SO005 sheet. -#### Selected retained-object range transform — opcodes `0x229`/`0x22a`/`0x22c`/`0x22d` (2026-07-21) +#### Selected retained-object range transform — opcodes `0x229`/`0x22a`/`0x22c`/`0x22d`/`0x22f` (2026-07-21; corrected 2026-08-16) `0x229` was formerly misclassified as a second per-object position setter. Native `op_0x229_set_gfx_range_transform@0x423700` instead resets an embedded gfx-object record at retained-gfx owner `+0x428`, writes `(first_handle,count)` to owner `+0x420/+0x424`, and writes operands 3..5 as that -embedded object's anchor at owner `+0x440..+0x448`. The actual per-object direct-position opcode remains -`0x22f`. +embedded object's anchor at owner `+0x440..+0x448`. On every render frame, `gfx_range_transform_sample_frame@0x476df0` samples the embedded object's ordinary scale/rotation/translation channels into owner matrix `+0xb5b4`. `gfx_object_composite@0x47f650` @@ -1306,7 +1305,10 @@ post-multiplies an object's normal matrix by this shared matrix only when its ha - `0x22b`: current axis-angle rotation (present in the native dispatch table, zero Himegari corpus calls); - `0x22c`: current translation in pixels; - `0x22d`: delayed/duration scale target, using the embedded object's ordinary one-shot scale channel; -- `0x22e`: delayed/duration axis-angle target (native-dispatch-only, zero Himegari corpus calls). +- `0x22e`: delayed/duration axis-angle target (native-dispatch-only, zero Himegari corpus calls); +- `0x22f`: delayed/duration translation target. Its handler loads ECX with retained-gfx owner + `ctx+0x46614`, and worker `gfx_range_transform_set_translation_target@0x472e90` writes the embedded + object's start/delay/duration fields and target matrix. Operand 1 is delay, not an object handle. FIELD's camera helper selects handles `[1,50000)`, anchors the transform at the current camera world coordinate `(G[0x767e],G[0x767f])`, sets translation to `(400-camera_x,300-camera_y,0)`, and applies the @@ -1316,6 +1318,14 @@ camera helper. Across the corpus, `0x229` occurs 693 times in 309 scripts: 590 a identity-range selections, and the two FIELD/LOOK camera selections. Correcting the contract therefore removes spurious object-zero mutations without changing established ADV output. +FIELD's minimap-click helper at `FIELD@0xb3fe` changes the camera world coordinate, then issues two +synchronized one-shot translations: `0x22f` pans the selected world range and `0x220` moves the ordinary +minimap viewport-marker object. The former was previously mistaken for a direct position write to ordinary +object handle 0, so the marker moved immediately while the world stayed fixed until the next camera-helper +rebuild snapped it to the new coordinate. Routing `0x22f` to the embedded range translation channel restores +the shared pan duration and keeps both representations aligned. The user confirmed the natural FIELD minimap +path now pans the world and viewport marker together on 2026-08-16. + ### Blend & transparency — colorkey + `0x202`/`0x203` color/alpha (2026-07-08) Reversed for graphics slice A (spec `docs/superpowers/specs/2026-07-08-blend-transparency-design.md`; @@ -1430,7 +1440,7 @@ annotated in Ghidra, saved. **In scope (built this slice):** | op | handler / worker | semantics | |---|---|---| -| `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. Operand 2 is also retained as channel control state; the port currently ignores that field. | +| `0x22f` | `op_0x22f_set_gfx_range_translation_target` → `gfx_range_transform_set_translation_target` | set the shared selected-range **translation target** with independent delay/duration (embedded object target matrix `owner+0x5d4` = `obj+0x1ac`); FIELD pairs it with the minimap marker's ordinary-object `0x220` tween. | | `0x229` | `op_0x229_set_gfx_range_transform` → `gfx_range_transform_reset` / `select_handles` / `set_anchor` | reset/select the shared **retained-object range transform**; not a per-object position setter (superseded finding above) | | `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 | diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index e7d765e..d54a124 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -1024,10 +1024,10 @@ Implemented through IHost.PlayModalMovieToSurface. Its operand uses the same nat - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x22d_set_gfx_range_scale_target@0x423990 divides operands 3..5 by 100 and calls gfx_range_transform_set_scale_target@0x472d50. The worker arms the embedded transform object's ordinary scale channel (delay obj+0x3c, duration +0x50, target matrix +0xac), which gfx_range_transform_sample_frame@0x476df0 samples before range composition. FIELD has the sole corpus call, a 300 ms camera zoom. -### 0x22f `set-gfx-position-current` (u00421DD0, argc 5) -- **summary:** (handle)(control)(x)(y)(z) — set the object's current/base translation matrix. The native worker also retains operand 2 as channel control state; the port currently applies x/y/z but ignores that control operand. +### 0x22f `set-gfx-range-translation-target` (u00421DD0, argc 5) +- **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. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: gfx_op_0x22f_set_position_anim@0x423b00 fetches x/y/z as floats and passes all five operands to gfx_worker_set_translation@0x472e90. The worker builds the current translation matrix with x/y/z and stores operand 2 in the channel record. This is distinct from the shared range transform at 0x229. The C# handler sets V24 directly and is therefore position-correct but does not yet model operand 2. +- **evidence:** Ghidra /v2 assembly: op_0x22f_set_gfx_range_translation_target@0x423b00 loads ECX with retained-gfx owner ctx+0x46614 before calling gfx_range_transform_set_translation_target@0x472e90. The worker writes owner+0x428's embedded object fields: active bit, shared start obj+0x34, delay obj+0x44, duration obj+0x58, and target translation matrix obj+0x1ac. It never resolves an ordinary object handle. FIELD@0xb4d8 supplies (0, pan_duration, target_x, target_y, 0) while the following 0x220 animates the minimap viewport marker over the same duration. ### 0x230 `reset-gfx-cyclic-animations` (u00421E70, argc 1) - **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. diff --git a/engine/Age.Engine.Tests/AnimChannelTests.cs b/engine/Age.Engine.Tests/AnimChannelTests.cs index e0c7ab8..c5e85f1 100644 --- a/engine/Age.Engine.Tests/AnimChannelTests.cs +++ b/engine/Age.Engine.Tests/AnimChannelTests.cs @@ -20,8 +20,7 @@ public class AnimChannelTests { (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), I(0), I(0)}), - (0x22f, new[]{G(1), G(2), G(3), G(4), G(5)}), + (0x1fb, new[]{G(1), I(0), I(0), I(0), I(1), I(1), G(3), G(4)}), (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)}), @@ -63,8 +62,7 @@ public class AnimChannelTests (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), I(0), I(0)}), - (0x22f, new[]{G(1), G(2), G(4), G(5), G(6)}), + (0x1fb, new[]{G(1), I(0), I(0), I(0), I(1), I(1), G(4), G(5)}), (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)}), diff --git a/engine/Age.Engine.Tests/GfxRangeTransformTests.cs b/engine/Age.Engine.Tests/GfxRangeTransformTests.cs index b5f6283..a661662 100644 --- a/engine/Age.Engine.Tests/GfxRangeTransformTests.cs +++ b/engine/Age.Engine.Tests/GfxRangeTransformTests.cs @@ -51,6 +51,41 @@ public class GfxRangeTransformTests Assert.False(gfx.HasActiveTimedPresentation(1300)); } + [Fact] + public void Opcode22fPansTheSelectedRangeInSyncWithAnOrdinaryObjectTween() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "RANGE_PAN", new List<(int, Operand[])> + { + (0x229, new[] { I(1), I(1), I(500), I(350), I(0) }), + (0x22c, new[] { I(100), I(50), I(0) }), + (0x22f, new[] { I(0), I(300), I(300), I(150), I(0) }), + (0x220, new[] { I(50001), I(0), I(300), I(100), I(50), I(0) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var vm = new VirtualMachine(script, table, new RecordingHost()); + + vm.Gfx.SetSurface(1, 1, -1); + vm.Gfx.BindDraw(1, 1, 0, 0, 1, 1, 500, 350); + vm.Gfx.BindDraw(50001, 1, 0, 0, 1, 1, 0, 0); + vm.Run(); + vm.Gfx.SnapshotVisibleObjects(1000); // seed the shared one-shot start + var halfway = vm.Gfx.SnapshotVisibleObjects(1150); + var map = halfway.Single(x => x.Handle == 1); + var marker = halfway.Single(x => x.Handle == 50001); + var mapPoint = Transform2DMath.Build(map.Transform).FromLocalOrigin(map.DstX, map.DstY) + .Then(map.RangeTransform!.Value).Apply(0, 0); + var markerPoint = Transform2DMath.Build(marker.Transform) + .FromLocalOrigin(marker.DstX, marker.DstY).Apply(0, 0); + + Assert.Equal((700.0, 450.0), mapPoint); + Assert.Equal((50.0, 25.0), markerPoint); + Assert.Null(vm.Gfx.TryGet(0)); + Assert.True(vm.Gfx.HasActiveTimedPresentation(1150)); + vm.Gfx.SnapshotVisibleObjects(1300); + Assert.False(vm.Gfx.HasActiveTimedPresentation(1300)); + } + [Fact] public void Opcode229SelectsRangeWithoutCreatingAnOrdinaryObject() { diff --git a/engine/Age.Engine/Model/GfxState.RetainedObjects.cs b/engine/Age.Engine/Model/GfxState.RetainedObjects.cs index 9f1abe0..6fc9bbd 100644 --- a/engine/Age.Engine/Model/GfxState.RetainedObjects.cs +++ b/engine/Age.Engine/Model/GfxState.RetainedObjects.cs @@ -182,6 +182,23 @@ public sealed partial class GfxState } } + /// Op 0x22f: arm the selected retained-gfx range transform's delayed one-shot + /// translation target. FIELD uses this channel to pan the world while its ordinary minimap object + /// runs a matching op-0x220 translation. + public void SetRangeTranslationChannel(long delayMs, long durationMs, + (long X, long Y, long Z) target) + { + lock (_lock) + { + _rangeTransform.TranslationDelayMs = delayMs; + _rangeTransform.TranslationDurationMs = durationMs; + _rangeTransform.TranslationTarget = target; + _rangeTransform.TranslationEnabled = durationMs > 0; + _rangeTransform.OneShotStartMs = -1; + MarkRetainedMutation(); + } + } + /// Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination. public bool CloneObject(long sourceHandle, long destinationHandle) { diff --git a/engine/Age.Engine/Vm/VirtualMachine.RetainedObjects.cs b/engine/Age.Engine/Vm/VirtualMachine.RetainedObjects.cs index 9b6fec9..31a83e4 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.RetainedObjects.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.RetainedObjects.cs @@ -39,8 +39,10 @@ public sealed partial class VirtualMachine Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1; // ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ---- - case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set) - Gfx.SetObjectPosition(Read(a[0]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1; + case "u00421DD0": // pre-reference compatibility + case "set-gfx-range-translation-target": // 0x22f (delay)(duration)(x)(y)(z) + Gfx.SetRangeTranslationChannel(Read(a[0]), Read(a[1]), + (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1; case "u004219E0": // pre-reference compatibility case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z) Gfx.SetRangeTransform(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 6d54b82..863f370 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1528,7 +1528,8 @@ public sealed partial class VirtualMachine return StepRetainedObject(label, a, pc); // ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ---- - case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set) + case "u00421DD0": // pre-reference compatibility + case "set-gfx-range-translation-target": // 0x22f (delay)(duration)(x)(y)(z) case "u004219E0": // pre-reference compatibility case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z) case "u00421A90": // pre-reference compatibility diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index e273e63..5f2af75 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -78,7 +78,7 @@ INFERRED: dict[int, dict] = { 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.'), - 0x22f: dict(name='set-gfx-position-current', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(control)(x)(y)(z) — set the object's current/base translation matrix. The native worker also retains operand 2 as channel control state; the port currently applies x/y/z but ignores that control operand."), + 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."), 0x232: dict(name='animate-gfx-color-loop', category='draw', noop=False, confidence='high', source='investigation', summary='0x232 anim-color (handle)(period)(alpha)(color): ping-pong the temporary packed ARGB passed to the normal object blit. Handler resolves negative alpha/RGB from static color obj+0x60 and clamps alpha above 255. Blend selector obj+0x30 is unchanged: mode 0 keeps default blending (animated alpha is inert; RGB is vertex modulation), while mode 1 uses sampled ARGB alpha as the SRCALPHA scale for additive composition. Fresh static color is 0xffffffff. The C# VM resolves sentinels and consumes sampled ARGB through the unchanged mode-specific path. See docs/engine-re.md §SC0000 anim cluster.'), diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 8b4860c..56472cb 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -6071,38 +6071,38 @@ argc = 5 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "set-gfx-position-current" +name = "set-gfx-range-translation-target" category = "draw" -summary = "(handle)(control)(x)(y)(z) — set the object's current/base translation matrix. The native worker also retains operand 2 as channel control state; the port currently applies x/y/z but ignores that control operand." +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." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: gfx_op_0x22f_set_position_anim@0x423b00 fetches x/y/z as floats and passes all five operands to gfx_worker_set_translation@0x472e90. The worker builds the current translation matrix with x/y/z and stores operand 2 in the channel record. This is distinct from the shared range transform at 0x229. The C# handler sets V24 directly and is therefore position-correct but does not yet model operand 2." +evidence = "Ghidra /v2 assembly: op_0x22f_set_gfx_range_translation_target@0x423b00 loads ECX with retained-gfx owner ctx+0x46614 before calling gfx_range_transform_set_translation_target@0x472e90. The worker writes owner+0x428's embedded object fields: active bit, shared start obj+0x34, delay obj+0x44, duration obj+0x58, and target translation matrix obj+0x1ac. It never resolves an ordinary object handle. FIELD@0xb4d8 supplies (0, pan_duration, target_x, target_y, 0) while the following 0x220 animates the minimap viewport marker over the same duration." [[opcode.semantics.args]] i = 1 -role = "" +role = "delay milliseconds" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "duration milliseconds" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "target translation X" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 4 -role = "" +role = "target translation Y" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 5 -role = "" +role = "target translation Z" observed_types = ["imm"] [[opcode]]