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:
Erik 2026-08-08 22:22:09 +02:00
parent 8bc458fb88
commit 6eaa490bb3
12 changed files with 586 additions and 13 deletions

View file

@ -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 67 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` |

View file

@ -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 | — | — | — |

View 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, &amp;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);
}

View 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;
}
}

View file

@ -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)
{

View file

@ -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,

View file

@ -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()

View file

@ -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();

View 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;
}

View file

@ -144,8 +144,12 @@ public sealed class ContentEffectsAudioCompositionTests
foreach (ContentEffectsAudioCompositionPoint point in
Enum.GetValues<ContentEffectsAudioCompositionPoint>())
{
// 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

View file

@ -0,0 +1,98 @@
using System.Linq;
using AcDream.Core.Audio;
using Xunit;
namespace AcDream.Core.Tests.Audio;
/// <summary>
/// Conformance tests for retail's <c>AdminEnvirons</c> code → interface sound
/// table, read case-by-case out of the switch in
/// <c>CPlayerSystem::Handle_Admin__Environs</c> @ <c>0x0055DE20</c>
/// (<c>0x0055E0C6</c>..<c>0x0055E2C7</c>).
/// </summary>
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);
}
}

View file

@ -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;
/// <summary>
/// Resolves retail's UI sound table from the installed dats and pins the answer.
///
/// <para>
/// <c>ClientUISystem::GetUISoundTable</c> @ <c>0x00563FB0</c> does NOT hold a
/// hard-coded DID — it calls <c>DBObj::GetByEnum</c> @ <c>0x00415490</c>, and
/// <c>DBCache::GetDIDFromEnum</c> @ <c>0x00413940</c> resolves the answer through
/// two <c>EnumIDMap</c> hops (<c>DB_TYPE_DID_MAPPER</c>, DID range
/// <c>0x25xxxxxx</c>, fetched as cache type <c>0x26</c>): 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 <c>0x20xxxxxx</c> value.
/// </para>
///
/// <para>
/// Walking it against the installed dats resolves master <c>0x25000000</c> →
/// slot-7 map <c>0x25000010</c> → <c>0x2000004B</c>, and that table contains
/// exactly the 32 <c>UI_*</c> slots (<c>UI_EnterPortal</c> 0x6A through
/// <c>UI_Thunder6</c> 0x8A) — content that confirms the walk independently of
/// the decode.
/// </para>
///
/// <para>
/// Skips cleanly when the dats are absent (CI), matching every other
/// installed-dat test in this suite.
/// </para>
/// </summary>
public sealed class UiSoundTableResolutionTests
{
/// <summary>
/// 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.
/// </summary>
private const uint UiSoundTableTypeKey = 0x10000003u;
/// <summary>The enum slot <c>GetUISoundTable</c> asks for.</summary>
private const uint UiSoundTableEnumSlot = 7u;
/// <summary>
/// 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.
/// </summary>
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<EnumIDMap>(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<EnumIDMap>(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<SoundTable>(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);
}
}