diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index f43a224..a36e21d 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -154,6 +154,9 @@ scene-context lifecycle coordination. `godot/GodotAdvHost.Surfaces.cs` owns deco surface pixels/resources/dimensions, fill/copy/resolve operations, render-target publication, and surface/range teardown. `godot/GodotAdvHost.Movies.cs` owns movie surface bindings, ordinary and modal playback, movie-mask transitions, diagnostic snapshots, frame/completion publication, and mask teardown. +`godot/GodotAdvHost.Audio.cs` owns BGM, voice and SFX resolution/dispatch, delayed voice state, volume/routing +control, and blocking BGM fades; presentation/input retains the message-skip, reset, and frame-pulse consumers +of that state through the sealed partial class. The disposable `build/page-map-.jsonl` files are produced by editor/development Godot runs and map runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 5167614..d09ab85 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -591,6 +591,11 @@ do not mix mechanical moves with semantic changes. `godot/GodotAdvHost.Movies.cs`. Presentation waits and mutable surface storage retain direct access through the sealed partial class; runtime validation remains green. + The fifth bounded `GodotAdvHost` split moved BGM, voice and SFX resolution/dispatch, delayed voice state, + volume/routing control, and blocking BGM fades into `godot/GodotAdvHost.Audio.cs`. Message-skip release, + scene reset, and frame-pulse consumers retain direct access through the sealed partial class. The planned + host decomposition is complete; runtime validation remains green. + **Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after each domain move. @@ -961,8 +966,8 @@ layer's rendering diverges from ADV; save layout. ## 8. Immediate next step Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed -by the tracked launcher and layered validation driver. With the planned `Main` domains and the first four -`GodotAdvHost` domains isolated, move host audio ownership next, then continue one existing domain at a time -while preserving public types, commands, and generated output. +by the tracked launcher and layered validation driver. With the planned `Main` and `GodotAdvHost` domains +isolated, begin the `GfxState` decomposition with one existing ownership domain at a time, preserving public +types, commands, and generated output. Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does not replace Phase B gameplay validation or the open cross-platform gates. diff --git a/godot/GodotAdvHost.Audio.cs b/godot/GodotAdvHost.Audio.cs new file mode 100644 index 0000000..0b8b1a8 --- /dev/null +++ b/godot/GodotAdvHost.Audio.cs @@ -0,0 +1,196 @@ +using System; +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; + +public sealed partial class GodotAdvHost +{ + private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset + private int _voiceBgmDuckControl; + private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice; + private readonly object _scheduledVoiceLock = new(); + private (AudioPayload Audio, int PlaybackVariant, uint DelayMs, uint? StartMs)? _scheduledVoice; + + public void PlayBgm(long id) + => DispatchBgm(id, 1, false); + + public void RestartBgm(long id, int startMode) + => DispatchBgm(id, startMode, true); + + private void DispatchBgm(long id, int startMode, bool forceRestart) + { + var asset = _res.ResolveBgm(id); + var audio = asset != null ? LoadAudio(asset) : null; + _timeline?.Event("bgm", new() + { + ["id"] = id, + ["file"] = audio?.Name, + ["start_mode"] = startMode, + ["force_restart"] = forceRestart, + }); + if (audio == null) return; + if (forceRestart) + _main.CallDeferred("RestartBgm", audio.Bytes, audio.Name, startMode); + else + _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name); + } + + public void StopBgm() + { + _timeline?.Event("bgm-stop", new()); + _main.CallDeferred("StopBgm"); + } + + public void PlayVoice(long id) => PlayVoice(id, 0); + + public void PlayVoice(long id, int playbackVariant) + { + var asset = _res.ResolveVoice(id); + var audio = asset != null ? LoadAudio(asset) : null; + _timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name, + ["playback_variant"] = playbackVariant }); + if (audio == null) return; + bool queuedForSkip; + bool firstQueued = false; + lock (_messageSkipLock) + { + queuedForSkip = _messageSkipActive; + if (queuedForSkip) + { + firstQueued = _queuedSkippedVoice == null; + _queuedSkippedVoice = (audio, playbackVariant); + } + } + if (queuedForSkip) + { + if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip"); + return; + } + DispatchVoice(audio, playbackVariant); + } + + private void DispatchVoice(AudioPayload audio, int playbackVariant) + { + int generation = _main.QueueVoicePlayback(); + bool duckBgm = (System.Threading.Volatile.Read(ref _voiceBgmDuckControl) & 1) == 0; + // Godot's stream player has no matching AGE start-mode control. Retain the native + // variant through dispatch/timeline so that distinction is not erased at the VM seam. + _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation, duckBgm, 50); + } + + public void SetVoiceBgmDuckControl(long flags) + { + System.Threading.Volatile.Write(ref _voiceBgmDuckControl, unchecked((int)flags)); + _timeline?.State("voice-bgm-duck-control", new() { ["flags"] = flags }); + } + + public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs) + { + var asset = _res.ResolveVoice(id); + var audio = asset != null ? LoadAudio(asset) : null; + _timeline?.Event("voice-scheduled", new() + { + ["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant, + ["delay_ms"] = unchecked((uint)delayMs), + }); + lock (_scheduledVoiceLock) + _scheduledVoice = audio == null + ? null + : (audio, playbackVariant, unchecked((uint)delayMs), null); + } + + public void LoadSoundEffect(long resourceId, int channel) + { + if ((uint)channel >= (uint)_sfxNames.Length) return; + 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, + ["file"] = audio?.Name }); + if (audio != null) _main.CallDeferred("LoadSoundEffect", audio.Bytes, audio.Name, channel); + } + + public void StartSoundEffect(int channel) => StartSoundEffect(channel, 0); + + public void StartSoundEffect(int channel, int startMode) + { + if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return; + _timeline?.Event("sfx-start", new() { ["channel"] = channel, + ["start_mode"] = startMode, ["file"] = _sfxNames[channel] }); + _main.CallDeferred("StartSoundEffect", channel, startMode); + } + + public void ScheduleSoundEffectStart(int channel, int startMode, long delayMs) + { + if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return; + long ms = System.Math.Clamp(delayMs, 0, 60_000); + double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed); + _timeline?.Event("sfx-start-scheduled", new() { ["channel"] = channel, + ["start_mode"] = startMode, ["delay_ms"] = ms, ["file"] = _sfxNames[channel] }); + _main.CallDeferred("ScheduleSoundEffectStart", channel, startMode, realSeconds); + } + + public void ReleaseSoundEffect(int channel) + { + if ((uint)channel >= (uint)_sfxNames.Length) return; + _timeline?.Event("sfx-release", new() { ["channel"] = channel, + ["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); + double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed); + _timeline?.State("bgm-fade", new() { ["target_percent"] = targetPercent, ["duration_ms"] = ms }); + _main.CallDeferred("FadeBgm", targetPercent, realSeconds); + long deadline = _clock.NowMs + ms; + IsSleeping = true; + bool scriptSuspended = SuspendScriptForPresentation(); + try + { + // Native parks the interpreter in its audio service while the main render loop continues. + // Publish scene changes accumulated before the fade (notably GAMESTART -> SC0000's black + // frame), then leave presentation ownership with the compositor for the timed wait. + RequestSynchronizedPresentation(); + while (_clock.NowMs < deadline && !_stopping) _frameSignal.WaitOne(50); + } + finally + { + ResumeScriptAfterPresentation(scriptSuspended); + IsSleeping = false; + } + _timeline?.State("running", new() { ["bgm_fade_complete"] = true }); + } + + public void ApplyAudioVolume(int category, int basisPoints) + { + _timeline?.State("audio-volume", new() + { + ["category"] = category, + ["basis_points"] = basisPoints, + }); + _main.CallDeferred("ApplyAudioVolume", category, basisPoints); + } + + public void ApplyAudioRouteEnabled(int category, bool enabled) + { + _timeline?.State("audio-route", new() + { + ["category"] = category, + ["enabled"] = enabled, + }); + _main.CallDeferred("ApplyAudioRouteEnabled", category, enabled); + } +} diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 1ce4ebf..82fd629 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Threading; using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; @@ -12,16 +10,11 @@ public sealed partial class GodotAdvHost : IHost private readonly Main _main; private readonly ResourceMap _res; private readonly string _rootScene; - private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset private readonly int _screenWidth; private readonly int _screenHeight; private readonly Age.Engine.Hosting.FrameClock _clock; private readonly GodotTimelineLog? _timeline; private readonly PageLocatorState _locator; - private int _voiceBgmDuckControl; - private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice; - private readonly object _scheduledVoiceLock = new(); - private (AudioPayload Audio, int PlaybackVariant, uint DelayMs, uint? StartMs)? _scheduledVoice; public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock, PageLocatorState locator, Sys4LogicalCanvas logicalCanvas, IGlyphMaskRasterizer surfaceTextRasterizer, @@ -52,189 +45,6 @@ public sealed partial class GodotAdvHost : IHost public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight); public void ReportWarning(string message) => System.Console.Error.WriteLine(message); - - public void PlayBgm(long id) - => DispatchBgm(id, 1, false); - - public void RestartBgm(long id, int startMode) - => DispatchBgm(id, startMode, true); - - private void DispatchBgm(long id, int startMode, bool forceRestart) - { - var asset = _res.ResolveBgm(id); - var audio = asset != null ? LoadAudio(asset) : null; - _timeline?.Event("bgm", new() - { - ["id"] = id, - ["file"] = audio?.Name, - ["start_mode"] = startMode, - ["force_restart"] = forceRestart, - }); - if (audio == null) return; - if (forceRestart) - _main.CallDeferred("RestartBgm", audio.Bytes, audio.Name, startMode); - else - _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name); - } - - public void StopBgm() - { - _timeline?.Event("bgm-stop", new()); - _main.CallDeferred("StopBgm"); - } - - public void PlayVoice(long id) => PlayVoice(id, 0); - - public void PlayVoice(long id, int playbackVariant) - { - var asset = _res.ResolveVoice(id); - var audio = asset != null ? LoadAudio(asset) : null; - _timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name, - ["playback_variant"] = playbackVariant }); - if (audio == null) return; - bool queuedForSkip; - bool firstQueued = false; - lock (_messageSkipLock) - { - queuedForSkip = _messageSkipActive; - if (queuedForSkip) - { - firstQueued = _queuedSkippedVoice == null; - _queuedSkippedVoice = (audio, playbackVariant); - } - } - if (queuedForSkip) - { - if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip"); - return; - } - DispatchVoice(audio, playbackVariant); - } - - private void DispatchVoice(AudioPayload audio, int playbackVariant) - { - int generation = _main.QueueVoicePlayback(); - bool duckBgm = (System.Threading.Volatile.Read(ref _voiceBgmDuckControl) & 1) == 0; - // Godot's stream player has no matching AGE start-mode control. Retain the native - // variant through dispatch/timeline so that distinction is not erased at the VM seam. - _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation, duckBgm, 50); - } - - public void SetVoiceBgmDuckControl(long flags) - { - System.Threading.Volatile.Write(ref _voiceBgmDuckControl, unchecked((int)flags)); - _timeline?.State("voice-bgm-duck-control", new() { ["flags"] = flags }); - } - - public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs) - { - var asset = _res.ResolveVoice(id); - var audio = asset != null ? LoadAudio(asset) : null; - _timeline?.Event("voice-scheduled", new() - { - ["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant, - ["delay_ms"] = unchecked((uint)delayMs), - }); - lock (_scheduledVoiceLock) - _scheduledVoice = audio == null - ? null - : (audio, playbackVariant, unchecked((uint)delayMs), null); - } - - public void LoadSoundEffect(long resourceId, int channel) - { - if ((uint)channel >= (uint)_sfxNames.Length) return; - 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, - ["file"] = audio?.Name }); - if (audio != null) _main.CallDeferred("LoadSoundEffect", audio.Bytes, audio.Name, channel); - } - - public void StartSoundEffect(int channel) => StartSoundEffect(channel, 0); - - public void StartSoundEffect(int channel, int startMode) - { - if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return; - _timeline?.Event("sfx-start", new() { ["channel"] = channel, - ["start_mode"] = startMode, ["file"] = _sfxNames[channel] }); - _main.CallDeferred("StartSoundEffect", channel, startMode); - } - - public void ScheduleSoundEffectStart(int channel, int startMode, long delayMs) - { - if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return; - long ms = System.Math.Clamp(delayMs, 0, 60_000); - double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed); - _timeline?.Event("sfx-start-scheduled", new() { ["channel"] = channel, - ["start_mode"] = startMode, ["delay_ms"] = ms, ["file"] = _sfxNames[channel] }); - _main.CallDeferred("ScheduleSoundEffectStart", channel, startMode, realSeconds); - } - - public void ReleaseSoundEffect(int channel) - { - if ((uint)channel >= (uint)_sfxNames.Length) return; - _timeline?.Event("sfx-release", new() { ["channel"] = channel, - ["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); - double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed); - _timeline?.State("bgm-fade", new() { ["target_percent"] = targetPercent, ["duration_ms"] = ms }); - _main.CallDeferred("FadeBgm", targetPercent, realSeconds); - long deadline = _clock.NowMs + ms; - IsSleeping = true; - bool scriptSuspended = SuspendScriptForPresentation(); - try - { - // Native parks the interpreter in its audio service while the main render loop continues. - // Publish scene changes accumulated before the fade (notably GAMESTART -> SC0000's black - // frame), then leave presentation ownership with the compositor for the timed wait. - RequestSynchronizedPresentation(); - while (_clock.NowMs < deadline && !_stopping) _frameSignal.WaitOne(50); - } - finally - { - ResumeScriptAfterPresentation(scriptSuspended); - IsSleeping = false; - } - _timeline?.State("running", new() { ["bgm_fade_complete"] = true }); - } - - public void ApplyAudioVolume(int category, int basisPoints) - { - _timeline?.State("audio-volume", new() - { - ["category"] = category, - ["basis_points"] = basisPoints, - }); - _main.CallDeferred("ApplyAudioVolume", category, basisPoints); - } - - public void ApplyAudioRouteEnabled(int category, bool enabled) - { - _timeline?.State("audio-route", new() - { - ["category"] = category, - ["enabled"] = enabled, - }); - _main.CallDeferred("ApplyAudioRouteEnabled", category, enabled); - } } public sealed record GodotHostDiagnosticSnapshot(