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 0x250000100x2000004B, 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); + } +}