diff --git a/docs/engine-re.md b/docs/engine-re.md index d78036e..8f82c1b 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -403,9 +403,31 @@ correct `dst`/`w`/`h`. Ancillary per-object tables observed: `ctx+0x14d54` (obj Worker functions decoded + annotated in the Ghidra project (updated 2026-07-09): `gfx_object_erase`(`0x47d850`), `gfx_object_erase_range`(`0x47d8b0`), `gfx_object_query_source_slot`(`0x47f280`), `gfx_object_get_or_create`(`0x47ddb0`, inserts a zeroed default via -`gfx_object_init_default`@`0x472810`), the setters `gfx_set_vec18/24/16c`(`0x47e960/e910/e800`), the getters +`gfx_object_init_default`@`0x472810`; critically, source slot `obj+4` defaults to **0**, while only an absent +map entry queries as `-1`), the setters `gfx_set_vec18/24/16c`(`0x47e960/e910/e800`), the getters `gfx_get_vec18/24`(`0x47f360/f2e0`). +**Page-58 lifecycle correction (2026-07-11).** Locator `SC0000 P058` resolves to `wait@0x6545`; the loader at +`0x6470..0x6478` resolves resource `0x7a` to `EV050EA.AGF`. The port loaded and bound it correctly, but handle +`0xcb2a` inherited an earlier translation target `(-100,0)`, rotation `-90 degrees`, and alpha endpoint zero. +The preceding query-gated cleanup had skipped this created-but-unbound object because the port initialized +`SourceSlot=-1`. Native `gfx_object_init_default` zeroes `obj+4`, so op `0x215` returns slot 0, the cleanup +erases the object, and the later bind recreates identity state. Matching that default leaves EV050EA centered, +unrotated, and opaque in the synchronized page-58 compositor trace. The adjacent op `0x242(handle,0)` is not +a reset: `op_0x242_set_object_field2d0@0x4249d0` calls `gfx_object_set_field2d0@0x47f1a0`, which only writes +the supplied value at `obj+0x2d0`. + +**Page-89 surface-lifetime correction (2026-07-11).** Locator `SC0000 P089` resolves to +`wait@0x89e9`; the loader at `0x8976..0x898a` resolves resource `0x6a` to `BG004D.AGF`. The background +was bound but composited at `(400,650)`. The remaining 550 pixels were intentional retained motion from +BG001A; the incorrect `(400,600)` component came from the host keeping slot 0's boot-time 800x600 dimensions +after op `0x1fa` released that surface. Native op `0x1fa` frees and nulls `ctx+0x52bd4[slot]`, so a subsequent +op `0x208` size query of the released slot yields no surface dimensions. Clearing the host slot resource and +dimensions on release makes the script's existing-object geometry path compute base `(0,-500)`; after the +retained `(0,550)` translation, BG004D lands at `(0,50)`. A synchronized run at the exact wait confirms the +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. + #### 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 @@ -918,8 +940,8 @@ negative operands in `SetColorAnimResolved`, samples packed ARGB before blend se the existing mode-specific path. Exact AE001H, mode-0 RGB-modulation, and mode-1 alpha regressions cover the contract; the white pulse is removed without suppressing the scripted channel. -**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. +**Resolved 2026-07-11:** `0x236` is the movie-to-retained-surface path described below. `0x242` is now +classified as the retained-object `+0x2d0` field setter described above; `0x23d/0x20a/0x20e` remain GAP. ### Movie-to-surface opcode `0x236` (2026-07-11) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 6d9f386..2783710 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -166,10 +166,10 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: handler gfx_op_0x1fd_set_vec_scaled@0x422650 fetches operands 2..4, divides each by the 100.0 constant, and calls gfx_object_set_scale_current@0x47e6b0. The worker gets/creates the object, marks obj+0x68, and calls matrix4_make_scale on obj+0x6c. SC0000 sets AE001D handles to 210/210/100 and 240/240/100; without this setter their 800x800 alpha circles remain below the viewport. Both functions annotated and /v2 saved 2026-07-11. -### 0x1ff `set-gfx-geom3-c` (set-gfx-geom3-c, argc 4) -- **summary:** 0x1ff (handle)(a)(b)(c) — gfx cmd-type 9. Handler gfx_op_0x1ff_set_geom3 @0x4227b0: SETS a 3-vector (int→float a,b,c) on object `handle` via native worker FUN_0047e800 (sibling of 0x217/0x219, a distinct per-object vector). See docs/engine-re.md gfx op-contract table. +### 0x1ff `set-current-translation` (set-gfx-geom3-c, argc 4) +- **summary:** 0x1ff (handle)(x)(y)(z) — immediately replace the retained object's current translation matrix at obj+0x16c. This is the direct-current companion to 0x220's delayed target at obj+0x1ac. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra handler 0x4227b0 (dispatch ctx[0x26c93+0x1ff]); FUN_0047e800(op1,(float)op2,(float)op3,(float)op4). +- **evidence:** Ghidra /v2: handler gfx_op_0x1ff_set_geom3@0x4227b0 converts operands 2..4 to float and calls gfx_object_set_translation_current@0x47e800. The worker gets/creates the object, marks obj+0x168, and calls matrix4_make_translation on obj+0x16c. SC0000 uses (0,0,0) before animating BG001A. Annotated and saved 2026-07-11. ### 0x202 `gfx-blit-color` (gfx-blit-color, argc 5) - **summary:** 0x202 (handle)(delay_ms)(duration_ms)(alpha)(color) — arm the one-shot packed-ARGB channel. Worker gfx_op_0x202_worker_set_color_anim @0x47ea00 resets shared start obj+0x34, writes delay +0x38, duration +0x4c, and target +0x64. gfx_object_apply_transform_channels @0x472f00 linearly interpolates each byte from current +0x60 on frame clock ctx+0xb550, commits the target, clears timing, writes target -1, and clears the one-shot active bit when all sibling channels finish. Negative alpha/RGB independently preserve current bytes. Implemented in GfxState with synchronized current/target timeline evidence; draw-string 0x204/0x7a remains separate. @@ -206,9 +206,9 @@ Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is - **evidence:** Ghidra handler 0x423110; writes obj+0x68/+0x6c from operands 2/3, obj from ctx+0x14d54[operand1*4]. ### 0x215 `query-gfx-object?` (query-gfx-object?, argc 2) -- **summary:** 0x215 (out_slot)(handle) — query the retained gfx-object map. Handler gfx_op_0x215_query_source_slot@0x42a0b0 calls gfx_object_query_source_slot@0x47f280 with owner ctx+0x46614. The worker searches owner+0x408, the same map populated by geometry/draw workers, and returns obj+4: the live source-surface slot written by draw-texture, or -1 if absent. A geometry-only object remains unbound and returns -1. SC0000 uses a successful result for existing-object geometry and query-guarded teardown: op 0x1f7 erases the object group and op 0x1fa releases this slot. VM fix 2026-07-09 restores AE001H magic-circle cleanup. +- **summary:** 0x215 (out_slot)(handle) — query the retained gfx-object map. Handler gfx_op_0x215_query_source_slot@0x42a0b0 calls gfx_object_query_source_slot@0x47f280 with owner ctx+0x46614. The worker searches owner+0x408, the same map populated by geometry/draw workers, and returns obj+4: the live source-surface slot written by draw-texture, or -1 if absent. gfx_object_init_default zeroes obj+4, so a geometry/animation-created but draw-unbound object returns slot 0, not -1. SC0000 uses a successful result for query-guarded teardown: op 0x1f7 erases the object group and op 0x1fa releases this slot. Matching the zero default prevents stale transforms from surviving into the EV050EA CG at SC0000 page 58. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra asm: handler 0x42a0b0 passes ECX=ctx+0x46614 to 0x47f280. That worker searches ECX+0x408 and returns resolved object+4. gfx_object_bind_draw@0x47e870, called with the same owner, get-or-creates in owner+0x408 and writes source slot to object+4. SC0000 post-effect cleanup 0x3321 queries G[0x62457], sign-tests, then executes 0x1f7(handle,10) + 0x1fa(returned_slot); AE001H res 0x37 was bound to that handle. +- **evidence:** Ghidra: handler 0x42a0b0 passes ECX=ctx+0x46614 to 0x47f280, which searches ECX+0x408 and returns object+4. gfx_object_bind_draw@0x47e870 writes the bound slot there; gfx_object_init_default@0x472810 explicitly writes zero to dword index 1. SC0000 page-58 trace: the old -1 default skipped cleanup of transform-created handle 0xcb2a, then EV050EA inherited translation (-100,0), rotation -90, and alpha 0; the native zero default makes the guard succeed and the reused object is identity/opaque. ### 0x216 `query-gfx-field?` (query-gfx-field?, argc 2) - **summary:** 0x216 (out)(idx) — gfx cmd-type 5. Handler gfx_op_0x216_query_table46d14 @0x42a0f0: out = *(ctx+0x46d14 + idx*0x14). A per-object field query over a stride-0x14 table. See docs/engine-re.md gfx op-contract table. @@ -316,6 +316,11 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b - **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. - **grounding:** source=kelebek, confidence=low +### 0x242 `set-gfx-field2d0` (set-gfx-field2d0, argc 2) +- **summary:** 0x242 (handle)(value) — command type 5. Get-or-create the retained gfx object and write value to obj+0x2d0. SC0000's common CG loader passes zero after draw binding. This field does not reset transform or color channels; its downstream purpose remains unknown. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x242_set_object_field2d0@0x4249d0 fetches operands 2 and 1 and calls gfx_object_set_field2d0@0x47f1a0; the worker calls gfx_object_get_or_create then stores operand 2 at returned object+0x2d0. + ### 0x243 `reset-anim-clock` (reset-anim-clock, argc 0) - **summary:** Reset the native global animation-service elapsed and duration fields to zero when service flag bit 1 is clear. - **grounding:** source=investigation, confidence=high @@ -1077,10 +1082,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x242 `u00422D60` (u00422D60, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x248 `u00422E80` (u00422E80, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 1a44ee5..95e6ebe 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1445,3 +1445,27 @@ around the wait. Page number is deliberately only the friendly coordinate becaus shift ordinals; the script/offset remains authoritative. A live SC0000 run verified page 1 as `show-text@0x834`, string `0x14963`, and `wait-for-input@0x83c`. Validation: focused Python tests, engine **152/152**, zero-warning Godot build, threaded `SELFTEST OK`, and the live lookup all pass. + +### SC0000 page-58 EV050EA background restored (2026-07-11) + +The first user-supplied locator, `SC0000 P058 / wait@0x6545 / text@0x653d`, led directly to the missing +full-screen CG. Resource `0x7a` resolves to `EV050EA.AGF` and was loaded into slot 5, but retained handle +`0xcb2a` inherited an older `(-100,0)` translation, `-90 degree` rotation, and alpha-zero endpoint. Native +RE showed why cleanup diverged: `gfx_object_init_default` zeroes source slot `obj+4`, so op `0x215` returns +0 for a created-but-unbound object; only a missing handle returns -1. The port's `SourceSlot=-1` default made +the sign-tested cleanup skip that object. The model now uses native slot 0, allowing erase/recreate to restore +identity state. A lifecycle regression reproduces the transform-create, query-cleanup, and CG-reuse sequence. +Validation: engine **153/153**, zero-warning Godot build, and a synchronized real-scene compositor trace at +page 58 showing `EV050EA.AGF` at `(0,0)`, scale 1, translation 0, rotation 0, and opacity 1. + +### SC0000 page-89 BG004D background restored (2026-07-11) + +Locator `SC0000 P089 / wait@0x89e9 / text@0x89e1` identified `BG004D.AGF` as loaded but offscreen at +`(400,650)`. The prior BG001A animation intentionally leaves translation y=550; the erroneous extra +`(400,600)` came from `GodotAdvHost` retaining slot 0's seeded 800x600 dimensions after op `0x1fa` released +the surface. Native release frees and nulls the slot. Static releases now remove the host resource and size +state (while the existing in-flight movie retention remains unchanged), and the CLI graphics trace mirrors +that lifecycle. The loader consequently builds base `(0,-500)`, so the retained translation places BG004D +at `(0,50)`. Op `0x1ff` was also completed as the native immediate current-translation setter at obj+0x16c. +Validation: engine **155/155**, zero-warning Godot and CLI builds, and a full synchronized run reaching the +exact page-89 wait with `BG004D.AGF` drawn at `(0,50)` alongside the character sprite. diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 5d5ba86..d68f7e2 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -373,8 +373,7 @@ sealed class GfxTraceHost : IHost private readonly ResourceMap _res; private readonly string _scene; private readonly Dictionary _slotAsset = new(); // slot -> resolved AGF name (or null) - // slot -> dims. Slot 0 is the primary/screen surface (800x600), normally created at engine boot which - // the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0). + // slot -> dimensions of the currently allocated surface. Slot 0 starts as the engine's primary surface. private readonly Dictionary _slotDims = new() { { 0, (800, 600) } }; public List Events { get; } = new(); public GfxTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } @@ -408,6 +407,12 @@ sealed class GfxTraceHost : IHost _slotDims[slot] = (width, height); Events.Add($"create-texture slot={slot} {width}x{height}"); } + public void ReleaseSurface(int slot) + { + _slotAsset.Remove(slot); + _slotDims.Remove(slot); + Events.Add($"release-surface slot={slot}"); + } public void ShowText(int offset, string text) { } public void WaitForInput() { } public void Sleep(long duration) { } diff --git a/engine/Age.Engine.Tests/AnimChannelTests.cs b/engine/Age.Engine.Tests/AnimChannelTests.cs index 2ace38d..e0c7ab8 100644 --- a/engine/Age.Engine.Tests/AnimChannelTests.cs +++ b/engine/Age.Engine.Tests/AnimChannelTests.cs @@ -20,6 +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)}), (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)}), @@ -62,6 +63,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)}), (0x228, new[]{G(10), G(1), G(11), G(12), G(13)}), diff --git a/engine/Age.Engine.Tests/GfxAnimationTests.cs b/engine/Age.Engine.Tests/GfxAnimationTests.cs index 6b26958..f9a4397 100644 --- a/engine/Age.Engine.Tests/GfxAnimationTests.cs +++ b/engine/Age.Engine.Tests/GfxAnimationTests.cs @@ -13,6 +13,7 @@ public class GfxAnimationTests public void ScaleAndTranslationChannels_AreIndependent() { var g = new GfxState(); + g.BindDraw(0x1000, 1, 0, 0, 1, 1, 0, 0); g.SetScaleChannel(0x1000, delayMs: 7, durationMs: 900, percent: (200, 50, 100)); g.SetTranslationChannel(0x1000, delayMs: 9, durationMs: 1200, target: (80, -25, 6)); var o = g.TryGet(0x1000)!; @@ -44,6 +45,7 @@ public class GfxAnimationTests public void RotationCycle_DoesNotOverwriteMatrixChannels() { var g = new GfxState(); + g.BindDraw(0x1000, 1, 0, 0, 1, 1, 0, 0); g.SetScaleChannel(0x1000, 0, 100, (150, 150, 100)); g.SetTranslationChannel(0x1000, 0, 100, (10, 20, 0)); g.SetRotationCycle(0x1000, periodMs: 30, axis: (0, 0, 5)); @@ -55,6 +57,30 @@ public class GfxAnimationTests Assert.True(o.RotationEnabled); } + [Fact] + public void PreBindMatrixChannels_CreateNeutralPlaceholderWithoutLatentMotion() + { + // SC0000 calls op 0x220 on background handle 0xcb20 before BG001A is bound. Native creates the + // object (so op 0x215 returns slot 0) but ignores all three matrix setters while visible bit 0 is clear. + var g = new GfxState(); + g.SetScaleChannel(0xcb20, 0, 500, (200, 200, 100)); + g.SetRotationChannel(0xcb20, 0, 500, (0, 0, 1), 90); + g.SetTranslationChannel(0xcb20, 0, 500, (0, 600, 0)); + + var placeholder = g.TryGet(0xcb20)!; + Assert.Equal(0, g.QuerySlot(0xcb20)); + Assert.False(placeholder.Visible); + Assert.False(placeholder.ScaleEnabled); + Assert.False(placeholder.RotationChannelEnabled); + Assert.False(placeholder.TranslationEnabled); + Assert.Equal((0.0, 0.0, 0.0), placeholder.TranslationTarget); + + g.BindDraw(0xcb20, 4, 0, 0, 800, 500, 0, -500); + g.SetTranslationChannel(0xcb20, 0, 500, (0, 600, 0)); + Assert.True(placeholder.TranslationEnabled); + Assert.Equal((0.0, 600.0, 0.0), placeholder.TranslationTarget); + } + [Fact] public void ActiveVisualPresentation_ExcludesStaticWaits_ButIncludesAmbientChannels() { @@ -159,6 +185,7 @@ public class GfxAnimationTests var scene = ScriptAssembler.Assemble(t, "ANIM", new List<(int, Operand[])> { MovGI(1, 0x1000), MovGI(2, 7), MovGI(3, 9), MovGI(4, 800), MovGI(5, 500), MovGI(6, 0), + (0x1fb, new[] { G(1), I(0), I(0), I(0), I(1), I(1), I(0), I(0) }), (0x220, new[] { G(1), G(2), G(3), G(4), G(5), G(6) }), MovGI(4, 200), MovGI(5, 50), MovGI(6, 100), (0x21e, new[] { G(1), G(2), G(3), G(4), G(5), G(6) }), @@ -215,6 +242,23 @@ public class GfxAnimationTests Assert.Equal(1, vm.Gfx.AnimClockGeneration); } + [Fact] + public void CurrentTranslationSetter_ReplacesTheLiveMatrixImmediately() + { + var t = T(); + var scene = ScriptAssembler.Assemble(t, "CURRENTTRANSLATION", new List<(int, Operand[])> + { + MovGI(1, 0xcb20), MovGI(2, 12), MovGI(3, 34), MovGI(4, 5), + (0x1ff, new[] { G(1), G(2), G(3), G(4) }), + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + var o = vm.Gfx.TryGet(0xcb20)!; + Assert.Equal((12.0, 34.0, 5.0), o.TranslationCurrent); + Assert.Equal((12L, 34L, 5L), o.V16c); + } + [Fact] public void ResetAnimClock_DispatchClearsOnlyGlobalServiceClock() { diff --git a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs index e69fa43..72ee97c 100644 --- a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs +++ b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs @@ -39,10 +39,10 @@ public class GfxCommandBufferTests private static (int, Operand[]) Register(int handle) => (0x1a2, new[] { G(handle) }); [Fact] - public void QueryReturnsMinusOneUntilDrawBound_ThenSourceSlot() + public void QueryReturnsZeroForCreatedUnboundObject_ThenBoundSourceSlot() { - // Op 0x215 returns obj+4 from the retained gfx object. Geometry creates the object but leaves it unbound; - // op 0x1a2's descriptor registry is unrelated. Draw-texture binds the source slot returned by the query. + // Op 0x215 returns obj+4 from the retained gfx object. Native initialization leaves an unbound object's + // field at zero; op 0x1a2 is unrelated. Draw-texture replaces it with the bound source slot. var t = T(); var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> { @@ -55,7 +55,7 @@ public class GfxCommandBufferTests }, System.Array.Empty()); var vm = new VirtualMachine(scene, t, new RecordingHost()); vm.Run(); - Assert.Equal(-1, vm.Globals[10]); + Assert.Equal(0, vm.Globals[10]); Assert.Equal(6, vm.Globals[11]); } diff --git a/engine/Age.Engine.Tests/GfxStateTests.cs b/engine/Age.Engine.Tests/GfxStateTests.cs index 5e7a9c0..633a4e1 100644 --- a/engine/Age.Engine.Tests/GfxStateTests.cs +++ b/engine/Age.Engine.Tests/GfxStateTests.cs @@ -6,20 +6,41 @@ public class GfxStateTests [Fact] public void QueryReturnsBoundSourceSlot_NotOperandRegistryValue() { - // Native op 0x215 queries the retained-object map and returns obj+4, the source slot set by draw-texture. - // Geometry alone creates an object but leaves obj+4 at -1. Op 0x1a2 is a separate descriptor registry. + // Native op 0x215 queries the retained-object map and returns obj+4. The default initializer zeroes + // that field, while draw-texture replaces it with the bound slot. Op 0x1a2 is a separate registry. var g = new GfxState(); g.GetOrCreate(0xcb2a).V18 = (400, 600, 0); - Assert.Equal(-1, g.QuerySlot(0xcb2a)); + Assert.Equal(0, g.QuerySlot(0xcb2a)); g.Register(0xcb2a); - Assert.Equal(-1, g.QuerySlot(0xcb2a)); + Assert.Equal(0, g.QuerySlot(0xcb2a)); g.BindDraw(0xcb2a, 6, 0, 0, 200, 200, 10, 20); Assert.Equal(6, g.QuerySlot(0xcb2a)); Assert.Equal(-1, g.QuerySlot(0x9999)); } + [Fact] + public void UnboundMatrixSetterCreatesQueryableObjectForCleanupBeforeReuse() + { + // Native matrix setters create a neutral placeholder even when they ignore the channel because it is + // not draw-bound. obj+4 still defaults to slot 0, so the later query >= 0 cleanup erases the object. + var g = new GfxState(); + g.SetTranslationChannel(0xcb2a, 0, 150, (-100, 0, 0)); + g.SetRotationChannel(0xcb2a, 0, 150, (0, 0, 1), -90); + Assert.Equal(0, g.QuerySlot(0xcb2a)); + + if (g.QuerySlot(0xcb2a) >= 0) + g.EraseRange(0xcb2a, 10); + + g.SetSurface(5, 0x7a, 0); + g.BindDraw(0xcb2a, 5, 0, 0, 800, 600, 0, 0); + var reused = Assert.Single(g.SnapshotVisibleObjects(1000)); + Assert.Equal((0.0, 0.0, 0.0), + (reused.Transform.TranslateX, reused.Transform.TranslateY, reused.Transform.RotationAngleDegrees)); + Assert.Equal(255, reused.Alpha); + } + [Fact] public void VectorsRoundTripPerObject() { diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 803b781..d09f62d 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -81,7 +81,9 @@ public sealed class GfxState public long ColorPeriod, ColorStart = -1, ColorTarget; public bool ColorAnim; // draw-texture bind (gfx_object_bind_draw): the surface to draw + its source rect + the visible flag. - public int SourceSlot = -1; + // Native gfx_object_init_default zeroes obj+4. Querying an object created by a geometry/animation + // setter therefore returns slot 0 even before draw-texture binds it; only an absent object returns -1. + public int SourceSlot; public (int X, int Y, int W, int H) SrcRect; public bool Visible; @@ -441,12 +443,26 @@ public sealed class GfxState } } + /// Op 0x1ff: immediately replace the object's current translation matrix at obj+0x16c. + public void SetCurrentTranslation(long handle, (long X, long Y, long Z) translation) + { + lock (_lock) + { + var o = GetOrCreate(handle); + o.V16c = translation; + o.TranslationCurrent = translation; + } + } + /// Op 0x21e: normalized scale target (100 = identity), with independent delay/duration. public void SetScaleChannel(long handle, long delayMs, long durationMs, (long X, long Y, long Z) percent) { lock (_lock) { var o = GetOrCreate(handle); + // Native setters get-or-create first, then require object flag bit 0 (draw-bound/visible). + // Pre-bind calls leave a neutral, queryable placeholder and do not queue latent motion. + if (!o.Visible) return; o.ScaleDelayMs = delayMs; o.ScaleDurationMs = durationMs; o.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0); o.ScaleEnabled = durationMs > 0; o.OneShotStartMs = -1; @@ -459,6 +475,7 @@ public sealed class GfxState lock (_lock) { var o = GetOrCreate(handle); + if (!o.Visible) return; o.TranslationDelayMs = delayMs; o.TranslationDurationMs = durationMs; o.TranslationTarget = target; o.TranslationEnabled = durationMs > 0; o.OneShotStartMs = -1; @@ -488,6 +505,7 @@ public sealed class GfxState lock (_lock) { var o = GetOrCreate(handle); + if (!o.Visible) return; o.RotationDelayMs = delayMs; o.RotationDurationMs = durationMs; o.RotationTarget = (axis.X, axis.Y, axis.Z, angleDegrees); o.RotationChannelEnabled = durationMs > 0; o.OneShotStartMs = -1; diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 960d3a4..01dcd89 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -411,8 +411,8 @@ public sealed class VirtualMachine } 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; - case "set-gfx-geom3-c": // 0x1ff (handle)(a)(b)(c) -> V16c - Gfx.GetOrCreate(Read(a[0])).V16c = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; + case "set-gfx-geom3-c": // 0x1ff: set current translation matrix + Gfx.SetCurrentTranslation(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1; case "u00420620": // upstream ABI label case "gfx-set-scale-current": // 0x1fd (handle)(sx%)(sy%)(sz%) -> current scale matrix Gfx.SetCurrentScale(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1; diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 52fb01f..5dfff79 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -16,8 +16,8 @@ public sealed class GodotAdvHost : IHost private readonly Dictionary _movieBySurface = new(); private readonly HashSet _completedMovies = new(); private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset - // slot -> dims. Slot 0 is the primary/screen surface (800x600), normally created at engine boot which - // the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0). + // slot -> dimensions of the currently allocated surface. Slot 0 begins as the engine's 800x600 + // primary surface, but op 0x1fa releases it like any other slot; subsequent size queries must return 0x0. private readonly Dictionary _slotDims = new() { { 0, (800, 600) } }; private readonly SemaphoreSlim _gate = new(0, 1); private readonly Age.Engine.Hosting.FrameClock _clock; @@ -359,7 +359,16 @@ public sealed class GodotAdvHost : IHost long resourceId; lock (_imageLock) { - if (!_movieBySurface.Remove(slot, out resourceId)) return; + if (!_movieBySurface.Remove(slot, out resourceId)) + { + lock (_textLock) + { + _surfaceText.Remove(slot); + _surfaceResources.Remove(slot); + } + _slotDims.Remove(slot); + return; + } if (!_completedMovies.Contains(resourceId)) { _movieBySurface[slot] = resourceId; @@ -368,6 +377,12 @@ public sealed class GodotAdvHost : IHost _movieFrames.Remove(resourceId); _completedMovies.Remove(resourceId); } + lock (_textLock) + { + _surfaceText.Remove(slot); + _surfaceResources.Remove(slot); + } + _slotDims.Remove(slot); _timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["surface"] = slot }); _main.CallDeferred("StopMovie", resourceId); } diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index ffe842c..bcf8fb6 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -22,6 +22,7 @@ INFERRED: dict[int, dict] = { 0x1f4: dict(name='stmt-begin', category='marker', noop=True, confidence='high', source='investigation', summary='zero-arg; opens scripts, pairs with stmt-end 0x1f5'), 0x1f5: dict(name='stmt-end', category='marker', noop=True, confidence='high', source='investigation', summary='zero-arg; precedes exit/next-stmt, pairs with 0x1f4'), 0x1fd: dict(name='gfx-set-scale-current', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(scale_x_percent)(scale_y_percent)(scale_z_percent) — immediately replace the retained object's current scale matrix at obj+0x6c. The handler divides each integer operand by 100.0 before calling matrix4_make_scale; this is distinct from 0x21e's delayed one-shot target scale."), + 0x1ff: dict(name='set-current-translation', category='draw', noop=False, confidence='high', source='investigation', summary="0x1ff (handle)(x)(y)(z) — immediately replace the retained object's current translation matrix at obj+0x16c. This is the direct-current companion to 0x220's delayed target at obj+0x1ac."), 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'), 0x258: dict(name='decl?', category='marker', noop=True, confidence='low', source='harness', summary='2 imm; runs in a chain right after script-entry 0x259, enumerating ids — prologue declaration/registration?'), 0x259: dict(name='script-entry', category='marker', noop=True, confidence='low', source='harness', summary='zero-arg; the first instruction of a script (offset 0), opens the decl chain that 0x258 continues — script/prologue entry marker, structural'), diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 650b197..59b6a13 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -4753,14 +4753,14 @@ argc = 4 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "set-gfx-geom3-c" +name = "set-current-translation" category = "draw" -summary = "0x1ff (handle)(a)(b)(c) — gfx cmd-type 9. Handler gfx_op_0x1ff_set_geom3 @0x4227b0: SETS a 3-vector (int→float a,b,c) on object `handle` via native worker FUN_0047e800 (sibling of 0x217/0x219, a distinct per-object vector). See docs/engine-re.md gfx op-contract table." +summary = "0x1ff (handle)(x)(y)(z) — immediately replace the retained object's current translation matrix at obj+0x16c. This is the direct-current companion to 0x220's delayed target at obj+0x1ac." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra handler 0x4227b0 (dispatch ctx[0x26c93+0x1ff]); FUN_0047e800(op1,(float)op2,(float)op3,(float)op4)." +evidence = "Ghidra /v2: handler gfx_op_0x1ff_set_geom3@0x4227b0 converts operands 2..4 to float and calls gfx_object_set_translation_current@0x47e800. The worker gets/creates the object, marks obj+0x168, and calls matrix4_make_translation on obj+0x16c. SC0000 uses (0,0,0) before animating BG001A. Annotated and saved 2026-07-11." [[opcode.semantics.args]] i = 1 @@ -5251,12 +5251,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "query-gfx-object?" category = "draw" -summary = "0x215 (out_slot)(handle) — query the retained gfx-object map. Handler gfx_op_0x215_query_source_slot@0x42a0b0 calls gfx_object_query_source_slot@0x47f280 with owner ctx+0x46614. The worker searches owner+0x408, the same map populated by geometry/draw workers, and returns obj+4: the live source-surface slot written by draw-texture, or -1 if absent. A geometry-only object remains unbound and returns -1. SC0000 uses a successful result for existing-object geometry and query-guarded teardown: op 0x1f7 erases the object group and op 0x1fa releases this slot. VM fix 2026-07-09 restores AE001H magic-circle cleanup." +summary = "0x215 (out_slot)(handle) — query the retained gfx-object map. Handler gfx_op_0x215_query_source_slot@0x42a0b0 calls gfx_object_query_source_slot@0x47f280 with owner ctx+0x46614. The worker searches owner+0x408, the same map populated by geometry/draw workers, and returns obj+4: the live source-surface slot written by draw-texture, or -1 if absent. gfx_object_init_default zeroes obj+4, so a geometry/animation-created but draw-unbound object returns slot 0, not -1. SC0000 uses a successful result for query-guarded teardown: op 0x1f7 erases the object group and op 0x1fa releases this slot. Matching the zero default prevents stale transforms from surviving into the EV050EA CG at SC0000 page 58." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra asm: handler 0x42a0b0 passes ECX=ctx+0x46614 to 0x47f280. That worker searches ECX+0x408 and returns resolved object+4. gfx_object_bind_draw@0x47e870, called with the same owner, get-or-creates in owner+0x408 and writes source slot to object+4. SC0000 post-effect cleanup 0x3321 queries G[0x62457], sign-tests, then executes 0x1f7(handle,10) + 0x1fa(returned_slot); AE001H res 0x37 was bound to that handle." +evidence = "Ghidra: handler 0x42a0b0 passes ECX=ctx+0x46614 to 0x47f280, which searches ECX+0x408 and returns object+4. gfx_object_bind_draw@0x47e870 writes the bound slot there; gfx_object_init_default@0x472810 explicitly writes zero to dword index 1. SC0000 page-58 trace: the old -1 default skipped cleanup of transform-created handle 0xcb2a, then EV050EA inherited translation (-100,0), rotation -90, and alpha 0; the native zero default makes the guard succeed and the reused object is identity/opaque." [[opcode.semantics.args]] i = 1 @@ -6426,28 +6426,28 @@ observed_types = ["l-int"] [[opcode]] op = 0x242 -label = "u00422D60" +label = "set-gfx-field2d0" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00422D60" -category = "unknown" -summary = "" +name = "set-gfx-field2d0" +category = "draw" +summary = "0x242 (handle)(value) — command type 5. Get-or-create the retained gfx object and write value to obj+0x2d0. SC0000's common CG loader passes zero after draw binding. This field does not reset transform or color channels; its downstream purpose remains unknown." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x242_set_object_field2d0@0x4249d0 fetches operands 2 and 1 and calls gfx_object_set_field2d0@0x47f1a0; the worker calls gfx_object_get_or_create then stores operand 2 at returned object+0x2d0." [[opcode.semantics.args]] i = 1 -role = "" +role = "handle_id" observed_types = ["l-int", "l-ptr"] [[opcode.semantics.args]] i = 2 -role = "" +role = "field2d0_value" observed_types = ["imm"] [[opcode]]