feat(audio): Campaign A slice A4 — the interface sound bus
Retail's UI sound bank was absent, so three families of cue were silent: the portal enter/exit stingers, the AdminEnvirons dungeon atmosphere (chanting, drums, whispers, thunder — what players remember as dungeon 'music'), and every other interface slot. The bank's DID is not a literal anywhere in retail: GetUISoundTable @0x00563FB0 asks GetByEnum for enum slot 7, and DBCache::GetDIDFromEnum @0x00413940 resolves it through two EnumIDMap hops off the portal dat header's master map. UiSoundTableResolver walks that chain the way RetailCursorResolver already walks it for cursors. Against the shipped dats it resolves to 0x2000004B, and that table holds exactly the 32 UI_* slots (UI_EnterPortal 0x6A .. UI_Thunder6 0x8A) — content that confirms the walk independently of the decode. UiSoundTableResolutionTests pins the walk, the DID, and the content, and skips when dats are absent. Two corrections to the research along the way. The lane-5 note recorded GetByEnum's arguments transposed: the 0x22 it called a fileType is the CACHE type (CLOCache(cache, CSoundTable::Allocator, 0x22)) and the real second-hop key is 0x10000003; walking it the other way finds nothing. And its claim that the interface volume pref applies is wrong — GetAttenuation with ambient=0 multiplies by the EFFECT knob, so retail's interface_sound_volume stays the dead knob lane 1 byte-decoded it to be. EnvironSoundCueMap is an explicit 21-case table read straight out of Handle_Admin__Environs @0x0055DE20, not arithmetic: codes 0x65..0x72 sit 0x11 below their SoundType, but 0x73/0x74 have no case, so 0x75 lands on UI_Squeal (0x84) where an offset gives 0x86, and the switch ends at 0x7B with no 0x7C case. Verified case-by-case against the decomp rather than from the lane note, whose tail table was ambiguous. Cues are attached where retail plays them: the teleport-animation boundary for the portal pair, and the AdminEnvirons handler for the stingers. PlaySoundFromCenter's pan-0 / distance-0 shape is what PlayUiWave already implements after A2. Retires TS-54. Narrows AP-115 to its notice-presentation residual. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8bc458fb88
commit
6eaa490bb3
12 changed files with 586 additions and 13 deletions
95
src/AcDream.App/Audio/UiSoundController.cs
Normal file
95
src/AcDream.App/Audio/UiSoundController.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using System;
|
||||
using AcDream.Core.Audio;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DRWSound = DatReaderWriter.Enums.Sound;
|
||||
|
||||
namespace AcDream.App.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's interface sound bus — <c>SoundManager::PlaySoundFromCenter</c> @
|
||||
/// <c>0x00550950</c> over <c>ClientUISystem::GetUISoundTable</c> @
|
||||
/// <c>0x00563FB0</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// "From centre" means pan 0 and <c>GetAttenuation(0.0f, vol, &out, 0)</c> —
|
||||
/// distance zero, so the flat branch of the curve, and the <b>effect</b> volume
|
||||
/// knob rather than the ambient one. Retail's separate
|
||||
/// <c>interface_sound_volume</c> preference is registered and never read, so
|
||||
/// there is deliberately no interface volume here either (AP-174).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The bank's DID is resolved from the dats by <see cref="UiSoundTableResolver"/>
|
||||
/// rather than hard-coded, and the table itself is fetched lazily on first use
|
||||
/// exactly as retail's <c>GetUISoundTable</c> caches it behind a null check.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiSoundController
|
||||
{
|
||||
private readonly OpenAlAudioEngine _engine;
|
||||
private readonly DatSoundCache _cache;
|
||||
private readonly ISoundRandom _rng;
|
||||
private readonly uint _tableDid;
|
||||
private SoundTable? _table;
|
||||
private bool _tableMissing;
|
||||
|
||||
public UiSoundController(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
uint tableDid,
|
||||
ISoundRandom? rng = null)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_tableDid = tableDid;
|
||||
_rng = rng ?? new SoundRandom();
|
||||
}
|
||||
|
||||
/// <summary>The resolved bank DID, or 0 when the dats carry no chain.</summary>
|
||||
public uint TableDid => _tableDid;
|
||||
|
||||
/// <summary>
|
||||
/// Play one interface slot. Returns false when the bank is absent, the slot
|
||||
/// is unauthored, or the entry's probability gate says silence.
|
||||
/// </summary>
|
||||
public bool Play(SoundId sound)
|
||||
{
|
||||
if (!_engine.IsAvailable || _tableDid == 0 || _tableMissing)
|
||||
return false;
|
||||
|
||||
if (_table is null)
|
||||
{
|
||||
_table = _cache.GetSoundTable(_tableDid);
|
||||
if (_table is null)
|
||||
{
|
||||
_tableMissing = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var entry = SoundCookbook.Select(_table, (DRWSound)sound, _rng);
|
||||
if (entry is null)
|
||||
return false;
|
||||
|
||||
uint waveId = (uint)entry.Id;
|
||||
if (waveId == 0)
|
||||
return false;
|
||||
|
||||
WaveData? wave = _cache.GetWave(waveId);
|
||||
if (wave is null)
|
||||
return false;
|
||||
|
||||
// PlaySoundFromCenter takes no volume argument, so the authored entry
|
||||
// volume is the one that reaches the mixer.
|
||||
return _engine.PlayUiWave(waveId, wave, entry.Volume);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play the interface stinger for an <c>AdminEnvirons</c> change type — the
|
||||
/// server-driven dungeon atmosphere (chanting, drums, whispers, thunder).
|
||||
/// Codes retail has no case for, including <c>0x73</c>/<c>0x74</c> inside the
|
||||
/// run, play nothing.
|
||||
/// </summary>
|
||||
public bool PlayEnvironCue(uint changeType) =>
|
||||
EnvironSoundCueMap.TryGetSound(changeType, out SoundId sound) && Play(sound);
|
||||
}
|
||||
69
src/AcDream.App/Audio/UiSoundTableResolver.cs
Normal file
69
src/AcDream.App/Audio/UiSoundTableResolver.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the DID of retail's interface sound bank by walking the dats, the
|
||||
/// way retail does, instead of carrying a literal.
|
||||
///
|
||||
/// <para>
|
||||
/// <c>ClientUISystem::GetUISoundTable</c> @ <c>0x00563FB0</c> calls
|
||||
/// <c>DBObj::GetByEnum</c> @ <c>0x00415490</c>, and
|
||||
/// <c>DBCache::GetDIDFromEnum</c> @ <c>0x00413940</c> resolves the answer with
|
||||
/// two <c>EnumIDMap</c> hops off <c>m_MasterMapID</c>: keyed first by the enum
|
||||
/// INDEX (7 for the UI bank), then by the type key. Same chain and same helper
|
||||
/// shape as <see cref="AcDream.App.Rendering.RetailCursorResolver"/> and the
|
||||
/// portal tunnel's setup/animation lookup — the master id comes from the portal
|
||||
/// dat header rather than being searched for.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Against the shipped dats this resolves to <c>0x2000004B</c>, a table holding
|
||||
/// exactly the 32 <c>UI_*</c> slots.
|
||||
/// <c>UiSoundTableResolutionTests</c> pins the walk, the DID, and the content.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class UiSoundTableResolver
|
||||
{
|
||||
/// <summary>The enum index the UI sound bank lives at.</summary>
|
||||
public const uint UiSoundTableEnumSlot = 7u;
|
||||
|
||||
/// <summary>
|
||||
/// The type key the second hop uses. <c>GetByEnum(type, idx, cache)</c> hands
|
||||
/// <c>(type, idx)</c> to the enum chain and the resolved DID to
|
||||
/// <c>DBCache::Get(did, cache)</c>; a SoundTable's CACHE type is <c>0x22</c>
|
||||
/// (<c>CLOCache(cache, CSoundTable::Allocator, 0x22)</c>), which is not this
|
||||
/// key. The lane-5 research note transposed the two arguments.
|
||||
/// </summary>
|
||||
public const uint UiSoundTableTypeKey = 0x10000003u;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the bank's DID, or 0 when the dats carry no such chain (in which
|
||||
/// case interface sounds stay silent rather than playing a guessed table).
|
||||
/// </summary>
|
||||
public static uint Resolve(IDatReaderWriter dats)
|
||||
{
|
||||
if (dats is null)
|
||||
return 0u;
|
||||
|
||||
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
|
||||
if (masterDid == 0)
|
||||
return 0u;
|
||||
|
||||
if (!dats.Portal.TryGet<EnumIDMap>(masterDid, out var master) || master is null)
|
||||
return 0u;
|
||||
|
||||
if (!master.ClientEnumToID.TryGetValue(UiSoundTableEnumSlot, out uint perSlotDid)
|
||||
|| perSlotDid == 0)
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
if (!dats.Portal.TryGet<EnumIDMap>(perSlotDid, out var perSlot) || perSlot is null)
|
||||
return 0u;
|
||||
|
||||
return perSlot.ClientEnumToID.TryGetValue(UiSoundTableTypeKey, out uint did) ? did : 0u;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,8 @@ internal sealed record ContentAudioGraph(
|
|||
DatSoundCache SoundCache,
|
||||
OpenAlAudioEngine Engine,
|
||||
DictionaryEntitySoundTable EntitySoundTables,
|
||||
AudioHookSink? HookSink);
|
||||
AudioHookSink? HookSink,
|
||||
UiSoundController? UiSounds);
|
||||
|
||||
internal sealed record ContentEffectsAudioResult(
|
||||
IDatReaderWriter Dats,
|
||||
|
|
@ -133,6 +134,10 @@ internal interface IContentEffectsAudioCompositionFactory
|
|||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
DictionaryEntitySoundTable entitySoundTables);
|
||||
UiSoundController CreateUiSounds(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
IDatReaderWriter dats);
|
||||
}
|
||||
|
||||
internal sealed class RetailContentEffectsAudioCompositionFactory
|
||||
|
|
@ -242,6 +247,12 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
|
|||
DatSoundCache cache,
|
||||
DictionaryEntitySoundTable entitySoundTables) =>
|
||||
new(engine, cache, entitySoundTables);
|
||||
|
||||
public UiSoundController CreateUiSounds(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
IDatReaderWriter dats) =>
|
||||
new(engine, cache, UiSoundTableResolver.Resolve(dats));
|
||||
}
|
||||
|
||||
internal enum ContentEffectsAudioCompositionPoint
|
||||
|
|
@ -268,6 +279,7 @@ internal enum ContentEffectsAudioCompositionPoint
|
|||
AudioEngineCreated,
|
||||
EntitySoundTablesCreated,
|
||||
AudioSinkCreated,
|
||||
UiSoundsCreated,
|
||||
AudioPublished,
|
||||
AudioHookRegistered,
|
||||
}
|
||||
|
|
@ -472,13 +484,21 @@ internal sealed class ContentEffectsAudioCompositionPhase :
|
|||
_factory.CreateEntitySoundTables();
|
||||
Fault(ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated);
|
||||
AudioHookSink? sink = null;
|
||||
UiSoundController? uiSounds = null;
|
||||
if (engine.IsAvailable)
|
||||
{
|
||||
sink = _factory.CreateAudioSink(engine, cache, soundTables);
|
||||
Fault(ContentEffectsAudioCompositionPoint.AudioSinkCreated);
|
||||
uiSounds = _factory.CreateUiSounds(engine, cache, dats);
|
||||
_dependencies.Log(
|
||||
uiSounds.TableDid == 0
|
||||
? "audio: UI sound bank unresolved (no enum chain in dats) "
|
||||
+ "- interface cues silent"
|
||||
: $"audio: UI sound bank = 0x{uiSounds.TableDid:X8}");
|
||||
Fault(ContentEffectsAudioCompositionPoint.UiSoundsCreated);
|
||||
}
|
||||
|
||||
graph = new ContentAudioGraph(cache, engine, soundTables, sink);
|
||||
graph = new ContentAudioGraph(cache, engine, soundTables, sink, uiSounds);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -937,9 +937,22 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
acceptedPositionDrive);
|
||||
|
||||
LocalPlayerTeleportController CreateLocalTeleportWithTunnel(
|
||||
PortalTunnelPresentation portalTunnel) =>
|
||||
CreateLocalTeleport(
|
||||
new LocalPlayerTeleportPresentation(portalTunnel));
|
||||
PortalTunnelPresentation portalTunnel)
|
||||
{
|
||||
var tunnelPresentation = new LocalPlayerTeleportPresentation(portalTunnel);
|
||||
if (content.Audio?.UiSounds is { } portalUiSounds)
|
||||
tunnelPresentation.UiSoundSink = sound => portalUiSounds.Play(sound);
|
||||
return CreateLocalTeleport(tunnelPresentation);
|
||||
}
|
||||
// Campaign A slice A4: the AdminEnvirons stingers from
|
||||
// Handle_Admin__Environs go through the interface sound bank. The portal
|
||||
// enter/exit cues are attached to the tunnel presentation below, which is
|
||||
// where retail's teleport-animation boundary plays them.
|
||||
if (content.Audio?.UiSounds is { } environUiSounds)
|
||||
{
|
||||
d.WorldEnvironment.EnvironSoundSink =
|
||||
changeType => environUiSounds.PlayEnvironCue(changeType);
|
||||
}
|
||||
var teleportLease = scope.Own(
|
||||
"local-player teleport",
|
||||
localTeleport,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.App.Rendering;
|
|||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Audio;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
|
|
@ -348,8 +349,28 @@ internal sealed class LocalPlayerTeleportPresentation
|
|||
}
|
||||
|
||||
public void TickTunnel(float deltaSeconds) => _tunnel.Tick(deltaSeconds);
|
||||
public void EnterTunnel() => _tunnel.Enter();
|
||||
public void ExitTunnel() => _tunnel.Exit();
|
||||
|
||||
/// <summary>
|
||||
/// Interface-sound sink for the portal cues. Retail plays these from the
|
||||
/// teleport-animation boundary, not from the tunnel renderer:
|
||||
/// <c>PlaySoundFromCenter(Sound_UI_EnterPortal, GetUISoundTable())</c> at
|
||||
/// <c>0x004D638E</c> inside <c>gmSmartBoxUI::BeginTeleportAnimation</c>, and
|
||||
/// <c>Sound_UI_ExitPortal</c> at <c>0x004D7405</c>. Assigned at composition;
|
||||
/// null (and therefore silent) when audio is unavailable.
|
||||
/// </summary>
|
||||
public Action<SoundId>? UiSoundSink { get; set; }
|
||||
|
||||
public void EnterTunnel()
|
||||
{
|
||||
_tunnel.Enter();
|
||||
UiSoundSink?.Invoke(SoundId.UI_EnterPortal);
|
||||
}
|
||||
|
||||
public void ExitTunnel()
|
||||
{
|
||||
_tunnel.Exit();
|
||||
UiSoundSink?.Invoke(SoundId.UI_ExitPortal);
|
||||
}
|
||||
public void SetWaitCue(bool visible) => _tunnel.SetWaitCue(visible);
|
||||
|
||||
public void Reset()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.Core.Audio;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.World;
|
||||
|
|
@ -113,8 +114,21 @@ internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource
|
|||
public void SynchronizeFromServer(double ticks) =>
|
||||
Runtime.SynchronizeFromServer(ticks);
|
||||
|
||||
public void ApplyAdminEnvirons(uint environChangeType) =>
|
||||
_ = Runtime.ApplyAdminEnvirons(environChangeType);
|
||||
/// <summary>
|
||||
/// Interface-sound sink for retail's AdminEnvirons stingers. Retail's
|
||||
/// <c>CPlayerSystem::Handle_Admin__Environs</c> @ <c>0x0055DE20</c> plays each
|
||||
/// of codes 0x65..0x7B from centre through the UI sound bank; the
|
||||
/// code-to-slot table is <see cref="EnvironSoundCueMap"/>. Null (silent)
|
||||
/// when audio is unavailable.
|
||||
/// </summary>
|
||||
public Action<uint>? EnvironSoundSink { get; set; }
|
||||
|
||||
public void ApplyAdminEnvirons(uint environChangeType)
|
||||
{
|
||||
RuntimeEnvironmentEffect effect = Runtime.ApplyAdminEnvirons(environChangeType);
|
||||
if (effect.Kind is RuntimeEnvironmentEffectKind.SoundCue)
|
||||
EnvironSoundSink?.Invoke(environChangeType);
|
||||
}
|
||||
|
||||
public void RefreshSkyForCurrentDay() => Runtime.RefreshDayGroup();
|
||||
|
||||
|
|
|
|||
67
src/AcDream.Core/Audio/EnvironSoundCueMap.cs
Normal file
67
src/AcDream.Core/Audio/EnvironSoundCueMap.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Collections.Frozen;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>AdminEnvirons</c> code → interface <see cref="SoundId"/> table,
|
||||
/// from the switch in <c>CPlayerSystem::Handle_Admin__Environs</c> @
|
||||
/// <c>0x0055DE20</c> (<c>0x0055E07F..0x0055E2C7</c>). Each case plays
|
||||
/// <c>SoundManager::PlaySoundFromCenter(Sound_UI_*, GetUISoundTable())</c> after
|
||||
/// checking that the local player physics object and the UI table both exist.
|
||||
///
|
||||
/// <para>
|
||||
/// This is what players remember as dungeon "music": the server drives the
|
||||
/// chanting, drums, whispers and thunder through these codes. It is not a music
|
||||
/// system — retail has none — but it is the atmosphere channel.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>An explicit table, deliberately.</b> The environ code and the SoundType
|
||||
/// differ by 0x11 for most of the run, but NOT uniformly: codes <c>0x73</c> and
|
||||
/// <c>0x74</c> have no case at all (retail falls through and plays nothing), so
|
||||
/// <c>0x75</c> lands on <c>UI_Squeal</c> (0x84) rather than the 0x86 arithmetic
|
||||
/// would give. Retail's switch is explicit; porting it as an offset would
|
||||
/// mis-map every code from <c>0x75</c> up.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class EnvironSoundCueMap
|
||||
{
|
||||
private static readonly FrozenDictionary<uint, SoundId> Map =
|
||||
new Dictionary<uint, SoundId>
|
||||
{
|
||||
[0x65u] = SoundId.UI_Roar,
|
||||
[0x66u] = SoundId.UI_Bell,
|
||||
[0x67u] = SoundId.UI_Chant1,
|
||||
[0x68u] = SoundId.UI_Chant2,
|
||||
[0x69u] = SoundId.UI_DarkWhispers1,
|
||||
[0x6Au] = SoundId.UI_DarkWhispers2,
|
||||
[0x6Bu] = SoundId.UI_DarkLaugh,
|
||||
[0x6Cu] = SoundId.UI_DarkWind,
|
||||
[0x6Du] = SoundId.UI_DarkSpeech,
|
||||
[0x6Eu] = SoundId.UI_Drums,
|
||||
[0x6Fu] = SoundId.UI_GhostSpeak,
|
||||
[0x70u] = SoundId.UI_Breathing,
|
||||
[0x71u] = SoundId.UI_Howl,
|
||||
[0x72u] = SoundId.UI_LostSouls,
|
||||
// 0x73 and 0x74: no case in retail's switch — nothing plays.
|
||||
[0x75u] = SoundId.UI_Squeal,
|
||||
[0x76u] = SoundId.UI_Thunder1,
|
||||
[0x77u] = SoundId.UI_Thunder2,
|
||||
[0x78u] = SoundId.UI_Thunder3,
|
||||
[0x79u] = SoundId.UI_Thunder4,
|
||||
[0x7Au] = SoundId.UI_Thunder5,
|
||||
[0x7Bu] = SoundId.UI_Thunder6,
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
/// <summary>
|
||||
/// Map an <c>AdminEnvirons</c> change type to its interface sound, or false
|
||||
/// when retail's switch has no case for it (including <c>0x73</c>/<c>0x74</c>
|
||||
/// inside the run, and every code outside it).
|
||||
/// </summary>
|
||||
public static bool TryGetSound(uint changeType, out SoundId sound) =>
|
||||
Map.TryGetValue(changeType, out sound);
|
||||
|
||||
/// <summary>Every code retail has a case for. Diagnostic and test use.</summary>
|
||||
public static IReadOnlyCollection<uint> Codes => Map.Keys;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue