Fix FIELD minimap camera pan

This commit is contained in:
gamer147
2026-08-16 10:31:01 -04:00
parent 3289948ce7
commit d4d9def8f4
9 changed files with 87 additions and 24 deletions

View File

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

View File

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

View File

@@ -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)}),

View File

@@ -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<Operand>()),
}, System.Array.Empty<string>());
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()
{

View File

@@ -182,6 +182,23 @@ public sealed partial class GfxState
}
}
/// <summary>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.</summary>
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();
}
}
/// <summary>Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination.</summary>
public bool CloneObject(long sourceHandle, long destinationHandle)
{

View File

@@ -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])));

View File

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

View File

@@ -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.'),

View File

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