Render DEBUGMAP tiled field surfaces

This commit is contained in:
gamer147
2026-07-21 13:02:02 -04:00
parent d5f69853d5
commit ff39c7f3f9
14 changed files with 465 additions and 112 deletions

View File

@@ -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 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. `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 *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 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 hunt for the selector (thought it was native scene state; even tried reading `G[0x62424]` live — the

View File

@@ -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 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. 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) ### 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`; 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 | | 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 | | `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` | | `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 | | `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` | | `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` |

View File

@@ -594,9 +594,25 @@ Implemented through IHost.PlayModalMovieToSurface. ResourceMap.ResolveRawMovie d
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2 handler gfx_op_0x228_query_position@0x42a3a0 calls gfx_object_query_translation_target@0x47cdd0. The worker copies the complete 0xb5-dword object record, passes copied obj+0x17c to matrix4_decompose_affine@0x48d7c8, and returns its translation outputs; the decomposition reads matrix elements +0x30/+0x34/+0x38, corresponding to obj+0x1ac/+0x1b0/+0x1b4. SC0000 AE001H queries this before each 0x220 leg. C# regression covers targets (40,-20), (50,-80), (130,-100), plus the missing-object output-preservation path. - **evidence:** Ghidra /v2 handler gfx_op_0x228_query_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) ### 0x229 `set-gfx-range-transform` (set-gfx-range-transform, 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. - **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=kelebek, confidence=low - **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) ### 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. - **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 - **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. - **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 ## input
### 0x86 `set-cursor-resource` (u0041B210, argc 1) ### 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:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **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) ### 0x230 `u00421E70` (u00421E70, argc 1)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **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:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x249 `u00422EB0` (u00422EB0, argc 3)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x24d `u00422E90` (u00422E90, argc 12) ### 0x24d `u00422E90` (u00422E90, argc 12)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low

View File

@@ -383,6 +383,39 @@ requires them.
Completion evidence combines original-game observation, executed-opcode/call traces, visible map/UI output, Completion evidence combines original-game observation, executed-opcode/call traces, visible map/UI output,
and before/after global-state comparisons for the action. 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 ## Later Phase B breadth
Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks: Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks:

View File

@@ -81,6 +81,22 @@ public class AgfDecoderTests
Assert.Contains(image.Pixels.Where((_, i) => (i & 3) == 3), a => a is > 0 and < 255); 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 private sealed class MemoryStore(byte[] bytes) : IAssetStore
{ {
public Stream Open(AssetEntry entry) => new MemoryStream(bytes, writable: false); public Stream Open(AssetEntry entry) => new MemoryStream(bytes, writable: false);

View File

@@ -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) }); => (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[]) 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] [Fact]
public void SetThenDrawTextureMakesAVisibleObjectFromTheSurface() 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(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)); 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<string>());
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));
}
} }

View File

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

View File

@@ -42,12 +42,15 @@ internal class RecordingHost : IHost
public readonly List<int> ClearedRenderTargets = new(); public readonly List<int> ClearedRenderTargets = new();
public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new(); public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new();
public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new(); public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new();
public readonly List<(long Resource, int Slot)> Textures = new();
public readonly List<bool> MessageSkipChanges = new(); public readonly List<bool> MessageSkipChanges = new();
public readonly List<bool> PhysicalMessageSkipChanges = new(); public readonly List<bool> PhysicalMessageSkipChanges = new();
public readonly List<long> CursorResources = new(); public readonly List<long> CursorResources = new();
public readonly List<bool> AdvPagePresentationSuspended = new(); public readonly List<bool> AdvPagePresentationSuspended = new();
public int CursorClearCount; public int CursorClearCount;
public int SceneContextResets; 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 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 SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y));
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) 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) public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument)
=> SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument)); => SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument));
public void CreateTexture(int slot, int w, int h) { } 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 ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot);
public void ReleaseSurfaceRange(int firstSlot, int count) => ReleasedSurfaceRanges.Add((firstSlot, count)); 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) { } public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { }

View File

@@ -38,7 +38,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long
int Alpha, long Tint, int TintStrength, BlendKind Blend, int Alpha, long Tint, int TintStrength, BlendKind Blend,
bool MultiplyTint, bool MultiplyTint,
SurfaceTransitionState? SurfaceTransition = null, SurfaceTransitionState? SurfaceTransition = null,
ColorTransitionState? ColorTransition = null); ColorTransitionState? ColorTransition = null,
Affine2D? RangeTransform = null);
/// <summary>Host-agnostic model of the AGE native gfx command-buffer (reversed in /// <summary>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 /// 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. // returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet.
private readonly Dictionary<long, GfxObject> _objects = new(); private readonly Dictionary<long, GfxObject> _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<long, long> _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0 private readonly Dictionary<long, long> _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0
public long CurrentObject { get; private set; } public long CurrentObject { get; private set; }
/// <summary>EngineCtx+0x14e08, selected by op 0x80 and used by op 0x1d9 when its slot is zero.</summary> /// <summary>EngineCtx+0x14e08, selected by op 0x80 and used by op 0x1d9 when its slot is zero.</summary>
@@ -155,6 +162,44 @@ public sealed class GfxState
lock (_lock) DefaultObjectSlot = slot; lock (_lock) DefaultObjectSlot = slot;
} }
/// <summary>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.</summary>
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 };
}
}
/// <summary>Op 0x22a: immediately replace the embedded range transform's current scale.</summary>
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);
}
/// <summary>Op 0x22c: immediately replace the embedded range transform's current translation.</summary>
public void SetRangeTranslationCurrent((long X, long Y, long Z) translation)
{
lock (_lock) _rangeTransform.TranslationCurrent = translation;
}
/// <summary>Op 0x22d: arm the range transform's delayed one-shot scale target.</summary>
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;
}
}
/// <summary>Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination.</summary> /// <summary>Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination.</summary>
public bool CloneObject(long sourceHandle, long destinationHandle) public bool CloneObject(long sourceHandle, long destinationHandle)
{ {
@@ -257,6 +302,9 @@ public sealed class GfxState
_surfaceTransitions.Clear(); _surfaceTransitions.Clear();
CurrentObject = 0; CurrentObject = 0;
CurrentRenderTargetSlot = -1; CurrentRenderTargetSlot = -1;
_rangeTransformFirst = 0;
_rangeTransformCount = 0;
_rangeTransform = new GfxObject();
AnimClockDurationTicks = 0; AnimClockDurationTicks = 0;
AnimClockGeneration++; AnimClockGeneration++;
} }
@@ -424,6 +472,8 @@ public sealed class GfxState
{ {
lock (_lock) lock (_lock)
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) || return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
_rangeTransform.TranslationEnabled ||
_objects.Values.Any(o => o.Visible && _objects.Values.Any(o => o.Visible &&
(o.OneShotAnimationControlFlags & 1) == 0 && (o.OneShotAnimationControlFlags & 1) == 0 &&
(o.OneShotColorEnabled || o.ScaleEnabled || (o.OneShotColorEnabled || o.ScaleEnabled ||
@@ -441,9 +491,16 @@ public sealed class GfxState
/// marked by op 0x242 bit 0 keep sampling asynchronously.</summary> /// marked by op 0x242 bit 0 keep sampling asynchronously.</summary>
private void ForceCompleteOneShotChannels() private void ForceCompleteOneShotChannels()
{ {
CommitOneShotChannels(_rangeTransform);
foreach (var o in _objects.Values) foreach (var o in _objects.Values)
{ {
if ((o.OneShotAnimationControlFlags & 1) != 0) continue; if ((o.OneShotAnimationControlFlags & 1) != 0) continue;
CommitOneShotChannels(o);
}
}
private static void CommitOneShotChannels(GfxObject o)
{
if (o.OneShotColorEnabled) if (o.OneShotColorEnabled)
{ {
o.Color = o.OneShotColorTarget & 0xffffffff; o.Color = o.OneShotColorTarget & 0xffffffff;
@@ -475,7 +532,6 @@ public sealed class GfxState
} }
o.OneShotStartMs = -1; o.OneShotStartMs = -1;
} }
}
/// <summary>Whether sampling the retained scene at a later frame can change its pixels without another /// <summary>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 /// VM mutation. Includes finite presentation work plus the ambient channels that may remain active while
@@ -484,6 +540,8 @@ public sealed class GfxState
{ {
lock (_lock) lock (_lock)
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) || return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
_rangeTransform.TranslationEnabled ||
_objects.Values.Any(o => o.Visible && _objects.Values.Any(o => o.Visible &&
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || (o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
o.TranslationEnabled || o.TranslationEnabled ||
@@ -651,7 +709,8 @@ public sealed class GfxState
/// <summary>Visible objects in ascending-handle order (= z-order), each with its source surface resolved /// <summary>Visible objects in ascending-handle order (= z-order), each with its source surface resolved
/// and its active channels interpolated at <paramref name="nowMs"/>. Position is the base V24 (a direct /// and its active channels interpolated at <paramref name="nowMs"/>. 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 /// 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.</summary> /// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary>
public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs) public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
@@ -659,6 +718,28 @@ public sealed class GfxState
lock (_lock) lock (_lock)
{ {
var list = new List<RenderObject>(); var list = new List<RenderObject>();
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)) foreach (var kv in _objects.OrderBy(k => k.Key))
{ {
var o = kv.Value; var o = kv.Value;
@@ -762,6 +843,9 @@ public sealed class GfxState
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st) SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
? SampleTransition(st, nowMs) : null; ? 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, list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h,
(int)o.V24.X, (int)o.V24.Y, (int)o.V24.X, (int)o.V24.Y,
new TransformState(scale.X, scale.Y, scale.Z, new TransformState(scale.X, scale.Y, scale.Z,
@@ -771,7 +855,8 @@ public sealed class GfxState
new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs, new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs,
o.RotationAxis.X, o.RotationAxis.Y, o.RotationAxis.X, o.RotationAxis.Y,
o.RotationAxis.Z, cycleAngle), o.RotationAxis.Z, cycleAngle),
alpha, tint, strength, blend, multiplyTint, transition, colorTransition)); alpha, tint, strength, blend, multiplyTint, transition,
colorTransition, objectRangeTransform));
} }
return list; return list;
} }

View File

@@ -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); return new(XX, XY, YX, YY, p.X, p.Y);
} }
/// <summary>Compose this row-vector transform followed by <paramref name="next"/>. Native uses this
/// order when it post-multiplies an object's matrix by the selected retained-gfx range transform.</summary>
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) public bool TryInverse(out Affine2D inverse)
{ {
double det = XX * YY - XY * YX; double det = XX * YY - XY * YX;

View File

@@ -41,10 +41,10 @@ public sealed class ResourceMap
return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null;
} }
/// <summary>Resolve an already-normalized raw catalog id without applying a scene section base.</summary> /// <summary>Resolve an already-normalized packed catalog id without applying a scene section base.</summary>
public AssetEntry? ResolveRawTexture(long rawId) public AssetEntry? ResolveRawTexture(long rawId)
{ {
var entry = _catalog.ResolveRaw(rawId); var entry = _catalog.ResolvePacked(rawId);
return entry is { IsPlaceholder: false } && return entry is { IsPlaceholder: false } &&
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
} }

View File

@@ -1328,6 +1328,20 @@ public sealed class VirtualMachine
_host.SetTexture(resolvedResourceId, (int)Read(a[1])); _host.SetTexture(resolvedResourceId, (int)Read(a[1]));
return pc + 1; // host still tracks dims for get-texture-size 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 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]), 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])); (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") ---- // ---- 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": // 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; 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) 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; 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) case "u00421EA0": // 0x231 looping spritesheet: (handle)(ms per frame)(frame count)(columns)

View File

@@ -660,6 +660,8 @@ public partial class Main : Godot.Control
var t = v.Transform; var t = v.Transform;
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation); var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation);
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY); var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
if (v.RangeTransform is { } rangeTransform)
localToDest = localToDest.Then(rangeTransform);
var projected = localToDest.Apply(0, 0); var projected = localToDest.Apply(0, 0);
int dstX = (int)System.Math.Round(projected.X); int dstX = (int)System.Math.Round(projected.X);
int dstY = (int)System.Math.Round(projected.Y); 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) if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue; continue;
var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY); 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; float opacity = source.Alpha / 255f * (float)transition.Progress;
if (source.SurfaceResId == 0) 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, Age.Engine.Model.Affine2D localToDest, float alpha = 1f, bool multiplyTint = false,
bool dynamic = false, BlendKind blend = BlendKind.Alpha) 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); var cacheKey = (assetId, colorKey);
int sourceWidth, sourceHeight; int sourceWidth, sourceHeight;
byte[] sourcePixels; byte[] sourcePixels;
@@ -961,8 +969,8 @@ public partial class Main : Godot.Control
sourcePixels = cached.Rgba; sourcePixels = cached.Rgba;
} }
int sw = w > 0 ? w : sourceWidth; int sw = w;
int sh = h > 0 ? h : sourceHeight; int sh = h;
sw = System.Math.Min(sw, sourceWidth - srcX); sw = System.Math.Min(sw, sourceWidth - srcX);
sh = System.Math.Min(sh, sourceHeight - srcY); sh = System.Math.Min(sh, sourceHeight - srcY);
if (sw <= 0 || sh <= 0) return; if (sw <= 0 || sh <= 0) return;

View File

@@ -5804,146 +5804,146 @@ observed_types = ["g-int", "l-int"]
[[opcode]] [[opcode]]
op = 0x229 op = 0x229
label = "u004219E0" label = "set-gfx-range-transform"
argc = 5 argc = 5
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u004219E0" name = "set-gfx-range-transform"
category = "draw" 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 noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "first affected object handle"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "affected handle count; zero disables"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "range-transform anchor X"
observed_types = ["imm", "g-int"] observed_types = ["imm", "g-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 4 i = 4
role = "" role = "range-transform anchor Y"
observed_types = ["imm", "g-int"] observed_types = ["imm", "g-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 5 i = 5
role = "" role = "range-transform anchor Z"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0x22a op = 0x22a
label = "u00421A90" label = "set-gfx-range-scale-current"
argc = 3 argc = 3
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00421A90" name = "set-gfx-range-scale-current"
category = "unknown" category = "draw"
summary = "" 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 noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "X scale percent"
observed_types = ["g-int"] observed_types = ["g-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "Y scale percent"
observed_types = ["g-int"] observed_types = ["g-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "Z scale percent"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0x22c op = 0x22c
label = "u00421BD0" label = "set-gfx-range-translation-current"
argc = 3 argc = 3
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00421BD0" name = "set-gfx-range-translation-current"
category = "unknown" category = "draw"
summary = "" summary = "(translate_x)(translate_y)(translate_z) — immediately replace the selected retained-gfx range transform's current translation matrix."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "translation X"
observed_types = ["l-int"] observed_types = ["l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "translation Y"
observed_types = ["l-int"] observed_types = ["l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "translation Z"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0x22d op = 0x22d
label = "u00421C60" label = "set-gfx-range-scale-target"
argc = 5 argc = 5
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00421C60" name = "set-gfx-range-scale-target"
category = "unknown" category = "draw"
summary = "" 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 noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "delay milliseconds"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "duration milliseconds"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "target X scale percent"
observed_types = ["g-int"] observed_types = ["g-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 4 i = 4
role = "" role = "target Y scale percent"
observed_types = ["g-int"] observed_types = ["g-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 5 i = 5
role = "" role = "target Z scale percent"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode]] [[opcode]]
@@ -6509,33 +6509,33 @@ observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0x249 op = 0x249
label = "u00422EB0" label = "load-raw-texture-surface"
argc = 3 argc = 3
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00422EB0" name = "load-raw-texture-surface"
category = "unknown" category = "draw"
summary = "" 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 noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "universal packed SYS4INI/AAI catalog id"
observed_types = ["imm", "l-int", "l-ptr"] observed_types = ["imm", "l-int", "l-ptr"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "surface slot"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "RGB888 colorkey; negative disables colorkey"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode]] [[opcode]]