Implement ROOM fades and character voices
This commit is contained in:
@@ -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
|
> **`resId → files[ section_base(scene) + resId ]`**, where `section_base` = the start of the SYS4INI
|
||||||
> section containing the scene's `SCxxxx.BIN`.
|
> 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
|
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`
|
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 ==
|
(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
|
`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
|
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
|
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
|
*How we got here (condensed):* first confirmed `resId == file_number` via Frida load-order correlation
|
||||||
for SC0000's opening, but `file_number` is not globally unique so a per-scene "scope" was needed. A long
|
for SC0000's opening, but `file_number` is not globally unique so a per-scene "scope" was needed. A long
|
||||||
@@ -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
|
→ `--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
|
(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:**
|
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`),
|
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.
|
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.
|
- **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 →
|
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` +
|
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
|
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
|
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
|
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`
|
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)
|
## 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
|
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
|
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`.
|
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
|
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.
|
`ResolvePacked` lookup and audio filtering; the existing Godot WAV/channel path consumes its result.
|
||||||
Extraction and conversion tools remain diagnostics.
|
Extraction and conversion tools remain diagnostics.
|
||||||
|
|||||||
@@ -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)
|
## Native walls backlog (targets for this loop)
|
||||||
|
|
||||||
- ~~**call-script dispatch**~~ — **SOLVED** (above): `call-script <id>` = raw SYS4INI file index.
|
- ~~**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
|
- **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
|
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`).
|
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
|
- ~~**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
|
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
|
state supplied via `--boot`. CGs render (screenshot-confirmed). See the op `0x215` finding + "The render
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- DO NOT EDIT -- generated from vm-map/globals.toml by tools/globals_build.py --build -->
|
<!-- DO NOT EDIT -- generated from vm-map/globals.toml by tools/globals_build.py --build -->
|
||||||
# Global Variable Reference (generated)
|
# 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
|
## choice-output
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
| address | name | conf | source | usage |
|
| 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 |
|
| `0x5` | — | low | auto-shape | array |
|
||||||
| `0xd2` | — | low | auto-shape | array |
|
| `0xd2` | — | low | auto-shape | array |
|
||||||
| `0xd7` | — | low | auto-shape | array |
|
| `0xd7` | — | low | auto-shape | array |
|
||||||
@@ -57,7 +58,6 @@
|
|||||||
| `0x26f1` | — | low | auto-shape | array |
|
| `0x26f1` | — | low | auto-shape | array |
|
||||||
| `0x2755` | — | low | auto-shape | array |
|
| `0x2755` | — | low | auto-shape | array |
|
||||||
| `0x27bd` | — | low | auto-shape | array |
|
| `0x27bd` | — | low | auto-shape | array |
|
||||||
| `0x2e49` | — | low | auto-shape | array |
|
|
||||||
| `0x3276` | — | low | auto-shape | array |
|
| `0x3276` | — | low | auto-shape | array |
|
||||||
| `0x328a` | — | low | auto-shape | array |
|
| `0x328a` | — | low | auto-shape | array |
|
||||||
| `0x329e` | — | low | auto-shape | array |
|
| `0x329e` | — | low | auto-shape | array |
|
||||||
|
|||||||
@@ -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.
|
- **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)
|
### 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
|
- **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)
|
### 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.
|
- **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
|
## 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)
|
### 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.
|
- **summary:** Take a typed reference to base[index], preserving whether the base belongs to local or global storage.
|
||||||
- **grounding:** source=investigation, confidence=high
|
- **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
|
- **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.
|
- **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)
|
### 0x135 `bit-set` (bit-set, argc 2)
|
||||||
- **summary:** (value)(bit_index) - set the indexed bit in the destination integer.
|
- **summary:** (value)(bit_index) - set the indexed bit in the destination integer.
|
||||||
- **grounding:** source=investigation, confidence=high
|
- **grounding:** source=investigation, confidence=high
|
||||||
@@ -348,6 +358,14 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit
|
|||||||
|
|
||||||
## draw
|
## 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)
|
### 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.
|
- **summary:** (out) - read the configured `message:MesWinAlpha` value used to alpha-modulate the ADV chrome.
|
||||||
- **grounding:** source=investigation, confidence=high
|
- **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.
|
- **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
|
- **grounding:** source=investigation, confidence=high
|
||||||
- **depends on:** 0x223, 0x1c7, 0x1cc
|
- **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.
|
- **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.
|
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.
|
- **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
|
- **grounding:** source=investigation, confidence=high
|
||||||
- **depends on:** 0x20c, 0x1c7, 0x1cc
|
- **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.
|
- **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.
|
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:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|
||||||
### 0x25 `u00418B40` (u00418B40, argc 3)
|
|
||||||
- **summary:** —
|
|
||||||
- **grounding:** source=kelebek, confidence=low
|
|
||||||
|
|
||||||
### 0x50 `add` (add, argc 3)
|
### 0x50 `add` (add, argc 3)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=med
|
- **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:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=med
|
- **grounding:** source=kelebek, confidence=med
|
||||||
|
|
||||||
### 0x60 `u0041A270` (u0041A270, argc 2)
|
|
||||||
- **summary:** —
|
|
||||||
- **grounding:** source=kelebek, confidence=low
|
|
||||||
|
|
||||||
### 0x63 `u00414A60` (u00414A60, argc 2)
|
### 0x63 `u00414A60` (u00414A60, argc 2)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **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)
|
### 0x6e `show-text` (show-text, argc 2)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=med
|
- **grounding:** source=kelebek, confidence=med
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
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;
|
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
|
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
|
## Stage B2 — Faithful full boot
|
||||||
|
|
||||||
|
|||||||
@@ -353,9 +353,9 @@ sealed class AudioTraceHost : IHost
|
|||||||
var entry = _res.ResolveBgm(id);
|
var entry = _res.ResolveBgm(id);
|
||||||
Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG <missing>"));
|
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}"));
|
Events.Add(("play-voice", id, e == null ? "<unresolved>" : $"{e.Archive} {e.Name}"));
|
||||||
}
|
}
|
||||||
public void ShowText(int offset, string text) { }
|
public void ShowText(int offset, string text) { }
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,7 +168,7 @@ public class Sys4AssetStoreTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Sc0000AndTitleAudioPayloadsReadDirectlyFromTheirNativeAddressSpaces()
|
public void Sc0000RoomAndTitleAudioPayloadsReadDirectlyFromTheirNativeAddressSpaces()
|
||||||
{
|
{
|
||||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||||
var archiveOnly = new Sys4AssetStore(catalog, Paths.GameDir);
|
var archiveOnly = new Sys4AssetStore(catalog, Paths.GameDir);
|
||||||
@@ -178,10 +178,15 @@ public class Sys4AssetStoreTests
|
|||||||
Assert.Equal("BGM005.OGG", bgm?.Name);
|
Assert.Equal("BGM005.OGG", bgm?.Name);
|
||||||
AssertOgg(resources.ReadAudio(bgm!));
|
AssertOgg(resources.ReadAudio(bgm!));
|
||||||
|
|
||||||
var voice = resources.Resolve("SC0000", 0x24);
|
var voice = resources.ResolveVoice("SC0000", 0x24);
|
||||||
Assert.Equal("MAN999.OGG", voice?.Name);
|
Assert.Equal("MAN999.OGG", voice?.Name);
|
||||||
AssertOgg(resources.ReadAudio(voice!));
|
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);
|
var sfx = resources.ResolveSoundEffect(0x28);
|
||||||
Assert.Equal("E0808.WAV", sfx?.Name);
|
Assert.Equal("E0808.WAV", sfx?.Name);
|
||||||
var wav = resources.ReadAudio(sfx!);
|
var wav = resources.ReadAudio(sfx!);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ internal class RecordingHost : IHost
|
|||||||
public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new();
|
public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new();
|
||||||
public readonly List<int> ClearedRenderTargets = new();
|
public readonly List<int> ClearedRenderTargets = new();
|
||||||
public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new();
|
public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new();
|
||||||
|
public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new();
|
||||||
public readonly List<bool> MessageSkipChanges = new();
|
public readonly List<bool> MessageSkipChanges = new();
|
||||||
public readonly List<long> CursorResources = new();
|
public readonly List<long> CursorResources = new();
|
||||||
public readonly List<bool> AdvPagePresentationSuspended = new();
|
public readonly List<bool> AdvPagePresentationSuspended = new();
|
||||||
@@ -109,6 +110,8 @@ internal class RecordingHost : IHost
|
|||||||
gfx.StartForegroundTransitions(100);
|
gfx.StartForegroundTransitions(100);
|
||||||
gfx.CompleteForegroundTransitions(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 CreateTexture(int slot, int w, int h) { }
|
||||||
public void SetTexture(long resId, int slot) { }
|
public void SetTexture(long resId, int slot) { }
|
||||||
public void ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot);
|
public void ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot);
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ public interface IHost
|
|||||||
// read/message-skip branch reaches op 0x20c and presents the completed endpoint immediately.
|
// read/message-skip branch reaches op 0x20c and presents the completed endpoint immediately.
|
||||||
void WaitForForegroundTransition(GfxState gfx) { }
|
void WaitForForegroundTransition(GfxState gfx) { }
|
||||||
void PresentFrame(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 CreateTexture(int slot, int width, int height);
|
||||||
void SetTexture(long resourceId, int slot);
|
void SetTexture(long resourceId, int slot);
|
||||||
void ReleaseSurface(int slot) { }
|
void ReleaseSurface(int slot) { }
|
||||||
|
|||||||
13
engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs
Normal file
13
engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,14 @@ public sealed class ResourceMap
|
|||||||
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
|
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>
|
/// <summary>Resolve an already-normalized raw catalog id without applying a scene section base.</summary>
|
||||||
public AssetEntry? ResolveRawTexture(long rawId)
|
public AssetEntry? ResolveRawTexture(long rawId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -649,7 +649,26 @@ public sealed class VirtualMachine
|
|||||||
Write(a[0], Read(a[0]) & ~(1L << (int)bit)); return pc + 1;
|
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 "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 "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 "call": _cur.CallStack.Add(pc + 1); return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1);
|
||||||
case "ret":
|
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;
|
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
|
case "present-frame": // 0x20c: read/message-skip path snaps a queued transition to its endpoint
|
||||||
_host.PresentFrame(Gfx); return pc + 1;
|
_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
|
case "mark-frame-yield": // 0x21c: normal foreground-transition scheduler/resume boundary
|
||||||
_host.WaitForForegroundTransition(Gfx); return pc + 1;
|
_host.WaitForForegroundTransition(Gfx); return pc + 1;
|
||||||
case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue
|
case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ public sealed class GodotAdvHost : IHost
|
|||||||
private volatile bool _modalMovieWaiting;
|
private volatile bool _modalMovieWaiting;
|
||||||
private volatile bool _modalMovieCancelled;
|
private volatile bool _modalMovieCancelled;
|
||||||
private GfxState? _foregroundGfx;
|
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 IsWaiting;
|
||||||
public volatile bool IsTransitionWaiting;
|
public volatile bool IsTransitionWaiting;
|
||||||
public volatile bool IsSleeping;
|
public volatile bool IsSleeping;
|
||||||
@@ -369,6 +372,19 @@ public sealed class GodotAdvHost : IHost
|
|||||||
return;
|
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();
|
if (IsWaiting && _gate.CurrentCount == 0) _gate.Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +480,17 @@ public sealed class GodotAdvHost : IHost
|
|||||||
|
|
||||||
public void PresentFrame(GfxState gfx)
|
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 started = gfx.StartForegroundTransitions(_clock.NowMs);
|
||||||
int completed = gfx.CompleteForegroundTransitions(_clock.NowMs);
|
int completed = gfx.CompleteForegroundTransitions(_clock.NowMs);
|
||||||
if (started > 0 || completed > 0)
|
if (started > 0 || completed > 0)
|
||||||
@@ -474,12 +501,88 @@ public sealed class GodotAdvHost : IHost
|
|||||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
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
|
// 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
|
// 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.
|
// Godot Label; waiting/sleeping alone do not alter background pixels.
|
||||||
public bool ShouldRecomposite(GfxState gfx)
|
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()
|
public void Stop()
|
||||||
{
|
{
|
||||||
@@ -518,6 +621,11 @@ public sealed class GodotAdvHost : IHost
|
|||||||
_modalMovieCancelled = false;
|
_modalMovieCancelled = false;
|
||||||
_modalMovieWaiting = false;
|
_modalMovieWaiting = false;
|
||||||
_foregroundGfx = null;
|
_foregroundGfx = null;
|
||||||
|
lock (_screenTransitionLock)
|
||||||
|
{
|
||||||
|
_renderTargetSnapshots.Clear();
|
||||||
|
_screenTransition = null;
|
||||||
|
}
|
||||||
IsWaiting = false;
|
IsWaiting = false;
|
||||||
IsTransitionWaiting = false;
|
IsTransitionWaiting = false;
|
||||||
IsSleeping = false;
|
IsSleeping = false;
|
||||||
@@ -694,6 +802,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
|
|
||||||
public void ReleaseSurface(int slot)
|
public void ReleaseSurface(int slot)
|
||||||
{
|
{
|
||||||
|
lock (_screenTransitionLock) _renderTargetSnapshots.Remove(slot);
|
||||||
long resourceId;
|
long resourceId;
|
||||||
lock (_imageLock)
|
lock (_imageLock)
|
||||||
{
|
{
|
||||||
@@ -739,6 +848,8 @@ public sealed class GodotAdvHost : IHost
|
|||||||
{
|
{
|
||||||
var stoppedMovies = new System.Collections.Generic.HashSet<long>();
|
var stoppedMovies = new System.Collections.Generic.HashSet<long>();
|
||||||
int end = checked(firstSlot + count);
|
int end = checked(firstSlot + count);
|
||||||
|
lock (_screenTransitionLock)
|
||||||
|
for (int slot = firstSlot; slot < end; slot++) _renderTargetSnapshots.Remove(slot);
|
||||||
lock (_imageLock)
|
lock (_imageLock)
|
||||||
{
|
{
|
||||||
for (int slot = firstSlot; slot < end; slot++)
|
for (int slot = firstSlot; slot < end; slot++)
|
||||||
@@ -808,7 +919,8 @@ public sealed class GodotAdvHost : IHost
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- audio ops (OGG plays natively in Godot) ----
|
// ---- 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)
|
public void PlayBgm(long id)
|
||||||
{
|
{
|
||||||
var asset = _res.ResolveBgm(id);
|
var asset = _res.ResolveBgm(id);
|
||||||
@@ -821,7 +933,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
|
|
||||||
public void PlayVoice(long id, int playbackVariant)
|
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;
|
var audio = asset != null ? LoadAudio(asset) : null;
|
||||||
_timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name,
|
_timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name,
|
||||||
["playback_variant"] = playbackVariant });
|
["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 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(
|
public readonly record struct AdvWaitIndicatorSnapshot(
|
||||||
RgbaImage Image, string Name, int AssetId, AdvWaitIndicatorConfig Config, int Frame);
|
RgbaImage Image, string Name, int AssetId, AdvWaitIndicatorConfig Config, int Frame);
|
||||||
|
|||||||
@@ -604,8 +604,31 @@ public partial class Main : Godot.Control
|
|||||||
foreach (var label in _surfaceTextLabels) label.Visible = false;
|
foreach (var label in _surfaceTextLabels) label.Visible = false;
|
||||||
int surfaceTextLabelIndex = 0;
|
int surfaceTextLabelIndex = 0;
|
||||||
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
|
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;
|
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
|
foreach (var v in visible) // interpolate at the retained-presentation clock
|
||||||
{
|
{
|
||||||
var t = v.Transform;
|
var t = v.Transform;
|
||||||
@@ -614,7 +637,7 @@ public partial class Main : Godot.Control
|
|||||||
var projected = localToDest.Apply(0, 0);
|
var projected = localToDest.Apply(0, 0);
|
||||||
int dstX = (int)System.Math.Round(projected.X);
|
int dstX = (int)System.Math.Round(projected.X);
|
||||||
int dstY = (int)System.Math.Round(projected.Y);
|
int dstY = (int)System.Math.Round(projected.Y);
|
||||||
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
|
float strength = v.TintStrength / 255f; // tint-blend / fill strength
|
||||||
string outcome;
|
string outcome;
|
||||||
if (v.SurfaceTransition is { } transition)
|
if (v.SurfaceTransition is { } transition)
|
||||||
@@ -661,9 +684,9 @@ public partial class Main : Godot.Control
|
|||||||
ColorTimeline(v.ColorTransition);
|
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);
|
var rawObject = _vm.Gfx.TryGet(v.Handle);
|
||||||
if (rawObject != null)
|
if (includeSurfaceText && rawObject != null)
|
||||||
{
|
{
|
||||||
foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot))
|
foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot))
|
||||||
{
|
{
|
||||||
@@ -681,9 +704,6 @@ public partial class Main : Godot.Control
|
|||||||
}
|
}
|
||||||
z++;
|
z++;
|
||||||
}
|
}
|
||||||
_screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels);
|
|
||||||
_screenTex.Update(_screen);
|
|
||||||
if (decisions != null) LogGfxDecisionChanges(decisions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateAdvTextPresentation()
|
private void UpdateAdvTextPresentation()
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
INFERRED: dict[int, dict] = {
|
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).'),
|
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.'),
|
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.'),
|
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.'),
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ source = "investigation"
|
|||||||
confidence = "high"
|
confidence = "high"
|
||||||
depends_on = ["0x6c1"]
|
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]]
|
[[global]]
|
||||||
address = "0x6c9"
|
address = "0x6c9"
|
||||||
name = "adv_hover_history"
|
name = "adv_hover_history"
|
||||||
|
|||||||
@@ -248,28 +248,29 @@ argc = 3
|
|||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "u00418B40"
|
name = "crossfade-surfaces"
|
||||||
category = "unknown"
|
category = "draw"
|
||||||
summary = ""
|
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
|
noop_headless = false
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "low"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = [0x20c, 0x20d]
|
||||||
evidence = ""
|
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]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
role = ""
|
role = "captured source surface"
|
||||||
observed_types = ["imm"]
|
observed_types = ["imm"]
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 2
|
i = 2
|
||||||
role = ""
|
role = "captured target surface"
|
||||||
observed_types = ["imm"]
|
observed_types = ["imm"]
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 3
|
i = 3
|
||||||
role = ""
|
role = "transition timer interval / accelerated duration argument"
|
||||||
observed_types = ["imm"]
|
observed_types = ["imm"]
|
||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
@@ -770,23 +771,23 @@ argc = 2
|
|||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "u0041A270"
|
name = "random-modulo"
|
||||||
category = "unknown"
|
category = "compute"
|
||||||
summary = ""
|
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
|
noop_headless = false
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "low"
|
confidence = "high"
|
||||||
depends_on = []
|
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]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
role = ""
|
role = "random remainder destination"
|
||||||
observed_types = ["l-int", "l-ptr"]
|
observed_types = ["l-int", "l-ptr"]
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 2
|
i = 2
|
||||||
role = ""
|
role = "exclusive modulus bound"
|
||||||
observed_types = ["imm", "l-int", "l-ptr"]
|
observed_types = ["imm", "l-int", "l-ptr"]
|
||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
@@ -880,23 +881,23 @@ argc = 2
|
|||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "copy-to-global"
|
name = "zero-int-range"
|
||||||
category = "unknown"
|
category = "compute"
|
||||||
summary = ""
|
summary = "(destination)(count) — write logical integer zero to count consecutive VM cells beginning at the resolved destination."
|
||||||
noop_headless = false
|
noop_headless = false
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "med"
|
confidence = "high"
|
||||||
depends_on = []
|
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]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
role = ""
|
role = "first integer destination"
|
||||||
observed_types = ["g-int", "l-int", "l-ptr"]
|
observed_types = ["g-int", "l-int", "l-ptr"]
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 2
|
i = 2
|
||||||
role = ""
|
role = "consecutive cell count"
|
||||||
observed_types = ["imm", "l-int"]
|
observed_types = ["imm", "l-int"]
|
||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
@@ -1947,16 +1948,16 @@ abi_source = "kelebek+decode-validated"
|
|||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "play-voice"
|
name = "play-voice"
|
||||||
category = "audio"
|
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
|
noop_headless = false
|
||||||
source = "investigation"
|
source = "investigation"
|
||||||
confidence = "high"
|
confidence = "high"
|
||||||
depends_on = []
|
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]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
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"]
|
observed_types = ["imm", "l-int", "l-ptr"]
|
||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
|
|||||||
Reference in New Issue
Block a user