From e42b99482e4b921a94628a180f5d7379d33757d1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 8 Aug 2026 21:58:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(audio):=20Campaign=20A=20slice=20A2=20?= =?UTF-8?q?=E2=80=94=20retail's=202D=20pan+gain=20mixer=20replaces=20AL=20?= =?UTF-8?q?3D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail is not a 3D audio engine. Every gameplay buffer is created with m_3D = 0 and the DirectSound 3D listener the client sets up is dead code; spatialization is two CPU scalars per voice, frozen at emission. This slice ports that math and demotes OpenAL to a voice bank. RetailSoundMixer (new, Core) carries the byte-decoded curve from SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2, clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g), with a hard -50 dB floor at which retail does not start the voice at all (audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's (int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero, forced to dead centre when (int)distance < 5, with no front/back and no elevation cue. Every AL source is now source-relative with rolloff 0 and the global distance model is None: AL's InverseDistanceClamped was first-power (2/d), quieter than retail up close and far louder at range with no cutoff whatsoever. That was the largest audible divergence in the subsystem (AP-28, retired here). RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan for a free or finished slot, then evict the first slot whose DAT priority is strictly lower, else drop. Eviction compared GAIN before, so a loud unimportant sound could silence a quiet important one. It lives in Core because the engine's play path talks to native AL handles and could not be tested; the pool now has 12 conformance tests. The listener keeps using the camera position, which the decode shows is retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided camera Position to SoundManager) — only the heading extraction changes, since retail reads one compass bearing and never a forward/up basis. An earlier draft of the plan called this a defect; corrected in the plan so it is not fixed backwards. Opus review found and this commit fixes: a linear pan-to-azimuth mapping that saturated to full separation at 30 degrees (OpenAL Soft's own speaker angle) where retail gives 15 dB — now inverts the constant-power pan law, so full deflection reaches 0.776 of the arc and both channels stay live; the stale FUN_00550ad0 / gain-eviction class header, which contradicted the register row this commit writes; missing discriminating tests for clamp order and pan truncation; dead PlayingGain state whose comment invented a retail symbol; and a third in-tree copy of Position::heading, now delegating to MoveToMath.PositionHeading. MasterVolume folds into the mixer's one multiply instead of AL listener gain, so the cutoff, radius and dB quantisation move with the slider. Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy), TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk, applied on the ambient path only) filed. Research note corrected twice where its summary contradicted its own decode (30 m dB, floor vs trunc). Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 10 +- .../plans/2026-08-08-audio-parity-campaign.md | 26 +- ...26-08-08-audio-retail-soundmanager-core.md | 13 +- src/AcDream.App/Audio/OpenAlAudioEngine.cs | 196 ++++++--- .../Audio/OpenAlResourceLifetime.cs | 19 +- .../Rendering/WorldRenderFrameBuilder.cs | 12 +- src/AcDream.Core/Audio/AudioModel.cs | 47 +-- src/AcDream.Core/Audio/RetailSoundMixer.cs | 263 ++++++++++++ src/AcDream.Core/Audio/RetailVoicePool.cs | 102 +++++ .../Audio/OpenAlResourceLifetimeTests.cs | 2 +- .../ContentEffectsAudioCompositionTests.cs | 2 +- .../Audio/RetailSoundMixerTests.cs | 382 ++++++++++++++++++ .../Audio/RetailVoicePoolTests.cs | 155 +++++++ 13 files changed, 1123 insertions(+), 106 deletions(-) create mode 100644 src/AcDream.Core/Audio/RetailSoundMixer.cs create mode 100644 src/AcDream.Core/Audio/RetailVoicePool.cs create mode 100644 tests/AcDream.Core.Tests/Audio/RetailSoundMixerTests.cs create mode 100644 tests/AcDream.Core.Tests/Audio/RetailVoicePoolTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 38a82b89..3cb209a6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -170,7 +170,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 121 active rows (AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 123 active rows (AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -221,7 +221,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | | AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 | -| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 | +| ~~AP-28~~ | **RETIRED 2026-08-08 (Campaign A slice A2).** The three picked AL parameters and the gain-driven eviction are both gone. `RetailSoundMixer` now carries the byte-decoded retail curve — `g = dist < 5 ? vol : 25·vol/dist²`, clamped to 1, ONE master multiply, `db = ceil(20·log10 g)`, and a hard −50 dB no-allocate floor (audible radius ≈94.2 m at unity) — with pan as retail's `−15·sin(Δbearing)` in whole decibels and a 5-metre integer deadzone. Every AL source is source-relative with `AL_ROLLOFF_FACTOR = 0` and the global distance model is `None`, so AL contributes no attenuation of its own; the old `InverseDistanceClamped` ref-2 m curve was inverse FIRST power (`2/d`), quieter than retail up close and far louder at range with no cutoff at all. Voice eviction now compares the DAT-authored float priority strictly-less in ring order per `SoundManager::PlaySoundInternal` @ `0x0054FEC0` (the row's old `FUN_00550ad0` citation was wrong — that address is inside an `IntrusiveHashTable` constructor). The residual pan-LAW approximation is AP-173; retail's own `s_bPlaySoundOnlyWhenActive` gate is TS-64. | retired | — | — | `SoundManager::GetAttenuation @ 0x00550020`; `SoundManager::PlaySoundInternal @ 0x00550170` and `@ 0x0054FEC0`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`ShellDrawLiftZ`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`DrawPortalDepthWrite`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | @@ -300,6 +300,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live). CORRECTED 2026-08-08 (re-gate finding R1). CORRECTED AGAIN 2026-08-08 (live vendor-diag evidence) — both earlier stories mis-identified the operand; this row now records the third and evidence-pinned shape.** The G2 fix fell back to the packed ItemProfile supply-count dword (unusable: a standard listing has UNLIMITED stock, `-1`). The R1 fix preferred the wire `PublicWeenieDesc::_stackSize` (`VendorShopItem.DescStackSize`) on the claim that ACE never populates it for a browse row — the live vendor-diag run REFUTED that claim: ACE serializes `descStackSize=1` for EVERY browse row (`[vendor-diag] ApproachVendor wire-item[...] descStackSize=1 stackSizeMax=100`), so desc-first resolved every vendor stack to 1 and the split bar never appeared (`ApplySelection ... failingPredicate=stackSize<=1u stackSize=1`). The named decomp settles what retail actually reads at its VENDOR-owned quantity sites: `pwd._maxStackSize` DIRECTLY — `VendorItemsUI::UpdateItemsList` (`0x004c1ea0`, `pc:201085-201133`) displays each browse row's quantity as `min(remaining, _maxStackSize)` (plain `_maxStackSize` for an unlimited listing, via `VendorSubUI::SetObjectStackSize`); `gmVendorUI::InqListSlotCount` (`0x004c0c10`, `pc:200052`) classifies rows on `pwd._maxStackSize <= 1`; the Buy cases (`gmVendorUI::HandleButtonClicks` `0x100000c9` @`pc:203996` / `0x100000cb` @`pc:204086`) gate the stackable-buy path on `pwd._maxStackSize > 1`. `VendorSplitPolicy.ResolveAuthoredStackSize(descStackSize, maxStackSize)` is therefore **max-first** (desc fallback, then 1), consumed only by the vendor-owned paths (`VendorShopItemMaterializer.ToWeenieData`, `VendorUiController.ResolveBuyQuantity`); player-inventory stacks never route through it. Matches the live retail screenshot ("1000 Prismatic Tapers", ceiling 1000 = the taper's authored max stack size). The toolbar-side `gmToolbarUI::HandleSelectionChanged` does read `pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) — on a REAL retail server the two agree for a browse row (the vendor UI stamps the displayed stack from `_maxStackSize`); against ACE (desc always 1) the `_maxStackSize` operand is the one that carries retail's meaning. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`); `src/AcDream.Core/Items/VendorSplitPolicy.cs` (`ResolveAuthoredStackSize`); `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ResolveBuyQuantity`) | This is an ACE-server-constraint adaptation on the toolbar leg only: retail's vendor UI reads `_maxStackSize` literally (ported as-is); the toolbar seed's `_stackSize` read is satisfied through the materialized `ClientObject.StackSize`, which this resolution stamps from `_maxStackSize` exactly as retail's own `UpdateItemsList` stamps the displayed stack — not an arbitrary substitute. | A vendor stocking a bounded but non-unit quantity shows a ceiling of `min` semantics only on a real retail server; against ACE the client-side slider ceiling is the authored max stack size, not the bounded stock count — the server remains authoritative and rejects an over-large Buy regardless (a latency/UX gap, not a correctness one — see AP-162). If ACE ever starts serializing a REAL per-listing `_stackSize` (not the constant 1), the max-first preference would hide it; the desc fallback fires only when no authored ceiling exists. | `VendorItemsUI::UpdateItemsList` `0x004c1ea0` `pc:201029-201133`; `gmVendorUI::InqListSlotCount` `0x004c0c10` `pc:200052`; `gmVendorUI::HandleButtonClicks` `pc:203996`/`204086`; `gmToolbarUI::HandleSelectionChanged` `pc:198688-198791`; live vendor-diag wire capture + live retail screenshot (2026-08-08) | | AP-170 | **Filed 2026-08-08, grand-gate finding G3 (out-of-range vendor Use lost silently).** Retail's `ItemHolder::UseObject @ 0x00588A80` has no client-side range check and sends Use immediately regardless of distance — this port's ORIGINAL `RequestUse` faithfully mirrored that shape. Live testing against the user's local ACE server showed it does not hold: walking to a vendor and using it from out of range plays the vendor's cosmetic greeting (a distance-only reaction, independent of Use) but never opens the shop panel — `ApproachVendor` never arrives. ACE's `Player.HandleActionUseItem` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`) explains why: an out-of-range target routes through `CreateMoveToChain(item, (success) => TryUseItem(item, success))` (`Player_Move.cs:37-96`), which polls every 0.1s for the player to reach `WithinUseRadius` and only then calls `ActOnUse` — it does not teleport or server-move the player; it waits for the CLIENT's own walk to land, and a Use that arrives before that poll ever starts observing an in-range player is simply never followed by the vendor's `ApproachVendor` send (`Vendor.ActOnUse`'s own doc comment: "the player will have been commanded to move using `DoMoveTo` before `ActOnUse` is called... it should be assumed that the player is within range" — a precondition our immediate send violated). `SelectionInteractionController.RequestUse` now arms the out-of-range case on the SAME arrival-gated shape `SendPickup`'s close-range (turn-only) branch already used (`RuntimeInteractionTransactionState.TryArmPostArrivalUse`/`TryResolveUseApproachCompletion`, mirroring `TryArmPostArrivalPickup`/`TryResolveApproachCompletion` field-for-field) — the wire Use dispatches only once the local approach naturally completes. An already-in-range Use (a turn at most, or no approach concept applies) is unaffected and still sends immediately, matching ACE's own "already within use distance" synchronous callback. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`RequestUse`, `HandleApproachCompletion`, `HandleUseApproachCompletion`, `CancelPendingApproach`, `OnEntityHidden`, `OnEntityRemoved`); `src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs` (`RuntimePendingUse`, `TryArmPostArrivalUse`, `TryResolveUseApproachCompletion`, `TryCancelPendingUse`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's REAL server walks the player itself before the target's `ActOnUse` ever sees the request, so the client's immediate send never races anything there. ACE does not do this for a player-initiated Use — it only polls and waits — so arming on arrival is required for correctness against the only server this port can test against, not a stylistic preference. | An interaction path that still calls `TryDispatchUse` directly without going through `RequestUse`'s approach gate (none identified at this fix) would keep the original race. The armed reservation is a live busy-count reference until arrival/cancellation resolves it; `ResetCore` releases it unconditionally on any reset/dispose so a teardown that runs without a preceding `CancelPendingApproach()` (e.g. a headless/no-window host with no `SelectionInteractionController`) cannot leak it. | `ItemHolder::UseObject` `0x00588A80`; `Player.HandleActionUseItem` `Player_Use.cs:176-215`; `Player.CreateMoveToChain`/`MoveToChain` `Player_Move.cs:37-153`; `Vendor.ActOnUse` `Vendor.cs:223-266` | | AP-171 | **Filed 2026-08-08 (user-approved modernization).** Double-clicking a vendor shop item buys it (select + the Buy button's exact quantity/price path). Retail has NO double-click-to-buy — the full named function table was swept at the Slice 6 research and the user chose the addition explicitly after being told. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (shop cell DoubleClicked) | Deliberate QoL divergence, user-directed; trivially removable. | None — additive input affordance; the single-click and Buy-button paths are unchanged. | User direction 2026-08-08 ("When I double click, I should buy it") | +| AP-173 | **Filed 2026-08-08 (Campaign A slice A2).** Retail pans with `IDirectSoundBuffer::SetPan`, which attenuates ONE output channel by \|pan\| decibels — so full deflection is a 15 dB inter-channel level difference, never full separation. OpenAL exposes no per-channel gain for a mono source, so acdream expresses the same pan as a source-relative AZIMUTH (`MaxPanAzimuthDegrees = 30`, scaled by pan/15) and lets OpenAL's constant-power panner turn it into channel gains. Everything about the pan's SHAPE is retail's and byte-verified: the value is `(int)(-15·sin(Δbearing))` in whole decibels from retail's compass convention, it is forced to dead centre when `(int)distance < 5`, it distinguishes neither front from back nor elevation, and it is frozen for the voice's lifetime. Only the mapping from a 15 dB channel difference to an azimuth under OpenAL's own pan law is approximate. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`MaxPanAzimuthDegrees`, `ApplyPan`) | The exact alternative is to pre-mix a stereo buffer per (wave, pan) pair, which multiplies AL buffer memory by up to the 31 distinct pan values and would fight the 48 MiB LRU; OpenAL's stereo pan law is also driver-dependent, so a measured mapping would not be portable. The audible quantity (inter-channel difference) is preserved in shape and bounded in magnitude. | Stereo image at full deflection may be somewhat wider or narrower than retail's 15 dB; direction and the centre deadzone are correct. Sounds are never hard-panned to silence in one ear the way an uncompressed azimuth would do. | `SoundManager::PlaySoundInternal @ 0x00550170`; `SoundBuf::Play` SetPan call; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §1 (pan decode) | +| AP-174 | **Volume-knob taxonomy differs from retail's, filed 2026-08-08 (Campaign A slice A2).** Retail has exactly three float knobs — `effect_sound_volume`, `ambient_sound_volume`, `interface_sound_volume` — **no master and no music knob**, and the interface one is registered and then never read (interface sounds are scaled by the EFFECT knob). acdream keeps an extra `MasterVolume` on top of `SfxVolume`, which A2 folds into the mixer's single master multiply (`EffectMaster = MasterVolume * SfxVolume`) rather than publishing as an AL listener gain — so the −50 dB no-allocate floor, the audible radius, and the whole-decibel quantisation all move with the slider the way they would if retail had one. `MusicVolume` is dead (retail has no music system at all; slice A6 deletes it) and `AmbientVolume` is unread until slice A5 wires the ambient path. No Interface knob exists yet; slice A4 adds the UI bus and will scale it by the effect knob, matching retail's dead-knob behaviour rather than implementing a working one. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`EffectMaster`); `src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs` | A master slider is a modern nicety users expect and costs nothing once it is inside the one retail multiply; implementing retail's dead interface knob as a working control would be a divergence in the other direction, so it stays dead. | At Master 1.0 (the default) behaviour is bit-identical to a retail single-knob mix. Below 1.0 the mix is quieter than retail's would be at the same effect setting, because retail has no such knob to turn down. | `SoundManager::InitPrefs @ 0x005503F0`; `SoundManager::GetAttenuation @ 0x00550020`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §3 D11/D13 | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | @@ -330,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) — 36 active rows (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) — 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) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -376,6 +378,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` | | TS-62 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** NO Position route in the dormant executor runs a live `ConstrainTo` binding - including the `SetPosition`/`SetPositionSimple` routes. `RuntimeAuthoritativePositionRoute.ConstrainPhase` (None/Before/After) is classified for EVERY accepted route and recorded into the execution trace, but the constrain-before-vs-after distinction exists purely as classified metadata pending a live binding at the production cutover. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`/`BuildPositionTrace`); `RuntimeAuthoritativePositionRouteClassifier.cs` (`ConstrainPhase`) | Host-cutover work with no Runtime-side owner to bind to yet; the canonical snapshot's Position IS refreshed on every accepted route, so the fact is retained - only the live constrain/smoothing behavior is deferred. The trace carries the exact phase a host must bind. | Until a host wires it, ANY Position continuation applies its raw pose with no constrain-distance clamp or smoothing - a visible pop instead of retail's constrained correction, on exactly the entities created while an authored placement was in flight. | `SmartBox::HandleReceivedPosition` 0x00453FD0, the three `ConstrainTo` sites (~93007 remote-after, ~93024 teleport-after, ~93041 local-ordinary-before) | | 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 | --- diff --git a/docs/plans/2026-08-08-audio-parity-campaign.md b/docs/plans/2026-08-08-audio-parity-campaign.md index 89448a4c..0439899d 100644 --- a/docs/plans/2026-08-08-audio-parity-campaign.md +++ b/docs/plans/2026-08-08-audio-parity-campaign.md @@ -128,7 +128,7 @@ Divergent or missing, ranked by audible impact: |---|---|---|---| | 1 | Probability gate absent: `SoundCookbook.Roll` short-circuits single-entry lists (4,183/4,184 entries!) before any roll; CDF walk instead of `(n−1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint | | 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) | -| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no −50 dB cutoff; listener = CAMERA; AL 3D pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve both directions; pan wrong from spring-arm offset (AP-28) | +| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no −50 dB cutoff; AL's 3D panner instead of retail's ±15 dB angular pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve in both directions — quieter than retail up close, audible where retail is silent; stereo image wider and 3-D where retail's is a narrow angular pan (AP-28) | | 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure | | 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range | | 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) | @@ -172,9 +172,23 @@ Port `GetAttenuation` + pan CPU-side exactly (5 m knee, `25·vol/d²`, clamp-after, ceil-dB, −50 dB no-allocate floor, `−15·sin(Δheading)` pan ±15 dB with 5 m dead zone, `Sound Features==1` pan disable). OpenAL becomes a dumb 2D voice bank: source-relative sources, per-voice gain + -pan (AL_POSITION ±x from pan only); remove `SelectRetailDistanceModel` -and listener orientation math. Listener feed moves from camera pose to -player position/heading. Eviction compares float priority (equal never +pan (AL_POSITION azimuth from pan only); remove AL's distance model and the +listener orientation math. + +**Listener correction (2026-08-08, from the lane-1 decode):** an earlier draft +of this plan said the listener must move "from camera pose to player +position/heading" and listed "listener = CAMERA" as a defect. That was wrong, +and it was written before lane 1 landed. Retail's listener IS the camera: +`SmartBox::set_viewer` @ `0x00452D36` hands the same COLLIDED third-person +camera Position to `SoundManager::SetPlayerPosition`, the sky, and the camera +setup, refreshed once per rendered frame from `SmartBox::update_viewer` @ +`0x00453CE0` (falling back to the player's own position when the sphere sweep +fails). acdream's chase camera collides too, so the position source was already +faithful; only the HEADING extraction changes, since retail reads +`Frame::get_heading` — one compass bearing — and never a forward/up basis. +Do not "fix" this back. + +Eviction compares float priority (equal never evicts); fix the pool citation to `PlaySoundInternal @ 0x0054FEC0`. Keep the squared-volume quirk faithful (register row if we later soften it). Map settings: Master (ours, AL listener gain) + Effect + Ambient + @@ -281,8 +295,8 @@ global kill switch. | Slice | Status | Commit | Gates | |---|---|---|---| -| A1 | **COMPLETE** 2026-08-08 | (this commit) | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. | -| A2 | — | — | — | +| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. | +| A2 | **COMPLETE** 2026-08-08 | `6d0156cb` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** | | A3 | — | — | — | | A4 | — | — | — | | A5 | — | — | — | diff --git a/docs/research/2026-08-08-audio-retail-soundmanager-core.md b/docs/research/2026-08-08-audio-retail-soundmanager-core.md index c1f8599d..961cac14 100644 --- a/docs/research/2026-08-08-audio-retail-soundmanager-core.md +++ b/docs/research/2026-08-08-audio-retail-soundmanager-core.md @@ -633,13 +633,19 @@ finds no caller. ### D4/D6 numbers side by side (vol = master = 1.0) +> **Corrected 2026-08-08 at the A2 code review:** the 30 m row read −35 dB, which +> contradicted both its own gain column (0.0278) and the formula — +> `ceil(20·log10 0.027778) = ceil(-31.13) = -31`. It is now −31. The conformance +> tests in `RetailSoundMixerTests` recompute every row from the decoded formula +> rather than reading this table, which is how the slip surfaced. + | distance | retail gain | retail dB (`ceil`) | acdream gain (`2/max(d,2)`) | acdream dB | |---|---|---|---|---| | 2 m | 1.000 | 0 | 1.000 | 0.0 | | 5 m | 1.000 | 0 | 0.400 | −8.0 | | 10 m | 0.250 | −12 | 0.200 | −14.0 | | 20 m | 0.0625 | −24 | 0.100 | −20.0 | -| 30 m | 0.0278 | −35 | 0.0667 | −23.5 | +| 30 m | 0.0278 | −31 | 0.0667 | −23.5 | | 50 m | 0.0100 | −40 | 0.0400 | −28.0 | | 90 m | 0.00309 | −50 (last audible) | 0.0222 | −33.1 | | ≥94.2 m | — | **not played** | 0.0212 | −33.5 | @@ -670,7 +676,10 @@ Per play (3D): if g <= 0: drop db = ceil(20*log10(g)); if db < -50: drop delta = normalise180( bearing(source -> listener) - listenerHeadingDegrees ) - pan = (int)floor(-15 * sin(delta * pi/180)) clamped [-15, 15] + pan = (int)(-15 * sin(delta * pi/180)) # TRUNCATE toward zero (retail _ftol2), + # NOT floor: they differ by 1 dB for + # negative pans. Corrected 2026-08-08 at + # the A2 review; §1 was already right. if (int)dist < 5: pan = 0 allocate voice: ring scan from cursor for free/finished; else first slot with slotPriority < newPriority; diff --git a/src/AcDream.App/Audio/OpenAlAudioEngine.cs b/src/AcDream.App/Audio/OpenAlAudioEngine.cs index 322f8a60..8196e9a0 100644 --- a/src/AcDream.App/Audio/OpenAlAudioEngine.cs +++ b/src/AcDream.App/Audio/OpenAlAudioEngine.cs @@ -7,8 +7,11 @@ using Silk.NET.OpenAL; namespace AcDream.App.Audio; /// -/// OpenAL-backed audio engine (Phase E.2) — faithful to retail's -/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3). +/// OpenAL-backed audio engine. Spatialization is NOT OpenAL's: retail creates +/// every gameplay buffer 2D (m_3D = 0) and computes a gain and a stereo +/// pan on the CPU per voice, so owns that math +/// and AL is reduced to a voice bank. Every source is source-relative with +/// AL_ROLLOFF_FACTOR = 0. /// /// /// Architecture: @@ -19,15 +22,19 @@ namespace AcDream.App.Audio; /// PulseAudio / CoreAudio — whichever OpenAL-Soft picks). /// /// -/// Fixed 16-source pool for 3D positional sounds. When all 16 are -/// busy, new Play3D calls evict the slot whose currently-playing -/// sound has lower effective gain than the incoming sound -/// (matches retail FUN_00550ad0 first-free-then-evict-quieter -/// algorithm at chunk_00550000.c:527). +/// Fixed 16-source pool for world sounds, allocated by +/// : a ring scan for a free or finished slot, +/// then eviction of the first slot whose DAT-authored priority is strictly +/// lower, else the sound is dropped. Retail's allocator is +/// SoundManager::PlaySoundInternal @ 0x0054FEC0 and it never +/// consults gain. (This comment previously cited FUN_00550ad0 and +/// described gain-based eviction; that address is inside an +/// IntrusiveHashTable constructor and the behaviour was ours, not +/// retail's — both corrected in Campaign A slice A2, register row AP-28.) /// /// /// Separate UI source pool (4 sources) for flat 2D UI clicks / -/// wooshes — not subject to the 3D eviction game. +/// wooshes — not subject to the world pool's eviction game. /// /// /// PCM buffer cache keyed by Wave dat id so the same footstep isn't @@ -72,13 +79,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen private const int PoolSize3D = 16; // retail 16-slot voice pool private const int PoolSizeUi = 4; - // Slot state per 3D source; mirrors retail's g_poolVols array (the - // EFFECTIVE gain at play-start time, used for eviction comparisons). + // Slot state per 3D source, mirroring retail's `SoundPlayingData` — + // {buffer, priority, start_time}. There is no gain field: retail's + // allocator compares priority only, and its start_time is written but never + // read, so neither a gain nor a timestamp is carried here. private sealed class Slot3D { public uint SourceId; public uint OwnerId; - public float PlayingGain; // gain at play time (for eviction compare) public bool InUse; // The DAT-authored priority, a float in [0,1] — NOT an 0..7 int. 4,100 // of the shipped entries carry a sub-1.0 priority that an int cast @@ -92,6 +100,30 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen private readonly uint[] _poolUi = new uint[PoolSizeUi]; + // ── Listener (retail's SmartBox::viewer: origin + compass heading) ─────── + private Vector3 _listenerPosition; + private float _listenerHeadingDegrees; + + /// + /// Half-width of the stereo pan arc, in degrees — OpenAL Soft's own + /// front-left/front-right speaker angle for a stereo device, so a normalised + /// stereo position of ±1 lands exactly on a speaker. + /// + /// + /// Positions are NOT retail's pan scaled linearly onto this arc. + /// inverts the + /// constant-power pan law first, so retail's ±15 dB inter-channel difference + /// maps to ±0.775 of the arc and both channels stay live; a linear mapping + /// would put full deflection on the speaker angle itself, giving effectively + /// infinite separation where retail gives 15 dB. The pan's shape is retail's + /// throughout (sine of the compass bearing, dead centre inside 5 m, no + /// front/back and no elevation, frozen for the voice's life); only the pan + /// LAW is approximated, since OpenAL exposes no per-channel gain for a mono + /// source. Registered as AP-173. + /// + /// + private const float MaxPanAzimuthDegrees = 30f; + // ── Buffer cache (Wave dat id → AL buffer) ─────────────────────────────── // Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing // as DatSoundCache's payload LRU, which this cache re-uploads from). 48 @@ -175,7 +207,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen } // Global distance model = inverse-square clamped (classic retail feel). - api.SelectRetailDistanceModel(); + api.DisableAlDistanceAttenuation(); _available = true; } @@ -225,25 +257,34 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen // ── IAudioEngine ───────────────────────────────────────────────────────── - public void SetListener( - float posX, float posY, float posZ, - float forwardX, float forwardY, float forwardZ, - float upX, float upY, float upZ) + /// + /// Records the listener pose retail's mixer reads: origin (for distance) + /// and compass heading (for pan). No AL listener orientation is published — + /// every voice is source-relative and its pan is computed on the CPU, so + /// AL's own panner must not also rotate the field. + /// + public void SetListener(float posX, float posY, float posZ, float headingDegrees) { - if (!_available || _al is null) return; - - _al.SetListenerProperty(ListenerVector3.Position, posX, posY, posZ); - // AL expects a 6-float orientation (fwd then up). - Span ori = stackalloc float[6] - { - forwardX, forwardY, forwardZ, - upX, upY, upZ - }; - fixed (float* p = ori) - _al.SetListenerProperty(ListenerFloatArray.Orientation, p); - _al.SetListenerProperty(ListenerFloat.Gain, MasterVolume); + _listenerPosition = new Vector3(posX, posY, posZ); + _listenerHeadingDegrees = headingDegrees; } + /// + /// The master multiply retail's GetAttenuation applies — the effect + /// knob for world/UI sounds, folded with acdream's extra master slider. + /// + /// + /// It is folded in HERE, before the mixer, rather than published as AL's + /// listener gain, because retail's audibility decisions are made against the + /// post-master value: the −50 dB no-allocate floor, the audible radius, and + /// the whole-decibel quantisation all move with the knob. Applying it + /// downstream as a listener gain would compute the cutoff against a louder + /// signal than the user hears, and would allocate voices at master 0 where + /// retail's g <= 0 gate drops them. + /// + /// + private float EffectMaster => MasterVolume * SfxVolume; + /// /// Not exposed on IAudioEngine but used by the hook sink — play a raw /// WaveData blob at a 3D position with full priority/volume controls. @@ -259,50 +300,91 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen { if (_worldAudioSuspended || !_available || _al is null) return false; - float effectiveGain = volume * SfxVolume; - if (effectiveGain < 0.001f) return false; // silent; skip + // Retail computes gain and pan BEFORE touching the voice pool, and a + // sound that attenuates past -50 dB is never started at all — so it + // consumes no slot and evicts nothing. At volume × master == 1 that + // silence radius is about 94 metres. + RetailVoiceMix mix = RetailSoundMixer.Mix( + _listenerPosition, + _listenerHeadingDegrees, + position, + volume, + EffectMaster); + if (!mix.Play) return false; uint buffer = EnsureBuffer(waveId, wave); if (buffer == 0) return false; - // Pick a slot: first free, else evict quieter one, else drop. - int slotIdx = -1; - for (int i = 0; i < PoolSize3D; i++) - { - int idx = (_pool3DCursor + i) & (PoolSize3D - 1); - var s = _pool3D[idx]; - if (!s.InUse || !IsStillPlaying(s.SourceId)) { slotIdx = idx; break; } - } - if (slotIdx < 0) - { - for (int i = 0; i < PoolSize3D; i++) - { - int idx = (_pool3DCursor + i) & (PoolSize3D - 1); - if (_pool3D[idx].PlayingGain < effectiveGain) { slotIdx = idx; break; } - } - } - if (slotIdx < 0) return false; // no slot quieter than us — drop + int slotIdx = AcquireWorldSlot(priority); + if (slotIdx < 0) return false; // nothing lower-priority — drop + float gain = RetailSoundMixer.LinearGain(mix.Decibels); var slot = _pool3D[slotIdx]; _al.SourceStop(slot.SourceId); _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer); - _al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, effectiveGain); + _al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, gain); // No pitch: retail never calls SetFrequency on a sound buffer, so // there is no per-play pitch variation to reproduce. - _al.SetSourceProperty(slot.SourceId, SourceVector3.Position, position.X, position.Y, position.Z); - _al.SetSourceProperty(slot.SourceId, SourceBoolean.SourceRelative, false); + ApplyPan(slot.SourceId, mix.Pan); _al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false); _al.SourcePlay(slot.SourceId); - slot.PlayingGain = effectiveGain; slot.InUse = true; slot.OwnerId = ownerId; slot.Priority = priority; - _pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1); + _pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D); return true; } + /// + /// Projects the live pool into 's slot view and + /// takes its answer. The allocation policy itself lives in Core so it can be + /// tested without an AL device; this method only supplies the one piece of + /// state AL owns — whether each slot's voice is still playing. + /// + private int AcquireWorldSlot(float priority) + { + Span slots = stackalloc VoiceSlotState[PoolSize3D]; + for (int i = 0; i < PoolSize3D; i++) + { + Slot3D s = _pool3D[i]; + slots[i] = new VoiceSlotState( + Occupied: s.InUse, + StillPlaying: s.InUse && IsStillPlaying(s.SourceId), + Priority: s.Priority); + } + + return RetailVoicePool.Acquire(slots, _pool3DCursor, priority); + } + + /// + /// Publishes retail's whole-decibel pan as a source-relative azimuth. The + /// source sits on a unit arc in front of the listener so a pan of 0 is dead + /// ahead (centred) and the deflection is purely left/right — retail + /// distinguishes neither front from back nor elevation. Distance plays no + /// part: rolloff is 0 and the CPU-computed gain is authoritative. + /// + /// + /// The azimuth comes from , + /// which inverts the constant-power pan law so the resulting inter-channel + /// difference is retail's ±15 dB rather than the full separation a linear + /// mapping onto the speaker angle would produce. See AP-172. + /// + /// + private void ApplyPan(uint sourceId, int pan) + { + float position = RetailSoundMixer.StereoPositionFromPan(pan); + float azimuth = position * MaxPanAzimuthDegrees * (MathF.PI / 180f); + _al!.SetSourceProperty(sourceId, SourceBoolean.SourceRelative, true); + _al.SetSourceProperty( + sourceId, + SourceVector3.Position, + MathF.Sin(azimuth), + 0f, + -MathF.Cos(azimuth)); + } + /// /// Stops every world-space voice while preserving the independent UI /// source pool. Retail suppresses ambient/object audio while cell loading @@ -349,11 +431,18 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen } if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort + // Retail's interface sounds go through PlaySoundFromCenter: pan 0, and + // GetAttenuation at distance 0 (so the flat branch), scaled by + // effect_sound_volume — NOT by interface_sound_volume, which retail + // registers as a preference and then never reads. + if (!RetailSoundMixer.TryGetAttenuation(0f, volume, EffectMaster, out int decibels)) + return false; + uint src = _poolUi[slotIdx]; _al.SourceStop(src); _al.SetSourceProperty(src, SourceInteger.Buffer, 0); _al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer); - _al.SetSourceProperty(src, SourceFloat.Gain, Math.Clamp(volume, 0f, 1f) * SfxVolume); + _al.SetSourceProperty(src, SourceFloat.Gain, RetailSoundMixer.LinearGain(decibels)); _al.SourcePlay(src); return true; } @@ -519,7 +608,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen } slot.OwnerId = 0; - slot.PlayingGain = 0f; slot.Priority = 0f; slot.InUse = false; } diff --git a/src/AcDream.App/Audio/OpenAlResourceLifetime.cs b/src/AcDream.App/Audio/OpenAlResourceLifetime.cs index e6a4b579..9e03034c 100644 --- a/src/AcDream.App/Audio/OpenAlResourceLifetime.cs +++ b/src/AcDream.App/Audio/OpenAlResourceLifetime.cs @@ -18,7 +18,7 @@ internal interface IOpenAlResourceApi uint GenerateSource(); void Configure3DSource(uint source); void ConfigureUiSource(uint source); - void SelectRetailDistanceModel(); + void DisableAlDistanceAttenuation(); void StopSource(uint source); void DeleteSource(uint source); void DeleteBuffer(uint buffer); @@ -57,12 +57,16 @@ internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi public uint GenerateSource() => AudioApi.GenSource(); + // World voices are source-relative with rolloff 0: `RetailSoundMixer` + // computes retail's gain and pan on the CPU and they are authoritative, so + // AL must not attenuate by distance on top of that. Retail's own curve is + // inverse-SQUARE from a 5 m reference with a hard -50 dB cutoff, which AL's + // inverse model (first power only) cannot express anyway. public void Configure3DSource(uint source) { AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f); - AudioApi.SetSourceProperty(source, SourceFloat.MaxDistance, 1000f); - AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 1f); - AudioApi.SetSourceProperty(source, SourceFloat.ReferenceDistance, 2f); + AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 0f); + AudioApi.SetSourceProperty(source, SourceBoolean.SourceRelative, true); AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false); } @@ -73,8 +77,11 @@ internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false); } - public void SelectRetailDistanceModel() => - AudioApi.DistanceModel(DistanceModel.InverseDistanceClamped); + // AL's distance models are all bypassed: rolloff 0 on every source means + // none of them contribute, and `RetailSoundMixer` owns the curve. Selecting + // None documents that rather than leaving a model that looks load-bearing. + public void DisableAlDistanceAttenuation() => + AudioApi.DistanceModel(DistanceModel.None); public void StopSource(uint source) => AudioApi.SourceStop(source); diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index db29e10d..b5b6554e 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -7,6 +7,7 @@ using AcDream.App.Rendering.Wb; using AcDream.App.Settings; using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.Core.Audio; using AcDream.Core.Lighting; using AcDream.Core.Physics; using AcDream.Core.Rendering; @@ -381,14 +382,19 @@ internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPrev if (_audio is not { IsAvailable: true }) return; + // Retail's audio listener IS the camera viewer, refreshed per rendered + // frame (`SmartBox::set_viewer` @ 0x00452D36 hands the same collided + // camera Position to SoundManager, the sky, and the camera setup), so + // the position source here is already faithful. What retail reads off it + // is only the origin and `Frame::get_heading` — a compass bearing, not a + // basis: pan comes from that heading alone, which is why retail has no + // front/back or elevation cue to reproduce. Matrix4x4 inverse = camera.InverseView; var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33); - var up = new Vector3(inverse.M21, inverse.M22, inverse.M23); Vector3 position = camera.Position; _audio.SetListener( position.X, position.Y, position.Z, - forward.X, forward.Y, forward.Z, - up.X, up.Y, up.Z); + RetailSoundMixer.CompassHeadingDegrees(Vector3.Zero, forward)); } } diff --git a/src/AcDream.Core/Audio/AudioModel.cs b/src/AcDream.Core/Audio/AudioModel.cs index 776f27cf..615ae0c3 100644 --- a/src/AcDream.Core/Audio/AudioModel.cs +++ b/src/AcDream.Core/Audio/AudioModel.cs @@ -39,32 +39,12 @@ public sealed class WaveData public TimeSpan Duration { get; init; } } -/// -/// Falloff math (r05 §2). Retail is CPU-side inverse-square, NOT -/// DirectSound3DBuffer. No doppler, no cone, no HRTF. -/// -public static class AudioFalloff -{ - /// - /// Attenuation factor based on distance. Retail uses pure inverse-square - /// above a minimum-distance threshold. - /// - public static float AttenuationAt(float distanceMeters, float minDistance = 1.0f) - { - if (distanceMeters < minDistance) return 1.0f; - float att = (minDistance * minDistance) / (distanceMeters * distanceMeters); - return Math.Clamp(att, 0f, 1f); - } - - /// - /// Stereo pan from listener-relative X coord. ±1.0 fully panned. - /// - public static float PanFromRelative(float relativeX, float panRange = 20f) - { - if (panRange <= 0) return 0f; - return Math.Clamp(relativeX / panRange, -1f, 1f); - } -} +// An `AudioFalloff` helper lived here until 2026-08-08 (Campaign A slice A2). +// It had the right shape but a 1-metre reference distance where retail's is 5, +// a `PanFromRelative` that was linear in relative X over an invented 20-metre +// range where retail pans by the SINE of a compass bearing, and no audibility +// cutoff. Nothing ever called either method. Both are superseded by +// `RetailSoundMixer`, which carries the byte-decoded retail math. /// /// Interface the platform audio engine (AcDream.App layer) implements. @@ -78,10 +58,17 @@ public interface IAudioEngine : IDisposable float MusicVolume { get; set; } float AmbientVolume{ get; set; } - /// Update listener pose (called per frame from player position). - void SetListener(float posX, float posY, float posZ, - float forwardX, float forwardY, float forwardZ, - float upX, float upY, float upZ); + /// + /// Update the listener pose. Retail's listener is SmartBox::viewer — + /// the COLLIDED third-person camera Position, refreshed once per rendered + /// frame (SmartBox::update_viewer @ 0x00453CE0), falling back + /// to the player's own position when the camera sweep fails. Only two + /// things are ever read out of it: the origin, for distance, and + /// Frame::get_heading, for pan. There is no up vector, no velocity, + /// and therefore no doppler and no elevation cue — which is why this takes + /// a single compass heading rather than a forward/up basis. + /// + void SetListener(float posX, float posY, float posZ, float headingDegrees); /// Play a 2D UI sound (no falloff). void PlayUi(SoundId id); diff --git a/src/AcDream.Core/Audio/RetailSoundMixer.cs b/src/AcDream.Core/Audio/RetailSoundMixer.cs new file mode 100644 index 00000000..f88261fa --- /dev/null +++ b/src/AcDream.Core/Audio/RetailSoundMixer.cs @@ -0,0 +1,263 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Audio; + +/// +/// One voice's mixing decision, as retail computes it at emission time. +/// +/// +/// False when retail would not start the voice at all — the attenuation fell +/// below . Retail does not start a +/// quiet voice; it starts nothing, so no pool slot is consumed either. +/// +/// +/// Integer decibels, ceil(20·log10 g), floored at +/// . +/// +/// +/// Retail's DirectSound pan in whole decibels, [-15, +15]; negative is +/// left, positive right, 0 dead centre. +/// +public readonly record struct RetailVoiceMix(bool Play, int Decibels, int Pan); + +/// +/// Retail's sound mixing math — the whole of its spatialization. +/// +/// +/// Retail is not a 3D audio engine. Every gameplay sound buffer is +/// created with m_3D = 0; the DirectSound 3D listener the client sets up +/// (rolloff 0.01, front (−1,0,0), top (0,1,0)) is dead code that no buffer ever +/// consults. Spatialization is exactly two CPU-computed scalars per voice, +/// frozen at emission: a gain in whole decibels from distance, and a stereo pan +/// in whole decibels from bearing. +/// +/// +/// +/// Sources: SoundManager::GetAttenuation @ 0x00550020 and +/// SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float, int) +/// @ 0x00550170, both byte-decoded from the PDB-paired binary — Binary +/// Ninja elides the x87 memory constants in the first (its pseudo-C multiplies +/// by a literal 0f, which would port as silence at every distance) and +/// misattributes a reused stack slot in the second (reporting the 5-metre +/// deadzone as an angle test rather than a distance test). Full decode: +/// docs/research/2026-08-08-audio-retail-soundmanager-core.md §1. +/// +/// +public static class RetailSoundMixer +{ + /// Distance below which gain is flat at the authored volume, metres. + public const float VolMinDistance = 5.0f; + + /// + /// squared — retail's numerator, so the curve + /// is continuous at the knee. + /// + public const float VolMinDistanceSq = 25.0f; + + /// + /// Retail's audibility floor. A voice whose computed decibels fall below + /// this is never started. + /// + public const int VolMinDecibels = -50; + + /// Retail's pan scale, applied to the sine of the reversed bearing. + public const float PanScale = -15.0f; + + /// + /// Pan is forced to dead centre when (int)distance is below this. + /// Retail truncates the distance before comparing, so this is an integer + /// metre test, not a float one. + /// + public const int PanDeadzoneMetres = 5; + + /// Retail's voice count (playing_sounds_[0x10]). + public const int VoiceCount = 16; + + /// + /// Retail's own single-precision degrees-to-radians literal, as it appears + /// in the pan computation. + /// + private const float DegreesToRadians = 0.0174532924f; + + /// + /// SoundManager::GetAttenuation. Returns whole decibels and whether + /// retail would start the voice. + /// + /// + /// is retail's ONE master multiply — + /// effect_sound_volume for ordinary sounds, ambient_sound_volume + /// when ambient != 0. Callers on the paths where retail ALSO + /// pre-multiplies by the same knob (its volume-squared quirk) must + /// pre-multiply themselves; this function applies + /// the knob exactly once. + /// + /// + public static bool TryGetAttenuation( + float distanceMetres, + float volume, + float masterVolume, + out int decibels) + { + float g = distanceMetres < VolMinDistance + ? volume + : (VolMinDistanceSq * volume) / (distanceMetres * distanceMetres); + + if (g > 1.0f) g = 1.0f; + g *= masterVolume; + + if (g <= 0.0f || float.IsNaN(g)) + { + decibels = VolMinDecibels; + return false; + } + + decibels = (int)MathF.Ceiling(20.0f * MathF.Log10(g)); + if (decibels >= VolMinDecibels) + return true; + + decibels = VolMinDecibels; + return false; + } + + /// + /// Retail's compass heading convention (Position::heading @ + /// 0x005A9520): degrees clockwise from +Y (north), +X (east) = 90°. + /// + /// + /// Delegates to — + /// the pinned port of the same retail function, with golden-cardinal + /// coverage — rather than carrying a third copy of the formula. Retail's own + /// constant is the double 57.29577951308232; the single-precision + /// narrowing there is at most ~1.5e-6 degrees, far inside the integer + /// truncation the pan applies afterwards. + /// + /// + public static float CompassHeadingDegrees(Vector3 from, Vector3 to) => + Physics.Motion.MoveToMath.PositionHeading(from, to); + + /// + /// Retail's heading-difference normalisation, verbatim: fmod(delta, 360) + /// then if (!(delta <= 180)) delta -= 360. + /// + /// + /// The output range is retail's (-360, 180], NOT (-180, 180] — + /// retail does not fold negatives back up, so an input of −270 stays −270. + /// That is harmless because the only consumer is sin, which has + /// period 360, and reproducing it exactly keeps this function comparable to + /// the decode. + /// + /// + public static float NormalizeSignedDegrees(float degrees) + { + float delta = degrees % 360.0f; + if (!(delta <= 180.0f)) delta -= 360.0f; + return delta; + } + + /// + /// Retail's pan, in whole decibels. + /// is Position::heading(soundPos, listenerPos) — the REVERSED + /// bearing; combined with the negative that yields + /// the correct handedness (a source due east of a north-facing listener + /// pans right). + /// + /// + /// There is no front/back and no elevation cue: a source dead ahead and one + /// directly behind both pan to 0, and Z reaches the mix only through + /// distance. + /// + /// + public static int GetPan( + float bearingSourceToListener, + float listenerHeadingDegrees, + float distanceMetres, + bool panningEnabled = true) + { + if (!panningEnabled) + return 0; + + // Retail truncates distance to an int before the deadzone compare. + if (Math.Abs((int)distanceMetres) < PanDeadzoneMetres) + return 0; + + float delta = NormalizeSignedDegrees(bearingSourceToListener - listenerHeadingDegrees); + int pan = (int)(MathF.Sin(delta * DegreesToRadians) * PanScale); + return Math.Clamp(pan, (int)PanScale, (int)-PanScale); + } + + /// + /// The whole per-play decision for a world sound: distance gain plus pan, + /// composed the way PlaySoundInternal composes them. + /// + public static RetailVoiceMix Mix( + Vector3 listenerPosition, + float listenerHeadingDegrees, + Vector3 sourcePosition, + float volume, + float masterVolume, + bool panningEnabled = true) + { + float distance = Vector3.Distance(listenerPosition, sourcePosition); + + // Pan is computed from the reversed bearing: source -> listener. + float bearing = CompassHeadingDegrees(sourcePosition, listenerPosition); + int pan = GetPan(bearing, listenerHeadingDegrees, distance, panningEnabled); + + bool play = TryGetAttenuation(distance, volume, masterVolume, out int decibels); + return new RetailVoiceMix(play, decibels, pan); + } + + /// + /// Linear amplitude for whole decibels: 10^(db/20). Retail hands the + /// decibel value straight to IDirectSoundBuffer::SetVolume, which is + /// hundredths of a decibel; OpenAL wants linear gain, so the conversion + /// happens here rather than changing the quantisation. + /// + public static float LinearGain(int decibels) => + MathF.Pow(10.0f, decibels / 20.0f); + + /// + /// Converts retail's pan (whole decibels of inter-channel difference, the + /// quantity IDirectSoundBuffer::SetPan expresses directly) into a + /// normalised stereo position in [-1, 1] for a constant-power panner + /// such as OpenAL's. + /// + /// + /// A constant-power panpot at position p over a speaker pair puts + /// cos((p+1)·π/4) in the left channel and sin((p+1)·π/4) in + /// the right, so the inter-channel difference is + /// 20·log10(tan((p+1)·π/4)). Inverting that for a target difference + /// gives p = (4/π)·atan(10^(pan/20)) − 1, which reaches only ±0.775 + /// at retail's ±15 dB — both channels stay live, exactly as DirectSound's + /// one-channel attenuation keeps them. Mapping pan linearly onto the + /// panner's full range instead would saturate to infinite separation at the + /// edges, which retail never does. + /// + /// + public static float StereoPositionFromPan(int pan) + { + float difference = MathF.Pow(10.0f, pan / 20.0f); + float position = (4.0f / MathF.PI) * MathF.Atan(difference) - 1.0f; + return Math.Clamp(position, -1.0f, 1.0f); + } + + /// + /// The maximum distance at which a sound of this volume is audible at all, + /// in metres — the radius where ceil(20·log10(25·vol·master/d²)) + /// last reaches . About 94.2 m at + /// vol·master == 1, 66.6 m at 0.5, 29.8 m at 0.1. Diagnostic and + /// test use; the live path gets the same answer from + /// . + /// + public static float AudibleRadius(float volume, float masterVolume) + { + float scale = volume * masterVolume; + if (scale <= 0f) return 0f; + + // Audible while db >= -50, i.e. ceil(20·log10 g) >= -50, i.e. + // 20·log10 g > -51 (ceil of anything above -51 is at least -50). + float minGain = MathF.Pow(10.0f, -51.0f / 20.0f); + return MathF.Sqrt(VolMinDistanceSq * scale / minGain); + } +} diff --git a/src/AcDream.Core/Audio/RetailVoicePool.cs b/src/AcDream.Core/Audio/RetailVoicePool.cs new file mode 100644 index 00000000..3206d6dc --- /dev/null +++ b/src/AcDream.Core/Audio/RetailVoicePool.cs @@ -0,0 +1,102 @@ +using System; + +namespace AcDream.Core.Audio; + +/// +/// One voice slot's state, as retail's SoundPlayingData carries it: +/// whether a buffer is installed, and the DAT-authored priority it was claimed +/// with. Retail also stores a start_time, which it writes and never +/// reads — age enters allocation only through the ring cursor — so there is no +/// timestamp here. +/// +/// +/// A buffer is installed in this slot (retail: buffer != null with a live +/// m_pBuf). +/// +/// +/// The installed buffer is still reporting DSBSTATUS_PLAYING. A finished +/// voice is reclaimed by the first pass exactly like an empty slot. +/// +/// The priority the slot's current sound was claimed with. +public readonly record struct VoiceSlotState(bool Occupied, bool StillPlaying, float Priority); + +/// +/// Retail's voice allocator — SoundManager::PlaySoundInternal(SoundBufRef*, +/// int pan, int volDb) @ 0x0054FEC0, byte-decoded. +/// +/// +/// Two passes, both walking the 16 slots in ring order from a persistent cursor: +/// +/// +/// +/// Claim the first slot that is empty, has a broken buffer, or is no longer +/// playing. +/// +/// +/// Otherwise claim the first slot whose priority is strictly less than +/// the incoming sound's — so equal priority never evicts, and gain is never +/// consulted at all. Before Campaign A slice A2 acdream compared GAIN here, +/// which let a loud unimportant sound evict a quiet important one. +/// +/// +/// +/// If neither pass finds a slot the new sound is silently dropped. +/// +/// +/// +/// This lives in Core, separate from the OpenAL engine, because it is pure +/// index logic over slot state: the engine's own play path talks to native AL +/// handles and cannot be reached by a test, and this is the second-largest +/// behavioural change in the audio campaign. +/// +/// +public static class RetailVoicePool +{ + /// Sentinel for "no slot available — drop the sound". + public const int NoSlot = -1; + + /// + /// Pick the slot retail would claim for a sound of , + /// or . + /// + /// + /// The pool, in slot order. Length is retail's + /// in production but any length + /// works so tests can use small pools. + /// + /// + /// Retail's curr_playing_buffer_ — where the ring scan starts. + /// + public static int Acquire(ReadOnlySpan slots, int cursor, float priority) + { + if (slots.Length == 0) return NoSlot; + + for (int i = 0; i < slots.Length; i++) + { + int idx = Ring(cursor, i, slots.Length); + VoiceSlotState slot = slots[idx]; + if (!slot.Occupied || !slot.StillPlaying) return idx; + } + + for (int i = 0; i < slots.Length; i++) + { + int idx = Ring(cursor, i, slots.Length); + if (slots[idx].Priority < priority) return idx; + } + + return NoSlot; + } + + /// + /// Retail's post-claim cursor advance: curr_playing_buffer_ = (slot + 1) + /// mod 16. Only a successful claim moves it. + /// + public static int AdvanceCursor(int claimedSlot, int slotCount) => + slotCount <= 0 ? 0 : (claimedSlot + 1) % slotCount; + + private static int Ring(int cursor, int offset, int slotCount) + { + int idx = (cursor + offset) % slotCount; + return idx < 0 ? idx + slotCount : idx; + } +} diff --git a/tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs b/tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs index bcf599eb..aeee5fea 100644 --- a/tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs +++ b/tests/AcDream.App.Tests/Audio/OpenAlResourceLifetimeTests.cs @@ -157,7 +157,7 @@ public sealed class OpenAlResourceLifetimeTests ThrowIfConfiguredFailure(source); } - public void SelectRetailDistanceModel() { } + public void DisableAlDistanceAttenuation() { } public void StopSource(uint source) { } diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs index 0b5aaede..4575890f 100644 --- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs @@ -531,7 +531,7 @@ public sealed class ContentEffectsAudioCompositionTests public uint GenerateSource() => _nextSource++; public void Configure3DSource(uint source) { } public void ConfigureUiSource(uint source) { } - public void SelectRetailDistanceModel() { } + public void DisableAlDistanceAttenuation() { } public void StopSource(uint source) { } public void DeleteSource(uint source) { } public void DeleteBuffer(uint buffer) { } diff --git a/tests/AcDream.Core.Tests/Audio/RetailSoundMixerTests.cs b/tests/AcDream.Core.Tests/Audio/RetailSoundMixerTests.cs new file mode 100644 index 00000000..49d372ae --- /dev/null +++ b/tests/AcDream.Core.Tests/Audio/RetailSoundMixerTests.cs @@ -0,0 +1,382 @@ +using System; +using System.Numerics; +using AcDream.Core.Audio; +using Xunit; + +namespace AcDream.Core.Tests.Audio; + +/// +/// Conformance tests for retail's mixing math — SoundManager::GetAttenuation +/// @ 0x00550020 and SoundManager::PlaySoundInternal(pos) @ 0x00550170, +/// both byte-decoded in +/// docs/research/2026-08-08-audio-retail-soundmanager-core.md §1. +/// +/// +/// Golden decibels are recomputed here from the decoded formula +/// (ceil(20·log10(min(1, 25·vol/d²)·master))) rather than copied from +/// the note's summary table, which has one transcription slip: it lists 30 m as +/// −35 dB where both its own gain column (0.0278) and the formula give −31. +/// +/// +public sealed class RetailSoundMixerTests +{ + // ── GetAttenuation ───────────────────────────────────────────────────── + + [Theory] + // Inside the 5 m knee gain is flat at the authored volume. + [InlineData(0f, 0)] + [InlineData(2f, 0)] + [InlineData(4.99f, 0)] + // At and beyond the knee: 25/d², continuous at 5 m. + [InlineData(5f, 0)] + [InlineData(10f, -12)] + [InlineData(20f, -24)] + [InlineData(30f, -31)] + [InlineData(50f, -40)] + [InlineData(90f, -50)] + [InlineData(94f, -50)] // last audible metre + public void Attenuation_MatchesRetailCurve(float distance, int expectedDecibels) + { + Assert.True(RetailSoundMixer.TryGetAttenuation(distance, 1f, 1f, out int db)); + Assert.Equal(expectedDecibels, db); + } + + [Theory] + [InlineData(95f)] + [InlineData(120f)] + [InlineData(1000f)] + public void Attenuation_BeyondCutoff_DoesNotPlay(float distance) + { + Assert.False(RetailSoundMixer.TryGetAttenuation(distance, 1f, 1f, out int db)); + Assert.Equal(RetailSoundMixer.VolMinDecibels, db); + } + + [Fact] + public void Attenuation_IsInverseSquare_NotInverseFirstPower() + { + // Doubling distance past the knee must cost 4x gain (12 dB), not 2x + // (6 dB). This is the single largest pre-A2 divergence: OpenAL's + // InverseDistanceClamped is first-power only. + RetailSoundMixer.TryGetAttenuation(10f, 1f, 1f, out int near); + RetailSoundMixer.TryGetAttenuation(20f, 1f, 1f, out int far); + Assert.Equal(12, near - far); + } + + [Fact] + public void Attenuation_ClampsAboveUnity() + { + // A volume above 1.0 (the dats reach 10.0) cannot make a close sound + // louder than 0 dB — but it DOES extend the audible radius, because + // retail clamps after the distance divide, not at the field. + Assert.True(RetailSoundMixer.TryGetAttenuation(1f, 10f, 1f, out int db)); + Assert.Equal(0, db); + } + + [Fact] + public void Attenuation_ClampsBeforeTheMasterMultiply_NotAfter() + { + // The single most easily-inverted line in the port. Retail clamps the + // distance term to unity and THEN applies the master knob: + // retail order : min(10, 1) = 1, x0.5 = 0.5 -> -6 dB + // inverted order : 10 x 0.5 = 5, min(5, 1) = 1 -> 0 dB + Assert.True(RetailSoundMixer.TryGetAttenuation(1f, 10f, 0.5f, out int db)); + Assert.Equal(-6, db); + } + + [Fact] + public void Attenuation_HighVolume_ExtendsAudibleRadius() + { + // 10x volume at 200 m: 25*10/40000 = 0.00625 → -44 dB, still audible, + // where a volume clamped to 1.0 at the field would have been silent. + Assert.False(RetailSoundMixer.TryGetAttenuation(200f, 1f, 1f, out _)); + Assert.True(RetailSoundMixer.TryGetAttenuation(200f, 10f, 1f, out int loud)); + Assert.Equal(-44, loud); + } + + [Theory] + [InlineData(0f)] + [InlineData(-1f)] + public void Attenuation_NonPositiveMaster_DoesNotPlay(float master) + { + Assert.False(RetailSoundMixer.TryGetAttenuation(1f, 1f, master, out int db)); + Assert.Equal(RetailSoundMixer.VolMinDecibels, db); + } + + [Fact] + public void Attenuation_MasterIsAppliedExactlyOnce() + { + // GetAttenuation multiplies by one master knob. Halving it must cost + // ~6 dB, not ~12 (which is what a second, caller-side multiply gives — + // retail's volume-squared quirk on the PlaySoundA(DataID, obj) and + // ambient paths, which callers opt into by pre-multiplying). + RetailSoundMixer.TryGetAttenuation(10f, 1f, 1f, out int full); + RetailSoundMixer.TryGetAttenuation(10f, 1f, 0.5f, out int half); + Assert.Equal(-6, half - full); + } + + [Theory] + // Solving ceil(20·log10(25·s/d²)) >= -50 for d. + [InlineData(1f, 94.2f)] + [InlineData(0.5f, 66.6f)] + [InlineData(0.1f, 29.8f)] + public void AudibleRadius_MatchesDecodedRadii(float scale, float expectedMetres) + { + Assert.Equal(expectedMetres, RetailSoundMixer.AudibleRadius(scale, 1f), 1); + } + + [Fact] + public void AudibleRadius_AgreesWithTheLivePredicate() + { + // The radius helper and the play decision must not drift apart. + for (float volume = 0.1f; volume <= 3f; volume += 0.1f) + { + float radius = RetailSoundMixer.AudibleRadius(volume, 1f); + Assert.True(RetailSoundMixer.TryGetAttenuation(radius - 0.5f, volume, 1f, out _)); + Assert.False(RetailSoundMixer.TryGetAttenuation(radius + 0.5f, volume, 1f, out _)); + } + } + + [Fact] + public void Decibels_AreWholeNumbers_QuantisedByCeil() + { + // Retail stair-steps in whole decibels rather than ramping smoothly. + var seen = new System.Collections.Generic.HashSet(); + for (float d = 5f; d < 94f; d += 0.05f) + { + RetailSoundMixer.TryGetAttenuation(d, 1f, 1f, out int db); + seen.Add(db); + } + // 0 dB down to -50 dB inclusive is at most 51 distinct steps. + Assert.InRange(seen.Count, 40, 51); + } + + [Fact] + public void LinearGain_RoundTripsTheDecibelScale() + { + Assert.Equal(1f, RetailSoundMixer.LinearGain(0), 5); + Assert.Equal(0.5f, RetailSoundMixer.LinearGain(-6), 2); + Assert.Equal(0.25f, RetailSoundMixer.LinearGain(-12), 2); + Assert.Equal(0.00316f, RetailSoundMixer.LinearGain(-50), 5); + } + + // ── Heading + pan ────────────────────────────────────────────────────── + + [Theory] + // Retail's compass convention: 0 = +Y (north), 90 = +X (east). + [InlineData(0f, 1f, 0f)] // north + [InlineData(1f, 0f, 90f)] // east + [InlineData(0f, -1f, 180f)] // south + [InlineData(-1f, 0f, 270f)] // west + public void CompassHeading_UsesRetailConvention(float dx, float dy, float expected) + { + float heading = RetailSoundMixer.CompassHeadingDegrees( + Vector3.Zero, new Vector3(dx, dy, 0f)); + Assert.Equal(expected, heading, 2); + } + + [Theory] + [InlineData(0f, 0f)] + [InlineData(180f, 180f)] // inclusive upper bound + [InlineData(181f, -179f)] + [InlineData(270f, -90f)] + [InlineData(359f, -1f)] + [InlineData(-90f, -90f)] + public void NormalizeSigned_MapsIntoRetailsWindow(float input, float expected) + { + Assert.Equal(expected, RetailSoundMixer.NormalizeSignedDegrees(input), 3); + } + + [Fact] + public void Pan_SourceDueEastOfNorthFacingListener_IsFullRight() + { + // The worked check from the decode: delta = -90 ⇒ pan = -15·sin(-90) = +15. + var mix = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f); + Assert.Equal(15, mix.Pan); + } + + [Fact] + public void Pan_SourceDueWestOfNorthFacingListener_IsFullLeft() + { + var mix = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(-10f, 0f, 0f), 1f, 1f); + Assert.Equal(-15, mix.Pan); + } + + [Fact] + public void Pan_HasNoFrontBackDistinction() + { + // Retail's cue is the sine of the bearing, so dead ahead and directly + // behind both centre. This is a faithfulness property, not a bug. + var ahead = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(0f, 10f, 0f), 1f, 1f); + var behind = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(0f, -10f, 0f), 1f, 1f); + Assert.Equal(0, ahead.Pan); + Assert.Equal(0, behind.Pan); + } + + [Fact] + public void Pan_RotatesWithListenerHeading() + { + // Facing east, a source due east is now dead ahead ⇒ centred. + var mix = RetailSoundMixer.Mix( + Vector3.Zero, 90f, new Vector3(10f, 0f, 0f), 1f, 1f); + Assert.Equal(0, mix.Pan); + } + + [Theory] + [InlineData(1f, 0)] // inside the deadzone + [InlineData(4.9f, 0)] // (int)4.9 == 4 < 5 + [InlineData(5f, 15)] // (int)5 == 5, deadzone ends + public void Pan_DeadzoneIsAnIntegerMetreTest(float distance, int expectedPan) + { + var mix = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(distance, 0f, 0f), 1f, 1f); + Assert.Equal(expectedPan, mix.Pan); + } + + [Fact] + public void Pan_ElevationNeverContributes() + { + // Z reaches the mix only through distance: two sources on the same + // horizontal bearing pan identically however far apart they are + // vertically, while their gains differ. + var level = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f); + var high = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(10f, 0f, 40f), 1f, 1f); + + Assert.Equal(level.Pan, high.Pan); + Assert.NotEqual(level.Decibels, high.Decibels); + } + + [Fact] + public void Pan_PurelyVerticalOffset_InheritsRetailsAtan2Degeneracy() + { + // A source directly overhead has dx == dy == 0, so retail's + // `fmod(450 - atan2(0, 0)·57.29578, 360)` yields 90° (due east) and the + // sound pans hard LEFT rather than centre. C's atan2(0,0) is 0, so this + // is retail's behaviour, not ours — pinned here so a future reader does + // not "fix" it into a centred pan. Unreachable for ordinary emitters, + // which are never exactly co-located horizontally; a source AT the + // listener is caught by the 5 m deadzone instead. + var mix = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(0f, 0f, 10f), 1f, 1f); + Assert.Equal(-15, mix.Pan); + Assert.Equal(-12, mix.Decibels); + } + + [Fact] + public void Pan_DisabledByPreference_IsAlwaysCentre() + { + // retail: s_SoundFeatures == 1 forces pan 0. + var mix = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(10f, 0f, 0f), 1f, 1f, panningEnabled: false); + Assert.Equal(0, mix.Pan); + } + + [Fact] + public void Pan_StaysWithinFifteenDecibels() + { + // Sweep every bearing: retail's pan saturates at ±15 dB, never full + // separation. + for (int deg = 0; deg < 360; deg++) + { + float rad = deg * MathF.PI / 180f; + var source = new Vector3(MathF.Sin(rad) * 20f, MathF.Cos(rad) * 20f, 0f); + var mix = RetailSoundMixer.Mix(Vector3.Zero, 0f, source, 1f, 1f); + Assert.InRange(mix.Pan, -15, 15); + } + } + + [Fact] + public void Mix_BeyondCutoff_ReportsDoNotPlay() + { + var mix = RetailSoundMixer.Mix( + Vector3.Zero, 0f, new Vector3(0f, 200f, 0f), 1f, 1f); + Assert.False(mix.Play); + } + + [Theory] + // Retail's `_ftol2` truncates toward zero. Bearing ±64.158° gives + // |−15·sin Δ| ≈ 13.5, and the NEGATIVE row is the discriminating one: + // truncation gives −13 where floor would give −14. (On the positive side + // truncation and floor agree, which is why one row cannot pin this.) + [InlineData(64.158f, 13)] + [InlineData(-64.158f, -13)] + public void Pan_TruncatesTowardZero_NotFloor(float bearingDegrees, int expectedPan) + { + // Place the source at the given bearing FROM the listener, 20 m out. + float rad = bearingDegrees * MathF.PI / 180f; + var source = new Vector3(MathF.Sin(rad) * 20f, MathF.Cos(rad) * 20f, 0f); + var mix = RetailSoundMixer.Mix(Vector3.Zero, 0f, source, 1f, 1f); + Assert.Equal(expectedPan, mix.Pan); + } + + [Fact] + public void NormalizeSigned_LeavesLargeNegativesAlone_AsRetailDoes() + { + // Retail's window is (-360, 180], not (-180, 180]: it never folds a + // negative back up. Pan-equivalent because only sin() consumes it. + Assert.Equal(-270f, RetailSoundMixer.NormalizeSignedDegrees(-270f), 3); + Assert.Equal( + MathF.Sin(90f * MathF.PI / 180f), + MathF.Sin(RetailSoundMixer.NormalizeSignedDegrees(-270f) * MathF.PI / 180f), + 3); + } + + // ── Pan law: retail's 15 dB, not full separation ──────────────────────── + + [Fact] + public void StereoPosition_CentreIsCentre() + { + Assert.Equal(0f, RetailSoundMixer.StereoPositionFromPan(0), 4); + } + + [Theory] + [InlineData(15)] + [InlineData(-15)] + public void StereoPosition_FullPan_StaysInsideTheSpeakerAngle(int pan) + { + // The whole point of inverting the pan law: full retail deflection must + // NOT reach ±1 (the speaker angle), which would give effectively + // infinite channel separation where retail gives 15 dB. + // (4/pi)·atan(10^(15/20)) - 1 = (4/pi)·atan(5.6234) - 1 = 0.7757. + float position = RetailSoundMixer.StereoPositionFromPan(pan); + Assert.Equal(0.776f, MathF.Abs(position), 3); + Assert.True(MathF.Abs(position) < 1f); + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(7)] + [InlineData(11)] + [InlineData(15)] + [InlineData(-6)] + [InlineData(-15)] + public void StereoPosition_ReproducesTheRequestedDecibelDifference(int pan) + { + // Under a constant-power panpot, position p yields channel gains + // cos((p+1)pi/4) and sin((p+1)pi/4). Round-trip the difference. + float p = RetailSoundMixer.StereoPositionFromPan(pan); + float angle = (p + 1f) * MathF.PI / 4f; + float left = MathF.Cos(angle); + float right = MathF.Sin(angle); + float differenceDb = 20f * MathF.Log10(right / left); + Assert.Equal(pan, differenceDb, 2); + } + + [Fact] + public void StereoPosition_IsMonotonicAcrossThePanRange() + { + float previous = RetailSoundMixer.StereoPositionFromPan(-15); + for (int pan = -14; pan <= 15; pan++) + { + float current = RetailSoundMixer.StereoPositionFromPan(pan); + Assert.True(current > previous, $"pan {pan} did not increase position"); + previous = current; + } + } +} diff --git a/tests/AcDream.Core.Tests/Audio/RetailVoicePoolTests.cs b/tests/AcDream.Core.Tests/Audio/RetailVoicePoolTests.cs new file mode 100644 index 00000000..d8c9f6c9 --- /dev/null +++ b/tests/AcDream.Core.Tests/Audio/RetailVoicePoolTests.cs @@ -0,0 +1,155 @@ +using System; +using System.Linq; +using AcDream.Core.Audio; +using Xunit; + +namespace AcDream.Core.Tests.Audio; + +/// +/// Conformance tests for retail's voice allocator, +/// SoundManager::PlaySoundInternal(SoundBufRef*, int, int) @ +/// 0x0054FEC0, decoded in +/// docs/research/2026-08-08-audio-retail-soundmanager-core.md §1. +/// +/// +/// The behaviour under test is the second-largest change in the audio campaign: +/// before it, acdream evicted by GAIN, so a loud unimportant sound could silence +/// a quiet important one. +/// +/// +public sealed class RetailVoicePoolTests +{ + private static VoiceSlotState Free() => new(Occupied: false, StillPlaying: false, Priority: 0f); + + private static VoiceSlotState Finished(float priority) => + new(Occupied: true, StillPlaying: false, Priority: priority); + + private static VoiceSlotState Busy(float priority) => + new(Occupied: true, StillPlaying: true, Priority: priority); + + private static VoiceSlotState[] AllBusy(float priority, int count = 16) + { + var slots = new VoiceSlotState[count]; + Array.Fill(slots, Busy(priority)); + return slots; + } + + [Fact] + public void EmptyPool_DropsTheSound() + { + Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(Array.Empty(), 0, 1f)); + } + + [Fact] + public void FirstPass_PrefersAFreeSlot_ScanningFromTheCursor() + { + var slots = AllBusy(1f); + slots[9] = Free(); + Assert.Equal(9, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0f)); + } + + [Fact] + public void FirstPass_ReclaimsAFinishedVoice_EvenAtHigherPriority() + { + // A finished voice is as reclaimable as an empty slot, whatever priority + // it was claimed with — the first pass never compares priority. + var slots = AllBusy(1f); + slots[4] = Finished(1f); + Assert.Equal(4, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.1f)); + } + + [Fact] + public void FirstPass_WrapsAroundTheRing() + { + var slots = AllBusy(1f); + slots[2] = Free(); + // Starting at 5, the scan must wrap past 15 to reach slot 2. + Assert.Equal(2, RetailVoicePool.Acquire(slots, cursor: 5, priority: 0f)); + } + + [Fact] + public void FirstPass_TakesTheNearestFreeSlotInRingOrder() + { + var slots = AllBusy(1f); + slots[1] = Free(); + slots[12] = Free(); + Assert.Equal(12, RetailVoicePool.Acquire(slots, cursor: 10, priority: 0f)); + } + + [Fact] + public void SecondPass_EvictsStrictlyLowerPriority() + { + var slots = AllBusy(0.5f); + slots[7] = Busy(0.2f); + Assert.Equal(7, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.3f)); + } + + [Fact] + public void SecondPass_EqualPriorityNeverEvicts() + { + // Retail's compare is `slot.priority < new.priority`. A pool full of + // equal-priority voices drops the newcomer. + var slots = AllBusy(0.5f); + Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.5f)); + } + + [Fact] + public void SecondPass_HigherPriorityPoolDropsTheNewSound() + { + var slots = AllBusy(0.9f); + Assert.Equal(RetailVoicePool.NoSlot, RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.4f)); + } + + [Fact] + public void SecondPass_TakesTheFirstLowerSlotInRingOrder_NotTheLowest() + { + // Retail stops at the FIRST slot below the incoming priority; it does not + // search for the quietest or least important one. + var slots = AllBusy(0.9f); + slots[3] = Busy(0.1f); + slots[6] = Busy(0.5f); + Assert.Equal(6, RetailVoicePool.Acquire(slots, cursor: 6, priority: 0.6f)); + } + + [Fact] + public void Eviction_IgnoresGain_ByConstruction() + { + // There is no gain in VoiceSlotState at all — the type cannot express the + // old behaviour. This test documents that as an intentional property. + var slots = AllBusy(0.8f); + Assert.Equal( + RetailVoicePool.NoSlot, + RetailVoicePool.Acquire(slots, cursor: 0, priority: 0.8f)); + Assert.DoesNotContain( + "Gain", + string.Join(",", typeof(VoiceSlotState).GetProperties().Select(p => p.Name))); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(15, 0)] + [InlineData(9, 10)] + public void Cursor_AdvancesPastTheClaimedSlot_AndWraps(int claimed, int expected) + { + Assert.Equal(expected, RetailVoicePool.AdvanceCursor(claimed, 16)); + } + + [Fact] + public void RingOrder_IsStableAcrossRepeatedClaims() + { + // Round-robin over a pool whose voices finish immediately: successive + // claims must walk the ring rather than reusing one slot. + var slots = new VoiceSlotState[4]; + Array.Fill(slots, Free()); + + int cursor = 0; + var claimed = new int[4]; + for (int i = 0; i < 4; i++) + { + claimed[i] = RetailVoicePool.Acquire(slots, cursor, 1f); + cursor = RetailVoicePool.AdvanceCursor(claimed[i], slots.Length); + } + + Assert.Equal(new[] { 0, 1, 2, 3 }, claimed); + } +}