From cffc75c64df7ff6dfed7b67c9995f4d609ed036a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 00:47:29 -0400 Subject: [PATCH] Implement ROOM fades and character voices --- docs/asset-resolution-re.md | 18 ++- docs/engine-re.md | 53 ++++++- docs/global-reference.md | 4 +- docs/opcode-reference.md | 37 +++-- docs/phase-b-framework.md | 19 ++- engine/Age.Cli/Program.cs | 4 +- .../CoreScalarAndScreenTransitionOpsTests.cs | 69 +++++++++ .../Age.Engine.Tests/Sys4AssetStoreTests.cs | 9 +- engine/Age.Engine.Tests/TestSupport.cs | 3 + engine/Age.Engine/Hosting/IHost.cs | 3 + .../Model/LegacyScreenTransitionTiming.cs | 13 ++ engine/Age.Engine/Sys4/ResourceMap.cs | 8 + engine/Age.Engine/Vm/VirtualMachine.cs | 25 +++- godot/GodotAdvHost.cs | 141 +++++++++++++++++- godot/Main.cs | 34 ++++- tools/age_opcodes_himegari.py | 3 + vm-map/globals.toml | 11 ++ vm-map/opcodes.toml | 59 ++++---- 18 files changed, 442 insertions(+), 71 deletions(-) create mode 100644 engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs create mode 100644 engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 3665b9a..fa3e796 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -65,7 +65,9 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b > **`resId → files[ section_base(scene) + resId ]`**, where `section_base` = the start of the SYS4INI > section containing the scene's `SCxxxx.BIN`. - Manifest rule holds for `set-texture(resId)` and `play-voice(id)`. **⚠ `play-bgm` is the EXCEPTION — + The manifest rule holds first for `set-texture(resId)` and `play-voice(id)`. Non-SC frontend scripts + such as ROOM instead carry universal raw catalog ids, so both typed resolvers fall back to raw lookup + when the executing script has no SC section. **⚠ `play-bgm` is the EXCEPTION — it does NOT use the manifest; it uses direct literal names `BGM{id:03d}.OGG` (see step 4, by-ear corrected 2026-07-06).** **Tool:** `tools/resolve_asset.py --build` → `build/asset-sections.json` (359 sections, 136 scenes); `resolve_asset.py [resId]` resolves. **Validated:** `file_number == @@ -81,7 +83,8 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b `set-texture(G[0x69b], slot=0x11)`. SYS4INI `raw_index 0x337e` is `DATA1/SO001.AGF`, the shared 800×300 RGBA system-chrome sheet. `ResourceMap.ResolveTexture` therefore tries the active script's scene manifest first and then the distinct universal raw-id lookup; the scene-manifest rule above - remains correct for ordinary SC texture/voice ids. + remains correct for ordinary SC texture/voice ids. ROOM voice `0x3365`, for example, resolves as raw + `EUA0016.OGG`; treating it only as a ROOM-local id produces no asset because ROOM owns no SC range. *How we got here (condensed):* first confirmed `resId == file_number` via Frida load-order correlation for SC0000's opening, but `file_number` is not globally unique so a per-scene "scope" was needed. A long @@ -124,7 +127,8 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b → `--selftest`/8-8 byte-identical. SC0000 fires 18 BGM + 198 voice. **By-ear VALIDATED (2026-07-06):** voices play on their lines (`play-voice` med→HIGH). **BUT the two audio ops use DIFFERENT addressing — the earlier "unified graphics+audio manifest" claim was WRONG for BGM:** - - **Voice** (`play-voice`) → per-scene manifest, `files[base+id]`, **offset 0** (same as textures). Proven: + - **Voice** (`play-voice`) → SC-section manifest `files[base+id]` at **offset 0**, with a type-checked + universal-raw fallback for frontend scripts without an SC section (same as textures). Proven: the manifest interleaves graphics/voice (`files[35]=EV049AA`, `[36]=MAN999`, `[37]=EV052CA`, `[38]=SYL0001`), so `id-1` would land voices on `.AGF` (silent) — they play, so offset is exactly 0. - **BGM** (`play-bgm`) → **DIRECT LITERAL NAME**, `id → BGM{id:03d}.OGG` (DATA3), NOT the manifest. @@ -152,12 +156,14 @@ rendering what the executed bytecode + the map produce (never a hardcoded image) A2b-background: **steps 1–3 landed.** Step 1 = `build/asset-index.json`. Step 2 = **`resId → files[section_base(scene) + resId]`** via SYS4INI per-scene sections (`tools/resolve_asset.py` + -`build/asset-sections.json`) — no runtime capture, all archives/types plus scene-local voice. Step 3 = **first-pass +`build/asset-sections.json`) — no runtime capture, all archives/types plus SC-section voice and typed raw +fallback for non-SC frontend scripts. Step 3 = **first-pass render** (ResourceMap + GodotAdvHost texture ops → TextureRect compositing): the full-screen event-CG layer renders end-to-end from the bytecode. Remaining (next chunk): the **graphics geometry/blend subsystem** — native geometry ops (`0x208` + sprite position/animation) so sprites/`BG*` position, plus alpha/blend for fades + chromakey. See `docs/phase-a-slice-plan.md` (A2b). Audio (step 4): **`play-voice` -uses the manifest** (`files[base+id]`); **`play-bgm` uses direct names** (`BGM{id:03d}.OGG`) — NOT unified. +uses the SC manifest first** (`files[base+id]`) and universal raw lookup for non-SC frontend scripts; +**`play-bgm` uses direct names** (`BGM{id:03d}.OGG`) — NOT unified. ## Native SFX resource proof (2026-07-11; addressing corrected 2026-07-20) @@ -222,7 +228,7 @@ store. 4. **Runtime consumers (complete for scripts, textures, and current audio families).** `ResourceMap.ResolveTexture` preserves scene-local resolution and falls back to universal raw ids for SYSTEM4 assets; `GodotAdvHost` caches decoded RGBA surfaces by catalog identity and supplies synchronous dimensions to opcode `0x208`. - Godot no longer reads `build/textures/*.BMP`. BGM direct-name, scene-local voice, and packed-raw SFX + Godot no longer reads `build/textures/*.BMP`. BGM direct-name, scene-first/raw-fallback voice, and packed-raw SFX entries are opened through the same `IAssetStore`. `ResourceMap.ResolveSoundEffect` owns op `0xb4`'s `ResolvePacked` lookup and audio filtering; the existing Godot WAV/channel path consumes its result. Extraction and conversion tools remain diagnostics. diff --git a/docs/engine-re.md b/docs/engine-re.md index dc9c6b7..0063fc7 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2135,13 +2135,64 @@ animation contract. No boot seed, script-offset branch, or persistence state is --- +### ROOM character selection, profile voice defaults, and legacy screen crossfade — `0x60` / `0x6c` / `0x25` (2026-07-21) + +ROOM's apparently connected missing greeting and instant menu presentation were three separate native +contracts. Opcode `0x60` is the random selector, `0x6c` establishes the default voice-setting array, and +`0x25` owns the blocking transition between two complete offscreen frames. + +`op_0x60_handler@0x426970` calls the imported CRT `rand`, fetches operand 2, and writes +`rand() % bound` to operand 1. Bound zero first writes zero and then raises script error `0x10005`. +`ROOM.BIN@0x5` passes bound 4; the result selects four character resource sets. Results 0, 1, and 2 also +select six voice ids each (`0x3365..0x3376`), including their greeting/farewell pair. Result 3 deliberately +leaves those locals zero in the release script, so that presentation variant is silent by script design. + +The voice-enabled default was being defeated earlier in boot. `op_0x6c_handler@0x426d90` resolves operand +1 as a writable integer address and fills operand-2 consecutive dwords with `EngineCtx::anti_tamper_b`, the +native encoded representation of logical zero. It is a zero-range operation, not the scalar +`copy-to-global` suggested by Kelebek's label. `INITCONFIG@0x30` therefore clears the 13-cell character +voice-setting table `G[0x2e49..0x2e55]`, after which `0x1a2` registers each cell with the shared profile +service. CONFIG indexes the same table for voice preview and its 0/1 enable/suppress choices. The old port +instead wrote the scalar count 13 into `G[0x2e49]`; ROOM treats a nonzero value in cell 0 as voice suppression and +skipped all greeting/farewell ids. Correct zero-range execution fixes the boot default without implementing +or choosing a persistence backend for `0x1a2`/`0x1a3`. + +`op_0x25_handler@0x41ce00` starts the legacy transition manager at `EngineCtx+0x1c38`, sets run-state bit +8, and keeps the script parked until the transition completes. Its three operands are source surface, +target surface, and a timing argument. For arguments up to 64, the handler arms that many milliseconds per +tick and advances the 8-bit alpha by 16. Larger arguments use `argument/16` milliseconds and alpha step +1. The branch is expressed in decompilation as `((arg < 65) - 1 & 0xfffffff1) + 0x10`; evaluating both +outcomes is significant (`true -> 16`, `false -> 1`). `interval_timer_poll_elapsed_steps@0x44d080` returns any missed timer intervals, and +`screen_transition_tick@0x43a7a0` mode 4 composites source first and target over it at the accumulated alpha; +at `0x100` it commits the target. `screen_transition_begin@0x439da0` and +`screen_transition_finalize@0x4399c0` own the endpoints. Thus an argument 10 lasts about 160 ms, while 30 +lasts about 480 ms; input can force the endpoint through the same transition-abort service. Manual ROOM +validation caught the initially inverted alpha-step branch. + +ROOM explicitly constructs those full frames. It selects/clears surface 1 and calls `0x20c`, mutates the +retained room presentation, captures surface 2 through another `0x20c`, restores the backbuffer, and calls +`0x25(1,2,10)`. The same form fades its menu away at `0x856`; after the farewell and one-second hold, +`0x25(1,2,30)@0x8dc` fades to the frame prepared for TITLE return. The interactive port now snapshots +retained objects when `0x20c` targets an offscreen surface, composites the captured target over the captured +source on the shared frame clock, blocks the VM through the endpoint, and only then permits the following +surface release/root reload. Headless hosts retain their non-rendering no-op policy. + +The same manual pass exposed a separate resource-addressing issue after `0x6c` was fixed: ROOM did execute +`play-voice`, but `GodotAdvHost` attempted only SC-section resolution. ROOM is a frontend script without an +SC section, and its voice operands `0x3365..0x3376` are universal raw SYS4INI indices (for example raw +`0x3365` is `EUA0016.OGG`). Voice resolution now matches the already-required texture rule: try the active +SC section first, then a type-checked raw-catalog fallback. SC0000's local `0x24 -> MAN999.OGG` mapping is +unchanged. + +--- + ## Native walls backlog (targets for this loop) - ~~**call-script dispatch**~~ — **SOLVED** (above): `call-script ` = raw SYS4INI file index. - **decision→scene** — how `0x62ccf`/the decision selects the next `SCxxxx`. Now narrower: scenes load via `call-script`/the same SYS4INI-index loader, so the open question is only where the decision value is turned into a scene *id* (a caller of SCJUMP; re-aimed away from `u00428010`). -- **op `0x60`** (`u0041A270`) — the rand-like value gating 1732/1755 SCJUMP decisions. +- ~~**op `0x60`**~~ — **SOLVED**: CRT `rand() % bound`, including the zero-bound script-error path. - ~~**gfx command-buffer**~~ — **DONE** (the `0x212–0x21a` positioned-object subsystem = the rendering drift): all 14 ops reversed + implemented against a host-side `GfxState`, and the missing INIT2 boot state supplied via `--boot`. CGs render (screenshot-confirmed). See the op `0x215` finding + "The render diff --git a/docs/global-reference.md b/docs/global-reference.md index 25c83c0..f020c2e 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -1,7 +1,7 @@ # Global Variable Reference (generated) -16417 globals (72 curated, 16345 auto shape-inferred). Source of truth: `vm-map/globals.toml`. +16417 globals (73 curated, 16344 auto shape-inferred). Source of truth: `vm-map/globals.toml`. ## choice-output @@ -26,6 +26,7 @@ | address | name | conf | source | usage | |---|---|---|---|---| +| `0x2e49` | character_voice_suppressed | high | investigation | Base of the per-character voice enable/suppress settings. INITCONFIG zero-fills all 13 cells and registers each with the shared profile service; LOADCONFIG restores them. CONFIG indexes the table to preview a character voice and write 0/1. ROOM reads cell 0 before assigning its selected greeter's greeting/farewell voice ids, so the port's former scalar interpretation of zero-int-range (writing 13 into the base cell) suppressed those voices on every natural boot. This names the script-visible setting array without choosing a persistence backend for op 0x1a2/0x1a3. | | `0x5` | — | low | auto-shape | array | | `0xd2` | — | low | auto-shape | array | | `0xd7` | — | low | auto-shape | array | @@ -57,7 +58,6 @@ | `0x26f1` | — | low | auto-shape | array | | `0x2755` | — | low | auto-shape | array | | `0x27bd` | — | low | auto-shape | array | -| `0x2e49` | — | low | auto-shape | array | | `0x3276` | — | low | auto-shape | array | | `0x328a` | — | low | auto-shape | array | | `0x329e` | — | low | auto-shape | array | diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 9ceec10..1e37ed4 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -153,9 +153,9 @@ - **evidence:** Ghidra op 0xc2 handler 0x4204c0 sets run-state 0x200, arms the service timer, and calls bgm_fade_arm@0x464830; bgm_fade_tick@0x464960 interpolates current/target percent and applies volume, releasing at target 0. Native SC0000 trace at 0x7c1/0x126c shows target 0, duration 3000, 1% ticks at about 30 ms. ### 0xc4 `play-voice` (play-voice, argc 1) -- **summary:** Play a voice clip by id with native playback/history variant 0; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). While all-message Skip is active, retain/replace the queued voice request instead of starting it; playback resumes from the latest queued request after Skip clears. Same resolver rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d}). +- **summary:** Play a voice clip by id with native playback/history variant 0. SC scripts resolve through their SYS4INI section manifest; frontend scripts without an SC section use the id as a universal raw-catalog index. While all-message Skip is active, retain/replace the queued voice request instead of starting it; playback resumes from the latest queued request after Skip clears. Same scene-first/raw-fallback resolver family as set-texture (NOT play-bgm, which is direct-name BGM{id:03d}). - **grounding:** source=investigation, confidence=high -- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it calls voice_play_indexed_asset@0x488330 with variant 0 and records pair {id,0}; while Skip is active it stores the latest id/zero variant at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active. +- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the SC-section offset is exactly 0. ROOM supplies raw ids 0x3365..0x3376 from a non-SC frontend script; raw 0x3365 is EUA0016.OGG. A scene-only resolver returned null there despite the opcode executing, while the established texture path already required the same raw fallback for frontend resources. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it calls voice_play_indexed_asset@0x488330 with variant 0 and records pair {id,0}; while Skip is active it stores the latest id/zero variant at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active. ### 0x1bd `play-history-voice` (u0041D910, argc 1) - **summary:** Replay a voice id selected from retained ADV text history using native playback/history variant 1, preserving normal Skip and Auto-voice state behavior. @@ -174,6 +174,11 @@ ## compute +### 0x60 `random-modulo` (u0041A270, argc 2) +- **summary:** (destination)(bound) — write CRT rand() % bound. A bound of zero first writes zero and then raises the engine's script error. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x60_handler@0x426970 calls imported CRT rand(), fetches operand 2, takes signed remainder, and writes operand 1. The zero-bound branch writes zero then throws error 0x10005. ROOM@0x5 calls (local 0x110,4), and its following branches select one of four character surface sets; variants 0..2 also select voice-id sextets. /v2 annotated and saved 2026-07-21. + ### 0x61 `lookup-array` (lookup-array, argc 3) - **summary:** Take a typed reference to base[index], preserving whether the base belongs to local or global storage. - **grounding:** source=investigation, confidence=high @@ -186,6 +191,11 @@ Operand 2 names the base cell itself: a global-bank operand produces a global re - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x64_handler@0x426b00 resolves operand 1 as a writable VM address, reads a count dword followed by values from frame_codebase + operand2*4, reverses the native loader's rotate/XOR in-memory representation with anti_tamper_a, and writes consecutive dwords. The original SYS4 file footer stores the count and values plainly; HISTORY.BIN uses 15 blobs to initialize its rectangle, coordinate, and lookup arrays. +### 0x6c `zero-int-range` (copy-to-global, argc 2) +- **summary:** (destination)(count) — write logical integer zero to count consecutive VM cells beginning at the resolved destination. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x6c_handler@0x426d90 resolves operand 1 as a writable VM pointer and fills operand-2 consecutive dwords with ctx->anti_tamper_b, the native encoded representation of logical integer zero. INITCONFIG@0x30 clears G[0x2e49..0x2e55] before registering those 13 profile cells; treating operand 2 as a scalar source incorrectly wrote 13 to G[0x2e49] and disabled ROOM's character greeting/farewell voices. /v2 annotated and saved 2026-07-21. + ### 0x135 `bit-set` (bit-set, argc 2) - **summary:** (value)(bit_index) - set the indexed bit in the destination integer. - **grounding:** source=investigation, confidence=high @@ -348,6 +358,14 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit ## draw +### 0x25 `crossfade-surfaces` (u00418B40, argc 3) +- **summary:** (source_surface)(target_surface)(interval_argument) — blocking legacy full-frame mode-4 alpha transition. The native engine composites the captured target surface over the captured source while an 8-bit alpha accumulator advances from 0 to 256. +- **grounding:** source=investigation, confidence=high +- **depends on:** 0x20c, 0x20d +- **evidence:** Ghidra /v2: dispatch handler op_0x25_handler@0x41ce00 sets run-state bit 8, converts operand 3 to timer interval/alpha step (<=64: interval=arg ms and step=16; >64: interval=arg/16 ms and step=1), calls screen_transition_begin@0x439da0 with mode 4, and later screen_transition_finalize@0x4399c0. engine_main_tick_with_exception_policy polls interval_timer_poll_elapsed_steps@0x44d080 and calls screen_transition_tick@0x43a7a0; mode 4 draws source then target with progress alpha and commits target at 0x100. Thus ROOM argument 10 is about 160 ms and argument 30 about 480 ms. ROOM sites: 0x12f,0x7c6,0x856 use (1,2,10); 0x8dc uses (1,2,30). The step branch was corrected after manual timing validation; /v2 annotated and saved 2026-07-21. + +The handler uses an alpha step of 1 and timer interval=argument when argument <=64. Above 64 it uses step=16 and interval=argument/16. The main loop polls that timer, advances by skipped intervals plus the current interval, and does not resume the script until the target endpoint has been presented. ROOM uses (1,2,10) for button/character entry and exit fades and (1,2,30) for the final fade before returning to TITLE. The port captures retained-frame snapshots whenever op 0x20c presents to a selected offscreen render target, then reproduces this blocking alpha lifecycle in the interactive host. + ### 0x131 `get-message-window-alpha` (get-message-window-alpha, argc 1) - **summary:** (out) - read the configured `message:MesWinAlpha` value used to alpha-modulate the ADV chrome. - **grounding:** source=investigation, confidence=high @@ -424,7 +442,7 @@ The handler clears the map embedded at retained-gfx owner+0x408, resets its coun - **summary:** Present the composited frame; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately. - **grounding:** source=investigation, confidence=high - **depends on:** 0x223, 0x1c7, 0x1cc -- **depended on by:** 0x20d, 0x223 +- **depended on by:** 0x25, 0x20d, 0x223 - **evidence:** Ghidra: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_present_frame; 0x26e9f-0x26c93=0x20c. 2026-07-08. Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is an explicit retained-state publication boundary, not a continuously visible object-store mutation. The read/message-skip branch resets the animation service then presents; the port publishes and snaps pending 0x223 state here. Normal playback branches to 0x21c, which owns repeated render/wait/resume. Headless hosts remain non-blocking. @@ -433,6 +451,7 @@ Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is - **summary:** Select an offscreen surface slot as Direct3D render target 0, or restore the device backbuffer when the operand is at least 1000. - **grounding:** source=investigation, confidence=high - **depends on:** 0x20c, 0x1c7, 0x1cc +- **depended on by:** 0x25 - **evidence:** Ghidra /v2: op_0x20d_select_render_target@0x422e10 passes operand 1 and retained-gfx owner ctx+0x46614 to retained_gfx_select_render_target@0x479660. Its D3D calls resolve texture level 0 or backbuffer 0, then invoke device vtable +0x94 SetRenderTarget and store selected slot at owner+0xb530. DATA1: 113 calls in 24 scripts. For slots below 1000 the native worker obtains that surface's level-0 D3D texture surface and calls IDirect3DDevice9::SetRenderTarget(0,...). The >=1000 path obtains backbuffer 0 and records current target -1. Himegari commonly follows a freshly created blank surface selection with 0x20e before drawing into it. @@ -825,10 +844,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 -### 0x25 `u00418B40` (u00418B40, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x50 `add` (add, argc 3) - **summary:** — - **grounding:** source=kelebek, confidence=med @@ -893,18 +908,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=med -### 0x60 `u0041A270` (u0041A270, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x63 `u00414A60` (u00414A60, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x6c `copy-to-global` (copy-to-global, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - ### 0x6e `show-text` (show-text, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=med diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index d8308ad..d1d2678 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -237,12 +237,27 @@ were presented, its terminal op `0x9` at `0x1fb` ran, and SYSTEM4 reconstructed launcher/session-lifecycle discrepancy was observed. DEBUG-specific content oddities are not acceptance failures for this developer route and remain out of scope unless they reproduce in a normal game script. -**TITLE Extra Room entry (`ROOM.BIN`, 2026-07-20).** The title dispatch itself is correct. The first manual +**TITLE Extra Room entry (`ROOM.BIN`, 2026-07-20 through 2026-07-21).** The title dispatch itself is correct. The first manual entry exposed a scheduler discrepancy: ROOM's stable input poll uses `sleep 0`, which native `0xc8` clamps to a one-millisecond timer. Godot previously treated it as a zero-duration no-op, letting the script consume the 20-million-step guard and display `-end-`. The interactive host now preserves the native one-tick yield; Godot also prints the VM halt reason and step count before its generic end marker so future terminations are -distinguishable. Manual ROOM presentation and its genuine op-`0x9` return to TITLE remain to be rechecked. +distinguishable. Manual follow-up confirmed the room remains interactive and its genuine op-`0x9` returns to +TITLE, then exposed three presentation gaps. Native RE resolved and implemented all three underlying +contracts: `0x60` selects the four room variants through CRT `rand()%4`; corrected `0x6c` zero-fills the +13-cell character-voice profile table instead of writing scalar 13 into its first cell; and `0x25` performs +a blocking target-over-source crossfade between the two frames ROOM captures through offscreen `0x20c` +presents. Entry/menu transitions use timing argument 10, the final black transition uses 30, and the port +now waits for the terminal captured frame before surface release or SYSTEM4 reload. The release script's +fourth random presentation variant intentionally has no voice ids; the first three have distinct +greeting/farewell pairs. ROOM now has 55/56 distinct opcodes and 420/421 instructions handled or proven +safe; its sole remaining gap is the already classified one-shot `0x1fe` current-rotation setter on a +decorative object, unrelated to these transition/audio paths. Manual visual/audio parity remains to be +rechecked on the updated build. The first recheck confirmed the transition was visible but exposed two +follow-up corrections: the decompiled alpha-step branch had initially been read backwards (`arg<=64` +means step 16, not step 1), making argument 10 about 160 ms and argument 30 about 480 ms; and ROOM's raw +voice ids were reaching `play-voice` but failing the port's SC-section-only lookup because ROOM owns no SC +section. Voice resolution now uses the same scene-first, type-checked raw fallback as frontend textures. ## Stage B2 — Faithful full boot diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index d68f7e2..c2c7c37 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -353,9 +353,9 @@ sealed class AudioTraceHost : IHost var entry = _res.ResolveBgm(id); Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG ")); } - public void PlayVoice(long id) // voice: per-scene manifest + public void PlayVoice(long id) // voice: SC section, then frontend raw id { - var e = _res.Resolve(_scene, id); + var e = _res.ResolveVoice(_scene, id); Events.Add(("play-voice", id, e == null ? "" : $"{e.Archive} {e.Name}")); } public void ShowText(int offset, string text) { } diff --git a/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs b/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs new file mode 100644 index 0000000..db39fd0 --- /dev/null +++ b/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class CoreScalarAndScreenTransitionOpsTests +{ + private const int Imm = 0, GlobalInt = 3, LocalInt = 9; + + [Fact] + public void ZeroIntRange_ClearsEveryConsecutiveDestinationCell() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + (int, Operand[]) Mov(int address, long value) + => (0x55, new[] { new Operand(GlobalInt, address), new Operand(Imm, value) }); + var ops = new List<(int, Operand[])> + { + Mov(0x2e49, 7), Mov(0x2e4a, 8), Mov(0x2e4b, 9), + (0x6c, new[] { new Operand(GlobalInt, 0x2e49), new Operand(Imm, 3) }), + (0x2, Array.Empty()), + }; + + var vm = new VirtualMachine(ScriptAssembler.Assemble(table, "ZERO_RANGE", ops, Array.Empty()), table, + new RecordingHost()); + vm.Run(); + + Assert.All(Enumerable.Range(0x2e49, 3), address => Assert.Equal(0, vm.Globals[address])); + } + + [Fact] + public void RandomModulo_WritesAValueInsideTheNativeRange() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var ops = new List<(int, Operand[])> + { + (0x60, new[] { new Operand(LocalInt, 0), new Operand(Imm, 4) }), + (0x55, new[] { new Operand(GlobalInt, 0x100), new Operand(LocalInt, 0) }), + (0x2, Array.Empty()), + }; + + var vm = new VirtualMachine(ScriptAssembler.Assemble(table, "RANDOM_MODULO", ops, Array.Empty()), table, + new RecordingHost()); + vm.Run(); + + Assert.InRange(vm.Globals[0x100], 0, 3); + } + + [Fact] + public void CrossfadeSurfaces_ForwardsBothCapturedSlotsAndTimingArgument() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "SURFACE_CROSSFADE", new List<(int, Operand[])> + { + (0x25, new[] { new Operand(Imm, 1), new Operand(Imm, 2), new Operand(Imm, 10) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + + new VirtualMachine(script, table, host).Run(); + + Assert.Equal((1, 2, 10L), Assert.Single(host.SurfaceCrossfades)); + Assert.Equal(160, LegacyScreenTransitionTiming.DurationMilliseconds(10)); + Assert.Equal(480, LegacyScreenTransitionTiming.DurationMilliseconds(30)); + Assert.Equal(1024, LegacyScreenTransitionTiming.DurationMilliseconds(65)); + } +} diff --git a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs index 2bb3dd3..da8c0b4 100644 --- a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs +++ b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs @@ -168,7 +168,7 @@ public class Sys4AssetStoreTests } [Fact] - public void Sc0000AndTitleAudioPayloadsReadDirectlyFromTheirNativeAddressSpaces() + public void Sc0000RoomAndTitleAudioPayloadsReadDirectlyFromTheirNativeAddressSpaces() { var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var archiveOnly = new Sys4AssetStore(catalog, Paths.GameDir); @@ -178,10 +178,15 @@ public class Sys4AssetStoreTests Assert.Equal("BGM005.OGG", bgm?.Name); AssertOgg(resources.ReadAudio(bgm!)); - var voice = resources.Resolve("SC0000", 0x24); + var voice = resources.ResolveVoice("SC0000", 0x24); Assert.Equal("MAN999.OGG", voice?.Name); AssertOgg(resources.ReadAudio(voice!)); + var roomVoice = resources.ResolveVoice("ROOM", 0x3365); + Assert.Equal("EUA0016.OGG", roomVoice?.Name); + AssertOgg(resources.ReadAudio(roomVoice!)); + Assert.Null(resources.ResolveVoice("ROOM", 0x337e)); // SO001.AGF is not voice audio. + var sfx = resources.ResolveSoundEffect(0x28); Assert.Equal("E0808.WAV", sfx?.Name); var wav = resources.ReadAudio(sfx!); diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 9b1d9fd..dbe3d42 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -39,6 +39,7 @@ internal class RecordingHost : IHost 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(); + public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new(); public readonly List MessageSkipChanges = new(); public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); @@ -109,6 +110,8 @@ internal class RecordingHost : IHost gfx.StartForegroundTransitions(100); gfx.CompleteForegroundTransitions(100); } + public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) + => SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument)); public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) { } public void ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot); diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 42d6907..b2e1ec2 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -77,6 +77,9 @@ public interface IHost // read/message-skip branch reaches op 0x20c and presents the completed endpoint immediately. void WaitForForegroundTransition(GfxState gfx) { } void PresentFrame(GfxState gfx) { } + // Legacy SYS4 screen-transition family (op 0x25): scripts render two complete frames into + // numbered surfaces, then block while the engine alpha-composites target over source. + 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 ReleaseSurface(int slot) { } diff --git a/engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs b/engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs new file mode 100644 index 0000000..9206584 --- /dev/null +++ b/engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs @@ -0,0 +1,13 @@ +namespace Age.Engine.Model; + +/// Native timing conversion shared by the legacy op-0x25 screen-transition family. +public static class LegacyScreenTransitionTiming +{ + public static long DurationMilliseconds(long argument) + { + long interval = argument <= 64 ? argument : argument / 16; + long step = argument <= 64 ? 16 : 1; + interval = System.Math.Max(1, interval); + return System.Math.Clamp(((256 + step - 1) / step) * interval, 1, 60_000); + } +} diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 76c1305..4653c2e 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -33,6 +33,14 @@ public sealed class ResourceMap entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } + /// Resolve voice audio through the active SC section when one exists, then through the + /// universal raw catalog used by non-SC frontend scripts such as ROOM. + public AssetEntry? ResolveVoice(string scene, long resId) + { + var entry = _catalog.ResolveScene(scene, resId) ?? _catalog.ResolveRaw(resId); + return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; + } + /// Resolve an already-normalized raw catalog id without applying a scene section base. public AssetEntry? ResolveRawTexture(long rawId) { diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 8c866c6..78029f2 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -649,7 +649,26 @@ public sealed class VirtualMachine Write(a[0], Read(a[0]) & ~(1L << (int)bit)); return pc + 1; } case "check-bit": Write(a[0], (Read(a[1]) >> (int)(Read(a[2]) & 31)) & 1); return pc + 1; - case "copy-to-global": Write(a[0], Read(a[1])); return pc + 1; + case "zero-int-range": + case "copy-to-global": // pre-reference compatibility for opcode 0x6c + { + int count = System.Math.Max(0, checked((int)Read(a[1]))); + for (int i = 0; i < count; i++) WriteConsecutive(a[0], i, 0); + return pc + 1; + } + case "random-modulo": // 0x60: native CRT rand() % bound + case "u0041A270": + { + long bound = Read(a[1]); + if (bound == 0) + { + Write(a[0], 0); + HaltReason ??= "random-modulo-zero"; + return HALT; + } + Write(a[0], System.Random.Shared.Next(0x8000) % bound); + return pc + 1; + } case "jmp": return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1); case "call": _cur.CallStack.Add(pc + 1); return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1); case "ret": @@ -1301,6 +1320,10 @@ public sealed class VirtualMachine Read(a[4]), (int)Read(a[5]), Read(a[6]), Read(a[7])); return pc + 1; case "present-frame": // 0x20c: read/message-skip path snaps a queued transition to its endpoint _host.PresentFrame(Gfx); return pc + 1; + case "crossfade-surfaces": // 0x25: legacy full-frame surface alpha transition + case "u00418B40": + _host.CrossfadeSurfaces(Gfx, (int)Read(a[0]), (int)Read(a[1]), Read(a[2])); + return pc + 1; case "mark-frame-yield": // 0x21c: normal foreground-transition scheduler/resume boundary _host.WaitForForegroundTransition(Gfx); return pc + 1; case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 2172f66..b7b95a3 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -51,6 +51,9 @@ public sealed class GodotAdvHost : IHost private volatile bool _modalMovieWaiting; private volatile bool _modalMovieCancelled; private GfxState? _foregroundGfx; + private readonly object _screenTransitionLock = new(); + private readonly Dictionary> _renderTargetSnapshots = new(); + private LegacyScreenTransition? _screenTransition; public volatile bool IsWaiting; public volatile bool IsTransitionWaiting; public volatile bool IsSleeping; @@ -369,6 +372,19 @@ public sealed class GodotAdvHost : IHost return; } } + if (IsTransitionWaiting) + { + lock (_screenTransitionLock) + { + if (_screenTransition != null) + { + _screenTransition.Forced = true; + _timeline?.State("screen-transition-forced-complete", new()); + _frameSignal.Set(); + return; + } + } + } if (IsWaiting && _gate.CurrentCount == 0) _gate.Release(); } @@ -464,6 +480,17 @@ public sealed class GodotAdvHost : IHost public void PresentFrame(GfxState gfx) { + if (gfx.CurrentRenderTargetSlot >= 0) + { + int slot = gfx.CurrentRenderTargetSlot; + var snapshot = gfx.SnapshotVisibleObjects(_clock.NowMs); + lock (_screenTransitionLock) _renderTargetSnapshots[slot] = snapshot; + _timeline?.Event("render-target-snapshot", new() + { + ["surface"] = slot, ["objects"] = snapshot.Count, + }); + return; + } int started = gfx.StartForegroundTransitions(_clock.NowMs); int completed = gfx.CompleteForegroundTransitions(_clock.NowMs); if (started > 0 || completed > 0) @@ -474,12 +501,88 @@ public sealed class GodotAdvHost : IHost System.Threading.Interlocked.Exchange(ref _presentRequested, 1); } + // Op 0x25's native mode-4 path advances an 8-bit alpha accumulator. Values <=64 use the + // operand as the timer interval and step alpha by sixteen; larger values divide the interval + // by sixteen and step alpha by one. This reproduces the resulting wall-clock duration. + public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) + { + IReadOnlyList source; + IReadOnlyList target; + long start = _clock.NowMs; + long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument); + lock (_screenTransitionLock) + { + source = _renderTargetSnapshots.TryGetValue(sourceSurface, out var capturedSource) + ? capturedSource : System.Array.Empty(); + target = _renderTargetSnapshots.TryGetValue(targetSurface, out var capturedTarget) + ? capturedTarget : gfx.SnapshotVisibleObjects(start); + _screenTransition = new LegacyScreenTransition(source, target, start, duration); + } + _foregroundGfx = null; + System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, start); + System.Threading.Interlocked.Exchange(ref _presentRequested, 1); + IsTransitionWaiting = true; + _timeline?.State("screen-transition-start", new() + { + ["source"] = sourceSurface, ["target"] = targetSurface, + ["interval_argument"] = intervalArgument, ["duration_ms"] = duration, + ["source_objects"] = source.Count, ["target_objects"] = target.Count, + }); + while (!_stopping) + { + bool complete; + lock (_screenTransitionLock) + complete = _screenTransition == null || _screenTransition.Forced + || _clock.NowMs - start >= duration; + if (complete) break; + _frameSignal.WaitOne(50); + } + // Publish the exact target endpoint before the following surface releases/root reload. + lock (_screenTransitionLock) + if (_screenTransition != null) _screenTransition.Forced = true; + System.Threading.Interlocked.Exchange(ref _presentRequested, 1); + // Do not let the VM release both source surfaces (or immediately reload SYSTEM4) until the + // main thread has actually published the terminal target frame. + while (!_stopping) + { + lock (_screenTransitionLock) + if (_screenTransition == null) break; + _frameSignal.WaitOne(50); + } + IsTransitionWaiting = false; + System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1); + _timeline?.State("running", new() { ["screen_transition_complete"] = true }); + } + + public bool TrySnapshotScreenTransition(out LegacyScreenTransitionSnapshot snapshot) + { + lock (_screenTransitionLock) + { + if (_screenTransition == null) + { + snapshot = default; + return false; + } + double progress = _screenTransition.Forced ? 1.0 + : System.Math.Clamp((_clock.NowMs - _screenTransition.StartMs) + / (double)_screenTransition.DurationMs, 0.0, 1.0); + snapshot = new LegacyScreenTransitionSnapshot( + _screenTransition.Source, _screenTransition.Target, progress); + if (_screenTransition.Forced) _screenTransition = null; + return true; + } + } + // Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness // once, then continue only while the sampled retained scene can actually change. Text reveal is a separate // Godot Label; waiting/sleeping alone do not alter background pixels. public bool ShouldRecomposite(GfxState gfx) - => System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 || - gfx.HasActiveVisualPresentation(_clock.NowMs); + { + bool screenTransitionActive; + lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null; + return System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 || + screenTransitionActive || gfx.HasActiveVisualPresentation(_clock.NowMs); + } public void Stop() { @@ -518,6 +621,11 @@ public sealed class GodotAdvHost : IHost _modalMovieCancelled = false; _modalMovieWaiting = false; _foregroundGfx = null; + lock (_screenTransitionLock) + { + _renderTargetSnapshots.Clear(); + _screenTransition = null; + } IsWaiting = false; IsTransitionWaiting = false; IsSleeping = false; @@ -694,6 +802,7 @@ public sealed class GodotAdvHost : IHost public void ReleaseSurface(int slot) { + lock (_screenTransitionLock) _renderTargetSnapshots.Remove(slot); long resourceId; lock (_imageLock) { @@ -739,6 +848,8 @@ public sealed class GodotAdvHost : IHost { var stoppedMovies = new System.Collections.Generic.HashSet(); int end = checked(firstSlot + count); + lock (_screenTransitionLock) + for (int slot = firstSlot; slot < end; slot++) _renderTargetSnapshots.Remove(slot); lock (_imageLock) { for (int slot = firstSlot; slot < end; slot++) @@ -808,7 +919,8 @@ public sealed class GodotAdvHost : IHost } // ---- audio ops (OGG plays natively in Godot) ---- - // BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: via the per-scene manifest. + // BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: SC-section first, + // then universal raw id for frontend scripts such as ROOM that do not own an SC section. public void PlayBgm(long id) { var asset = _res.ResolveBgm(id); @@ -821,7 +933,7 @@ public sealed class GodotAdvHost : IHost public void PlayVoice(long id, int playbackVariant) { - var asset = _res.Resolve(CurrentScene, id); + var asset = _res.ResolveVoice(CurrentScene, id); var audio = asset != null ? LoadAudio(asset) : null; _timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant }); @@ -914,5 +1026,26 @@ public sealed class GodotAdvHost : IHost } public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style); +public readonly record struct LegacyScreenTransitionSnapshot( + IReadOnlyList Source, IReadOnlyList Target, double Progress); + +internal sealed class LegacyScreenTransition +{ + public IReadOnlyList Source { get; } + public IReadOnlyList Target { get; } + public long StartMs { get; } + public long DurationMs { get; } + public bool Forced { get; set; } + + public LegacyScreenTransition(IReadOnlyList source, IReadOnlyList target, + long startMs, long durationMs) + { + Source = source; + Target = target; + StartMs = startMs; + DurationMs = durationMs; + } +} + public readonly record struct AdvWaitIndicatorSnapshot( RgbaImage Image, string Name, int AssetId, AdvWaitIndicatorConfig Config, int Frame); diff --git a/godot/Main.cs b/godot/Main.cs index 7935c39..20faf7f 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -604,8 +604,31 @@ public partial class Main : Godot.Control foreach (var label in _surfaceTextLabels) label.Visible = false; int surfaceTextLabelIndex = 0; System.Collections.Generic.Dictionary? decisions = _gfxLogPath != null || _timeline != null ? new() : null; + if (_host.TrySnapshotScreenTransition(out var transition)) + { + // Native mode 4 keeps the captured source opaque and alpha-composites the complete target + // surface over it. Each offscreen target has an opaque-black clear beneath its objects. + CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false); + FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress); + CompositeVisibleObjects(transition.Target, (float)transition.Progress, + ref surfaceTextLabelIndex, decisions, false); + } + else + { + var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // synchronized objects + ranges + CompositeVisibleObjects(visible, 1f, ref surfaceTextLabelIndex, decisions, true); + } + _screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels); + _screenTex.Update(_screen); + if (decisions != null) LogGfxDecisionChanges(decisions); + } + + private void CompositeVisibleObjects(IReadOnlyList visible, float globalOpacity, + ref int surfaceTextLabelIndex, + System.Collections.Generic.Dictionary? decisions, + bool includeSurfaceText) + { int z = 0; - var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // one synchronized sample for objects + ranges foreach (var v in visible) // interpolate at the retained-presentation clock { var t = v.Transform; @@ -614,7 +637,7 @@ public partial class Main : Godot.Control var projected = localToDest.Apply(0, 0); int dstX = (int)System.Math.Round(projected.X); int dstY = (int)System.Math.Round(projected.Y); - float opacity = v.Alpha / 255f; // transform Z is never opacity + float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity float strength = v.TintStrength / 255f; // tint-blend / fill strength string outcome; if (v.SurfaceTransition is { } transition) @@ -661,9 +684,9 @@ public partial class Main : Godot.Control ColorTimeline(v.ColorTransition); } } - decisions?.Add(v.Handle, $"z{z} {outcome}"); + if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}"; var rawObject = _vm.Gfx.TryGet(v.Handle); - if (rawObject != null) + if (includeSurfaceText && rawObject != null) { foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot)) { @@ -681,9 +704,6 @@ public partial class Main : Godot.Control } z++; } - _screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels); - _screenTex.Update(_screen); - if (decisions != null) LogGfxDecisionChanges(decisions); } private void UpdateAdvTextPresentation() diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 6a6fd17..936ce30 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -4,6 +4,9 @@ from __future__ import annotations INFERRED: dict[int, dict] = { + 0x25: dict(name='crossfade-surfaces', category='draw', noop=False, confidence='high', source='investigation', summary='(source_surface)(target_surface)(interval_argument) — blocking legacy full-frame mode-4 alpha transition. The native engine composites the captured target surface over the captured source while an 8-bit alpha accumulator advances from 0 to 256.'), + 0x60: dict(name='random-modulo', category='compute', noop=False, confidence='high', source='investigation', summary="(destination)(bound) — write CRT rand() % bound. A bound of zero first writes zero and then raises the engine's script error."), + 0x6c: dict(name='zero-int-range', category='compute', noop=False, confidence='high', source='investigation', summary='(destination)(count) — write logical integer zero to count consecutive VM cells beginning at the resolved destination.'), 0x7b: dict(name='coroutine-save-yield-handlers', category='control', noop=False, confidence='high', source='investigation', summary='(handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index); its generic handler prologue records the 5-dword instruction length. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator).'), 0x7c: dict(name='coroutine-resume', category='control', noop=False, confidence='high', source='investigation', summary='() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework.'), 0x86: dict(name='set-cursor-resource', category='input', noop=False, confidence='high', source='investigation', summary='(resource_id) - load an indexed cursor asset and install it as the active custom cursor.'), diff --git a/vm-map/globals.toml b/vm-map/globals.toml index 7e05f43..6b1838d 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -94,6 +94,17 @@ source = "investigation" confidence = "high" depends_on = ["0x6c1"] +[[global]] +address = "0x2e49" +name = "character_voice_suppressed" +category = "data-table" +type = "int[13]" +value_domain = "13 cells of {0,1}; 0 enables that character's voice, 1 suppresses it" +usage = "Base of the per-character voice enable/suppress settings. INITCONFIG zero-fills all 13 cells and registers each with the shared profile service; LOADCONFIG restores them. CONFIG indexes the table to preview a character voice and write 0/1. ROOM reads cell 0 before assigning its selected greeter's greeting/farewell voice ids, so the port's former scalar interpretation of zero-int-range (writing 13 into the base cell) suppressed those voices on every natural boot. This names the script-visible setting array without choosing a persistence backend for op 0x1a2/0x1a3." +source = "investigation" +confidence = "high" +depends_on = [] + [[global]] address = "0x6c9" name = "adv_hover_history" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 2835d41..08e6253 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -248,28 +248,29 @@ argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00418B40" -category = "unknown" -summary = "" +name = "crossfade-surfaces" +category = "draw" +summary = "(source_surface)(target_surface)(interval_argument) — blocking legacy full-frame mode-4 alpha transition. The native engine composites the captured target surface over the captured source while an 8-bit alpha accumulator advances from 0 to 256." +details = "The handler uses an alpha step of 1 and timer interval=argument when argument <=64. Above 64 it uses step=16 and interval=argument/16. The main loop polls that timer, advances by skipped intervals plus the current interval, and does not resume the script until the target endpoint has been presented. ROOM uses (1,2,10) for button/character entry and exit fades and (1,2,30) for the final fade before returning to TITLE. The port captures retained-frame snapshots whenever op 0x20c presents to a selected offscreen render target, then reproduces this blocking alpha lifecycle in the interactive host." noop_headless = false -source = "kelebek" -confidence = "low" -depends_on = [] -evidence = "" +source = "investigation" +confidence = "high" +depends_on = [0x20c, 0x20d] +evidence = "Ghidra /v2: dispatch handler op_0x25_handler@0x41ce00 sets run-state bit 8, converts operand 3 to timer interval/alpha step (<=64: interval=arg ms and step=16; >64: interval=arg/16 ms and step=1), calls screen_transition_begin@0x439da0 with mode 4, and later screen_transition_finalize@0x4399c0. engine_main_tick_with_exception_policy polls interval_timer_poll_elapsed_steps@0x44d080 and calls screen_transition_tick@0x43a7a0; mode 4 draws source then target with progress alpha and commits target at 0x100. Thus ROOM argument 10 is about 160 ms and argument 30 about 480 ms. ROOM sites: 0x12f,0x7c6,0x856 use (1,2,10); 0x8dc uses (1,2,30). The step branch was corrected after manual timing validation; /v2 annotated and saved 2026-07-21." [[opcode.semantics.args]] i = 1 -role = "" +role = "captured source surface" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "captured target surface" observed_types = ["imm"] [[opcode.semantics.args]] i = 3 -role = "" +role = "transition timer interval / accelerated duration argument" observed_types = ["imm"] [[opcode]] @@ -770,23 +771,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041A270" -category = "unknown" -summary = "" +name = "random-modulo" +category = "compute" +summary = "(destination)(bound) — write CRT rand() % bound. A bound of zero first writes zero and then raises the engine's script error." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x60_handler@0x426970 calls imported CRT rand(), fetches operand 2, takes signed remainder, and writes operand 1. The zero-bound branch writes zero then throws error 0x10005. ROOM@0x5 calls (local 0x110,4), and its following branches select one of four character surface sets; variants 0..2 also select voice-id sextets. /v2 annotated and saved 2026-07-21." [[opcode.semantics.args]] i = 1 -role = "" +role = "random remainder destination" observed_types = ["l-int", "l-ptr"] [[opcode.semantics.args]] i = 2 -role = "" +role = "exclusive modulus bound" observed_types = ["imm", "l-int", "l-ptr"] [[opcode]] @@ -880,23 +881,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "copy-to-global" -category = "unknown" -summary = "" +name = "zero-int-range" +category = "compute" +summary = "(destination)(count) — write logical integer zero to count consecutive VM cells beginning at the resolved destination." noop_headless = false -source = "kelebek" -confidence = "med" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x6c_handler@0x426d90 resolves operand 1 as a writable VM pointer and fills operand-2 consecutive dwords with ctx->anti_tamper_b, the native encoded representation of logical integer zero. INITCONFIG@0x30 clears G[0x2e49..0x2e55] before registering those 13 profile cells; treating operand 2 as a scalar source incorrectly wrote 13 to G[0x2e49] and disabled ROOM's character greeting/farewell voices. /v2 annotated and saved 2026-07-21." [[opcode.semantics.args]] i = 1 -role = "" +role = "first integer destination" observed_types = ["g-int", "l-int", "l-ptr"] [[opcode.semantics.args]] i = 2 -role = "" +role = "consecutive cell count" observed_types = ["imm", "l-int"] [[opcode]] @@ -1947,16 +1948,16 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "play-voice" category = "audio" -summary = "Play a voice clip by id with native playback/history variant 0; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). While all-message Skip is active, retain/replace the queued voice request instead of starting it; playback resumes from the latest queued request after Skip clears. Same resolver rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d})." +summary = "Play a voice clip by id with native playback/history variant 0. SC scripts resolve through their SYS4INI section manifest; frontend scripts without an SC section use the id as a universal raw-catalog index. While all-message Skip is active, retain/replace the queued voice request instead of starting it; playback resumes from the latest queued request after Skip clears. Same scene-first/raw-fallback resolver family as set-texture (NOT play-bgm, which is direct-name BGM{id:03d})." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it calls voice_play_indexed_asset@0x488330 with variant 0 and records pair {id,0}; while Skip is active it stores the latest id/zero variant at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active." +evidence = "By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the SC-section offset is exactly 0. ROOM supplies raw ids 0x3365..0x3376 from a non-SC frontend script; raw 0x3365 is EUA0016.OGG. A scene-only resolver returned null there despite the opcode executing, while the established texture path already required the same raw fallback for frontend resources. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it calls voice_play_indexed_asset@0x488330 with variant 0 and records pair {id,0}; while Skip is active it stores the latest id/zero variant at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active." [[opcode.semantics.args]] i = 1 -role = "voice id (section-manifest index)" +role = "voice id (SC-section index or frontend raw-catalog index)" observed_types = ["imm", "l-int", "l-ptr"] [[opcode]]