diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 3cb209a6..9688b591 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -307,7 +307,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
-| AP-115 | The DAT-authored portal-space viewport, animation `SoundTweakedHook`, and centered repeating `"In Portal Space - Please Wait..."` display string are live, but the separate `ClientUISystem` enter/exit sound enums are not yet presented. **Scope note (2026-08-06):** this row covers the cue's PRESENTATION only. Its five-second arming threshold is a separate, unregistered divergence now filed as AP-150 — retail emits the notice unconditionally per tunnel rotation segment (0.6-1.8 s) and has no such threshold. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs` | acdream has no ClientUISystem sound-table-enum resolver yet; inventing direct wave IDs would be less faithful. The notice uses the retained fullscreen UI rather than chat and remains tied to the portal presentation lifetime. | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, animation-authored sound, and centered wait notice, but lacks retail's short UI enter/exit cue sounds. | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
+| AP-115 | **NARROWED 2026-08-08 (Campaign A slice A4) — the sound half is landed; only the notice's presentation remains.** The enter/exit cues now play: `LocalPlayerTeleportPresentation.EnterTunnel`/`ExitTunnel` fire `UI_EnterPortal`/`UI_ExitPortal` through the resolved interface sound bank, which is where retail plays them (`0x004D638E` / `0x004D7405`, inside the teleport-animation boundary rather than the tunnel renderer). The DAT-authored portal-space viewport, animation `SoundTweakedHook`, and centered repeating `"In Portal Space - Please Wait..."` display string are live. **Scope note (2026-08-06):** this row covers the cue's PRESENTATION only. Its five-second arming threshold is a separate, unregistered divergence now filed as AP-150 — retail emits the notice unconditionally per tunnel rotation segment (0.6-1.8 s) and has no such threshold. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs` | acdream has no ClientUISystem sound-table-enum resolver yet; inventing direct wave IDs would be less faithful. The notice uses the retained fullscreen UI rather than chat and remains tied to the portal presentation lifetime. | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, animation-authored sound, centered wait notice, and (as of A4) retail's short UI enter/exit cue sounds. The residual is that the notice uses the retained fullscreen UI rather than chat, and its five-second arming is AP-150. | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
| AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
@@ -368,7 +368,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order |
| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
-| TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` |
+| ~~TS-54~~ | **RETIRED 2026-08-08 (Campaign A slice A4).** The AdminEnvirons stingers now play. `UiSoundController.PlayEnvironCue` maps the change type through `EnvironSoundCueMap` — an EXPLICIT table read case-by-case out of `CPlayerSystem::Handle_Admin__Environs` @ `0x0055DE20` (`0x0055E0C6..0x0055E2C7`), not an offset: codes `0x65..0x72` sit 0x11 below their SoundType but `0x73`/`0x74` have no case at all, so `0x75` lands on `UI_Squeal` (0x84) where arithmetic would give 0x86, and the switch ends at `0x7B`/`UI_Thunder6` with no `0x7C` case. All 21 cases are pinned by conformance tests. The bank itself is no longer a blocker either: the UI sound table's DID is resolved by walking the dats' EnumIDMap chain (`UiSoundTableResolver`, master → slot-7 map → `0x2000004B`), which is how retail finds it — `GetUISoundTable` holds no literal. | retired | — | — | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20`; `SoundManager::PlaySoundFromCenter @ 0x00550950`; `ClientUISystem::GetUISoundTable @ 0x00563FB0`; `docs/research/2026-08-08-audio-retail-music-absence.md` §5 |
| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) |
| TS-57 | No outbound `RejectRetransmit`: a server NAK for an id no longer in the sent-packet cache is dropped silently (counted in `TransportStats.UncachedNakIds`); retail answers `RejectRetransmit @ FlowQueue` so the server abandons the id immediately | `src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs` (`OnRetransmitRequest`) | ACE parses `RejectRetransmit` and no-ops it (NetworkSession.cs — no handler), and the standalone unsequenced form would trip ACE's watermark hole (campaign doc §3 row 3: any cleartext non-ack packet with a live sequence advances the watermark and skips a real packet forever) | Against a server that DOES honor RejectRetransmit, an uncached NAKed id keeps being re-requested until that server's own NAK give-up logic fires — never against ACE, which forgets the id when its next cumulative ack passes it | `RecipientData::ProcessNaks @ 0x00547010`; ACE NetworkSession.cs:299-304 (server-side emit), no client-consume handler |
| TS-58 | No outbound TimeSync/EchoRequest keepalive (retail sends both every 6 half-second intervals, ~3 s). The 2.0 s cumulative AckSequence is the sole idle keepalive; it refreshes ACE's 60 s timeout, which is the only server-side consumer. | `src/AcDream.Core.Net/Transport/TransportClock.cs`; `src/AcDream.Core.Net/Transport/AckNakScheduler.cs` | Standalone unsequenced TimeSync/Echo packets trip ACE's exactly-AckSequence watermark rule (NetworkSession.cs:474-476) and are only ACE-safe piggybacked, which needs retail's CoalesceData (AP-125). The ack keepalive covers the timeout; no transport RTT sample is lost that LinkStatus' app-level ping does not already provide. | No transport-level RTT/latency sample; a future server gating on TimeSync cadence would see silence; ACE's speedhack echo checks never engage. | `ClientFlowQueue::IncrementLocalInterval @ 0x00547F10`; ACE `NetworkSession.cs:474-476`, `Session.cs:101-102` |
diff --git a/docs/plans/2026-08-08-audio-parity-campaign.md b/docs/plans/2026-08-08-audio-parity-campaign.md
index ac5a97db..fbc74476 100644
--- a/docs/plans/2026-08-08-audio-parity-campaign.md
+++ b/docs/plans/2026-08-08-audio-parity-campaign.md
@@ -305,6 +305,6 @@ global kill switch.
| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
| A2 | **COMPLETE** 2026-08-08 | `6d0156cb` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** |
| A3 | **COMPLETE** 2026-08-08 | `3fae0c7d` | 14 wire-conformance tests + 5 controller tests; full Release suite 11,658 passed / 4 skipped / 0 failed. **Owed: connected gate** (melee hit / pickup / lifestone audible against ACE). |
-| A4 | — | — | — |
+| A4 | **COMPLETE** 2026-08-08 | `489ea6ad` | UI bank DID resolved from the dats (`0x2000004B`, content-verified: exactly the 32 `UI_*` slots) + 21-case environ table, 30 new Core tests; full Release suite 11,691 passed / 4 skipped. Retires TS-54; narrows AP-115 to notice-only. **Owed: connected gate** (`@environs` thunder + recall cues audible). **Suite note:** two load-dependent measurement flakes were observed on separate full-suite runs (`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, and one unnamed Core.Net test); both pass in isolation and neither touches audio. |
| A5 | — | — | — |
| A6 | — | — | — |
diff --git a/src/AcDream.App/Audio/UiSoundController.cs b/src/AcDream.App/Audio/UiSoundController.cs
new file mode 100644
index 00000000..9907123b
--- /dev/null
+++ b/src/AcDream.App/Audio/UiSoundController.cs
@@ -0,0 +1,95 @@
+using System;
+using AcDream.Core.Audio;
+using DatReaderWriter.DBObjs;
+using DRWSound = DatReaderWriter.Enums.Sound;
+
+namespace AcDream.App.Audio;
+
+///
+/// Retail's interface sound bus — SoundManager::PlaySoundFromCenter @
+/// 0x00550950 over ClientUISystem::GetUISoundTable @
+/// 0x00563FB0.
+///
+///
+/// "From centre" means pan 0 and GetAttenuation(0.0f, vol, &out, 0) —
+/// distance zero, so the flat branch of the curve, and the effect volume
+/// knob rather than the ambient one. Retail's separate
+/// interface_sound_volume preference is registered and never read, so
+/// there is deliberately no interface volume here either (AP-174).
+///
+///
+///
+/// The bank's DID is resolved from the dats by
+/// rather than hard-coded, and the table itself is fetched lazily on first use
+/// exactly as retail's GetUISoundTable caches it behind a null check.
+///
+///
+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();
+ }
+
+ /// The resolved bank DID, or 0 when the dats carry no chain.
+ public uint TableDid => _tableDid;
+
+ ///
+ /// Play one interface slot. Returns false when the bank is absent, the slot
+ /// is unauthored, or the entry's probability gate says silence.
+ ///
+ 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);
+ }
+
+ ///
+ /// Play the interface stinger for an AdminEnvirons change type — the
+ /// server-driven dungeon atmosphere (chanting, drums, whispers, thunder).
+ /// Codes retail has no case for, including 0x73/0x74 inside the
+ /// run, play nothing.
+ ///
+ public bool PlayEnvironCue(uint changeType) =>
+ EnvironSoundCueMap.TryGetSound(changeType, out SoundId sound) && Play(sound);
+}
diff --git a/src/AcDream.App/Audio/UiSoundTableResolver.cs b/src/AcDream.App/Audio/UiSoundTableResolver.cs
new file mode 100644
index 00000000..40428efd
--- /dev/null
+++ b/src/AcDream.App/Audio/UiSoundTableResolver.cs
@@ -0,0 +1,69 @@
+using AcDream.Content;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Audio;
+
+///
+/// Resolves the DID of retail's interface sound bank by walking the dats, the
+/// way retail does, instead of carrying a literal.
+///
+///
+/// ClientUISystem::GetUISoundTable @ 0x00563FB0 calls
+/// DBObj::GetByEnum @ 0x00415490, and
+/// DBCache::GetDIDFromEnum @ 0x00413940 resolves the answer with
+/// two EnumIDMap hops off m_MasterMapID: keyed first by the enum
+/// INDEX (7 for the UI bank), then by the type key. Same chain and same helper
+/// shape as and the
+/// portal tunnel's setup/animation lookup — the master id comes from the portal
+/// dat header rather than being searched for.
+///
+///
+///
+/// Against the shipped dats this resolves to 0x2000004B, a table holding
+/// exactly the 32 UI_* slots.
+/// UiSoundTableResolutionTests pins the walk, the DID, and the content.
+///
+///
+public static class UiSoundTableResolver
+{
+ /// The enum index the UI sound bank lives at.
+ public const uint UiSoundTableEnumSlot = 7u;
+
+ ///
+ /// The type key the second hop uses. GetByEnum(type, idx, cache) hands
+ /// (type, idx) to the enum chain and the resolved DID to
+ /// DBCache::Get(did, cache); a SoundTable's CACHE type is 0x22
+ /// (CLOCache(cache, CSoundTable::Allocator, 0x22)), which is not this
+ /// key. The lane-5 research note transposed the two arguments.
+ ///
+ public const uint UiSoundTableTypeKey = 0x10000003u;
+
+ ///
+ /// 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).
+ ///
+ 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(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(perSlotDid, out var perSlot) || perSlot is null)
+ return 0u;
+
+ return perSlot.ClientEnumToID.TryGetValue(UiSoundTableTypeKey, out uint did) ? did : 0u;
+ }
+}
diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
index b92a76c8..84249e2c 100644
--- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
+++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
@@ -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)
{
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index e3871e9f..c12c6652 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -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,
diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
index c9d86758..0dc47301 100644
--- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
+++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
@@ -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();
+
+ ///
+ /// Interface-sound sink for the portal cues. Retail plays these from the
+ /// teleport-animation boundary, not from the tunnel renderer:
+ /// PlaySoundFromCenter(Sound_UI_EnterPortal, GetUISoundTable()) at
+ /// 0x004D638E inside gmSmartBoxUI::BeginTeleportAnimation, and
+ /// Sound_UI_ExitPortal at 0x004D7405. Assigned at composition;
+ /// null (and therefore silent) when audio is unavailable.
+ ///
+ public Action? 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()
diff --git a/src/AcDream.App/World/WorldEnvironmentController.cs b/src/AcDream.App/World/WorldEnvironmentController.cs
index d4030acb..c605d733 100644
--- a/src/AcDream.App/World/WorldEnvironmentController.cs
+++ b/src/AcDream.App/World/WorldEnvironmentController.cs
@@ -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);
+ ///
+ /// Interface-sound sink for retail's AdminEnvirons stingers. Retail's
+ /// CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20 plays each
+ /// of codes 0x65..0x7B from centre through the UI sound bank; the
+ /// code-to-slot table is . Null (silent)
+ /// when audio is unavailable.
+ ///
+ public Action? 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();
diff --git a/src/AcDream.Core/Audio/EnvironSoundCueMap.cs b/src/AcDream.Core/Audio/EnvironSoundCueMap.cs
new file mode 100644
index 00000000..696fac28
--- /dev/null
+++ b/src/AcDream.Core/Audio/EnvironSoundCueMap.cs
@@ -0,0 +1,67 @@
+using System.Collections.Frozen;
+using System.Collections.Generic;
+
+namespace AcDream.Core.Audio;
+
+///
+/// Retail's AdminEnvirons code → interface table,
+/// from the switch in CPlayerSystem::Handle_Admin__Environs @
+/// 0x0055DE20 (0x0055E07F..0x0055E2C7). Each case plays
+/// SoundManager::PlaySoundFromCenter(Sound_UI_*, GetUISoundTable()) after
+/// checking that the local player physics object and the UI table both exist.
+///
+///
+/// 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.
+///
+///
+///
+/// An explicit table, deliberately. The environ code and the SoundType
+/// differ by 0x11 for most of the run, but NOT uniformly: codes 0x73 and
+/// 0x74 have no case at all (retail falls through and plays nothing), so
+/// 0x75 lands on UI_Squeal (0x84) rather than the 0x86 arithmetic
+/// would give. Retail's switch is explicit; porting it as an offset would
+/// mis-map every code from 0x75 up.
+///
+///
+public static class EnvironSoundCueMap
+{
+ private static readonly FrozenDictionary Map =
+ new Dictionary
+ {
+ [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();
+
+ ///
+ /// Map an AdminEnvirons change type to its interface sound, or false
+ /// when retail's switch has no case for it (including 0x73/0x74
+ /// inside the run, and every code outside it).
+ ///
+ public static bool TryGetSound(uint changeType, out SoundId sound) =>
+ Map.TryGetValue(changeType, out sound);
+
+ /// Every code retail has a case for. Diagnostic and test use.
+ public static IReadOnlyCollection Codes => Map.Keys;
+}
diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
index 4575890f..8a9cda9b 100644
--- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
@@ -144,8 +144,12 @@ public sealed class ContentEffectsAudioCompositionTests
foreach (ContentEffectsAudioCompositionPoint point in
Enum.GetValues())
{
+ // Everything from the sound cache through the UI sound bank is the
+ // OPTIONAL audio prefix: a fault there is swallowed and audio is
+ // disabled, so those points are not required boundaries. A4 added
+ // UiSoundsCreated to that prefix.
if (point is >= ContentEffectsAudioCompositionPoint.SoundCacheCreated
- and <= ContentEffectsAudioCompositionPoint.AudioSinkCreated)
+ and <= ContentEffectsAudioCompositionPoint.UiSoundsCreated)
{
continue;
}
@@ -474,6 +478,15 @@ public sealed class ContentEffectsAudioCompositionTests
DatSoundCache cache,
DictionaryEntitySoundTable entitySoundTables) =>
new(engine, cache, entitySoundTables);
+
+ // The stub dats carry no EnumIDMap chain, so a real resolve would return
+ // 0 anyway; pass 0 explicitly so the composition test exercises the
+ // "bank unresolved" branch deterministically.
+ public UiSoundController CreateUiSounds(
+ OpenAlAudioEngine engine,
+ DatSoundCache cache,
+ IDatReaderWriter dats) =>
+ new(engine, cache, tableDid: 0u);
}
public class NullProxy : DispatchProxy
diff --git a/tests/AcDream.Core.Tests/Audio/EnvironSoundCueMapTests.cs b/tests/AcDream.Core.Tests/Audio/EnvironSoundCueMapTests.cs
new file mode 100644
index 00000000..b9229b4a
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Audio/EnvironSoundCueMapTests.cs
@@ -0,0 +1,98 @@
+using System.Linq;
+using AcDream.Core.Audio;
+using Xunit;
+
+namespace AcDream.Core.Tests.Audio;
+
+///
+/// Conformance tests for retail's AdminEnvirons code → interface sound
+/// table, read case-by-case out of the switch in
+/// CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20
+/// (0x0055E0C6..0x0055E2C7).
+///
+public sealed class EnvironSoundCueMapTests
+{
+ [Theory]
+ // Every case in retail's switch, in address order.
+ [InlineData(0x65u, SoundId.UI_Roar)]
+ [InlineData(0x66u, SoundId.UI_Bell)]
+ [InlineData(0x67u, SoundId.UI_Chant1)]
+ [InlineData(0x68u, SoundId.UI_Chant2)]
+ [InlineData(0x69u, SoundId.UI_DarkWhispers1)]
+ [InlineData(0x6Au, SoundId.UI_DarkWhispers2)]
+ [InlineData(0x6Bu, SoundId.UI_DarkLaugh)]
+ [InlineData(0x6Cu, SoundId.UI_DarkWind)]
+ [InlineData(0x6Du, SoundId.UI_DarkSpeech)]
+ [InlineData(0x6Eu, SoundId.UI_Drums)]
+ [InlineData(0x6Fu, SoundId.UI_GhostSpeak)]
+ [InlineData(0x70u, SoundId.UI_Breathing)]
+ [InlineData(0x71u, SoundId.UI_Howl)]
+ [InlineData(0x72u, SoundId.UI_LostSouls)]
+ [InlineData(0x75u, SoundId.UI_Squeal)]
+ [InlineData(0x76u, SoundId.UI_Thunder1)]
+ [InlineData(0x77u, SoundId.UI_Thunder2)]
+ [InlineData(0x78u, SoundId.UI_Thunder3)]
+ [InlineData(0x79u, SoundId.UI_Thunder4)]
+ [InlineData(0x7Au, SoundId.UI_Thunder5)]
+ [InlineData(0x7Bu, SoundId.UI_Thunder6)]
+ public void EveryRetailCase_MapsToItsSound(uint code, SoundId expected)
+ {
+ Assert.True(EnvironSoundCueMap.TryGetSound(code, out SoundId sound));
+ Assert.Equal(expected, sound);
+ }
+
+ [Theory]
+ // Retail's switch has NO case for these two, mid-run: they fall through and
+ // play nothing. This is exactly why the port is a table and not arithmetic.
+ [InlineData(0x73u)]
+ [InlineData(0x74u)]
+ public void GapCodes_HaveNoCase(uint code)
+ {
+ Assert.False(EnvironSoundCueMap.TryGetSound(code, out _));
+ }
+
+ [Theory]
+ // Below the run are the fog/lighting overrides (1..6, handled elsewhere);
+ // 0x7C is one past the last case — the switch ends at 0x7B.
+ [InlineData(0u)]
+ [InlineData(1u)]
+ [InlineData(6u)]
+ [InlineData(0x64u)]
+ [InlineData(0x7Cu)]
+ [InlineData(0xFFu)]
+ public void CodesOutsideTheRun_HaveNoCase(uint code)
+ {
+ Assert.False(EnvironSoundCueMap.TryGetSound(code, out _));
+ }
+
+ [Fact]
+ public void TheTableIsNotAConstantOffset()
+ {
+ // 0x65..0x72 sit 0x11 below their SoundType, but 0x75 -> UI_Squeal (0x84)
+ // is 0x0F, because the two-code gap does not shift the sound run. An
+ // offset port would mis-map everything from 0x75 up.
+ Assert.True(EnvironSoundCueMap.TryGetSound(0x72u, out SoundId lostSouls));
+ Assert.True(EnvironSoundCueMap.TryGetSound(0x75u, out SoundId squeal));
+ Assert.Equal(0x11u, (uint)lostSouls - 0x72u);
+ Assert.Equal(0x0Fu, (uint)squeal - 0x75u);
+ }
+
+ [Fact]
+ public void CoversExactlyTheTwentyOneInterfaceStingers()
+ {
+ // The UI_* stinger run is UI_Roar (0x76) through UI_Thunder6 (0x8A) —
+ // 21 sounds — and retail's switch has one case for each.
+ Assert.Equal(21, EnvironSoundCueMap.Codes.Count);
+
+ var sounds = EnvironSoundCueMap.Codes
+ .Select(code =>
+ {
+ EnvironSoundCueMap.TryGetSound(code, out SoundId sound);
+ return (uint)sound;
+ })
+ .OrderBy(value => value)
+ .ToList();
+
+ Assert.Equal(Enumerable.Range(0x76, 21).Select(v => (uint)v), sounds);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Audio/UiSoundTableResolutionTests.cs b/tests/AcDream.Core.Tests/Audio/UiSoundTableResolutionTests.cs
new file mode 100644
index 00000000..3f7be452
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Audio/UiSoundTableResolutionTests.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AcDream.Core.Tests.Conformance;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace AcDream.Core.Tests.Audio;
+
+///
+/// Resolves retail's UI sound table from the installed dats and pins the answer.
+///
+///
+/// ClientUISystem::GetUISoundTable @ 0x00563FB0 does NOT hold a
+/// hard-coded DID — it calls DBObj::GetByEnum @ 0x00415490, and
+/// DBCache::GetDIDFromEnum @ 0x00413940 resolves the answer through
+/// two EnumIDMap hops (DB_TYPE_DID_MAPPER, DID range
+/// 0x25xxxxxx, fetched as cache type 0x26): the master map keyed by
+/// the enum INDEX yields a per-slot map, which keyed by the TYPE yields the
+/// concrete DID. Because that chain lives in the dats rather than the binary,
+/// the only honest way to learn the DID is to walk it — which is what this
+/// does, instead of guessing a plausible 0x20xxxxxx value.
+///
+///
+///
+/// Walking it against the installed dats resolves master 0x25000000 →
+/// slot-7 map 0x25000010 → 0x2000004B, and that table contains
+/// exactly the 32 UI_* slots (UI_EnterPortal 0x6A through
+/// UI_Thunder6 0x8A) — content that confirms the walk independently of
+/// the decode.
+///
+///
+///
+/// Skips cleanly when the dats are absent (CI), matching every other
+/// installed-dat test in this suite.
+///
+///
+public sealed class UiSoundTableResolutionTests
+{
+ ///
+ /// The key the second enum hop uses. `DBObj::GetByEnum` @ 0x00415490 takes
+ /// (type, idx, cache) and passes (type, idx) to the enum chain, then hands
+ /// the resolved DID to `DBCache::Get(did, cache)`. Walking the real dats
+ /// shows this hop is keyed by values in the 0x1000000x space, and
+ /// 0x10000003 is the one whose value lands in the SoundTable DID range —
+ /// so 0x10000003 is the `type` argument and the 0x22 in the decode is the
+ /// CACHE type (`CLOCache(cache, CSoundTable::Allocator, 0x22)`), not a
+ /// lookup key. The lane-5 note transposed the two.
+ ///
+ private const uint UiSoundTableTypeKey = 0x10000003u;
+
+ /// The enum slot GetUISoundTable asks for.
+ private const uint UiSoundTableEnumSlot = 7u;
+
+ ///
+ /// The DID the chain resolves to in the shipped dats. Pinned so a dat
+ /// change or a regression in the walk is caught rather than silently
+ /// producing a different sound bank.
+ ///
+ public const uint ExpectedUiSoundTableDid = 0x2000004Bu;
+
+ private readonly ITestOutputHelper _out;
+
+ public UiSoundTableResolutionTests(ITestOutputHelper output) => _out = output;
+
+ [Fact]
+ public void UiSoundTable_ResolvesThroughTheEnumIdMapChain()
+ {
+ string? datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null)
+ return; // dats absent (CI) — nothing to resolve
+
+ using var dats = new DatCollection(new DatCollectionOptions
+ {
+ DatDirectory = datDir,
+ AccessType = DatAccessType.Read,
+ });
+
+ var candidates = new List<(uint MapId, uint PerTypeMapId, uint SoundTableDid)>();
+ for (uint id = 0x25000000u; id <= 0x2500FFFFu; id++)
+ {
+ EnumIDMap? master;
+ try
+ {
+ master = dats.Get(id);
+ }
+ catch
+ {
+ continue; // not an EnumIDMap / unreadable
+ }
+ if (master is null)
+ continue;
+
+ // Retail's order, from the decode: the MASTER map is keyed by the
+ // enumIndex (arg4, = 7) and yields a per-slot map; that map is then
+ // keyed by the fileType (arg3, = 0x22) and yields the DID. Reading
+ // the two hops the other way round finds nothing.
+ if (!master.ClientEnumToID.TryGetValue(UiSoundTableEnumSlot, out uint perSlot)
+ || perSlot == 0)
+ {
+ continue;
+ }
+
+ EnumIDMap? perSlotMap;
+ try
+ {
+ perSlotMap = dats.Get(perSlot);
+ }
+ catch
+ {
+ continue;
+ }
+ if (perSlotMap is null)
+ continue;
+
+ _out.WriteLine(
+ $"master 0x{id:X8} -> slot-{UiSoundTableEnumSlot} map 0x{perSlot:X8} " +
+ $"({perSlotMap.ClientEnumToID.Count} type keys)");
+ foreach (var slot in perSlotMap.ClientEnumToID.OrderBy(kv => kv.Key))
+ _out.WriteLine($" typeKey 0x{slot.Key:X8} -> 0x{slot.Value:X8}");
+
+ if (perSlotMap.ClientEnumToID.TryGetValue(UiSoundTableTypeKey, out uint did)
+ && did != 0)
+ {
+ candidates.Add((id, perSlot, did));
+ }
+ }
+
+ Assert.NotEmpty(candidates);
+
+ // Every master map that carries the chain must agree on the answer;
+ // retail follows exactly one, so a disagreement would mean the walk is
+ // wrong rather than that retail is ambiguous.
+ uint resolved = candidates[0].SoundTableDid;
+ Assert.All(candidates, c => Assert.Equal(resolved, c.SoundTableDid));
+
+ _out.WriteLine($"RESOLVED UI sound table DID = 0x{resolved:X8}");
+ Assert.Equal(ExpectedUiSoundTableDid, resolved);
+
+ // It must be a real SoundTable in the 0x20xxxxxx range and it must load.
+ Assert.InRange(resolved, 0x20000000u, 0x2000FFFFu);
+ SoundTable? table = dats.Get(resolved);
+ Assert.NotNull(table);
+ Assert.NotEmpty(table!.Sounds);
+
+ _out.WriteLine($"UI sound table 0x{resolved:X8} carries {table.Sounds.Count} slots:");
+ foreach (var kv in table.Sounds.OrderBy(kv => (uint)kv.Key))
+ _out.WriteLine($" {kv.Key} ({(uint)kv.Key:X2}) -> {kv.Value.Entries.Count} entries");
+
+ // The content check: every slot must be a UI_* slot, and the three the
+ // named decode calls out by name must be present.
+ Assert.All(
+ table.Sounds.Keys,
+ slot => Assert.InRange((uint)slot, 0x6Au, 0x8Au));
+ Assert.Contains(DatReaderWriter.Enums.Sound.UI_EnterPortal, table.Sounds.Keys);
+ Assert.Contains(DatReaderWriter.Enums.Sound.UI_ExitPortal, table.Sounds.Keys);
+ Assert.Contains(DatReaderWriter.Enums.Sound.UI_Roar, table.Sounds.Keys);
+ Assert.Contains(DatReaderWriter.Enums.Sound.UI_Thunder6, table.Sounds.Keys);
+ }
+}