diff --git a/docs/engine-re.md b/docs/engine-re.md index e1c5b34..6ee39f5 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1595,6 +1595,56 @@ After the 213/213 video/audio corpus gate and clean audible LOGO/OP/CHAPTER acce `DirectShowMovieDecoder`, its COM/temp-file adapter, compatibility test, and managed Windows annotations. Native AGE's DirectShow behavior remains relevant evidence for opcode semantics; it is no longer port code. +### Movie-mask transition `0x24d` and tiled-surface edge `0x248` (2026-07-29) + +The adjacent opcode numbers do not represent one subsystem. `op_0x248_set_tiled_surface_edge_length` +(`0x424af0`) only copies its operand into `g_nTiledSurfaceEdgeLength` (`0x5b15b0`). The global's 30 +references are confined to `gfx_tiled_surface_create`, `gfx_tiled_surface_blit`, +`gfx_tiled_surface_rect_op`, and `gfx_tiled_surface_upload_agf`. Those workers partition a logical mode-1 +surface into `ceil(width / edge)` by `ceil(height / edge)` ordinary texture tiles and clip logical +operations across them. SYSTEM4's sole call sets the edge to 128 immediately after opcode `0x149`. +This is legacy texture-allocation configuration for `0x249`; a backend with contiguous RGBA surfaces may +retain the value for state parity without physically reproducing the tiling. + +`op_0x24d_play_movie_mask_transition` (`0x424db0`) is instead a 12-operand presentation operation: + +1. It allocates or reuses the scratch movie-to-texture object in operand 2 and opens operand 10 as a + universal packed resource. It routes sound with native flag value 6 and configures operand 11 as the + movie's start delay. +2. It reads `IMediaPosition::get_StopTime` and sets `put_Rate` to + `(native stop time in milliseconds) / operand 12`, retiming the complete movie to the requested + transition duration. +3. `movie_texture_allocate_transition_mask@0x415d90` allocates an operand-7 by operand-8 byte mask and + initializes it to zero when operand 9 is one, or 255 otherwise. +4. `gfx_movie_mask_transition_register@0x47f560` records a type-1 retained command keyed by operand 1. + The record identifies the old/source range (operands 3 and 4), scratch movie surface, mask mode, + start delay, and destination rectangle `{x=operand 5, y=operand 6, width=operand 7, + height=operand 8}`. + +The retained surface-command compositor's type-1 branch captures the old and new object sets, updates the +byte mask from decoded movie frames, and composites the two captures per pixel within that rectangle. It +keeps its blocking/redraw state set until the movie completes, at which point it clears the command and +mask. Operand 1 is the immediately preceding new `draw-texture` range key; operand 3 is the old range that +the script erases after its `0x21c`/`0x224` presentation boundary. + +Both shipped calls are in DEBUG.BIN. They use scratch slots 45/46, old ranges 10/20, new keys 11/21, +mode zero, zero delay, and a 1000 ms duration. Resource `0x325e` is `TEST.AGF`; its `00 00 01 BA` +header identifies an MPEG program stream despite the extension. One call uses a signed x coordinate of +-184, confirming that the position operands are not unsigned dimensions. Retail TITLE cannot enter this +post-exit developer menu, although the port deliberately exposes DEBUG through its F4 diagnostic route. + +The implementation boundary is consequently larger than ordinary movie playback or a scalar crossfade: +the port needs movie-frame luminance/mask extraction, old/new range capture, per-pixel composition, and +blocking lifetime cleanup. The two handlers and their exact helpers/global are named and commented in +the saved `/v2` Ghidra image. + +**`0x248` port implementation (2026-07-29):** `GfxState` retains the complete signed dword with native +zero initialization. The setter neither publishes a retained mutation nor rebuilds surfaces that already +exist, and scene-context reset leaves the process-global-equivalent setting intact. Opcode `0x249` +continues to create one contiguous portable RGBA surface; only AGE's obsolete physical D3D partitioning is +omitted. Focused tests cover SYSTEM4's value 128, mode-1 texture loading, signed replacement, and reset +lifetime. + ### Grey-background root cause — slot collision + tint-strength (2026-07-08, gfx-log) Diagnosed with the new `--gfx-log` compositor/op trace (docs/tools-reference.md). The grey background has diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 9a25387..4181686 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -1090,11 +1090,23 @@ 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. +### 0x248 `set-tiled-surface-edge-length` (set-tiled-surface-edge-length, argc 1) +- **summary:** Set the legacy mode-1 tiled-surface edge length in pixels. Native surface creation, blitting, rectangle operations, and AGF upload use it to partition one logical surface into ordinary texture tiles; SYSTEM4 initializes it to 128. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x248_set_tiled_surface_edge_length@0x424af0 directly stores operand 1 in g_nTiledSurfaceEdgeLength@0x5b15b0. Its 30 xrefs are confined to gfx_tiled_surface_create/blit/rect_op/upload_agf, where dimensions are split by ceil(size / edge). SYSTEM4's sole call passes 128 immediately after opcode 0x149. This configures opcode 0x249's mode-1 tiled wrapper and is unrelated to adjacent opcode 0x24d. + +Port status (2026-07-29): implemented as retained signed-dword graphics configuration. The value survives scene-context resets and does not dirty presentation or rebuild existing surfaces. The portable backend deliberately keeps mode-1 surfaces as contiguous RGBA images rather than reproducing legacy D3D texture tiling. + ### 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 packed-id/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, unchanged packed operand, asset_open_indexed_entry, RGB colorkey conversion, load failure, and cleanup. Its only relevant distinction is mode-1 gfx_surface_mode1_ctor, a tiled large-image wrapper: gfx_tiled_surface_create@0x432ff0 splits logical dimensions into ordinary mode-0 child textures; gfx_tiled_surface_upload_agf@0x431a10 decodes/uploads regions; gfx_tiled_surface_blit@0x4316b0 subdivides logical source rectangles. Corpus literals include FIELD 0x32da..0x32dd -> SO005/SO007/SO008A/SO007A. The former claim that only 0x249 bypasses scene normalization was wrong because native never performs scene normalization for 0x1f9 either. +### 0x24d `play-movie-mask-transition` (play-movie-mask-transition, argc 12) +- **summary:** Open a movie into a scratch surface, retime it to the requested duration, use its decoded frames as a per-pixel mask, and register a blocking retained-surface transition from an old object range to a newly drawn range within the supplied rectangle. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x24d_play_movie_mask_transition@0x424db0 opens operand 10 through movie_to_texture_open_asset_graph, configures delay operand 11, sets IMediaPosition::put_Rate to native_stop_time_ms / operand12_ms, allocates a width*height byte mask through movie_texture_allocate_transition_mask@0x415d90, and calls gfx_movie_mask_transition_register@0x47f560 with operands 1-9/11. The retained compositor's type-1 branch applies the movie-updated mask between the old and new object ranges and holds its blocking dirty state until playback completes. Both DEBUG.BIN sites use TEST.AGF (an MPEG program stream), delay 0, and duration 1000 ms. + ### 0x24e `set-gfx-animation-service-flags` (set-gfx-animation-service-flags, argc 1) - **summary:** Replace the retained graphics animation-service flags with operand 1. BTL brackets combat presentation with values 1 and 0; GAMECLEAR uses 3 and 0. - **grounding:** source=investigation, confidence=high @@ -2213,19 +2225,11 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting - **grounding:** source=kelebek, confidence=low - **evidence:** Not observed in Himegari's script corpus; ABI label/argc come from Kelebek's AGE table. -### 0x248 `u00422E80` (u00422E80, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x24a `u004163C0` (u004163C0, argc 3) - **summary:** Broader AGE-catalog compatibility stub; the port currently traces and skips it. - **grounding:** source=kelebek, confidence=low - **evidence:** Not observed in Himegari's script corpus; ABI label/argc come from Kelebek's AGE table. -### 0x24d `u00422E90` (u00422E90, argc 12) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x24f `u00422ED0` (u00422ED0, argc 10) - **summary:** Broader AGE-catalog compatibility stub; the port currently traces and skips it. - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 5b18968..c95ad1f 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -947,6 +947,41 @@ EngineCtx lint, a zero-warning Godot build, clean diff checking, and the Himegar **NEXT:** rerank the remaining six gaps; begin with the two-site `0x24d` and check whether adjacent `0x248` belongs to the same native subsystem before choosing the next implementation boundary. +**MOVIE-MASK/TILED-SURFACE INVESTIGATION COMPLETE (2026-07-29):** `0x24d` and `0x248` are unrelated. +`0x248(edge)` sets the legacy mode-1 tiled-surface edge length; SYSTEM4 supplies 128, while the port's +contiguous RGBA surfaces do not need to reproduce AGE's old texture-size partitioning. It is an exact, +small state-parity implementation. + +`0x24d(new_key,movie_slot,old_start,old_count,x,y,width,height,mode,movie_id,delay_ms,duration_ms)` is a +movie-driven per-pixel transition. Native retimes the MPEG to `duration_ms`, allocates a byte mask at the +requested size, and registers a blocking retained command that composites the old and new ranges through +successive movie frames. Both sites are confined to DEBUG.BIN's optional TEST.AGF pages; one uses signed +x=-184. The port's explicit F4 diagnostic route makes those pages reachable, but implementing the opcode +requires a new mask-frame/compositor seam rather than reuse of the existing scalar crossfade. + +The remaining inventory is still 6 opcodes / 8 instructions because this slice changed metadata only. +`0x248` is the sensible next implementation: it is exact and closes SYSTEM4's singleton without imposing +obsolete D3D tiling. After that, investigate the two normal CONFIG sites of `0x142` before deciding whether +the developer-only `0x24d` transition justifies its larger compositor slice. The `/v2` handlers, +transition helpers, and tiled-size global are named/commented and the program is saved. + +**NEXT:** implement `0x248` as retained graphics configuration/state parity, then investigate CONFIG's +two-site `0x142`; keep exact `0x24d` semantics documented for a later diagnostic-compositor slice. + +**TILED-SURFACE EDGE `0x248` IMPLEMENTED (2026-07-29):** the VM now retains AGE's signed-dword tile-edge +configuration in `GfxState`. It starts at zero, SYSTEM4 sets 128, later writes replace the complete value, +and scene-context reset does not clear it. Like native, the setter does not dirty presentation or rebuild +existing mode-1 surfaces. The portable `0x249` path still uses one contiguous RGBA image, preserving every +script-visible result without reproducing the legacy D3D tile allocation. + +Two focused regressions cover SYSTEM4's exact setting followed by a mode-1 texture load, native signed +replacement, and reset lifetime. The slice removes one instruction and leaves 5 effectful opcodes / 7 +instructions. Validation passes 495/495 engine tests, opcode and EngineCtx lint, a zero-warning Godot +build, clean diff checking, and the Himegari-targeted threaded `SELFTEST OK`. + +**NEXT:** investigate CONFIG's two-site `0x142`; keep developer-only `0x24d` for a later dedicated +movie-mask compositor slice. + ## Later Phase B breadth **INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static diff --git a/engine/Age.Engine.Tests/TiledSurfaceEdgeOpcodeTests.cs b/engine/Age.Engine.Tests/TiledSurfaceEdgeOpcodeTests.cs new file mode 100644 index 0000000..ec774b5 --- /dev/null +++ b/engine/Age.Engine.Tests/TiledSurfaceEdgeOpcodeTests.cs @@ -0,0 +1,44 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class TiledSurfaceEdgeOpcodeTests +{ + private static Operand I(long value) => new(0, value); + + [Fact] + public void OpcodeRetainsSystem4EdgeWhileModeOneSurfaceUsesPortableBackend() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var scene = ScriptAssembler.Assemble(table, "TILED-SURFACE", new List<(int, Operand[])> + { + (0x248, new[] { I(128) }), + (0x249, new[] { I(0x32da), I(0x3e), I(0) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(scene, table, host); + + vm.Run(); + + Assert.Equal(128, vm.Gfx.TiledSurfaceEdgeLength); + Assert.Equal((0x32daL, 0x3e), Assert.Single(host.Textures)); + var surface = Assert.Single(vm.Gfx.CapturePersistenceSnapshot().Surfaces); + Assert.Equal((0x3e, 0x32daL), (surface.Slot, surface.ResourceId)); + } + + [Fact] + public void SettingReplacesSignedDwordAndSurvivesSceneContextReset() + { + var gfx = new GfxState(); + Assert.Equal(0, gfx.TiledSurfaceEdgeLength); + + gfx.SetTiledSurfaceEdgeLength(-1); + Assert.Equal(-1, gfx.TiledSurfaceEdgeLength); + + gfx.SetTiledSurfaceEdgeLength(128); + gfx.ResetSceneContext(); + Assert.Equal(128, gfx.TiledSurfaceEdgeLength); + } +} diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index b1d43c9..c0012e2 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -205,6 +205,9 @@ public sealed class GfxState public int DefaultObjectSlot { get; private set; } /// The D3D render target selected by op 0x20d. -1 denotes the main backbuffer. public int CurrentRenderTargetSlot { get; private set; } = -1; + /// Legacy mode-1 surface tile edge selected by op 0x248. The portable backend retains + /// the native process-global value for parity but stores every surface as one contiguous image. + public int TiledSurfaceEdgeLength { get; private set; } // ---- Separate global animation service clock (op 0x238; ctx+0x51b7c total / +0x51b78 elapsed). // Retained for its opcode family; 0x21e scale and 0x220 translation use frame-time directly instead. ---- @@ -250,6 +253,13 @@ public sealed class GfxState lock (_lock) DefaultObjectSlot = slot; } + public void SetTiledSurfaceEdgeLength(long edgeLength) + { + // Native stores the complete operand in one signed dword. It does not invalidate or rebuild + // already-created mode-1 surfaces, nor does it mark the retained compositor dirty. + lock (_lock) TiledSurfaceEdgeLength = unchecked((int)edgeLength); + } + public void SetObjectAnchor(long handle, (long X, long Y, long Z) anchor) { lock (_lock) GetOrCreate(handle).V18 = anchor; diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 8c9bdd0..3f1c4e5 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -2367,6 +2367,10 @@ public sealed class VirtualMachine _host.SetTexture(resourceId, (int)Read(a[1]), colorKey); return pc + 1; // host still tracks dims for get-texture-size } + case "u00422E80": // pre-reference compatibility + case "set-tiled-surface-edge-length": // 0x248 (edge pixels) + Gfx.SetTiledSurfaceEdgeLength(Read(a[0])); + return pc + 1; case "u00422EB0": // pre-reference compatibility case "load-raw-texture-surface": // 0x249 (packed resource id)(slot)(colorkey) { diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index e94ef36..8b77347 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -6590,23 +6590,24 @@ evidence = "Ghidra handler 0x4182d0: if !(ctx+0x51b80 & 2), set retained-gfx own [[opcode]] op = 0x248 -label = "u00422E80" +label = "set-tiled-surface-edge-length" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00422E80" -category = "unknown" -summary = "" +name = "set-tiled-surface-edge-length" +category = "draw" +summary = "Set the legacy mode-1 tiled-surface edge length in pixels. Native surface creation, blitting, rectangle operations, and AGF upload use it to partition one logical surface into ordinary texture tiles; SYSTEM4 initializes it to 128." +details = "Port status (2026-07-29): implemented as retained signed-dword graphics configuration. The value survives scene-context resets and does not dirty presentation or rebuild existing surfaces. The portable backend deliberately keeps mode-1 surfaces as contiguous RGBA images rather than reproducing legacy D3D texture tiling." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x248_set_tiled_surface_edge_length@0x424af0 directly stores operand 1 in g_nTiledSurfaceEdgeLength@0x5b15b0. Its 30 xrefs are confined to gfx_tiled_surface_create/blit/rect_op/upload_agf, where dimensions are split by ceil(size / edge). SYSTEM4's sole call passes 128 immediately after opcode 0x149. This configures opcode 0x249's mode-1 tiled wrapper and is unrelated to adjacent opcode 0x24d." [[opcode.semantics.args]] i = 1 -role = "" +role = "tile edge length in pixels" observed_types = ["imm"] [[opcode]] @@ -6642,78 +6643,78 @@ observed_types = ["imm", "l-int"] [[opcode]] op = 0x24d -label = "u00422E90" +label = "play-movie-mask-transition" argc = 12 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00422E90" -category = "unknown" -summary = "" +name = "play-movie-mask-transition" +category = "draw" +summary = "Open a movie into a scratch surface, retime it to the requested duration, use its decoded frames as a per-pixel mask, and register a blocking retained-surface transition from an old object range to a newly drawn range within the supplied rectangle." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x24d_play_movie_mask_transition@0x424db0 opens operand 10 through movie_to_texture_open_asset_graph, configures delay operand 11, sets IMediaPosition::put_Rate to native_stop_time_ms / operand12_ms, allocates a width*height byte mask through movie_texture_allocate_transition_mask@0x415d90, and calls gfx_movie_mask_transition_register@0x47f560 with operands 1-9/11. The retained compositor's type-1 branch applies the movie-updated mask between the old and new object ranges and holds its blocking dirty state until playback completes. Both DEBUG.BIN sites use TEST.AGF (an MPEG program stream), delay 0, and duration 1000 ms." [[opcode.semantics.args]] i = 1 -role = "" +role = "new retained-command range key" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "scratch movie surface slot" observed_types = ["imm"] [[opcode.semantics.args]] i = 3 -role = "" +role = "old/source retained-object range start" observed_types = ["imm"] [[opcode.semantics.args]] i = 4 -role = "" +role = "old/source retained-object range count" observed_types = ["imm"] [[opcode.semantics.args]] i = 5 -role = "" +role = "mask destination x" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 6 -role = "" +role = "mask destination y" observed_types = ["imm"] [[opcode.semantics.args]] i = 7 -role = "" +role = "mask width" observed_types = ["imm"] [[opcode.semantics.args]] i = 8 -role = "" +role = "mask height" observed_types = ["imm"] [[opcode.semantics.args]] i = 9 -role = "" +role = "mask direction/mode" observed_types = ["imm"] [[opcode.semantics.args]] i = 10 -role = "" +role = "universal packed movie resource id" observed_types = ["imm"] [[opcode.semantics.args]] i = 11 -role = "" +role = "movie start delay in milliseconds" observed_types = ["imm"] [[opcode.semantics.args]] i = 12 -role = "" +role = "total transition duration in milliseconds" observed_types = ["imm"] [[opcode]]