diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index fa3e796..8bec336 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -86,6 +86,16 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b remains correct for ordinary SC texture/voice ids. ROOM voice `0x3365`, for example, resolves as raw `EUA0016.OGG`; treating it only as a ROOM-local id produces no asset because ROOM owns no SC range. + **Explicit raw texture loader (identified and implemented 2026-07-21).** Opcode `0x249` is the + unambiguous packed raw-id texture path even while a scene section is active. It shares `0x1f9`'s surface + replacement, AGF decode, and RGB colorkey contract, but passes native surface mode 1 and does not apply + the executing frame's section base. FIELD uses `0x32da..0x32dd`, the universal SYS4INI indexes for + `SO005.AGF`, `SO007.AGF`, `SO008A.AGF`, and `SO007A.AGF`, to populate map-sheet surfaces `0x3e..0x41`. + The port therefore forwards those ids directly to `ResolveRawTexture`; it must not run them through + `ResolveTextureResourceId` first. Native mode 1 is a large-image wrapper which tiles the same decoded + logical pixels over ordinary child textures; it is not a different AGF/spritesheet interpretation or + blend rule. The port's contiguous CPU image is therefore equivalent for rendering purposes. + *How we got here (condensed):* first confirmed `resId == file_number` via Frida load-order correlation for SC0000's opening, but `file_number` is not globally unique so a per-scene "scope" was needed. A long hunt for the selector (thought it was native scene state; even tried reading `G[0x62424]` live — the diff --git a/docs/engine-re.md b/docs/engine-re.md index be19d1f..cd8fee2 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -941,6 +941,61 @@ Separate scale/rotation/translation state and timing are implemented. Anchor sem cyclic wrapping, 2D projection, and affine raster coverage have focused native-oracle tests. Native D3D9 filtering and render-target command execution remain separate fidelity work. +#### Raw mode-1 surface load — opcode `0x249` (2026-07-21) + +`op_0x249_load_raw_texture_surface@0x424b20` has ABI +`(universal_packed_catalog_id, surface_slot, RGB_colorkey)`. Its release, movie detach, indexed-asset open, +colorkey conversion, failure exception, and stream cleanup are the same as `0x1f9`. The only native loader +difference is the last argument to `gfx_surface_load_asset@0x477c40`: `0x1f9` passes mode 0, while `0x249` +passes mode 1. `gfx_surface_decode_and_create@0x474e90` selects a base 0x450-byte texture object for mode 0 +and a derived 0x460-byte texture object for mode 1. A successful mode-0 load records the resource id in its +device-reload record; mode 1 records `-1`. + +The derived class is specifically a **tiled large-image surface**, not an alternate pixel format, +spritesheet interpretation, or blend mode. `gfx_tiled_surface_create@0x432ff0` divides the logical image into +`ceil(width / DAT_005b15b0) × ceil(height / DAT_005b15b0)` ordinary mode-0 child surfaces. +`gfx_tiled_surface_upload_agf@0x431a10` decodes indexed 1/4/8-bpp and 24/32-bpp input and uploads each tile; +`gfx_tiled_surface_blit@0x4316b0` divides any requested logical source rectangle across the intersecting +children and adjusts their destinations. A CPU compositor can therefore keep one contiguous decoded RGBA +image without losing the mode-1 behavior relevant to Himegari. + +The corpus makes the addressing and first gameplay consequence concrete. FIELD calls `0x249` with raw ids +`0x32da..0x32dd`, which are SYS4INI entries `SO005`, `SO007`, `SO008A`, and `SO007A`, into slots +`0x3e..0x41`. `DRAWMAP.BIN` then creates the dungeon tile objects almost entirely from slots `0x3e` and +`0x3f`. A skipped `0x249` therefore leaves the surrounding UI operational but the central map black. After +implementing it, the first DEBUGMAP retest exposed a separate host blit error: FIELD intentionally binds a +zero-width/zero-height SO005 prototype object, while the port expanded zero dimensions to the full texture. +Native `gfx_object_blit_d3d9@0x4774c0` clips the explicit source rectangle and returns when +`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) + +`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`. + +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` +post-multiplies an object's normal matrix by this shared matrix only when its handle is in +`[first_handle, first_handle+count)`. The sibling setters are: + +- `0x22a`: current scale, three integer percentages divided by 100; +- `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). + +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 +zoom percentage from `G[0xccc09]`. Thus the map layer is centered/scaled while handles `>=50000`—the dungeon +UI—remain screen-fixed. FIELD's sole `0x22d` call animates a zoom over 300 ms. LOOK reuses the immediate +camera helper. Across the corpus, `0x229` occurs 693 times in 309 scripts: 590 all-zero disables, 101 +identity-range selections, and the two FIELD/LOOK camera selections. Correcting the contract therefore +removes spurious object-zero mutations without changing established ADV output. + ### 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`; @@ -1048,7 +1103,7 @@ annotated in Ghidra, saved. | 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 | -| `0x229` | `gfx_op_0x229_set_position` (`FUN_00472bb0`+`FUN_00472be0`) | set object **position/geometry** immediately (`obj+0x420/0x424` + vec `obj+0x440..0x448`) | +| `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 | | `0x232` | `gfx_op_0x232_anim_color` → `gfx_worker_anim_color` | **animate color**: bit2 active, period `obj+0x220`, target `obj+0x240` → interpolator COLOR channel (ping-pong). Negative alpha/RGB preserve corresponding bytes from static color `obj+0x60`; alpha >255 clamps. Distinct from one-shot `0x202`/static `0x203` | diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 09ff845..294962f 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -594,9 +594,25 @@ Implemented through IHost.PlayModalMovieToSurface. ResourceMap.ResolveRawMovie d - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2 handler gfx_op_0x228_query_position@0x42a3a0 calls gfx_object_query_translation_target@0x47cdd0. The worker copies the complete 0xb5-dword object record, passes copied obj+0x17c to matrix4_decompose_affine@0x48d7c8, and returns its translation outputs; the decomposition reads matrix elements +0x30/+0x34/+0x38, corresponding to obj+0x1ac/+0x1b0/+0x1b4. SC0000 AE001H queries this before each 0x220 leg. C# regression covers targets (40,-20), (50,-80), (130,-100), plus the missing-object output-preservation path. -### 0x229 `u004219E0` (u004219E0, argc 5) -- **summary:** 0x229 set-position2 (handle)(op2)(x)(y)(z): set object position/geometry directly (FUN_00472bb0/be0). C# VM: sets V24. See docs/engine-re.md §SC0000 anim cluster. -- **grounding:** source=kelebek, confidence=low +### 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. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x229_set_gfx_range_transform@0x423700 first calls gfx_range_transform_reset@0x472b80, then writes operands 1/2 to retained-gfx owner+0x420/+0x424 and operands 3..5 to the embedded transform object's anchor at owner+0x440..+0x448. gfx_object_composite@0x47f650 post-multiplies the sampled owner+0xb5b4 matrix only for handles in that selected range. Corpus: 693 calls/309 scripts; 590 disable with all zeroes, 101 select from handle 1 with a script-computed count, and FIELD/LOOK supply camera anchors. This supersedes the former incorrect per-object-position interpretation; per-object direct position is 0x22f. + +### 0x22a `set-gfx-range-scale-current` (set-gfx-range-scale-current, argc 3) +- **summary:** (scale_x_percent)(scale_y_percent)(scale_z_percent) — immediately replace the selected retained-gfx range transform's current scale matrix. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x22a_set_gfx_range_scale_current@0x4237b0 divides all three operands by 100 and calls gfx_range_transform_set_scale_current@0x472c10, which builds owner+0x494. FIELD and LOOK each call it once after 0x229/0x22c; FIELD's zoom percent is G[0xccc09]. + +### 0x22c `set-gfx-range-translation-current` (set-gfx-range-translation-current, argc 3) +- **summary:** (translate_x)(translate_y)(translate_z) — immediately replace the selected retained-gfx range transform's current translation matrix. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x22c_set_gfx_range_translation_current@0x423900 passes the three integer operands as floats to gfx_range_transform_set_translation_current@0x472d00, which builds owner+0x594. FIELD computes (400-camera_x, 300-camera_y, 0), making the selected map anchor land at screen center; LOOK uses the same camera helper. + +### 0x22d `set-gfx-range-scale-target` (set-gfx-range-scale-target, argc 5) +- **summary:** (delay_ms)(duration_ms)(scale_x_percent)(scale_y_percent)(scale_z_percent) — animate the selected retained-gfx range transform's scale from its current matrix to the target. +- **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 `u00421DD0` (u00421DD0, argc 5) - **summary:** 0x22f set-position (handle)(op2)(x)(y)(z): set the object base position (direct transform, not ping-pong). Worker gfx_worker_set_translation @0x472e90. C# VM: sets V24. See docs/engine-re.md §SC0000 anim cluster. @@ -658,6 +674,11 @@ The setter get-or-creates the object and writes the complete operand. During ret - **depends on:** 0x242 - **evidence:** Ghidra handler 0x4182d0: if !(ctx+0x51b80 & 2), set retained-gfx owner+0xb55c (EngineCtx+0x51b70)=1 and zero owner+0xb564/+0xb568. gfx_object_apply_transform_channels treats force value 1 as immediate completion unless obj+0x2d0 bit 0 is set. SC0000 label_1235a calls it before present-frame. +### 0x249 `load-raw-texture-surface` (load-raw-texture-surface, argc 3) +- **summary:** Load an AGF by universal packed SYS4INI/AAI catalog id into a retained surface slot using native surface mode 1 and the same RGB colorkey contract as set-texture (0x1f9). +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x249_load_raw_texture_surface@0x424b20 is instruction-length 7 and is contract-identical to gfx_op_0x1f9_load_surface through release, asset_open_indexed_entry, RGB colorkey conversion, load failure, and cleanup. Its mode-1 gfx_surface_mode1_ctor selects a tiled large-image wrapper: gfx_tiled_surface_create@0x432ff0 splits the logical dimensions into DAT_005b15b0-sized ordinary mode-0 child textures; gfx_tiled_surface_upload_agf@0x431a10 decodes and uploads each region; gfx_tiled_surface_blit@0x4316b0 subdivides a requested logical source rectangle across those tiles. It is not a spritesheet interpretation or alternate blend mode, so the port's contiguous CPU image is behaviorally equivalent. Corpus literals are universal raw indexes, including FIELD 0x32da..0x32dd -> SO005/SO007/SO008A/SO007A, and therefore bypass scene-section normalization. + ## input ### 0x86 `set-cursor-resource` (u0041B210, argc 1) @@ -1181,18 +1202,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 -### 0x22a `u00421A90` (u00421A90, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x22c `u00421BD0` (u00421BD0, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x22d `u00421C60` (u00421C60, argc 5) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x230 `u00421E70` (u00421E70, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -1221,10 +1230,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 -### 0x249 `u00422EB0` (u00422EB0, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x24d `u00422E90` (u00422E90, argc 12) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 1aea23a..374a929 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -383,6 +383,39 @@ requires them. Completion evidence combines original-game observation, executed-opcode/call traces, visible map/UI output, and before/after global-state comparisons for the action. +### DEBUGMAP field-entry result (2026-07-21; manually validated) + +The shipped `DEBUGMAP.BIN` path is useful for the first bounded field slice, but it is not treated as a +replacement for the natural campaign path. It performs substantial script-authored setup itself: it creates +the test units, writes stage id `0xa5` to `G[0x4dfbc]`, fills the field-mode globals, writes system-flow +request `G[0]=3`, and returns so SYSTEM4 enters `FIELD.BIN`. Runs launched from TITLE also retain the real +SYSTEM4/INIT tables. A future discrepancy in party, inventory, stage, or progression state may still be an +unpublished debug-level prerequisite; do not invent a seed unless its missing producer is proven. + +The first observed field discrepancy was a black central map while the surrounding field UI and minimap +input remained alive. `DRAWMAP.BIN` is 23/23 opcodes handled and `RENDERMAP.BIN` is 31/31. FIELD's missing +opcode `0x249` loads universal raw map sheets `0x32da..0x32dd` +(`SO005`/`SO007`/`SO008A`/`SO007A`) into surfaces `0x3e..0x41`, after which DRAWMAP binds its generated tile +objects to those surfaces. Skipping the loader left valid retained objects pointing at empty surfaces. + +The first retest after adding `0x249` showed the complete SO005 sheet enlarged over a grey field. The native +mode-1 class is now fully identified as a large-image tiled wrapper over the same decoded pixels, ruling out +a special spritesheet or blend interpretation. Two independent presentation gaps caused the retest: + +- native treats a zero-area draw-texture source rectangle as an empty draw; the port incorrectly expanded + it to the entire source image, exposing FIELD's intentionally invisible SO005 prototype object; +- FIELD's camera depends on the shared retained-object range transform. Corrected `0x229` selects the map + handle range and anchor (it is not a per-object position opcode), while newly implemented `0x22a`, + `0x22c`, and `0x22d` apply immediate zoom, immediate translation, and animated zoom to that range without + moving the surrounding UI. + +The related native `0x22b`/`0x22e` range-rotation setters have zero Himegari corpus calls and need no runtime +implementation yet. The remaining `DRAWMINIMAP` gap is `0x207` (eight calls) and is confined to minimap +work; FIELD's other 14 static gaps do not produce the main terrain layer. Installed-asset decode, +range-isolation/animation, VM dispatch, full engine tests, and the threaded Godot selftest pass. Manual +acceptance confirms that DEBUGMAP now displays the dungeon map correctly; the earlier full-sheet overlay is +gone and the field presentation remains operational after the camera-transform correction. + ## Later Phase B breadth Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks: diff --git a/engine/Age.Engine.Tests/AgfDecoderTests.cs b/engine/Age.Engine.Tests/AgfDecoderTests.cs index f62ec4e..266a317 100644 --- a/engine/Age.Engine.Tests/AgfDecoderTests.cs +++ b/engine/Age.Engine.Tests/AgfDecoderTests.cs @@ -81,6 +81,22 @@ public class AgfDecoderTests Assert.Contains(image.Pixels.Where((_, i) => (i & 3) == 3), a => a is > 0 and < 255); } + [Theory] + [InlineData(0x32da, "SO005.AGF")] + [InlineData(0x32db, "SO007.AGF")] + [InlineData(0x32dc, "SO008A.AGF")] + [InlineData(0x32dd, "SO007A.AGF")] + public void InstalledFieldMapSheetsResolveAndDecodeByRawCatalogIndex(int rawId, string name) + { + var resources = ResourceMap.Load(); + var asset = resources.ResolveRawTexture(rawId); + Assert.NotNull(asset); + Assert.Equal(name, asset.Name); + var image = resources.DecodeTexture(asset); + Assert.True(image.Width > 0); + Assert.True(image.Height > 0); + } + private sealed class MemoryStore(byte[] bytes) : IAssetStore { public Stream Open(AssetEntry entry) => new MemoryStream(bytes, writable: false); diff --git a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs index 1439588..3daf219 100644 --- a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs +++ b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs @@ -102,6 +102,8 @@ public class GfxCommandBufferTests => (0x1fb, new[] { G(handle), G(slot), I(0), I(0), G(w), G(h), G(dx), G(dy) }); private static (int, Operand[]) SetTex(int resId, int slot) => (0x1f9, new[] { G(resId), G(slot), I(0) }); + private static (int, Operand[]) SetRawTex(long resId, long slot, long colorKey) + => (0x249, new[] { I(resId), I(slot), I(colorKey) }); [Fact] public void SetThenDrawTextureMakesAVisibleObjectFromTheSurface() @@ -122,4 +124,26 @@ public class GfxCommandBufferTests Assert.Equal(0x25, vis[0].SurfaceResId); // resolved from the object's live source slot Assert.Equal((800, 600, 0, 0), (vis[0].W, vis[0].H, vis[0].DstX, vis[0].DstY)); } + + [Fact] + public void RawTextureLoadBypassesSceneResourceNormalizationAndFeedsRetainedDraws() + { + var t = T(); + var scene = ScriptAssembler.Assemble(t, "RAW-GFX", new List<(int, Operand[])> + { + SetRawTex(0x32da, 0x3e, 0), + (0x1fb, new[] { I(0x100), I(0x3e), I(0), I(0), I(100), I(100), I(20), I(30) }), + Exit(), + }, System.Array.Empty()); + var host = new RecordingHost { TextureResourceIdOffset = 0x1000 }; + var vm = new VirtualMachine(scene, t, host); + + vm.Run(); + + Assert.Equal((0x32daL, 0x3e), Assert.Single(host.Textures)); + var visible = Assert.Single(vm.Gfx.SnapshotVisibleObjects()); + Assert.Equal(0x32da, visible.SurfaceResId); + Assert.Equal(0, visible.ColorKey); + Assert.Equal((100, 100, 20, 30), (visible.W, visible.H, visible.DstX, visible.DstY)); + } } diff --git a/engine/Age.Engine.Tests/GfxRangeTransformTests.cs b/engine/Age.Engine.Tests/GfxRangeTransformTests.cs new file mode 100644 index 0000000..4a3b212 --- /dev/null +++ b/engine/Age.Engine.Tests/GfxRangeTransformTests.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class GfxRangeTransformTests +{ + [Fact] + public void RangeCameraCentersItsAnchorAndLeavesUiHandlesUnchanged() + { + var gfx = new GfxState(); + gfx.SetSurface(1, 1, -1); + gfx.BindDraw(10, 1, 0, 0, 1, 1, 500, 350); // selected map object at the camera anchor + gfx.BindDraw(20, 1, 0, 0, 1, 1, 500, 350); // screen-fixed UI object outside the range + gfx.SetRangeTransform(10, 1, (500, 350, 0)); + gfx.SetRangeTranslationCurrent((-100, -50, 0)); + gfx.SetRangeScaleCurrent((150, 150, 100)); + + var objects = gfx.SnapshotVisibleObjects(); + var map = objects.Single(x => x.Handle == 10); + var ui = objects.Single(x => x.Handle == 20); + var mapOrigin = Transform2DMath.Build(map.Transform).FromLocalOrigin(map.DstX, map.DstY) + .Then(map.RangeTransform!.Value).Apply(0, 0); + var uiOrigin = Transform2DMath.Build(ui.Transform).FromLocalOrigin(ui.DstX, ui.DstY).Apply(0, 0); + + Assert.Equal((400.0, 300.0), mapOrigin); + Assert.Null(ui.RangeTransform); + Assert.Equal((500.0, 350.0), uiOrigin); + } + + [Fact] + public void RangeScaleTargetUsesTheOrdinaryOneShotClock() + { + var gfx = new GfxState(); + gfx.SetSurface(1, 1, -1); + gfx.BindDraw(10, 1, 0, 0, 1, 1, 410, 300); + gfx.SetRangeTransform(10, 1, (400, 300, 0)); + gfx.SetRangeScaleCurrent((100, 100, 100)); + gfx.SetRangeScaleChannel(delayMs: 0, durationMs: 300, percent: (200, 200, 100)); + + gfx.SnapshotVisibleObjects(1000); // seeds the native-style shared channel start + var halfway = gfx.SnapshotVisibleObjects(1150).Single(); + var p = Transform2DMath.Build(halfway.Transform).FromLocalOrigin(halfway.DstX, halfway.DstY) + .Then(halfway.RangeTransform!.Value).Apply(0, 0); + + Assert.Equal((415.0, 300.0), p); + Assert.True(gfx.HasActiveTimedPresentation(1150)); + gfx.SnapshotVisibleObjects(1300); + Assert.False(gfx.HasActiveTimedPresentation(1300)); + } + + [Fact] + public void Opcode229SelectsRangeWithoutCreatingAnOrdinaryObject() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "RANGE", new List<(int, Operand[])> + { + (0x229, new[] { I(1), I(100), I(400), I(300), I(0) }), + (0x22c, new[] { I(0), I(0), I(0) }), + (0x22a, new[] { I(100), I(100), I(100) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var vm = new VirtualMachine(script, table, new RecordingHost()); + + vm.Run(); + + Assert.Null(vm.Gfx.TryGet(1)); + vm.Gfx.SetSurface(1, 1, -1); + vm.Gfx.BindDraw(1, 1, 0, 0, 1, 1, 400, 300); + Assert.NotNull(vm.Gfx.SnapshotVisibleObjects().Single().RangeTransform); + } + + private static Operand I(long v) => new(0, v); +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 3d30cdb..d3cdff7 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -42,12 +42,15 @@ internal class RecordingHost : IHost public readonly List ClearedRenderTargets = new(); public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new(); public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new(); + public readonly List<(long Resource, int Slot)> Textures = new(); public readonly List MessageSkipChanges = new(); public readonly List PhysicalMessageSkipChanges = new(); public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); public int CursorClearCount; public int SceneContextResets; + public long TextureResourceIdOffset; + public long ResolveTextureResourceId(long resourceId) => resourceId + TextureResourceIdOffset; public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y)); public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) @@ -123,7 +126,7 @@ internal class RecordingHost : IHost public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) => SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument)); public void CreateTexture(int slot, int w, int h) { } - public void SetTexture(long resId, int slot) { } + public void SetTexture(long resId, int slot) => Textures.Add((resId, slot)); public void ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot); public void ReleaseSurfaceRange(int firstSlot, int count) => ReleasedSurfaceRanges.Add((firstSlot, count)); public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 549164a..a00f240 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -38,7 +38,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long int Alpha, long Tint, int TintStrength, BlendKind Blend, bool MultiplyTint, SurfaceTransitionState? SurfaceTransition = null, - ColorTransitionState? ColorTransition = null); + ColorTransitionState? ColorTransition = null, + Affine2D? RangeTransform = null); /// Host-agnostic model of the AGE native gfx command-buffer (reversed in /// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the @@ -119,6 +120,12 @@ public sealed class GfxState // returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet. private readonly Dictionary _objects = new(); + // Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled + // matrix is post-multiplied onto only the selected handle range during native composition. FIELD uses + // this as its map camera while the surrounding dungeon UI remains screen-fixed. + private long _rangeTransformFirst, _rangeTransformCount; + private GfxObject _rangeTransform = new(); + private readonly Dictionary _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0 public long CurrentObject { get; private set; } /// EngineCtx+0x14e08, selected by op 0x80 and used by op 0x1d9 when its slot is zero. @@ -155,6 +162,44 @@ public sealed class GfxState lock (_lock) DefaultObjectSlot = slot; } + /// Op 0x229: reset the embedded range transform, select [first, first+count), and set its + /// anchor/pivot. This does not create or mutate an ordinary retained object. + public void SetRangeTransform(long first, long count, (long X, long Y, long Z) anchor) + { + lock (_lock) + { + _rangeTransformFirst = first; + _rangeTransformCount = System.Math.Max(0, count); + _rangeTransform = new GfxObject { V18 = anchor }; + } + } + + /// Op 0x22a: immediately replace the embedded range transform's current scale. + public void SetRangeScaleCurrent((long X, long Y, long Z) percent) + { + lock (_lock) + _rangeTransform.ScaleCurrent = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0); + } + + /// Op 0x22c: immediately replace the embedded range transform's current translation. + public void SetRangeTranslationCurrent((long X, long Y, long Z) translation) + { + lock (_lock) _rangeTransform.TranslationCurrent = translation; + } + + /// Op 0x22d: arm the range transform's delayed one-shot scale target. + public void SetRangeScaleChannel(long delayMs, long durationMs, (long X, long Y, long Z) percent) + { + lock (_lock) + { + _rangeTransform.ScaleDelayMs = delayMs; + _rangeTransform.ScaleDurationMs = durationMs; + _rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0); + _rangeTransform.ScaleEnabled = durationMs > 0; + _rangeTransform.OneShotStartMs = -1; + } + } + /// Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination. public bool CloneObject(long sourceHandle, long destinationHandle) { @@ -257,6 +302,9 @@ public sealed class GfxState _surfaceTransitions.Clear(); CurrentObject = 0; CurrentRenderTargetSlot = -1; + _rangeTransformFirst = 0; + _rangeTransformCount = 0; + _rangeTransform = new GfxObject(); AnimClockDurationTicks = 0; AnimClockGeneration++; } @@ -424,6 +472,8 @@ public sealed class GfxState { lock (_lock) return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) || + _rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled || + _rangeTransform.TranslationEnabled || _objects.Values.Any(o => o.Visible && (o.OneShotAnimationControlFlags & 1) == 0 && (o.OneShotColorEnabled || o.ScaleEnabled || @@ -441,42 +491,48 @@ public sealed class GfxState /// marked by op 0x242 bit 0 keep sampling asynchronously. private void ForceCompleteOneShotChannels() { + CommitOneShotChannels(_rangeTransform); foreach (var o in _objects.Values) { if ((o.OneShotAnimationControlFlags & 1) != 0) continue; - if (o.OneShotColorEnabled) - { - o.Color = o.OneShotColorTarget & 0xffffffff; - o.OneShotColorTarget = -1; - o.ColorDelayMs = 0; - o.ColorDurationMs = 0; - o.OneShotColorEnabled = false; - } - if (o.ScaleEnabled) - { - o.ScaleCurrent = o.ScaleTarget; - o.ScaleDelayMs = 0; - o.ScaleDurationMs = 0; - o.ScaleEnabled = false; - } - if (o.RotationChannelEnabled) - { - o.RotationCurrent = o.RotationTarget; - o.RotationDelayMs = 0; - o.RotationDurationMs = 0; - o.RotationChannelEnabled = false; - } - if (o.TranslationEnabled) - { - o.TranslationCurrent = o.TranslationTarget; - o.TranslationDelayMs = 0; - o.TranslationDurationMs = 0; - o.TranslationEnabled = false; - } - o.OneShotStartMs = -1; + CommitOneShotChannels(o); } } + private static void CommitOneShotChannels(GfxObject o) + { + if (o.OneShotColorEnabled) + { + o.Color = o.OneShotColorTarget & 0xffffffff; + o.OneShotColorTarget = -1; + o.ColorDelayMs = 0; + o.ColorDurationMs = 0; + o.OneShotColorEnabled = false; + } + if (o.ScaleEnabled) + { + o.ScaleCurrent = o.ScaleTarget; + o.ScaleDelayMs = 0; + o.ScaleDurationMs = 0; + o.ScaleEnabled = false; + } + if (o.RotationChannelEnabled) + { + o.RotationCurrent = o.RotationTarget; + o.RotationDelayMs = 0; + o.RotationDurationMs = 0; + o.RotationChannelEnabled = false; + } + if (o.TranslationEnabled) + { + o.TranslationCurrent = o.TranslationTarget; + o.TranslationDelayMs = 0; + o.TranslationDurationMs = 0; + o.TranslationEnabled = false; + } + o.OneShotStartMs = -1; + } + /// Whether sampling the retained scene at a later frame can change its pixels without another /// VM mutation. Includes finite presentation work plus the ambient channels that may remain active while /// the interpreter is parked at an input wait. Static waits themselves are deliberately not animation. @@ -484,6 +540,8 @@ public sealed class GfxState { lock (_lock) return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) || + _rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled || + _rangeTransform.TranslationEnabled || _objects.Values.Any(o => o.Visible && (o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled || @@ -651,7 +709,8 @@ public sealed class GfxState /// Visible objects in ascending-handle order (= z-order), each with its source surface resolved /// and its active channels interpolated at . Position is the base V24 (a direct - /// transform, ops 0x22f/0x229); scale and translation are independent one-shot matrix channels. The + /// transform (op 0x22f); scale and translation are independent one-shot matrix channels. Ops + /// 0x229-0x22e contribute a second sampled matrix only to their selected handle range. The /// src-rect channel (0x239/0x231) selects the spritesheet cell; the color channel (0x232) ping-pongs the /// alpha/tint. Channel Start fields seed to nowMs on first sight. public IReadOnlyList SnapshotVisibleObjects(long nowMs) @@ -659,6 +718,28 @@ public sealed class GfxState lock (_lock) { var list = new List(); + Affine2D? rangeAffine = null; + if (_rangeTransformCount > 0) + { + var r = _rangeTransform; + bool hadRangeOneShot = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled; + if (hadRangeOneShot && r.OneShotStartMs < 0) r.OneShotStartMs = nowMs; + var rangeScale = SampleMatrixChannel(ref r.ScaleCurrent, r.ScaleTarget, r.ScaleDelayMs, + r.ScaleDurationMs, r.OneShotStartMs, ref r.ScaleEnabled, nowMs); + var rangeRotation = SampleRotationChannel(ref r.RotationCurrent, r.RotationTarget, + r.RotationDelayMs, r.RotationDurationMs, r.OneShotStartMs, + ref r.RotationChannelEnabled, nowMs); + var rangeTranslation = SampleMatrixChannel(ref r.TranslationCurrent, r.TranslationTarget, + r.TranslationDelayMs, r.TranslationDurationMs, r.OneShotStartMs, + ref r.TranslationEnabled, nowMs); + if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled) + r.OneShotStartMs = -1; + rangeAffine = Transform2DMath.Build(new TransformState( + rangeScale.X, rangeScale.Y, rangeScale.Z, + rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z, + r.V18.X, r.V18.Y, r.V18.Z, + rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle)); + } foreach (var kv in _objects.OrderBy(k => k.Key)) { var o = kv.Value; @@ -762,6 +843,9 @@ public sealed class GfxState SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st) ? SampleTransition(st, nowMs) : null; + Affine2D? objectRangeTransform = rangeAffine is { } ra && + kv.Key >= _rangeTransformFirst && kv.Key - _rangeTransformFirst < _rangeTransformCount + ? ra : null; list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h, (int)o.V24.X, (int)o.V24.Y, new TransformState(scale.X, scale.Y, scale.Z, @@ -771,7 +855,8 @@ public sealed class GfxState new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs, o.RotationAxis.X, o.RotationAxis.Y, o.RotationAxis.Z, cycleAngle), - alpha, tint, strength, blend, multiplyTint, transition, colorTransition)); + alpha, tint, strength, blend, multiplyTint, transition, + colorTransition, objectRangeTransform)); } return list; } diff --git a/engine/Age.Engine/Model/Transform2DMath.cs b/engine/Age.Engine/Model/Transform2DMath.cs index 297411c..7bd55d2 100644 --- a/engine/Age.Engine/Model/Transform2DMath.cs +++ b/engine/Age.Engine/Model/Transform2DMath.cs @@ -12,6 +12,17 @@ public readonly record struct Affine2D(double XX, double XY, double YX, double Y return new(XX, XY, YX, YY, p.X, p.Y); } + /// Compose this row-vector transform followed by . Native uses this + /// order when it post-multiplies an object's matrix by the selected retained-gfx range transform. + public Affine2D Then(Affine2D next) + => new( + XX * next.XX + XY * next.YX, + XX * next.XY + XY * next.YY, + YX * next.XX + YY * next.YX, + YX * next.XY + YY * next.YY, + TX * next.XX + TY * next.YX + next.TX, + TX * next.XY + TY * next.YY + next.TY); + public bool TryInverse(out Affine2D inverse) { double det = XX * YY - XY * YX; diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 4653c2e..6c5d5df 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -41,10 +41,10 @@ public sealed class ResourceMap return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; } - /// Resolve an already-normalized raw catalog id without applying a scene section base. + /// Resolve an already-normalized packed catalog id without applying a scene section base. public AssetEntry? ResolveRawTexture(long rawId) { - var entry = _catalog.ResolveRaw(rawId); + var entry = _catalog.ResolvePacked(rawId); return entry is { IsPlaceholder: false } && entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 7d32881..47d650d 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1328,6 +1328,20 @@ public sealed class VirtualMachine _host.SetTexture(resolvedResourceId, (int)Read(a[1])); return pc + 1; // host still tracks dims for get-texture-size } + case "u00422EB0": // pre-reference compatibility + case "load-raw-texture-surface": // 0x249 (raw catalog id)(slot)(colorkey) + { + // Native shares 0x1f9's release/load/colorkey path, but constructs its mode-1 + // surface subclass and receives an already-global SYS4INI catalog index. The + // CPU compositor does not need the D3D subclass distinction; it does need the + // resource id to bypass the executing script's scene-section normalization. + long rawResourceId = Read(a[0]); + int surfaceSlot = (int)Read(a[1]); + _host.ReleaseSurface(surfaceSlot); + Gfx.SetSurface(surfaceSlot, rawResourceId, Read(a[2])); + _host.SetTexture(rawResourceId, surfaceSlot); + return pc + 1; + } case "draw-texture": // 0x1fb (handle)(slot)(srcX)(srcY)(w)(h)(dstX)(dstY) — bind object -> surface + rect + pos Gfx.BindDraw(Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7])); @@ -1445,8 +1459,21 @@ public sealed class VirtualMachine // ---- 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 "u004219E0": // 0x229 set-position2: same shape, direct position Gfx.GetOrCreate(Read(a[0])).V24 = (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]))); + return pc + 1; + case "u00421A90": // pre-reference compatibility + case "set-gfx-range-scale-current": // 0x22a (sx%)(sy%)(sz%) + Gfx.SetRangeScaleCurrent((Read(a[0]), Read(a[1]), Read(a[2]))); return pc + 1; + case "u00421BD0": // pre-reference compatibility + case "set-gfx-range-translation-current": // 0x22c (tx)(ty)(tz) + Gfx.SetRangeTranslationCurrent((Read(a[0]), Read(a[1]), Read(a[2]))); return pc + 1; + case "u00421C60": // pre-reference compatibility + 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 "u004223C0": // 0x239 spritesheet cell: (handle)(delay)(duration)(frame count)(columns)(cell) Gfx.SetSrcRect(Read(a[0]), Read(a[3]), Read(a[4]), Read(a[5]), 0); return pc + 1; case "u00421EA0": // 0x231 looping spritesheet: (handle)(ms per frame)(frame count)(columns) diff --git a/godot/Main.cs b/godot/Main.cs index fbe78d2..2ee98ba 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -660,6 +660,8 @@ public partial class Main : Godot.Control var t = v.Transform; var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation); var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY); + if (v.RangeTransform is { } rangeTransform) + localToDest = localToDest.Then(rangeTransform); var projected = localToDest.Apply(0, 0); int dstX = (int)System.Math.Round(projected.X); int dstY = (int)System.Math.Round(projected.Y); @@ -869,6 +871,8 @@ public partial class Main : Godot.Control if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null) continue; var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY); + if (source.RangeTransform is { } rangeTransform) + affine = affine.Then(rangeTransform); float opacity = source.Alpha / 255f * (float)transition.Progress; if (source.SurfaceResId == 0) { @@ -927,6 +931,10 @@ public partial class Main : Godot.Control Age.Engine.Model.Affine2D localToDest, float alpha = 1f, bool multiplyTint = false, bool dynamic = false, BlendKind blend = BlendKind.Alpha) { + // Native gfx_object_blit_d3d9 clips the explicit source rectangle and returns without drawing when + // right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005; + // expanding those dimensions to the full texture leaks the entire spritesheet onto the map. + if (w <= 0 || h <= 0) return; var cacheKey = (assetId, colorKey); int sourceWidth, sourceHeight; byte[] sourcePixels; @@ -961,8 +969,8 @@ public partial class Main : Godot.Control sourcePixels = cached.Rgba; } - int sw = w > 0 ? w : sourceWidth; - int sh = h > 0 ? h : sourceHeight; + int sw = w; + int sh = h; sw = System.Math.Min(sw, sourceWidth - srcX); sh = System.Math.Min(sh, sourceHeight - srcY); if (sw <= 0 || sh <= 0) return; diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 133085d..893eb39 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -5804,146 +5804,146 @@ observed_types = ["g-int", "l-int"] [[opcode]] op = 0x229 -label = "u004219E0" +label = "set-gfx-range-transform" argc = 5 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004219E0" +name = "set-gfx-range-transform" category = "draw" -summary = "0x229 set-position2 (handle)(op2)(x)(y)(z): set object position/geometry directly (FUN_00472bb0/be0). C# VM: sets V24. See docs/engine-re.md §SC0000 anim cluster." +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." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x229_set_gfx_range_transform@0x423700 first calls gfx_range_transform_reset@0x472b80, then writes operands 1/2 to retained-gfx owner+0x420/+0x424 and operands 3..5 to the embedded transform object's anchor at owner+0x440..+0x448. gfx_object_composite@0x47f650 post-multiplies the sampled owner+0xb5b4 matrix only for handles in that selected range. Corpus: 693 calls/309 scripts; 590 disable with all zeroes, 101 select from handle 1 with a script-computed count, and FIELD/LOOK supply camera anchors. This supersedes the former incorrect per-object-position interpretation; per-object direct position is 0x22f." [[opcode.semantics.args]] i = 1 -role = "" +role = "first affected object handle" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "affected handle count; zero disables" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "range-transform anchor X" observed_types = ["imm", "g-int"] [[opcode.semantics.args]] i = 4 -role = "" +role = "range-transform anchor Y" observed_types = ["imm", "g-int"] [[opcode.semantics.args]] i = 5 -role = "" +role = "range-transform anchor Z" observed_types = ["imm"] [[opcode]] op = 0x22a -label = "u00421A90" +label = "set-gfx-range-scale-current" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00421A90" -category = "unknown" -summary = "" +name = "set-gfx-range-scale-current" +category = "draw" +summary = "(scale_x_percent)(scale_y_percent)(scale_z_percent) — immediately replace the selected retained-gfx range transform's current scale matrix." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x22a_set_gfx_range_scale_current@0x4237b0 divides all three operands by 100 and calls gfx_range_transform_set_scale_current@0x472c10, which builds owner+0x494. FIELD and LOOK each call it once after 0x229/0x22c; FIELD's zoom percent is G[0xccc09]." [[opcode.semantics.args]] i = 1 -role = "" +role = "X scale percent" observed_types = ["g-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "Y scale percent" observed_types = ["g-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "Z scale percent" observed_types = ["imm"] [[opcode]] op = 0x22c -label = "u00421BD0" +label = "set-gfx-range-translation-current" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00421BD0" -category = "unknown" -summary = "" +name = "set-gfx-range-translation-current" +category = "draw" +summary = "(translate_x)(translate_y)(translate_z) — immediately replace the selected retained-gfx range transform's current translation matrix." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x22c_set_gfx_range_translation_current@0x423900 passes the three integer operands as floats to gfx_range_transform_set_translation_current@0x472d00, which builds owner+0x594. FIELD computes (400-camera_x, 300-camera_y, 0), making the selected map anchor land at screen center; LOOK uses the same camera helper." [[opcode.semantics.args]] i = 1 -role = "" +role = "translation X" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "translation Y" observed_types = ["l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "translation Z" observed_types = ["imm"] [[opcode]] op = 0x22d -label = "u00421C60" +label = "set-gfx-range-scale-target" argc = 5 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00421C60" -category = "unknown" -summary = "" +name = "set-gfx-range-scale-target" +category = "draw" +summary = "(delay_ms)(duration_ms)(scale_x_percent)(scale_y_percent)(scale_z_percent) — animate the selected retained-gfx range transform's scale from its current matrix to the target." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +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." [[opcode.semantics.args]] i = 1 -role = "" +role = "delay milliseconds" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "duration milliseconds" observed_types = ["imm"] [[opcode.semantics.args]] i = 3 -role = "" +role = "target X scale percent" observed_types = ["g-int"] [[opcode.semantics.args]] i = 4 -role = "" +role = "target Y scale percent" observed_types = ["g-int"] [[opcode.semantics.args]] i = 5 -role = "" +role = "target Z scale percent" observed_types = ["imm"] [[opcode]] @@ -6509,33 +6509,33 @@ observed_types = ["imm"] [[opcode]] op = 0x249 -label = "u00422EB0" +label = "load-raw-texture-surface" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00422EB0" -category = "unknown" -summary = "" +name = "load-raw-texture-surface" +category = "draw" +summary = "Load an AGF by universal packed SYS4INI/AAI catalog id into a retained surface slot using native surface mode 1 and the same RGB colorkey contract as set-texture (0x1f9)." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x249_load_raw_texture_surface@0x424b20 is instruction-length 7 and is contract-identical to gfx_op_0x1f9_load_surface through release, asset_open_indexed_entry, RGB colorkey conversion, load failure, and cleanup. Its mode-1 gfx_surface_mode1_ctor selects a tiled large-image wrapper: gfx_tiled_surface_create@0x432ff0 splits the logical dimensions into DAT_005b15b0-sized ordinary mode-0 child textures; gfx_tiled_surface_upload_agf@0x431a10 decodes and uploads each region; gfx_tiled_surface_blit@0x4316b0 subdivides a requested logical source rectangle across those tiles. It is not a spritesheet interpretation or alternate blend mode, so the port's contiguous CPU image is behaviorally equivalent. Corpus literals are universal raw indexes, including FIELD 0x32da..0x32dd -> SO005/SO007/SO008A/SO007A, and therefore bypass scene-section normalization." [[opcode.semantics.args]] i = 1 -role = "" +role = "universal packed SYS4INI/AAI catalog id" observed_types = ["imm", "l-int", "l-ptr"] [[opcode.semantics.args]] i = 2 -role = "" +role = "surface slot" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "RGB888 colorkey; negative disables colorkey" observed_types = ["imm", "l-int"] [[opcode]]