diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 9688b591..11b065b3 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -332,7 +332,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers committed the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm named the destination — measured 2026-08-04 at round 3. **AMENDED 2026-08-05 at the C5b architecture review: the route-2 half of that measurement is now STALE and the two callers no longer agree.** C5b made the merge withhold the wire cell (AD-60), and route 2 submits from `TryExecuteAcceptedLocalPosition` BEFORE the `OnPosition` prologue rebucket (W2) it returns ahead of — so on a route-2 FIRST submit `PlacementTouchesPrefix`'s `CurrentCellId` arm now names the SOURCE landblock the local player is leaving, not the destination. The graphical REMOTE half is unchanged: its prologue rebucket still runs ahead of the far-snap submit. The consequence is confined to which prefix the quiescence pre-flight matches, which this row's own part (2) already established cannot be the correctness mechanism (`SubmitPreparedPlacementCore`'s restorable parks are); it widens rather than narrows the set of prefixes a local force can be parked against. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and the surviving non-Position rebucket writer (the projection materializer — C4 route 4b-3 deleted the second shipped writer, `RemoteTeleportController`'s rollback, and C4 route 7 D4 demoted the third, the equipped-child renderer, to a presentation-only move that no longer touches `record.FullCellId`) can rebucket it to a third landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). **C4 route 4b-3 adds a second producer of the visible-without-collision shape in item (1)'s storing list**: the teleport arm inherits the identical store-and-stay-visible residual for the same reasons — a remote that teleports into a non-published landblock and stands still is visible but not collidable until a later packet commits. No new machinery; the retirement path is the same #309. A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) |
| AP-139 | **Filed 2026-08-04 (Bug B).** The remote tick clears its InterpolationManager queue on the LANDING edge — retail’s own `set_on_walkable(1)` transition, the same edge HitGround fires from. Retail has no such clear on a ground or contact edge: its only queue teardown outside a completed walk is `PositionManager::StopInterpolating` from `CPhysicsObj::teleport_hook` @0x00514EFD and the `InterpolationManager::UseTime` @0x00555f20 stall/autonomy blips. The clear is carried over unchanged in intent from the deleted hand-rolled landing block (#184, 2026-07-07), which hung it on a hand-rolled `Airborne && IsOnGround && Velocity.Z <= 0` test that also fired on a steep (non-walkable) contact; Bug B re-derived the edge without changing the behaviour it was written for | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (the SetPositionInternal commit block); the packet-side twin lives in `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the player-remote landing snap) | A contact-free arc never enqueues — route 4a's airborne no-op writes nothing at all — so anything still queued when the body lands is a pre-arc waypoint, and the first catch-up after touchdown would otherwise walk the body backward toward it | A remote that regains contact while a legitimately fresh waypoint is queued loses one correction and re-acquires it on the next accepted Position (~5-10 Hz). A body that repeatedly loses and regains contact (a bounce chain down a rough face) clears the queue once per bounce. Retire when the arc itself feeds the queue, at which point the pre-arc waypoints are no longer stale | `CPhysicsObj::teleport_hook @ 0x00514ED0` (`StopInterpolating` @0x00514EFD); `InterpolationManager::UseTime @ 0x00555f20`; `CPhysicsObj::SetPositionInternal @ 0x00515330` |
-## 4. Temporary stopgap (TS) — 38 active rows (TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
+## 4. Temporary stopgap (TS) — 39 active rows (TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -351,7 +351,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` |
| TS-28 | **NARROWED 2026-08-03** — F751 teleports resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login no longer acknowledges raw PlayerCreate receipt: graphical and prepared headless hosts send exactly once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. Residual: initial login still does not enter the full portal-space presentation. | `src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs`; `src/AcDream.App/Net/GraphicalSessionEventRoute.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`; `src/AcDream.Core.Net/WorldSession.cs` | Initial placement is now the shared readiness contract that releases ACE's intentional Hidden/pink-bubble state without racing presentation. The content-less direct host uses its only truthful admission edge. | The persistent login materialization haze is fixed and server updates no longer unlock before canonical placement. The remaining difference is presentation-only: initial login skips retail's wormhole sequence. | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` |
-| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
+| ~~TS-29~~ | **RETIRED 2026-08-08 (Campaign A slices A5/A6).** Both halves are resolved, in opposite directions. **Ambient:** ported. `AmbientSoundGatherer` walks retail's 3x3 landblock ring x 64 land cells off the region file's `SoundInfo`/`SceneInfo`/`TerrainInfo` chain, `AmbientSoundScheduler` runs the absolute-deadline queue, and continuous beds are re-fired one-shots on `min_rate` rather than looping voices — retail never sets the DirectSound loop flag, so the `StartAmbient`/`StopAmbient` handle API this row described modelled a mechanism that does not exist and is deleted. **Music:** there is nothing to port. Retail EoR links a complete winmm MIDI player and never feeds it — `midiPlay` has zero callers, the string "music" appears zero times in the 65 MB decomp, `SoundType` has no music member, `InitPrefs` registers no music key, and the retail install ships no music files. What players remember as dungeon music is the AdminEnvirons `UI_*` stinger family (TS-54, landed at A4). | retired | — | — | `Ambient::UpdatePlayQueue @ 0x551A50`; `Ambient::Play @ 0x5517A0`; `Ambient::UseTime @ 0x551880`; `CLandBlock::add_ambient_sounds @ 0x530310`; `docs/research/2026-08-08-audio-retail-ambient-runtime.md`; `docs/research/2026-08-08-audio-retail-music-absence.md` |
| TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
| TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
@@ -380,6 +380,8 @@ 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-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/docs/plans/2026-08-08-audio-parity-campaign.md b/docs/plans/2026-08-08-audio-parity-campaign.md
index fbc74476..c50c8ca1 100644
--- a/docs/plans/2026-08-08-audio-parity-campaign.md
+++ b/docs/plans/2026-08-08-audio-parity-campaign.md
@@ -306,5 +306,5 @@ global kill switch.
| 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 | **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 | — | — | — |
+| A5 | **COMPLETE** 2026-08-08 | `e81bc474` | 46 ambient conformance tests; full Release suite 11,739 passed / 4 skipped. Opus review run and applied — it caught a FATAL frame bug (cell offsets built in absolute world coordinates while the listener is in the streamed frame: every contribution culled at ~32 km, feature silent with no error), a per-entry vs per-cell denominator error that would have pushed multi-entry beds under the audibility floor, an infinite loop on a zero play-rate, and newly-audible ambients not firing until a full period later. Also moved beds onto retail's single 16-voice priority pool and made the in-block direction test XY-only. Retires TS-29; files TS-66 (`seen_outside` interiors), TS-67 (in-plane weight). **Owed: user listening gate.** |
| A6 | — | — | — |
diff --git a/src/AcDream.App/Audio/AmbientSoundController.cs b/src/AcDream.App/Audio/AmbientSoundController.cs
new file mode 100644
index 00000000..ed04c947
--- /dev/null
+++ b/src/AcDream.App/Audio/AmbientSoundController.cs
@@ -0,0 +1,321 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Audio;
+using DatReaderWriter.DBObjs;
+using DRWSound = DatReaderWriter.Enums.Sound;
+
+namespace AcDream.App.Audio;
+
+///
+/// Drives retail's region ambient soundscape: rebuild on an objcell change,
+/// drain the deadline queue every frame, and play each firing as a one-shot.
+///
+///
+/// Retail's Ambient system is a weighted-accumulation + timer-queue
+/// engine, NOT looping voices. On every objcell change (24 m granularity)
+/// CellManager::ChangePosition @ 0x4559B0 rebuilds per-sound
+/// weights over the 3×3 landblock ring × 64 land cells each; playback is a
+/// min-heap of absolute deadlines drained from the frame tick, where each pop
+/// fires a one-shot and re-arms. A continuous bed is simply a one-shot re-fired
+/// every min_rate seconds with a freshly rolled table pick and crossfade
+/// volume.
+///
+///
+///
+/// Continuous beds play from centre (no position, no pan, no distance
+/// attenuation); intermittent ones play positionally at a random accumulated
+/// bearing. Both go through the ambient volume knob, which retail applies
+/// TWICE — once in PlayAmbientSound* and again inside
+/// GetAttenuation — so the slider is effectively squared. That quirk is
+/// reproduced here because two independent research lanes byte-confirmed the
+/// double application (see TS-65).
+///
+///
+public sealed class AmbientSoundController
+{
+ private readonly OpenAlAudioEngine _engine;
+ private readonly DatSoundCache _cache;
+ private readonly AmbientSoundScheduler _scheduler;
+ private readonly AmbientSoundGatherer _gatherer;
+ private readonly ISoundRandom _rng;
+ private readonly List _firings = [];
+
+ private Region? _region;
+ private Func _landblocks = static _ => null;
+ private uint _currentObjCell;
+ private Vector3 _listenerPosition;
+ private double _clock;
+ private bool _suspended;
+
+ public AmbientSoundController(
+ OpenAlAudioEngine engine,
+ DatSoundCache cache,
+ ISoundRandom? rng = null)
+ {
+ _engine = engine ?? throw new ArgumentNullException(nameof(engine));
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ _rng = rng ?? new SoundRandom();
+ _scheduler = new AmbientSoundScheduler(_rng);
+ _gatherer = new AmbientSoundGatherer(_scheduler);
+ }
+
+ /// Live instance count. Diagnostic use.
+ public int InstanceCount => _scheduler.Instances.Count;
+
+ /// Instances holding a deadline. Diagnostic use.
+ public int QueuedCount => _scheduler.QueuedCount;
+
+ ///
+ /// Install the region whose authored ambient data drives the soundscape, and
+ /// the terrain-word source for the 3×3 ring.
+ ///
+ public void InstallRegion(Region region, Func landblocks)
+ {
+ _region = region ?? throw new ArgumentNullException(nameof(region));
+ _landblocks = landblocks ?? throw new ArgumentNullException(nameof(landblocks));
+ _currentObjCell = 0;
+ _scheduler.Clear();
+ }
+
+ ///
+ /// Report the listener's cell and position. A change of objcell triggers the
+ /// rebuild — retail's trigger is CellManager::ChangePosition, not a
+ /// landblock streaming event, so the cadence is every 24 m rather than every
+ /// 192 m.
+ ///
+ public void ObserveListener(
+ uint objCellId,
+ Vector3 position,
+ Vector3 landblockLocalPosition,
+ bool seenOutside = false)
+ {
+ _listenerPosition = position;
+ if (_region is null || objCellId == _currentObjCell)
+ return;
+
+ // Latch the new cell FIRST and unconditionally, the way
+ // CellManager::ChangePosition assigns load_pos at its tail. Clearing it
+ // inside the indoor branch would leave the change edge permanently
+ // armed while standing still indoors.
+ _currentObjCell = objCellId;
+
+ // Indoors is silent by design: retail's CEnvCell::add_ambient_sounds is
+ // an empty folded ret and the EnvCell format carries no sound data. The
+ // gate is `isOutdoorCell(pos) || curr_cell->seen_outside`, so an
+ // interior that can see the sky still gets the OUTDOOR set — a cottage
+ // does not cut the ambience dead.
+ if (IsIndoorCell(objCellId) && !seenOutside)
+ {
+ _scheduler.Clear();
+ return;
+ }
+
+ _gatherer.Rebuild(
+ _region,
+ (objCellId >> 16 << 16) | 0xFFFFu,
+ landblockLocalPosition,
+ _landblocks,
+ _clock);
+ }
+
+ ///
+ /// Advance the ambient clock and fire everything now due. Called once per
+ /// frame from the same tick that drives the rest of the effect system.
+ ///
+ public void Tick(double deltaSeconds)
+ {
+ if (deltaSeconds > 0)
+ _clock += deltaSeconds;
+
+ if (_suspended || !_engine.IsAvailable || _region is null)
+ return;
+
+ _firings.Clear();
+ _scheduler.Tick(_clock, _firings, _listenerPosition);
+ Emit();
+ }
+
+ ///
+ /// Stop contributing while the world is being replaced. The scheduler's
+ /// deadlines are dropped rather than paused: the next rebuild re-arms
+ /// everything audible, which is what a cell change does anyway. Dropping
+ /// them also avoids a salvo of every overdue bed firing at once on resume.
+ ///
+ public void Suspend()
+ {
+ _suspended = true;
+ StopAll();
+ }
+
+ public void Resume() => _suspended = false;
+
+ /// Drop every instance and deadline (world teardown / reset).
+ public void StopAll()
+ {
+ _scheduler.Clear();
+ _currentObjCell = 0;
+ }
+
+ private void Emit()
+ {
+ foreach (AmbientSoundFiring firing in _firings)
+ Play(firing);
+ _firings.Clear();
+ }
+
+ private void Play(in AmbientSoundFiring firing)
+ {
+ SoundTable? table = _cache.GetSoundTable(firing.Instance.SoundTableDid);
+ if (table is null)
+ return;
+
+ // The variant pick and the entry's probability gate apply to ambients
+ // exactly as they do everywhere else — PlayAmbientSound* rolls the same
+ // PlayProbability inline.
+ var entry = SoundCookbook.Select(
+ table,
+ (DRWSound)(uint)firing.Instance.Descriptor.Sound,
+ _rng);
+ if (entry is null)
+ return;
+
+ uint waveId = (uint)entry.Id;
+ if (waveId == 0)
+ return;
+
+ WaveData? wave = _cache.GetWave(waveId);
+ if (wave is null)
+ return;
+
+ // Retail pre-multiplies by the ambient knob here and GetAttenuation
+ // multiplies by it again — the squared-slider quirk (TS-65).
+ float volume = firing.Volume * _engine.AmbientVolume;
+
+ if (firing.Position is { } position)
+ {
+ _engine.PlayAmbient3DWave(waveId, wave, position, volume, entry.Priority);
+ return;
+ }
+
+ // Continuous bed: from centre, no position and no attenuation — but
+ // still through the SAME 16-voice priority pool as everything else.
+ // Retail has one pool (SoundManager::playing_sounds_[0x10]); the UI pool
+ // is ours, and parking beds there would let a portal cue chop one
+ // mid-wave and would discard the entry's priority.
+ _engine.PlayAmbientFromCenter(waveId, wave, volume, entry.Priority);
+ }
+
+ ///
+ /// Outdoor land cells are 0x…FFFF style ids below 0x0100 in the cell
+ /// word; anything above that is an EnvCell (indoor), which retail gives no
+ /// ambients.
+ ///
+ private static bool IsIndoorCell(uint objCellId) => (objCellId & 0xFFFFu) >= 0x0100u;
+}
+
+///
+/// The per-frame ambient step, as a typed collaborator. Extracted update owners
+/// may not retain delegates, so the effect phase takes this rather than an
+/// Action<float>.
+///
+public interface IAmbientFramePhase
+{
+ void TickAmbient(float deltaSeconds);
+}
+
+///
+/// Binds the ambient controller to the live listener: reports the local
+/// player's cell and position (retail rebuilds on an objcell change, and reads
+/// the viewer's position), then drains the deadline queue.
+///
+public sealed class AmbientFramePhase : IAmbientFramePhase
+{
+ private readonly AmbientSoundController _ambient;
+ private readonly IAmbientListenerSource _listener;
+
+ public AmbientFramePhase(AmbientSoundController ambient, IAmbientListenerSource listener)
+ {
+ _ambient = ambient ?? throw new ArgumentNullException(nameof(ambient));
+ _listener = listener ?? throw new ArgumentNullException(nameof(listener));
+ }
+
+ public void TickAmbient(float deltaSeconds)
+ {
+ if (_listener.TryGetListener(out AmbientListenerPose pose))
+ {
+ _ambient.ObserveListener(
+ pose.ObjCellId,
+ pose.Position,
+ pose.LandblockLocalPosition,
+ pose.SeenOutside);
+ }
+ _ambient.Tick(deltaSeconds);
+ }
+}
+
+///
+/// The listener's pose, in both frames the ambient system needs.
+///
+/// The land/env cell — the rebuild trigger.
+///
+/// The streamed-frame position, used for PLAYBACK (it is the frame the audio
+/// engine's listener lives in).
+///
+///
+/// The landblock-local position, x/y in [0, 192), used for the CELL WALK.
+/// Mixing the two frames culls every contribution.
+///
+///
+/// True when an interior cell can see the sky; retail gives those the outdoor
+/// ambient set rather than silence.
+///
+public readonly record struct AmbientListenerPose(
+ uint ObjCellId,
+ Vector3 Position,
+ Vector3 LandblockLocalPosition,
+ bool SeenOutside);
+
+/// Supplies the listener's current pose.
+public interface IAmbientListenerSource
+{
+ bool TryGetListener(out AmbientListenerPose pose);
+}
+
+///
+/// over the canonical local-player movement
+/// owner. Retail's ambient listener is the same viewer the mixer uses; the cell
+/// is what decides when to rebuild.
+///
+public sealed class LocalPlayerAmbientListenerSource : IAmbientListenerSource
+{
+ private readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState _player;
+
+ public LocalPlayerAmbientListenerSource(
+ AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player) =>
+ _player = player ?? throw new ArgumentNullException(nameof(player));
+
+ public bool TryGetListener(out AmbientListenerPose pose)
+ {
+ if (_player.Controller is { } controller)
+ {
+ AcDream.Core.Physics.Position cell = controller.CellPosition;
+ pose = new AmbientListenerPose(
+ controller.CellId,
+ 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);
+ return true;
+ }
+
+ pose = default;
+ return false;
+ }
+}
diff --git a/src/AcDream.App/Audio/OpenAlAudioEngine.cs b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
index 8196e9a0..2cd75c7f 100644
--- a/src/AcDream.App/Audio/OpenAlAudioEngine.cs
+++ b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
@@ -135,10 +135,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
private readonly Dictionary _bufferByWaveId = new();
private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
- // ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
- private readonly Dictionary _ambientSources = new();
- private int _nextAmbientHandle = 1;
-
// ── Public volume knobs ──────────────────────────────────────────────────
public float MasterVolume { get; set; } = 1f;
public float SfxVolume { get; set; } = 1f;
@@ -456,25 +452,109 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
public void Play3D(SoundId id, float x, float y, float z) { /* handled via AudioHookSink */ }
- public int StartAmbient(SoundId id, float x, float y, float z)
+ ///
+ /// Play a positional ambient one-shot — retail's
+ /// SoundManager::PlayAmbientSound @ 0x00550820. Identical to a
+ /// world sound except that GetAttenuation is told this is ambient, so
+ /// the AMBIENT volume knob is the master multiply rather than the effect one.
+ ///
+ public bool PlayAmbient3DWave(
+ uint waveId,
+ WaveData wave,
+ Vector3 position,
+ float volume,
+ float priority)
{
- // Looping ambient — needs a decoded wave + WaveId. The hook sink
- // doesn't route ambient; a separate landblock-attached ambient
- // system (outside R5) will drive this. For now: reserve a handle.
- int handle = _nextAmbientHandle++;
- return handle;
+ if (_worldAudioSuspended || !_available || _al is null) return false;
+
+ RetailVoiceMix mix = RetailSoundMixer.Mix(
+ _listenerPosition,
+ _listenerHeadingDegrees,
+ position,
+ volume,
+ AmbientMaster);
+ if (!mix.Play) return false;
+
+ uint buffer = EnsureBuffer(waveId, wave);
+ if (buffer == 0) return false;
+
+ int slotIdx = AcquireWorldSlot(priority);
+ if (slotIdx < 0) return false;
+
+ Slot3D slot = _pool3D[slotIdx];
+ _al.SourceStop(slot.SourceId);
+ _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
+ _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
+ _al.SetSourceProperty(
+ slot.SourceId,
+ SourceFloat.Gain,
+ RetailSoundMixer.LinearGain(mix.Decibels));
+ ApplyPan(slot.SourceId, mix.Pan);
+ _al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
+ _al.SourcePlay(slot.SourceId);
+
+ slot.InUse = true;
+ slot.OwnerId = 0;
+ slot.Priority = priority;
+ _pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
+ return true;
}
- public void StopAmbient(int handle)
+ ///
+ /// Play a non-positional ambient bed — retail's
+ /// SoundManager::PlayAmbientSoundFromCenter @ 0x005508B0. A
+ /// continuous ambient has no position at all (its GetSoundPos is a
+ /// folded xor eax,eax), so there is no pan and no distance
+ /// attenuation: it is a stereo bed centred on the listener. Distance 0 takes
+ /// the flat branch of the curve, scaled by the ambient knob.
+ ///
+ public bool PlayAmbientFromCenter(
+ uint waveId,
+ WaveData wave,
+ float volume,
+ float priority)
{
- if (!_available || _al is null) return;
- if (_ambientSources.TryGetValue(handle, out var src))
- {
- _al.SourceStop(src);
- _ambientSources.Remove(handle);
- }
+ if (_worldAudioSuspended || !_available || _al is null) return false;
+
+ if (!RetailSoundMixer.TryGetAttenuation(0f, volume, AmbientMaster, out int decibels))
+ return false;
+
+ uint buffer = EnsureBuffer(waveId, wave);
+ if (buffer == 0) return false;
+
+ // The SAME 16-voice priority pool as everything else. Retail has exactly
+ // one (`SoundManager::playing_sounds_[0x10]`), reached by
+ // PlayAmbientSoundFromCenter @ 0x5508B0 -> PlaySoundInternal @ 0x54FEC0;
+ // the UI pool is acdream's own. Parking beds there would let a portal
+ // cue chop one mid-wave and would discard the authored priority.
+ int slotIdx = AcquireWorldSlot(priority);
+ if (slotIdx < 0) return false;
+
+ Slot3D slot = _pool3D[slotIdx];
+ _al.SourceStop(slot.SourceId);
+ _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
+ _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
+ _al.SetSourceProperty(
+ slot.SourceId,
+ SourceFloat.Gain,
+ RetailSoundMixer.LinearGain(decibels));
+ ApplyPan(slot.SourceId, 0); // dead centre: a bed has no bearing
+ _al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
+ _al.SourcePlay(slot.SourceId);
+
+ slot.InUse = true;
+ slot.OwnerId = 0;
+ slot.Priority = priority;
+ _pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
+ return true;
}
+ ///
+ /// The ambient master multiply, folded with acdream's extra master slider —
+ /// the ambient counterpart of . See AP-174.
+ ///
+ private float AmbientMaster => MasterVolume * AmbientVolume;
+
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
public void StopMusic() { /* ditto */ }
@@ -567,10 +647,9 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
{
if (IsSourceBoundTo(_poolUi[i], bufferId)) return true;
}
- foreach (uint sourceId in _ambientSources.Values)
- {
- if (IsSourceBoundTo(sourceId, bufferId)) return true;
- }
+ // Ambients share the world and UI pools scanned above; there is no
+ // separate ambient source list any more (slice A5 deleted the
+ // looping-handle model retail never had).
return false;
}
diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
index 84249e2c..c643ed07 100644
--- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
+++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs
@@ -25,7 +25,8 @@ internal sealed record ContentAudioGraph(
OpenAlAudioEngine Engine,
DictionaryEntitySoundTable EntitySoundTables,
AudioHookSink? HookSink,
- UiSoundController? UiSounds);
+ UiSoundController? UiSounds,
+ AmbientSoundController? Ambient);
internal sealed record ContentEffectsAudioResult(
IDatReaderWriter Dats,
@@ -138,6 +139,9 @@ internal interface IContentEffectsAudioCompositionFactory
OpenAlAudioEngine engine,
DatSoundCache cache,
IDatReaderWriter dats);
+ AmbientSoundController CreateAmbient(
+ OpenAlAudioEngine engine,
+ DatSoundCache cache);
}
internal sealed class RetailContentEffectsAudioCompositionFactory
@@ -253,6 +257,11 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
DatSoundCache cache,
IDatReaderWriter dats) =>
new(engine, cache, UiSoundTableResolver.Resolve(dats));
+
+ public AmbientSoundController CreateAmbient(
+ OpenAlAudioEngine engine,
+ DatSoundCache cache) =>
+ new(engine, cache);
}
internal enum ContentEffectsAudioCompositionPoint
@@ -280,6 +289,7 @@ internal enum ContentEffectsAudioCompositionPoint
EntitySoundTablesCreated,
AudioSinkCreated,
UiSoundsCreated,
+ AmbientCreated,
AudioPublished,
AudioHookRegistered,
}
@@ -485,6 +495,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
Fault(ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated);
AudioHookSink? sink = null;
UiSoundController? uiSounds = null;
+ AmbientSoundController? ambient = null;
if (engine.IsAvailable)
{
sink = _factory.CreateAudioSink(engine, cache, soundTables);
@@ -496,9 +507,12 @@ internal sealed class ContentEffectsAudioCompositionPhase :
+ "- interface cues silent"
: $"audio: UI sound bank = 0x{uiSounds.TableDid:X8}");
Fault(ContentEffectsAudioCompositionPoint.UiSoundsCreated);
+ ambient = _factory.CreateAmbient(engine, cache);
+ Fault(ContentEffectsAudioCompositionPoint.AmbientCreated);
}
- graph = new ContentAudioGraph(cache, engine, soundTables, sink, uiSounds);
+ graph = new ContentAudioGraph(
+ cache, engine, soundTables, sink, uiSounds, ambient);
}
catch (Exception failure)
{
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index c12c6652..f1d443c6 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -10,6 +10,7 @@ using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Runtime;
using AcDream.App.Settings;
+using AcDream.App.Audio;
using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
@@ -797,6 +798,44 @@ internal sealed class SessionPlayerCompositionPhase
new LiveProjectionRescueRebucketter(
live.WorldState,
live.LiveEntities));
+ // Campaign A slice A5: retail rebuilds the ambient soundscape on every
+ // objcell change (24 m) and drains its deadline queue from the frame
+ // tick. The terrain words come from the loaded landblocks in the 3x3
+ // ring; the listener is the local player's cell and position.
+ IAmbientFramePhase? BuildAmbientFrame()
+ {
+ if (content.Audio?.Ambient is not { } ambient)
+ return null;
+
+ DatReaderWriter.DBObjs.Region? region =
+ content.Dats.Get(0x13000000u);
+ if (region is null)
+ return null;
+
+ ambient.InstallRegion(region, LoadTerrainWords);
+ return new AmbientFramePhase(
+ ambient,
+ new LocalPlayerAmbientListenerSource(d.PlayerController));
+
+ ushort[]? LoadTerrainWords(uint landblockId)
+ {
+ if (!live.WorldState.TryGetLandblock(landblockId, out LoadedLandblock? loaded)
+ || loaded?.Heightmap is not { } heightmap)
+ {
+ return null;
+ }
+
+ // The dat exposes terrain words as TerrainInfo; the gatherer wants
+ // the raw words. Converted here rather than in Core so the ambient
+ // model stays free of the dat type. This runs only on an objcell
+ // change (every 24 m), nine landblocks at a time.
+ var words = new ushort[heightmap.Terrain.Length];
+ for (int i = 0; i < words.Length; i++)
+ words[i] = (ushort)heightmap.Terrain[i];
+ return words;
+ }
+ }
+
var liveEffectFrame = new LiveEffectFrameController(
d.TranslucencyFades,
content.AnimationHookFrames,
@@ -807,7 +846,8 @@ internal sealed class SessionPlayerCompositionPhase
content.ParticleSystem,
content.ScriptRunner,
d.UpdateClock,
- new SettingsParticleRangeSource(d.Settings));
+ new SettingsParticleRangeSource(d.Settings),
+ BuildAmbientFrame());
var liveSpatialReconciler = new LiveSpatialPresentationReconciler(
live.EntityEffects,
live.EquippedChildren,
diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs
index 99c698f5..b838d63a 100644
--- a/src/AcDream.App/Update/LiveObjectFrameController.cs
+++ b/src/AcDream.App/Update/LiveObjectFrameController.cs
@@ -1,3 +1,4 @@
+using AcDream.App.Audio;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Interaction;
@@ -88,7 +89,8 @@ internal sealed class LiveEffectFrameController
ParticleSystem particles,
PhysicsScriptRunner scripts,
IPhysicsScriptTimeSource scriptTime,
- IParticleRangeSource particleRange)
+ IParticleRangeSource particleRange,
+ IAmbientFramePhase? ambientFrame = null)
{
_translucencyFades = translucencyFades
?? throw new ArgumentNullException(nameof(translucencyFades));
@@ -102,8 +104,19 @@ internal sealed class LiveEffectFrameController
_scripts = scripts ?? throw new ArgumentNullException(nameof(scripts));
_scriptTime = scriptTime ?? throw new ArgumentNullException(nameof(scriptTime));
_particleRange = particleRange ?? throw new ArgumentNullException(nameof(particleRange));
+ _ambientFrame = ambientFrame;
}
+ ///
+ /// Campaign A slice A5: retail drains the ambient deadline queue from the
+ /// same per-frame tick that advances the rest of the effect system
+ /// (SmartBox::UseTime -> Ambient::UseTime). A typed
+ /// collaborator rather than a callback — extracted update owners must not
+ /// retain delegates (`ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks`).
+ /// Null when audio is unavailable.
+ ///
+ private readonly IAmbientFramePhase? _ambientFrame;
+
public void Tick(float deltaSeconds)
{
// Retail's ordinary-object UpdatePositionInternal @ 0x00512C30 calls
@@ -123,6 +136,7 @@ internal sealed class LiveEffectFrameController
// once-per-host tail and its static-order difference remain TS-51.
_particles.Tick(deltaSeconds);
_scripts.Tick(_scriptTime.CurrentScriptTime);
+ _ambientFrame?.TickAmbient(deltaSeconds);
}
}
diff --git a/src/AcDream.Core/Audio/AmbientSoundGatherer.cs b/src/AcDream.Core/Audio/AmbientSoundGatherer.cs
new file mode 100644
index 00000000..37ada173
--- /dev/null
+++ b/src/AcDream.Core/Audio/AmbientSoundGatherer.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.Core.Audio;
+
+///
+/// Builds the live ambient set from the region file's authored data — retail's
+/// Ambient::InitSounds plus CLandBlock::add_ambient_sounds @
+/// 0x50AC10 and LScape::add_ambient_sounds.
+///
+///
+/// Where ambients come from. Entirely region.dat:
+/// Region.SoundInfo.STBDesc[] holds the AmbientSTBDesc entries,
+/// Region.SceneInfo.SceneTypes[i].StbIndex points into that list, and
+/// Region.TerrainInfo.TerrainTypes[t].SceneTypes[s] points at the scene
+/// type. 0xFFFFFFFF means none. There is no separate ambient dat range —
+/// AmbientSTBDesc.STBId is an ordinary SoundTable DID.
+///
+///
+///
+/// Granularity. Selection is per LAND CELL, not per landblock: retail
+/// walks the 8×8 cells of each landblock, decodes that cell's terrain word to
+/// (terrainType, sceneIndex), and positions the contribution at the
+/// cell's SW vertex. Only the 3×3 landblock ring around the viewer contributes
+/// (LScape::add_ambient_sounds feeds blocks whose
+/// get_block_orient is ring ≤ 1).
+///
+///
+///
+/// Indoors is silent. CEnvCell::add_ambient_sounds exists in the
+/// PDB but is ICF-folded onto a bare ret, and the EnvCell format carries
+/// no sound field — corroborated independently by two research lanes. Dungeon
+/// silence is retail-correct; any indoor ambient would be a new feature needing
+/// a divergence row, not a port.
+///
+///
+public sealed class AmbientSoundGatherer
+{
+ /// Land cells per landblock side.
+ public const int CellsPerSide = 8;
+
+ /// Terrain-word entries per landblock side (a 9×9 vertex grid).
+ private const int VerticesPerSide = 9;
+
+ /// Retail's "no entry" sentinel in the scene/STB index chain.
+ private const uint NoIndex = 0xFFFFFFFFu;
+
+ private readonly AmbientSoundScheduler _scheduler;
+
+ public AmbientSoundGatherer(AmbientSoundScheduler scheduler) =>
+ _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
+
+ ///
+ /// Rebuild the ambient set for a listener standing at
+ /// — the listener's LANDBLOCK-LOCAL
+ /// position, x and y in [0, 192), as retail's Position carries
+ /// it. supplies the terrain words for each
+ /// landblock in the 3×3 ring, keyed by landblock id; a missing entry simply
+ /// contributes nothing.
+ ///
+ ///
+ /// Frames matter here. Offsets are computed the way retail's
+ /// Position::get_offset / LandDefs::get_block_offset do —
+ /// landblock delta plus in-block coordinates — NOT by differencing absolute
+ /// world coordinates. acdream's live Position is in a streamed frame
+ /// rebased on the streaming centre, so subtracting it from an absolute cell
+ /// coordinate yields tens of kilometres and culls every contribution. The
+ /// streamed-frame listener position is still needed for PLAYBACK, but it is
+ /// a separate value carried by the scheduler.
+ ///
+ ///
+ public void Rebuild(
+ Region region,
+ uint viewerLandblockId,
+ Vector3 listenerLocalPosition,
+ Func landblocks,
+ double now)
+ {
+ ArgumentNullException.ThrowIfNull(region);
+ ArgumentNullException.ThrowIfNull(landblocks);
+
+ _scheduler.BeginRebuild();
+
+ uint viewerX = viewerLandblockId >> 24;
+ uint viewerY = (viewerLandblockId >> 16) & 0xFFu;
+
+ for (int dx = -1; dx <= 1; dx++)
+ {
+ for (int dy = -1; dy <= 1; dy++)
+ {
+ long blockX = viewerX + dx;
+ long blockY = viewerY + dy;
+ if (blockX < 0 || blockX > 0xFF || blockY < 0 || blockY > 0xFF)
+ continue;
+
+ uint landblockId = ((uint)blockX << 24) | ((uint)blockY << 16) | 0xFFFFu;
+ ushort[]? terrain = landblocks(landblockId);
+ if (terrain is null || terrain.Length < VerticesPerSide * VerticesPerSide)
+ continue;
+
+ ContributeLandblock(
+ region,
+ dx,
+ dy,
+ terrain,
+ listenerLocalPosition);
+ }
+ }
+
+ _scheduler.EndRebuild(now);
+ }
+
+ private void ContributeLandblock(
+ Region region,
+ int blockDeltaX,
+ int blockDeltaY,
+ ushort[] terrain,
+ Vector3 listenerLocalPosition)
+ {
+ // The ring neighbour's origin RELATIVE to the listener's own landblock.
+ const float landblockLength = CellsPerSide * AmbientSoundConstants.LandCellLength;
+ float blockOriginX = blockDeltaX * landblockLength;
+ float blockOriginY = blockDeltaY * landblockLength;
+
+ // 8x8 CELLS, not the 9x9 vertex grid: each cell contributes once, at its
+ // south-west vertex.
+ for (int x = 0; x < CellsPerSide; x++)
+ {
+ for (int y = 0; y < CellsPerSide; y++)
+ {
+ ushort raw = terrain[(x * VerticesPerSide) + y];
+ uint terrainType = (uint)((raw >> 2) & 0x1F);
+ uint sceneIndex = (uint)((raw >> 11) & 0x1F);
+
+ if (!TryResolveStbDesc(region, terrainType, sceneIndex, out var stb))
+ continue;
+
+ // Retail positions each contribution at the land cell's SW
+ // vertex. Z stays planar: CalcDir ignores Z outright and
+ // CalcWeight's Z term is the terrain height difference, which we
+ // do not sample here (see the register row).
+ var offset = new Vector3(
+ blockOriginX + (x * AmbientSoundConstants.LandCellLength)
+ - listenerLocalPosition.X,
+ blockOriginY + (y * AmbientSoundConstants.LandCellLength)
+ - listenerLocalPosition.Y,
+ 0f);
+ // Cheap reject before touching the descriptor list: beyond 120 m
+ // the weight is zero and retail's AddSound gate drops it.
+ if (offset.LengthSquared() > AmbientSoundConstants.MaxDistanceSq)
+ continue;
+
+ // Ambient::AddSound @ 0x551610 adds this cell's weight to the
+ // shared denominator ONCE, then feeds every descriptor in the
+ // table. Adding it per descriptor would divide each bed's
+ // crossfade by the entry count and push quiet beds under the
+ // 0.03 audibility floor.
+ _scheduler.ContributeCell(offset, stb!, static (stb, index) =>
+ {
+ var sound = stb.AmbientSounds[index];
+ return new AmbientSoundDescriptor(
+ (SoundId)(uint)sound.SType,
+ sound.Volume,
+ sound.BaseChance,
+ sound.MinRate,
+ sound.MaxRate);
+ });
+ }
+ }
+ }
+
+ ///
+ /// terrain type → scene type → STB descriptor, with retail's
+ /// 0xFFFFFFFF "none" sentinel honoured at each hop.
+ ///
+ private static bool TryResolveStbDesc(
+ Region region,
+ uint terrainType,
+ uint sceneIndex,
+ out DatReaderWriter.Types.AmbientSTBDesc? stb)
+ {
+ stb = null;
+
+ var terrainTypes = region.TerrainInfo?.TerrainTypes;
+ if (terrainTypes is null || terrainType >= terrainTypes.Count)
+ return false;
+
+ var sceneTypes = terrainTypes[(int)terrainType].SceneTypes;
+ if (sceneIndex >= sceneTypes.Count)
+ return false;
+
+ uint sceneTypeIndex = sceneTypes[(int)sceneIndex];
+ var sceneList = region.SceneInfo?.SceneTypes;
+ if (sceneTypeIndex == NoIndex || sceneList is null || sceneTypeIndex >= sceneList.Count)
+ return false;
+
+ uint stbIndex = sceneList[(int)sceneTypeIndex].StbIndex;
+ var descriptors = region.SoundInfo?.STBDesc;
+ if (stbIndex == NoIndex || descriptors is null || stbIndex >= descriptors.Count)
+ return false;
+
+ var candidate = descriptors[(int)stbIndex];
+ if (candidate.STBId == 0 || candidate.AmbientSounds.Count == 0)
+ return false;
+
+ stb = candidate;
+ return true;
+ }
+}
diff --git a/src/AcDream.Core/Audio/AmbientSoundModel.cs b/src/AcDream.Core/Audio/AmbientSoundModel.cs
new file mode 100644
index 00000000..162bdb4b
--- /dev/null
+++ b/src/AcDream.Core/Audio/AmbientSoundModel.cs
@@ -0,0 +1,409 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Audio;
+
+///
+/// Retail's LandDefs::Direction — where a contributing land cell sits
+/// relative to the viewer's landblock. The jump table at 0x5A9A7C gives
+/// each one a compass heading in radians.
+///
+public enum AmbientDirection
+{
+ InViewerBlock = 0,
+ North = 1,
+ South = 2,
+ East = 3,
+ West = 4,
+ Northwest = 5,
+ Southwest = 6,
+ Northeast = 7,
+ Southeast = 8,
+}
+
+///
+/// One authored ambient entry — retail's AmbientSoundDesc (0x14 packed).
+/// IsContinuous is DERIVED at unpack from BaseChance == 0, not
+/// stored; Binary Ninja renders that compare inverted, and porting its version
+/// yields silence rather than a wrong sound.
+///
+/// The slot in the referenced SoundTable.
+/// Authored linear gain.
+/// 0 ⇒ continuous; otherwise the per-fire probability base.
+/// Re-fire interval floor, seconds.
+/// Re-fire interval ceiling, seconds (continuous ignores it).
+public readonly record struct AmbientSoundDescriptor(
+ SoundId Sound,
+ float Volume,
+ float BaseChance,
+ float MinRate,
+ float MaxRate)
+{
+ ///
+ /// base_chance == 0 ⇒ ConstantSound (a crossfaded,
+ /// non-positional bed re-fired every seconds);
+ /// non-zero ⇒ IntermitSound.
+ ///
+ public bool IsContinuous => BaseChance == 0f;
+}
+
+///
+/// Retail's ambient constants, byte-read from the PDB-paired binary. Every one
+/// of these is elided or mis-rendered somewhere in the pseudo-C.
+///
+public static class AmbientSoundConstants
+{
+ /// Full-weight radius, metres (0x81F148).
+ public const float MinDistance = 20.0f;
+
+ /// squared (0x81F14C).
+ public const float MinDistanceSq = 400.0f;
+
+ /// Cull radius, metres (0x81F150).
+ public const float MaxDistance = 120.0f;
+
+ /// squared (0x81F154).
+ public const float MaxDistanceSq = 14400.0f;
+
+ ///
+ /// Audibility floor for a continuous bed's crossfaded volume
+ /// (0x81F158) — about −30.5 dB.
+ ///
+ public const float MinVolume = 0.03f;
+
+ ///
+ /// Total jitter cone applied to an intermittent sound's bearing
+ /// (0x81F1B0): π/8 radians = 22.5°, so ±11.25°.
+ ///
+ public const float HeadingSpread = 0.392699093f;
+
+ ///
+ /// The "close enough to be anywhere around you" threshold in
+ /// Ambient::CalcDir: × 0.5 = 200 m²,
+ /// i.e. 14.142 m. This is the ONLY place the squared value is halved —
+ /// reading it as would widen the omnidirectional
+ /// zone from 14 m to 20 m.
+ ///
+ public const float InViewerBlockDistanceSq = MinDistanceSq * 0.5f;
+
+ /// Half of — the shell half-thickness, metres.
+ public const float ShellHalfThickness = MinDistance * 0.5f;
+
+ /// Near bound used for the omnidirectional spread, metres (5.0f − 1.0f).
+ public const float InBlockNearDistance = 4.0f;
+
+ /// Metres per land cell (LandDefs::square_length, 0x799128).
+ public const float LandCellLength = 24.0f;
+
+ /// Retail's per-direction compass heading, radians (jump table 0x5A9A7C).
+ public static float Heading(AmbientDirection direction) => direction switch
+ {
+ AmbientDirection.North => 0.0f,
+ AmbientDirection.South => 3.14159274f,
+ AmbientDirection.East => 1.57079637f,
+ AmbientDirection.West => 4.71238899f,
+ AmbientDirection.Northwest => 5.49778700f,
+ AmbientDirection.Southwest => 3.92699075f,
+ AmbientDirection.Northeast => 0.78539819f,
+ AmbientDirection.Southeast => 2.35619450f,
+ // IN_VIEWER_BLOCK and anything out of range fall to 0.0.
+ _ => 0.0f,
+ };
+
+ ///
+ /// Ambient::CalcWeight @ 0x550DD0: full weight inside 20 m,
+ /// inverse-square out to 120 m, nothing beyond. Binary Ninja dropped the
+ /// arithmetic entirely.
+ ///
+ public static float CalcWeight(Vector3 offset)
+ {
+ float distanceSq = offset.LengthSquared();
+ if (distanceSq > MaxDistanceSq) return 0f;
+ if (distanceSq < MinDistanceSq) return 1f;
+ return MinDistanceSq / distanceSq;
+ }
+
+ ///
+ /// Ambient::CalcDir @ 0x550E40: which compass sector a
+ /// contributing cell falls in, or
+ /// when it is inside . A sector counts as
+ /// diagonal when neither axis dominates the other by more than 2×
+ /// (0x7C5E24).
+ ///
+ public static AmbientDirection CalcDirection(Vector3 offset)
+ {
+ float x = offset.X;
+ float y = offset.Y;
+
+ // CalcDir squares X and Y only — Z is deliberately absent here, where
+ // CalcWeight deliberately includes it. The two functions differ on
+ // purpose; collapsing them would widen or narrow the omnidirectional
+ // zone by the height difference.
+ if (((x * x) + (y * y)) < InViewerBlockDistanceSq)
+ return AmbientDirection.InViewerBlock;
+ float ax = MathF.Abs(x);
+ float ay = MathF.Abs(y);
+
+ const float diagonalRatio = 2.0f;
+ const float epsilon = 0.0002f; // F_EPSILON @ 0x7CB0A0
+
+ bool diagonal =
+ ax > epsilon && ay > epsilon
+ && ay / ax <= diagonalRatio
+ && ax / ay <= diagonalRatio;
+
+ if (diagonal)
+ {
+ return y >= 0f
+ ? (x >= 0f ? AmbientDirection.Northeast : AmbientDirection.Northwest)
+ : (x >= 0f ? AmbientDirection.Southeast : AmbientDirection.Southwest);
+ }
+
+ if (ay >= ax)
+ return y >= 0f ? AmbientDirection.North : AmbientDirection.South;
+ return x >= 0f ? AmbientDirection.East : AmbientDirection.West;
+ }
+}
+
+///
+/// One live ambient instance — retail's ConstantSound or
+/// IntermitSound. Accumulates weight (and, for the intermittent kind,
+/// bearings) during a rebuild, then answers the four questions the scheduler
+/// asks: can it be heard, should it fire now, how loud, and when again.
+///
+public sealed class AmbientSoundInstance
+{
+ private readonly List _directions = [];
+
+ public AmbientSoundInstance(AmbientSoundDescriptor descriptor, uint soundTableDid)
+ {
+ Descriptor = descriptor;
+ SoundTableDid = soundTableDid;
+ }
+
+ public AmbientSoundDescriptor Descriptor { get; }
+
+ /// The SoundTable the descriptor's slot is looked up in.
+ public uint SoundTableDid { get; }
+
+ /// Accumulated weight from every contributing land cell.
+ public float SoundCount { get; private set; }
+
+ /// Crossfaded volume — continuous instances only.
+ public float CurrentVolume { get; private set; }
+
+ /// Per-fire probability — intermittent instances only.
+ public float PlayChance { get; private set; }
+
+ /// True while this instance holds a slot in the deadline queue.
+ public bool OnQueue { get; set; }
+
+ public IReadOnlyList Directions => _directions;
+
+ ///
+ /// ResetCount (0x550CD0 intermittent, 0x550D70
+ /// continuous). Must run for EVERY instance before a rebuild accumulates:
+ /// IntermitSound::UpdateSound never clears ,
+ /// so a skipped reset leaves a stale bearing and probability alive forever.
+ /// Note retail does NOT reset a continuous instance's
+ /// here.
+ ///
+ public void ResetCount()
+ {
+ SoundCount = 0f;
+ _directions.Clear();
+ if (!Descriptor.IsContinuous)
+ PlayChance = 0f;
+ }
+
+ ///
+ /// AddTo @ 0x551450. A cell outside the viewer's own block
+ /// contributes a 20 m-thick shell at its bearing; a cell inside
+ /// could be
+ /// anywhere around the listener, so it contributes 4–10 m in all eight
+ /// directions.
+ ///
+ public void AddTo(float weight, Vector3 offset, AmbientDirection direction)
+ {
+ SoundCount += weight;
+ if (Descriptor.IsContinuous)
+ return; // continuous beds track weight only, never bearings
+
+ float distance = MathF.Sqrt(offset.LengthSquared());
+ float half = AmbientSoundConstants.ShellHalfThickness;
+
+ if (direction != AmbientDirection.InViewerBlock)
+ {
+ AddDirection(direction, distance - half, distance + half);
+ return;
+ }
+
+ AddDirection(AmbientDirection.North, AmbientSoundConstants.InBlockNearDistance, half);
+ AddDirection(AmbientDirection.South, AmbientSoundConstants.InBlockNearDistance, half);
+ AddDirection(AmbientDirection.East, AmbientSoundConstants.InBlockNearDistance, half);
+ AddDirection(AmbientDirection.West, AmbientSoundConstants.InBlockNearDistance, half);
+ AddDirection(AmbientDirection.Northwest, AmbientSoundConstants.InBlockNearDistance, half);
+ AddDirection(AmbientDirection.Southwest, AmbientSoundConstants.InBlockNearDistance, half);
+ AddDirection(AmbientDirection.Northeast, AmbientSoundConstants.InBlockNearDistance, half);
+ AddDirection(AmbientDirection.Southeast, AmbientSoundConstants.InBlockNearDistance, half);
+ }
+
+ ///
+ /// AddDir @ 0x550CF0: widen an existing shell for this bearing
+ /// or append a new one. Retail keeps at most eight.
+ ///
+ private void AddDirection(AmbientDirection direction, float min, float max)
+ {
+ for (int i = 0; i < _directions.Count; i++)
+ {
+ if (_directions[i].Direction != direction)
+ continue;
+
+ AmbientDirectionShell existing = _directions[i];
+ _directions[i] = new AmbientDirectionShell(
+ direction,
+ MathF.Min(existing.MinDistance, min),
+ MathF.Max(existing.MaxDistance, max));
+ return;
+ }
+
+ if (_directions.Count >= 8)
+ return;
+ _directions.Add(new AmbientDirectionShell(direction, min, max));
+ }
+
+ ///
+ /// UpdateSound (0x551540 continuous, 0x551310
+ /// intermittent). is the sum over ALL
+ /// ambients, not per-descriptor — that denominator is what makes the mix a
+ /// terrain-share crossfade.
+ ///
+ public void UpdateSound(float totalSoundCount)
+ {
+ if (Descriptor.IsContinuous)
+ {
+ if (SoundCount == 0f)
+ {
+ CurrentVolume = 0f;
+ return;
+ }
+ CurrentVolume = Descriptor.Volume / totalSoundCount * SoundCount;
+ return;
+ }
+
+ // Intermittent: note the asymmetry — a zero weight leaves PlayChance
+ // ALONE rather than zeroing it. Only ResetCount clears it.
+ if (SoundCount <= 0f)
+ return;
+ PlayChance = Descriptor.BaseChance / totalSoundCount * SoundCount;
+ }
+
+ ///
+ /// CanHear (0x550FD0 continuous, 0x550F80
+ /// intermittent). Both compares are rendered as an unimplemented predicate
+ /// by Binary Ninja and would port inverted.
+ ///
+ public bool CanHear() =>
+ Descriptor.IsContinuous
+ ? CurrentVolume >= AmbientSoundConstants.MinVolume
+ : PlayChance > 0f;
+
+ ///
+ /// PlayNow (continuous is a folded mov eax,1 — ALWAYS true;
+ /// intermittent rolls against @ 0x550FA0).
+ ///
+ public bool PlayNow(ISoundRandom rng)
+ {
+ ArgumentNullException.ThrowIfNull(rng);
+ return Descriptor.IsContinuous || rng.NextVariantRoll() <= PlayChance;
+ }
+
+ ///
+ /// GetVolume: the crossfaded value for a continuous bed
+ /// (0x551070 returns the AUTHORED volume for intermittent — the
+ /// crossfade lives in its probability instead).
+ ///
+ public float GetVolume() =>
+ Descriptor.IsContinuous ? CurrentVolume : Descriptor.Volume;
+
+ ///
+ /// GetPlayInterval: intermittent rolls between the authored rates
+ /// (0x551080); continuous uses min_rate alone
+ /// (0x5510A0) — that rate IS the author's intended loop period, which
+ /// is how retail fakes a sustained bed without a looping voice.
+ ///
+ public float GetPlayInterval(ISoundRandom rng)
+ {
+ ArgumentNullException.ThrowIfNull(rng);
+ return Descriptor.IsContinuous
+ ? Descriptor.MinRate
+ : RollDice(Descriptor.MinRate, Descriptor.MaxRate, rng);
+ }
+
+ ///
+ /// GetSoundPos @ 0x551350: offset the LISTENER's position in
+ /// the XY plane, keeping their Z. Returns false for a continuous bed, whose
+ /// base implementation is a folded xor eax,eax — no position at all,
+ /// so it plays from centre.
+ ///
+ ///
+ /// The distance is min + (max − min)·t², quadratically biased toward
+ /// min; a linear lerp puts intermittent ambients audibly further away
+ /// on average.
+ ///
+ ///
+ public bool TryGetSoundPosition(
+ Vector3 listenerPosition,
+ ISoundRandom rng,
+ out Vector3 position)
+ {
+ ArgumentNullException.ThrowIfNull(rng);
+ position = listenerPosition;
+
+ if (Descriptor.IsContinuous || _directions.Count == 0)
+ return false;
+
+ int index = (int)MathF.Floor(rng.NextVariantRoll() * _directions.Count);
+ if (index >= _directions.Count)
+ index = _directions.Count - 1;
+ AmbientDirectionShell shell = _directions[index];
+
+ float spread = AmbientSoundConstants.HeadingSpread;
+ float angle = AmbientSoundConstants.Heading(shell.Direction)
+ + (rng.NextVariantRoll() * spread)
+ - (spread * 0.5f);
+
+ float t = rng.NextVariantRoll();
+ float distance = shell.MinDistance
+ + ((shell.MaxDistance - shell.MinDistance) * t * t);
+
+ // AC's compass convention: north is +Y, east is +X.
+ position = new Vector3(
+ listenerPosition.X + (MathF.Sin(angle) * distance),
+ listenerPosition.Y + (MathF.Cos(angle) * distance),
+ listenerPosition.Z);
+ return true;
+ }
+
+ ///
+ /// Random::RollDice @ 0x42C600, including its swap on an
+ /// inverted range and its equal-bounds short circuit.
+ ///
+ internal static float RollDice(float min, float max, ISoundRandom rng)
+ {
+ if (min == max) return min;
+ float lo = min, hi = max;
+ if (max < min) { lo = max; hi = min; }
+ return lo + ((hi - lo) * rng.NextVariantRoll());
+ }
+}
+
+///
+/// One accumulated bearing for an intermittent ambient: a distance shell at a
+/// compass direction.
+///
+public readonly record struct AmbientDirectionShell(
+ AmbientDirection Direction,
+ float MinDistance,
+ float MaxDistance);
diff --git a/src/AcDream.Core/Audio/AmbientSoundScheduler.cs b/src/AcDream.Core/Audio/AmbientSoundScheduler.cs
new file mode 100644
index 00000000..bf034a0a
--- /dev/null
+++ b/src/AcDream.Core/Audio/AmbientSoundScheduler.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Audio;
+
+///
+/// One ambient firing, as the scheduler hands it to the audio backend.
+///
+/// The instance that fired (its descriptor names the slot).
+/// The volume to play at — crossfaded for a continuous bed.
+///
+/// The world position, or null for a continuous bed — retail plays those through
+/// PlayAmbientSoundFromCenter, with no position, no pan and no distance
+/// attenuation: a stereo bed centred on the listener.
+///
+public readonly record struct AmbientSoundFiring(
+ AmbientSoundInstance Instance,
+ float Volume,
+ Vector3? Position);
+
+///
+/// Retail's ambient playback engine: a min-heap of ABSOLUTE deadlines, drained
+/// once per frame, where each pop plays a one-shot and immediately re-arms
+/// itself. There is no looping voice anywhere in retail's audio path — a
+/// "continuous" ambient is a one-shot re-fired every min_rate seconds,
+/// which is why an AL_LOOPING port sounds wrong (no re-randomised table
+/// pick, no re-rolled crossfade volume, no gap).
+///
+///
+/// Ports Ambient::UpdatePlayQueue @ 0x551A50,
+/// Ambient::Play @ 0x5517A0 and Ambient::UseTime @
+/// 0x551880.
+///
+///
+public sealed class AmbientSoundScheduler
+{
+ private readonly PriorityQueue _queue = new();
+ private readonly List _instances = [];
+ private readonly ISoundRandom _rng;
+
+ public AmbientSoundScheduler(ISoundRandom? rng = null) => _rng = rng ?? new SoundRandom();
+
+ /// Every live instance, in accumulation order.
+ public IReadOnlyList Instances => _instances;
+
+ /// Instances currently holding a deadline. Diagnostic use.
+ public int QueuedCount => _queue.Count;
+
+ /// Sum of every instance's weight — the crossfade denominator.
+ public float TotalSoundCount { get; private set; }
+
+ ///
+ /// Begin a rebuild. Ambient::InitSounds @ 0x5515D0 resets EVERY
+ /// existing instance's counters before the accumulation pass — skipping that
+ /// leaves stale bearings and probabilities alive indefinitely, because
+ /// IntermitSound::UpdateSound never clears them itself.
+ ///
+ public void BeginRebuild()
+ {
+ foreach (AmbientSoundInstance instance in _instances)
+ instance.ResetCount();
+ TotalSoundCount = 0f;
+ }
+
+ ///
+ /// Register an instance for this world, if it is not already tracked. Called
+ /// while walking the contributing land cells.
+ ///
+ public AmbientSoundInstance Track(AmbientSoundDescriptor descriptor, uint soundTableDid)
+ {
+ foreach (AmbientSoundInstance existing in _instances)
+ {
+ if (existing.Descriptor == descriptor && existing.SoundTableDid == soundTableDid)
+ return existing;
+ }
+
+ var created = new AmbientSoundInstance(descriptor, soundTableDid);
+ _instances.Add(created);
+ return created;
+ }
+
+ ///
+ /// Contribute one land cell to every ambient its sound table authors —
+ /// Ambient::AddSound @ 0x551610.
+ ///
+ ///
+ /// The cell's weight lands in exactly ONCE,
+ /// however many descriptors the table carries. Retail adds w to
+ /// total_sound_count before looping the descriptors, so with an
+ /// N-entry table the per-instance counts sum to N × the denominator — a
+ /// deliberate retail property, not an oversight. Adding the weight per
+ /// descriptor instead divides every bed's crossfade by N, which pushes a
+ /// typical authored volume under the 0.03 audibility floor and silences it.
+ ///
+ ///
+ public void ContributeCell(
+ Vector3 offset,
+ TTable table,
+ Func descriptorAt)
+ where TTable : DatReaderWriter.Types.AmbientSTBDesc
+ {
+ ArgumentNullException.ThrowIfNull(table);
+ ArgumentNullException.ThrowIfNull(descriptorAt);
+
+ float weight = AmbientSoundConstants.CalcWeight(offset);
+ if (weight <= 0f)
+ return;
+
+ AmbientDirection direction = AmbientSoundConstants.CalcDirection(offset);
+ TotalSoundCount += weight;
+
+ for (int i = 0; i < table.AmbientSounds.Count; i++)
+ {
+ AmbientSoundInstance instance = Track(descriptorAt(table, i), table.STBId);
+ instance.AddTo(weight, offset, direction);
+ }
+ }
+
+ ///
+ /// Contribute one land cell's weight to a single instance. Test seam and the
+ /// single-descriptor case; is the
+ /// production path and owns the shared denominator.
+ ///
+ public void Contribute(AmbientSoundInstance instance, Vector3 offset)
+ {
+ ArgumentNullException.ThrowIfNull(instance);
+
+ float weight = AmbientSoundConstants.CalcWeight(offset);
+ if (weight <= 0f)
+ return;
+
+ instance.AddTo(weight, offset, AmbientSoundConstants.CalcDirection(offset));
+ TotalSoundCount += weight;
+ }
+
+ ///
+ /// Finish a rebuild: recompute every instance's crossfade against the shared
+ /// denominator, then arm any audible instance that is not already queued.
+ ///
+ ///
+ /// The on_queue guard is load-bearing in both directions. Re-arming
+ /// unconditionally restarts every ambient on every 24 m crossing — audible as
+ /// a machine-gun of one-shots. Never re-arming leaves a newly-audible ambient
+ /// silent until the next rebuild.
+ ///
+ ///
+ public void EndRebuild(
+ double now,
+ ICollection? firings = null,
+ Vector3 listenerPosition = default)
+ {
+ foreach (AmbientSoundInstance instance in _instances)
+ instance.UpdateSound(TotalSoundCount);
+
+ foreach (AmbientSoundInstance instance in _instances)
+ {
+ if (instance.OnQueue || !instance.CanHear())
+ continue;
+
+ // UpdatePlayQueue @ 0x551A50 calls Play for every on_queue == 0
+ // sound, and Play @ 0x5517A0 PLAYS first and then re-arms. There is
+ // no initial delay: an ambient that becomes audible at a crossing
+ // sounds at the crossing, not one min_rate later.
+ Fire(instance, now, firings, listenerPosition);
+ }
+ }
+
+ ///
+ /// Drain every deadline that has come due — Ambient::UseTime. Each pop
+ /// either fires (and re-arms) or, if the instance can no longer be heard,
+ /// leaves the queue entirely until a later rebuild re-arms it.
+ ///
+ public void Tick(double now, ICollection firings, Vector3 listenerPosition)
+ {
+ ArgumentNullException.ThrowIfNull(firings);
+
+ // Ambient::UseTime @ 0x551880 breaks on !(key < cur_time) — STRICTLY
+ // below. That is also what stops a descriptor authored with a zero rate
+ // from re-arming at the same instant and spinning this loop forever.
+ while (_queue.TryPeek(out _, out double deadline) && deadline < now)
+ {
+ AmbientSoundInstance instance = _queue.Dequeue();
+ instance.OnQueue = false;
+
+ // Ambient::Play @ 0x5517A0 — an inaudible instance drops off the
+ // queue rather than re-arming.
+ if (!instance.CanHear())
+ continue;
+
+ Fire(instance, now, firings, listenerPosition);
+ }
+ }
+
+ ///
+ /// Drop every instance and deadline — Ambient::FlushSoundTables /
+ /// ReleaseSoundTables, reached from CellManager::Reset.
+ ///
+ public void Clear()
+ {
+ foreach (AmbientSoundInstance instance in _instances)
+ instance.OnQueue = false;
+ _instances.Clear();
+ _queue.Clear();
+ TotalSoundCount = 0f;
+ }
+
+ ///
+ /// Ambient::Play @ 0x5517A0: roll, emit if it fires, then
+ /// re-arm at cur_time + GetPlayInterval() regardless.
+ ///
+ private void Fire(
+ AmbientSoundInstance instance,
+ double now,
+ ICollection? firings,
+ Vector3 listenerPosition)
+ {
+ if (firings is not null && instance.PlayNow(_rng))
+ {
+ Vector3? position =
+ instance.TryGetSoundPosition(listenerPosition, _rng, out Vector3 resolved)
+ ? resolved
+ : null;
+ firings.Add(new AmbientSoundFiring(instance, instance.GetVolume(), position));
+ }
+
+ Enqueue(instance, now);
+ }
+
+ private void Enqueue(AmbientSoundInstance instance, double now)
+ {
+ _queue.Enqueue(instance, now + instance.GetPlayInterval(_rng));
+ instance.OnQueue = true;
+ }
+}
diff --git a/src/AcDream.Core/Audio/AudioModel.cs b/src/AcDream.Core/Audio/AudioModel.cs
index 615ae0c3..ab28e7f9 100644
--- a/src/AcDream.Core/Audio/AudioModel.cs
+++ b/src/AcDream.Core/Audio/AudioModel.cs
@@ -76,9 +76,15 @@ public interface IAudioEngine : IDisposable
/// Play a 3D sound at a world position.
void Play3D(SoundId id, float x, float y, float z);
- /// Start a looped ambient sound (landblock-attached).
- int StartAmbient(SoundId id, float x, float y, float z);
- void StopAmbient(int handle);
+ // A `StartAmbient(id, x, y, z)` / `StopAmbient(handle)` pair lived here
+ // until 2026-08-08 (Campaign A slice A5). It modelled a LOOPING,
+ // handle-owned ambient voice, which retail does not have: retail never sets
+ // the DirectSound loop flag, and a "continuous" ambient is a one-shot
+ // re-fired every min_rate seconds off an absolute-deadline queue, with a
+ // fresh variant pick and crossfade volume each time. The implementation was
+ // a stub that minted a handle and played nothing, while StopAmbient looked
+ // up a source that was never created. `AmbientSoundController` +
+ // `AmbientSoundScheduler` carry the real model.
/// Start music (fades out previous if any).
void PlayMusic(string resourceName, bool loop);
diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
index 8a9cda9b..7b6de332 100644
--- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs
@@ -149,7 +149,7 @@ public sealed class ContentEffectsAudioCompositionTests
// disabled, so those points are not required boundaries. A4 added
// UiSoundsCreated to that prefix.
if (point is >= ContentEffectsAudioCompositionPoint.SoundCacheCreated
- and <= ContentEffectsAudioCompositionPoint.UiSoundsCreated)
+ and <= ContentEffectsAudioCompositionPoint.AmbientCreated)
{
continue;
}
@@ -487,6 +487,11 @@ public sealed class ContentEffectsAudioCompositionTests
DatSoundCache cache,
IDatReaderWriter dats) =>
new(engine, cache, tableDid: 0u);
+
+ public AmbientSoundController CreateAmbient(
+ OpenAlAudioEngine engine,
+ DatSoundCache cache) =>
+ new(engine, cache);
}
public class NullProxy : DispatchProxy
diff --git a/tests/AcDream.Core.Tests/Audio/AmbientSoundTests.cs b/tests/AcDream.Core.Tests/Audio/AmbientSoundTests.cs
new file mode 100644
index 00000000..07987f22
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Audio/AmbientSoundTests.cs
@@ -0,0 +1,616 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Audio;
+using Xunit;
+
+namespace AcDream.Core.Tests.Audio;
+
+///
+/// Conformance tests for retail's ambient runtime — the
+/// AmbientSound/ConstantSound/IntermitSound family and the
+/// deadline scheduler. Byte-decoded values and pseudocode:
+/// docs/research/2026-08-08-audio-retail-ambient-runtime.md.
+///
+///
+/// Five separate x87 compares in this subsystem are rendered by Binary Ninja as
+/// an unimplemented predicate and would port INVERTED — including the one that
+/// decides continuous vs intermittent. These tests pin the decoded polarity.
+///
+///
+public sealed class AmbientSoundTests
+{
+ private sealed class ScriptedRandom(params float[] rolls) : ISoundRandom
+ {
+ private readonly Queue _rolls = new(rolls);
+ public float NextVariantRoll() => _rolls.Count > 0 ? _rolls.Dequeue() : 0f;
+ public float NextProbabilityRoll() => NextVariantRoll();
+ }
+
+ private static AmbientSoundDescriptor Continuous(
+ float volume = 1f, float minRate = 5f) =>
+ new(SoundId.Ambient1, volume, BaseChance: 0f, minRate, MaxRate: 0f);
+
+ private static AmbientSoundDescriptor Intermittent(
+ float volume = 1f, float baseChance = 0.5f, float minRate = 4f, float maxRate = 10f) =>
+ new(SoundId.Ambient2, volume, baseChance, minRate, maxRate);
+
+ // ── The polarity that decides the whole model ──────────────────────────
+
+ [Fact]
+ public void BaseChanceZero_MeansContinuous_NotIntermittent()
+ {
+ // is_continuous = (base_chance == 0). BN renders this test inverted;
+ // porting its version yields silence rather than a wrong sound.
+ Assert.True(Continuous().IsContinuous);
+ Assert.False(Intermittent(baseChance: 0.5f).IsContinuous);
+ }
+
+ // ── CalcWeight ─────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(0f, 1f)] // on top of the listener
+ [InlineData(19.9f, 1f)] // inside the 20 m full-weight radius
+ [InlineData(20f, 1f)] // 400 == 400 is not > 400, and not < 400 -> 400/400
+ [InlineData(40f, 0.25f)] // 400/1600
+ [InlineData(120f, 400f / 14400f)]
+ [InlineData(120.1f, 0f)] // beyond the 120 m cull
+ [InlineData(500f, 0f)]
+ public void CalcWeight_IsFullInsideTwentyMetres_ThenInverseSquare(float distance, float expected)
+ {
+ float weight = AmbientSoundConstants.CalcWeight(new Vector3(distance, 0f, 0f));
+ Assert.Equal(expected, weight, 4);
+ }
+
+ // ── CalcDirection ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void CalcDirection_InsideFourteenMetres_IsInViewerBlock()
+ {
+ // The threshold is min_dist_sq * 0.5 = 200 m^2 = 14.142 m — the ONLY
+ // place the squared value is halved. Reading it as min_dist would widen
+ // the omnidirectional zone from 14 m to 20 m.
+ Assert.Equal(
+ AmbientDirection.InViewerBlock,
+ AmbientSoundConstants.CalcDirection(new Vector3(14.1f, 0f, 0f)));
+ Assert.NotEqual(
+ AmbientDirection.InViewerBlock,
+ AmbientSoundConstants.CalcDirection(new Vector3(14.2f, 0f, 0f)));
+ }
+
+ [Theory]
+ [InlineData(0f, 50f, AmbientDirection.North)]
+ [InlineData(0f, -50f, AmbientDirection.South)]
+ [InlineData(50f, 0f, AmbientDirection.East)]
+ [InlineData(-50f, 0f, AmbientDirection.West)]
+ [InlineData(40f, 40f, AmbientDirection.Northeast)]
+ [InlineData(-40f, 40f, AmbientDirection.Northwest)]
+ [InlineData(40f, -40f, AmbientDirection.Southeast)]
+ [InlineData(-40f, -40f, AmbientDirection.Southwest)]
+ public void CalcDirection_SectorsMatchRetail(float x, float y, AmbientDirection expected)
+ {
+ Assert.Equal(expected, AmbientSoundConstants.CalcDirection(new Vector3(x, y, 0f)));
+ }
+
+ [Fact]
+ public void CalcDirection_DiagonalNeedsBothAxesWithinTwoTimes()
+ {
+ // Ratio gate is 2.0 both ways: 50/20 = 2.5 is NOT diagonal.
+ Assert.Equal(
+ AmbientDirection.North,
+ AmbientSoundConstants.CalcDirection(new Vector3(20f, 50f, 0f)));
+ // 40/30 = 1.33 IS diagonal.
+ Assert.Equal(
+ AmbientDirection.Northeast,
+ AmbientSoundConstants.CalcDirection(new Vector3(30f, 40f, 0f)));
+ }
+
+ // ── Crossfade: the shared denominator ──────────────────────────────────
+
+ [Fact]
+ public void ContinuousVolume_IsItsShareOfTheTotalWeight()
+ {
+ // Normalisation is by the sum over ALL ambients, not per-descriptor.
+ // Getting the denominator wrong changes the crossfade, not just level.
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance grass = scheduler.Track(Continuous(volume: 1f), 0x20000001u);
+ AmbientSoundInstance shore = scheduler.Track(Continuous(volume: 1f), 0x20000002u);
+
+ // Three cells of grass, one of shore, all inside the full-weight radius.
+ scheduler.Contribute(grass, new Vector3(1f, 0f, 0f));
+ scheduler.Contribute(grass, new Vector3(2f, 0f, 0f));
+ scheduler.Contribute(grass, new Vector3(3f, 0f, 0f));
+ scheduler.Contribute(shore, new Vector3(4f, 0f, 0f));
+ scheduler.EndRebuild(now: 0);
+
+ Assert.Equal(4f, scheduler.TotalSoundCount, 4);
+ Assert.Equal(0.75f, grass.CurrentVolume, 4);
+ Assert.Equal(0.25f, shore.CurrentVolume, 4);
+ }
+
+ [Fact]
+ public void ContinuousBed_BelowTheAudibilityFloor_CannotBeHeard()
+ {
+ // ambient_sound_min_vol = 0.03 linear (about -30.5 dB).
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance faint = scheduler.Track(Continuous(volume: 1f), 0x20000001u);
+ AmbientSoundInstance loud = scheduler.Track(Continuous(volume: 1f), 0x20000002u);
+
+ scheduler.Contribute(faint, new Vector3(119f, 0f, 0f)); // tiny weight
+ for (int i = 0; i < 5; i++)
+ scheduler.Contribute(loud, new Vector3(i, 0f, 0f)); // weight 1 each
+ scheduler.EndRebuild(now: 0);
+
+ Assert.True(faint.CurrentVolume < AmbientSoundConstants.MinVolume);
+ Assert.False(faint.CanHear());
+ Assert.True(loud.CanHear());
+ }
+
+ [Fact]
+ public void IntermittentPlayChance_ScalesWithItsShare_AndVolumeStaysAuthored()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance a = scheduler.Track(Intermittent(volume: 0.8f, baseChance: 0.6f), 1u);
+ AmbientSoundInstance b = scheduler.Track(Intermittent(volume: 0.8f, baseChance: 0.6f), 2u);
+ scheduler.Contribute(a, new Vector3(1f, 0f, 0f));
+ scheduler.Contribute(b, new Vector3(2f, 0f, 0f));
+ scheduler.EndRebuild(now: 0);
+
+ // Half the total weight each -> half the base chance.
+ Assert.Equal(0.3f, a.PlayChance, 4);
+ // Intermittent volume is NOT crossfaded; the crossfade is in the chance.
+ Assert.Equal(0.8f, a.GetVolume(), 4);
+ }
+
+ [Fact]
+ public void IntermittentUpdate_WithZeroWeight_LeavesPlayChanceAlone()
+ {
+ // The documented asymmetry: UpdateSound early-returns without zeroing.
+ // Only ResetCount clears it — which is why the rebuild MUST reset first.
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance sound = scheduler.Track(Intermittent(baseChance: 0.6f), 1u);
+ scheduler.Contribute(sound, new Vector3(1f, 0f, 0f));
+ scheduler.EndRebuild(now: 0);
+ Assert.Equal(0.6f, sound.PlayChance, 4);
+
+ // A rebuild that contributes nothing must clear it via ResetCount.
+ scheduler.BeginRebuild();
+ scheduler.EndRebuild(now: 0);
+ Assert.Equal(0f, sound.PlayChance);
+ Assert.False(sound.CanHear());
+ }
+
+ [Fact]
+ public void Rebuild_ResetsStaleBearings()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance sound = scheduler.Track(Intermittent(), 1u);
+ scheduler.Contribute(sound, new Vector3(0f, 60f, 0f)); // north
+ scheduler.EndRebuild(now: 0);
+ Assert.Contains(
+ sound.Directions,
+ shell => shell.Direction == AmbientDirection.North);
+
+ scheduler.BeginRebuild();
+ scheduler.Contribute(sound, new Vector3(0f, -60f, 0f)); // south only
+ scheduler.EndRebuild(now: 0);
+ Assert.DoesNotContain(
+ sound.Directions,
+ shell => shell.Direction == AmbientDirection.North);
+ }
+
+ [Fact]
+ public void InViewerBlockContribution_SpreadsAcrossAllEightDirections()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance sound = scheduler.Track(Intermittent(), 1u);
+ scheduler.Contribute(sound, new Vector3(3f, 0f, 0f)); // inside 14.14 m
+ scheduler.EndRebuild(now: 0);
+
+ Assert.Equal(8, sound.Directions.Count);
+ Assert.All(sound.Directions, shell =>
+ {
+ Assert.Equal(AmbientSoundConstants.InBlockNearDistance, shell.MinDistance, 3);
+ Assert.Equal(AmbientSoundConstants.ShellHalfThickness, shell.MaxDistance, 3);
+ });
+ }
+
+ // ── PlayNow / positions / intervals ────────────────────────────────────
+
+ [Fact]
+ public void ContinuousPlayNow_IsAlwaysTrue()
+ {
+ // PlayNow for a continuous bed is a folded `mov eax,1; ret`.
+ AmbientSoundInstance sound = new(Continuous(), 1u);
+ Assert.True(sound.PlayNow(new ScriptedRandom(0.99f)));
+ }
+
+ [Theory]
+ [InlineData(0.4f, true)] // roll <= chance
+ [InlineData(0.5f, true)] // inclusive
+ [InlineData(0.6f, false)]
+ public void IntermittentPlayNow_RollsAgainstPlayChance(float roll, bool expected)
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance sound = scheduler.Track(Intermittent(baseChance: 0.5f), 1u);
+ scheduler.Contribute(sound, new Vector3(1f, 0f, 0f));
+ scheduler.EndRebuild(now: 0);
+ Assert.Equal(0.5f, sound.PlayChance, 4);
+
+ Assert.Equal(expected, sound.PlayNow(new ScriptedRandom(roll)));
+ }
+
+ [Fact]
+ public void ContinuousBed_HasNoPosition()
+ {
+ // Its GetSoundPos is a folded `xor eax,eax` -> plays from centre.
+ AmbientSoundInstance sound = new(Continuous(), 1u);
+ Assert.False(sound.TryGetSoundPosition(
+ new Vector3(100f, 200f, 10f), new ScriptedRandom(0f), out _));
+ }
+
+ [Fact]
+ public void IntermittentPosition_OffsetsTheListenerAndKeepsTheirZ()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance sound = scheduler.Track(Intermittent(), 1u);
+ scheduler.Contribute(sound, new Vector3(0f, 60f, 0f)); // north shell
+ scheduler.EndRebuild(now: 0);
+
+ var listener = new Vector3(100f, 200f, 37f);
+ // rolls: shell index, bearing jitter (mid), distance t
+ Assert.True(sound.TryGetSoundPosition(
+ listener, new ScriptedRandom(0f, 0.5f, 1f), out Vector3 position));
+
+ Assert.Equal(37f, position.Z); // listener Z preserved
+ Assert.True(position.Y > listener.Y); // to the north
+ Assert.Equal(100f, position.X, 1); // no jitter at mid-cone
+ }
+
+ [Fact]
+ public void IntermittentDistance_IsQuadraticallyBiasedTowardMin()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
+ scheduler.BeginRebuild();
+ AmbientSoundInstance sound = scheduler.Track(Intermittent(), 1u);
+ scheduler.Contribute(sound, new Vector3(0f, 60f, 0f));
+ scheduler.EndRebuild(now: 0);
+ AmbientDirectionShell shell = sound.Directions[0];
+
+ var listener = Vector3.Zero;
+ // t = 0.5 -> min + (max-min)*0.25, NOT the linear midpoint.
+ sound.TryGetSoundPosition(
+ listener, new ScriptedRandom(0f, 0.5f, 0.5f), out Vector3 position);
+
+ float distance = position.Length();
+ float quadratic = shell.MinDistance + ((shell.MaxDistance - shell.MinDistance) * 0.25f);
+ float linear = shell.MinDistance + ((shell.MaxDistance - shell.MinDistance) * 0.5f);
+ Assert.Equal(quadratic, distance, 1);
+ Assert.NotEqual(linear, distance, 1);
+ }
+
+ [Fact]
+ public void ContinuousInterval_UsesMinRateOnly()
+ {
+ AmbientSoundInstance sound = new(Continuous(minRate: 7f), 1u);
+ Assert.Equal(7f, sound.GetPlayInterval(new ScriptedRandom(0.9f)));
+ }
+
+ [Fact]
+ public void IntermittentInterval_RollsBetweenTheAuthoredRates()
+ {
+ AmbientSoundInstance sound = new(Intermittent(minRate: 4f, maxRate: 10f), 1u);
+ Assert.Equal(7f, sound.GetPlayInterval(new ScriptedRandom(0.5f)), 3);
+ }
+
+ [Fact]
+ public void RollDice_SwapsAnInvertedRange()
+ {
+ Assert.Equal(
+ 7f,
+ AmbientSoundInstance.RollDice(10f, 4f, new ScriptedRandom(0.5f)),
+ 3);
+ }
+
+ // ── The scheduler ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void Scheduler_FiresAtTheDeadlineAndReArms()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ var firings = new List();
+ scheduler.BeginRebuild();
+ AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 5f), 1u);
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.EndRebuild(now: 0, firings, Vector3.Zero);
+
+ // Arming PLAYS: Ambient::Play fires and then re-arms (see the dedicated
+ // test below). Clear that first firing to test the queue cadence.
+ firings.Clear();
+
+ scheduler.Tick(now: 4.9, firings, Vector3.Zero);
+ Assert.Empty(firings);
+
+ // Retail's drain is STRICTLY below the deadline (UseTime @ 0x551880
+ // breaks on !(key < cur_time)), so exactly-at does not fire.
+ scheduler.Tick(now: 5.0, firings, Vector3.Zero);
+ Assert.Empty(firings);
+
+ scheduler.Tick(now: 5.01, firings, Vector3.Zero);
+ Assert.Single(firings);
+ // Re-armed rather than dropped: a continuous bed is a repeating one-shot.
+ Assert.True(bed.OnQueue);
+ Assert.Equal(1, scheduler.QueuedCount);
+
+ firings.Clear();
+ scheduler.Tick(now: 10.1, firings, Vector3.Zero);
+ Assert.Single(firings);
+ }
+
+ [Fact]
+ public void Scheduler_ArmingPlaysImmediately_WithNoInitialDelay()
+ {
+ // UpdatePlayQueue @ 0x551A50 calls Play for every on_queue == 0 sound,
+ // and Play @ 0x5517A0 plays BEFORE re-arming. An ambient that becomes
+ // audible at a crossing must sound at the crossing, not one min_rate
+ // later — that delay is exactly the "ambience lags behind me" character.
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ var firings = new List();
+
+ scheduler.BeginRebuild();
+ AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 30f), 1u);
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.EndRebuild(now: 100.0, firings, Vector3.Zero);
+
+ Assert.Single(firings);
+ Assert.True(bed.OnQueue);
+ }
+
+ [Fact]
+ public void Scheduler_ZeroPlayInterval_DoesNotSpinForever()
+ {
+ // A descriptor authored with a zero rate re-arms at the same instant.
+ // With a <= drain this loops until the process dies; retail's strict <
+ // makes it structurally impossible.
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ scheduler.BeginRebuild();
+ AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 0f), 1u);
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.EndRebuild(now: 0);
+
+ var firings = new List();
+ scheduler.Tick(now: 1.0, firings, Vector3.Zero);
+
+ // One pop, then the re-armed deadline equals `now` and the loop stops.
+ Assert.Single(firings);
+ }
+
+ [Fact]
+ public void Scheduler_DoesNotReArmAnAlreadyQueuedInstanceOnRebuild()
+ {
+ // The on_queue guard: re-arming unconditionally on every 24 m crossing
+ // would machine-gun one-shots.
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ scheduler.BeginRebuild();
+ AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 5f), 1u);
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.EndRebuild(now: 0);
+ Assert.Equal(1, scheduler.QueuedCount);
+
+ for (int i = 0; i < 5; i++)
+ {
+ scheduler.BeginRebuild();
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.EndRebuild(now: 0);
+ }
+
+ Assert.Equal(1, scheduler.QueuedCount);
+ }
+
+ [Fact]
+ public void Scheduler_ArmsAnInstanceThatBecomesAudible()
+ {
+ // The other half of the guard: a newly audible ambient must not stay
+ // silent until some later event.
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ scheduler.BeginRebuild();
+ AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 5f), 1u);
+ scheduler.EndRebuild(now: 0); // no contribution -> inaudible
+ Assert.Equal(0, scheduler.QueuedCount);
+
+ scheduler.BeginRebuild();
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.EndRebuild(now: 0);
+ Assert.Equal(1, scheduler.QueuedCount);
+ }
+
+ [Fact]
+ public void Scheduler_DropsAnInstanceThatWentInaudible()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ scheduler.BeginRebuild();
+ AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 5f), 1u);
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.EndRebuild(now: 0);
+
+ // Walk away: rebuild with no contribution, so the bed is inaudible when
+ // its deadline arrives.
+ scheduler.BeginRebuild();
+ scheduler.EndRebuild(now: 0);
+
+ var firings = new List();
+ scheduler.Tick(now: 99.0, firings, Vector3.Zero);
+
+ Assert.Empty(firings);
+ Assert.Equal(0, scheduler.QueuedCount);
+ Assert.False(bed.OnQueue);
+ }
+
+ [Fact]
+ public void Scheduler_ContinuousFiringHasNoPosition_IntermittentDoes()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ scheduler.BeginRebuild();
+ AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 1f), 1u);
+ AmbientSoundInstance chirp = scheduler.Track(
+ Intermittent(baseChance: 1f, minRate: 1f, maxRate: 1f), 2u);
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.Contribute(chirp, new Vector3(0f, 60f, 0f));
+ scheduler.EndRebuild(now: 0);
+
+ var firings = new List();
+ scheduler.Tick(now: 1.01, firings, Vector3.Zero);
+
+ Assert.Equal(2, firings.Count);
+ Assert.Null(firings.Find(f => f.Instance == bed).Position);
+ Assert.NotNull(firings.Find(f => f.Instance == chirp).Position);
+ }
+
+ [Fact]
+ public void Scheduler_Clear_DropsEverything()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ scheduler.BeginRebuild();
+ AmbientSoundInstance bed = scheduler.Track(Continuous(), 1u);
+ scheduler.Contribute(bed, Vector3.Zero);
+ scheduler.EndRebuild(now: 0);
+
+ scheduler.Clear();
+
+ Assert.Equal(0, scheduler.QueuedCount);
+ Assert.Empty(scheduler.Instances);
+ Assert.False(bed.OnQueue);
+ Assert.Equal(0f, scheduler.TotalSoundCount);
+ }
+
+ // ── The gatherer: the region walk and the shared denominator ───────────
+
+ private static DatReaderWriter.DBObjs.Region SyntheticRegion(
+ int ambientEntriesPerTable)
+ {
+ // terrainType 0 -> sceneType index 0 -> scene 0 -> STB 0.
+ var region = new DatReaderWriter.DBObjs.Region
+ {
+ TerrainInfo = new DatReaderWriter.Types.TerrainDesc(),
+ SceneInfo = new DatReaderWriter.Types.SceneDesc(),
+ SoundInfo = new DatReaderWriter.Types.SoundDesc(),
+ };
+
+ var terrain = new DatReaderWriter.Types.TerrainType();
+ terrain.SceneTypes.Add(0u);
+ region.TerrainInfo.TerrainTypes.Add(terrain);
+
+ var scene = new DatReaderWriter.Types.SceneType { StbIndex = 0u };
+ region.SceneInfo.SceneTypes.Add(scene);
+
+ var stb = new DatReaderWriter.Types.AmbientSTBDesc { STBId = 0x20000001u };
+ for (int i = 0; i < ambientEntriesPerTable; i++)
+ {
+ stb.AmbientSounds.Add(new DatReaderWriter.Types.AmbientSoundDesc
+ {
+ SType = (DatReaderWriter.Enums.Sound)((uint)DatReaderWriter.Enums.Sound.Ambient1 + i),
+ Volume = 1f,
+ BaseChance = 0f, // continuous
+ MinRate = 5f,
+ MaxRate = 0f,
+ });
+ }
+ region.SoundInfo.STBDesc.Add(stb);
+ return region;
+ }
+
+ /// A landblock whose every terrain word selects terrain 0 / scene 0.
+ private static ushort[] UniformTerrain() => new ushort[81];
+
+ [Fact]
+ public void Gatherer_ListenerInsideTheBlock_AccumulatesWeight()
+ {
+ // The regression this exists for: the walk must build offsets in the
+ // LANDBLOCK-LOCAL frame. Differencing absolute world coordinates against
+ // acdream's streamed-frame position puts every cell tens of kilometres
+ // away, culls all 576 of them, and the whole feature goes silent with no
+ // error anywhere.
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ var gatherer = new AmbientSoundGatherer(scheduler);
+
+ gatherer.Rebuild(
+ SyntheticRegion(ambientEntriesPerTable: 1),
+ viewerLandblockId: 0xA9B4FFFFu,
+ listenerLocalPosition: new Vector3(96f, 96f, 0f), // mid-landblock
+ landblocks: _ => UniformTerrain(),
+ now: 0);
+
+ Assert.True(scheduler.TotalSoundCount > 0f);
+ AmbientSoundInstance instance = Assert.Single(scheduler.Instances);
+ Assert.True(instance.CanHear());
+ }
+
+ [Fact]
+ public void Gatherer_TotalWeightCountsCellsOnce_NotOncePerAmbientEntry()
+ {
+ // Ambient::AddSound adds the cell's weight to the denominator ONCE and
+ // then loops the descriptors. Adding it per descriptor divides every
+ // bed's crossfade by the entry count — with three entries and a typical
+ // authored volume that lands under the 0.03 floor and goes silent.
+ var single = new AmbientSoundScheduler(new ScriptedRandom());
+ new AmbientSoundGatherer(single).Rebuild(
+ SyntheticRegion(1), 0xA9B4FFFFu, new Vector3(96f, 96f, 0f),
+ _ => UniformTerrain(), now: 0);
+
+ var triple = new AmbientSoundScheduler(new ScriptedRandom());
+ new AmbientSoundGatherer(triple).Rebuild(
+ SyntheticRegion(3), 0xA9B4FFFFu, new Vector3(96f, 96f, 0f),
+ _ => UniformTerrain(), now: 0);
+
+ // Same cells contributing in both runs => same denominator, regardless
+ // of how many ambients each table authors.
+ Assert.Equal(single.TotalSoundCount, triple.TotalSoundCount, 3);
+ Assert.Equal(3, triple.Instances.Count);
+
+ // And each of the three beds keeps a full share, not a third of one.
+ AmbientSoundInstance one = Assert.Single(single.Instances);
+ Assert.All(
+ triple.Instances,
+ bed => Assert.Equal(one.CurrentVolume, bed.CurrentVolume, 3));
+ }
+
+ [Fact]
+ public void Gatherer_MissingNeighbours_ContributeNothing()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ var gatherer = new AmbientSoundGatherer(scheduler);
+
+ gatherer.Rebuild(
+ SyntheticRegion(1),
+ 0xA9B4FFFFu,
+ new Vector3(96f, 96f, 0f),
+ // Only the viewer's own landblock is loaded.
+ id => id == 0xA9B4FFFFu ? UniformTerrain() : null,
+ now: 0);
+
+ Assert.True(scheduler.TotalSoundCount > 0f);
+ }
+
+ [Fact]
+ public void Gatherer_UnauthoredTerrain_ProducesNoAmbients()
+ {
+ var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
+ var region = SyntheticRegion(1);
+ region.SceneInfo.SceneTypes[0].StbIndex = 0xFFFFFFFFu; // retail's "none"
+
+ new AmbientSoundGatherer(scheduler).Rebuild(
+ region, 0xA9B4FFFFu, new Vector3(96f, 96f, 0f),
+ _ => UniformTerrain(), now: 0);
+
+ Assert.Empty(scheduler.Instances);
+ Assert.Equal(0f, scheduler.TotalSoundCount);
+ }
+}