fix(audio): listening-gate round 1 — tunnel interior sound + ambience in houses (#355 gate)

Two user findings from the Campaign A listening session.

1. The portal tunnel's in-flight sound was silent while its enter/exit
cues played. The tunnel's authored SoundTweakedHook drained into the
world 3-D path at its synthetic owner's origin (0,0,0) — after A2 that
dies twice: the listener is usually beyond the -50 dB no-allocate radius,
and the world pool is suspended for the whole transit hold. The cues the
user COULD hear were on the interface bus, which has neither problem, and
retail's tunnel is gmSmartBoxUI — UI-owned — so that bus is also the
faithful route. UiPresentationHookSink now wraps the shared router for
the tunnel: sound-bearing hooks go from-centre through the interface bus
(AudioHookSink.OnUiHook); every other hook kind still reaches the
particle/lighting/translucency sinks unchanged.

2. Ambience cut dead inside houses; retail keeps the outdoor soundscape
in sky-lit interiors. This is TS-66, now retired: the ambient listener
source resolves the per-cell CEnvCell.seen_outside bit through the
physics cache (the same #107 field AdjustPosition reads) and converts the
envcell-local origin through the cell's WorldTransform into landblock
coordinates before the 3x3 walk centres on it — an outdoor Position's
origin is already landblock-local, an envcell's is cell-local, and
skipping that conversion would centre the walk wrongly by up to a
landblock. A not-yet-resident cell record resolves to silence for that
rebuild rather than a wrong walk. Sealed dungeons stay silent, which is
retail-correct.

The user also reports interiors carrying their own local sound in retail
(hearth-type emitters). Statics already register their sound tables and
route animation hooks, so the expectation is that the seen_outside fix
plus existing emitters covers it; re-listen decides, and anything still
missing becomes a precise follow-up.

Full Release suite: 11,740 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 12:56:26 +02:00
parent 78b981cca0
commit e5ade796ac
6 changed files with 159 additions and 14 deletions

View file

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

View file

@ -290,28 +290,54 @@ public interface IAmbientListenerSource
public sealed class LocalPlayerAmbientListenerSource : IAmbientListenerSource
{
private readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState _player;
private readonly Func<uint, Vector3, Vector3?> _indoorLandblockLocal;
public LocalPlayerAmbientListenerSource(
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player) =>
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player,
Func<uint, Vector3, Vector3?>? 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;
}

View file

@ -140,6 +140,59 @@ public sealed class AudioHookSink : IAnimationHookSink
priority: entry.Priority);
}
/// <summary>
/// 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 <c>gmSmartBoxUI</c>,
/// and its in-tunnel <c>SoundTweakedHook</c> 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.
/// </summary>
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)

View file

@ -0,0 +1,47 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.App.Audio;
/// <summary>
/// Hook sink for UI-OWNED presentations (the portal tunnel is retail's
/// <c>gmSmartBoxUI</c>): 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.
///
/// <para>
/// Without this split the tunnel's authored in-flight <c>SoundTweakedHook</c>
/// 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.
/// </para>
/// </summary>
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);
}
}

View file

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

View file

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