From 233f791c1040a657a1617f07622fd71f5c6d92cd Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 11 Jul 2026 11:01:11 -0400 Subject: [PATCH] Migrate audio payloads onto asset VFS --- docs/asset-resolution-re.md | 40 +++++++++-------- docs/phase-a-slice-plan.md | 19 +++++++- docs/remake-architecture-and-roadmap.md | 4 +- docs/tools-reference.md | 2 +- engine/Age.Cli/Program.cs | 7 ++- .../Age.Engine.Tests/Sys4AssetStoreTests.cs | 31 +++++++++++++ engine/Age.Engine/Sys4/ResourceMap.cs | 30 +++++++------ godot/GodotAdvHost.cs | 43 ++++++++++++------- godot/Main.cs | 20 ++++----- 9 files changed, 132 insertions(+), 64 deletions(-) diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 41a4f92..001d4d7 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -114,10 +114,9 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b verified SO001 is 800×300, 32-bpp, with substantial per-pixel alpha; the current rasterizer already consumes source alpha. The missing prerequisites are system-asset resolution/conversion and retained slot initialization, not new textbox drawing or button interaction. -4. **Audio.** **✅ WIRED (2026-07-06) — no Frida needed.** Same rule as textures: - `play-bgm(id)`/`play-voice(id)` → `files[section_base(scene)+id]` → OGG. `IHost.PlayBgm/PlayVoice` + - VM dispatch (`play-bgm` 0xbf / `play-voice` 0xc4, both argc 1); `ResourceMap.AudioPath` → loose - `extracted/DATA{n}/{name}.OGG`; `GodotAdvHost` → `Main`'s two `AudioStreamPlayer` nodes +4. **Audio.** **✅ WIRED (2026-07-06), VFS bytes complete (2026-07-11).** `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 → `--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 @@ -128,12 +127,12 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b - **BGM** (`play-bgm`) → **DIRECT LITERAL NAME**, `id → BGM{id:03d}.OGG` (DATA3), NOT the manifest. Confirmed by ear (`play-bgm 5→BGM005`, `8→BGM008`; the manifest gave BGM006/009 = off-by-one) and proven by `play-bgm 0x23→BGM035.OGG` — a real standalone track (BGM set skips 030-034) the manifest mis-resolved - to a graphics entry. Implemented as `ResourceMap.BgmPathById(id)`; `GodotAdvHost.PlayBgm` uses it. + to a graphics entry. Implemented as `ResourceMap.ResolveBgm(id)`; `GodotAdvHost.PlayBgm` uses it. The prior "Frida-confirmed play-bgm 5→BGM006" record was a mis-attribution. - Lily silent = correct (form-gated on `G[0xa57/0xa58/0xa59]`, unseeded). `play-sound-effect` (0xb4, argc 2) - left stubbed — arg roles unconfirmed. See `docs/phase-a-slice-plan.md` (A2b-Audio). Diagnostic: `Age.Cli - audio `. + Lily silent = correct (form-gated on `G[0xa57/0xa58/0xa59]`, unseeded). Scene-local SFX WAV entries use + the same byte path while retaining the existing channel lifecycle. See `docs/phase-a-slice-plan.md`. + Diagnostic: `Age.Cli audio `. 5. **Movies** (`OP`/`MVB`, MPEG) — a separate video-playback path; deferred. ## Validation reality (why this is the big haul) @@ -162,9 +161,9 @@ The matching native trace at SC0000 `0xc29` captures resource `0x28`, channel 0; `DATA1/E0808.WAV`, and the port trace resolves the same file. The following `0xc31` preload uses the same resource on native secondary channel 4. `play-bgm` remains the separate direct-name exception. -The current Phase-A backend deliberately continues through the extracted-file bootstrap: `ResourceMap.AudioPath` -accepts both OGG and WAV and Godot loads the WAV bytes into its fixed SC0000 channel pool. This does not change -the scoped VFS plan below: ALF/AAI mounting and in-process asset reads remain a separate foundation track. +The Phase-A backend now resolves the OGG/WAV catalog entry and opens it through `IAssetStore`; Godot decodes +the returned bytes into its existing BGM, voice, and fixed SC0000 SFX channel players. The earlier +`ResourceMap.AudioPath` extracted-file bootstrap is retired. ## Runtime asset-VFS track (VFS-A/B/C complete 2026-07-11) @@ -213,11 +212,12 @@ 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.** Script and texture loading are complete. `ResourceMap.ResolveTexture` preserves +4. **Runtime consumers (complete for scripts, textures, and audio).** `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`. OGG/WAV byte migration remains a separate follow-up; - retain extraction/conversion tools as diagnostics until parity is established. + Godot no longer reads `build/textures/*.BMP`. BGM direct-name entries plus scene-local voice/SFX entries + are opened through the same `IAssetStore`; Godot decodes their OGG/WAV bytes in process. Extraction and + conversion tools remain diagnostics. ### Acceptance gates @@ -250,8 +250,14 @@ Five installed assets spanning raw/compressed metadata and pixel/alpha combinati `AGF2BMP2AGF` BMP oracle pixel-for-pixel. SO001 resolves through universal raw id `0x337e`, decodes to 800×300 with intermediate alpha values, and is inherited in surface slot 17 before SC0000. A windowed page-1 capture with `build/textures/` moved aside showed the translucent textbox edge and bottom-right -controls. Texture runtime no longer consults `extracted/` or `build/textures/`; current audio consumers -still use extracted OGG/WAV paths by design. Audio migration and movie `0x236` remain unimplemented. +controls. Texture runtime no longer consults `extracted/` or `build/textures/`. + +Audio byte migration passes its bounded gate in `Sys4AssetStoreTests`: archive-only SC0000 reads identify +`BGM005.OGG` and `MAN999.OGG` as Ogg streams and `E0808.WAV` as RIFF/WAVE without consulting `extracted/`. +A windowed SC0000 run with `extracted/` moved aside crossed both voice sites and the first SFX sequence, +recording BGM005 plus `E0808.WAV` load/start/preload on channels 0/0/4 with no Godot OGG/WAV decode errors. +Channel, loop, interruption, timing, fade, load/start, and release behavior is unchanged. Movie `0x236` +remains unimplemented. VFS-B passes its bounded gates in `Sys4AssetStoreTests`: the installed AAI expands from the LZSS stream at `0x118` (expanded size at `0x110`, packed size at `0x114`) to one `APPEND01.ALF` archive and 81 80-byte @@ -264,7 +270,7 @@ through `0x01xxxxxx`; direct base-name lookup deliberately does not see append r ### Deliberate non-goals - Writing/repacking ALF or AAI; loose overrides already provide the native mod/translation workflow. -- AGF encoding, movie/video decoding for MPEG-like `OP/MVB*.AGF`, or SFX channel semantics. +- AGF encoding or movie/video decoding for MPEG-like `OP/MVB*.AGF`. - A generalized multi-mod dependency manager. Start with native game-root loose overrides; configurable ordered mod roots can be layered onto the same store later. - Removing the extraction/conversion tools immediately. They remain independent parity oracles until the diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 854914b..24624f5 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1169,7 +1169,7 @@ the same `(load ch0, start ch0, preload ch4)` sequence in one frame. Startup `0x `0xc2(target,duration)` is the adjacent blocking BGM fade: 100 linear steps for durations at least 1000 ms, 10 steps below that, with target zero releasing the source. `0xd9` only clears native service bit `0x1000`; the isolated VM recognizes it with no host-visible effect. The Godot backend retains ten `AudioStreamPlayer` -channels, loads the existing extracted WAV bytes, separates load from start, releases buffers deterministically, +channels, loads catalog-resolved WAV bytes, separates load from start, releases buffers deterministically, and parks BGM fades on the unified virtual clock. ALF/AAI/AGF VFS work and movie `0x236` remain separate. Ghidra `/v2` now names/comments all five handlers plus the asset-open, decode, DirectSound start/refill/stop/ @@ -1186,3 +1186,20 @@ leaving 37 GAP ops / 101 GAP instructions. Headless sequence capture still emits **Manual validation:** the normal-speed windowed port advanced through `wait-for-input@0x1a58` at 45.6 s, well past the first effects and dialogue pages, with no audio-related stall. The user confirmed the effects were audible and sounded good. + +### Audio-byte migration onto the asset VFS DONE (2026-07-11) + +`ResourceMap.BgmPathById` and `ResourceMap.AudioPath` are removed. BGM direct-name resolution now returns a +catalog entry, while voice and SFX retain their scene-local resolution; all three flow through +`ResourceMap.ReadAudio` and the injected `IAssetStore`. `GodotAdvHost` passes owned byte arrays across the +existing deferred main-thread boundary, and `Main` decodes them with +`AudioStreamOggVorbis.LoadFromBuffer` / `AudioStreamWav.LoadFromBuffer`. The existing BGM loop and fade, +voice interruption, ten-channel SFX load/start/release, clock pacing, and gain behavior are unchanged. + +Validation: engine **128/128** and both .NET/Godot builds pass. An archive-only SC0000 regression verifies +the Ogg signatures for `BGM005.OGG` and `MAN999.OGG` plus the RIFF/WAVE signature for `E0808.WAV`. With +`extracted/` physically moved aside, the CLI completed SC0000's 127-op audio trace and a windowed Godot run +crossed voice at `0xa0a`/`0xbf6`, BGM005 at `0x7fa`, and the established SFX load/start/preload sequence at +`0xc29`/`0xc2e`/`0xc31` on channels 0/0/4. Godot reported no OGG/WAV read, decode, or deferred-call errors; +`extracted/` was restored afterward. Movie `0x236`, AGF work, opcode semantics, and unrelated VM behavior +were untouched. diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 3af6eba..3916dea 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -72,7 +72,9 @@ Three layers, cleanly separated: - **Native content foundation status (2026-07-11).** VFS-A runtime-parses the base SYS4 catalog and applies loose-first bounded ALF reads; VFS-B mounts selector-keyed S4AC append catalogs and resolves the native high-byte/low-24-bit id split; VFS-C decodes AGF directly to platform-neutral RGBA8 and feeds Godot - without pre-extracted or pre-converted texture files. Audio-byte consumers remain a separate follow-up. + without pre-extracted or pre-converted texture files. BGM, voice, and SFX now open catalog entries through + the same store and decode OGG/WAV from bytes in Godot, so runtime scripts, textures, and audio no longer + depend on `extracted/`. - **Profile = manifest.** Adding a game = a new profile + its maps. Adding an engine version = a new front-end plugin + profiles that reference it. See §5. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 02b0b6a..94b50d9 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -87,7 +87,7 @@ root/call-script parsing). Generated asset/callscript JSON remains a tooling and | `run ` | Execute a script; print steps, show-text count, **call-script dispatch count**, the first 30 lines (each tagged with its source script), and the distinct source scripts. | `CaptureHost` (headless); **executes call-script**. | | `trace ` | Trace every SC/SP scene → offsets + halt + steps. **Provider-less** (call-script stubbed) = a base-ISA offset dump. | writes JSON. (Was the vm0 differential oracle; vm0 is retired from oracle duty — `TraceDiffTests` removed.) | | `trace [--boot] [--state ] [0xADDR=VAL…] --trace-json ` | ★ Emit the **full per-op executed-offset path** of one scene (not just show-text), filtered to the scene's own frame — the VM side of the differential offset-path oracle (`diff_optrace.py`). `--boot` runs the SYSTEM4 state prefix; **`--state ` loads a captured scene-entry snapshot** (`capture_global_writes.py`) = the engine's real pre-scene state; `0xADDR=VAL` hand-seeds. | `JsonOffsetTraceSink` (observe-only, parity held) → `{scene, offsets:[…]}` JSON. | -| `audio [0xADDR=VAL…]` | Dump executed `play-bgm`/`play-voice` in order + resolved file. | optional seeds. provider-less (stub) for now. | +| `audio [0xADDR=VAL…]` | Dump executed `play-bgm`/`play-voice` in order + resolved catalog record. | optional seeds. provider-less (stub) for now. | | `gfx [--boot] [0xADDR=VAL…]` | Dump executed `set-texture`/`get-texture-size`/`draw-texture` (resolved file + computed geometry) **plus the per-object gfx slots** — the headless geometry oracle. **`--boot`** runs SYSTEM4's state prefix (`INITCONFIG/INIT2/INIT`) via `GameSession` first (so INIT2's gfx handle array is present) and runs the target with call-script on; without it, seeds-only + provider-less. | gfx ops now execute against `GfxState`. | | `play [--boot] [--state ] [--save-state ] [0xADDR=VAL…]` | ★ Cross-scene **state runner**: run a scene sequence carrying persistent globals. `--boot` first runs the 9 `*INIT` data scripts (real skill/item/unit/map/stage state). `--state`/`--save-state` load/persist a JSON snapshot. | `GameSession`; **executes call-script**. | | `sweep [--boot] [0xADDR=VAL…]` | Corpus-scale run. **With call-script execution on: 284/297 exit, 13 STEP-LIMIT** (input/state-gated ADV scenes spin headless once subroutine global-writes drive their loops — state divergence, not a bug; 0 depth-cap/unresolved). **With seeds = a story-state explorer**: reports which scenes' dialogue changes ±seed (e.g. form flag `0xa57=1` → 34/297 scenes). | | diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 3bedf86..5d5ba86 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -350,14 +350,13 @@ sealed class AudioTraceHost : IHost public AudioTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } public void PlayBgm(long id) // BGM: direct name, not the manifest { - var path = _res.BgmPathById(id); - Events.Add(("play-bgm", id, path != null ? $"DATA3 {System.IO.Path.GetFileName(path)}" : $"BGM{id:D3}.OGG ")); + var entry = _res.ResolveBgm(id); + Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG ")); } public void PlayVoice(long id) // voice: per-scene manifest { var e = _res.Resolve(_scene, id); - Events.Add(("play-voice", id, e == null ? "" - : $"{e.Archive} {e.Name}" + (ResourceMap.AudioPath(e) == null ? " [NO FILE]" : ""))); + Events.Add(("play-voice", id, e == null ? "" : $"{e.Archive} {e.Name}")); } public void ShowText(int offset, string text) { } public void WaitForInput() { } diff --git a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs index 0116ee3..938bd61 100644 --- a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs +++ b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs @@ -167,6 +167,31 @@ public class Sys4AssetStoreTests } } + [Fact] + public void Sc0000BgmVoiceAndSfxPayloadsReadDirectlyFromArchives() + { + var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + var archiveOnly = new Sys4AssetStore(catalog, Paths.GameDir); + var resources = new ResourceMap(catalog, archiveOnly); + + var bgm = resources.ResolveBgm(5); + Assert.Equal("BGM005.OGG", bgm?.Name); + AssertOgg(resources.ReadAudio(bgm!)); + + var voice = resources.Resolve("SC0000", 0x24); + Assert.Equal("MAN999.OGG", voice?.Name); + AssertOgg(resources.ReadAudio(voice!)); + + var sfx = resources.Resolve("SC0000", 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.Throws(() => resources.ReadAudio(catalog.ResolveName("SO001.AGF")!)); + } + [Fact] public void AllInstalledLooseScriptOverridesShadowArchiveCopies() { @@ -235,4 +260,10 @@ public class Sys4AssetStoreTests stream.CopyTo(copy); return copy.ToArray(); } + + private static void AssertOgg(AudioPayload payload) + { + Assert.EndsWith(".OGG", payload.Name, StringComparison.OrdinalIgnoreCase); + Assert.Equal("OggS", Encoding.ASCII.GetString(payload.Bytes, 0, 4)); + } } diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 7285f7a..82f55a8 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -4,7 +4,7 @@ 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. -/// See docs/asset-resolution-re.md. Extracted paths remain temporary graphics/audio backends only. +/// See docs/asset-resolution-re.md. /// public sealed class ResourceMap { @@ -40,27 +40,29 @@ public sealed class ResourceMap public AssetEntry? ResolveName(string name) => _catalog.ResolveName(name); /// - /// Resolve a BGM id to its OGG path. BGM is addressed by DIRECT LITERAL NAME (BGM{id:D3}.OGG), NOT the + /// Resolve a BGM id to its catalog entry. BGM is addressed by DIRECT LITERAL NAME (BGM{id:D3}.OGG), NOT the /// per-scene section manifest that voices/textures use. Confirmed by ear (play-bgm 5->BGM005, 8->BGM008) /// and by the play-bgm 0x23->BGM035 case: BGM035 is a real standalone track (the BGM set skips 030-034), /// which the manifest mis-resolved to a graphics entry. See docs/asset-resolution-re.md. /// - public string? BgmPathById(long id) + public AssetEntry? ResolveBgm(long id) { var name = $"BGM{id:D3}.OGG"; - var f = _catalog.ResolveName(name); - return f == null ? null : AudioPath(f); + var entry = _catalog.ResolveName(name); + return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; } - /// Loose extracted OGG/WAV path for an audio asset (extracted/DATA{n}/{name}), or null. - /// Used by the current bootstrap audio backend; voices and SFX use the scene manifest via Resolve. - public static string? AudioPath(AssetEntry a) + /// Read a catalog-resolved OGG/WAV payload through the loose-first ALF/AAI byte store. + public AudioPayload ReadAudio(AssetEntry entry) { - if (!a.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase) && - !a.Name.EndsWith(".WAV", StringComparison.OrdinalIgnoreCase)) return null; - var dir = a.Archive.EndsWith(".ALF", StringComparison.OrdinalIgnoreCase) - ? a.Archive[..^4] : a.Archive; // "DATA3.ALF" -> "DATA3" - var audio = Path.Combine(Paths.Extracted, dir, a.Name); - return File.Exists(audio) ? audio : null; + if (entry.IsPlaceholder || !IsAudio(entry)) + throw new InvalidDataException($"not an OGG/WAV asset: {entry.Name}"); + return new AudioPayload(entry.Name, _store.ReadAll(entry)); } + + private static bool IsAudio(AssetEntry entry) + => entry.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase) + || entry.Name.EndsWith(".WAV", StringComparison.OrdinalIgnoreCase); } + +public sealed record AudioPayload(string Name, byte[] Bytes); diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 32d0db1..71be8a7 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -11,7 +11,7 @@ public sealed class GodotAdvHost : IHost private readonly string _scene; // e.g. "SC0000" — for section_base private readonly object _imageLock = new(); private readonly Dictionary _images = new(); // raw catalog id -> decoded pixels - private readonly string?[] _sfxPaths = new string?[10]; // SC0000 native channel subset + private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset // slot -> dims. Slot 0 is the primary/screen surface (800x600), normally created at engine boot which // the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0). private readonly Dictionary _slotDims = new() { { 0, (800, 600) } }; @@ -280,46 +280,57 @@ public sealed class GodotAdvHost : IHost // BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: via the per-scene manifest. public void PlayBgm(long id) { - var path = _res.BgmPathById(id); - _timeline?.Event("bgm", new() { ["id"] = id, ["file"] = path != null ? System.IO.Path.GetFileName(path) : null }); - if (path != null) _main.CallDeferred("PlayBgm", path); + var asset = _res.ResolveBgm(id); + var audio = asset != null ? LoadAudio(asset) : null; + _timeline?.Event("bgm", new() { ["id"] = id, ["file"] = audio?.Name }); + if (audio != null) _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name); } public void PlayVoice(long id) { var asset = _res.Resolve(_scene, id); - var path = asset != null ? ResourceMap.AudioPath(asset) : null; - if (path != null) _main.CallDeferred("PlayVoice", path); + var audio = asset != null ? LoadAudio(asset) : null; + if (audio != null) _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name); } public void LoadSoundEffect(long resourceId, int channel) { - if ((uint)channel >= (uint)_sfxPaths.Length) return; + if ((uint)channel >= (uint)_sfxNames.Length) return; var asset = _res.Resolve(_scene, resourceId); - var path = asset != null ? ResourceMap.AudioPath(asset) : null; - _sfxPaths[channel] = path; + var audio = asset != null ? LoadAudio(asset) : null; + _sfxNames[channel] = audio?.Name; _timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel, - ["file"] = path != null ? System.IO.Path.GetFileName(path) : null }); - if (path != null) _main.CallDeferred("LoadSoundEffect", path, channel); + ["file"] = audio?.Name }); + if (audio != null) _main.CallDeferred("LoadSoundEffect", audio.Bytes, audio.Name, channel); } public void StartSoundEffect(int channel) { - if ((uint)channel >= (uint)_sfxPaths.Length || _sfxPaths[channel] == null) return; + if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return; _timeline?.Event("sfx-start", new() { ["channel"] = channel, - ["file"] = System.IO.Path.GetFileName(_sfxPaths[channel]) }); + ["file"] = _sfxNames[channel] }); _main.CallDeferred("StartSoundEffect", channel); } public void ReleaseSoundEffect(int channel) { - if ((uint)channel >= (uint)_sfxPaths.Length) return; + if ((uint)channel >= (uint)_sfxNames.Length) return; _timeline?.Event("sfx-release", new() { ["channel"] = channel, - ["file"] = _sfxPaths[channel] != null ? System.IO.Path.GetFileName(_sfxPaths[channel]) : null }); - _sfxPaths[channel] = null; + ["file"] = _sfxNames[channel] }); + _sfxNames[channel] = null; _main.CallDeferred("ReleaseSoundEffect", channel); } + private AudioPayload? LoadAudio(AssetEntry asset) + { + try { return _res.ReadAudio(asset); } + catch (System.Exception e) + { + Godot.GD.Print($"audio read failed {asset.Name}: {e.Message}"); + return null; + } + } + public void FadeBgm(int targetPercent, long durationMs) { long ms = System.Math.Clamp(durationMs, 0, 60_000); diff --git a/godot/Main.cs b/godot/Main.cs index 922d6d3..fab81e5 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -514,31 +514,31 @@ public partial class Main : Godot.Control img.SetData(w, h, false, img.GetFormat(), px); } - // Load an OGG off disk and play it. BGM loops; voice plays once, cutting off any prior line. - public void PlayBgm(string oggPath) + // Decode VFS-owned bytes in Godot. BGM loops; voice plays once, cutting off any prior line. + public void PlayBgm(byte[] oggBytes, string assetName) { - var stream = AudioStreamOggVorbis.LoadFromBuffer(System.IO.File.ReadAllBytes(oggPath)); - if (stream == null) { GD.Print($"OGG load failed {oggPath}"); return; } + var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes); + if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; } stream.Loop = true; _bgm.VolumeDb = 0; _bgm.Stream = stream; _bgm.Play(); } - public void PlayVoice(string oggPath) + public void PlayVoice(byte[] oggBytes, string assetName) { - var stream = AudioStreamOggVorbis.LoadFromBuffer(System.IO.File.ReadAllBytes(oggPath)); - if (stream == null) { GD.Print($"OGG load failed {oggPath}"); return; } + var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes); + if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; } stream.Loop = false; _voice.Stream = stream; _voice.Play(); } - public void LoadSoundEffect(string wavPath, int channel) + public void LoadSoundEffect(byte[] wavBytes, string assetName, int channel) { if ((uint)channel >= (uint)_sfx.Length) return; - var stream = AudioStreamWav.LoadFromBuffer(System.IO.File.ReadAllBytes(wavPath)); - if (stream == null) { GD.Print($"WAV load failed {wavPath}"); return; } + var stream = AudioStreamWav.LoadFromBuffer(wavBytes); + if (stream == null) { GD.Print($"WAV load failed {assetName}"); return; } stream.LoopMode = AudioStreamWav.LoopModeEnum.Disabled; _sfx[channel].Stop(); _sfx[channel].VolumeDb = 0;