diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4f430413..a2fdec0a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -380,7 +380,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-63 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** `ApplyResidentCellCleanup`'s three branches: (1) claimed-cell + celless + NOT under lost-cell/deferred ownership - retail's genuine `AddObjectToBeDestroyed` case - has no safe Runtime destruction owner yet, so the executor performs a typed ABANDONMENT (`RejectedAuthority`) instead of destroying; (2) claimed + celless + deferred returns `DeferredUnderLostCellOwnership` - retail's destruction bookkeeping for this exact entity is already owned by the lost-cell/deferred `SetPosition` lifetime (a statement, not a parallel mechanism); (3) claimedCell==0 returns `CelllessNoWeenieMarkUnreachable` and is NOT a divergence - every admitted envelope structurally carries a WeenieDescription (`HasValidShape`), so retail's no-weenie destruction alternative is unreachable through this construction. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyResidentCellCleanup`; the Abandon conversion in `ApplyEnvelope`) | No production caller yet; every branch is typed and test-observable; building a parallel destruction mechanism ahead of the object-table/lost-cell cutover wiring would be the exact workaround class CLAUDE.md forbids - failing closed is the honest interim. | Branch (1): a genuinely claimed-but-celless-undeferred entity aborts the drain and SURVIVES where retail destroys it, until the cutover wiring lands. Branch (3): a future envelope construction without a WeenieDescription would break the premise and needs re-examination. | `SmartBox::HandleCreateObject` 0x00454C80 tail (~93933 destruction mark; ~93942-93943 un-mark/no-weenie) | | TS-64 | **Retail's sound-preference surface is only partly present.** Retail registers eight `[Sound]` keys in `SoundManager::InitPrefs` @ `0x005503F0`; two are unimplemented in acdream. (a) `s_bPlaySoundOnlyWhenActive` (default **1**) is checked against `Device::m_bIsActiveApp` in every entry point and in both `PlaySoundInternal` overloads, so an unfocused retail client is SILENT; acdream keeps playing when the window loses focus. (b) `s_SoundFeatures == 1` forces pan to dead centre; acdream's `RetailSoundMixer.Mix`/`GetPan` take a `panningEnabled` flag with conformance coverage, but no preference is wired behind it, so panning can never be turned off. The three enable bools (`Sound Disabled`, `Ambient Sound Disabled`, `Interface Sound Disabled`) also have no acdream counterpart — note retail's on-disk polarity is inverted relative to its backing variables, so a future reader must not assume the sense. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (no focus gate); `src/AcDream.Core/Audio/RetailSoundMixer.cs` (`panningEnabled`, unwired) | Slice A2 kept its blast radius on the mixing model: window-focus state and a preference surface are host plumbing rather than mixing math, and the mixer parameter exists so wiring them later needs no math change. | Alt-tabbed acdream keeps making noise where retail goes quiet; users cannot disable panning or the individual sound classes. | `SoundManager::InitPrefs @ 0x005503F0`; `SoundManager::PlaySoundInternal @ 0x0054FEC0` and `@ 0x00550170`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §1 | | TS-65 | **Volume-squared quirk applied on the ambient path only.** Retail multiplies its volume knob twice on several paths: `PlaySoundA(DataID, CPhysicsObj*)` passes `effect_sound_volume` as the `vol` argument and `GetAttenuation` then multiplies by `effect_sound_volume` again, and both `PlayAmbientSound*` entry points pre-multiply by `ambient_sound_volume` before that same second multiply — so those sliders are effectively squared. acdream's `RetailSoundMixer.TryGetAttenuation` applies the knob exactly once (which is what `GetAttenuation` itself does) and the animation-hook path does not pre-multiply. Slice A5 squares the ambient path, where two independent lanes byte-confirmed the double application. | `src/AcDream.Core/Audio/RetailSoundMixer.cs` (`TryGetAttenuation` remarks); `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`Play3DWave`) | Which `PlaySoundA` overload the animation-hook path reaches was not pinned by the lane-1 decode, and inventing a squaring on an unconfirmed overload would change every hook sound's loudness curve on a guess. Single-multiply is the conservative, decoded-function-exact choice; the open question is cheap to settle with a cdb breakpoint on the two overloads. | At a non-unity effect slider, hook sounds are louder than retail (slider 0.5 gives −6 dB where retail gives −12). At the default slider of 1.0 the two are identical, so this is inert until the user moves the slider. | `SoundManager::PlaySoundA @ 0x00550AF0`/`@ 0x00550B70`/`@ 0x005507A0`; `SoundManager::GetAttenuation @ 0x00550020`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §3 D12 | -| TS-66 | **`seen_outside` interiors are silent; retail gives them the OUTDOOR ambient set.** Retail's gate is `isOutdoorCell(pos) || curr_cell->seen_outside`, so a sky-lit interior (a cottage, an open shopfront) keeps the outdoor soundscape while a sealed dungeon goes quiet. acdream silences every indoor cell. The mechanism is wired — `AmbientSoundController.ObserveListener` takes a `seenOutside` argument and honours it — but the production listener source passes `false`, because the flag lives on the cell's collision record (`FlatCollisionAssets.SeenOutside`, from `EnvCellFlags.SeenOutside`) rather than on the `Position` that source holds, and resolving it needs a physics-cache lookup the source does not own. | `src/AcDream.App/Audio/AmbientSoundController.cs` (`LocalPlayerAmbientListenerSource`) | Filed rather than guessed at the end of slice A5: fabricating a cell lookup without verifying which cache owns it at that point in the frame is how a wrong-cell bug gets shipped. Dungeon silence — the common case and the one the research called out — is already correct. | Step into a cottage and the outdoor ambience cuts dead where retail keeps it. | `Ambient` gate per `docs/research/2026-08-08-audio-retail-ambient-authoring.md` §6/§8; `CEnvCell::add_ambient_sounds` (folded `ret`) | +| ~~TS-66~~ | **RETIRED 2026-08-08 (Campaign A listening-gate fix; user-reported).** `seen_outside` interiors now keep the OUTDOOR ambient set: the listener source resolves the per-cell `CEnvCell.seen_outside` bit through the physics cache's `CellPhysics` record (the same #107 field `AdjustPosition` reads) and converts the ENVCELL-local origin through the cell's `WorldTransform` into landblock coordinates before the 3×3 walk centres on it — an outdoor Position's origin is already landblock-local, an envcell's is not, and skipping the conversion would centre the walk on a wrong point by up to a landblock. A cell record not yet resident resolves to silence for that rebuild rather than a wrong walk. Sealed interiors (dungeons) remain silent, which is retail-correct. | retired | — | — | `Ambient` gate per `docs/research/2026-08-08-audio-retail-ambient-authoring.md` §6/§8; `CEnvCell::add_ambient_sounds` (folded `ret`); user listening gate 2026-08-08 ("in retail I get both outside ambient and the ambient from indoors") | | TS-67 | **Ambient contributions are computed in-plane.** Retail's `CLandBlock::add_ambient_sounds` @ `0x530310` positions each contributing land cell at its own SW terrain VERTEX, including that vertex's height, and `Ambient::CalcWeight` deliberately includes Z in its distance (where `CalcDir` deliberately excludes it — the two differ on purpose). acdream's gatherer supplies Z = 0 for the offset, so a cell's weight ignores the height difference between the listener and the terrain under that cell. | `src/AcDream.Core/Audio/AmbientSoundGatherer.cs` (`ContributeLandblock`) | Sampling the height needs the landblock's height table threaded into the walk alongside the terrain words; the walk already runs only on a 24 m crossing so the cost is not the obstacle, the extra plumbing at slice end was. The error is bounded by terrain relief inside 120 m and affects the crossfade weight only, never the direction. | On steep ground an ambient reads slightly louder than retail, because the true 3-D distance is longer than the planar one. | `CLandBlock::add_ambient_sounds @ 0x530310`; `Ambient::CalcWeight @ 0x550DD0` | --- diff --git a/src/AcDream.App/Audio/AmbientSoundController.cs b/src/AcDream.App/Audio/AmbientSoundController.cs index ed04c947..694f6eca 100644 --- a/src/AcDream.App/Audio/AmbientSoundController.cs +++ b/src/AcDream.App/Audio/AmbientSoundController.cs @@ -290,28 +290,54 @@ public interface IAmbientListenerSource public sealed class LocalPlayerAmbientListenerSource : IAmbientListenerSource { private readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState _player; + private readonly Func _indoorLandblockLocal; public LocalPlayerAmbientListenerSource( - AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player) => + AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player, + Func? indoorLandblockLocal = null) + { _player = player ?? throw new ArgumentNullException(nameof(player)); + _indoorLandblockLocal = indoorLandblockLocal ?? ((_, _) => null); + } public bool TryGetListener(out AmbientListenerPose pose) { if (_player.Controller is { } controller) { AcDream.Core.Physics.Position cell = controller.CellPosition; + uint objCellId = controller.CellId; + Vector3 landblockLocal = cell.Frame.Origin; + bool seenOutside = false; + + // Retail's gate is `isOutdoorCell(pos) || curr_cell->seen_outside` + // (TS-66, retired with this wiring): a sky-lit interior — a + // cottage, an open shopfront — keeps the OUTDOOR ambient set, + // while a sealed dungeon stays silent. The flag is the same + // per-cell `CEnvCell.seen_outside` bit the physics cache already + // carries for AdjustPosition (#107). + // + // Frames: an OUTDOOR Position's origin is already landblock-local, + // but an ENVCELL's origin is CELL-local — it must go through the + // cell's own transform (the dat authors cell Positions in + // landblock coordinates) before the 3×3 walk can centre on it. + // The resolver returns null when it cannot answer (cell record + // not yet resident), which keeps the interior silent for that + // rebuild rather than centring the walk on a wrong point. + if ((objCellId & 0xFFFFu) >= 0x0100u) + { + if (_indoorLandblockLocal(objCellId, cell.Frame.Origin) + is { } converted) + { + landblockLocal = converted; + seenOutside = true; + } + } + pose = new AmbientListenerPose( - controller.CellId, + objCellId, controller.Position, - cell.Frame.Origin, - // Retail's gate is `isOutdoorCell(pos) || curr_cell->seen_outside`, - // so a sky-lit interior keeps the outdoor set. acdream's - // seen_outside lives on the cell's collision record rather than - // its Position, and resolving it here needs a physics-cache - // lookup this source does not own — deferred as TS-66. Until - // then every interior is silent, which is right for a dungeon - // and wrong for a cottage. - SeenOutside: false); + landblockLocal, + seenOutside); return true; } diff --git a/src/AcDream.App/Audio/AudioHookSink.cs b/src/AcDream.App/Audio/AudioHookSink.cs index 48a8db44..e6518a07 100644 --- a/src/AcDream.App/Audio/AudioHookSink.cs +++ b/src/AcDream.App/Audio/AudioHookSink.cs @@ -140,6 +140,59 @@ public sealed class AudioHookSink : IAnimationHookSink priority: entry.Priority); } + /// + /// Play a sound-bearing animation hook through the INTERFACE bus — from + /// centre, distance 0, unaffected by the world-audio suspension that + /// covers reveal holds. This is the route for hooks authored on + /// UI-owned presentations: retail's portal tunnel is gmSmartBoxUI, + /// and its in-tunnel SoundTweakedHook accompanies the viewer rather + /// than a world object. Routing it through the world 3-D path instead + /// killed it twice over after Campaign A slice A2 — the hook's synthetic + /// owner sits at the world origin, usually beyond the −50 dB no-allocate + /// radius, and the world pool is suspended for the whole transit hold — + /// which is exactly why the enter/exit cues (already on this bus) were + /// audible while the tunnel interior was silent. + /// + public void OnUiHook(uint entityId, AnimationHook hook) + { + if (!_engine.IsAvailable) return; + + switch (hook) + { + case SoundHook s: + PlayUi((uint)s.Id, volume: 1f); + break; + + case SoundTableHook st: + // A UI-owned presentation resolves through the owner's table + // exactly like the world path; the tunnel's synthetic owner + // carries none, so this is inert there but keeps the route + // complete for any UI owner that does. + uint tableId = _entitySoundTables.GetSoundTableId(entityId); + if (tableId == 0) return; + SoundTable? table = _cache.GetSoundTable(tableId); + if (table is null) return; + var entry = SoundCookbook.Select(table, st.SoundType, _rng); + if (entry is null) return; + PlayUi((uint)entry.Id, entry.Volume); + break; + + case SoundTweakedHook stw: + PlayUi( + (uint)stw.SoundId, + stw.Volume > 0 ? stw.Volume : 1f); + break; + } + } + + private void PlayUi(uint waveId, float volume) + { + if (waveId == 0) return; + WaveData? wave = _cache.GetWave(waveId); + if (wave is null) return; + _engine.PlayUiWave(waveId, wave, volume); + } + private void PlayFromSoundTable( uint entityId, Vector3 worldPos, DRWSound sound, float volumeMult = 1f) diff --git a/src/AcDream.App/Audio/UiPresentationHookSink.cs b/src/AcDream.App/Audio/UiPresentationHookSink.cs new file mode 100644 index 00000000..2c9e486d --- /dev/null +++ b/src/AcDream.App/Audio/UiPresentationHookSink.cs @@ -0,0 +1,47 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Types; + +namespace AcDream.App.Audio; + +/// +/// Hook sink for UI-OWNED presentations (the portal tunnel is retail's +/// gmSmartBoxUI): sound-bearing hooks go to the interface bus — from +/// centre, distance 0, immune to the world-audio suspension that covers +/// reveal holds — while every other hook kind forwards to the shared router +/// unchanged, so particles/lighting/translucency behave exactly as before. +/// +/// +/// Without this split the tunnel's authored in-flight SoundTweakedHook +/// went down the world 3-D path at the synthetic owner's origin: usually +/// beyond the −50 dB no-allocate radius AND inside the suspended-transit +/// window, so the tunnel interior was silent while the enter/exit cues (on +/// the interface bus already) played fine. +/// +/// +public sealed class UiPresentationHookSink : IAnimationHookSink +{ + private readonly IAnimationHookSink _router; + private readonly AudioHookSink? _audio; + + public UiPresentationHookSink(IAnimationHookSink router, AudioHookSink? audio) + { + _router = router ?? throw new ArgumentNullException(nameof(router)); + _audio = audio; + } + + public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) + { + if (hook is SoundHook or SoundTableHook or SoundTweakedHook) + { + // With no audio graph (headless/driver-less) the sound hook is + // simply dropped — forwarding it to the router would put it back + // on the world 3-D path this sink exists to bypass. + _audio?.OnUiHook(entityId, hook); + return; + } + + _router.OnHook(entityId, entityWorldPosition, hook); + } +} diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 0aaf5c35..c953d0ec 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1092,7 +1092,12 @@ internal sealed class LivePresentationCompositionPhase host.GpuFrameLifetime, content.Dats, content.AnimationLoader, - d.HookRouter, + // The tunnel is UI-owned (retail gmSmartBoxUI): its + // sound hooks route to the interface bus; every other + // hook still reaches the shared router. + new AcDream.App.Audio.UiPresentationHookSink( + d.HookRouter, + content.Audio?.HookSink), portalDispatcher, foundation.SceneLighting!, foundation.MeshAdapter!, diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index f1d443c6..6475ef55 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -815,7 +815,21 @@ internal sealed class SessionPlayerCompositionPhase ambient.InstallRegion(region, LoadTerrainWords); return new AmbientFramePhase( ambient, - new LocalPlayerAmbientListenerSource(d.PlayerController)); + new LocalPlayerAmbientListenerSource( + d.PlayerController, + indoorLandblockLocal: (cellId, cellLocal) => + { + // seen_outside interiors keep the outdoor ambient set + // (TS-66). The cell's WorldTransform maps its local + // frame into landblock coordinates, which is the frame + // the ambient walk centres on. + var cellStruct = d.PhysicsDataCache.GetCellStruct(cellId); + if (cellStruct is null || !cellStruct.SeenOutside) + return null; + return System.Numerics.Vector3.Transform( + cellLocal, + cellStruct.WorldTransform); + })); ushort[]? LoadTerrainWords(uint landblockId) {