diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 908d37c..4b76c80 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -117,7 +117,7 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b catalog id in the graphics surface, so that surface remains stable after a nested helper returns or a sibling script becomes active. Voice and movie calls likewise resolve against the executing frame; SFX is the separate universal packed-id family documented below. -4. **Audio.** **BGM/voice wired; VFS bytes complete (2026-07-11); packed-raw SFX correction pending.** `IHost.PlayBgm/PlayVoice` + +4. **Audio.** **BGM/voice/SFX wired; VFS bytes complete (2026-07-11); packed-raw SFX corrected 2026-07-20.** `IHost.PlayBgm/PlayVoice` + VM dispatch (`play-bgm` 0xbf / `play-voice` 0xc4, both argc 1); `ResourceMap.ReadAudio` opens the resolved catalog entry through `IAssetStore`; `GodotAdvHost` passes the bytes to `Main`'s players (`AudioStreamOggVorbis.LoadFromBuffer`; BGM loops, voice interrupt-on-new). Non-Godot hosts no-op it @@ -219,13 +219,13 @@ store. on-disk `BinExtractALF.exe` are validation references; the Kelebek repository exposes no clear license, so its code should not be copied without clarification. The focused `LzssDecoder` is shared with `Sys4AssetCatalog`; raw and compressed information/pixel/ACIF sections use the same bounded primitive. -4. **Runtime consumers (scripts/textures complete; SFX resolver correction pending).** `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 decoded RGBA surfaces by catalog identity and supplies synchronous dimensions to opcode `0x208`. - Godot no longer reads `build/textures/*.BMP`. BGM direct-name and scene-local voice entries are opened - through the same `IAssetStore`; the SFX WAV/player path is also present, but `GodotAdvHost` still applies - scene-local resolution where op `0xb4` requires `ResolvePacked`. SC0000 worked because its raw ids happen - to coincide with the first section. Extraction and conversion tools remain diagnostics. + Godot no longer reads `build/textures/*.BMP`. BGM direct-name, scene-local 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. ### Acceptance gates diff --git a/docs/engine-re.md b/docs/engine-re.md index bee8302..586ea41 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1795,14 +1795,16 @@ raw catalog id `0x28` to `E0808.WAV`, loads channel 0, and `0xb5@0xc2e` starts i scratch global `G[0x6242d]` is maintained outside script-visible writes; the SC0000 port profile exposes it as an external value of 4 rather than pretending the script assigned it. -**TITLE SFX silence root cause (2026-07-20).** TITLE preloads raw id `0x2aea` (`SE020.WAV`) into channel 2 +**TITLE SFX packed-raw correction (2026-07-20).** TITLE preloads raw id `0x2aea` (`SE020.WAV`) into channel 2 and executes `0xb5(2)` whenever mouse/joy selection changes. Each activation path loads raw id `0x3321` (`SE015.WAV`) into channel 1 and immediately starts it; GAMESTART additionally uses raw `0x2aeb` (`SE013.WAV`). A synchronized Godot trace captured repeated hover starts and the Game Start activation, but -each preceding load recorded `file:null`. `GodotAdvHost.LoadSoundEffect` currently applies the active -script's scene-local manifest, and TITLE has only 14 local entries, so all three large raw ids fail before -the existing WAV/player path. BGM remains audible because it uses its separate direct-name resolver. The -bounded fix is a scene-independent packed-raw SFX resolver, not changes to callbacks, mixing, or channels. +each preceding load recorded `file:null`: `GodotAdvHost.LoadSoundEffect` applied the active script's +scene-local manifest, and TITLE has only 14 local entries. `ResourceMap.ResolveSoundEffect` now uses the +native packed lookup and filters the selected record to OGG/WAV before the existing byte/player path. +The corrected synchronized trace resolves and starts `SE020`, `SE015`, and `SE013` through +TITLE→GAMESTART→TITLE, and the user confirmed hover, activation, and cancel sounds are audible. BGM remains +independent through its direct-name resolver; no callback, mixer, or channel workaround was needed. That later service start is opcode `0x2bf`, now identified as the facade's `SetDelay` operation. `op_0x2bf_schedule_sfx_start@0x425240` passes `(channel,start_mode,delay_ms)` to diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 7133d87..9230cb9 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -130,7 +130,7 @@ ### 0xb4 `sfx-load` (play-sound-effect, argc 2) - **summary:** (packed_raw_resource_id)(channel) — synchronously open the universal SYS4INI/AAI catalog entry and replace the channel's decoded sound buffer without starting playback. A zero high byte is a raw SYS4INI index; a nonzero high byte selects an append catalog and uses the low 24-bit index. Native manager supports channels 0..12; SC0000 uses 0..9. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0xb4_sfx_load@0x4201d0 -> sfx_channel_load@0x482500 -> asset_open_indexed_entry@0x44f390 + sound_decode_channel@0x483360. The opener indexes the base catalog directly for a zero high byte and selects mounted_aai[high_byte] otherwise. SC0000 0xc29 uses raw 0x28 = E0808.WAV. TITLE independently proves the addressing rule: raw 0x2aea = SE020.WAV is preloaded on channel 2 and started on every hover-selection change; raw 0x3321 = SE015.WAV is loaded/started on activation. A synchronized port trace executes those loads and starts but reports file=null because Godot incorrectly applies TITLE's 14-entry scene manifest. +- **evidence:** Ghidra /v2: op_0xb4_sfx_load@0x4201d0 -> sfx_channel_load@0x482500 -> asset_open_indexed_entry@0x44f390 + sound_decode_channel@0x483360. The opener indexes the base catalog directly for a zero high byte and selects mounted_aai[high_byte] otherwise. SC0000 0xc29 uses raw 0x28 = E0808.WAV. TITLE independently proves the addressing rule: raw 0x2aea = SE020.WAV is preloaded on channel 2 and started on every hover-selection change; raw 0x3321 = SE015.WAV is loaded/started on activation. The pre-fix port trace reported file=null under scene-manifest resolution; after switching Godot to ResolveSoundEffect/ResolvePacked, a synchronized TITLE->GAMESTART->TITLE trace resolves and starts SE020/SE015/SE013, confirmed audible by the user. ### 0xb5 `sfx-start` (u0041D050, argc 1) - **summary:** (channel) — start the already-loaded channel once (logical loop=false). DirectSound publishes synchronously through Play(0,0,DSBPLAY_LOOPING); the low-level flag loops only the streaming ring, while decoder EOF stops logical playback. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 24c6193..92e6edb 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -99,11 +99,12 @@ SYSTEM4's computed resource id `G[0x699]=0x22`; `G[0]=1` and the script-produced single-scene diagnostic harness. This lands the boot/title/New Game entry half of B1–B3; proving a completed scene return plus boundary cleanup still belongs to B1 completion. -**TITLE SFX investigation (2026-07-20).** Hover and activation callbacks are already executing their -scripted `0xb5` starts. The load fails earlier because op `0xb4` uses universal packed SYS4INI/AAI ids, -while Godot currently treats them as active-script manifest ids. TITLE's `0x2aea`/`SE020.WAV` hover and -`0x3321`/`SE015.WAV` activation loads therefore resolve null; BGM is unaffected. The next bounded correction -is a packed-raw SFX resolver plus TITLE/GAMESTART regression coverage, with no mixer redesign indicated. +**TITLE SFX packed-raw correction (2026-07-20).** Hover and activation callbacks were already executing their +scripted `0xb5` starts. The load failed earlier because op `0xb4` uses universal packed SYS4INI/AAI ids, +while Godot treated them as active-script manifest ids. The new packed-raw resolver maps TITLE's +`0x2aea`/`SE020.WAV` hover, `0x3321`/`SE015.WAV` activation, and GAMESTART's `0x2aeb`/`SE013.WAV` cancel +through the existing channel players. A synchronized TITLE→GAMESTART→TITLE trace records every load/start +with its filename, and manual validation confirms they are audible; BGM remains unaffected. ## Stage B1 — Persistent session and scene coordinator diff --git a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs index 938bd61..2bb3dd3 100644 --- a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs +++ b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs @@ -168,7 +168,7 @@ public class Sys4AssetStoreTests } [Fact] - public void Sc0000BgmVoiceAndSfxPayloadsReadDirectlyFromArchives() + public void Sc0000AndTitleAudioPayloadsReadDirectlyFromTheirNativeAddressSpaces() { var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var archiveOnly = new Sys4AssetStore(catalog, Paths.GameDir); @@ -182,13 +182,20 @@ public class Sys4AssetStoreTests Assert.Equal("MAN999.OGG", voice?.Name); AssertOgg(resources.ReadAudio(voice!)); - var sfx = resources.Resolve("SC0000", 0x28); + var sfx = resources.ResolveSoundEffect(0x28); Assert.Equal("E0808.WAV", sfx?.Name); var wav = resources.ReadAudio(sfx!); Assert.Equal("E0808.WAV", wav.Name); Assert.Equal("RIFF", Encoding.ASCII.GetString(wav.Bytes, 0, 4)); Assert.Equal("WAVE", Encoding.ASCII.GetString(wav.Bytes, 8, 4)); + Assert.Null(resources.Resolve("TITLE", 0x2aea)); + Assert.Equal("SE020.WAV", resources.ResolveSoundEffect(0x2aea)?.Name); + Assert.Equal("SE013.WAV", resources.ResolveSoundEffect(0x2aeb)?.Name); + Assert.Equal("SE015.WAV", resources.ResolveSoundEffect(0x3321)?.Name); + Assert.Null(resources.ResolveSoundEffect(0x337e)); // SO001.AGF is not audio. + Assert.Null(resources.ResolveSoundEffect(0x02000000)); // unmounted append selector. + Assert.Throws(() => resources.ReadAudio(catalog.ResolveName("SO001.AGF")!)); } diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 0f6aafd..5bd0170 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -1,9 +1,8 @@ namespace Age.Engine.Sys4; /// -/// Compatibility facade over the runtime SYS4 catalog. SYS4INI's file list is sectioned (one per scene: SCxxxx.BIN + its -/// cross-archive asset manifest); file_number is the index within a section. So a bytecode -/// resId resolves as files[section_base(scene) + resId] -- unified for graphics and audio. +/// Compatibility facade over the runtime SYS4 catalog. Scene-local graphics/voice/movie ids resolve +/// through the executing script's manifest; BGM uses direct names; SFX/cursors use universal packed ids. /// See docs/asset-resolution-re.md. /// public sealed class ResourceMap @@ -75,6 +74,13 @@ public sealed class ResourceMap return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; } + /// Resolve opcode 0xb4's universal packed SYS4INI/AAI id to an audio entry. + public AssetEntry? ResolveSoundEffect(long packedRawId) + { + var entry = _catalog.ResolvePacked(packedRawId); + return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; + } + /// Read a catalog-resolved OGG/WAV payload through the loose-first ALF/AAI byte store. public AudioPayload ReadAudio(AssetEntry entry) { diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 984e277..cceb9b8 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -748,7 +748,7 @@ public sealed class GodotAdvHost : IHost public void LoadSoundEffect(long resourceId, int channel) { if ((uint)channel >= (uint)_sfxNames.Length) return; - var asset = _res.Resolve(CurrentScene, resourceId); + var asset = _res.ResolveSoundEffect(resourceId); var audio = asset != null ? LoadAudio(asset) : null; _sfxNames[channel] = audio?.Name; _timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel, diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 3e59e4d..df70e59 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1736,7 +1736,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0xb4_sfx_load@0x4201d0 -> sfx_channel_load@0x482500 -> asset_open_indexed_entry@0x44f390 + sound_decode_channel@0x483360. The opener indexes the base catalog directly for a zero high byte and selects mounted_aai[high_byte] otherwise. SC0000 0xc29 uses raw 0x28 = E0808.WAV. TITLE independently proves the addressing rule: raw 0x2aea = SE020.WAV is preloaded on channel 2 and started on every hover-selection change; raw 0x3321 = SE015.WAV is loaded/started on activation. A synchronized port trace executes those loads and starts but reports file=null because Godot incorrectly applies TITLE's 14-entry scene manifest." +evidence = "Ghidra /v2: op_0xb4_sfx_load@0x4201d0 -> sfx_channel_load@0x482500 -> asset_open_indexed_entry@0x44f390 + sound_decode_channel@0x483360. The opener indexes the base catalog directly for a zero high byte and selects mounted_aai[high_byte] otherwise. SC0000 0xc29 uses raw 0x28 = E0808.WAV. TITLE independently proves the addressing rule: raw 0x2aea = SE020.WAV is preloaded on channel 2 and started on every hover-selection change; raw 0x3321 = SE015.WAV is loaded/started on activation. The pre-fix port trace reported file=null under scene-manifest resolution; after switching Godot to ResolveSoundEffect/ResolvePacked, a synchronized TITLE->GAMESTART->TITLE trace resolves and starts SE020/SE015/SE013, confirmed audible by the user." [[opcode.semantics.args]] i = 1