Implement ROOM fades and character voices

This commit is contained in:
gamer147
2026-07-21 00:47:29 -04:00
parent 2fa42b085a
commit cffc75c64d
18 changed files with 442 additions and 71 deletions

View File

@@ -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 <SCENE> [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 13 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.

View File

@@ -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 <id>` = 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 `0x2120x21a` 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

View File

@@ -1,7 +1,7 @@
<!-- DO NOT EDIT -- generated from vm-map/globals.toml by tools/globals_build.py --build -->
# 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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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 <missing>"));
}
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 ? "<unresolved>" : $"{e.Archive} {e.Name}"));
}
public void ShowText(int offset, string text) { }

View File

@@ -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<Operand>()),
};
var vm = new VirtualMachine(ScriptAssembler.Assemble(table, "ZERO_RANGE", ops, Array.Empty<string>()), 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<Operand>()),
};
var vm = new VirtualMachine(ScriptAssembler.Assemble(table, "RANDOM_MODULO", ops, Array.Empty<string>()), 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<Operand>()),
}, Array.Empty<string>());
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));
}
}

View File

@@ -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!);

View File

@@ -39,6 +39,7 @@ internal class RecordingHost : IHost
public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new();
public readonly List<int> 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<bool> MessageSkipChanges = new();
public readonly List<long> CursorResources = new();
public readonly List<bool> 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);

View File

@@ -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) { }

View File

@@ -0,0 +1,13 @@
namespace Age.Engine.Model;
/// <summary>Native timing conversion shared by the legacy op-0x25 screen-transition family.</summary>
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);
}
}

View File

@@ -33,6 +33,14 @@ public sealed class ResourceMap
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>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.</summary>
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;
}
/// <summary>Resolve an already-normalized raw catalog id without applying a scene section base.</summary>
public AssetEntry? ResolveRawTexture(long rawId)
{

View File

@@ -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

View File

@@ -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<int, IReadOnlyList<RenderObject>> _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<RenderObject> source;
IReadOnlyList<RenderObject> target;
long start = _clock.NowMs;
long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument);
lock (_screenTransitionLock)
{
source = _renderTargetSnapshots.TryGetValue(sourceSurface, out var capturedSource)
? capturedSource : System.Array.Empty<RenderObject>();
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<long>();
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<RenderObject> Source, IReadOnlyList<RenderObject> Target, double Progress);
internal sealed class LegacyScreenTransition
{
public IReadOnlyList<RenderObject> Source { get; }
public IReadOnlyList<RenderObject> Target { get; }
public long StartMs { get; }
public long DurationMs { get; }
public bool Forced { get; set; }
public LegacyScreenTransition(IReadOnlyList<RenderObject> source, IReadOnlyList<RenderObject> 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);

View File

@@ -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<long, string>? 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<RenderObject> visible, float globalOpacity,
ref int surfaceTextLabelIndex,
System.Collections.Generic.Dictionary<long, string>? 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()

View File

@@ -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.'),

View File

@@ -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"

View File

@@ -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]]