From 6e147014ecaa89706a45105be5c56202e9a49c0a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 19:30:56 -0400 Subject: [PATCH] Implement DEBUGMAP combat frontier --- docs/engine-re.md | 114 +++++++- docs/opcode-reference.md | 142 ++++++---- docs/phase-b-framework.md | 103 ++++++- .../BattleFrontierOpsTests.cs | 127 +++++++++ .../Age.Engine.Tests/IntegerQueueOpsTests.cs | 188 +++++++++++++ .../RenderObjectBlendTests.cs | 19 ++ .../Age.Engine.Tests/RgbaSurfaceOpsTests.cs | 80 ++++++ engine/Age.Engine.Tests/TestSupport.cs | 7 + engine/Age.Engine/Hosting/IHost.cs | 8 + engine/Age.Engine/Model/GfxState.cs | 53 +++- engine/Age.Engine/Sys4/RgbaSurfaceOps.cs | 82 ++++++ engine/Age.Engine/Vm/VirtualMachine.cs | 159 ++++++++++- godot/GodotAdvHost.cs | 162 ++++++++++- godot/Main.cs | 22 +- vm-map/opcodes.toml | 259 +++++++++--------- 15 files changed, 1318 insertions(+), 207 deletions(-) create mode 100644 engine/Age.Engine.Tests/BattleFrontierOpsTests.cs create mode 100644 engine/Age.Engine.Tests/IntegerQueueOpsTests.cs create mode 100644 engine/Age.Engine.Tests/RgbaSurfaceOpsTests.cs create mode 100644 engine/Age.Engine/Sys4/RgbaSurfaceOps.cs diff --git a/docs/engine-re.md b/docs/engine-re.md index bac639d..ab4d447 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1024,6 +1024,14 @@ Ghidra functions renamed + plate-commented, saved). generic tint-strength control. Mode 3 remains separately scoped beyond the completed mode-1 path, and surfaceless mode-0 fills remain a distinct consumer case. +Created/render-target surfaces are a third mode-0 consumer, distinct from both loaded textures and an object +with no surface. SYSTEM4 creates and fills surface 3; BUNKI draws it with alpha `0xd0`, while FIELD draws it +beneath the minimap with alpha `0x40`. Original screenshots show the former as a translucent tinted panel and +the latter allowing the sidebar paper through. Therefore created surfaces consume packed alpha as object +opacity and packed RGB as multiplicative modulation. The port retains created-surface identity separately +even though these surfaces have resource id zero; their no-colorkey sentinel is `-1`, not active key-black +zero. Loaded mode-0 textures keep the established opaque/alpha-inert behavior above. + **TITLE SO022 additive proof (2026-07-20).** TITLE loads type-1 8-bpp `SO022.AGF` with no alpha plane and no color key, binds two 140×140 spritesheet objects, and calls `0x203(handle,1,255,0xffffff)` for both. Ordinary alpha composition therefore produces opaque black squares around the blue flames. Native mode-1 @@ -1698,8 +1706,11 @@ effect snapshot, and clears stale labels whenever `0x71` resets that layout. The same slice implements the support calls at their natural seams. Op `0x131` reads a host configuration property (currently defaulting to zero; choosing a profile/config persistence backend remains deferred), -`0x20b` clears the addressed region of the mutable name-strip text surface, and `0x222` requests one retained -recomposition boundary. Surface text is now a list rather than one draw per slot: HISTORY's five 600x30 +`0x20b` replaces every clipped RGBA pixel in the addressed mutable-surface rectangle and clears modeled text +draws anchored inside it, and `0x222` requests one retained recomposition boundary. This pixel write matters +outside HISTORY: SYSTEM4 fills created surface 3 opaque white, then BUNKI applies mode-0 tint plus `0xd0` +opacity to produce its translucent menu interior. FIELD reuses the surface at `0x40` opacity under its +minimap. Surface text is now a list rather than one draw per slot: HISTORY's five 600x30 source rows therefore retain five independent speaker names, and compositor source-rectangle clipping maps each one into its bound object. A real-script regression retains SC0000 page one, runs unmodified `HISTORY.BIN`, observes a non-empty rendered row, and reaches its `(0,60000)` presentation call. @@ -2563,6 +2574,105 @@ call and cover half-width right alignment, zero padding, and the observed full-w DRAWENP is now 36/36 opcodes and 611/611 instructions handled; all 284 engine tests, the zero-warning Godot build, and threaded frontend selftest pass. +### DEBUGMAP-to-battle frontier opcode cluster (2026-07-21) + +A static call-graph/coverage pass from `FIELD.BIN` through selection, movement, combat, damage, growth, and +status helpers shows that the next gameplay risk is presentation plumbing rather than battle arithmetic. +The following native handlers were decoded while bounding that slice: + +- `op_0x191_absolute_value@0x426de0` writes + `(value ^ (value >> 31)) - (value >> 31)` through operand 1. This is signed 32-bit absolute value; all + five corpus sites are in `SELACT.BIN`, normalizing a signed preview delta before display. +- `op_0xd0_get_monotonic_time_ms@0x428860` writes `timeGetTime()` to operand 1. `BTL.BIN` samples it around + its timed callback/HP presentation, and `MVRTN.BIN` contains the other two calls. The port's host-owned + `FrameClock.NowMs` is the matching monotonic, speed-scaled service timebase. +- `op_0x23c_sample_frame_time@0x417580` shifts `EngineCtx+0x51b64` to `+0x51b68`, then stores + `timeGetTime()` as the new current timestamp. BTL callback frames, FIELD/USEMAGIC movie loops, ADDEXP, + and SHOWGROW place it next to presentation boundaries. +- `op_0x23a_query_movie_surface_active@0x42a440` writes zero for an empty surface slot; otherwise it writes + whether the movie-surface field at `+0x42c` is nonzero. All four corpus sites use that result as a movie + completion-loop predicate: BTL scans its active combat surfaces, while FIELD and USEMAGIC poll slot 42. +- `op_0x24e_set_gfx_animation_service_flags@0x425070` copies its operand directly to + `EngineCtx.gfx_animation_service_flags` (`+0x51b80`). BTL brackets combat presentation with 1/0 and + GAMECLEAR uses 3/0. Bit 1 is independently consumed by opcode `0x243` to suppress a force-complete and + animation-clock-reset request. +- `op_0x207_copy_surface_rect@0x422b50` builds source/destination rectangles from + `(source_surface, destination_surface, source_x, source_y, width, height, destination_x, destination_y)` + and calls `gfx_copy_surface_rect@0x477da0`. The worker validates both slots, clips both rectangles while + preserving their correspondence, treats an empty intersection as success, marks the destination dirty, + and copies through locked D3D surfaces. Its 15 corpus sites are isolated to DRAWMINIMAP (8), STATUS (3), + READICON (2), and DRAWTIP (2). +- `op_0x2c0_schedule_voice_playback@0x425290` forwards + `(voice_id, playback_variant, delay_ms)` to `voice_schedule_delayed_playback@0x488480` on the service at + `EngineCtx+0x14508`. The setter marks one pending request active and clears its start timestamp. The main + engine tick calls `voice_tick_delayed_playback@0x4884d0`, which captures the first tick then, after + unsigned elapsed time reaches the delay, clears the request and invokes + `voice_play_indexed_asset(voice_id, playback_variant)`. BTL's sole call schedules a randomized combat + voice with variant 0 and an entity-specific delay; it is presentation-only, not battle state. + +These handlers and their newly understood workers are renamed/commented in Ghidra `/v2`; the program was saved. +The exact source metadata lives in `vm-map/opcodes.toml`. The port now implements this cluster against the +shared Godot frame clock, retained graphics state, movie decoder state, mutable RGBA surfaces, and delayed +voice service. `0x207` copies colorkey-baked source pixels into immutable published snapshots so compositor +reads cannot race VM-side mutations. The first complete player attack remains the manual acceptance test; +the implementation deliberately leaves BTL's two `0x1a2` shared-profile writes deferred because they do +not feed same-exchange combat state. + +### Movement/attack flood-fill FIFO -- opcodes `0x132`-`0x134` (2026-07-21) + +The DEBUGMAP symptom "selected unit can wait on its origin, but has no blue reachable tiles and cannot +move" is caused by the only three effectful gaps in `MVSEEK.BIN`, not by `CALCSCOPE` or FIELD input. AGE +provides 11 context-owned integer FIFO slots: + +- `op_0x132_reset_int_queue@0x4217d0` validates `queue_id <= 10`, destroys any existing object in the + selected slot, and allocates a fresh 0x1c-byte FIFO. `int_queue_construct@0x4074c0` allocates 0x100 + dwords, uses another 0x100 dwords as its growth quantum, and zeros the read/end/high-water indices. +- `op_0x133_enqueue_int@0x4218d0` validates the slot and calls `int_queue_enqueue@0x408930`. The helper + appends at the end, first compacting consumed entries when possible or growing storage when necessary. +- `op_0x134_try_dequeue_int@0x429620` writes `(success=1, value)` and advances the read index when the FIFO + is nonempty; otherwise it writes `success=0`. Its value output is not meaningful on failure, and both + shipped callers branch on success before inspecting it. + +Only `MVSEEK.BIN` and `ATSEEK.BIN` use this cluster. Both reset queue 0, pack map coordinates as +`(x << 16) + y`, enqueue the origin, and repeatedly dequeue a tile and enqueue accepted neighbors. +`MVSEEK` writes the origin's movement cost before invoking the queue ops. The former generic stubs left the +origin valid but left `0x134`'s zero-initialized success local unchanged, so the flood fill exited on its +first loop test. This exactly explained why clicking the occupied tile still reached Wait while neither +reachable overlays nor movement targets existed; `ATSEEK` was blocked identically. + +The port now retains 11 VM-lifetime integer FIFO slots and implements reset/enqueue/try-dequeue with native +signed-dword behavior. It diagnoses invalid or never-reset slots; shipped scripts always reset queue 0 +first. On empty dequeue it writes `success=0` and retains the value destination rather than reproducing the +native handler's unusable implementation-pointer value. Focused tests cover independent slots, FIFO order, +signed values, empty reads, and reset replacement. Real-script tests seed a bounded passable grid and prove +that shipped `MVSEEK.BIN` populates all four neighboring movement costs while `ATSEEK.BIN` populates attack +distance 1 around the origin. Both scripts are now 100% handled. The handlers and queue helpers are +renamed/commented in Ghidra `/v2`; the program was saved. + +The first live recheck after that FIFO implementation still showed no movement, exposing the caller layer +that the direct real-script tests had bypassed. SYSTEM4 does not leave these workers to ordinary +`call-script`: its only three opcode `0x06` sites preload `ATSEEK.BIN` (`0x337f`) into EngineCtx frame slot +`0x1d`, `SETROUTE.BIN` (`0x3380`) into slot `0x1e`, and `MVSEEK.BIN` (`0x3381`) into slot `0x1f`. FIELD and +the route helpers then contain 64 total opcode `0x08` calls targeting only those three slots. Both opcodes +were still generic effectful stubs, so live FIELD never entered MVSEEK despite the now-correct worker. + +Native `op_0x6_preload_script_slot@0x41bdb0` temporarily selects a caller-specified context index (valid +range 0..39), calls the ordinary script resource loader there, then restores the current index without +executing the loaded script. `op_0x8_call_preloaded_script_slot@0x41bf00` switches to the selected loaded +context, records the caller context as its return target, resets its PC to codebase, and begins execution. +`op_0x2_exit_or_return_frame@0x417940` disposes only an adjacent child (`parent+1 == current`); the +non-adjacent service slots therefore survive return with their local banks allocated. + +The VM now models those persistent preloaded frames, clears them on root-scene reset, and emits normal +call-script trace events when invoked. A focused worker proves repeated `0x08` calls restart code while +retaining locals. A second regression uses the exact SYSTEM4 ABI—`0x06(0x3381,0x1f)` followed by +`0x08(0x1f)`—to run the shipped MVSEEK and populate all four neighboring movement costs. FIELD's five +formerly skipped `0x08` instructions are handled, as are all indirect MVSEEK/ATSEEK/SETROUTE consumers. +The two native handlers are renamed/commented in Ghidra `/v2`; the program was saved. +Manual DEBUGMAP acceptance confirms that reachable-tile overlays and movement now work through this live +route and that player combat is reachable. Any remaining work at this frontier should start from the +concrete combat discrepancies observed in that run rather than from movement search or dispatch. + --- ## Native walls backlog (targets for this loop) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index cecaad8..17fcac2 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -188,6 +188,13 @@ - **grounding:** source=frida, confidence=high - **evidence:** Ghidra /v2: op_0x2bf_schedule_sfx_start@0x425240 calls sfx_set_delay@0x482720 on the ctx+0x14024 sound facade. The worker's native error text names Function:SetDelay, validates channel<=9, and stores active/progress/delay/start-mode state. Existing SC0000 trace: SetDelay(channel 4, mode 0, 100) is followed about 109 ms later by the ordinary sfx_channel_start worker on channel 4 with mode 0 and no intervening 0xb5. +### 0x2c0 `schedule-voice-playback` (schedule-voice-playback, argc 3) +- **summary:** Arm delayed voice playback: (voice_id, playback_variant, delay_ms). The engine main tick starts the voice after the monotonic deadline. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x2c0_schedule_voice_playback@0x425290 forwards three operands to voice_schedule_delayed_playback@0x488480 on the text/ADV service at EngineCtx+0x14508. That worker stores active=1, start=0, delay at +0x424, voice id at +0x428, and variant at +0x42c. engine_main_tick_with_exception_policy calls voice_tick_delayed_playback@0x4884d0; after unsigned elapsed >= delay it clears the request and calls voice_play_indexed_asset(voice_id,variant). Corpus: one BTL site at 0x2f6e. + +The setter replaces the single pending request, marks it active, and clears its start timestamp. On the first service tick the worker captures the current millisecond time; once unsigned elapsed time reaches delay_ms it clears the request and calls the ordinary indexed-voice player with voice_id and playback_variant. BTL's only call selects a randomized combat voice id, variant 0, and an entity-specific delay. + ## compute ### 0x60 `random-modulo` (u0041A270, argc 2) @@ -224,6 +231,27 @@ Operand 2 names the base cell itself: a global-bank operand produces a global re Implemented with domain-preserving addressed-array access, native signed 32-bit key addition/overflow, stable insertion ordering, repeated count reads, and the native unconditional out_indices[0]=0 write. Focused tests lock stability/overflow/zero-count behavior; a natural SYSTEM4-to-SC0000 state carried into release CHMENU proves the initial slot remains selected after the real roster sort. +### 0x132 `reset-int-queue` (reset-int-queue, argc 1) +- **summary:** (queue_id) - destroy any existing queue in the selected engine slot and replace it with an empty integer FIFO. Valid queue ids are 0..10. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x132_reset_int_queue@0x4217d0 fetches queue_id, rejects values above 10, invokes the existing object's virtual destructor, allocates 0x1c bytes, and calls int_queue_construct@0x4074c0. The constructor allocates 0x100 dwords, sets capacity and growth quantum to 0x100, and zeros the read/end/high-water indices. The only corpus sites are ATSEEK@0x32 and MVSEEK@0x145, immediately before packing and enqueueing the origin coordinate for their flood fills. + +Implemented as 11 VM-lifetime queue slots. Reset replaces the selected queue with an empty FIFO pre-sized to the native 0x100-dword initial capacity; invalid ids halt with a diagnostic. + +### 0x133 `enqueue-int` (enqueue-int, argc 2) +- **summary:** (queue_id, value) - append one integer to the selected engine FIFO, compacting consumed entries or growing its storage when required. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x133_enqueue_int@0x4218d0 validates queue_id 0..10, fetches value, and calls int_queue_enqueue@0x408930 on ctx's selected queue. The helper appends at end_index, compacts unread entries when read_index is nonzero, or grows capacity by the 0x100-dword quantum. All four corpus sites are in ATSEEK/MVSEEK and enqueue coordinates packed as (x << 16) + y. + +Implemented with signed 32-bit value normalization into the selected FIFO. The port diagnoses invalid or never-reset slots; every shipped use resets queue 0 before enqueueing. + +### 0x134 `try-dequeue-int` (try-dequeue-int, argc 3) +- **summary:** (queue_id, out_success, out_value) - consume the next integer from the selected FIFO, writing success=1 and the value; write success=0 when empty. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x134_try_dequeue_int@0x429620 validates queue_id 0..10 and compares the selected queue's read_index with end_index. When nonempty it reads data[read_index], increments read_index, updates the high-water index, writes 1 to operand 2, and writes the item to operand 3; when empty it writes 0 to operand 2. Native still writes a non-item implementation value to operand 3 on failure, but both shipped callers branch on out_success before reading out_value. ATSEEK and MVSEEK use the opcode as the loop head for their coordinate flood fills. + +Implemented as FIFO TryDequeue: nonempty writes success=1 plus the signed dword; empty writes success=0 and retains the prior value destination because native's failure value is an unusable implementation pointer. Focused tests cover slot independence, ordering, empty/reset behavior, and signed values; real MVSEEK/ATSEEK regressions prove both searches expand beyond the origin. + ### 0x135 `bit-set` (bit-set, argc 2) - **summary:** (value)(bit_index) - set the indexed bit in the destination integer. - **grounding:** source=investigation, confidence=high @@ -234,6 +262,16 @@ Implemented with domain-preserving addressed-array access, native signed 32-bit - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x136_handler@0x429730 fetches operand 2 as an unsigned bit index, rejects values >=32 through the native script-error path, fetches operand 1, and writes value & ~(1 << index). HIDEWIN.BIN clears index 1 at 0x154 after testing mask 0x2. +### 0x191 `absolute-value` (absolute-value, argc 2) +- **summary:** Write the signed 32-bit absolute value of operand 2 to operand 1. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x191_handler@0x426de0 computes (value ^ (value >> 31)) - (value >> 31) and writes it through vm_operand_write. All five Himegari calls are in SELACT, where it normalizes a signed preview delta before drawing it. + +### 0x193 `concat` (concat, argc 3) +- **summary:** Concatenate operand 2 followed by operand 3 and replace the destination string. Sources are resolved before the write, so destination/source aliasing is supported. +- **grounding:** source=inference, confidence=high +- **evidence:** Kelebek identifies param1 = param2.concat(param3). Corpus ordering confirms direction and aliasing: ADDEXP builds level/result messages with both literal-prefix concat(dst, literal, dst) and append concat(dst, dst, literal); BTL has eight calls. + ### 0x194 `string-equals` (string-equals, argc 3) - **summary:** (out)(left)(right) - compare two complete SYS4 strings and write 1 when equal, otherwise 0. - **grounding:** source=investigation, confidence=high @@ -258,6 +296,11 @@ Native applies strlen to the NUL-terminated engine byte string and shifts the by - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2 op_0x1b0_copy_dwords@0x427060 fetches operand 3, resolves addressable operands 1 and 2 through vm_operand_resolve_address@0x425a50, and calls memcpy(destination, source, count*4). Corpus: 65 calls across direct global/local spans and local pointers; 43 are immediately preceded by take-address 0x63. The C# VM copies resolved integer-cell spans while retaining local/global address domains; focused tests cover direct spans and aliased pointers, and the traced natural boot reaches the UNITECH/CALCCC pair without fallback. +### 0x1c8 `toString` (toString, argc 2) +- **summary:** Convert the source signed 32-bit integer to its invariant decimal string and replace the destination string. +- **grounding:** source=inference, confidence=high +- **evidence:** Kelebek identifies integer-to-string conversion. All five corpus sites are in ADDEXP and feed concat immediately: level numbers, signed deployment-cost deltas, and movement deltas. Source types are global/local integer and the destination is a local string. + ### 0x2c5 `byte-string-length` (strlen, argc 2) - **summary:** Write the resolved NUL-terminated engine string's raw byte length. - **grounding:** source=investigation, confidence=high @@ -295,6 +338,22 @@ Companion op 0x8f `call` is INTRA-script (a local JSR), not cross-script -- see This also names the whole call graph statically (build/callscript-names.json). +### 0x6 `preload-script-slot` (preload-script-slot, argc 2) +- **summary:** (script_id, frame_slot) - load and allocate a script into a numbered engine context slot without executing it. Valid slots are 0..39. +- **grounding:** source=investigation, confidence=high +- **depended on by:** 0x8 +- **evidence:** Ghidra /v2: op_0x6_preload_script_slot@0x41bdb0 fetches script_id and frame_slot, saves the current context index, selects frame_slot, rejects values above 39, calls script_frame_load_resource(ctx+0x54fe8, script_id), restores the caller index, and throws on load failure. SYSTEM4's only three sites preload ATSEEK.BIN (0x337f) into slot 0x1d, SETROUTE.BIN (0x3380) into 0x1e, and MVSEEK.BIN (0x3381) into 0x1f before INIT2. + +Implemented as persistent VM-owned preloaded slots containing the resolved script id and one reusable ExecFrame. Replacing a slot allocates a fresh frame/local bank; invalid slots, absent providers, and unresolved scripts halt diagnostically. Root scene reload clears the slots before SYSTEM4 registers them again. + +### 0x8 `call-preloaded-script-slot` (call-preloaded-script-slot, argc 1) +- **summary:** (frame_slot) - restart and execute the script previously loaded into that engine context slot, returning to the caller when it exits. +- **grounding:** source=investigation, confidence=high +- **depends on:** 0x6 +- **evidence:** Ghidra /v2: op_0x8_call_preloaded_script_slot@0x41bf00 fetches frame_slot, switches cur_ctx_index to it, errors if frame_codebase is null, stores the caller context index into the selected slot's ctx_record_base, resets its PC to codebase and instruction length to zero, and returns to the dispatcher. op_0x2_exit_or_return_frame@0x417940 disposes only an adjacent child (parent+1==current); SYSTEM4's non-adjacent slots 0x1d..0x1f therefore retain their allocated local banks between calls. Corpus has 64 sites, exclusively slots 0x1d/0x1e/0x1f. FIELD uses 0x1f for MVSEEK, 0x1d for ATSEEK, and 0x1e for SETROUTE. + +Implemented by recursively executing the reusable preloaded ExecFrame while preserving its local banks across invocations, restarting at script offset zero, and propagating halt/root-reload/exit outcomes like ordinary call-script. The port emits normal call-script trace events for observability. Focused tests prove code restart plus local persistence and drive the real MVSEEK through SYSTEM4's exact 0x06/0x08 ABI. + ### 0x9 `exit-script` (exit-script, argc 0) - **summary:** () - discard the complete active script stack, reset scene-owned engine services, and load raw script resource 0 as the new root. The global VM banks and process-owned configuration survive; the initial-root-run flag queried by op 0x130 is cleared so LOGO/OP are not replayed. - **grounding:** source=investigation, confidence=high @@ -344,6 +403,11 @@ Implemented as a whole-stack root-reload boundary in the persistent VM. A reques Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_timer_arm @0x44cff0 at ctx+0x5f304 = active flag + start tick + duration) that the engine main loop polls, resuming the script when elapsed. Operand UNIT = MILLISECONDS (start = ms tick source DAT_0056f3d4, timeGetTime/GetTickCount class), and the native arm helper clamps duration to a minimum of 1 ms. ROOM's input loop deliberately uses sleep(0), making it a one-engine-tick yield rather than a no-op. The handler also records its generic 3-dword instruction length and runs anti-tamper checks, neither needed host-side. Port equivalent: the Godot host parks the VM thread for max(1, scaled duration) ms while the presentation compositor continues. Sleep is one proven presentation-capable service boundary; ordinary AE setup runs burst-fast to 0x21c and is not paced per opcode. Headless hosts no-op it (parity). +### 0xd0 `get-monotonic-time-ms` (get-monotonic-time-ms, argc 1) +- **summary:** Write the native monotonic millisecond clock to operand 1. Battle presentation uses paired samples around timed callback sequences to calculate elapsed time. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0xd0_handler@0x428860 calls imp_winmm_timeGetTime and writes the returned 32-bit tick count to operand 1. BTL samples it before and after its timed HP/damage presentation; MVRTN has the other two corpus calls. + ### 0xd3 `begin-timed-callback-sequence` (u00425960, argc 0) - **summary:** Clear and initialize the current script frame's timed local-callback sequence. - **grounding:** source=investigation, confidence=high @@ -508,6 +572,13 @@ The handler clears the map embedded at retained-gfx owner+0x408, resets its coun The field width includes an optional sign. Bit 0 zero-pads; bit 1 centers omitted leading cells; bit 2 left-aligns; bits 3/4 request '+' for positive/zero; bit 5 renders zero with '-'. With bit 16, output remains half-width ASCII and omitted-cell x adjustment uses half the current font cell advance; otherwise the formatter converts digits/signs to full-width CP932. Default alignment preserves the field's right edge by shifting x right for each omitted leading cell. +### 0x207 `copy-surface-rect` (copy-surface-rect, argc 8) +- **summary:** Copy a rectangular pixel region between mutable graphics surfaces: (source_surface, destination_surface, source_x, source_y, width, height, destination_x, destination_y). +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x207_handler@0x422b50 constructs source [x,y,x+w,y+h] and destination [dx,dy,dx+w,dy+h] rectangles and calls gfx_copy_surface_rect@0x477da0. The worker validates both surface slots, clips both rectangles together, marks the destination dirty, and copies through locked D3D surfaces. Corpus: 15 calls total: DRAWMINIMAP 8, STATUS 3, READICON 2, DRAWTIP 2. + +The worker clips the paired source and destination rectangles against both surfaces while preserving their correspondence, returns successfully for an empty clipped rectangle, and marks the destination surface dirty. Himegari uses the opcode for minimap markers plus STATUS, READICON, and DRAWTIP surface composition. + ### 0x208 `get-texture-size` (get-texture-size, argc 3) - **summary:** 0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649) - **grounding:** source=inference, confidence=med @@ -689,6 +760,11 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b - **grounding:** source=investigation, confidence=high - **evidence:** Native /v2 worker and gfx_object_apply_transform_channels decompile. The consumer advances target_frame cells over duration after delay, preserves the existing source-rect dimensions, and commits the endpoint. +### 0x23a `query-movie-surface-active` (query-movie-surface-active, argc 2) +- **summary:** Write whether a movie-backed surface has a nonzero playback/synchronization state at surface object offset 0x42c; an empty surface slot writes zero. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x23a_handler@0x42a440 indexes the surface table by operand 2, writes zero for a null slot, otherwise writes surface+0x42c != 0. All four corpus sites are movie completion polling loops: BTL checks active combat-movie surfaces 7+, while FIELD and USEMAGIC poll surface 42 between present-frame, frame-time sampling, and sleep(16). + ### 0x23b `draw-decimal-glyphs` (u00422460, argc 7) - **summary:** Draw an integer as decimal glyph objects from a style registered by opcode 0x13a. - **grounding:** source=investigation, confidence=high @@ -697,6 +773,11 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b First erase digit_capacity objects beginning at base_handle. Then split value by signed division/modulo 10 and bind at most digit_capacity retained objects using adjacent digit-width cells from the registered atlas. Flags bit 0 zero-pads, bit 1 centers the used digits, and bit 2 left-aligns them; with no alignment bit the value is right-aligned in the capacity. Invalid or unregistered style indices raise the engine's script error. +### 0x23c `sample-frame-time` (sample-frame-time, argc 0) +- **summary:** Shift the current retained-presentation timestamp to the previous-frame field, then sample the native monotonic millisecond clock as the new current timestamp. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x23c_handler@0x417580 copies EngineCtx frame_timer at +0x51b64 to +0x51b68, then stores imp_winmm_timeGetTime() at +0x51b64. BTL, ADDEXP, SHOWGROW, USEMAGIC, and FIELD place it at presentation/present-frame boundaries. + ### 0x23d `release-transient-surfaces` (release-transient-surfaces, argc 0) - **summary:** Stop movie bindings and release transient gfx surface slots 42 through 999 inclusive, preserving system-owned slots 0 through 41. - **grounding:** source=investigation, confidence=high @@ -730,6 +811,11 @@ The setter get-or-creates the object and writes the complete operand. During ret - **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. +### 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 +- **evidence:** Ghidra /v2: op_0x24e_handler@0x425070 writes operand 1 directly to EngineCtx.gfx_animation_service_flags at +0x51b80. The mapped field is also read by op 0x243: bit 1 suppresses its force-complete/clock-reset request. + ## input ### 0x86 `set-cursor-resource` (u0041B210, argc 1) @@ -966,14 +1052,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=med -### 0x6 `u00417E80` (u00417E80, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x8 `u00417FC0` (u00417FC0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x21 `u00418860` (u00418860, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -1098,26 +1176,10 @@ 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 -### 0xd0 `u00415830` (u00415830, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x12c `lookup-array-2d` (lookup-array-2d, argc 5) - **summary:** — - **grounding:** source=kelebek, confidence=med -### 0x132 `u0041EF00` (u0041EF00, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x133 `u0041EFF0` (u0041EFF0, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x134 `u0041F050` (u0041F050, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x137 `u0041F1C0` (u0041F1C0, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -1146,18 +1208,10 @@ 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 -### 0x191 `u0041A4A0` (u0041A4A0, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x192 `set-string` (set-string, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=med -### 0x193 `concat` (concat, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - ### 0x196 `display-furigana` (display-furigana, argc 3) - **summary:** — - **grounding:** source=kelebek, confidence=med @@ -1230,14 +1284,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 -### 0x1c8 `toString` (toString, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - -### 0x207 `u00420B00` (u00420B00, argc 8) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x230 `u00421E70` (u00421E70, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -1246,14 +1292,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 -### 0x23a `u00422420` (u00422420, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x23c `u004162B0` (u004162B0, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x241 `u00422B80` (u00422B80, argc 5) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -1266,14 +1304,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 -### 0x24e `u00422EA0` (u00422EA0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x2c0 `u004231C0` (u004231C0, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x2c6 `u0042B5E0` (u0042B5E0, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 05bde83..bbd88cc 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -474,8 +474,107 @@ fixed-width signed decimal field, applies zero-pad/alignment/half-width flags, a suppressed leading cells, and rasterizes the result into the same temporary surface using current text style. The shared VM implementation now covers that path; focused tests use DRAWENP's exact level call and the observed half-width, zero-padded, and full-width variants. DRAWENP is now 611/611 instructions handled; -all 284 engine tests, the zero-warning Godot build, and threaded frontend selftest pass. A manual -deployment-card visual recheck remains. +all 284 engine tests, the zero-warning Godot build, and threaded frontend selftest pass. The manual +deployment-card comparison now passes. + +### Post-DEBUGMAP script audit and next slice (2026-07-21) + +The next slice should prove one complete **player attack and battle presentation** from the working +DEBUGMAP field: deploy a unit, enter its action flow, choose a reachable target, resolve one `BTL` exchange, +observe the HP/damage presentation settle, and return to an interactive `FIELD`. This is a stronger next +gate than expanding save/configuration or polishing isolated menus because the underlying gameplay scripts +are already much closer to complete than their presentation suggests: + +- `CALCSCOPE`, `CALCARR`, `CALCBTPARAM`, `CALCREVISE`, `CALCOCC`, and `CALCDMG` are fully handled; so are + `BTANINIT`, `SETEN`, `REMOVECH`, and the ordinary map renderers. +- `BTL` has 2,194 of 2,215 static instructions handled (including one safe no-op) and 78 of 85 distinct + opcodes handled. Its remaining cluster is presentation-oriented: elapsed-time sampling (`0xd0`), + frame-time sampling (`0x23c`), movie + completion polling (`0x23a`), animation-service flags (`0x24e`), result-string construction (`0x193`), + profile bookkeeping (`0x1a2`), and delayed combat voice playback (`0x2c0`). +- `SELACT` has only five calls to missing `0x191`, now proven to be signed absolute value. `ADDEXP` is + similarly blocked mainly on string concatenation/decimal conversion plus two frame-time samples. +- FIELD itself has only 31 unhandled instructions out of 7,933. Save/load/profile and read-skip services + account for several of those and need not block the first attack loop. + +The implementation order inside that slice should be evidence-driven and checkpointed: first `0x191` plus +the battle clock/frame cluster (`0xd0`, `0x23c`, `0x23a`, `0x24e`), then `0x193`/`0x1c8` for battle and EXP +messages. The sole `0x2c0` site is now proven audio-only, so delayed combat voice can follow the visible +exchange rather than block it. + +The existing Godot `FrameClock` is already the correct millisecond timebase, so this does not require a new +scheduler. Defer `0x1a2` profile persistence unless the attack trace proves its value affects same-session +combat state. + +One independent low-risk cleanup is now precisely scoped but should not displace the attack gate: opcode +`0x207` is a clipped surface-to-surface rectangle copy. Its 15 corpus calls restore eight minimap marker +copies plus STATUS/READICON/DRAWTIP composition. It is a good first commit or visual checkpoint within the +next work period, but it does not unlock combat state. After the first player attack returns cleanly to +FIELD, the next decision gate is enemy-turn/end-turn behavior, then stage-clear/win-loss transition—not +save/profile breadth. + +The subsequent manual movement check found and closed a prerequisite earlier in that path. `MVSEEK`'s three +effectful gaps (`0x132`-`0x134`) are native reset/enqueue/try-dequeue operations on one of 11 engine integer +FIFOs. With the trio stubbed, queue 0's packed-coordinate flood fill wrote only the origin and exited because +the dequeue-success local stayed zero. That produced the exact observed state: Wait remained available on +the current tile, but no blue range overlay or other clickable movement target existed. The VM now owns the +11 FIFO slots and implements all three operations. Focused service tests plus seeded executions of the real +`MVSEEK.BIN` and `ATSEEK.BIN` prove movement costs and attack distances expand to neighboring tiles; both +scripts are now 100% handled. All 298 engine tests, the zero-warning Godot build, threaded selftest, opcode +validation, and full-corpus decode validation pass. The manual movement/overlay recheck is the next acceptance +gate, followed by the pending one-attack return to FIELD. + +That first recheck remained broken because the direct worker tests bypassed SYSTEM4's persistent service +frames. SYSTEM4 uses missing `0x06` to preload ATSEEK, SETROUTE, and MVSEEK into frame slots `0x1d`-`0x1f`; +FIELD uses missing `0x08` to invoke them. Native RE proves that `0x06` allocates a script in a selected slot +without running it, while `0x08` restarts that slot at codebase and returns to the caller without discarding +its local bank. Both are now implemented as reusable VM frames and cleared on root reload. A focused test +proves restart/local persistence, and a bridge regression drives the real MVSEEK through the exact +`0x06(0x3381,0x1f)` / `0x08(0x1f)` ABI. All 300 engine tests, opcode validation, the zero-warning Godot +build, and threaded selftest pass. Manual DEBUGMAP acceptance now confirms that the complete live path shows +reachable-tile overlays, accepts movement, and reaches working player combat. The next bounded work is to +investigate the concrete combat discrepancies found during that acceptance run. + +### Player-attack runtime frontier implemented (2026-07-21; manual attack pending) + +The selected attack-path opcode cluster is now implemented. `0x191` preserves native signed-32-bit absolute +value behavior (including `INT_MIN`); `0xd0` and `0x23c` sample the shared monotonic/frame clock; `0x23a` +queries live movie-surface completion; and `0x24e` retains animation-service flags, including bit 1's +suppression of `0x243`. `0x193` and `0x1c8` now build aliased battle/progression strings, while `0x2c0` +replaces and services one delayed combat-voice request on the Godot frame clock. + +The independent `0x207` checkpoint also landed as a real mutable-surface path. Created textures own RGBA +buffers, static sources apply their load-time color key before copying, paired source/destination clipping +is platform-neutral, overlapping self-copies use stable source pixels, and the compositor resolves generated +surfaces by slot even though they have no asset resource id. This covers minimap markers and the shared +STATUS/READICON/DRAWTIP background composition family. + +Static coverage after implementation is: SELACT 59/59 opcodes and 1,099/1,099 instructions; ADDEXP 45/45 +opcodes handled (including its two proven safe markers) and 310/310 instructions; DRAWMINIMAP, STATUS, +READICON, and DRAWTIP are all 100%; BTL is 84/85 opcodes handled and 2,213/2,215 instructions handled when +its safe marker is included. BTL's only remaining gap is two `0x1a2` shared-profile bookkeeping writes. +Those remain intentionally deferred until save/profile ownership is implemented because neither write feeds +the current exchange. + +Focused regressions cover exact dispatch, signed edge cases, string aliasing, clock rollover truncation, +movie polling, animation reset suppression, delayed voice operands, paired clipping, overlap, and colorkey +transparency. The next gate is manual: execute one player attack from DEBUGMAP, confirm HP/damage and combat +voice presentation settle, and verify control returns to interactive FIELD. If that passes, proceed to an +enemy-turn/end-turn slice; if it fails, use the first concrete visual/control discrepancy rather than the +deferred profile write as the investigation entry point. + +**Mutable-surface fill/blend regression corrected.** The first visual recheck exposed BUNKI's menu interior +as transparent. SYSTEM4 creates 800x600 surface 3 and fills it opaque white through `0x20b`; the metadata-only +host fill left the new pixel buffer transparent. Implementing the fill alone made the panel solid gray and +covered FIELD's paper minimap backing, exposing the second half of the contract: native created/render-target +surfaces are not loaded mode-0 textures and are not surfaceless fills. BUNKI draws created surface 3 with +alpha `0xd0`; FIELD draws it beneath the minimap with alpha `0x40`. Created-surface mode 0 now uses packed +alpha as opacity and packed RGB as multiplicative modulation, while loaded textures retain their established +opaque/alpha-inert behavior. Created surfaces also carry no-colorkey `-1` rather than key-black zero. Focused +regressions cover clipped RGBA replacement and the exact created-surface blend classification; all 295 engine +tests, the zero-warning Godot build, and threaded selftest pass. Manual recheck confirms both the translucent +BUNKI panel and minimap paper are correct. The movement/overlay recheck described above is now the next manual +gate on the one-player-attack acceptance path. ## Later Phase B breadth diff --git a/engine/Age.Engine.Tests/BattleFrontierOpsTests.cs b/engine/Age.Engine.Tests/BattleFrontierOpsTests.cs new file mode 100644 index 0000000..e95ffb1 --- /dev/null +++ b/engine/Age.Engine.Tests/BattleFrontierOpsTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class BattleFrontierOpsTests +{ + private const int Immediate = 0, InlineString = 2, GlobalInt = 3, GlobalString = 5, + LocalString = 11; + + private static OpcodeTable Table() => OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand I(long value) => new(Immediate, value); + private static Operand G(int address) => new(GlobalInt, address); + + [Fact] + public void SurfaceRectCopy_ForwardsTheCompleteBlitRequest() + { + var table = Table(); + var script = ScriptAssembler.Assemble(table, "SURFACE_COPY", new List<(int, Operand[])> + { + (0x207, new[] { I(72), I(67), I(12), I(15), I(3), I(4), I(30), I(33) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + + new VirtualMachine(script, table, host).Run(); + + Assert.Equal(new SurfaceRectCopy(72, 67, 12, 15, 3, 4, 30, 33), + Assert.Single(host.SurfaceCopies)); + } + + [Fact] + public void ScalarBattleHelpers_PreserveNativeSignedBehaviorAndStringAliasing() + { + var table = Table(); + var script = ScriptAssembler.Assemble(table, "BATTLE_SCALARS", new List<(int, Operand[])> + { + (0x191, new[] { G(0x100), I(int.MinValue) }), + (0x193, new[] { new Operand(GlobalString, 0x500), new Operand(GlobalString, 0x500), + new Operand(InlineString, 0) }), + (0x1c8, new[] { new Operand(LocalString, 0), I(-42) }), + (0x55, new[] { new Operand(GlobalString, 0x501), new Operand(LocalString, 0) }), + (0x2, Array.Empty()), + }, new[] { "/99" }); + var vm = new VirtualMachine(script, table, new RecordingHost()); + vm.GlobalStrings[0x500] = "17"; + + vm.Run(); + + Assert.Equal(int.MinValue, vm.Globals[0x100]); + Assert.Equal("17/99", vm.GlobalStrings[0x500]); + Assert.Equal("-42", vm.GlobalStrings[0x501]); + } + + [Fact] + public void MonotonicAndFrameTimeOps_StoreNativeLowDwordSamples() + { + var table = Table(); + var host = new SequencedClockHost(0x1_0000_0005, 100, 116); + var script = ScriptAssembler.Assemble(table, "BATTLE_CLOCK", new List<(int, Operand[])> + { + (0xd0, new[] { G(0x100) }), + (0x23c, Array.Empty()), + (0x23c, Array.Empty()), + (0x2, Array.Empty()), + }, Array.Empty()); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal(5, vm.Globals[0x100]); + Assert.Equal(100u, vm.Gfx.PreviousFrameTimeMilliseconds); + Assert.Equal(116u, vm.Gfx.CurrentFrameTimeMilliseconds); + } + + [Fact] + public void MovieActivityAndAnimationServiceFlags_ControlBattlePollingAndReset() + { + var table = Table(); + var host = new RecordingHost(); + host.ActiveMovieSurfaces.Add(7); + var script = ScriptAssembler.Assemble(table, "BATTLE_CONTROL", new List<(int, Operand[])> + { + (0x23a, new[] { G(0x100), I(7) }), + (0x23a, new[] { G(0x101), I(8) }), + (0x238, new[] { I(400) }), + (0x24e, new[] { I(2) }), + (0x243, Array.Empty()), + (0x2, Array.Empty()), + }, Array.Empty()); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal(1, vm.Globals[0x100]); + Assert.Equal(0, vm.Globals[0x101]); + Assert.Equal(400, vm.Gfx.AnimClockDurationTicks); + Assert.Equal(1, vm.Gfx.AnimClockGeneration); + Assert.Equal(2, vm.Gfx.AnimationServiceFlags); + } + + [Fact] + public void DelayedCombatVoice_ForwardsAllSchedulingOperands() + { + var table = Table(); + var host = new RecordingHost(); + var script = ScriptAssembler.Assemble(table, "BATTLE_VOICE", new List<(int, Operand[])> + { + (0x2c0, new[] { I(123), I(0), I(275) }), + (0x2, Array.Empty()), + }, Array.Empty()); + + new VirtualMachine(script, table, host).Run(); + + Assert.Equal((123L, 0, 275L), Assert.Single(host.ScheduledVoiceRequests)); + } + + private sealed class SequencedClockHost : RecordingHost + { + private readonly Queue _samples; + public SequencedClockHost(params long[] samples) => _samples = new Queue(samples); + public override long InputClockMilliseconds => _samples.Dequeue(); + } +} diff --git a/engine/Age.Engine.Tests/IntegerQueueOpsTests.cs b/engine/Age.Engine.Tests/IntegerQueueOpsTests.cs new file mode 100644 index 0000000..9ef498e --- /dev/null +++ b/engine/Age.Engine.Tests/IntegerQueueOpsTests.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class IntegerQueueOpsTests +{ + private const int Immediate = 0, GlobalInt = 3, LocalInt = 9; + + private static Operand I(long value) => new(Immediate, value); + private static Operand G(int address) => new(GlobalInt, address); + private static Operand L(int address) => new(LocalInt, address); + + [Fact] + public void IntegerQueues_AreIndependentFifosAndResetDiscardsPendingValues() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "INT_QUEUE", new List<(int, Operand[])> + { + (0x132, new[] { I(0) }), + (0x132, new[] { I(1) }), + (0x133, new[] { I(0), I(11) }), + (0x133, new[] { I(0), I(-22) }), + (0x133, new[] { I(1), I(33) }), + + (0x134, new[] { I(0), L(0), L(1) }), + (0x55, new[] { G(0x100), L(0) }), + (0x55, new[] { G(0x101), L(1) }), + (0x134, new[] { I(0), L(0), L(1) }), + (0x55, new[] { G(0x102), L(0) }), + (0x55, new[] { G(0x103), L(1) }), + (0x134, new[] { I(1), L(0), L(1) }), + (0x55, new[] { G(0x104), L(0) }), + (0x55, new[] { G(0x105), L(1) }), + + (0x55, new[] { L(1), I(777) }), + (0x134, new[] { I(0), L(0), L(1) }), + (0x55, new[] { G(0x106), L(0) }), + (0x55, new[] { G(0x107), L(1) }), + (0x133, new[] { I(1), I(44) }), + (0x132, new[] { I(1) }), + (0x134, new[] { I(1), L(0), L(1) }), + (0x55, new[] { G(0x108), L(0) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var vm = new VirtualMachine(script, table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(1, vm.Globals[0x100]); + Assert.Equal(11, vm.Globals[0x101]); + Assert.Equal(1, vm.Globals[0x102]); + Assert.Equal(-22, vm.Globals[0x103]); + Assert.Equal(1, vm.Globals[0x104]); + Assert.Equal(33, vm.Globals[0x105]); + Assert.Equal(0, vm.Globals[0x106]); + Assert.Equal(777, vm.Globals[0x107]); + Assert.Equal(0, vm.Globals[0x108]); + } + + [Fact] + public void PreloadedScriptSlot_RestartsCodeAndRetainsItsLocalBankAcrossCalls() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var root = ScriptAssembler.Assemble(table, "PRELOADED_ROOT", new List<(int, Operand[])> + { + (0x6, new[] { I(0x500), I(0x1f) }), + (0x8, new[] { I(0x1f) }), + (0x8, new[] { I(0x1f) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var worker = ScriptAssembler.Assemble(table, "PRELOADED_WORKER", new List<(int, Operand[])> + { + (0x50, new[] { L(0), L(0), I(1) }), + (0x55, new[] { G(0x120), L(0) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var vm = new VirtualMachine(root, table, new RecordingHost(), + provider: new MapProvider(new Dictionary { [0x500] = worker })); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(2, vm.Globals[0x120]); + Assert.Equal(2, vm.CallScriptDispatches); + } + + [Fact] + public void RegisteredRealMvseek_ExpandsMovementCostsThroughTheSystem4ServiceAbi() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var mvseek = Sys4Loader.Load(Paths.Scripts()["MVSEEK.BIN"], table); + var root = ScriptAssembler.Assemble(table, "SYSTEM4_SERVICE_BRIDGE", new List<(int, Operand[])> + { + (0x6, new[] { I(0x3381), I(0x1f) }), + (0x8, new[] { I(0x1f) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var vm = new VirtualMachine(root, table, new RecordingHost(), + provider: new MapProvider(new Dictionary { [0x3381] = mvseek })); + SeedSearchGrid(vm, movementPoints: 3); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(3, vm.Globals[MovementCell(6, 5)]); + Assert.Equal(3, vm.Globals[MovementCell(4, 5)]); + Assert.Equal(3, vm.Globals[MovementCell(5, 6)]); + Assert.Equal(3, vm.Globals[MovementCell(5, 4)]); + } + + [Fact] + public void RealMvseek_ExpandsMovementCostsBeyondTheOrigin() + { + var vm = RealSearchVm("MVSEEK.BIN"); + SeedSearchGrid(vm, movementPoints: 3); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(3, vm.Globals[MovementCell(6, 5)]); + Assert.Equal(3, vm.Globals[MovementCell(4, 5)]); + Assert.Equal(3, vm.Globals[MovementCell(5, 6)]); + Assert.Equal(3, vm.Globals[MovementCell(5, 4)]); + } + + [Fact] + public void RealAtseek_ExpandsAttackDistancesBeyondTheOrigin() + { + var vm = RealSearchVm("ATSEEK.BIN"); + SeedSearchGrid(vm, movementPoints: 0); + vm.Globals[0xcc9f3] = 2; + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(1, vm.Globals[AttackCell(6, 5)]); + Assert.Equal(1, vm.Globals[AttackCell(4, 5)]); + Assert.Equal(1, vm.Globals[AttackCell(5, 6)]); + Assert.Equal(1, vm.Globals[AttackCell(5, 4)]); + } + + private static VirtualMachine RealSearchVm(string name) + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = Sys4Loader.Load(Paths.Scripts()[name], table); + return new VirtualMachine(script, table, new RecordingHost()); + } + + private static void SeedSearchGrid(VirtualMachine vm, int movementPoints) + { + const int entity = 0; + const int x = 5, y = 5; + vm.Globals[0x66713] = entity; + vm.Globals[0x5231f + entity] = x; + vm.Globals[0x52351 + entity] = y; + vm.Globals[0x4e11b + entity * 14 + 10] = movementPoints; + + vm.Globals[0xccbcf + 1] = 1; + vm.Globals[0xccbcf + 2] = -1; + vm.Globals[0xccbcf + 3] = 0; + vm.Globals[0xccbcf + 4] = 0; + vm.Globals[0xccbd4 + 1] = 0; + vm.Globals[0xccbd4 + 2] = 0; + vm.Globals[0xccbd4 + 3] = 1; + vm.Globals[0xccbd4 + 4] = -1; + + // Terrain id 1 is passable. MVSEEK checks destination centers; ATSEEK checks the + // fine-grid edge between the origin and each logical neighbor. + vm.Globals[0xe6ae0 + 1] = 1; + SetTerrain(vm, x * 2, y * 2); + SetTerrain(vm, (x + 1) * 2, y * 2); + SetTerrain(vm, (x - 1) * 2, y * 2); + SetTerrain(vm, x * 2, (y + 1) * 2); + SetTerrain(vm, x * 2, (y - 1) * 2); + SetTerrain(vm, x * 2 + 1, y * 2); + SetTerrain(vm, x * 2 - 1, y * 2); + SetTerrain(vm, x * 2, y * 2 + 1); + SetTerrain(vm, x * 2, y * 2 - 1); + } + + private static void SetTerrain(VirtualMachine vm, int x, int y) + => vm.Globals[0x341ab + y * 53 + x] = 1; + + private static int MovementCell(int x, int y) => 0xaba96 + y * 27 + x; + private static int AttackCell(int x, int y) => 0xb8d86 + y * 27 + x; +} diff --git a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs index 29008a4..32ab754 100644 --- a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs +++ b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs @@ -93,6 +93,25 @@ public class RenderObjectBlendTests Assert.False(rendered.MultiplyTint); } + [Fact] + public void CreatedSurfaceMode0_UsesPackedAlphaAsOpacityAndRgbAsModulation() + { + var g = new GfxState(); + g.CreateSurface(3); + g.BindDraw(0x100, 3, 0, 0, 10, 10, 0, 0); + g.SetStaticObjectColorResolved(0x100, 0, 0x40, 0x102030); + + var rendered = g.SnapshotVisibleObjects().Single(); + + Assert.Equal(0, rendered.SurfaceResId); + Assert.Equal(-1, rendered.ColorKey); + Assert.Equal(0x40, rendered.Alpha); + Assert.Equal(0, rendered.TintStrength); + Assert.Equal(0x102030, rendered.Tint); + Assert.True(rendered.MultiplyTint); + Assert.Equal(BlendKind.Alpha, rendered.Blend); + } + [Fact] public void Mode1_UsesAdditiveBlendWithArgbSourceScaleAndRgbModulation() { diff --git a/engine/Age.Engine.Tests/RgbaSurfaceOpsTests.cs b/engine/Age.Engine.Tests/RgbaSurfaceOpsTests.cs new file mode 100644 index 0000000..bdc7253 --- /dev/null +++ b/engine/Age.Engine.Tests/RgbaSurfaceOpsTests.cs @@ -0,0 +1,80 @@ +using Age.Engine.Sys4; +using Age.Engine.Model; +using Xunit; + +public class RgbaSurfaceOpsTests +{ + [Fact] + public void FillRect_ReplacesClippedPixelsIncludingAlpha() + { + var surface = Row(1, 2, 3, 4); + + Assert.True(RgbaSurfaceOps.FillRect(surface, -1, 0, 3, 1, 0xd0, 0x112233)); + + Assert.Equal(new byte[] { 0x11, 0x22, 0x33, 0xd0 }, surface.Pixels[..4]); + Assert.Equal(new byte[] { 0x11, 0x22, 0x33, 0xd0 }, surface.Pixels[4..8]); + Assert.Equal(3, Values(surface)[2]); + Assert.Equal(4, Values(surface)[3]); + } + + [Fact] + public void GeneratedSurfaceBinding_DefaultsToNoColorKey() + { + var gfx = new GfxState(); + gfx.CreateSurface(67); + gfx.BindDraw(1, 67, 0, 0, 4, 1, 0, 0); + + Assert.Equal(-1, Assert.Single(gfx.SnapshotVisibleObjects()).ColorKey); + } + + [Fact] + public void CopyRect_ClipsSourceAndDestinationAsOnePairedRectangle() + { + var source = Row(10, 20, 30, 40); + var destination = Row(1, 2, 3, 4); + + Assert.True(RgbaSurfaceOps.CopyRect(source, destination, -1, 0, 4, 1, 1, 0)); + + Assert.Equal(new byte[] { 1, 2, 10, 20 }, Values(destination)); + } + + [Fact] + public void CopyRect_UsesStableSourcePixelsForOverlappingSelfCopy() + { + var surface = Row(10, 20, 30, 40); + + Assert.True(RgbaSurfaceOps.CopyRect(surface, surface, 0, 0, 3, 1, 1, 0)); + + Assert.Equal(new byte[] { 10, 10, 20, 30 }, Values(surface)); + } + + [Fact] + public void WithColorKey_MakesMatchingSourcePixelsTransparentBeforeComposition() + { + var source = new RgbaImage(2, 1, new byte[] { 1, 2, 3, 255, 4, 5, 6, 255 }); + + var keyed = RgbaSurfaceOps.WithColorKey(source, 0x010203); + + Assert.Equal(0, keyed.Pixels[3]); + Assert.Equal(255, keyed.Pixels[7]); + Assert.Equal(255, source.Pixels[3]); + } + + private static RgbaImage Row(params byte[] values) + { + var pixels = new byte[values.Length * 4]; + for (int index = 0; index < values.Length; index++) + { + pixels[index * 4] = values[index]; + pixels[index * 4 + 3] = 255; + } + return new RgbaImage(values.Length, 1, pixels); + } + + private static byte[] Values(RgbaImage image) + { + var values = new byte[image.Width]; + for (int index = 0; index < values.Length; index++) values[index] = image.Pixels[index * 4]; + return values; + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 9a42f2b..97ea115 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -24,6 +24,7 @@ internal class RecordingHost : IHost public int HistoryPresentationEnds; public readonly List ClearedTextLayouts = new(); public readonly List SurfaceFills = new(); + public readonly List SurfaceCopies = new(); public readonly List<(long First, long Count)> PresentedRanges = new(); public readonly List WaitIndicators = new(); public readonly List WaitIndicatorEnabledChanges = new(); @@ -32,6 +33,7 @@ internal class RecordingHost : IHost public readonly List<(long Resource, int Channel)> SfxLoads = new(); public readonly List Voices = new(); public readonly List<(long Id, int PlaybackVariant)> VoiceRequests = new(); + public readonly List<(long Id, int PlaybackVariant, long DelayMs)> ScheduledVoiceRequests = new(); public readonly List VoiceBgmDuckControls = new(); public readonly List SfxStarts = new(); public readonly List<(int Channel, int StartMode, long DelayMs)> ScheduledSfxStarts = new(); @@ -40,6 +42,7 @@ internal class RecordingHost : IHost public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new(); public System.Action? OnPlayMovie; public long? MovieStopTimeMs; + public readonly HashSet ActiveMovieSurfaces = new(); public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new(); public readonly List ClearedRenderTargets = new(); public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new(); @@ -78,6 +81,7 @@ internal class RecordingHost : IHost } public int MessageWindowAlphaSetting { get; set; } public void FillSurfaceRect(SurfaceRectFill fill) => SurfaceFills.Add(fill); + public void CopySurfaceRect(SurfaceRectCopy copy) => SurfaceCopies.Add(copy); public void PresentObjectRange(GfxState gfx, long firstHandle, long count) => PresentedRanges.Add((firstHandle, count)); public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) => WaitIndicators.Add(config); @@ -143,6 +147,8 @@ internal class RecordingHost : IHost VoiceRequests.Add((id, playbackVariant)); } public void SetVoiceBgmDuckControl(long flags) => VoiceBgmDuckControls.Add(flags); + public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs) + => ScheduledVoiceRequests.Add((id, playbackVariant, delayMs)); public void LoadSoundEffect(long resourceId, int channel) => SfxLoads.Add((resourceId, channel)); public void StartSoundEffect(int channel) => SfxStarts.Add(channel); public void ScheduleSoundEffectStart(int channel, int startMode, long delayMs) @@ -155,6 +161,7 @@ internal class RecordingHost : IHost OnPlayMovie?.Invoke(); return MovieStopTimeMs; } + public bool IsMovieSurfaceActive(int surfaceSlot) => ActiveMovieSurfaces.Contains(surfaceSlot); public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) => ModalMovies.Add((rawResourceId, surfaceSlot, movieFlags)); } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 8b94317..09e6618 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -13,6 +13,10 @@ public readonly record struct AdvAutoWaitState( public readonly record struct SurfaceRectFill( int SurfaceSlot, int X, int Y, int Width, int Height, int Alpha, long Rgb); +public readonly record struct SurfaceRectCopy( + int SourceSurface, int DestinationSurface, int SourceX, int SourceY, + int Width, int Height, int DestinationX, int DestinationY); + public interface IHost { /// Report a recoverable runtime discrepancy while allowing script execution to continue. @@ -36,6 +40,7 @@ public interface IHost void EndTextHistoryPresentation() { } int MessageWindowAlphaSetting => 0; void FillSurfaceRect(SurfaceRectFill fill) { } + void CopySurfaceRect(SurfaceRectCopy copy) { } void PresentObjectRange(GfxState gfx, long firstHandle, long count) { } void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { } // Op 0x1ce explicitly starts/stops the same animated marker that op 0x72 starts for an ADV wait. @@ -87,6 +92,7 @@ public interface IHost void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { } void CreateTexture(int slot, int width, int height); void SetTexture(long resourceId, int slot); + void SetTexture(long resourceId, int slot, long colorKey) => SetTexture(resourceId, slot); void ReleaseSurface(int slot) { } /// Clear the selected target's pixels; -1 denotes the main backbuffer. void ClearRenderTarget(int surfaceSlot) { } @@ -101,6 +107,7 @@ public interface IHost // Native voice playback retains a second start argument: ordinary dialogue passes 0, // while History replay (0x1bd) passes 1. Existing non-audio hosts may ignore it. void PlayVoice(long id, int playbackVariant) => PlayVoice(id); + void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs) { } // Native op 0x1cf stores a transient control mask. Bit 0 suppresses the automatic // BGM attenuation normally applied when a voice starts. void SetVoiceBgmDuckControl(long flags) { } @@ -117,6 +124,7 @@ public interface IHost /// when the host could not obtain usable timing metadata. Native op 0x23f queries this state /// immediately after 0x236 returns. long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) => null; + bool IsMovieSurfaceActive(int surfaceSlot) => false; // Native op 0x20f uses a universal raw-catalog id and parks script execution until the movie // reaches EOF or the player cancels it. The decoder remains asynchronous; the interactive host // owns the modal wait so its render loop can continue publishing frames. diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 5fb615c..a1a2cdc 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -145,6 +145,9 @@ public sealed class GfxState // Retained for its opcode family; 0x21e scale and 0x220 translation use frame-time directly instead. ---- public long AnimClockDurationTicks { get; private set; } public long AnimClockGeneration { get; private set; } + public long AnimationServiceFlags { get; private set; } + public uint PreviousFrameTimeMilliseconds { get; private set; } + public uint CurrentFrameTimeMilliseconds { get; private set; } /// Live geometry objects and the surface slot they draw from — for the CLI gfx oracle. public IEnumerable<(long Handle, int Slot)> Objects @@ -307,6 +310,7 @@ public sealed class GfxState _objects.Clear(); _fieldTable.Clear(); _surfaces.Clear(); + _createdSurfaces.Clear(); _movieStopTimesMs.Clear(); _surfaceTransitions.Clear(); CurrentObject = 0; @@ -316,6 +320,9 @@ public sealed class GfxState _rangeTransform = new GfxObject(); AnimClockDurationTicks = 0; AnimClockGeneration++; + AnimationServiceFlags = 0; + PreviousFrameTimeMilliseconds = 0; + CurrentFrameTimeMilliseconds = 0; } } @@ -323,6 +330,9 @@ public sealed class GfxState // ---- surfaces (image buffers per slot): ctx+0x52bd4[slot], from create/set-texture ---- private readonly Dictionary _surfaces = new(); + // Created surfaces have real pixels but no asset resource id. Keep their class separate from both + // loaded textures and truly surfaceless objects because native mode-0 consumes packed alpha differently. + private readonly HashSet _createdSurfaces = new(); // A separate entry models the native CMovieToTexture object attached to a surface. A null value means // the movie object exists but its host decoder supplied no usable IMediaPosition stop time. private readonly Dictionary _movieStopTimesMs = new(); @@ -332,6 +342,7 @@ public sealed class GfxState lock (_lock) { _surfaces[slot] = (resId, colorKey); + _createdSurfaces.Remove(slot); _movieStopTimesMs.Remove(slot); } } @@ -366,6 +377,7 @@ public sealed class GfxState for (int slot = firstSlot; slot < end; slot++) { _surfaces.Remove(slot); + _createdSurfaces.Remove(slot); _movieStopTimesMs.Remove(slot); _surfaceTransitions.Remove(slot); } @@ -463,12 +475,24 @@ public sealed class GfxState o.ColorAnim = true; } } + public void CreateSurface(int slot) + { + lock (_lock) + { + _surfaces[slot] = (0, -1); // create-texture: real mutable pixels, no asset id or color key + _createdSurfaces.Add(slot); + _movieStopTimesMs.Remove(slot); + } + } + public void ClearSurface(int slot) { lock (_lock) { - _surfaces[slot] = (0, 0); // create-texture (blank) + _surfaces.Remove(slot); + _createdSurfaces.Remove(slot); _movieStopTimesMs.Remove(slot); + _surfaceTransitions.Remove(slot); } } @@ -801,12 +825,27 @@ public sealed class GfxState lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; } } + public void SetAnimationServiceFlags(long flags) + { + lock (_lock) AnimationServiceFlags = unchecked((uint)flags); + } + + public void SampleFrameTime(long nowMilliseconds) + { + lock (_lock) + { + PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds; + CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds); + } + } + /// Op 0x243: force ordinary finite channels to their endpoints and reset the separate /// global animation-service clock. Op-0x242-detached objects ignore the completion request. public void ResetAnimClock() { lock (_lock) { + if ((AnimationServiceFlags & 2) != 0) return; ForceCompleteOneShotChannels(); AnimClockDurationTicks = 0; AnimClockGeneration++; @@ -856,7 +895,10 @@ public sealed class GfxState if (!o.Visible) continue; bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled; - var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, 0L); + // Created/mutable surfaces have no file resource id but remain a distinct surface class. + // Zero is an active "key black" value, so created and absent slots both default to -1. + var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, -1L); + bool createdSurface = _createdSurfaces.Contains(o.SourceSlot); // ---- packed color: a static mode 0 treats alpha as tint/fill strength. Once op 0x202 has // armed the one-shot channel, its current/target ARGB instead supplies opacity and D3D-style @@ -898,6 +940,13 @@ public sealed class GfxState // not a request to replace every texel with white. alpha = a; strength = 0; blend = BlendKind.Alpha; } + else if (createdSurface) + { + // Native created/render-target surfaces use packed alpha as object opacity. + // SYSTEM4/BUNKI relies on this for translucent panels; FIELD uses the same surface + // at alpha 0x40 beneath the minimap so the paper chrome remains visible. + alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true; + } else if (resId != 0) { // Native mode 0 is the opaque textured path. RGB modulates the source and the diff --git a/engine/Age.Engine/Sys4/RgbaSurfaceOps.cs b/engine/Age.Engine/Sys4/RgbaSurfaceOps.cs new file mode 100644 index 0000000..dd6a0e8 --- /dev/null +++ b/engine/Age.Engine/Sys4/RgbaSurfaceOps.cs @@ -0,0 +1,82 @@ +namespace Age.Engine.Sys4; + +/// Platform-neutral mutation helpers for AGE's software-modeled RGBA surfaces. +public static class RgbaSurfaceOps +{ + public static bool FillRect(RgbaImage destination, int x, int y, int width, int height, + byte alpha, long rgb) + { + long left = x, top = y, right = left + width, bottom = top + height; + if (width <= 0 || height <= 0) return false; + left = System.Math.Max(0, left); + top = System.Math.Max(0, top); + right = System.Math.Min(destination.Width, right); + bottom = System.Math.Min(destination.Height, bottom); + if (right <= left || bottom <= top) return false; + + byte red = (byte)((rgb >> 16) & 0xff); + byte green = (byte)((rgb >> 8) & 0xff); + byte blue = (byte)(rgb & 0xff); + for (int row = (int)top; row < (int)bottom; row++) + { + int offset = checked((row * destination.Width + (int)left) * 4); + for (int column = (int)left; column < (int)right; column++, offset += 4) + { + destination.Pixels[offset] = red; + destination.Pixels[offset + 1] = green; + destination.Pixels[offset + 2] = blue; + destination.Pixels[offset + 3] = alpha; + } + } + return true; + } + + public static RgbaImage WithColorKey(RgbaImage source, long colorKey) + { + if (!Age.Engine.Model.BlendMath.HasColorKey(colorKey)) return source; + byte[] pixels = (byte[])source.Pixels.Clone(); + for (int index = 0; index < pixels.Length; index += 4) + if (Age.Engine.Model.BlendMath.ColorKeyMatches( + pixels[index], pixels[index + 1], pixels[index + 2], colorKey)) + pixels[index + 3] = 0; + return new RgbaImage(source.Width, source.Height, pixels); + } + + /// Copy a rectangle while clipping source and destination together. A temporary buffer + /// preserves native blit behavior when the two rectangles overlap in the same surface. + public static bool CopyRect(RgbaImage source, RgbaImage destination, + int sourceX, int sourceY, int width, int height, + int destinationX, int destinationY) + { + long sx = sourceX, sy = sourceY, dx = destinationX, dy = destinationY; + long w = width, h = height; + if (w <= 0 || h <= 0) return false; + + if (sx < 0) { long n = -sx; sx = 0; dx += n; w -= n; } + if (sy < 0) { long n = -sy; sy = 0; dy += n; h -= n; } + if (dx < 0) { long n = -dx; dx = 0; sx += n; w -= n; } + if (dy < 0) { long n = -dy; dy = 0; sy += n; h -= n; } + w = System.Math.Min(w, source.Width - sx); + h = System.Math.Min(h, source.Height - sy); + w = System.Math.Min(w, destination.Width - dx); + h = System.Math.Min(h, destination.Height - dy); + if (w <= 0 || h <= 0) return false; + + int clippedWidth = checked((int)w); + int clippedHeight = checked((int)h); + int rowBytes = checked(clippedWidth * 4); + byte[] pixels = new byte[checked(rowBytes * clippedHeight)]; + for (int row = 0; row < clippedHeight; row++) + { + int sourceOffset = checked(((int)sy + row) * source.Width * 4 + (int)sx * 4); + source.Pixels.AsSpan(sourceOffset, rowBytes).CopyTo(pixels.AsSpan(row * rowBytes, rowBytes)); + } + for (int row = 0; row < clippedHeight; row++) + { + int destinationOffset = checked(((int)dy + row) * destination.Width * 4 + (int)dx * 4); + pixels.AsSpan(row * rowBytes, rowBytes) + .CopyTo(destination.Pixels.AsSpan(destinationOffset, rowBytes)); + } + return true; + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 4c7b45c..5bcba84 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -53,6 +53,12 @@ public sealed class VirtualMachine private volatile bool _advSkipServiceEnabled; private AdvTextStyle _advTextStyle = AdvTextStyle.Default; private readonly Dictionary _valueSwitchTargets = new(StringComparer.Ordinal); + // Native EngineCtx owns 11 lazily allocated integer FIFOs at +0x55130. ATSEEK/MVSEEK use + // slot zero as their packed-coordinate flood-fill worklist; op 0x132 replaces a slot. + private readonly Queue?[] _intQueues = new Queue?[11]; + // Opcodes 0x06/0x08 load scripts into numbered EngineCtx frame slots and invoke them later. + // Unlike ordinary call-script frames, native non-adjacent slots survive return with locals intact. + private readonly Dictionary _preloadedScriptSlots = new(); public long CallScriptDispatches { get; private set; } public Dictionary Globals { get; } = new(); @@ -493,6 +499,7 @@ public sealed class VirtualMachine private sealed class RootReloadRequestedException : Exception { } private sealed class ProcessExitRequestedException : Exception { } private sealed record DebugFrameReturnRequest(ExecFrame Frame, IReadOnlyDictionary GlobalWrites); + private sealed record PreloadedScriptSlot(long ScriptId, ExecFrame Frame); private enum FrameOutcome { Returned, DebugReturned, RootReload, ExitRequested, Halted, RanOff } public void Run(int entryOffset = 0) @@ -537,6 +544,7 @@ public sealed class VirtualMachine { Gfx.ResetSceneContext(); _valueSwitchTargets.Clear(); + _preloadedScriptSlots.Clear(); lock (_interactiveLock) { _interactiveFrame = null; @@ -717,6 +725,25 @@ public sealed class VirtualMachine case "string-not-equals": Write(a[0], string.Equals(ReadStr(a[1]), ReadStr(a[2]), StringComparison.Ordinal) ? 0 : 1); return pc + 1; + case "concat": + { + string left = ReadStr(a[1]); + string right = ReadStr(a[2]); + WriteStr(a[0], left + right); + return pc + 1; + } + case "toString": + WriteStr(a[0], unchecked((int)Read(a[1])).ToString(System.Globalization.CultureInfo.InvariantCulture)); + return pc + 1; + case "absolute-value": + { + int value = unchecked((int)Read(a[1])); + int sign = value >> 31; + Write(a[0], unchecked((value ^ sign) - sign)); + return pc + 1; + } + case "get-monotonic-time-ms": + Write(a[0], unchecked((int)_host.InputClockMilliseconds)); return pc + 1; case "lt": Write(a[0], Read(a[1]) < Read(a[2]) ? 1 : 0); return pc + 1; case "lte": Write(a[0], Read(a[1]) <= Read(a[2]) ? 1 : 0); return pc + 1; case "gr": Write(a[0], Read(a[1]) > Read(a[2]) ? 1 : 0); return pc + 1; @@ -840,6 +867,62 @@ public sealed class VirtualMachine } return pc + 1; } + case "u0041EF00": + case "reset-int-queue": // 0x132 (queue_id): destroy/recreate one of 11 native FIFO slots + { + int queueId = unchecked((int)Read(a[0])); + if ((uint)queueId >= (uint)_intQueues.Length) + { + HaltReason ??= $"int-queue-id-out-of-range:{queueId}"; + return HALT; + } + _intQueues[queueId] = new Queue(0x100); + return pc + 1; + } + case "u0041EFF0": + case "enqueue-int": // 0x133 (queue_id, value) + { + int queueId = unchecked((int)Read(a[0])); + if ((uint)queueId >= (uint)_intQueues.Length) + { + HaltReason ??= $"int-queue-id-out-of-range:{queueId}"; + return HALT; + } + if (_intQueues[queueId] is not { } queue) + { + HaltReason ??= $"int-queue-uninitialized:{queueId}"; + return HALT; + } + queue.Enqueue(unchecked((int)Read(a[1]))); + return pc + 1; + } + case "u0041F050": + case "try-dequeue-int": // 0x134 (queue_id, out_success, out_value) + { + int queueId = unchecked((int)Read(a[0])); + if ((uint)queueId >= (uint)_intQueues.Length) + { + HaltReason ??= $"int-queue-id-out-of-range:{queueId}"; + return HALT; + } + if (_intQueues[queueId] is not { } queue) + { + HaltReason ??= $"int-queue-uninitialized:{queueId}"; + return HALT; + } + if (queue.TryDequeue(out int value)) + { + Write(a[1], 1); + Write(a[2], value); + } + else + { + // Native writes success=0 and an implementation pointer to operand 3. Shipped + // callers branch on success before reading it, so retain the prior destination. + Write(a[1], 0); + } + return pc + 1; + } case "bit-set": { long bit = Read(a[1]); @@ -977,6 +1060,60 @@ public sealed class VirtualMachine if (outcome == FrameOutcome.ExitRequested) throw new ProcessExitRequestedException(); return pc + 1; // Returned / RanOff: resume caller } + case "u00417E80": + case "preload-script-slot": // 0x06 (script_id, frame_slot), valid slots 0..39 + { + long id = Read(a[0]); + int slot = unchecked((int)Read(a[1])); + if ((uint)slot >= 40) + { + HaltReason ??= $"preloaded-script-slot-out-of-range:{slot}"; + return HALT; + } + if (_provider == null) + { + HaltReason ??= $"preloaded-script-provider-unavailable:0x{id:x}"; + return HALT; + } + var script = _provider.GetById(id); + if (script == null) + { + HaltReason ??= $"preloaded-script-unresolved:0x{id:x}"; + return HALT; + } + int entry = script.IndexByOffset.TryGetValue(0, out int loadedEntry) ? loadedEntry : 0; + _preloadedScriptSlots[slot] = new PreloadedScriptSlot(id, new ExecFrame(script, entry)); + return pc + 1; + } + case "u00417FC0": + case "call-preloaded-script-slot": // 0x08 (frame_slot) + { + int slot = unchecked((int)Read(a[0])); + if ((uint)slot >= 40) + { + HaltReason ??= $"preloaded-script-slot-out-of-range:{slot}"; + return HALT; + } + if (!_preloadedScriptSlots.TryGetValue(slot, out var loaded)) + { + HaltReason ??= $"preloaded-script-slot-empty:{slot}"; + return HALT; + } + if (_depth >= _o.CallDepthCap) { HaltReason ??= "call-depth-exceeded"; return HALT; } + + CallScriptDispatches++; + _sink.Emit(TraceEvent.CallScript(loaded.ScriptId, loaded.Frame.Script.Name)); + // PC restarts at codebase while the native slot's local banks remain allocated. + // Balanced local calls leave this empty; clearing the port-only emission guard makes + // each invocation an independent diagnostic activation. + loaded.Frame.CallStack.Clear(); + loaded.Frame.EmitSeen.Clear(); + var outcome = RunFrame(loaded.Frame, FrameCause.CallScript, loaded.ScriptId); + if (outcome == FrameOutcome.Halted) return HALT; + if (outcome == FrameOutcome.RootReload) return ROOT_RELOAD; + if (outcome == FrameOutcome.ExitRequested) throw new ProcessExitRequestedException(); + return pc + 1; + } case "show-text": foreach (var o in a) { @@ -1398,7 +1535,7 @@ public sealed class VirtualMachine return pc + 1; case "create-texture": // 0x1f8 (slot)(w)(h) — allocate a blank surface at the slot _host.ReleaseSurface((int)Read(a[0])); - Gfx.ClearSurface((int)Read(a[0])); + Gfx.CreateSurface((int)Read(a[0])); _host.CreateTexture((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1; case "set-texture": // 0x1f9 (resId)(slot)(colorkey) — load a file into the slot's surface { @@ -1408,8 +1545,9 @@ public sealed class VirtualMachine System.Console.Error.WriteLine($"[settex] resId=0x{requestedResourceId:x}->0x{resolvedResourceId:x} slot={(int)Read(a[1])} " + $"slotOp=(type={a[1].Type} val=0x{a[1].Value:x}){(a[1].Type == 3 ? $" G[0x{a[1].Value:x}]" : "")}"); _host.ReleaseSurface((int)Read(a[1])); - Gfx.SetSurface((int)Read(a[1]), resolvedResourceId, a.Count > 2 ? Read(a[2]) : 0); - _host.SetTexture(resolvedResourceId, (int)Read(a[1])); + long colorKey = a.Count > 2 ? Read(a[2]) : -1; + Gfx.SetSurface((int)Read(a[1]), resolvedResourceId, colorKey); + _host.SetTexture(resolvedResourceId, (int)Read(a[1]), colorKey); return pc + 1; // host still tracks dims for get-texture-size } case "u00422EB0": // pre-reference compatibility @@ -1423,7 +1561,7 @@ public sealed class VirtualMachine int surfaceSlot = (int)Read(a[1]); _host.ReleaseSurface(surfaceSlot); Gfx.SetSurface(surfaceSlot, rawResourceId, Read(a[2])); - _host.SetTexture(rawResourceId, surfaceSlot); + _host.SetTexture(rawResourceId, surfaceSlot, Read(a[2])); return pc + 1; } case "draw-texture": // 0x1fb (handle)(slot)(srcX)(srcY)(w)(h)(dstX)(dstY) — bind object -> surface + rect + pos @@ -1471,6 +1609,11 @@ public sealed class VirtualMachine (int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), (int)System.Math.Min(Read(a[5]), 255), Read(a[6]) & 0x00ff_ffff)); return pc + 1; + case "copy-surface-rect": // 0x207: paired-clipped source-to-destination surface copy + _host.CopySurfaceRect(new SurfaceRectCopy( + (int)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]))); + return pc + 1; case "clear-retained-gfx-objects": // 0x1f6: erase object records, but preserve surfaces Gfx.ClearRetainedObjects(); return pc + 1; case "select-render-target": // 0x20d: slot <1000 selects a surface; >=1000 restores backbuffer @@ -1492,6 +1635,8 @@ public sealed class VirtualMachine _host.PlayVoice(Read(a[0]), 1); return pc + 1; case "set-voice-bgm-duck-control": // 0x1cf: bit 0 suppresses automatic voice ducking _host.SetVoiceBgmDuckControl(Read(a[0])); return pc + 1; + case "schedule-voice-playback": // 0x2c0: replace the pending delayed combat voice request + _host.ScheduleVoicePlayback(Read(a[0]), (int)Read(a[1]), Read(a[2])); return pc + 1; case "play-sound-effect": // 0xb4 / semantics: sfx-load _host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1; case "u0041D050": // 0xb5 / semantics: sfx-start @@ -1626,6 +1771,10 @@ public sealed class VirtualMachine Write(a[0], stopTimeMs.Value); return pc + 1; } + case "query-movie-surface-active": // 0x23a (out)(surface slot) + Write(a[0], _host.IsMovieSurfaceActive((int)Read(a[1])) ? 1 : 0); return pc + 1; + case "sample-frame-time": // 0x23c: previous <- current; current <- monotonic time + Gfx.SampleFrameTime(_host.InputClockMilliseconds); return pc + 1; case "set-gfx-geom3-c": // 0x1ff: set current translation matrix Gfx.SetCurrentTranslation(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1; case "u00420620": // upstream ABI label @@ -1666,6 +1815,8 @@ public sealed class VirtualMachine Gfx.SetOneShotAnimationControl(Read(a[0]), Read(a[1])); return pc + 1; case "reset-anim-clock": // 0x243: force unprotected one-shots and reset the global service clock Gfx.ResetAnimClock(); return pc + 1; + case "set-gfx-animation-service-flags": // 0x24e: bit 1 suppresses op 0x243 + Gfx.SetAnimationServiceFlags(Read(a[0])); return pc + 1; case "queue-surface-alpha-transition": // 0x223: target surface crossfade over two object ranges Gfx.QueueSurfaceAlphaTransition(Read(a[0]), (int)Read(a[1]), Read(a[2]), (int)Read(a[3]), Read(a[4]), (int)Read(a[5]), Read(a[6]), Read(a[7])); return pc + 1; diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 10b0fcd..8b8f791 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -17,6 +17,10 @@ public sealed class GodotAdvHost : IHost private readonly Stack _scriptContexts = new(); private readonly object _imageLock = new(); private readonly Dictionary _images = new(); // raw catalog id -> decoded pixels + // Mutable AGE surfaces are published by replacing immutable RgbaImage snapshots, so the compositor + // can safely finish reading an old frame while the VM prepares a copied-rectangle update. + private readonly Dictionary _surfaceImages = new(); + private readonly Dictionary _surfaceColorKeys = new(); private readonly Dictionary _surfaceResources = new(); // surface slot -> normalized raw catalog id private readonly Dictionary _movieFrames = new(); private readonly Dictionary _movieBySurface = new(); @@ -47,6 +51,8 @@ public sealed class GodotAdvHost : IHost private volatile bool _messageSkipActive; private int _voiceBgmDuckControl; private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice; + private readonly object _scheduledVoiceLock = new(); + private (AudioPayload Audio, int PlaybackVariant, uint DelayMs, uint? StartMs)? _scheduledVoice; private int _activeWaitLayout; private long _waitIndicatorStartedMs; private bool _waitIndicatorEnabled; @@ -218,10 +224,30 @@ public sealed class GodotAdvHost : IHost { lock (_textLock) { - if (!_surfaceText.TryGetValue(fill.SurfaceSlot, out var draws)) return; - int right = fill.X + System.Math.Max(0, fill.Width); - int bottom = fill.Y + System.Math.Max(0, fill.Height); - draws.RemoveAll(draw => draw.X >= fill.X && draw.X < right && draw.Y >= fill.Y && draw.Y < bottom); + if (_surfaceText.TryGetValue(fill.SurfaceSlot, out var draws)) + { + long right = (long)fill.X + System.Math.Max(0, fill.Width); + long bottom = (long)fill.Y + System.Math.Max(0, fill.Height); + draws.RemoveAll(draw => draw.X >= fill.X && draw.X < right + && draw.Y >= fill.Y && draw.Y < bottom); + } + } + + RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot); + if (destination == null && _slotDims.TryGetValue(fill.SurfaceSlot, out var dimensions) + && dimensions.W >= 0 && dimensions.H >= 0) + destination = new RgbaImage(dimensions.W, dimensions.H, + new byte[checked(dimensions.W * dimensions.H * 4)]); + if (destination != null) + { + var updated = new RgbaImage(destination.Width, destination.Height, + (byte[])destination.Pixels.Clone()); + if (RgbaSurfaceOps.FillRect(updated, fill.X, fill.Y, fill.Width, fill.Height, + unchecked((byte)fill.Alpha), fill.Rgb)) + { + lock (_imageLock) _surfaceImages[fill.SurfaceSlot] = updated; + System.Threading.Interlocked.Exchange(ref _presentRequested, 1); + } } _timeline?.Event("surface-fill", new() { @@ -663,6 +689,7 @@ public sealed class GodotAdvHost : IHost _messageSkipActive = false; _queuedSkippedVoice = null; } + lock (_scheduledVoiceLock) _scheduledVoice = null; System.Threading.Volatile.Write(ref _voiceBgmDuckControl, 0); _advPagePresentationSuspended = false; _modalMovieCancelled = false; @@ -685,7 +712,33 @@ public sealed class GodotAdvHost : IHost } // Main thread, once per rendered frame: releases a VM thread parked in Sleep or a presentation/input wait. - public void PulseFrame() => _frameSignal.Set(); + public void PulseFrame() + { + (AudioPayload Audio, int PlaybackVariant)? due = null; + lock (_scheduledVoiceLock) + { + if (_scheduledVoice is { } pending) + { + uint now = unchecked((uint)_clock.NowMs); + if (!pending.StartMs.HasValue) + _scheduledVoice = pending with { StartMs = now }; + else if (unchecked(now - pending.StartMs.Value) >= pending.DelayMs) + { + due = (pending.Audio, pending.PlaybackVariant); + _scheduledVoice = null; + } + } + } + if (due.HasValue) + { + _timeline?.Event("voice-scheduled-start", new() + { + ["file"] = due.Value.Audio.Name, ["playback_variant"] = due.Value.PlaybackVariant, + }); + DispatchVoice(due.Value.Audio, due.Value.PlaybackVariant); + } + _frameSignal.Set(); + } // Ordinary opcode bursts run to the next service boundary without frame pacing. Persistent message // Skip removes most of those boundaries, but native adv_interpreter_tick still executes one opcode per @@ -732,12 +785,27 @@ public sealed class GodotAdvHost : IHost { lock (_textLock) _surfaceText.Remove(slot); lock (_textLock) _surfaceResources.Remove(slot); - _slotDims[slot] = (width, height); + int safeWidth = System.Math.Max(0, width); + int safeHeight = System.Math.Max(0, height); + lock (_imageLock) + { + _surfaceImages[slot] = new RgbaImage(safeWidth, safeHeight, + new byte[checked(safeWidth * safeHeight * 4)]); + _surfaceColorKeys.Remove(slot); + } + _slotDims[slot] = (safeWidth, safeHeight); if (TraceOps) Godot.GD.Print($"[op] create-texture slot={slot} {width}x{height}"); } - public void SetTexture(long resourceId, int slot) + public void SetTexture(long resourceId, int slot) => SetTexture(resourceId, slot, -1); + + public void SetTexture(long resourceId, int slot, long colorKey) { + lock (_imageLock) + { + _surfaceImages.Remove(slot); + _surfaceColorKeys[slot] = colorKey; + } lock (_textLock) { _surfaceText.Remove(slot); @@ -757,6 +825,49 @@ public sealed class GodotAdvHost : IHost // the visible objects each frame in ascending-handle order. No immediate blit here. public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } + public void CopySurfaceRect(SurfaceRectCopy copy) + { + RgbaImage? source = ResolveSurfacePixels(copy.SourceSurface); + RgbaImage? destination = ResolveSurfacePixels(copy.DestinationSurface); + if (destination == null && _slotDims.TryGetValue(copy.DestinationSurface, out var dimensions) + && dimensions.W >= 0 && dimensions.H >= 0) + destination = new RgbaImage(dimensions.W, dimensions.H, + new byte[checked(dimensions.W * dimensions.H * 4)]); + if (source == null || destination == null) + { + ReportWarning($"surface copy unresolved source={copy.SourceSurface} destination={copy.DestinationSurface}"); + return; + } + + var updated = new RgbaImage(destination.Width, destination.Height, (byte[])destination.Pixels.Clone()); + if (RgbaSurfaceOps.CopyRect(source, updated, copy.SourceX, copy.SourceY, copy.Width, copy.Height, + copy.DestinationX, copy.DestinationY)) + { + lock (_imageLock) _surfaceImages[copy.DestinationSurface] = updated; + System.Threading.Interlocked.Exchange(ref _presentRequested, 1); + } + _timeline?.Event("surface-copy", new() + { + ["source"] = copy.SourceSurface, ["source_x"] = copy.SourceX, ["source_y"] = copy.SourceY, + ["w"] = copy.Width, ["h"] = copy.Height, ["destination"] = copy.DestinationSurface, + ["destination_x"] = copy.DestinationX, ["destination_y"] = copy.DestinationY, + }); + } + + private RgbaImage? ResolveSurfacePixels(int slot) + { + lock (_imageLock) + if (_surfaceImages.TryGetValue(slot, out var mutable)) return mutable; + long resourceId; + lock (_textLock) + if (!_surfaceResources.TryGetValue(slot, out resourceId)) return null; + var resolved = ResolveResIdTexture(resourceId); + if (resolved == null) return null; + long colorKey; + lock (_imageLock) colorKey = _surfaceColorKeys.GetValueOrDefault(slot, -1); + return RgbaSurfaceOps.WithColorKey(resolved.Value.Image, colorKey); + } + /// Resolve a gfx surface through scene-local or universal raw-id addressing and decode it /// from the loose-first asset store. public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId) @@ -775,6 +886,15 @@ public sealed class GodotAdvHost : IHost return asset != null && image != null ? (image, asset.Name, asset.RawIndex, false) : null; } + public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture( + int surfaceSlot, long fallbackResourceId) + { + lock (_imageLock) + if (_surfaceImages.TryGetValue(surfaceSlot, out var surface)) + return (surface, $"", int.MinValue + surfaceSlot, true); + return fallbackResourceId != 0 ? ResolveResIdTexture(fallbackResourceId) : null; + } + public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) { string scene = CurrentScene; @@ -784,6 +904,13 @@ public sealed class GodotAdvHost : IHost out long? stopTimeMs) ? stopTimeMs : null; } + public bool IsMovieSurfaceActive(int surfaceSlot) + { + lock (_imageLock) + return _movieBySurface.TryGetValue(surfaceSlot, out long resourceId) + && !_completedMovies.Contains(resourceId); + } + public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) { var asset = _res.ResolveRawMovie(rawResourceId); @@ -873,6 +1000,8 @@ public sealed class GodotAdvHost : IHost { if (!_movieBySurface.Remove(slot, out resourceId)) { + _surfaceImages.Remove(slot); + _surfaceColorKeys.Remove(slot); lock (_textLock) { _surfaceText.Remove(slot); @@ -888,6 +1017,8 @@ public sealed class GodotAdvHost : IHost } _movieFrames.Remove(resourceId); _completedMovies.Remove(resourceId); + _surfaceImages.Remove(slot); + _surfaceColorKeys.Remove(slot); } lock (_textLock) { @@ -925,6 +1056,8 @@ public sealed class GodotAdvHost : IHost _movieFrames.Remove(resourceId); _completedMovies.Remove(resourceId); } + _surfaceImages.Remove(slot); + _surfaceColorKeys.Remove(slot); _slotDims.Remove(slot); } } @@ -1037,6 +1170,21 @@ public sealed class GodotAdvHost : IHost _timeline?.State("voice-bgm-duck-control", new() { ["flags"] = flags }); } + public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs) + { + var asset = _res.ResolveVoice(CurrentScene, id); + var audio = asset != null ? LoadAudio(asset) : null; + _timeline?.Event("voice-scheduled", new() + { + ["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant, + ["delay_ms"] = unchecked((uint)delayMs), + }); + lock (_scheduledVoiceLock) + _scheduledVoice = audio == null + ? null + : (audio, playbackVariant, unchecked((uint)delayMs), null); + } + public void LoadSoundEffect(long resourceId, int channel) { if ((uint)channel >= (uint)_sfxNames.Length) return; diff --git a/godot/Main.cs b/godot/Main.cs index 504a649..45177b9 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -673,6 +673,10 @@ public partial class Main : Godot.Control int dstY = (int)System.Math.Round(projected.Y); float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity float strength = v.TintStrength / 255f; // tint-blend / fill strength + var rawObject = _vm.Gfx.TryGet(v.Handle); + var surfaceTexture = rawObject != null + ? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId) + : null; string outcome; if (v.SurfaceTransition is { } transition) { @@ -680,7 +684,7 @@ public partial class Main : Godot.Control outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " + $"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}"; } - else if (v.SurfaceResId == 0) + else if (v.SurfaceResId == 0 && surfaceTexture == null) { // A colored object with no bound surface = a fade/flash fill (e.g. fade-to-black). Its presence // is the tint STRENGTH (0=absent, 255=solid), scaled by any object opacity. Uncolored surfaceless @@ -702,24 +706,22 @@ public partial class Main : Godot.Control } else { - var texture = _host.ResolveResIdTexture(v.SurfaceResId); + var texture = surfaceTexture ?? _host.ResolveResIdTexture(v.SurfaceResId); if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)"; else { BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H, localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend); - var raw = _vm.Gfx.TryGet(v.Handle); - outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " + + outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " + $"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " + $"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " + $"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " + - $"mode={raw?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" + + $"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" + ColorTimeline(v.ColorTransition); } } if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}"; - var rawObject = _vm.Gfx.TryGet(v.Handle); if (includeSurfaceText && rawObject != null) { foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot)) @@ -880,7 +882,11 @@ public partial class Main : Godot.Control if (source.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform); float opacity = source.Alpha / 255f * (float)transition.Progress; - if (source.SurfaceResId == 0) + var rawObject = _vm.Gfx.TryGet(source.Handle); + var texture = rawObject != null + ? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId) + : null; + if (source.SurfaceResId == 0 && texture == null) { if (source.Blend == BlendKind.Opaque) continue; int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600; @@ -888,7 +894,7 @@ public partial class Main : Godot.Control } else { - var texture = _host.ResolveResIdTexture(source.SurfaceResId); + texture ??= _host.ResolveResIdTexture(source.SurfaceResId); if (texture == null) continue; BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f, source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint, diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 413bce5..57c47e5 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -127,49 +127,51 @@ evidence = "" [[opcode]] op = 0x6 -label = "u00417E80" +label = "preload-script-slot" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00417E80" -category = "unknown" -summary = "" +name = "preload-script-slot" +category = "control" +summary = "(script_id, frame_slot) - load and allocate a script into a numbered engine context slot without executing it. Valid slots are 0..39." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x6_preload_script_slot@0x41bdb0 fetches script_id and frame_slot, saves the current context index, selects frame_slot, rejects values above 39, calls script_frame_load_resource(ctx+0x54fe8, script_id), restores the caller index, and throws on load failure. SYSTEM4's only three sites preload ATSEEK.BIN (0x337f) into slot 0x1d, SETROUTE.BIN (0x3380) into 0x1e, and MVSEEK.BIN (0x3381) into 0x1f before INIT2." +details = "Implemented as persistent VM-owned preloaded slots containing the resolved script id and one reusable ExecFrame. Replacing a slot allocates a fresh frame/local bank; invalid slots, absent providers, and unresolved scripts halt diagnostically. Root scene reload clears the slots before SYSTEM4 registers them again." [[opcode.semantics.args]] i = 1 -role = "" +role = "script_id" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "frame_slot" observed_types = ["imm"] [[opcode]] op = 0x8 -label = "u00417FC0" +label = "call-preloaded-script-slot" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00417FC0" -category = "unknown" -summary = "" +name = "call-preloaded-script-slot" +category = "control" +summary = "(frame_slot) - restart and execute the script previously loaded into that engine context slot, returning to the caller when it exits." noop_headless = false -source = "kelebek" -confidence = "low" -depends_on = [] -evidence = "" +source = "investigation" +confidence = "high" +depends_on = [0x6] +evidence = "Ghidra /v2: op_0x8_call_preloaded_script_slot@0x41bf00 fetches frame_slot, switches cur_ctx_index to it, errors if frame_codebase is null, stores the caller context index into the selected slot's ctx_record_base, resets its PC to codebase and instruction length to zero, and returns to the dispatcher. op_0x2_exit_or_return_frame@0x417940 disposes only an adjacent child (parent+1==current); SYSTEM4's non-adjacent slots 0x1d..0x1f therefore retain their allocated local banks between calls. Corpus has 64 sites, exclusively slots 0x1d/0x1e/0x1f. FIELD uses 0x1f for MVSEEK, 0x1d for ATSEEK, and 0x1e for SETROUTE." +details = "Implemented by recursively executing the reusable preloaded ExecFrame while preserving its local banks across invocations, restarting at script offset zero, and propagating halt/root-reload/exit outcomes like ordinary call-script. The port emits normal call-script trace events for observability. Focused tests prove code restart plus local persistence and drive the real MVSEEK through SYSTEM4's exact 0x06/0x08 ABI." [[opcode.semantics.args]] i = 1 -role = "" +role = "frame_slot" observed_types = ["imm"] [[opcode]] @@ -2104,23 +2106,23 @@ evidence = "Ghidra /v2: op_0xcd_dispatch_mouse_callback@0x417e10 compares timeGe [[opcode]] op = 0xd0 -label = "u00415830" +label = "get-monotonic-time-ms" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415830" -category = "unknown" -summary = "" +name = "get-monotonic-time-ms" +category = "control" +summary = "Write the native monotonic millisecond clock to operand 1. Battle presentation uses paired samples around timed callback sequences to calculate elapsed time." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0xd0_handler@0x428860 calls imp_winmm_timeGetTime and writes the returned 32-bit tick count to operand 1. BTL samples it before and after its timed HP/damage presentation; MVRTN has the other two corpus calls." [[opcode.semantics.args]] i = 1 -role = "" +role = "out_time_ms" observed_types = ["l-int"] [[opcode]] @@ -2658,80 +2660,83 @@ observed_types = ["g-int", "l-int"] [[opcode]] op = 0x132 -label = "u0041EF00" +label = "reset-int-queue" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041EF00" -category = "unknown" -summary = "" +name = "reset-int-queue" +category = "compute" +summary = "(queue_id) - destroy any existing queue in the selected engine slot and replace it with an empty integer FIFO. Valid queue ids are 0..10." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x132_reset_int_queue@0x4217d0 fetches queue_id, rejects values above 10, invokes the existing object's virtual destructor, allocates 0x1c bytes, and calls int_queue_construct@0x4074c0. The constructor allocates 0x100 dwords, sets capacity and growth quantum to 0x100, and zeros the read/end/high-water indices. The only corpus sites are ATSEEK@0x32 and MVSEEK@0x145, immediately before packing and enqueueing the origin coordinate for their flood fills." +details = "Implemented as 11 VM-lifetime queue slots. Reset replaces the selected queue with an empty FIFO pre-sized to the native 0x100-dword initial capacity; invalid ids halt with a diagnostic." [[opcode.semantics.args]] i = 1 -role = "" +role = "queue_id" observed_types = ["imm"] [[opcode]] op = 0x133 -label = "u0041EFF0" +label = "enqueue-int" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041EFF0" -category = "unknown" -summary = "" +name = "enqueue-int" +category = "compute" +summary = "(queue_id, value) - append one integer to the selected engine FIFO, compacting consumed entries or growing its storage when required." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x133_enqueue_int@0x4218d0 validates queue_id 0..10, fetches value, and calls int_queue_enqueue@0x408930 on ctx's selected queue. The helper appends at end_index, compacts unread entries when read_index is nonzero, or grows capacity by the 0x100-dword quantum. All four corpus sites are in ATSEEK/MVSEEK and enqueue coordinates packed as (x << 16) + y." +details = "Implemented with signed 32-bit value normalization into the selected FIFO. The port diagnoses invalid or never-reset slots; every shipped use resets queue 0 before enqueueing." [[opcode.semantics.args]] i = 1 -role = "" +role = "queue_id" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "value" observed_types = ["l-int"] [[opcode]] op = 0x134 -label = "u0041F050" +label = "try-dequeue-int" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041F050" -category = "unknown" -summary = "" +name = "try-dequeue-int" +category = "compute" +summary = "(queue_id, out_success, out_value) - consume the next integer from the selected FIFO, writing success=1 and the value; write success=0 when empty." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x134_try_dequeue_int@0x429620 validates queue_id 0..10 and compares the selected queue's read_index with end_index. When nonempty it reads data[read_index], increments read_index, updates the high-water index, writes 1 to operand 2, and writes the item to operand 3; when empty it writes 0 to operand 2. Native still writes a non-item implementation value to operand 3 on failure, but both shipped callers branch on out_success before reading out_value. ATSEEK and MVSEEK use the opcode as the loop head for their coordinate flood fills." +details = "Implemented as FIFO TryDequeue: nonempty writes success=1 plus the signed dword; empty writes success=0 and retains the prior value destination because native's failure value is an unusable implementation pointer. Focused tests cover slot independence, ordering, empty/reset behavior, and signed values; real MVSEEK/ATSEEK regressions prove both searches expand beyond the origin." [[opcode.semantics.args]] i = 1 -role = "" +role = "queue_id" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "out_success" observed_types = ["l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "out_value" observed_types = ["l-int"] [[opcode]] @@ -3028,28 +3033,28 @@ observed_types = ["imm"] [[opcode]] op = 0x191 -label = "u0041A4A0" +label = "absolute-value" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041A4A0" -category = "unknown" -summary = "" +name = "absolute-value" +category = "compute" +summary = "Write the signed 32-bit absolute value of operand 2 to operand 1." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x191_handler@0x426de0 computes (value ^ (value >> 31)) - (value >> 31) and writes it through vm_operand_write. All five Himegari calls are in SELACT, where it normalizes a signed preview delta before drawing it." [[opcode.semantics.args]] i = 1 -role = "" +role = "out_absolute_value" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "value" observed_types = ["g-int"] [[opcode]] @@ -3086,27 +3091,27 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "concat" -category = "unknown" -summary = "" +category = "compute" +summary = "Concatenate operand 2 followed by operand 3 and replace the destination string. Sources are resolved before the write, so destination/source aliasing is supported." noop_headless = false -source = "kelebek" -confidence = "med" +source = "inference" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Kelebek identifies param1 = param2.concat(param3). Corpus ordering confirms direction and aliasing: ADDEXP builds level/result messages with both literal-prefix concat(dst, literal, dst) and append concat(dst, dst, literal); BTL has eight calls." [[opcode.semantics.args]] i = 1 -role = "" +role = "destination" observed_types = ["g-str", "l-str"] [[opcode.semantics.args]] i = 2 -role = "" +role = "left" observed_types = ["string", "g-str", "l-str", "l-str-ptr"] [[opcode.semantics.args]] i = 3 -role = "" +role = "right" observed_types = ["string", "g-str", "l-str", "l-ptr", "l-str-ptr"] [[opcode]] @@ -4154,22 +4159,22 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "toString" -category = "unknown" -summary = "" +category = "compute" +summary = "Convert the source signed 32-bit integer to its invariant decimal string and replace the destination string." noop_headless = false -source = "kelebek" -confidence = "med" +source = "inference" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Kelebek identifies integer-to-string conversion. All five corpus sites are in ADDEXP and feed concat immediately: level numbers, signed deployment-cost deltas, and movement deltas. Source types are global/local integer and the destination is a local string." [[opcode.semantics.args]] i = 1 -role = "" +role = "destination" observed_types = ["l-str"] [[opcode.semantics.args]] i = 2 -role = "" +role = "signed_integer" observed_types = ["g-int", "l-int"] [[opcode]] @@ -4962,58 +4967,59 @@ observed_types = ["imm"] [[opcode]] op = 0x207 -label = "u00420B00" +label = "copy-surface-rect" argc = 8 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00420B00" -category = "unknown" -summary = "" +name = "copy-surface-rect" +category = "draw" +summary = "Copy a rectangular pixel region between mutable graphics surfaces: (source_surface, destination_surface, source_x, source_y, width, height, destination_x, destination_y)." +details = "The worker clips the paired source and destination rectangles against both surfaces while preserving their correspondence, returns successfully for an empty clipped rectangle, and marks the destination surface dirty. Himegari uses the opcode for minimap markers plus STATUS, READICON, and DRAWTIP surface composition." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x207_handler@0x422b50 constructs source [x,y,x+w,y+h] and destination [dx,dy,dx+w,dy+h] rectangles and calls gfx_copy_surface_rect@0x477da0. The worker validates both surface slots, clips both rectangles together, marks the destination dirty, and copies through locked D3D surfaces. Corpus: 15 calls total: DRAWMINIMAP 8, STATUS 3, READICON 2, DRAWTIP 2." [[opcode.semantics.args]] i = 1 -role = "" +role = "source_surface" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "destination_surface" observed_types = ["imm", "g-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "source_x" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 4 -role = "" +role = "source_y" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 5 -role = "" +role = "width" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 6 -role = "" +role = "height" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 7 -role = "" +role = "destination_x" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 8 -role = "" +role = "destination_y" observed_types = ["imm", "l-int"] [[opcode]] @@ -6272,28 +6278,28 @@ observed_types = ["imm"] [[opcode]] op = 0x23a -label = "u00422420" +label = "query-movie-surface-active" argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00422420" -category = "unknown" -summary = "" +name = "query-movie-surface-active" +category = "draw" +summary = "Write whether a movie-backed surface has a nonzero playback/synchronization state at surface object offset 0x42c; an empty surface slot writes zero." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x23a_handler@0x42a440 indexes the surface table by operand 2, writes zero for a null slot, otherwise writes surface+0x42c != 0. All four corpus sites are movie completion polling loops: BTL checks active combat-movie surfaces 7+, while FIELD and USEMAGIC poll surface 42 between present-frame, frame-time sampling, and sleep(16)." [[opcode.semantics.args]] i = 1 -role = "" +role = "out_active" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "surface_slot" observed_types = ["imm", "l-int"] [[opcode]] @@ -6350,19 +6356,19 @@ observed_types = ["imm"] [[opcode]] op = 0x23c -label = "u004162B0" +label = "sample-frame-time" argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004162B0" -category = "unknown" -summary = "" +name = "sample-frame-time" +category = "draw" +summary = "Shift the current retained-presentation timestamp to the previous-frame field, then sample the native monotonic millisecond clock as the new current timestamp." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x23c_handler@0x417580 copies EngineCtx frame_timer at +0x51b64 to +0x51b68, then stores imp_winmm_timeGetTime() at +0x51b64. BTL, ADDEXP, SHOWGROW, USEMAGIC, and FIELD place it at presentation/present-frame boundaries." [[opcode]] op = 0x23d @@ -6622,23 +6628,23 @@ observed_types = ["imm"] [[opcode]] op = 0x24e -label = "u00422EA0" +label = "set-gfx-animation-service-flags" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00422EA0" -category = "unknown" -summary = "" +name = "set-gfx-animation-service-flags" +category = "draw" +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." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x24e_handler@0x425070 writes operand 1 directly to EngineCtx.gfx_animation_service_flags at +0x51b80. The mapped field is also read by op 0x243: bit 1 suppresses its force-complete/clock-reset request." [[opcode.semantics.args]] i = 1 -role = "" +role = "flags" observed_types = ["imm"] [[opcode]] @@ -6737,33 +6743,34 @@ observed_types = ["imm", "l-int"] [[opcode]] op = 0x2c0 -label = "u004231C0" +label = "schedule-voice-playback" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004231C0" -category = "unknown" -summary = "" +name = "schedule-voice-playback" +category = "audio" +summary = "Arm delayed voice playback: (voice_id, playback_variant, delay_ms). The engine main tick starts the voice after the monotonic deadline." +details = "The setter replaces the single pending request, marks it active, and clears its start timestamp. On the first service tick the worker captures the current millisecond time; once unsigned elapsed time reaches delay_ms it clears the request and calls the ordinary indexed-voice player with voice_id and playback_variant. BTL's only call selects a randomized combat voice id, variant 0, and an entity-specific delay." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x2c0_schedule_voice_playback@0x425290 forwards three operands to voice_schedule_delayed_playback@0x488480 on the text/ADV service at EngineCtx+0x14508. That worker stores active=1, start=0, delay at +0x424, voice id at +0x428, and variant at +0x42c. engine_main_tick_with_exception_policy calls voice_tick_delayed_playback@0x4884d0; after unsigned elapsed >= delay it clears the request and calls voice_play_indexed_asset(voice_id,variant). Corpus: one BTL site at 0x2f6e." [[opcode.semantics.args]] i = 1 -role = "" +role = "voice_id" observed_types = ["l-ptr"] [[opcode.semantics.args]] i = 2 -role = "" +role = "playback_variant" observed_types = ["imm"] [[opcode.semantics.args]] i = 3 -role = "" +role = "delay_ms" observed_types = ["l-ptr"] [[opcode]]