docs: Campaign A (audio parity) — six-lane retail decode + campaign plan
Full review of the audio subsystem against the named 2013 retail decomp, with byte-verification of every load-bearing float compare (five BN polarity/constant elisions caught). Headlines: retail is a CPU-side 2D pan+gain engine (no 3D listener in use); the SoundTable probability field is a Bernoulli SILENCE gate our SoundCookbook never applies (4,183/4,184 entries are single-entry and we short-circuit them); 0xF750 server sounds are entirely unhandled; ambients are region-authored weighted one-shots (indoors silent by design); and retail EoR has NO music system at all. Plan proposes slices A1-A6; awaiting user go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6bb4cfa795
commit
ffa5087527
7 changed files with 4472 additions and 0 deletions
289
docs/plans/2026-08-08-audio-parity-campaign.md
Normal file
289
docs/plans/2026-08-08-audio-parity-campaign.md
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
# Campaign A — Audio retail-feel parity
|
||||||
|
|
||||||
|
**Status: PROPOSED 2026-08-08 — awaiting user go.** Research phase complete;
|
||||||
|
six-lane named-retail decode done, all load-bearing claims byte-verified
|
||||||
|
against the PDB-paired 2013 binary (BN pseudo-C alone was NOT sufficient —
|
||||||
|
see "BN traps" below).
|
||||||
|
|
||||||
|
**Goal:** the client sounds like retail. Every divergence between our audio
|
||||||
|
runtime and the 2013 EoR client is either fixed to the retail mechanism or
|
||||||
|
recorded in the divergence register with a reason.
|
||||||
|
|
||||||
|
**Research base (read the lane note before implementing its slice):**
|
||||||
|
|
||||||
|
| Lane | Note | Owns |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `docs/research/2026-08-08-audio-retail-soundmanager-core.md` | SoundManager, falloff, pan, voice pool, prefs |
|
||||||
|
| 2 | `docs/research/2026-08-08-audio-retail-ambient-runtime.md` | AmbientSound/ConstantSound/IntermitSound runtime |
|
||||||
|
| 3 | `docs/research/2026-08-08-audio-retail-ambient-authoring.md` | Region-file authoring chain, dat coverage |
|
||||||
|
| 4 | `docs/research/2026-08-08-audio-retail-dat-layer.md` | SoundTable/Wave formats, selection model, dat census |
|
||||||
|
| 5 | `docs/research/2026-08-08-audio-retail-server-sounds.md` | 0xF750 wire path, play_sound, trigger catalog |
|
||||||
|
| 6 | `docs/research/2026-08-08-audio-retail-music-absence.md` | Music (there is none), MediaMachine, AdminEnvirons |
|
||||||
|
|
||||||
|
The older `docs/research/deepdives/r05-audio-sound.md` is SUPERSEDED where it
|
||||||
|
conflicts with the lane notes (its §5.1 falloff, §6 music, and §7 ambient
|
||||||
|
sections are wrong — Ghidra-era `FUN_xxx` reads that the named decomp + byte
|
||||||
|
decode overturned). Slice A6 adds the banner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What retail's audio engine actually is (one page)
|
||||||
|
|
||||||
|
Retail is a **2D pan+gain engine**, not 3D audio. Every gameplay buffer is
|
||||||
|
created with `m_3D = 0`; the DirectSound 3D listener the client sets up is
|
||||||
|
dead code. Spatialization is CPU-side per voice at play time:
|
||||||
|
|
||||||
|
- **Gain** (`SoundManager::GetAttenuation @ 0x00550AD0` region; byte-decoded):
|
||||||
|
`g = dist < 5m ? vol : 25·vol/dist²`, clamped to 1.0, multiplied by ONE
|
||||||
|
master knob (`effect_sound_volume` or `ambient_sound_volume`), then
|
||||||
|
`db = ceil(20·log10 g)` with a hard floor at −50 dB — below the floor the
|
||||||
|
voice is **not allocated at all**. Audible radius ≈ 94 m at vol 1.0.
|
||||||
|
- **Pan**: `pan_dB = −15·sin(Δheading listener→source)`, saturated at
|
||||||
|
±15 dB, forced to 0 inside 5 m. No front/back, no elevation.
|
||||||
|
- **Listener** = the player physics object's position/heading
|
||||||
|
(`SoundManager::SetPlayerPosition`), NOT the camera.
|
||||||
|
- **Voice pool**: allocator is `SoundManager::PlaySoundInternal @ 0x0054FEC0`.
|
||||||
|
Eviction compares the DAT-authored **float priority** (0..1); equal
|
||||||
|
priority never evicts. (`FUN_00550AD0` cited in our code is a hash-table
|
||||||
|
constructor — wrong symbol.)
|
||||||
|
- **No loops, no pitch**: retail never sets the DSound loop flag and never
|
||||||
|
calls `SetFrequency`. "Looping" ambients are re-fired one-shots.
|
||||||
|
- **Variant selection** (`SoundTableData::Lookup` + play sites): pick
|
||||||
|
`idx = (int)(roll01 · (n−1))` — uniform over all but the LAST entry
|
||||||
|
(a genuine Turbine off-by-one; the last variant is unreachable and a
|
||||||
|
faithful port keeps that) — then a SEPARATE Bernoulli gate
|
||||||
|
`rand()/32767 < probability`, else **silence**. Probability is a gate,
|
||||||
|
not a weight.
|
||||||
|
- **Volume field** is unbounded gain (dats go up to 10.0); retail clamps
|
||||||
|
only AFTER the distance divide, so >1 volumes extend audible range.
|
||||||
|
- **Prefs** (`InitPrefs @ 0x005503F0`, 8 keys): three float volumes
|
||||||
|
(effect / ambient / interface — interface is registered but **never
|
||||||
|
read**), three enable bools, `Sound Features` (==1 disables pan),
|
||||||
|
`Play Sound Only When Active`. There is NO music knob.
|
||||||
|
- **Quirk (faithful-port decision)**: effect and ambient volumes are each
|
||||||
|
applied twice (once at the play site, again inside GetAttenuation) — the
|
||||||
|
sliders are effectively **squared**.
|
||||||
|
|
||||||
|
**Sound triggers, exhaustively** (lane 5): (1) animation hooks
|
||||||
|
(SoundHook/SoundTableHook/SoundTweakedHook) — footsteps, combat swooshes,
|
||||||
|
all authored in MotionTables; (2) the server `Sound` event **0xF750**
|
||||||
|
(`guid, SoundType, vol`) — hits, wounds, wield, pickup, locks, lifestone,
|
||||||
|
spell resists; retail queues an event for a not-yet-known guid and replays
|
||||||
|
it on CreateObject, and plays at the WIRE volume, ignoring the table
|
||||||
|
entry's volume (the hook path does the opposite); (3) PhysicsScripts
|
||||||
|
(0xF754/5 — already live in acdream); (4) UI sounds via the ClientUISystem
|
||||||
|
sound table loaded by `DBObj::GetByEnum(0x22, slot 7)`; (5) region-authored
|
||||||
|
ambients (below). `CPhysicsObj::play_sound` has exactly ONE caller — the
|
||||||
|
0xF750 handler. There are NO client-local collision/jump/water sound call
|
||||||
|
sites; inventing one is a divergence.
|
||||||
|
|
||||||
|
**Ambients** (lanes 2+3): authored entirely in `region.dat`
|
||||||
|
(`Region.SoundInfo` → `AmbientSTBDesc[]` referenced by scene types ← terrain
|
||||||
|
types). On every **objcell change** (24 m), `CellManager::ChangePosition`
|
||||||
|
rebuilds weights by walking the **3×3 landblock ring × 64 land cells each**,
|
||||||
|
decoding each cell's terrain word to (terrainType, sceneIdx) and
|
||||||
|
accumulating per-sound inverse-square weight (1.0 inside 20 m, `(20/d)²` to
|
||||||
|
120 m, 0 beyond) plus an 8-way bearing histogram. Playback is a min-heap of
|
||||||
|
absolute deadlines ticked from the frame loop; each pop plays a one-shot
|
||||||
|
and re-arms. `base_chance == 0` ⇒ **ConstantSound**: non-positional,
|
||||||
|
volume = its weight share of total (a real terrain crossfade), re-fires
|
||||||
|
every `min_rate` s. Non-zero ⇒ **IntermitSound**: authored fixed volume,
|
||||||
|
positioned at a random accumulated bearing ±11.25° at distance
|
||||||
|
`min + (max−min)·t²`, gated by `roll ≤ base_chance`, interval
|
||||||
|
`RollDice(min_rate, max_rate)`. **Indoors is silent by design** —
|
||||||
|
`CEnvCell::add_ambient_sounds` is an empty folded `ret`; EnvCell has no
|
||||||
|
sound data. No day/night/weather selection exists.
|
||||||
|
|
||||||
|
**Music does not exist** (lane 6): the linked winmm MIDI player has zero
|
||||||
|
callers (`midiPlay` = 4 textual occurrences: definition + its own queue
|
||||||
|
drainer; verified independently), "music" appears 0 times in the 65 MB
|
||||||
|
decomp, no SoundType music member, no music pref, no music files shipped.
|
||||||
|
`MediaMachine` is a UI-state media bytecode VM whose `Update_Sound` routes
|
||||||
|
LayoutDesc-authored waves/table rows to the interface bus.
|
||||||
|
|
||||||
|
### BN traps (binding on every slice — reread before porting)
|
||||||
|
|
||||||
|
Binary Ninja renders x87 memory-operand compares as unimplemented `bool p`
|
||||||
|
and elides the constants; **five** sites would have ported with inverted
|
||||||
|
polarity or zeroed math: `is_continuous`, both `CanHear`s, `PlayNow`,
|
||||||
|
`PlayProbability`, plus `GetAttenuation` printing `* 0f`. Byte-decode the
|
||||||
|
PDB-paired binary (`reference_pe_byte_decode.md` workflow) for ANY float
|
||||||
|
compare or constant in this subsystem. The lane notes contain the verified
|
||||||
|
values; if a needed constant is not in a note, decode it — do not trust
|
||||||
|
the pseudo-C rendering and do not guess.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where acdream is today
|
||||||
|
|
||||||
|
Working and retail-correct-in-shape: the animation-hook trigger path
|
||||||
|
(`AudioHookSink`, correctly the only client-local trigger), SoundTable/Wave
|
||||||
|
dat parsing (byte-exact vs retail), `SoundId` enum (golden-conformance
|
||||||
|
tested), entity→SoundTable resolution (Setup-then-wire precedence),
|
||||||
|
world-audio quiescence across portal transitions, AL buffer budget/lifetime.
|
||||||
|
|
||||||
|
Divergent or missing, ranked by audible impact:
|
||||||
|
|
||||||
|
| # | Defect | Where | Symptom |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 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) |
|
||||||
|
| 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) |
|
||||||
|
| 7 | UI sound bank absent; AdminEnvirons stingers logged not played; portal enter/exit cues missing | — | TS-54, AP-115 |
|
||||||
|
| 8 | `PlayMusic`/`StopMusic`/`MusicVolume` model retail code that never runs | `IAudioEngine`, settings | Dead API + misleading settings knob |
|
||||||
|
| 9 | Dead code: `AudioFalloff` (wrong constants, unused), wrong `FUN_00550ad0` citation, invented `PitchMin/PitchMax`+`Loop`+`Is3D` fields on `SoundEntry` | `AudioModel.cs`, engine header | Traps for future readers |
|
||||||
|
|
||||||
|
Register rows in scope: **AP-28** (retire at A2), **AP-115** sound half
|
||||||
|
(retire at A4), **TS-29** (retire at A5/A6), **TS-54** (retire at A4),
|
||||||
|
**TS-9** (re-scope at A6 — dat census says exactly 1 of 786 waves is MP3).
|
||||||
|
Issue **#321** (sound-cache decode-dedup race) folds into A6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slices
|
||||||
|
|
||||||
|
Ordering is audible-value per effort; A1–A2 are the "it sounds wrong"
|
||||||
|
fixes, A3–A4 the "it's silent where retail speaks" fixes, A5 the big new
|
||||||
|
system, A6 the cleanup. Each slice: grep-named → (byte-decode if any new
|
||||||
|
constant) → pseudocode check against lane note → port → conformance tests →
|
||||||
|
build/test green → commit; user listening gates where marked.
|
||||||
|
|
||||||
|
### A1 — Selection-model correctness (small; biggest audible fix)
|
||||||
|
|
||||||
|
Replace `SoundCookbook.Roll` with retail's exact model: uniform
|
||||||
|
`idx = (int)(roll01 · (n−1))` (preserving the last-entry-unreachable
|
||||||
|
off-by-one), then the separate Bernoulli probability gate returning
|
||||||
|
"silence" — including for single-entry lists. Priority stays float [0,1]
|
||||||
|
end-to-end (`SoundEntry.Priority`, engine slots). Volume passes through
|
||||||
|
unclamped; clamp moves to post-attenuation (staged here, consumed by A2).
|
||||||
|
Delete the invented `PitchMin/PitchMax/Loop/Is3D` fields. Rewrite
|
||||||
|
`SoundCookbookTests` against golden values from the lane-4 note's decoded
|
||||||
|
tables; add a distribution test for the gate.
|
||||||
|
|
||||||
|
Acceptance: conformance tests green; connected sanity — creature idle
|
||||||
|
chatter audibly rare (Speak1 ≈ 5% per trigger, was 100%).
|
||||||
|
|
||||||
|
### A2 — Falloff/pan/listener/voice parity (retires AP-28)
|
||||||
|
|
||||||
|
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
|
||||||
|
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 +
|
||||||
|
Interface mirroring retail's knobs; note interface is read by no retail
|
||||||
|
path (we wire it to the UI bus anyway — divergence row, deliberate).
|
||||||
|
|
||||||
|
Acceptance: unit tests on gain/pan tables (golden distances from lane 1
|
||||||
|
note); **user listening gate** — side-by-side with retail: walk away from
|
||||||
|
a blacksmith's hammering, confirm matching fade-out distance (~94 m) and
|
||||||
|
pan behavior.
|
||||||
|
|
||||||
|
### A3 — Server sound path (0xF750)
|
||||||
|
|
||||||
|
Parse `Sound` (guid, SoundType u32, volume f32) in the message router;
|
||||||
|
route to a new `ServerSoundController`: resolve guid → entity; unknown
|
||||||
|
guid ⇒ queue the event and replay on CreateObject (retail
|
||||||
|
`CObjectMaint` behavior); known guid without SoundTable ⇒ silent drop;
|
||||||
|
play via the SoundTable at the **wire volume** (ignore table volume —
|
||||||
|
asymmetric with the hook path, byte-verified). Position at the entity's
|
||||||
|
current origin.
|
||||||
|
|
||||||
|
Acceptance: wire-format conformance test (three-oracle layout);
|
||||||
|
connected gate — melee hits, item pickup/drop, lifestone bind audibly
|
||||||
|
fire against ACE.
|
||||||
|
|
||||||
|
### A4 — UI + interface sounds (retires TS-54, AP-115's sound half)
|
||||||
|
|
||||||
|
Load the ClientUISystem sound table (`GetByEnum` cache 0x22, enum slot 7 —
|
||||||
|
resolve the actual DID at port time from `ClientUISystem::GetUISoundTable`).
|
||||||
|
Route `PlayUi(SoundId)` through it (delete the no-op). Wire:
|
||||||
|
AdminEnvirons 0x65..0x7C → `PlaySoundFromCenter` stingers
|
||||||
|
(`WorldEnvironmentController.ApplyAdminEnvirons` already parses them);
|
||||||
|
portal enter/exit `UI_EnterPortal`/`UI_ExitPortal`; button/panel cues where
|
||||||
|
the retained UI already has command seams; `MediaDescSound` support in
|
||||||
|
`LayoutImporter` (DatReaderWriter parses it; interface bus, per lane 6).
|
||||||
|
|
||||||
|
Acceptance: connected gate — `@environs` thunder/drums audible; portal
|
||||||
|
enter/exit cues audible on recall; **user listening gate** vs retail.
|
||||||
|
|
||||||
|
### A5 — Region ambient system (retires TS-29's ambient half)
|
||||||
|
|
||||||
|
New `AmbientSoundSystem` (App layer, owned like other world controllers):
|
||||||
|
rebuild on objcell change (reuse streaming's cell-transit signal), walk
|
||||||
|
the 3×3 ring × 64 cells via the SAME terrain-word decode the scenery
|
||||||
|
pipeline uses (`SceneryGenerator`-shared helper), accumulate weight
|
||||||
|
(1.0 ≤ 20 m, `(20/d)²` ≤ 120 m) + 8-way bearing, build
|
||||||
|
Constant/Intermit instances from `AmbientSTBDesc` (`base_chance == 0` ⇒
|
||||||
|
constant — the byte-verified polarity), min-heap of absolute deadlines
|
||||||
|
ticked per frame, one-shots through the ambient volume path (squared,
|
||||||
|
faithful). ConstantSound non-positional; IntermitSound positioned at
|
||||||
|
random accumulated bearing, `min + (max−min)·t²` distance. Teardown on
|
||||||
|
world transition via the existing quiescence edge. Indoors: NO ambients
|
||||||
|
(retail-faithful); `seen_outside` cells get the outdoor set. Delete
|
||||||
|
`StartAmbient`/`StopAmbient` from `IAudioEngine` (wrong shape — looping
|
||||||
|
handle API models a mechanism retail doesn't have).
|
||||||
|
|
||||||
|
Acceptance: unit tests on weight accumulation + scheduler with a synthetic
|
||||||
|
region; **user listening gate** — Holtburg outdoors vs retail side-by-side
|
||||||
|
(birdsong/wind character and rough cadence), dungeon silence, ambient
|
||||||
|
crossfade walking shore → grass.
|
||||||
|
|
||||||
|
### A6 — Deletions, bookkeeping, and the long tail
|
||||||
|
|
||||||
|
- Delete `PlayMusic`/`StopMusic`/`MusicVolume` and the `AudioSettings.Music`
|
||||||
|
knob (settings migration: drop the field, tolerate old json). Retail has
|
||||||
|
no music system; register row NOT needed once the API is gone (nothing
|
||||||
|
diverges — absence matches retail).
|
||||||
|
- Delete dead `AudioFalloff` (superseded by A2's ported math).
|
||||||
|
- r05 doc: SUPERSEDED banner pointing at the six lane notes; corrections
|
||||||
|
list from lane notes §12/§13.
|
||||||
|
- TS-9 re-scope: 1 MP3 wave in the shipped dats (`0x0A000393`, ~2 s) —
|
||||||
|
either a ~50-line managed MP3 decode for one asset or an accepted-loss
|
||||||
|
row with the census cited. ADPCM count to be measured the same way
|
||||||
|
before deciding.
|
||||||
|
- #321: make `DatSoundCache` decode-dedup safe under concurrent access
|
||||||
|
(single-flight per wave id) — the full-suite flake.
|
||||||
|
- Register sweep: retire AP-28/TS-29/TS-54 rows in their landing slices'
|
||||||
|
commits (rule 1); add rows for: interface-volume wired (A2), any
|
||||||
|
softened quirk, and anything discovered mid-campaign.
|
||||||
|
|
||||||
|
Acceptance: build/test green, register diff reviewed, no orphaned
|
||||||
|
API/settings references.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (explicitly)
|
||||||
|
|
||||||
|
- Client-local physics sounds (collision/jump/water) — retail has none;
|
||||||
|
the server sends them. Do not invent.
|
||||||
|
- Indoor ambient beds — retail is silent indoors.
|
||||||
|
- A music system — retail has none. (If we ever WANT music, that's a
|
||||||
|
new-feature decision for the user, not parity work.)
|
||||||
|
- HRTF/doppler/reverb — no retail counterpart.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Each slice is one commit (A6 possibly two); rollback is `git revert
|
||||||
|
<slice-sha>`, recorded in this doc's ledger as slices land. A2 and A5 are
|
||||||
|
the only slices touching frame-loop code paths; both are behind the
|
||||||
|
existing audio-availability guard, so `ACDREAM_NO_AUDIO=1` remains the
|
||||||
|
global kill switch.
|
||||||
|
|
||||||
|
## Ledger
|
||||||
|
|
||||||
|
| Slice | Status | Commit | Gates |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | — | — | — |
|
||||||
|
| A2 | — | — | — |
|
||||||
|
| A3 | — | — | — |
|
||||||
|
| A4 | — | — | — |
|
||||||
|
| A5 | — | — | — |
|
||||||
|
| A6 | — | — | — |
|
||||||
739
docs/research/2026-08-08-audio-retail-ambient-authoring.md
Normal file
739
docs/research/2026-08-08-audio-retail-ambient-authoring.md
Normal file
|
|
@ -0,0 +1,739 @@
|
||||||
|
# Retail ambient sounds — the authoring / DAT data path (Lane 3)
|
||||||
|
|
||||||
|
Research-only note. Oracles, in the order the project's rules require:
|
||||||
|
|
||||||
|
1. `docs/research/named-retail/acclient_2013_pseudo_c.txt` (PDB-named BN pseudo-C,
|
||||||
|
Sept 2013 EoR build) + `acclient.h` (verbatim retail structs) + `symbols.json`.
|
||||||
|
2. Raw byte decode of `C:\Users\erikn\Downloads\acclient.exe` (the PDB-paired
|
||||||
|
v11.4186 binary) for every place BN elided or inverted an x87 comparison.
|
||||||
|
Method per `claude-memory/reference_pe_byte_decode.md`.
|
||||||
|
3. `references/DatReaderWriter/` (production dat reader) and
|
||||||
|
`references/ACViewer/ACE/Source/ACE.DatLoader/` as the independent 2nd/3rd
|
||||||
|
parser cross-check.
|
||||||
|
|
||||||
|
Runtime tick (`Ambient::UseTime`, `Play`, `PlaySoundA`, the play queue,
|
||||||
|
`IntermitSound::GetSoundPos`) is a sibling lane's scope. This note owns
|
||||||
|
**where the data comes from** and stops at the point a sound instance exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR
|
||||||
|
|
||||||
|
* Ambient sound authoring lives **entirely in the region file** (`0x13xxxxxx`,
|
||||||
|
`DB_TYPE_REGION`). There is no separate "ambient table" dat range.
|
||||||
|
* `AmbientSTBDesc.stb_id` is a **`SoundTable` DID in `0x20000000–0x2000FFFF`**
|
||||||
|
(`DB_TYPE_STABLE`). "STB" = Sound TaBle. `0x22` in the retail code is the
|
||||||
|
**DBObj cache-type index**, not a dat-id prefix.
|
||||||
|
* Outdoor selection is per-**land cell** (8×8 per landblock) off the terrain
|
||||||
|
word: `terrainType = (w >> 2) & 0x1F`, `sceneOrdinal = w >> 11`. Two index
|
||||||
|
hops (terrain type → scene type → STB desc) land on the STB descriptor.
|
||||||
|
* **Indoor / EnvCell ambients do not exist as authored data.** `CEnvCell::add_ambient_sounds`
|
||||||
|
is present in the PDB but ICF-folded onto a bare `ret` — an empty stub in the
|
||||||
|
2013 build. The EnvCell dat has no sound field at all.
|
||||||
|
* Rebuild happens **once per cell change** in `CellManager::ChangePosition`, and
|
||||||
|
only for landblocks in the **3×3 ring around the viewer block**.
|
||||||
|
* Two BN pseudo-C readings in this area are **wrong** and byte-verified corrected
|
||||||
|
below: the `is_continuous` derivation and `Ambient::CalcWeight`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The complete data chain
|
||||||
|
|
||||||
|
```
|
||||||
|
Region DBObj (DID 0x13000000 + regionNumber, DB_TYPE_REGION)
|
||||||
|
│ loaded by CRegionDesc::SetRegion(regionNumber) @ 0x004FE8F0
|
||||||
|
│ → DBObj::GetByEnum(regionNumber, type=0x0B, cache=0x1C)
|
||||||
|
│ → stored in the global CRegionDesc::current_region (data @ 0x0084146C)
|
||||||
|
│
|
||||||
|
├── sound_info : CSoundDesc
|
||||||
|
│ └── stb_desc : AmbientSTBDesc[] ← the authored ambient sound sets
|
||||||
|
│ ├── stb_id : DID → SoundTable (0x20000000–0x2000FFFF)
|
||||||
|
│ └── ambient_sounds : AmbientSoundDesc[]
|
||||||
|
│ { stype, volume, base_chance, min_rate, max_rate }
|
||||||
|
│
|
||||||
|
├── scene_info : CSceneDesc
|
||||||
|
│ └── scene_types : CSceneType[]
|
||||||
|
│ ├── <stbIndex> (u32, 0xFFFFFFFF = none) → &sound_info.stb_desc[i]
|
||||||
|
│ └── scenes : DID[] (0x12xxxxxx Scene objects, procedural
|
||||||
|
│ scenery — same record, different consumer)
|
||||||
|
│
|
||||||
|
└── terrain_info : CTerrainDesc
|
||||||
|
└── terrain_types : CTerrainType[] (indexed by the terrain word's type)
|
||||||
|
├── terrain_name, terrain_color
|
||||||
|
└── scene_types : u32[] (0xFFFFFFFF = none)
|
||||||
|
→ &scene_info.scene_types[idx]
|
||||||
|
```
|
||||||
|
|
||||||
|
Retail resolves the two index fields into **pointers at unpack time** inside
|
||||||
|
`CRegionDesc::UnPack` (@ 0x004FF440), so at runtime `CSceneType::sound_table_desc`
|
||||||
|
is a direct pointer into the shared `CSoundDesc::stb_desc` array. Consequence
|
||||||
|
worth porting deliberately: **`AmbientSTBDesc` instances are shared**, so their
|
||||||
|
`sound_table` cache and `play_count` are per-region-entry, not per-cell.
|
||||||
|
|
||||||
|
Resolution code, verbatim shape (`CRegionDesc::UnPack`, scene section @ 0x004FF713):
|
||||||
|
|
||||||
|
```c
|
||||||
|
for (i = 0; i < numSceneTypes; ++i) {
|
||||||
|
CSceneType* st = new CSceneType();
|
||||||
|
stbIdx = read_u32(); // read by the CALLER
|
||||||
|
st->sound_table_desc = (stbIdx != 0xFFFFFFFF)
|
||||||
|
? sound_info->stb_desc.m_data[stbIdx]
|
||||||
|
: NULL;
|
||||||
|
CSceneType::unpack(st, &buf, &len); // numScenes + scene DIDs
|
||||||
|
CSceneDesc::Add(scene_info, st);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and the terrain section (@ 0x004FF8AE):
|
||||||
|
|
||||||
|
```c
|
||||||
|
sceneTypeIdx = read_u32();
|
||||||
|
terrainType->scene_types[n] = (sceneTypeIdx != 0xFFFFFFFF)
|
||||||
|
? scene_info->scene_types.m_data[sceneTypeIdx]
|
||||||
|
: NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the asymmetry that trips up a naive port: **`CSceneType::pack`/`unpack`
|
||||||
|
do NOT read/write the STB index** — the enclosing `CRegionDesc` does.
|
||||||
|
`CSceneType::pack_size` @ 0x005031C0 is `(scenes.m_num << 2) + 8`, i.e. it
|
||||||
|
budgets 8 bytes of header (STB index + count) while `pack` itself only writes
|
||||||
|
the count. Both DatReaderWriter and ACE.DatLoader model this correctly by
|
||||||
|
putting `StbIndex` as the first field of `SceneType`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Struct layouts (verbatim from `acclient.h`)
|
||||||
|
|
||||||
|
```c
|
||||||
|
/* 3763 */ // sizeof = 0x1C
|
||||||
|
struct __cppobj AmbientSTBDesc
|
||||||
|
{
|
||||||
|
IDClass<_tagDataID,32,0> stb_id; // +0x00 SoundTable DID
|
||||||
|
int stb_not_found; // +0x04 negative cache
|
||||||
|
AC1Legacy::SmartArray<AmbientSoundDesc *> ambient_sounds; // +0x08 m_data, +0x0C m_size, +0x10 m_num
|
||||||
|
CSoundTable *sound_table; // +0x14 resolved DBObj
|
||||||
|
unsigned int play_count; // +0x18 per-rebuild
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 3761 */ // sizeof = 0x18 in memory, 0x14 on disk
|
||||||
|
struct AmbientSoundDesc
|
||||||
|
{
|
||||||
|
SoundType stype; // +0x00 which slot to pull from the SoundTable
|
||||||
|
int is_continuous; // +0x04 DERIVED at unpack, NOT stored on disk
|
||||||
|
float volume; // +0x08
|
||||||
|
float base_chance; // +0x0C
|
||||||
|
float min_rate; // +0x10
|
||||||
|
float max_rate; // +0x14
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 5846 */
|
||||||
|
struct __cppobj CSoundDesc
|
||||||
|
{
|
||||||
|
AC1Legacy::SmartArray<AmbientSTBDesc *> stb_desc;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 5830 */ // sizeof = 0x14
|
||||||
|
struct __cppobj CSceneType
|
||||||
|
{
|
||||||
|
PStringBase<char> scene_name; // +0x00
|
||||||
|
SmartArray<IDClass<_tagDataID,32,0>,1> scenes; // +0x04 m_data, +0x08 m_sizeAndDealloc, +0x0C m_num
|
||||||
|
AmbientSTBDesc *sound_table_desc; // +0x10
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 5832 */
|
||||||
|
struct __cppobj CTerrainType
|
||||||
|
{
|
||||||
|
AC1Legacy::PStringBase<char> terrain_name; // +0x00
|
||||||
|
RGBAUnion terrain_color; // +0x04
|
||||||
|
AC1Legacy::SmartArray<CSceneType *> scene_types; // +0x08 m_data, +0x0C m_size, +0x10 m_num
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 5834 */
|
||||||
|
struct __cppobj CTerrainDesc
|
||||||
|
{
|
||||||
|
LandSurf *land_surfaces;
|
||||||
|
AC1Legacy::SmartArray<CTerrainType *> terrain_types;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 5851 */
|
||||||
|
struct __cppobj CRegionDesc : SerializeUsingPackDBObj
|
||||||
|
{
|
||||||
|
unsigned int region_number;
|
||||||
|
AC1Legacy::PStringBase<char> region_name;
|
||||||
|
unsigned int version;
|
||||||
|
int minimize_pal;
|
||||||
|
unsigned int parts_mask;
|
||||||
|
FileNameDesc *file_info;
|
||||||
|
SkyDesc *sky_info;
|
||||||
|
CSoundDesc *sound_info; // ← ambient sound sets live here
|
||||||
|
CSceneDesc *scene_info;
|
||||||
|
CTerrainDesc *terrain_info;
|
||||||
|
CEncounterDesc *encounter_info;
|
||||||
|
WaterDesc *water_info;
|
||||||
|
FogDesc *fog_info;
|
||||||
|
DistanceFogDesc *dist_fog_info;
|
||||||
|
RegionMapDesc *region_map_info;
|
||||||
|
RegionMisc *region_misc;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime instances (for reference; sibling lane owns their behavior):
|
||||||
|
|
||||||
|
```c
|
||||||
|
/* 3765 */
|
||||||
|
struct __cppobj Ambient
|
||||||
|
{
|
||||||
|
Position player_pos; // +0x00
|
||||||
|
float total_sound_count; // +0x24
|
||||||
|
unsigned int num_sounds; // +0x28
|
||||||
|
DArray<AmbientSound *> sounds; // +0x2C
|
||||||
|
AC1Legacy::PQueueArray<double> sound_queue;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 3759 */ // sizeof = 0x18
|
||||||
|
struct __cppobj AmbientSound
|
||||||
|
{
|
||||||
|
AmbientSoundVtbl *vfptr; // +0x00
|
||||||
|
int on_queue; // +0x04
|
||||||
|
float sound_count; // +0x08 accumulated weight this rebuild
|
||||||
|
AmbientSTBDesc *desc; // +0x0C identity key part 1
|
||||||
|
unsigned int ambient_sound_id; // +0x10 identity key part 2 (index into desc->ambient_sounds)
|
||||||
|
int constant_sound; // +0x14
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 5804 */ // sizeof = 0x80
|
||||||
|
struct __cppobj IntermitSound : AmbientSound
|
||||||
|
{
|
||||||
|
float play_chance; // +0x18
|
||||||
|
float min_dist[8]; // +0x1C
|
||||||
|
float max_dist[8]; // +0x3C
|
||||||
|
unsigned int num_dir; // +0x5C
|
||||||
|
LandDefs::Direction sound_dir[8]; // +0x60
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 5807 */ // sizeof = 0x1C
|
||||||
|
struct __cppobj ConstantSound : AmbientSound
|
||||||
|
{
|
||||||
|
float current_volume; // +0x18
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`AmbientSound`'s own virtuals are all ICF-folded stubs (`AmbientSound::vftable`
|
||||||
|
@ 0x007CB0A4 points at `IDClass::~IDClass`, `MediaDesc::GetDuration`,
|
||||||
|
`Client::You_Must_Not_Have_Multiple_Implementations_Of_AddRef_In_A_Hierarchy`,
|
||||||
|
etc.). The class is effectively abstract; only `IntermitSound`
|
||||||
|
(vftable 0x007CB0C4) and `ConstantSound` (vftable 0x007CB0E4) do work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. On-disk pack layouts
|
||||||
|
|
||||||
|
Derived from `*::Pack` / `*::pack_size` / `*::UnPack` and confirmed field-for-field
|
||||||
|
by DatReaderWriter and ACE.DatLoader.
|
||||||
|
|
||||||
|
### `CSoundDesc` (region `SoundInfo`, present iff `PartsMask.HasSoundInfo`)
|
||||||
|
|
||||||
|
| offset | type | field |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | u32 | numSTBDesc |
|
||||||
|
| 4 | AmbientSTBDesc × N | — |
|
||||||
|
|
||||||
|
### `AmbientSTBDesc` — `pack_size = 8 + 0x14 * numSounds` (@ 0x00551300)
|
||||||
|
|
||||||
|
| offset | type | field |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | u32 | `stb_id` (SoundTable DID) |
|
||||||
|
| 4 | u32 | numAmbientSounds |
|
||||||
|
| 8 | AmbientSoundDesc × N (0x14 each) | — |
|
||||||
|
|
||||||
|
### `AmbientSoundDesc` — 20 bytes on disk (@ 0x00551220 / 0x005518F0)
|
||||||
|
|
||||||
|
| offset | type | field |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | u32 | `stype` (`SoundType`) |
|
||||||
|
| 4 | f32 | `volume` |
|
||||||
|
| 8 | f32 | `base_chance` |
|
||||||
|
| 12 | f32 | `min_rate` |
|
||||||
|
| 16 | f32 | `max_rate` |
|
||||||
|
|
||||||
|
`is_continuous` is **not on disk** — it is computed during unpack (see §3.1).
|
||||||
|
|
||||||
|
### `CSceneType` (region `SceneInfo` entries)
|
||||||
|
|
||||||
|
| offset | type | field | written by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | u32 | `stbIndex` (0xFFFFFFFF = none) | `CRegionDesc::Pack` |
|
||||||
|
| 4 | u32 | numScenes | `CSceneType::pack` |
|
||||||
|
| 8 | u32 × N | Scene DIDs (`0x12xxxxxx`) | `CSceneType::pack` |
|
||||||
|
|
||||||
|
### `CTerrainType` (region `TerrainInfo` entries)
|
||||||
|
|
||||||
|
| type | field |
|
||||||
|
|---|---|
|
||||||
|
| PStringBase\<char\> + align(4) | `terrain_name` |
|
||||||
|
| u32 | `terrain_color` (ARGB) |
|
||||||
|
| u32 | numSceneTypes |
|
||||||
|
| u32 × N | scene-type indices into `SceneInfo.SceneTypes` (0xFFFFFFFF = none) |
|
||||||
|
|
||||||
|
### 3.1 CORRECTION #1 — `is_continuous` (BN pseudo-C is inverted)
|
||||||
|
|
||||||
|
BN renders `AmbientSTBDesc::UnPack` @ 0x005519A9 as if `is_continuous` were
|
||||||
|
`base_chance != 0`. Byte decode of the paired binary says the opposite:
|
||||||
|
|
||||||
|
```
|
||||||
|
005519a9 d9 43 0c fld dword [ebx+0x0C] ; base_chance
|
||||||
|
005519ac dc 1d 10 46 79 00 fcomp qword [0x00794610] ; = 0.0 (verified)
|
||||||
|
005519b2 df e0 fnstsw ax
|
||||||
|
005519b4 f6 c4 44 test ah, 0x44 ; C3(equal) | C2(unordered)
|
||||||
|
005519b7 7a 07 jp 0x005519C0 ; PF set ⇔ mask result == 0 ⇔ NOT equal
|
||||||
|
005519b9 b8 01 00 00 00 mov eax, 1
|
||||||
|
005519be eb 02 jmp 0x005519C2
|
||||||
|
005519c0 33 c0 xor eax, eax
|
||||||
|
005519c2 89 43 04 mov [ebx+0x04], eax ; is_continuous
|
||||||
|
```
|
||||||
|
|
||||||
|
**`is_continuous = (base_chance == 0.0f)`.**
|
||||||
|
|
||||||
|
Corroborated by `Ambient::GetSound` @ 0x005510B0 (byte-verified at 0x00551106:
|
||||||
|
`mov eax,[esp+0x10]; test eax,eax; je +0x3A` — the `je` goes to the 0x80-byte
|
||||||
|
allocation):
|
||||||
|
|
||||||
|
* `is_continuous == 0` → `operator new(0x80)` → **`IntermitSound`**
|
||||||
|
* `is_continuous != 0` → `operator new(0x1C)` → **`ConstantSound`**
|
||||||
|
|
||||||
|
So, authored semantics:
|
||||||
|
|
||||||
|
| `base_chance` | instance | behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `0.0` | `ConstantSound` | continuous/looping ambience, volume-weighted |
|
||||||
|
| non-zero | `IntermitSound` | random one-shots, chance-weighted |
|
||||||
|
|
||||||
|
Getting this backwards is silent: every continuous ambience becomes an
|
||||||
|
intermittent sound with a 0 play chance, i.e. total silence.
|
||||||
|
|
||||||
|
### 3.2 Field meanings (from the two subclasses)
|
||||||
|
|
||||||
|
| field | `ConstantSound` | `IntermitSound` |
|
||||||
|
|---|---|---|
|
||||||
|
| `stype` | SoundTable slot to play (typically `Sound_Ambient1..8` = `0x46..0x4D`) | same |
|
||||||
|
| `volume` | `current_volume = volume / total_sound_count * sound_count` (@ 0x00551576) | `GetVolume` returns `volume` verbatim (@ 0x00551070) |
|
||||||
|
| `base_chance` | must be 0 (that's what selects this class) | `play_chance = base_chance / total_sound_count * sound_count` (@ 0x0055133C) |
|
||||||
|
| `min_rate` | `GetPlayInterval` returns `min_rate` — the loop re-trigger period (@ 0x005510A0) | lower bound of `Random::RollDice(min_rate, max_rate)` |
|
||||||
|
| `max_rate` | unused | upper bound of the roll (@ 0x00551094) |
|
||||||
|
|
||||||
|
`Sound_Ambient1..Sound_Ambient8 = 0x46..0x4D` (`acclient.h:4641-4648`). Nothing
|
||||||
|
forces `stype` into that range — it is just the key looked up in the STB's
|
||||||
|
`CSoundTable::Sounds` dictionary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Outdoor selection — `CLandBlock::add_ambient_sounds` @ 0x00530310
|
||||||
|
|
||||||
|
Faithful pseudocode:
|
||||||
|
|
||||||
|
```c
|
||||||
|
void CLandBlock::add_ambient_sounds(Ambient* ambient)
|
||||||
|
{
|
||||||
|
Position soundPos; // identity frame, then filled per cell
|
||||||
|
int n = this->side_cell_count; // 8
|
||||||
|
for (int y = 0; y < n; ++y) {
|
||||||
|
for (int x = 0; x < n; ++x) {
|
||||||
|
// sound position = the land cell's SW terrain vertex, in landblock space
|
||||||
|
const float* v = vertex_array.vertices
|
||||||
|
+ (side_vertex_count * y + x) * CVertexArray::vertex_size;
|
||||||
|
soundPos.origin = { v[0], v[1], v[2] };
|
||||||
|
soundPos.objcell_id = this->lcell[n * y + x].m_DID.id;
|
||||||
|
|
||||||
|
// terrain array is 9x9 uint16, row stride 0x12 bytes
|
||||||
|
uint16 w = *(uint16*)(this->terrain + (y * 0x12 + x * 2));
|
||||||
|
uint32 tType = (w >> 2) & 0x1F; // terrain type (5 bits)
|
||||||
|
uint32 sScene = w >> 11; // scene ordinal (5 bits, uint16 >> 11)
|
||||||
|
|
||||||
|
if (sScene < CRegionDesc::NumSceneType(current_region, tType)) {
|
||||||
|
AmbientSTBDesc* d = CRegionDesc::GetSTBDesc(current_region, tType, sScene);
|
||||||
|
if (d) Ambient::AddSound(ambient, d, &soundPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The two lookups:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// CTerrainDesc::NumSceneType @ 0x00502430
|
||||||
|
uint32 NumSceneType(t) {
|
||||||
|
return (t < terrain_types.m_num) ? terrain_types[t]->scene_types.m_num : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CTerrainDesc::GetSTBDesc @ 0x00502400 (field offsets confirmed against acclient.h)
|
||||||
|
AmbientSTBDesc* GetSTBDesc(t, s) {
|
||||||
|
if (t >= terrain_types.m_num) return NULL;
|
||||||
|
CTerrainType* tt = terrain_types[t];
|
||||||
|
if (s >= tt->scene_types.m_num) return NULL;
|
||||||
|
CSceneType* st = tt->scene_types[s];
|
||||||
|
return st ? st->sound_table_desc : NULL; // +0x10
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRegionDesc::GetSTBDesc @ 0x004FEAB0 — adds lazy SoundTable resolution
|
||||||
|
AmbientSTBDesc* GetSTBDesc(t, s) {
|
||||||
|
AmbientSTBDesc* d = terrain_info->GetSTBDesc(t, s);
|
||||||
|
if (!d) return NULL;
|
||||||
|
int ok = 0;
|
||||||
|
if (d->sound_table == NULL) ok = d->InitSoundTable();
|
||||||
|
return (d->sound_table || ok) ? d : NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmbientSTBDesc::InitSoundTable @ 0x004FEA60
|
||||||
|
int InitSoundTable() {
|
||||||
|
if (stb_not_found) return 0;
|
||||||
|
if (stb_id == INVALID_DID) return 0;
|
||||||
|
sound_table = (CSoundTable*)DBObj::Get(QualifiedDataID(stb_id, /*type*/ 0x22));
|
||||||
|
if (sound_table) return 1;
|
||||||
|
stb_not_found = 1; // negative cache; never retried
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`0x22` is the DBObj cache-type index for `CSoundTable`, proven by
|
||||||
|
`CLOCache::CLOCache(cache, CSoundTable::Allocator, 0x22)` @ 0x004FB831. The
|
||||||
|
same `0x22` is used for object/setup sound tables (`CPhysicsObj` /
|
||||||
|
`SetupDesc::default_stable_id` sites @ 0x00513A36, 0x00514F9F) and by
|
||||||
|
`MediaDesc` @ 0x004658DA — so **no separate ambient dat range exists**; ambient
|
||||||
|
sound tables are ordinary `SoundTable` objects in `0x20000000–0x2000FFFF`.
|
||||||
|
|
||||||
|
### 4.1 Which landblocks contribute — `LScape::add_ambient_sounds` @ 0x00505810
|
||||||
|
|
||||||
|
```c
|
||||||
|
void LScape::add_ambient_sounds(Ambient* ambient)
|
||||||
|
{
|
||||||
|
for (int by = 0; by < mid_width; ++by)
|
||||||
|
for (int bx = 0; bx < mid_width; ++bx) {
|
||||||
|
int ring; LandDefs::Direction dir;
|
||||||
|
LScape::get_block_orient(this, by, bx, &ring, &dir);
|
||||||
|
if (ring != 1) continue; // <-- the gate
|
||||||
|
CLandBlock* lb = land_blocks[mid_width * by + bx];
|
||||||
|
if (lb) lb->add_ambient_sounds(ambient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`LScape::get_block_orient` @ 0x00504F90 computes
|
||||||
|
`d = max(|bx - mid_radius|, |by - mid_radius|)` (Chebyshev distance in
|
||||||
|
landblocks from the viewer block) and emits `ring = 1` for `d <= 1`,
|
||||||
|
`2` for `d == 2`, `4` for `d in [3,4]`, `8` for `d > 4`.
|
||||||
|
|
||||||
|
So ambient sounds are gathered from the **3×3 landblock neighbourhood centred
|
||||||
|
on the viewer's landblock** — up to 9 × 64 = **576 `AddSound` calls** per cell
|
||||||
|
change. Every call is distance-gated inside `AddSound`, so most contribute
|
||||||
|
nothing (a landblock is 192 m across; the outer cut is 120 m).
|
||||||
|
|
||||||
|
### 4.2 CORRECTION #2 — `Ambient::CalcWeight` @ 0x00550DD0
|
||||||
|
|
||||||
|
BN drops the arithmetic entirely. Byte decode gives the exact function:
|
||||||
|
|
||||||
|
```
|
||||||
|
d9 44 24 04 fld dword [esp+4] ; d2 = ox² + oy² + oz²
|
||||||
|
d8 1d 54f18100 fcomp dword [0x0081F154] ; ambient_sound_max_dist_sq = 14400
|
||||||
|
df e0 / f6 c4 41 / 75 09 ; if (d2 > max) -> fld [0x00795344]=0.0; ret
|
||||||
|
d9 44 24 04 fld dword [esp+4]
|
||||||
|
d8 1d 4cf18100 fcomp dword [0x0081F14C] ; ambient_sound_min_dist_sq = 400
|
||||||
|
df e0 / f6 c4 05 / 7a 09 ; if (d2 < min) -> fld [0x007928B0]=1.0; ret
|
||||||
|
d9 05 4cf18100 fld dword [0x0081F14C] ; 400
|
||||||
|
d8 74 24 04 fdiv dword [esp+4] ; 400 / d2
|
||||||
|
```
|
||||||
|
|
||||||
|
```c
|
||||||
|
float Ambient::CalcWeight(const Vector3& offset)
|
||||||
|
{
|
||||||
|
float d2 = offset.x*offset.x + offset.y*offset.y + offset.z*offset.z;
|
||||||
|
if (d2 > 14400.0f) return 0.0f; // beyond 120 m: silent
|
||||||
|
if (d2 < 400.0f) return 1.0f; // within 20 m: full
|
||||||
|
return 400.0f / d2; // inverse-square; 0.0278 at 120 m
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified globals (`.data`):
|
||||||
|
|
||||||
|
| address | symbol | value |
|
||||||
|
|---|---|---|
|
||||||
|
| 0x0081F148 | `Ambient::ambient_sound_min_dist` | 20.0 m |
|
||||||
|
| 0x0081F14C | `Ambient::ambient_sound_min_dist_sq` | 400.0 |
|
||||||
|
| 0x0081F150 | `Ambient::ambient_sound_max_dist` | 120.0 m |
|
||||||
|
| 0x0081F154 | `Ambient::ambient_sound_max_dist_sq` | 14400.0 |
|
||||||
|
| 0x0081F158 | `Ambient::ambient_sound_min_vol` | 0.03 |
|
||||||
|
|
||||||
|
### 4.3 `Ambient::AddSound` @ 0x00551610 (the accumulator)
|
||||||
|
|
||||||
|
```c
|
||||||
|
void Ambient::AddSound(AmbientSTBDesc* desc, const Position& soundPos)
|
||||||
|
{
|
||||||
|
if (!SoundManager::ambient_sounds_enabled) return;
|
||||||
|
Vector3 off = player_pos.get_offset(soundPos); // player-frame offset
|
||||||
|
if (off.LengthSq() >= ambient_sound_max_dist_sq) return;
|
||||||
|
float w = CalcWeight(off);
|
||||||
|
LandDefs::Direction dir = CalcDir(off);
|
||||||
|
if (w <= 0) return;
|
||||||
|
total_sound_count += w; // ONCE per cell
|
||||||
|
for (uint i = 0; i < desc->ambient_sounds.m_num; ++i)
|
||||||
|
GetSound(desc, i)->AddTo(w, off, dir); // per authored sound
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Faithfulness note for the port: `total_sound_count` is bumped **once per
|
||||||
|
contributing land cell**, while each of the STB's N `AmbientSoundDesc` entries
|
||||||
|
gets `w` added to its own `sound_count`. For an STB with N > 1, the sum of
|
||||||
|
`sound_count` is therefore N × `total_sound_count`, so the "share of total"
|
||||||
|
normalisation used by `ConstantSound::UpdateSound`
|
||||||
|
(`volume / total_sound_count * sound_count`) can legitimately exceed
|
||||||
|
`volume`. Reproduce it; don't "fix" it.
|
||||||
|
|
||||||
|
`Ambient::GetSound` @ 0x005510B0 keys the instance cache on the pair
|
||||||
|
`(desc pointer, ambient_sound_id)` and **never evicts** — instances accumulate
|
||||||
|
for the life of the `Ambient`. That is what makes it correct to only
|
||||||
|
`ResetCount()` on rebuild.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Indoor / EnvCell: the hook exists, the data does not
|
||||||
|
|
||||||
|
`symbols.json` has:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"address": "0x00694750", "name": "CEnvCell::add_ambient_sounds",
|
||||||
|
"mangled": "?add_ambient_sounds@CEnvCell@@SAXPAVAmbient@@@Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SAX` = **static**, void, one `Ambient*` argument. Address `0x00694750` is
|
||||||
|
shared with `IDClass<_tagDataID,32,0>::~IDClass` and `AmbientSound::ResetCount`,
|
||||||
|
and the pseudo-C for that address is:
|
||||||
|
|
||||||
|
```c
|
||||||
|
00694750 void IDClass<_tagDataID,32,0>::~IDClass(...) __pure
|
||||||
|
00694750 { return; }
|
||||||
|
```
|
||||||
|
|
||||||
|
That is COMDAT identical-code folding onto a bare `ret`. The call site in
|
||||||
|
`CellManager::ChangePosition` @ 0x00455B0A is rendered by BN as
|
||||||
|
`IDClass<...>::~IDClass(ambient_sounds)` — passing an `Ambient*` to a DID
|
||||||
|
destructor, which is the tell that it is really the folded
|
||||||
|
`CEnvCell::add_ambient_sounds(ambient)`.
|
||||||
|
|
||||||
|
**Conclusion: in the Sept 2013 EoR client, indoor cells contribute zero
|
||||||
|
ambient sounds through this path.** Independently corroborated by the dat
|
||||||
|
format — `EnvCell` (DatReaderWriter `DBObjs/EnvCell.generated.cs`) has exactly:
|
||||||
|
`Flags`, `Surfaces`, `EnvironmentId`, `CellStructure`, `Position`,
|
||||||
|
`CellPortals`, `VisibleCells`, `StaticObjects`, `RestrictionObj`. No sound
|
||||||
|
field, no sound table, no ambient list. `LandDefs` likewise has no sound field.
|
||||||
|
|
||||||
|
Where dungeon ambience actually comes from in retail (out of this lane's scope,
|
||||||
|
but the obvious next question): server-spawned objects carrying a
|
||||||
|
`SoundTableId` / physics-script sound, i.e. the `0x22` consumers at
|
||||||
|
0x00513A36 / 0x00514F9F — object sound tables, not the `Ambient` system.
|
||||||
|
Also note `LScape::add_ambient_sounds` is skipped entirely while indoors unless
|
||||||
|
the current cell has `seen_outside != 0` (see §6), so an interior cell that
|
||||||
|
can see outdoors still hears the outdoor set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Lifecycle — `CellManager::ChangePosition` @ 0x004559B0
|
||||||
|
|
||||||
|
Everything ambient-related is inside the **cell-changed** branch. There is no
|
||||||
|
per-frame ambient rebuild.
|
||||||
|
|
||||||
|
```c
|
||||||
|
void CellManager::ChangePosition(const Position* newPos, int forceReload)
|
||||||
|
{
|
||||||
|
if (newPos->objcell_id == 0) { Reset(); return; }
|
||||||
|
|
||||||
|
int reload = blocking_for_cells ? 1 : forceReload;
|
||||||
|
|
||||||
|
if (load_pos.objcell_id != newPos->objcell_id || curr_cell == NULL)
|
||||||
|
{
|
||||||
|
PreFetchCells(newPos->objcell_id, reload);
|
||||||
|
... release old curr_cell, update LScape loadpoint, grab_visible_cells ...
|
||||||
|
CEnvCell::master_incell_timestamp += 1;
|
||||||
|
CEnvCell::flush_cells();
|
||||||
|
|
||||||
|
if (curr_cell != NULL)
|
||||||
|
{
|
||||||
|
bool outdoorish = isOutdoorCell(newPos) || curr_cell->seen_outside;
|
||||||
|
|
||||||
|
if (outdoorish) { ...sunlight / SetWorldAmbientLight from LScape... }
|
||||||
|
else { SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF); }
|
||||||
|
|
||||||
|
Ambient::InitSounds(ambient_sounds, newPos); // 1
|
||||||
|
CEnvCell::add_ambient_sounds(ambient_sounds); // 2 (empty stub)
|
||||||
|
if (outdoorish)
|
||||||
|
LScape::add_ambient_sounds(lscape, ambient_sounds); // 3
|
||||||
|
Ambient::UpdatePlayQueue(ambient_sounds); // 4
|
||||||
|
Ambient::ReleaseSoundTables(ambient_sounds); // 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load_pos = *newPos;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**(1) `Ambient::InitSounds` @ 0x005515D0** — the rebuild barrier:
|
||||||
|
|
||||||
|
```c
|
||||||
|
void Ambient::InitSounds(const Position* p)
|
||||||
|
{
|
||||||
|
player_pos = *p;
|
||||||
|
total_sound_count = 0.0f;
|
||||||
|
for (i = 0; i < num_sounds; ++i) sounds[i]->ResetCount();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`IntermitSound::ResetCount` @ 0x00550CD0 / `ConstantSound::ResetCount` @
|
||||||
|
0x00550D70 zero `sound_count` (and `desc->play_count`). Instances are **not**
|
||||||
|
destroyed — a sound that no longer has any nearby cell simply drops to
|
||||||
|
`sound_count == 0` and goes silent (`ConstantSound::UpdateSound` sets
|
||||||
|
`current_volume = 0`).
|
||||||
|
|
||||||
|
**(5) `Ambient::ReleaseSoundTables` @ 0x00455770** — the streaming release:
|
||||||
|
|
||||||
|
```c
|
||||||
|
for (i = 0; i < num_sounds; ++i) {
|
||||||
|
AmbientSTBDesc* d = sounds[i]->desc;
|
||||||
|
if (d->sound_table && d->play_count == 0) { // nothing will play from it
|
||||||
|
d->sound_table->Release();
|
||||||
|
d->sound_table = NULL; // re-fetched lazily next time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`play_count` is bumped in `IntermitSound::UpdateSound` / `ConstantSound::UpdateSound`
|
||||||
|
during step (4), so step (5) drops the `CSoundTable` DBObj reference for every
|
||||||
|
STB whose sounds ended up inaudible at the new position.
|
||||||
|
|
||||||
|
**Teardown — `CellManager::Reset` @ 0x00455930** calls
|
||||||
|
`Ambient::FlushSoundTables` @ 0x00452920, which is `ReleaseSoundTables` plus
|
||||||
|
a `ResetCount()` on every sound and `total_sound_count = 0`.
|
||||||
|
`Ambient::Destroy` @ 0x00551580 / `~Ambient` @ 0x00551760 delete the
|
||||||
|
`AmbientSound` instances (after re-stamping the base vftable — the usual
|
||||||
|
C++ dtor-devirtualisation artifact).
|
||||||
|
|
||||||
|
Per-frame ticking is `SmartBox` → `Ambient::UseTime` @ 0x00551880 (sibling lane).
|
||||||
|
|
||||||
|
Both `AddSound` and `UpdatePlayQueue` are gated on
|
||||||
|
`SoundManager::ambient_sounds_enabled`, so the user's audio option short-circuits
|
||||||
|
the whole gather.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. DatReaderWriter coverage (what we get for free)
|
||||||
|
|
||||||
|
| retail type | DRW class | file | status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CRegionDesc` | `DBObjs.Region` (0x13000000–0x1300FFFF, `HasId`) | `Generated/DBObjs/Region.generated.cs` | **Complete for our needs.** Parses `RegionNumber`, `Version`, `RegionName`, `LandDefs`, `GameTime`, `PartsMask`, then masked `SkyInfo` / `SoundInfo` / `SceneInfo`, unconditional `TerrainInfo`, masked `RegionMisc`. |
|
||||||
|
| `CSoundDesc` | `Types.SoundDesc` → `List<AmbientSTBDesc> STBDesc` | `Generated/Types/SoundDesc.generated.cs` | **Exact match** to retail pack (`u32 count` + N entries). |
|
||||||
|
| `AmbientSTBDesc` | `Types.AmbientSTBDesc` → `uint STBId`, `List<AmbientSoundDesc>` | `Generated/Types/AmbientSTBDesc.generated.cs` | **Exact match.** |
|
||||||
|
| `AmbientSoundDesc` | `Types.AmbientSoundDesc` → `Sound SType`, `float Volume/BaseChance/MinRate/MaxRate` | `Generated/Types/AmbientSoundDesc.generated.cs` | **Exact match** to the 20-byte on-disk record. Correctly omits `is_continuous`. |
|
||||||
|
| `CSceneDesc` | `Types.SceneDesc` → `List<SceneType>` | `Generated/Types/SceneDesc.generated.cs` | **Exact match.** |
|
||||||
|
| `CSceneType` | `Types.SceneType` → `uint StbIndex`, `List<QualifiedDataId<Scene>> Scenes` | `Generated/Types/SceneType.generated.cs` | **Exact match**, including the caller-written `StbIndex` first. Independently confirmed by `ACE.DatLoader/Entity/SceneType.cs`. |
|
||||||
|
| `CTerrainDesc` | `Types.TerrainDesc` → `List<TerrainType>`, `LandSurf` | `Generated/Types/TerrainDesc.generated.cs` | **Exact match.** |
|
||||||
|
| `CTerrainType` | `Types.TerrainType` → `TerrainName`, `ColorARGB TerrainColor`, `List<uint> SceneTypes` | `Generated/Types/TerrainType.generated.cs` | **Exact match** (indices, not resolved pointers). |
|
||||||
|
| `CSoundTable` | `DBObjs.SoundTable` (0x20000000–0x2000FFFF, `HasId`) → `HashKey`, `Dictionary<uint,SoundHashData> Hashes`, `Dictionary<Sound,SoundData> Sounds` | `Generated/DBObjs/SoundTable.generated.cs` | **Complete.** `Sounds[stype].Entries` is the wave list. |
|
||||||
|
| `SoundType` | `Enums.Sound` (incl. `Ambient1..8`) | `Generated/Enums/Sound.generated.cs` | Present. |
|
||||||
|
|
||||||
|
**Gaps we must write ourselves** (none of them are parsers):
|
||||||
|
|
||||||
|
1. **`is_continuous` derivation.** DRW deliberately stores only the on-disk
|
||||||
|
fields. We compute `IsContinuous => BaseChance == 0f` at load. §3.1.
|
||||||
|
2. **Index → object resolution.** DRW hands back raw `StbIndex` and
|
||||||
|
`TerrainType.SceneTypes` indices with `0xFFFFFFFF` sentinels. Retail resolves
|
||||||
|
them once at unpack; we need the equivalent resolve step (or resolve on
|
||||||
|
lookup, which is what `GetSTBDesc` does anyway) and must honour
|
||||||
|
`0xFFFFFFFF == none`.
|
||||||
|
3. **The whole `Ambient` runtime**: `AmbientSTBDesc` shared state
|
||||||
|
(`sound_table` cache, `stb_not_found` negative cache, `play_count`), the
|
||||||
|
`(desc, index)`-keyed instance cache, `IntermitSound`/`ConstantSound`,
|
||||||
|
`CalcWeight`/`CalcDir`, the play queue, the release policy. No reference
|
||||||
|
repo has any of this — ACE is a server and does not model client ambience;
|
||||||
|
ACViewer has no ambient sound handling (`grep -ri ambient` over
|
||||||
|
`references/ACViewer/` returns only render-pass / ambient-light hits).
|
||||||
|
4. **`CLandBlock`/`LScape` gather** — the terrain-word decode, the 8×8 land-cell
|
||||||
|
walk with the 9-wide row stride, the `ring == 1` 3×3 landblock gate, and the
|
||||||
|
`CellManager::ChangePosition` trigger point. All ours.
|
||||||
|
5. **`Random::RollDice(min_rate, max_rate)`** for the intermittent interval.
|
||||||
|
Verify our RNG matches retail's `RollDice` semantics before wiring
|
||||||
|
`min_rate`/`max_rate`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Answers to the posed questions
|
||||||
|
|
||||||
|
1. **Complete chain.** `Region` DBObj `0x13000000+regionNumber` →
|
||||||
|
`SoundInfo (CSoundDesc)` → `AmbientSTBDesc[]`; each descriptor's `stb_id` is
|
||||||
|
a `SoundTable` DID in `0x20000000–0x2000FFFF`, fetched via
|
||||||
|
`DBObj::Get(QualifiedDataID(id, cacheType=0x22))`; each descriptor carries N
|
||||||
|
`AmbientSoundDesc { stype, volume, base_chance, min_rate, max_rate }`, and
|
||||||
|
`base_chance == 0` selects `ConstantSound` while non-zero selects
|
||||||
|
`IntermitSound`. Selection is reached indirectly:
|
||||||
|
`TerrainInfo.TerrainTypes[t].SceneTypes[s]` → `SceneInfo.SceneTypes[idx]`
|
||||||
|
→ `.StbIndex` → `SoundInfo.STBDesc[stbIdx]`.
|
||||||
|
|
||||||
|
2. **Outdoor selection.** Per land cell, from the landblock's 9×9 `uint16`
|
||||||
|
terrain array: `terrainType = (w >> 2) & 0x1F`, `sceneOrdinal = w >> 11`;
|
||||||
|
bounds-checked against `NumSceneType(terrainType)`; resolved by
|
||||||
|
`CRegionDesc::GetSTBDesc(terrainType, sceneOrdinal)`. Not region-wide, not
|
||||||
|
per-landblock — **per land cell**, and the sound's position is that cell's
|
||||||
|
SW terrain vertex with the land cell's own `objcell_id`.
|
||||||
|
|
||||||
|
3. **Indoor.** Nowhere. `CEnvCell::add_ambient_sounds` is an ICF-folded empty
|
||||||
|
stub, and the EnvCell dat record has no sound field. Interiors flagged
|
||||||
|
`seen_outside` still get the outdoor set.
|
||||||
|
|
||||||
|
4. **Lifecycle.** Built in `CellManager::ChangePosition` only when
|
||||||
|
`load_pos.objcell_id != newPos.objcell_id || curr_cell == NULL`, in the exact
|
||||||
|
order `InitSounds` → `CEnvCell::add_ambient_sounds` (no-op) →
|
||||||
|
`LScape::add_ambient_sounds` (if outdoor-ish) → `UpdatePlayQueue` →
|
||||||
|
`ReleaseSoundTables`. Teardown is `CellManager::Reset` →
|
||||||
|
`Ambient::FlushSoundTables`; final destruction is `Ambient::Destroy`.
|
||||||
|
|
||||||
|
5. **DRW coverage.** Every on-disk structure in the chain is already parsed
|
||||||
|
exactly (Region / SoundDesc / AmbientSTBDesc / AmbientSoundDesc / SceneDesc /
|
||||||
|
SceneType / TerrainDesc / TerrainType / SoundTable / Sound enum). What we
|
||||||
|
write is the derived flag, index resolution, and the entire runtime gather +
|
||||||
|
instance model. See §7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Divergence-register candidates (if/when this is implemented)
|
||||||
|
|
||||||
|
* If we ever gather ambients from more than the 3×3 landblock ring, that is a
|
||||||
|
deviation — retail's gate is `get_block_orient(...) == 1`.
|
||||||
|
* If we implement indoor ambience from any authored source, that is a **new
|
||||||
|
feature**, not a port — retail has none. Register it.
|
||||||
|
* The multi-entry-STB `total_sound_count` asymmetry in §4.3 is retail behavior;
|
||||||
|
"normalising" it is a deviation.
|
||||||
|
|
||||||
|
## 10. Retail anchors (for code comments)
|
||||||
|
|
||||||
|
| symbol | address |
|
||||||
|
|---|---|
|
||||||
|
| `CRegionDesc::SetRegion` | 0x004FE8F0 |
|
||||||
|
| `CRegionDesc::UnPack` (index→pointer resolution) | 0x004FF440 |
|
||||||
|
| `CRegionDesc::NumSceneType` | 0x004FE960 |
|
||||||
|
| `CRegionDesc::GetSTBDesc` | 0x004FEAB0 |
|
||||||
|
| `CTerrainDesc::GetSTBDesc` | 0x00502400 |
|
||||||
|
| `CTerrainDesc::NumSceneType` | 0x00502430 |
|
||||||
|
| `AmbientSTBDesc::InitSoundTable` | 0x004FEA60 |
|
||||||
|
| `AmbientSTBDesc::UnPack` | 0x005518F0 |
|
||||||
|
| `AmbientSTBDesc::Pack` / `pack_size` | 0x00551220 / 0x00551300 |
|
||||||
|
| `CSoundDesc::UnPack` | 0x005028D0 |
|
||||||
|
| `CSceneType::unpack` / `pack_size` | 0x005032C0 / 0x005031C0 |
|
||||||
|
| `CLandBlock::add_ambient_sounds` | 0x00530310 |
|
||||||
|
| `LScape::add_ambient_sounds` | 0x00505810 |
|
||||||
|
| `LScape::get_block_orient` | 0x00504F90 |
|
||||||
|
| `CEnvCell::add_ambient_sounds` (folded no-op) | 0x00694750 |
|
||||||
|
| `CellManager::ChangePosition` | 0x004559B0 |
|
||||||
|
| `CellManager::Reset` | 0x00455930 |
|
||||||
|
| `Ambient::InitSounds` | 0x005515D0 |
|
||||||
|
| `Ambient::AddSound` | 0x00551610 |
|
||||||
|
| `Ambient::GetSound` | 0x005510B0 |
|
||||||
|
| `Ambient::CalcWeight` / `CalcDir` | 0x00550DD0 / 0x00550E40 |
|
||||||
|
| `Ambient::ReleaseSoundTables` / `FlushSoundTables` | 0x00455770 / 0x00452920 |
|
||||||
|
| `IntermitSound::UpdateSound` / `GetPlayInterval` | 0x00551310 / 0x00551080 |
|
||||||
|
| `ConstantSound::UpdateSound` / `GetPlayInterval` | 0x00551540 / 0x005510A0 |
|
||||||
1119
docs/research/2026-08-08-audio-retail-ambient-runtime.md
Normal file
1119
docs/research/2026-08-08-audio-retail-ambient-runtime.md
Normal file
File diff suppressed because it is too large
Load diff
647
docs/research/2026-08-08-audio-retail-dat-layer.md
Normal file
647
docs/research/2026-08-08-audio-retail-dat-layer.md
Normal file
|
|
@ -0,0 +1,647 @@
|
||||||
|
# Lane 4 — Sound DAT layer: retail vs DatReaderWriter vs acdream
|
||||||
|
|
||||||
|
Read-only audit, 2026-08-08. Three oracles used:
|
||||||
|
|
||||||
|
1. **Retail decomp** — `docs/research/named-retail/acclient_2013_pseudo_c.txt` +
|
||||||
|
`acclient.h` (Sept 2013 EoR, PDB-named).
|
||||||
|
2. **The PDB-paired binary** — `C:\Users\erikn\Downloads\acclient.exe`
|
||||||
|
v11.4186 (`check_exe_pdb.py` MATCH). Used to recover every constant
|
||||||
|
Binary Ninja elided through the x87 stack (`0f`, `0.0`, garbled `fyl2x`
|
||||||
|
chains). This was necessary: **three of the four load-bearing numbers in
|
||||||
|
this subsystem are invisible in the pseudo-C.**
|
||||||
|
3. **The shipped dats** — `%USERPROFILE%\Documents\Asheron's Call\*.dat`,
|
||||||
|
walked with an independent Python B-tree reader using retail's byte order
|
||||||
|
(not DRW's). All 190 sound tables and 786 waves parsed cleanly, which is
|
||||||
|
itself an external cross-check that DRW's layout is right.
|
||||||
|
|
||||||
|
Scratchpad tooling: `lane4_pe.py` (PE/VA reader + xref scan),
|
||||||
|
`lane4_datscan3.py` (dat B-tree + SoundTable/Wave scanner).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Retail function map (VA, imagebase 0x400000)
|
||||||
|
|
||||||
|
| Symbol | VA |
|
||||||
|
|---|---|
|
||||||
|
| `CSoundTable::UnPack` | `0x00551CD0` |
|
||||||
|
| `SoundTableData::UnPack` | `0x00552370` |
|
||||||
|
| `SoundTableData::Lookup` | `0x005520A0` |
|
||||||
|
| `CSoundTable::Lookup` | `0x00552100` |
|
||||||
|
| `SoundManager::GetSound` | `0x00550680` |
|
||||||
|
| `SoundManager::PlayProbability` | `0x005500E0` |
|
||||||
|
| `SoundManager::GetAttenuation` | `0x00550020` |
|
||||||
|
| `SoundManager::PlaySoundInternal(buf, Position*, vol, isAmbient)` | `0x00550170` |
|
||||||
|
| `SoundManager::PlaySoundInternal(buf, pan, vol)` | `0x0054FEC0` |
|
||||||
|
| `SoundManager::CreateSound` | `0x00550BF0` |
|
||||||
|
| `SoundManager::PlaySoundA` (4 overloads) | `0x00550730`, `0x005507A0`, `0x00550AF0`, `0x00550B70` |
|
||||||
|
| `SoundManager::PlayAmbientSound` | `0x00550820` |
|
||||||
|
| `SoundManager::PlaySoundFromCenter` | `0x00550950`, `0x005509E0` |
|
||||||
|
| `DBWave::UnPack` | `0x00551B90` |
|
||||||
|
| `SoundBuf::Create` | `0x00552930` |
|
||||||
|
| `SoundBuf::CopyWaveToBuffer` | `0x005526D0` |
|
||||||
|
| `Random::RollDice(float,float)` | `0x0042C600` |
|
||||||
|
| `Random::rand` | `0x0042C4C0` |
|
||||||
|
| `CSoundDesc::UnPack` | `0x005028D0` |
|
||||||
|
| static init `VOL_MIN_DIST_SQ = 5f*5f` | `0x00706490` |
|
||||||
|
|
||||||
|
### Constants recovered from the binary (all elided by BN)
|
||||||
|
|
||||||
|
| Address | Type | Value | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `0x007CAEAC` | f32 | **5.0** | `VOL_MIN_DIST` — falloff onset, metres |
|
||||||
|
| `0x0086F404` | f32 | **25.0** | `VOL_MIN_DIST_SQ` (runtime-init `5*5`; BN printed `0f` because it reads the zero-filled file image) |
|
||||||
|
| `0x00794EE0` | f64 | **2.0** | log base → the `fyl2x` chain is `log2(v)` |
|
||||||
|
| `0x007CAF48` | f64 | **6.0206** | `= 20·log10(2)`; with the above → `dB = 20·log10(v)` |
|
||||||
|
| `0x0081F060` | i32 | **-50** | `SoundManager::VOL_MIN`, in **dB** (never written; 3 read-only xrefs) |
|
||||||
|
| `0x007CAF50` | f32 | **1/32767** (3.0518509e-05) | `PlayProbability` rand normaliser |
|
||||||
|
| `0x00797D50` / `0x00797D48` | f64 | 4.656613057e-10 / **0.99999988** | `Random::rand` scale + hard clamp → **rand ∈ [0, 0.99999988], never 1.0** |
|
||||||
|
| `0x007CAF58` | f64 | **-15.0** | pan scale (note the sign) |
|
||||||
|
| `0x007991B0` | f64 | **5.0** | pan distance gate, metres |
|
||||||
|
| `0x0079B504` | f32 | 0.0174532924 | deg→rad |
|
||||||
|
| `0x0079B6B8` / `0x0079BC8C` | f64/f32 | 360.0 / 180.0 | heading wrap |
|
||||||
|
|
||||||
|
`SoundBuf` setters (`0x00552D80` region): `SetVolume(vol * 100)` via
|
||||||
|
`IDirectSoundBuffer` vtable `+0x3C`, `SetPan(pan * 100)` via `+0x40`. So
|
||||||
|
GetAttenuation's integer output is **decibels**, ×100 = DirectSound
|
||||||
|
centibels; pan is `±15` ×100 = `±1500` of DirectSound's `±10000` range.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. SoundTable wire format — retail vs DRW vs ours
|
||||||
|
|
||||||
|
### 1a. Retail structure (recursive), from `SoundTableData::UnPack` @ `0x00552370`
|
||||||
|
|
||||||
|
```
|
||||||
|
CSoundTable // DBObjType 0x22, id range 0x20000000..0x2000FFFF
|
||||||
|
[DBObj header: u32 id]
|
||||||
|
SoundTableData root
|
||||||
|
[pad to 4-byte boundary, zero-filled] // CSoundTable::UnPack tail
|
||||||
|
|
||||||
|
SoundTableData:
|
||||||
|
u32 m_hashKey // the SoundType this node answers to
|
||||||
|
u32 num_stdatas_
|
||||||
|
SoundData[num_stdatas_] // 16 bytes each, read ONLY if arg3 >= 0x10
|
||||||
|
u32 sound_id_ // DataID → Wave (0x0A00xxxx); 0 = none
|
||||||
|
float priority_
|
||||||
|
float probability_
|
||||||
|
float volume_
|
||||||
|
u32 numChildren
|
||||||
|
SoundTableData[numChildren] // RECURSIVE
|
||||||
|
```
|
||||||
|
|
||||||
|
Header struct (`acclient.h:31197`) confirms the 16-byte `SoundData` field
|
||||||
|
order verbatim:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct __cppobj SoundData {
|
||||||
|
IDClass<_tagDataID,32,0> sound_id_;
|
||||||
|
float priority_;
|
||||||
|
float probability_;
|
||||||
|
float volume_;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Two details worth writing down:
|
||||||
|
|
||||||
|
* **Default-init before reading.** The freshly allocated entry array is
|
||||||
|
memset to `{id=0, priority=0.0f, probability=1.0f, volume=1.0f}`
|
||||||
|
(`0x005523D9`–`0x005523E1`: two `0x3F800000` stores). If the version/size
|
||||||
|
argument `arg3 < 0x10`, retail *keeps those defaults* rather than failing.
|
||||||
|
Probability defaults to **1.0**, not 0.
|
||||||
|
* **Eager wave preload.** For every non-zero `sound_id_`, UnPack calls
|
||||||
|
`SoundManager::CreateSound(id)` — which refcount-bumps or allocates a
|
||||||
|
`SoundBufRef` + `SoundBuf` immediately. Retail creates the DirectSound
|
||||||
|
buffer for **every wave a table references at table-load time**, not on
|
||||||
|
first play. We are lazy-on-first-play (`DatSoundCache.GetWave`). Benign
|
||||||
|
divergence, but it's why retail never has a first-hit decode stall.
|
||||||
|
|
||||||
|
`CSoundTable::Lookup(SoundType)` → `SoundTableData::Lookup` is a plain
|
||||||
|
intrusive-hash probe over the **children** (`hashKey % m_numBuckets`, walk
|
||||||
|
`m_hashNext`). So SoundType→variants lives one level down; the root's own
|
||||||
|
entry array is separate.
|
||||||
|
|
||||||
|
### 1b. DRW's flattened parse (`SoundTable.generated.cs`, `SoundData/SoundEntry/SoundHashData.generated.cs`)
|
||||||
|
|
||||||
|
DRW reads exactly the same bytes in exactly the same order, but re-expresses
|
||||||
|
the depth-2 tree as two dictionaries:
|
||||||
|
|
||||||
|
| Retail field | DRW field | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| root `m_hashKey` | `SoundTable.HashKey` (i32) | ✅ |
|
||||||
|
| root `num_stdatas_` | `Hashes` count (i32) | ✅ |
|
||||||
|
| root `SoundData[i].sound_id_` | `Hashes` **key** (u32) | ✅ |
|
||||||
|
| root `SoundData[i].{priority_, probability_, volume_}` | `SoundHashData.{Priority, Probability, Volume}` (3× f32) | ✅ exact order |
|
||||||
|
| root `numChildren` | `Sounds` count (i32) | ✅ |
|
||||||
|
| child `m_hashKey` | `Sounds` **key** cast to `Enums.Sound` (u32) | ✅ |
|
||||||
|
| child `num_stdatas_` | `SoundData.Entries` count (u32) | ✅ |
|
||||||
|
| child `SoundData[i]` | `SoundEntry.{Id, Priority, Probability, Volume}` (`QualifiedDataId<Wave>` = 1× u32, then 3× f32) | ✅ exact order |
|
||||||
|
| child `numChildren` | `SoundData.Unknown` (i32) | ⚠️ **read and discarded** |
|
||||||
|
|
||||||
|
**The one structural divergence: DRW is not recursive.** It assumes depth
|
||||||
|
exactly 2 and swallows the grandchild count as `Unknown`. A depth-3 table
|
||||||
|
would desynchronise the reader from that point on. Measured on the shipped
|
||||||
|
dats: **0 of 190 tables have grandchildren**, and `Unknown` is 0 everywhere,
|
||||||
|
so DRW is correct on retail content. It would silently mis-parse custom
|
||||||
|
content. Worth a comment in our tree, not a fix.
|
||||||
|
|
||||||
|
DRW's own tests (`DatReaderWriter.Tests/DBObjs/SoundTableTests.cs`,
|
||||||
|
`WaveTests.cs`) are **write-then-read round-trips only** — they prove
|
||||||
|
Pack/Unpack agree with each other, not with retail. The retail-layout
|
||||||
|
evidence is (a) the `SoundTableData::UnPack` disassembly above and (b) my
|
||||||
|
independent Python parse of all 190 real tables.
|
||||||
|
|
||||||
|
### 1c. What the real dats actually contain
|
||||||
|
|
||||||
|
Scanned `client_portal.dat` (926 MB, 79,694 files). Waves and sound tables
|
||||||
|
live **only** in the portal dat (the "hits" in `client_cell_1.dat` at
|
||||||
|
`0x0A00FFFF`/`0x2000xxxx` are id-range collisions with cell ids, not audio).
|
||||||
|
|
||||||
|
| Measurement | Value |
|
||||||
|
|---|---|
|
||||||
|
| Waves (`0x0A00xxxx`) | **786** |
|
||||||
|
| Sound tables (`0x2000xxxx`) | **190** |
|
||||||
|
| Distinct SoundTypes used across all tables | 123 |
|
||||||
|
| Root `num_stdatas_` | **always exactly 1, always `sound_id_ == 0`** (a dummy; retail skips it because `CreateSound` is gated on `id != 0`) |
|
||||||
|
| Per-SoundType entry counts | **`1` × 4,183 … and `2` × 1** |
|
||||||
|
| Tables with depth > 2 | 0 |
|
||||||
|
|
||||||
|
The single multi-variant sound in the entire game:
|
||||||
|
|
||||||
|
```
|
||||||
|
table 0x200000A8, SoundType 31 (0x1F = Swoosh2), 2 entries:
|
||||||
|
wave 0x0A000519 priority 0.9 probability 1.0 volume 1.0
|
||||||
|
wave 0x0A00051E priority 0.9 probability 1.0 volume 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
This single fact reframes the whole "variation" story: **AC's per-object
|
||||||
|
sound variation is not driven by multi-entry lists.** The `Swoosh1/2/3`,
|
||||||
|
`Attack1/2/3`, `Wound1/2/3` *SoundType triples* are the variation mechanism;
|
||||||
|
the entry list under each type is a singleton. Our `SoundCookbook` doc
|
||||||
|
comment ("3 swoosh variants", "footsteps sound slightly different each
|
||||||
|
step") describes a mechanism that has exactly one instance in the shipped
|
||||||
|
data.
|
||||||
|
|
||||||
|
Field value distributions across all 4,184 entries:
|
||||||
|
|
||||||
|
| Field | Distribution | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| `probability_` | 3,498 × 1.0; **686 entries < 1.0** — 0.7 (249), 0.8 (129), 0.6 (108), 0.9 (74), 0.05 (53), 0.5 (21), 0.1 (19), 0.75 (11), **0.0001 (6)**, 0.02/0.03/0.01/0.003/0.15/0.2/0.3/0.95 (tail) | **linear chance in [0,1]**, honoured per-play |
|
||||||
|
| `priority_` | float, 0.0 … 1.0. Mode 0.7 (2,315), then 0.9 (311), 0.95 (251), 0.8 (242), 0.75 (192), 0.3 (169), 0.0 (156), 1.0 (80) | **float [0,1]**, never an integer 0..7 |
|
||||||
|
| `volume_` | mostly ≤ 1.0, but **44 entries exceed it**: 10.0 (31), 5.0 (3), 4.0 (1), 3.0 (4), 2.0 (5), 1.3 (1) | **unbounded linear gain**, NOT a 0..1 multiplier |
|
||||||
|
|
||||||
|
### 1d. Our side
|
||||||
|
|
||||||
|
`AcDream.Core.Audio.SoundEntry` (`AudioModel.cs:21-31`) is **dead code** —
|
||||||
|
declared, documented, never constructed anywhere in `src/` or `tests/`
|
||||||
|
(same for `ISoundCache`). The production path uses
|
||||||
|
`DatReaderWriter.Types.SoundEntry` directly
|
||||||
|
(`AudioHookSink.PlayFromSoundTable`). But its comments have already leaked
|
||||||
|
into real code as facts:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public int Priority { get; init; } // eviction ordering (0..7) ← invented
|
||||||
|
public float Probability{ get; init; } // for entries with multiple alternatives ← half-true
|
||||||
|
public float VolumeBase { get; init; } // 0..1 multiplier applied before falloff ← wrong
|
||||||
|
public float PitchMin / PitchMax ← no such retail fields
|
||||||
|
public bool Loop / Is3D ← not in SoundData; Is3D lives on SoundBuf
|
||||||
|
```
|
||||||
|
|
||||||
|
Consequences downstream (outside the strict DAT layer, but caused by it):
|
||||||
|
|
||||||
|
* `OpenAlAudioEngine.cs:297` — `slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7)`.
|
||||||
|
Retail priority is a float in [0,1]; `(int)0.7f == 0`. **4,100+ of 4,184
|
||||||
|
entries collapse to 0** and the 80 entries at exactly 1.0 collapse to 1.
|
||||||
|
Priority ordering is effectively destroyed.
|
||||||
|
* `AudioHookSink.cs:114` — `volume: Math.Clamp(entry.Volume * volumeMult, 0f, 1f)`.
|
||||||
|
Retail clamps **after** the distance division, never the raw field (§3).
|
||||||
|
Clamping the field flattens the 44 extended-range entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Selection + probability — retail vs `SoundCookbook.Roll`
|
||||||
|
|
||||||
|
### 2a. Retail, exactly
|
||||||
|
|
||||||
|
`SoundManager::GetSound` @ `0x00550680`, hand-disassembled (BN dropped both
|
||||||
|
the multiply and the `-1`):
|
||||||
|
|
||||||
|
```
|
||||||
|
005506ae mov ecx,[eax+0x7c] ; n = num_stdatas_
|
||||||
|
005506b1 test ecx,ecx
|
||||||
|
005506b3 jbe return ; n == 0 → no sound
|
||||||
|
005506b5 push 0x3f800000 ; push 0
|
||||||
|
005506bc call Random::RollDice ; st0 = roll, roll ∈ [0, 0.99999988]
|
||||||
|
005506c5 mov esi,[edi+0x7c] ; esi = n
|
||||||
|
005506c8 lea ecx,[esi-1] ; ecx = n - 1 ← !!
|
||||||
|
005506d4 fild dword [esp+0x14] ; st0 = (float)(n-1) ; st1 = roll
|
||||||
|
005506e0 fmul st, st(1) ; st0 = (n-1) * roll
|
||||||
|
005506e2 call _ftol2 ; idx = (int)trunc(...)
|
||||||
|
005506e9 cmp eax, esi
|
||||||
|
005506eb jae return ; idx >= n → no sound (unreachable)
|
||||||
|
005506ed ... data_[idx] copied wholesale into out
|
||||||
|
00550717 if (out->sound_id_ != 0) → sound_hash_.find(id) → SoundBufRef*
|
||||||
|
```
|
||||||
|
|
||||||
|
Pseudocode:
|
||||||
|
|
||||||
|
```
|
||||||
|
GetSound(stype, table) -> SoundData:
|
||||||
|
std = table.Lookup(stype); if !std or std.n == 0: return none
|
||||||
|
roll = Random::rand() # [0, 0.99999988], NEVER 1.0
|
||||||
|
idx = (int)(roll * (std.n - 1)) # truncate toward zero
|
||||||
|
if idx >= std.n: return none # dead branch
|
||||||
|
return std.data[idx] # id, priority, probability, volume
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, **separately and downstream**, the *selected* entry's probability is a
|
||||||
|
single Bernoulli gate. `PlayProbability` @ `0x005500E0`:
|
||||||
|
|
||||||
|
```
|
||||||
|
r = rand() * (1/32767) # rand() is C rand(), RAND_MAX 32767 → r ∈ [0, 1]
|
||||||
|
return (r < probability) ? 1 : 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Call sites: `PlaySoundA(SoundType, obj[, vol])` `0x00550AF0`/`0x00550B70`,
|
||||||
|
`PlaySoundA(DataID, obj, prio, prob, vol)` `0x005507A0`,
|
||||||
|
`PlaySoundFromCenter(SoundType, table)` `0x00550950`, and inline (same
|
||||||
|
`rand()*1/32767 < prob` sequence, not a call) in `PlayAmbientSound`
|
||||||
|
`0x00550820` and `PlayAmbientSoundFromCenter` `0x005508B0`. Fail → **the
|
||||||
|
sound is simply not played**. There is no fallback entry, no retry.
|
||||||
|
|
||||||
|
So retail's model is: **uniform index pick over the variant list, then an
|
||||||
|
independent play/skip roll on the picked entry's `probability_`.** The
|
||||||
|
probability field is *not* a selection weight and is never normalised or
|
||||||
|
accumulated.
|
||||||
|
|
||||||
|
### 2b. The `n-1` off-by-one is real, and its blast radius is one wave
|
||||||
|
|
||||||
|
Because `Random::rand` is hard-clamped to 0.99999988 (`0x00797D48`), `roll *
|
||||||
|
(n-1)` never reaches `n-1`, so **`idx ∈ [0, n-2]` and the last entry can
|
||||||
|
never be selected.**
|
||||||
|
|
||||||
|
* `n == 1` → `idx = (int)(roll * 0) = 0` ✅ correct.
|
||||||
|
* `n == 2` → `idx` always 0; entry[1] is dead.
|
||||||
|
* `n == 3` → `idx ∈ {0,1}`; entry[2] is dead.
|
||||||
|
|
||||||
|
Measured against real data (§1c): only `0x200000A8` / SoundType 31 has
|
||||||
|
`n == 2`, so in retail **wave `0x0A00051E` never plays.** Everything else is
|
||||||
|
`n == 1` and unaffected.
|
||||||
|
|
||||||
|
Per CLAUDE.md ("do not 'fix' the decompiled code"), the port should
|
||||||
|
reproduce `(n-1)` verbatim with a comment citing `0x005506C8` and this
|
||||||
|
measurement, and add a divergence-register row **only if** we deliberately
|
||||||
|
choose `n` instead.
|
||||||
|
|
||||||
|
### 2c. Our `SoundCookbook.Roll` — three distinct divergences
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (entries.Count == 1) return entries[0]; // ← probability never consulted
|
||||||
|
float sample = (float)rng.NextDouble();
|
||||||
|
float cum = 0f;
|
||||||
|
for (...) { cum += entries[i].Probability; if (sample < cum) return entries[i]; }
|
||||||
|
return total > 0.999f ? entries[^1] : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
| # | Divergence | Retail | Ours |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | **Probability is never applied on single-entry lists** | Bernoulli gate: `rand()/32767 < probability` → else silence | `entries.Count == 1` short-circuits before any roll → always plays |
|
||||||
|
| D2 | **Selection model** | uniform index `(int)(roll·(n-1))`; probability plays no part in selection | cumulative-distribution walk weighted *by* probability |
|
||||||
|
| D3 | **"Silence tail"** | doesn't exist as a concept; silence comes from the per-entry gate | invented: `null` when Σprobability < 1 and the sample lands past the last entry |
|
||||||
|
|
||||||
|
D1 is the one that matters, because §1c says 4,183 of 4,184 entries are
|
||||||
|
single-entry lists and **686 of them have `probability < 1.0`**. Every one
|
||||||
|
of those is a sound retail plays *sometimes* and we play *always*. Broken
|
||||||
|
down by SoundType (20 of 123 types affected):
|
||||||
|
|
||||||
|
| SoundType | sub-1.0 probabilities present | audible symptom |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 `Speak1` | 0.05 ×49, 0.1 ×17, 0.01–0.03 ×7, 0.15/0.2/0.3/0.5 | **creature idle chatter fires ~20× too often** — the loudest symptom by far |
|
||||||
|
| 58 (0x3A) | **0.0001 ×6**, 0.003 ×2 | 1-in-10,000 easter-egg cues play on every trigger |
|
||||||
|
| 12/13/14 `Wound1/2/3` | 0.7 ×~80 each, 0.8 ×8 each | 30% of wound sounds should be dropped |
|
||||||
|
| 3/4/5 `Attack1/2/3` | 0.6 ×64/6/1, 0.8, 0.9, 0.95 | attack grunts over-fire |
|
||||||
|
| 15 `Death1` | 0.75 ×11 | |
|
||||||
|
| 30/31/32 `Swoosh1/2/3` | 0.6–0.8 | weapon swings over-fire |
|
||||||
|
| 33 `Thump1` | 0.8 ×30 | |
|
||||||
|
| 34 `Smash1` | 0.8 ×21, 0.9 ×12, 0.6 ×9 | |
|
||||||
|
| 35 `Scratch1` | 0.9 ×42, 0.8 ×20 | |
|
||||||
|
| 16, 24, 29, 41, 57 | 0.05 / 0.1 / 0.5 / 0.8 / 0.95 tail | |
|
||||||
|
|
||||||
|
`SoundCookbook.Roll` is the **only** consumer of `Probability` in the whole
|
||||||
|
tree (`AudioHookSink.cs:108`); nothing else applies it. So the gate is
|
||||||
|
categorically absent from acdream today.
|
||||||
|
|
||||||
|
Suggested retail-faithful shape (two separate steps, matching retail's
|
||||||
|
split):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// SoundManager::GetSound @ 0x00550680 — uniform index, note the (n-1).
|
||||||
|
static SoundEntry? Pick(IReadOnlyList<SoundEntry> e, IRandom r) {
|
||||||
|
if (e.Count == 0) return null;
|
||||||
|
int idx = (int)(r.NextUnit() * (e.Count - 1)); // NextUnit() ∈ [0, 0.99999988]
|
||||||
|
return idx < e.Count ? e[idx] : null;
|
||||||
|
}
|
||||||
|
// SoundManager::PlayProbability @ 0x005500E0 — Bernoulli gate at the play site.
|
||||||
|
static bool PlayProbability(float p, IRandom r) => r.NextUnit() < p;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note also that retail draws from **two different RNGs**: `Random::rand`
|
||||||
|
(`0x0042C4C0`, a dual-LCG returning a float in [0, 0.99999988]) for the
|
||||||
|
index, and C library `rand()` scaled by 1/32767 for the probability gate.
|
||||||
|
Nothing observable depends on which we use, but the quantisation differs
|
||||||
|
(1/32767 grid vs ~2⁻³¹), and a probability of 0.0001 needs finer than
|
||||||
|
1/32767 granularity to be meaningful — retail's gate resolves it as
|
||||||
|
`rand() ∈ {0,1,2,3}` out of 32768, i.e. ~1.2e-4 effective, not 1e-4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Volume, distance falloff and pan (the fields' real semantics)
|
||||||
|
|
||||||
|
`SoundManager::GetAttenuation` @ `0x00550020`, disassembled and with the
|
||||||
|
three elided constants restored:
|
||||||
|
|
||||||
|
```
|
||||||
|
GetAttenuation(float dist, float vol, int* outDb, int isAmbient) -> bool
|
||||||
|
{
|
||||||
|
v = (dist >= 5.0f) // VOL_MIN_DIST 0x007CAEAC
|
||||||
|
? (25.0f * vol) / (dist * dist) // VOL_MIN_DIST_SQ 0x0086F404
|
||||||
|
: vol; // flat inside 5 m
|
||||||
|
if (v > 1.0) v = 1.0; // clamp AFTER the division
|
||||||
|
v *= isAmbient ? ambient_sound_volume : effect_sound_volume;
|
||||||
|
if (!(v > 0.0)) { *outDb = VOL_MIN; return false; }
|
||||||
|
dB = (int)ceil( log2(v) * 6.0206 ); // == 20*log10(v)
|
||||||
|
if (dB < VOL_MIN /* -50 */) { *outDb = VOL_MIN; return false; }
|
||||||
|
*outDb = dB; return true; // caller: SetVolume(dB * 100) centibels
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three facts our `AudioFalloff` gets wrong:
|
||||||
|
|
||||||
|
* **Min distance is 5 m, not 1 m.** `AudioFalloff.AttenuationAt(d,
|
||||||
|
minDistance = 1.0f)` defaults to a 1 m plateau; retail's is 5 m and the
|
||||||
|
numerator is `minDistance²` = 25.
|
||||||
|
* **`volume_` is not bounded by 1.** Because the clamp is applied to
|
||||||
|
`25·vol/d²`, a `volume_` of 10 means "hold full loudness out to
|
||||||
|
`d = √(25·10) ≈ 15.8 m`, then inverse-square". Clamping the field to
|
||||||
|
[0,1] (as `AudioHookSink.cs:114` does) shrinks the plateau of all 44
|
||||||
|
extended-range entries from ~15.8 m back to 5 m — a 3× audible-range loss
|
||||||
|
on wound/death/impact/ambient sounds (types 12/13/14/15, 18–26, 30, 33,
|
||||||
|
35, 57, 66, 95/96, 149, 152/153).
|
||||||
|
* **`VOL_MIN = -50 dB`** is the cut-off; below it the sound is not started
|
||||||
|
at all (`return false` → caller skips `PlaySoundInternal`).
|
||||||
|
|
||||||
|
Pan (`PlaySoundInternal(buf, Position*, vol, isAmbient)` @ `0x00550170`):
|
||||||
|
|
||||||
|
```
|
||||||
|
heading = Frame::get_heading(player_position_.frame)
|
||||||
|
dist = Position::distance(soundPos, player_position_)
|
||||||
|
bearing = Position::heading(soundPos, player_position_) // note: from the SOUND
|
||||||
|
pan = 0
|
||||||
|
if (s_SoundFeatures != 1) {
|
||||||
|
a = fmod(bearing - heading, 360.0); if (a > 180.0) a -= 360.0
|
||||||
|
if (abs((int)dist) >= 5.0) // 0x007991B0
|
||||||
|
pan = (int)( sin(a * pi/180) * -15.0 ) // 0x007CAF58, note the sign
|
||||||
|
}
|
||||||
|
if (GetAttenuation(dist, vol, &dB, isAmbient)) PlaySoundInternal(buf, pan, dB)
|
||||||
|
→ SoundBuf::Play → SetPan(pan*100), SetVolume(dB*100)
|
||||||
|
```
|
||||||
|
|
||||||
|
So retail pan is `sin(Δbearing)` scaled to **±15 → ±1500 centibels**, i.e.
|
||||||
|
only 15% of DirectSound's ±10000 range, and **zero inside 5 m**. Our
|
||||||
|
`AudioFalloff.PanFromRelative(relativeX, panRange = 20f)` is a linear
|
||||||
|
`x/20` clamp on a listener-relative X — a different model with a different
|
||||||
|
saturation curve and no near-field dead zone. (The `-15.0` sign also needs a
|
||||||
|
live A/B before trusting the left/right orientation.)
|
||||||
|
|
||||||
|
Also from `PlaySoundInternal` @ `0x0054FEC0`: the playing-buffer ring is
|
||||||
|
indexed `(curr_playing_buffer_ + i) & 0x8000000F` over `i < 0x10` —
|
||||||
|
**retail has exactly 16 concurrent voices**, and the steal decision compares
|
||||||
|
the candidate slot's stored `priority` (`SoundPlayingData.priority`, the
|
||||||
|
float straight from `SoundData.priority_`) against the incoming one. That is
|
||||||
|
what `priority_` is for; it is not an 0..7 eviction class.
|
||||||
|
|
||||||
|
Finally: `AudioModel.cs`'s claim that retail is "CPU-side inverse-square,
|
||||||
|
NOT DirectSound3DBuffer" is only half right. `SoundBuf::Create` @
|
||||||
|
`0x00552930` requests `DSBCAPS_CTRL3D` (`0x100B0`) and QueryInterfaces
|
||||||
|
`IDirectSound3DBuffer` when `m_3D` is set, falling back to the 2D
|
||||||
|
pan/volume path (`0x100E0`, `CTRLVOLUME|CTRLPAN|CTRLFREQUENCY`) when the
|
||||||
|
3D listener is unavailable. The attenuation math above is the 2D path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Wave format — retail vs DRW vs `WaveDecoder`
|
||||||
|
|
||||||
|
### 4a. On-disk layout
|
||||||
|
|
||||||
|
`DBWave::UnPack` @ `0x00551B90`:
|
||||||
|
|
||||||
|
```
|
||||||
|
u32 headerSize // format-chunk size
|
||||||
|
u32 dataSize
|
||||||
|
byte[headerSize] header // raw WAVEFORMATEX, no RIFF wrapper
|
||||||
|
byte[dataSize] data
|
||||||
|
```
|
||||||
|
|
||||||
|
(The allocation order in the disassembly is data-buffer first, header-buffer
|
||||||
|
second, but the *read* order is header then data — `memcpy(fmt, p,
|
||||||
|
headerSize); p += headerSize; memcpy(data, p, dataSize)`.)
|
||||||
|
|
||||||
|
DRW's `Wave.Unpack` reads `headerSize, dataSize, header[], data[]` — **exact
|
||||||
|
match**. Our `WaveDecoder`'s documented layout is also exact. ✅
|
||||||
|
|
||||||
|
`acclient.h:1199` `tWAVEFORMATEX` is `#pragma pack(1)`:
|
||||||
|
`wFormatTag u16, nChannels u16, nSamplesPerSec u32, nAvgBytesPerSec u32,
|
||||||
|
nBlockAlign u16, wBitsPerSample u16, cbSize u16` = 18 bytes. Our
|
||||||
|
`WaveDecoder` offsets (0, 2, 4, 14) match. ✅
|
||||||
|
|
||||||
|
### 4b. What retail does with a non-PCM wave
|
||||||
|
|
||||||
|
`SoundBuf::Create` @ `0x00552930`:
|
||||||
|
|
||||||
|
```
|
||||||
|
wf = DBObj::Get(QualifiedDataID(waveId, 0x0F /*DB_TYPE_WAVE*/)) // + 0x38 → WaveFile
|
||||||
|
if (wf->m_pwfmt->wFormatTag == 1) { // PCM
|
||||||
|
bufsize = wf->m_nDataSize
|
||||||
|
} else { // anything else
|
||||||
|
dst = { wFormatTag=1, nChannels=1, nSamplesPerSec=11025,
|
||||||
|
nAvgBytesPerSec=22050, nBlockAlign=2, wBitsPerSample=16, cbSize=0 }
|
||||||
|
acmStreamOpen(&phas, NULL, wf->m_pwfmt, &dst, ...)
|
||||||
|
acmStreamSize(phas, wf->m_nDataSize, &bufsize, 0)
|
||||||
|
}
|
||||||
|
CreateSoundBuffer(bufsize) ; CopyWaveToBuffer(this, wf)
|
||||||
|
if (non-PCM) { acmStreamClose(phas); phas = NULL; }
|
||||||
|
```
|
||||||
|
|
||||||
|
`SoundBuf::CopyWaveToBuffer` @ `0x005526D0`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Lock(buf, 0, bufsize, &p1, &n1, &p2, &n2, 0)
|
||||||
|
if (phas == NULL) memcpy(p1, wf->m_pData, ...) [+ wrap-around memcpy into p2]
|
||||||
|
else acmStreamPrepareHeader / acmStreamConvert / acmStreamUnprepareHeader
|
||||||
|
Unlock(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
So: **retail does not decode compressed waves itself.** It hands the source
|
||||||
|
`WAVEFORMATEX` to the Windows ACM (`msacm32`) and converts straight into the
|
||||||
|
locked DirectSound buffer, with a **hard-coded destination format of PCM
|
||||||
|
mono 11,025 Hz 16-bit**. Everything else is a raw `memcpy` of the dat bytes.
|
||||||
|
Our `AudioModel.cs` comment "we decode MP3 to PCM once at load (same as
|
||||||
|
retail does for long clips)" is right in spirit — retail decodes the whole
|
||||||
|
buffer once at `Create` time, not streaming — but the target format detail
|
||||||
|
is a concrete portable fact we should match if we ever add the decoder.
|
||||||
|
|
||||||
|
### 4c. Do we decode MP3 at all? No — and it costs exactly one wave
|
||||||
|
|
||||||
|
`grep` over `src/` and every `*.csproj`: **no MP3 or ADPCM decoder, and no
|
||||||
|
NAudio / NLayer / mpg123 package reference exists.** `WaveDecoder.Decode`
|
||||||
|
returns `null` for any `wFormatTag != 1`, `DatSoundCache` files the id in
|
||||||
|
`_negativeWaveIds`, and the sound is permanently silent.
|
||||||
|
|
||||||
|
Measured cost, `client_portal.dat`:
|
||||||
|
|
||||||
|
| Format tag | Count |
|
||||||
|
|---|---|
|
||||||
|
| `0x0001` PCM | **785** |
|
||||||
|
| `0x0055` MPEGLAYER3 | **1** |
|
||||||
|
|
||||||
|
The single MP3 is **wave `0x0A000393`**, header 30 bytes, data 5,120 bytes:
|
||||||
|
|
||||||
|
```
|
||||||
|
55 00 wFormatTag = 0x0055 MPEGLAYER3
|
||||||
|
01 00 nChannels = 1
|
||||||
|
11 2b 00 00 nSamplesPerSec = 11025
|
||||||
|
c4 09 00 00 nAvgBytesPerSec = 2500 (20 kbps → ~2.05 s of audio)
|
||||||
|
01 00 nBlockAlign = 1
|
||||||
|
00 00 wBitsPerSample = 0
|
||||||
|
0c 00 cbSize = 12
|
||||||
|
01 00 wID = MPEGLAYER3_ID_MPEG
|
||||||
|
02 00 00 00 fdwFlags = MPEGLAYER3_FLAG_PADDING_OFF
|
||||||
|
04 01 nBlockSize = 260
|
||||||
|
02 00 nFramesPerBlock = 2
|
||||||
|
71 05 nCodecDelay = 1393
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verdict on the "MP3-sourced waves are silently broken" hypothesis: true
|
||||||
|
but immaterial — 1 wave of 786 (0.13%), one ~2-second mono cue.** Adding an
|
||||||
|
MP3 decoder is a footnote, not a P0. There are **no ADPCM (`0x0002`) waves
|
||||||
|
at all.**
|
||||||
|
|
||||||
|
### 4d. Real-wave PCM parameter ranges (what our decoder must survive)
|
||||||
|
|
||||||
|
| Parameter | Distribution across the 785 PCM waves |
|
||||||
|
|---|---|
|
||||||
|
| header size | 18 bytes (all of them) |
|
||||||
|
| channels | mono 772, **stereo 14** |
|
||||||
|
| bits/sample | 16 × 714, **8 × 71** |
|
||||||
|
| sample rate | 11025 (471), 22050 (164), 44100 (89), 8000 (25), 32000 (20), 16000 (10), plus 5500/6000/7333/8287/12000 singletons |
|
||||||
|
|
||||||
|
`WaveDecoder.Decode` handles all of this correctly (it reads the real
|
||||||
|
`nChannels`/`nSamplesPerSec`/`wBitsPerSample` and returns the raw bytes).
|
||||||
|
Two things to check downstream, outside this lane: (a) 71 waves are **8-bit
|
||||||
|
unsigned PCM** — OpenAL needs `AL_FORMAT_MONO8`/`STEREO8`, and 8-bit PCM in
|
||||||
|
WAV is *unsigned* while 16-bit is signed; (b) the odd rates
|
||||||
|
(5500/7333/8287) are fine for OpenAL but will resample.
|
||||||
|
|
||||||
|
Minor: `WaveDecoder` guards `header.Length < 14` and reads bits at offset 14
|
||||||
|
only when `Length >= 16`. Every real wave is 18 or 30 bytes, so the fallback
|
||||||
|
`bitsPer = 16` never fires on retail data — but note the MP3 header has
|
||||||
|
`wBitsPerSample == 0`, which the current `bitsPer == 0 ? 16` fallback would
|
||||||
|
silently paper over if MP3 ever reached that line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `CSoundDesc` — what it actually is (not the per-object table)
|
||||||
|
|
||||||
|
`acclient.h:53246`: `CSoundDesc` is a member of **`CRegionDesc`**, alongside
|
||||||
|
`SkyDesc`, `CSceneDesc`, `CTerrainDesc`, `FogDesc`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct __cppobj CSoundDesc { AC1Legacy::SmartArray<AmbientSTBDesc *> stb_desc; };
|
||||||
|
```
|
||||||
|
|
||||||
|
`CSoundDesc::UnPack` @ `0x005028D0` = `u32 count` + `AmbientSTBDesc[count]`,
|
||||||
|
which DRW's `SoundDesc.Unpack` matches exactly. ✅
|
||||||
|
|
||||||
|
So **`CSoundDesc` is the region's ambient sound-table list, not the
|
||||||
|
per-object sound-table pointer.** It feeds `PlayAmbientSound` /
|
||||||
|
`PlayAmbientSoundFromCenter`.
|
||||||
|
|
||||||
|
The per-object path is different. `CPhysicsObj::sound_table` (`acclient.h`
|
||||||
|
offset via `arg2->sound_table` in `PlaySoundA` @ `0x00550B20`) is resolved
|
||||||
|
from a **DataID on the object**, with a Setup-level default:
|
||||||
|
|
||||||
|
* `0x00514F76` — `id = desc->stable_id.id`; if non-zero,
|
||||||
|
`sound_table = DBObj::Get(QualifiedDataID(id, 0x22))`.
|
||||||
|
* `0x00513A00` — `id = setup->default_stable_id.id`, same resolution.
|
||||||
|
* Sibling field `phstable_id` / `default_phstable_id` is the *physics-script*
|
||||||
|
table, a separate thing.
|
||||||
|
* `stable_id` / `phstable_id` are wire-serialised
|
||||||
|
(`0x0051DAEC`, `0x0051DB03`) — i.e. the server can override the Setup's
|
||||||
|
default per object.
|
||||||
|
|
||||||
|
`PlaySoundA(SoundType, CPhysicsObj*)` therefore reads
|
||||||
|
`obj->sound_table`, calls `GetSound(stype, table)`, gates on
|
||||||
|
`PlayProbability`, and plays at `obj->m_position`. Our
|
||||||
|
`IEntitySoundTable.GetSoundTableId(entityId)` seam is the right shape; the
|
||||||
|
resolution order to match is **`stable_id` from the object's physics desc,
|
||||||
|
falling back to the Setup's `default_stable_id`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Test inventory — golden vs self-referential
|
||||||
|
|
||||||
|
| File | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| `SoundIdConformanceTests.cs` (272 lines) | ✅ **Genuine golden conformance.** A 205-entry table transcribed from `acclient.h:4569 enum SoundType`, cross-checked against ACE and DRW, with tests for exact values, no extras, dense coverage to `0xCC`, and agreement with the DRW enum used at runtime. This is the model the rest of the lane should follow. |
|
||||||
|
| `WaveDecoderTests.cs` (104 lines) | ⚠️ **Synthetic, hand-built headers; no dat-derived golden values.** It builds an 18-byte PCM header with plausible values (mono/22050/16 — a rate that exists in the dats, so plausible) and asserts our own parse. `Decode_Mp3Header_ReturnsNull` and `Decode_AdpcmHeader_ReturnsNull` **pin the missing-decoder behaviour as correct** — they will have to be inverted when a decoder lands. No test exercises the real 8-bit or stereo waves, or the real MP3's `wBitsPerSample == 0`. |
|
||||||
|
| `DatSoundCookbookTests.cs` → `SoundCookbookTests.cs` (97 lines) | ❌ **Entirely self-referential, and it locks in the wrong model.** `Roll_WeightedEntries_DistributionMatches` asserts 50/30/20 split from a CDF walk; `Roll_SilenceTail_ReturnsNullOccasionally` asserts the invented 40%-silence tail; `Roll_SingleEntry_AlwaysReturnsIt` explicitly asserts the D1 bug (a 0.5-probability single entry returns unconditionally). Zero retail anchors. Every one of these five tests must change when §2 is ported. |
|
||||||
|
| `DatSoundCacheTests.cs` (245 lines) | ✅ **Correctly scoped and honest** — LRU eviction order, byte accounting, negative-result memoisation, oversize bypass, concurrent-decode dedup. It's infrastructure, not retail behaviour, and it doesn't pretend otherwise. Only caveat: `GetWave_UnsupportedFormat_...` uses `MakeMp3Wave` and asserts the null path, same "pins a gap as correct" note as above. |
|
||||||
|
| DRW's `SoundTableTests.cs` / `WaveTests.cs` | ⚠️ Round-trip only (write then read). Prove Pack↔Unpack symmetry, prove nothing about retail's layout. |
|
||||||
|
|
||||||
|
**Nothing in the tree conformance-tests the SoundTable byte layout, the
|
||||||
|
selection algorithm, the probability gate, the attenuation curve, or the
|
||||||
|
priority/volume semantics against retail.** The only golden table is the
|
||||||
|
SoundType enum.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Divergence list, ranked by audible impact
|
||||||
|
|
||||||
|
| # | Divergence | Evidence | Audible symptom | Fix size |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **1** | **`probability_` is never applied.** `SoundCookbook.Roll` returns single-entry lists unconditionally, and 4,183 of 4,184 real entries are single-entry — 686 of those have probability < 1.0. | `SoundCookbook.cs:44`; `PlayProbability` @ `0x005500E0`; dat scan §1c/§2c | Creature idle chatter (`Speak1`, 49 entries at 5%) fires ~20× too often; wound/attack/swoosh/impact sounds never drop; six 0.01%-chance easter eggs play every time. **This is the single loudest wrong thing in the audio stack.** | small — add a Bernoulli gate at the play site |
|
||||||
|
| **2** | **`priority_` treated as `int` 0..7.** Retail is a float in [0,1] driving 16-voice steal ordering. | `AudioModel.cs:24` comment; `OpenAlAudioEngine.cs:297` `(uint)Math.Clamp((int)priority,0,7)`; dat histogram (mode 0.7) | 4,100+ entries collapse to priority 0 → voice-steal is effectively arbitrary; important sounds (death, casting) lose to footsteps. | small — keep the float; port the ring compare from `0x0054FF70` |
|
||||||
|
| **3** | **`volume_` clamped to [0,1] before falloff.** Retail clamps `25·vol/d²` after the division, so `volume_ > 1` extends the full-volume plateau. 44 real entries exceed 1.0 (31 at 10.0). | `AudioHookSink.cs:114`; `GetAttenuation` @ `0x00550020` with `0x0086F404 = 25.0` | Wound/death/impact/ambient sounds audible to ~5 m instead of ~15.8 m — they feel local and thin instead of carrying. | small |
|
||||||
|
| **4** | **Falloff min-distance 1 m vs retail 5 m; no −50 dB cutoff; dB curve absent.** | `AudioFalloff.AttenuationAt` default `minDistance = 1.0f`; retail `VOL_MIN_DIST = 5.0`, `VOL_MIN = -50` dB, `dB = ceil(20·log10(v))` | Everything is quieter than retail at 1–5 m and audible far past retail's cutoff. | small |
|
||||||
|
| **5** | **Pan model is linear `x/20`; retail is `sin(Δbearing) · ±15` with a 5 m dead zone.** | `AudioFalloff.PanFromRelative`; `0x00550170` with `0x007CAF58 = -15.0` | Wrong stereo image; near sounds pan when retail keeps them centred; overall pan ~6× stronger than retail's ±1500/±10000. Sign needs a live A/B. | small |
|
||||||
|
| **6** | **Selection is a CDF walk, not `(int)(roll·(n-1))`.** | `SoundCookbook.cs:46-59`; `GetSound` @ `0x005506C8` | Nearly inaudible on retail data (one 2-entry sound exists). Matters only for retail-faithfulness and for custom content. Note retail's `n-1` means the last variant is **never** played — port verbatim, don't "fix". | small |
|
||||||
|
| **7** | **Invented "silence tail"** (`null` when Σprobability < 1). | `SoundCookbook.cs:53-59` | With single-entry lists at probability 0.05, our code returns the entry (count==1 short-circuit) — so the tail is dead code that would misfire the moment multi-entry lists appear. | delete |
|
||||||
|
| **8** | **No MP3 decoder** → `0x0A000393` (one ~2 s mono cue) is permanently silent. Retail uses winmm ACM into PCM mono/11025/16-bit. | `WaveDecoder.cs:87`; `SoundBuf::Create` @ `0x00552AD0`; dat scan (1 of 786) | One missing sound effect. **Not a P0.** | medium (needs a managed decoder) |
|
||||||
|
| **9** | **Lazy wave load vs retail's eager `CreateSound` at table UnPack.** | `SoundTableData::UnPack` `0x00552451`; `DatSoundCache.GetWave` | Possible first-play hitch; retail has none. Architecturally our choice is better for the 30-bot fleet. | none — document |
|
||||||
|
| **10** | **DRW's SoundTable parse is non-recursive** (grandchild count read into `SoundData.Unknown` and discarded). | `SoundData.generated.cs`; `SoundTableData::UnPack` recursion at `0x00552503` | Zero impact on retail data (0 of 190 tables nest deeper than 2). Would silently corrupt custom deep tables. | none — comment |
|
||||||
|
| **11** | **`AcDream.Core.Audio.SoundEntry` / `ISoundCache` are dead code whose invented comments (`Priority 0..7`, `VolumeBase 0..1`, `PitchMin/Max`, `Loop`, `Is3D`) are the documented source of divergences 2 and 3.** | `AudioModel.cs:21-31, 110-115`; no constructors anywhere in `src/` or `tests/` | none directly | delete or correct — highest value per line changed |
|
||||||
|
| **12** | **`AudioModel.cs` claims retail never uses `IDirectSound3DBuffer`.** It does, when `m_3D` is set and a 3D listener exists. | `SoundBuf::Create` `0x0055295E` (`0x100B0` = `DSBCAPS_CTRL3D`), `0x00552B58` QueryInterface | none directly; the doc misleads future work | doc fix |
|
||||||
|
| **13** | **Voice limit unmodelled.** Retail has exactly 16 concurrent buffers with a priority-based steal. | `PlaySoundInternal` `0x0054FEC0` (`& 0x8000000F`, `i < 0x10`) | dense-combat mix density differs from retail | medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Things worth pinning as conformance tests
|
||||||
|
|
||||||
|
1. `SoundTableData` byte layout — a golden hex fixture for one real table
|
||||||
|
(e.g. `0x200000A8`, the only 2-entry one) asserting id/priority/probability/
|
||||||
|
volume for both entries and the root dummy `{0, …}`.
|
||||||
|
2. `Pick()` — `n == 2` must always return index 0 (the `n-1` truncation),
|
||||||
|
citing `0x005506C8`; `n == 1` returns index 0.
|
||||||
|
3. `PlayProbability(0.0f)` never plays; `PlayProbability(1.0f)` always plays;
|
||||||
|
`PlayProbability(0.05f)` over 100k trials lands in [4.7%, 5.3%].
|
||||||
|
4. `GetAttenuation` golden rows, computed from the recovered constants:
|
||||||
|
`(dist, vol) → dB` for `(1, 1) → 0`, `(5, 1) → 0`,
|
||||||
|
`(10, 1) → ceil(20·log10(0.25)) = -12`,
|
||||||
|
`(10, 10) → ceil(20·log10(1.0)) = 0` (clamped),
|
||||||
|
`(50, 1) → ceil(20·log10(0.01)) = -40`,
|
||||||
|
`(200, 1) → below −50 → inaudible, returns false`.
|
||||||
|
5. Wave header parse against the real `0x0A000393` MP3 header bytes (§4c)
|
||||||
|
and against at least one real 8-bit and one real stereo wave.
|
||||||
|
6. A dat-backed invariant test (gated on the dats being present, like the
|
||||||
|
existing installed-DAT gates): every SoundTable in `client_portal.dat`
|
||||||
|
parses, root `num_stdatas_ == 1` with `sound_id_ == 0`, and no table has
|
||||||
|
grandchildren — this is the guard that keeps DRW's flattening honest.
|
||||||
491
docs/research/2026-08-08-audio-retail-music-absence.md
Normal file
491
docs/research/2026-08-08-audio-retail-music-absence.md
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
# Lane 6 — Retail MUSIC system (MediaMachine / MD_Data_Sound / winmm MIDI)
|
||||||
|
|
||||||
|
Read-only research note. Sources: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||||||
|
(Sept 2013 EoR build, PDB-named), `acclient.h` (verbatim retail structs),
|
||||||
|
`symbols.json`, plus `references/DatReaderWriter/`, `references/ACViewer/`,
|
||||||
|
the retail install at `C:\Turbine\Asheron's Call\`, and the live
|
||||||
|
`UserPreferences.ini`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Headline finding — retail EoR HAS NO MUSIC SYSTEM
|
||||||
|
|
||||||
|
This is the load-bearing result and it contradicts the standing assumption in
|
||||||
|
`docs/research/deepdives/r05-audio-sound.md` §6.
|
||||||
|
|
||||||
|
Three independent pieces of evidence, all from the exact PDB-paired 2013 build:
|
||||||
|
|
||||||
|
1. **`midiPlay` has zero callers.** The only three occurrences of address
|
||||||
|
`0x00553390` in the whole 65 MB pseudo-C are the function's own
|
||||||
|
definition/open/close lines (`acclient_2013_pseudo_c.txt:350072,350074,350133`).
|
||||||
|
The single internal call is `midiPlayNext` → `midiPlay`
|
||||||
|
(`:350147`), and `midiPlayNext` is itself only reachable from the
|
||||||
|
`MidiProc` buffer-done callback (`:350223`) — i.e. it only ever advances a
|
||||||
|
*queue that nothing ever fills*.
|
||||||
|
2. **Both MIDI callbacks are permanently null.** `midiEventCallback` and
|
||||||
|
`midiStartCallback` are statically initialised to 0
|
||||||
|
(`:1185597`, `:1185598`) and there is no assignment site anywhere in the
|
||||||
|
image. `MidiProc` null-checks them on every event and no-ops.
|
||||||
|
3. **No music preference and no music files.** `SoundManager::InitPrefs` /
|
||||||
|
`ShutDown` register exactly eight sound preferences —
|
||||||
|
`SoundDisabled`, `SoundVolume`, `AmbientSoundDisabled`,
|
||||||
|
`AmbientSoundVolume`, `InterfaceSoundDisabled`, `InterfaceSoundVolume`,
|
||||||
|
`SoundFeatures`, `PlaySoundOnlyWhenActive` (`:346764`–`:346810` region,
|
||||||
|
Unregister list at `:00550367`–`:005503ad`). The live
|
||||||
|
`%USERPROFILE%\Documents\Asheron's Call\UserPreferences.ini` `[Sound]`
|
||||||
|
section contains exactly those keys — **no music volume, no music toggle**.
|
||||||
|
And `C:\Turbine\Asheron's Call\` contains **zero `.mid` / `.rmi` / `.mp3` /
|
||||||
|
`.wav`** files; the only media file on disk is `turbine_logo_ac.avi`.
|
||||||
|
4. **The word "music" does not appear anywhere in the 65 MB pseudo-C**
|
||||||
|
(case-insensitive grep: 0 hits), and `SoundType` (the 0x00–0xCC enum,
|
||||||
|
`acclient.h:4569`) has **no music member**.
|
||||||
|
|
||||||
|
So: the client *links* a complete Microsoft-sample-derived SMF streaming
|
||||||
|
player, initialises it at startup (`SoundManager::Init` → `midiSetup()`,
|
||||||
|
`:346764`), tears it down at shutdown (`SoundManager::ShutDown` →
|
||||||
|
`midiCleanup()`, `:346655`), and **never hands it a file**. It is dead
|
||||||
|
infrastructure — a vestige of a 1999 design decision that was cut.
|
||||||
|
|
||||||
|
What players actually hear as "music" in retail EoR is one of three things,
|
||||||
|
all of them ordinary DAT `Wave` (0x0A) PCM played through the normal
|
||||||
|
DirectSound path:
|
||||||
|
|
||||||
|
| Perceived as | Actually is | Retail mechanism |
|
||||||
|
|---|---|---|
|
||||||
|
| Login / splash score | audio track of `turbine_logo_ac.avi` | `MD_Data_Movie` → DirectShow `IGraphBuilder` |
|
||||||
|
| Dungeon "chanting/drums/whispers" atmosphere | UI SoundTable stingers | `CPlayerSystem::Handle_Admin__Environs` codes 0x65–0x7C |
|
||||||
|
| Outdoor/dungeon soundscape | region ambient sound rolls | `Ambient` priority queue + `AmbientSTBDesc` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The MIDI subsystem (documented for completeness / correction of r05)
|
||||||
|
|
||||||
|
Free functions, all at `0x00552f60`–`0x00553840`. It is a near-verbatim port of
|
||||||
|
Microsoft's `MIDIPLYR` SDK sample, including the literal event name
|
||||||
|
`"Wait For Buffer Return"` (`:350321`).
|
||||||
|
|
||||||
|
| Symbol | Addr | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `midiSetup()` | `0x00553770` | `midiOutGetNumDevs`, fill `dwVolCache[16]`/`dwVolPctCache[16]` with 100, `CreateEventA("Wait For Buffer Return")`, `midiStreamOpen(&hStream, &uMIDIDeviceID, 1, MidiProc, 0, 0x30000)`. Sets `MidiIsSetup`. |
|
||||||
|
| `StreamBufferSetup(char* path)` | `0x00553030` | allocates **6 buffers × 0x400 bytes** (`LocalAlloc(LMEM_ZEROINIT, 0x423)` then 32-byte-aligned), `ConverterInit(path)`, `midiStreamProperty(…, 0x80000001)` = set time division, primes all 6 via `ConvertToBuffer` + `midiOutPrepareHeader` + `midiStreamOut`. |
|
||||||
|
| `midiPlay(path, loop, immediate, tempoMul)` | `0x00553390` | if already playing and `immediate==0` → stash into `pending`/`pending_loop`/`dwQueuedTempoMultiplier`, set `is_pending`, return (that is the "queue next track" path). Otherwise `midiStop()`, `StreamBufferSetup`, then per-channel `midiOutShortMsg(0xB0|ch, ctrl 7, vol)` for all 16 channels, `midiStreamRestart`. `tempoMul` is a percentage (default `0x64` = 100). |
|
||||||
|
| `midiPlayNext()` | `0x005534c0` | pops `pending` → `midiPlay(pending, pending_loop, 1, …)`. |
|
||||||
|
| `MidiProc` | `0x00553500` | `MOM_DONE` (0x3C9) refills/rotates buffers mod 6; on end-of-data (`uCallbackStatus == 0x12C`) waits for all 6 buffers back, then either `midiPlayNext()` if a track is queued or `midiStop()`. `MOM_POSITIONCB` (0x3CA) sniffs the stream: caches controller-7 volume per channel and forwards note-on/off + volume on **channels 14/15 only** to `midiEventCallback` (a game-sync hook — never installed). |
|
||||||
|
| `midiStop()` | `0x00553240` | `midiStreamStop`, `midiOutReset`, `WaitForSingleObject(hBufferReturnEvent, 0x7D0 = 2000 ms)`, `ConverterCleanup`, `FreeBuffers`, close+reopen the stream, reset `is_pending`/`pending_loop`/`dwQueuedTempoMultiplier = 100`. |
|
||||||
|
| `SetChannelVolume(ch, pct)` | `0x00552f60` | `midiOutShortMsg(0xB0|ch, ctrl 7, dwVolCache[ch]*pct/100)`. The only volume control; **no fade, no crossfade, no ramp anywhere**. |
|
||||||
|
| `ConverterInit(path)` | `0x00554530` | `CreateFileA(path, GENERIC_READ, …)` → reads `'MThd'` (`0x6468544D`), byte-swaps header, `dwFormat`/`dwTrackCount`/`dwTimeDivision`, then per track reads `'MTrk'` (`0x6B72544D`) into a 0x400 window. **Standard MIDI File from a loose disk path — never from a DAT.** |
|
||||||
|
|
||||||
|
### Corrections to `r05-audio-sound.md` §6
|
||||||
|
|
||||||
|
| r05 claim | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| "Music is MIDI, streamed through midiStreamOpen" | **Correct as to mechanism**, wrong as to it being used. |
|
||||||
|
| 6 × 1024-byte buffers, "Wait For Buffer Return" event, 16-channel volume arrays | **Confirmed.** |
|
||||||
|
| "MThd/MTrk parsing at FUN_00555150" | Right idea, wrong address in the named build: `ConverterInit` @ `0x00554530`. |
|
||||||
|
| "pan (0x0A)" via `midiOutShortMsg` | **Not found.** Only controller **7 (volume)** is written (`SetChannelVolume`, `midiPlay`). No pan CC. |
|
||||||
|
| "Track selection is by the game code calling `PlayMusic(path, loop)` — driven by region/area rules" | **REFUTED.** No such caller and no region→track table exists. This sentence is the source of the `PlayMusic(string resourceName, bool loop)` shape in `IAudioEngine` — that signature is an invention, not a retail port. |
|
||||||
|
| "Recommended: convert MIDI to OGG offline" | Moot — there is no MIDI content to convert. |
|
||||||
|
|
||||||
|
**Consequence for the port:** a MIDI synth is *not* needed. Neither is an OGG
|
||||||
|
music bus. There is nothing to be faithful to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `MediaMachine` — what it actually is (a per-UI-element media bytecode VM)
|
||||||
|
|
||||||
|
`MediaMachine` is **not** the music system. It is the interpreter for the
|
||||||
|
*media script* attached to every UI element **state** in the LayoutDesc DAT.
|
||||||
|
Playing a sound is one of its eleven instructions.
|
||||||
|
|
||||||
|
### Ownership chain
|
||||||
|
|
||||||
|
```
|
||||||
|
LayoutDesc (DBObj) acclient.h:33881
|
||||||
|
└─ ElementDesc : StateDesc acclient.h:33693
|
||||||
|
└─ StateDesc acclient.h:33640
|
||||||
|
└─ SmartArray<MediaDesc*> m_media ← the script
|
||||||
|
UIElement
|
||||||
|
└─ MediaMachine m_mediaMachine acclient.h:33786
|
||||||
|
├─ UIElement* m_owner
|
||||||
|
├─ SmartArray<MediaDesc*> m_array ← deep copy of the active state's m_media
|
||||||
|
└─ unsigned m_curIndex ← the program counter
|
||||||
|
```
|
||||||
|
|
||||||
|
`MediaMachine : UIListener` (`acclient.h:33873`).
|
||||||
|
|
||||||
|
### State machine (this is the whole thing)
|
||||||
|
|
||||||
|
- `MediaMachine::Reset(const SmartArray<MediaDesc*>&)` @ `0x00465d90`
|
||||||
|
(`:112681`): `Cleanup()`, then deep-copy every `MediaDesc` via
|
||||||
|
`MediaDesc::CreateMediaType(const MediaDesc*)`, set `m_curIndex = 0`, and
|
||||||
|
immediately `Update()`. Called from `UIElement::SetState` →
|
||||||
|
`MediaMachine::Reset(&m_mediaMachine, &m_desc.m_media)` (`:108863`), and on
|
||||||
|
element copy (`:111688`, `:111745`).
|
||||||
|
- `MediaMachine::Update()` @ `0x00465ba0` (`:112526`) — the interpreter loop:
|
||||||
|
1. `UIListener::UnRegisterForGlobalMessage(this, 3)`.
|
||||||
|
2. While `m_curIndex < m_array.m_num`: dispatch on `m_type - 1` through an
|
||||||
|
11-entry jump table (`jump_table_465cc0`, `:112621`) to the matching
|
||||||
|
`Update_X(desc)`.
|
||||||
|
3. **The return value is "may I advance?"** — non-zero ⇒ `m_curIndex++` and
|
||||||
|
continue in the same call; **zero ⇒ break** (the instruction is still
|
||||||
|
blocking).
|
||||||
|
4. On break, `UIListener::RegisterForGlobalMessage(this, 3)` — i.e. subscribe
|
||||||
|
to the per-tick global message so the machine resumes next frame.
|
||||||
|
- `MediaMachine::ListenToGlobalMessage(msg, _)` @ `0x00465cf0`: `if (msg == 3)
|
||||||
|
Update()`. **Global message 3 is the machine's clock.** There is no
|
||||||
|
dedicated music/media tick.
|
||||||
|
- `Cleanup()` @ `0x00465af0`: virtual-deletes every owned `MediaDesc`, zeroes
|
||||||
|
the array. Called by dtor and by `Reset`.
|
||||||
|
|
||||||
|
Termination: the machine runs off the end of the array and stops (no
|
||||||
|
re-registration). A `Jump` instruction is what makes a script loop forever.
|
||||||
|
|
||||||
|
### Instruction set (`MediaDesc::m_type`, 1-based)
|
||||||
|
|
||||||
|
Verified from `MediaDesc::CreateMediaType(uint32_t)` @ `0x0069d420`
|
||||||
|
(`:675786`) and each ctor's `MediaDesc::MediaDesc(this, N)`:
|
||||||
|
|
||||||
|
| # | Type | Struct (acclient.h) | Blocking? | Semantics |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Movie | `MD_Data_Movie` :34160 — `PStringBase m_strFileName`, `bool m_StretchToFullScreen`, `MovieTheatre*` | yes, until done | `MD_Data_Movie::Update(owner)` @ `0x0069d489`; DirectShow (`ATL::CComPtr<IGraphBuilder>` :34191). Requires owner visible-bit `(m_owner+0x554) >> 0x11 & 1`. |
|
||||||
|
| 2 | Alpha | `MD_Data_Alpha` :34118 — `DID m_file` | no | alpha mask image |
|
||||||
|
| 3 | Anim | `MD_Data_Anim` :34101 — `float m_duration`, `m_drawMode`, `SmartArray<DID> m_frames`, `double m_StartTime`, `int m_displayedFrameNum` | yes, for `m_duration` | flipbook; latches `m_StartTime` on first visit (sentinel `-1.0`) |
|
||||||
|
| 4 | Cursor | `MD_Data_Cursor` :34168 — `DID m_file`, `int m_xHotspot`, `m_yHotspot` | no | `UIElement::SetCursor` |
|
||||||
|
| 5 | Image | `MD_Data_Image` :34111 — `DID m_file`, `m_drawMode` | no | set the element's picture |
|
||||||
|
| 6 | Jump | `MD_Data_Jump` :34132 — `uint m_jumpItemIndex`, `float m_probability` | no | `RollDice(0,1)` vs probability; on pass `m_curIndex = m_jumpItemIndex - 1` (then the loop's `++` lands exactly on `m_jumpItemIndex`). **This is the loop primitive.** |
|
||||||
|
| 7 | Message | `MD_Data_Message` :34139 — `uint m_messageID`, `float m_probability` | no | `UIElement::BroadcastElementMessage(owner, m_messageID, 0, 0)` |
|
||||||
|
| 8 | Pause | `MD_Data_Pause` :34124 — `float m_minDuration`, `m_maxDuration`, `double m_endTime` | **yes** | first visit: `m_endTime = Timer::compute_time() + RollDice(min,max)`; blocks until now ≥ endTime, then resets `m_endTime = -1.0` |
|
||||||
|
| 9 | **Sound** | `MD_Data_Sound` :34146 — `DID m_file`, `SoundType m_stype` | no | see §3 |
|
||||||
|
| 10 | State | `MD_Data_State` :34153 — `uint m_stateID`, `float m_probability` | terminal | probabilistic `owner->SetState(m_stateID)`; **always returns 0** (`:112068`) so the machine stops — the new state's `Reset` takes over |
|
||||||
|
| 11 | Fade | `MD_Data_Fade` :34176 — `float m_startAlpha`, `m_endAlpha`, `m_duration`, `double m_startTime` | **yes** | see §4 |
|
||||||
|
|
||||||
|
Sentinel convention: `m_StartTime` / `m_endTime` / `m_startTime` use the
|
||||||
|
double `-1.0` (`0xBFF00000` in the high word) as "not yet started"; each
|
||||||
|
blocking instruction resets it to `-1.0` when it completes, so a `Jump` back
|
||||||
|
over it re-arms it.
|
||||||
|
|
||||||
|
### Text/serialised form
|
||||||
|
|
||||||
|
`MediaDesc::ToFileNode` / `CreateFromFileNode` (`0x0069d740` / `0x0069d7e0`)
|
||||||
|
read a `MediaType` node (keyword `"MediaType"`, `KW_MEDIATYPE` @ `:821851`)
|
||||||
|
under a `"Media"` node (`KW_MEDIA` @ `:821841`), enum-name table 14. Per-type
|
||||||
|
keywords seen in the init block around `:821780`–`:821960`:
|
||||||
|
`MinDuration`, `MaxDuration`, `Probability`, `SoundName`, `SoundTable`,
|
||||||
|
`StartAlpha`, `EndAlpha`, `Duration`, `StateID`, `StretchToFullScreen`,
|
||||||
|
`NoDBFile`, `PassToChildren`. `StateDesc::LoadMedia` @ `0x0069c950`
|
||||||
|
(`:674969`) appends each parsed desc; `StateDesc::ConcatenateMedia`
|
||||||
|
@ `0x0069c9b0` merges a parent state's script into a child's.
|
||||||
|
|
||||||
|
`DatReaderWriter` already models all of this:
|
||||||
|
`references/DatReaderWriter/DatReaderWriter/Generated/Types/MediaDesc.generated.cs`
|
||||||
|
(abstract + `MediaType` dispatch) and `MediaDescSound.generated.cs`
|
||||||
|
(`uint File`, `Sound Sound`). **The binary layout is `int32 mediaType, int32
|
||||||
|
type, uint32 file, uint32 sound`** — note the doubled type field, which the
|
||||||
|
generated reader reproduces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `MediaMachine::Update_Sound` — the authored-sound instruction
|
||||||
|
|
||||||
|
`0x004658b0` (`:112264`). Full decode:
|
||||||
|
|
||||||
|
```
|
||||||
|
Update_Sound(MD_Data_Sound* d):
|
||||||
|
if (d == null || m_owner == null) return 0; // 0 = block (dead-end)
|
||||||
|
|
||||||
|
if (d->m_stype == Sound_Invalid) // 0
|
||||||
|
# m_file is a direct Wave DID
|
||||||
|
if (d->m_file.id != 0)
|
||||||
|
SoundManager::PlaySoundFromCenter(d->m_file, 1.0f) # volume literal 0x3F800000
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
# m_file is a SoundTable DID; look it up as DBO type 0x22
|
||||||
|
CSoundTable* st = DBObj::Get(QualifiedDataID(d->m_file.id, 0x22))
|
||||||
|
if (st != null)
|
||||||
|
SoundManager::PlaySoundFromCenter(d->m_stype, st)
|
||||||
|
return 1
|
||||||
|
return 1 # falls through, still advances
|
||||||
|
```
|
||||||
|
|
||||||
|
Two shapes, discriminated by `m_stype`:
|
||||||
|
|
||||||
|
- `m_stype == Sound_Invalid` ⇒ **`m_file` is a `Wave` DID (0x0A……)**, played at
|
||||||
|
literal volume 1.0 via the `PlaySoundFromCenter(DID, float)` overload
|
||||||
|
(`0x005509e0`, `:346951`) which looks the wave up in
|
||||||
|
`SoundManager::sound_hash_`.
|
||||||
|
- `m_stype != Sound_Invalid` ⇒ **`m_file` is a `SoundTable` DID (0x20……)** and
|
||||||
|
`m_stype` selects the row; `PlaySoundFromCenter(SoundType, CSoundTable*)`
|
||||||
|
(`0x00550950`, `:346927`) rolls `SoundManager::GetSound` (weighted pick over
|
||||||
|
`SoundTableData::data_[]` with priority/probability/volume) and plays it.
|
||||||
|
|
||||||
|
DBO types confirmed: `CSoundTable::GetDBOType() == 0x22` (`:349100`),
|
||||||
|
`DBWave::Get()` uses `QualifiedDataID(id, 0xF)` (`:349327`).
|
||||||
|
|
||||||
|
**Both go through the *interface* channel**, not a music channel:
|
||||||
|
`PlaySoundFromCenter` gates on `SoundManager::interface_sounds_enabled` and
|
||||||
|
`s_bPlaySoundOnlyWhenActive && Device::m_bIsActiveApp`, then
|
||||||
|
`GetAttenuation(0f, vol, &out, /*isAmbient=*/0)` and
|
||||||
|
`PlaySoundInternal(buf, /*position=*/0, vol)` — position 0 ⇒ non-positional,
|
||||||
|
"from center". So the UI/authored-media bus == the interface-sound bus, and
|
||||||
|
the retail Interface Sound Volume slider is its only volume control.
|
||||||
|
|
||||||
|
This is the closest thing retail has to "the client plays a piece of authored
|
||||||
|
audio because a UI state was entered" — and it is the mechanism the login
|
||||||
|
screen and any title-card audio would use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Fades — the only fade math in the media system (and it is alpha, not audio)
|
||||||
|
|
||||||
|
`MediaMachine::Update_Fade` @ `0x00465930` (`:112307`):
|
||||||
|
|
||||||
|
```
|
||||||
|
now = Timer::compute_time()
|
||||||
|
target = owner->m_object ?? owner->m_parent->GetObjectA() # the drawable
|
||||||
|
if (d->m_startTime == -1.0) d->m_startTime = now # latch on first visit
|
||||||
|
|
||||||
|
dur = d->GetDuration() # == m_duration
|
||||||
|
if (fabs(dur) >= 0.000199999995f) # EPSILON = 2e-4 s
|
||||||
|
t = (now - d->m_startTime) / dur
|
||||||
|
else
|
||||||
|
t = 1.0f
|
||||||
|
t = clamp(t, 0.0f, 1.0f) # two-sided clamp
|
||||||
|
|
||||||
|
alpha = d->m_startAlpha + (d->m_endAlpha - d->m_startAlpha) * t # plain lerp
|
||||||
|
target->vtable[0x48](alpha) # set element alpha
|
||||||
|
|
||||||
|
if (t >= 1.0f) { d->m_startTime = -1.0; return 1 } # done → advance
|
||||||
|
return 0 # still fading → block
|
||||||
|
```
|
||||||
|
|
||||||
|
Notable: **linear** interpolation, no easing; duration epsilon
|
||||||
|
`2e-4 s`; a zero/short duration snaps to `endAlpha` in one tick.
|
||||||
|
**There is no audio fade, no crossfade, and no volume ramp anywhere in
|
||||||
|
`MediaMachine` or in the MIDI code.** `SetChannelVolume` is an instantaneous
|
||||||
|
CC-7 write. Any crossfade in an acdream music feature would be new design, not
|
||||||
|
a port.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Trigger map — every code site that starts "music-adjacent" audio
|
||||||
|
|
||||||
|
| Event | Site | What plays |
|
||||||
|
|---|---|---|
|
||||||
|
| UI element enters a state | `UIElement::SetState` → `MediaMachine::Reset` (`:108863`) → `Update_Sound` (`:112589`) | authored `MD_Data_Sound` (Wave DID or SoundTable+SoundType) |
|
||||||
|
| Per-tick continuation of a blocked script | `MediaMachine::ListenToGlobalMessage(3)` → `Update` (`:112642`) | next instruction in the script |
|
||||||
|
| UI element cleanup / hide | `MediaMachine::Cleanup` (`:109993`), `Update` (`:110007`) | — (stops the script, does **not** stop already-playing sounds) |
|
||||||
|
| Splash / intro | `MD_Data_Movie::Update` (`:675489`) → DirectShow on `m_strFileName` | `turbine_logo_ac.avi` (its audio track *is* the theme) |
|
||||||
|
| Teleport start (portal in) | `gmSmartBoxUI` teleport anim, `:218903` | `Sound_UI_EnterPortal` (0x6A) from `ClientUISystem::GetUISoundTable()` |
|
||||||
|
| Teleport end (portal out, `TAS_WORLD_FADE_IN`) | `:219745` | `Sound_UI_ExitPortal` (0x6B) |
|
||||||
|
| **Server-pushed atmosphere cue** | `CPlayerSystem::Handle_Admin__Environs(uint)` @ `0x0055de20` (`:362360`+) | codes below |
|
||||||
|
| Landblock / cell change | `CellManager::ChangePosition` @ `0x004559b0` → `Ambient::InitSounds` (`:94714`), `LScape::add_ambient_sounds` (`:94718`), `Ambient::UpdatePlayQueue` (`:94722`), `Ambient::ReleaseSoundTables` (`:94726`) | region ambient rolls (§6) |
|
||||||
|
| Per-frame ambient pump | `Ambient::UseTime` @ `0x00551880`, called from `:94200` | due entries from the ambient PQueue |
|
||||||
|
|
||||||
|
### `Handle_Admin__Environs` — the retail "set the mood" opcode
|
||||||
|
|
||||||
|
`AdminEnvirons` (acdream: `0xEA60`, `WorldSession.cs:1915`). Two disjoint
|
||||||
|
ranges:
|
||||||
|
|
||||||
|
- `1..6` → lighting/fog overrides (`LScape::m_override_*`: ambient level,
|
||||||
|
ambient colour, fog colour, fog min/max; case 6 also sets
|
||||||
|
`m_bRadarBlank = 1`). acdream already ports these.
|
||||||
|
- `0x65..0x7C` → **one-shot UI-SoundTable stinger**, each
|
||||||
|
`PlaySoundFromCenter(Sound_UI_*, GetUISoundTable())`, gated on the player
|
||||||
|
physics object existing:
|
||||||
|
|
||||||
|
| code | SoundType | | code | SoundType |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 0x65 | `Sound_UI_Roar` (0x76) | | 0x6E | `Sound_UI_Drums` (0x7F) |
|
||||||
|
| 0x66 | `Sound_UI_Bell` (0x77) | | 0x6F | `Sound_UI_GhostSpeak` (0x80) |
|
||||||
|
| 0x67 | `Sound_UI_Chant1` (0x78) | | 0x70 | `Sound_UI_Breathing` (0x81) |
|
||||||
|
| 0x68 | `Sound_UI_Chant2` (0x79) | | 0x71 | `Sound_UI_Howl` (0x82) |
|
||||||
|
| 0x69 | `Sound_UI_DarkWhispers1` (0x7A) | | 0x72 | `Sound_UI_LostSouls` (0x83) |
|
||||||
|
| 0x6A | `Sound_UI_DarkWhispers2` (0x7B) | | 0x75 | `Sound_UI_Squeal` (0x84) |
|
||||||
|
| 0x6B | `Sound_UI_DarkLaugh` (0x7C) | | 0x76–0x7A | `Sound_UI_Thunder1..5` (0x85–0x89) |
|
||||||
|
| 0x6C | `Sound_UI_DarkWind` (0x7D) | | (0x7B/0x7C) | tail of the Thunder run |
|
||||||
|
| 0x6D | `Sound_UI_DarkSpeech` (0x7E) | | | |
|
||||||
|
|
||||||
|
Codes `0x73`/`0x74` have no case (fall through, nothing plays). Note the
|
||||||
|
environ code and the `SoundType` are **offset by 0x11** but not uniformly —
|
||||||
|
the switch is explicit, so port it as an explicit table, never as arithmetic.
|
||||||
|
|
||||||
|
This is *the* trigger for what players remember as dungeon "music".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Region ambient soundscape — retail's real "area audio"
|
||||||
|
|
||||||
|
Not music, but it is the system a "music by region" feature would have to sit
|
||||||
|
next to, and it is the only region→audio authored data in the DATs.
|
||||||
|
|
||||||
|
```
|
||||||
|
CRegionDesc (acclient.h:53230)
|
||||||
|
└─ CSoundDesc* sound_info :53200 — SmartArray<AmbientSTBDesc*>
|
||||||
|
CSceneType :53240 — { name, scenes[], AmbientSTBDesc* sound_table_desc }
|
||||||
|
AmbientSTBDesc :35486 — { DID stb_id, bool stb_not_found,
|
||||||
|
SmartArray<AmbientSoundDesc*> ambient_sounds,
|
||||||
|
CSoundTable* sound_table, uint play_count }
|
||||||
|
AmbientSoundDesc :35496 — { SoundType stype, bool is_continuous, float volume,
|
||||||
|
float base_chance, float min_rate, float max_rate }
|
||||||
|
```
|
||||||
|
|
||||||
|
- Selection is **per land cell, from the terrain word**: in
|
||||||
|
`CLandBlock::add_ambient_sounds` (`:314270`+) each cell reads
|
||||||
|
`terrainType = (t >> 2) & 0x1F` and `sceneIdx = t >> 11`, then
|
||||||
|
`CRegionDesc::GetSTBDesc(region, terrainType, sceneIdx)` →
|
||||||
|
`Ambient::AddSound(ambient, stbDesc, cellVertexPos)` (`:314293`).
|
||||||
|
- `AmbientSound` is polymorphic: `ConstantSound` (continuous, tracks
|
||||||
|
`current_volume`) and `IntermitSound` (per-`LandDefs::Direction` `min_dist[8]`
|
||||||
|
/ `max_dist[8]` arrays + `play_chance`) — `acclient.h:52830`, `:52856`.
|
||||||
|
- Scheduling is an absolute-deadline priority queue:
|
||||||
|
`Ambient::Play` @ `0x005517a0` (`:347826`) → `CanHear()`, `PlayNow()`,
|
||||||
|
`GetSoundPos()`; positional ⇒ `PlayAmbientSound`, non-positional ⇒
|
||||||
|
`PlayAmbientSoundFromCenter`; then
|
||||||
|
`PQueueArray<double>::Insert(sound_queue, Timer::cur_time + GetPlayInterval(), snd)`
|
||||||
|
and `on_queue = 1`. `Ambient::UseTime` pops due entries.
|
||||||
|
- Ambient volume path: `PlayAmbientSoundFromCenter` @ `0x005508b0`
|
||||||
|
multiplies by `SoundManager::ambient_sound_volume`, rolls
|
||||||
|
`rand() * 3.05185094e-05f` (= `1/32768`) against the entry probability, then
|
||||||
|
`GetAttenuation(0, vol, &out, /*isAmbient=*/1)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cross-reference results
|
||||||
|
|
||||||
|
- **`references/ACViewer/`** — grep for `midi|music` over all `*.cs`: only
|
||||||
|
three hits, all unrelated (`WeenieClassName.cs`, `SoulEmote.cs`). **ACViewer
|
||||||
|
implements no music and no MIDI.** It is not an oracle here, and its silence
|
||||||
|
is itself corroboration that there is nothing to load.
|
||||||
|
- **`references/DatReaderWriter/`** — no music/MIDI DBObj type exists. Audio
|
||||||
|
surface is exactly two DBObjs:
|
||||||
|
- `Wave` — `DBObjType.Wave`, range **`0x0A000000`–`0x0A00FFFF`**, layout
|
||||||
|
`int32 headerSize, int32 dataSize, byte[] header, byte[] data`
|
||||||
|
(`DBObjs/Wave.generated.cs`). The `header` is a `WAVEFORMATEX` blob;
|
||||||
|
the body can be PCM **or MP3** (retail decodes via winmm ACM).
|
||||||
|
- `SoundTable` — `DBObjType.SoundTable`, range **`0x20000000`–`0x2000FFFF`**,
|
||||||
|
`int32 HashKey`, `Dictionary<uint, SoundHashData>`,
|
||||||
|
`Dictionary<Sound, SoundData>` with `SoundEntry { QualifiedDataId<Wave> Id,
|
||||||
|
float Priority, Probability, Volume }`.
|
||||||
|
- `MediaDesc*` types are all present (§2), including `MediaDescSound`.
|
||||||
|
- **There is no `0x25……` music table and no MIDI DAT type.** (`0x25……` is
|
||||||
|
the `RegionDesc`/`Region` family, not music.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Answers to the five questions
|
||||||
|
|
||||||
|
**1. Formats and where the bytes live.**
|
||||||
|
Retail's *only* music-capable path is winmm `midiStream*` fed a **Standard MIDI
|
||||||
|
File read from a loose disk path via `CreateFileA`** — never from a DAT. No
|
||||||
|
DirectMusic, no MP3-as-music, no `0x25……` music table. That path is never
|
||||||
|
invoked and **no `.mid` ships with the client**. All audio the client actually
|
||||||
|
plays is DAT `Wave` (`0x0A000000`–`0x0A00FFFF`, PCM or MP3-in-WAVEFORMATEX)
|
||||||
|
selected either directly by DID or through `SoundTable`
|
||||||
|
(`0x20000000`–`0x2000FFFF`). The one long-form musical asset that ships is the
|
||||||
|
audio track of `turbine_logo_ac.avi`, played by DirectShow.
|
||||||
|
|
||||||
|
**2. The MediaMachine state machine.**
|
||||||
|
It is not a music machine — it is an 11-opcode media bytecode VM per UI
|
||||||
|
element state, with `m_curIndex` as PC, "may I advance?" booleans as the
|
||||||
|
blocking protocol, global message 3 as the clock, `Jump` as the loop
|
||||||
|
primitive, `State` as the terminal instruction, and `-1.0` double sentinels for
|
||||||
|
"not yet armed". Full opcode table and per-opcode decode in §2; `Update_Sound`
|
||||||
|
in §3; `Update_Fade` (linear alpha lerp, 2e-4 s duration epsilon) in §4.
|
||||||
|
|
||||||
|
**3. Trigger map.** §5. Region/landblock entry drives *ambient*, not music.
|
||||||
|
Portal in/out and the 24 `AdminEnvirons` codes `0x65–0x7C` drive the
|
||||||
|
atmosphere stingers. UI state entry drives authored `MD_Data_Sound`. Login/
|
||||||
|
intro music is a movie file. Nothing anywhere starts a music track.
|
||||||
|
|
||||||
|
**4. Does a modern port need a MIDI synth?** **No.** There is no MIDI content
|
||||||
|
and no code that would play it; EoR-era audio is 100% sampled. ACViewer
|
||||||
|
implements nothing here either. Building a synth (or an OGG music bus) would be
|
||||||
|
new feature design with no retail referent — and per CLAUDE.md that is a
|
||||||
|
"different feature", not a port, so it needs explicit approval.
|
||||||
|
|
||||||
|
**5. Rough port scope.**
|
||||||
|
|
||||||
|
- **Delete the false surface (smallest, highest value).**
|
||||||
|
`IAudioEngine.PlayMusic(string resourceName, bool loop)` /
|
||||||
|
`StopMusic()` in `src/AcDream.Core/Audio/AudioModel.cs:102-103`, their
|
||||||
|
no-op bodies in `src/AcDream.App/Audio/OpenAlAudioEngine.cs:386-387`, and
|
||||||
|
`MusicVolume` at `AudioModel.cs:84` / `OpenAlAudioEngine.cs` are modelled on
|
||||||
|
a retail feature **that does not exist**. `resourceName` as a *string path*
|
||||||
|
is itself a tell — every other audio entry point in the engine is DID-keyed.
|
||||||
|
A retail-faithful engine has three buses (SFX / Ambient / Interface), not
|
||||||
|
four. Removing them retires a divergence rather than creating one; if they
|
||||||
|
stay, they need a `retail-divergence-register.md` row explaining that they
|
||||||
|
model dead retail code. Also worth a look:
|
||||||
|
`src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs:260` comments
|
||||||
|
the music path as "stubbed for R5 MIDI", and retail's Settings has no music
|
||||||
|
slider at all.
|
||||||
|
- **Correct the record.** `docs/research/deepdives/r05-audio-sound.md` §6 and
|
||||||
|
its executive-summary table row ("MIDI music | winmm midiStream | Loose
|
||||||
|
`*.mid` files on disk") should carry the "infrastructure present, never
|
||||||
|
invoked, no content ships" finding — see the correction table in §1. That
|
||||||
|
table row is what produced the phantom API.
|
||||||
|
- **The genuinely missing retail behavior, in cost order:**
|
||||||
|
1. **`AdminEnvirons` sound cues** (24 codes → `Sound_UI_*` via the UI
|
||||||
|
SoundTable). acdream already parses these and already has
|
||||||
|
`RuntimeEnvironmentSoundCue`, with
|
||||||
|
`src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs:214-239`
|
||||||
|
logging `audio binding pending`. This is a table + one `PlayUi` call —
|
||||||
|
the cheapest real retail-fidelity win in the whole lane.
|
||||||
|
2. **`MediaDescSound` in the LayoutDesc importer.**
|
||||||
|
`src/AcDream.App/UI/Layout/LayoutImporter.cs:464-489` reads
|
||||||
|
`MediaDescImage` and `MediaDescCursor` from each state's media list and
|
||||||
|
ignores everything else. Adding the Sound opcode (two shapes per §3, both
|
||||||
|
routed to the interface bus) gives retail's authored UI sounds for free —
|
||||||
|
`DatReaderWriter` already parses the type.
|
||||||
|
3. **Region ambient system** (§6): `AmbientSTBDesc` selection from the
|
||||||
|
terrain word on landblock change, `ConstantSound`/`IntermitSound`, the
|
||||||
|
absolute-deadline PQueue, `ambient_sound_volume`. This is the real
|
||||||
|
"area audio" and it is what `StartAmbient`/`StopAmbient`
|
||||||
|
(`OpenAlAudioEngine.cs:367-385`, currently handle-reservation only) exist
|
||||||
|
to serve. Multi-commit; belongs in a roadmap phase, not an issue.
|
||||||
|
4. **`MediaMachine` proper** (Pause/Jump/Anim/Fade/State scripting) — only
|
||||||
|
if animated UI states become a goal. Not required for audio.
|
||||||
|
- **Explicitly out of scope for a faithful port:** MIDI playback, a soundfont
|
||||||
|
synth, crossfades, a music bus, region→track tables. None exist in retail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Anchors (for citation in code comments)
|
||||||
|
|
||||||
|
| Symbol | Address | pseudo-C line |
|
||||||
|
|---|---|---|
|
||||||
|
| `SoundManager::Init` (calls `midiSetup`) | `0x00550640` | 346753 |
|
||||||
|
| `SoundManager::ShutDown` (calls `midiCleanup`) | `0x005502b0` | ~346580 |
|
||||||
|
| `midiSetup` | `0x00553770` | 350282 |
|
||||||
|
| `midiPlay` (**no callers**) | `0x00553390` | 350072 |
|
||||||
|
| `midiPlayNext` | `0x005534c0` | 350138 |
|
||||||
|
| `midiStop` | `0x00553240` | 349978 |
|
||||||
|
| `midiCleanup` | `0x00553350` | 350047 |
|
||||||
|
| `MidiProc` | `0x00553500` | 350152 |
|
||||||
|
| `StreamBufferSetup` | `0x00553030` | 349843 |
|
||||||
|
| `ConverterInit` (MThd/MTrk) | `0x00554530` | 351254 |
|
||||||
|
| `SetChannelVolume` | `0x00552f60` | 349787 |
|
||||||
|
| `midiEventCallback` / `midiStartCallback` (= 0) | `0x0086fa70` / `0x0086fa74` | 1185597–8 |
|
||||||
|
| `MediaMachine::Update` | `0x00465ba0` | 112526 |
|
||||||
|
| `MediaMachine::Reset` | `0x00465d90` | 112681 |
|
||||||
|
| `MediaMachine::Cleanup` | `0x00465af0` | 112475 |
|
||||||
|
| `MediaMachine::ListenToGlobalMessage` | `0x00465cf0` | 112638 |
|
||||||
|
| `MediaMachine::Update_Sound` | `0x004658b0` | 112264 |
|
||||||
|
| `MediaMachine::Update_Fade` | `0x00465930` | 112307 |
|
||||||
|
| `MediaMachine::Update_Pause` | `0x00465520` | 111937 |
|
||||||
|
| `MediaMachine::Update_Jump` | `0x004655b0` | 111983 |
|
||||||
|
| `MediaDesc::CreateMediaType(uint)` | `0x0069d420` | 675786 |
|
||||||
|
| `MD_Data_Sound::MD_Data_Sound` (type 9) | `0x0069e5f0` | 677163 |
|
||||||
|
| `MD_Data_Sound::Serialize` | `0x0069e670` | 677192 |
|
||||||
|
| `StateDesc::LoadMedia` | `0x0069c950` | 674969 |
|
||||||
|
| `SoundManager::PlaySoundFromCenter(SoundType, CSoundTable*)` | `0x00550950` | 346927 |
|
||||||
|
| `SoundManager::PlaySoundFromCenter(DID, float)` | `0x005509e0` | 346951 |
|
||||||
|
| `SoundManager::PlayAmbientSoundFromCenter` | `0x005508b0` | 346893 |
|
||||||
|
| `CPlayerSystem::Handle_Admin__Environs` | `0x0055de20` | ~362360 |
|
||||||
|
| `Ambient::Play` | `0x005517a0` | 347826 |
|
||||||
|
| `Ambient::UseTime` | `0x00551880` | 347957 |
|
||||||
|
| `CellManager::ChangePosition` (ambient re-init) | `0x004559b0` | 94601 |
|
||||||
|
| `CSoundTable::GetDBOType` (= 0x22) | `0x00552560` | 349100 |
|
||||||
|
| `DBWave::Get` (QDID type 0xF) | `0x00552880` | 349327 |
|
||||||
|
| `enum SoundType` (0x00–0xCC) | — | `acclient.h:4569` |
|
||||||
|
| `MediaMachine` / `MediaDesc` / `MD_Data_*` structs | — | `acclient.h:33873, 33907, 34101–34182` |
|
||||||
482
docs/research/2026-08-08-audio-retail-server-sounds.md
Normal file
482
docs/research/2026-08-08-audio-retail-server-sounds.md
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
# Lane 5 — server-driven & physics-driven sounds: retail decode + acdream audit
|
||||||
|
|
||||||
|
Research-only. No repo files touched.
|
||||||
|
|
||||||
|
Oracles used:
|
||||||
|
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (BN pseudo-C, PDB-named, Sept 2013 EoR)
|
||||||
|
- `docs/research/named-retail/acclient.h` (verbatim retail structs/enums)
|
||||||
|
- `references/ACE/Source/` (server side — what actually gets sent)
|
||||||
|
- `references/holtburger/` (independent client-side parser)
|
||||||
|
- **Raw byte decode** of the PDB-paired `C:\Users\erikn\Downloads\acclient.exe`
|
||||||
|
(v11.4186) for two FPU-elided/mis-polarised spots BN got wrong
|
||||||
|
(per `reference_pe_byte_decode.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The Sound game message (0xF750)
|
||||||
|
|
||||||
|
### Wire layout (three oracles agree)
|
||||||
|
|
||||||
|
| offset | type | field |
|
||||||
|
|---|---|---|
|
||||||
|
| 0x00 | u32 | opcode `0xF750` |
|
||||||
|
| 0x04 | u32 | object GUID |
|
||||||
|
| 0x08 | u32 | `SoundType` (retail `enum SoundType`, = ACE `Sound`) |
|
||||||
|
| 0x0C | f32 | volume |
|
||||||
|
|
||||||
|
Total 16 bytes. Direction S→C.
|
||||||
|
|
||||||
|
- Retail: `CM_Physics::DispatchSB_SoundEvent` @ `0x006AC760` reads
|
||||||
|
`*(u32*)buf == 0xf750`, then passes `buf+4` (guid), `buf+8` (sound),
|
||||||
|
`buf+0xc` (float volume).
|
||||||
|
- ACE: `Network/GameMessages/Messages/GameMessageSound.cs` —
|
||||||
|
`base(GameMessageOpcode.Sound /*0xF750*/, GameMessageGroup.SmartboxQueue, 16)`,
|
||||||
|
writes `WriteGuid(guid)`, `(uint)soundId`, `float volume`.
|
||||||
|
Opcode confirmed at `GameMessageOpcode.cs:60`.
|
||||||
|
- holtburger: `crates/holtburger-protocol/src/messages/effects/types.rs`
|
||||||
|
`PlaySoundData { target: Guid, sound_id: u32, volume: f32 }`, routed from
|
||||||
|
`GameOpcode::Sound` in `game_message/unpack.rs:186`. **holtburger parses it
|
||||||
|
and then does nothing with it** — no consumer anywhere in
|
||||||
|
`holtburger-core`/`apps` (it's a TUI, no audio). So holtburger is a layout
|
||||||
|
oracle only, not a behaviour oracle here.
|
||||||
|
- ACE's `Sound` enum (`ACE.Entity/Enum/Sound.cs`) is byte-for-byte the retail
|
||||||
|
`SoundType` enum (`acclient.h:4569`) — verified across the whole 0x00–0xC5+
|
||||||
|
range. Values we care about: `Collision=0x2F`, `Footstep1=0x37`,
|
||||||
|
`Footstep2=0x38`, `Walk1=0x39`, `Open=0x42`, `Close=0x43`,
|
||||||
|
`OpenSlam=0x44`, `CloseSlam=0x45`, `LogIn=0x50`, `LifestoneOn=0x51`,
|
||||||
|
`Fizzle=0x5E`, `Launch=0x5F`, `Explode=0x60`,
|
||||||
|
`UI_EnterPortal=0x6A` … `UI_Thunder6=0x8A`, `WieldObject=0x8C`,
|
||||||
|
`PickUpItem=0x8F`, `DropItem=0x90`, `ResistSpell=0x91`,
|
||||||
|
`TriggerActivated=0x95`, `SpellExpire=0x96`, `ItemManaDepleted=0x97`.
|
||||||
|
|
||||||
|
### Retail handler chain (pseudocode)
|
||||||
|
|
||||||
|
```
|
||||||
|
CM_Physics::DispatchSB_SoundEvent(SmartBox* sb, NetBlob* blob) // 0x006AC760
|
||||||
|
if (!blob || !sb) return NETBLOB_ERROR;
|
||||||
|
if (*(u32*)blob->buf != 0xF750) return NETBLOB_ERROR;
|
||||||
|
return SmartBox::HandleSoundEvent(sb, blob,
|
||||||
|
guid = *(u32*)(buf+4),
|
||||||
|
sound = *(i32*)(buf+8),
|
||||||
|
volume = *(f32*)(buf+0xC));
|
||||||
|
|
||||||
|
SmartBox::HandleSoundEvent(sb, blob, guid, sound, volume) // 0x00451FC0
|
||||||
|
CPhysicsObj* obj = CObjectMaint::GetObjectA(sb->m_pObjMaint, guid);
|
||||||
|
if (obj == nullptr) {
|
||||||
|
CObjectMaint::QueueBlobForObject(sb->m_pObjMaint, guid, blob);
|
||||||
|
return NETBLOB_QUEUED; // 4 — REPLAYED when the object arrives
|
||||||
|
}
|
||||||
|
CPhysicsObj::play_sound(obj, sound, volume);
|
||||||
|
return NETBLOB_ERROR/OK; // BN mush; play_sound is void
|
||||||
|
|
||||||
|
CPhysicsObj::play_sound(this, SoundType t, float vol) // 0x0050F460
|
||||||
|
if (this->sound_table != nullptr) // NO table → SILENTLY DROPPED
|
||||||
|
SoundManager::PlaySoundA(t, this, vol);
|
||||||
|
|
||||||
|
SoundManager::PlaySoundA(SoundType t, CPhysicsObj* obj, float vol) // 0x00550AF0
|
||||||
|
if (!effect_sounds_enabled) return;
|
||||||
|
if (s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp) return;
|
||||||
|
SoundData d;
|
||||||
|
if (obj->sound_table == nullptr) return;
|
||||||
|
GetSound(obj->sound_table, t, &d); // rolls ONE variant
|
||||||
|
if (d.buf != null && PlayProbability(d.probability_))
|
||||||
|
PlaySoundInternal(d.buf, &obj->m_position, vol, /*isAmbient=*/0);
|
||||||
|
// ^^^ WIRE volume, not d.volume_
|
||||||
|
```
|
||||||
|
|
||||||
|
Three retail facts worth writing down:
|
||||||
|
|
||||||
|
1. **Object identity is required and the message is deferrable.** If the guid
|
||||||
|
isn't in `CObjectMaint` yet, retail *queues the blob against that guid* and
|
||||||
|
replays it on `CreateObject`. Our implementation must do the same or a
|
||||||
|
"creature spawns and immediately grunts" sequence will silently drop the
|
||||||
|
grunt.
|
||||||
|
2. **No sound table → nothing plays.** `play_sound` early-returns on
|
||||||
|
`sound_table == nullptr`. The server can send `Sound` for any object; only
|
||||||
|
objects that carry a SoundTable make noise.
|
||||||
|
3. **Server-driven sounds use the WIRE volume, not the SoundTable entry
|
||||||
|
volume.** The 3-arg `PlaySoundA(SoundType, obj, vol)` passes `arg3` straight
|
||||||
|
to `PlaySoundInternal`. The 2-arg overload used by animation hooks
|
||||||
|
(`SoundTableHook::Execute`) instead passes the *entry's* `volume_`. That
|
||||||
|
asymmetry is real and must be preserved.
|
||||||
|
`GetAttenuation(dist, vol, &out, isAmbient)` then multiplies by the user's
|
||||||
|
`effect_sound_volume` / `ambient_sound_volume` pref and clamps to `VOL_MIN`.
|
||||||
|
|
||||||
|
### Where the SoundTable comes from
|
||||||
|
|
||||||
|
`CPhysicsObj::sound_table` is a `CSoundTable*` = `DBObj::Get(QualifiedDataID(did, 0x22))`
|
||||||
|
(`0x22` = 34 = `DB_TYPE_STABLE`; matches ACE `DatFileType.SoundTable = 34`).
|
||||||
|
Two writers, in retail construction order:
|
||||||
|
|
||||||
|
1. `CPhysicsObj::InitDefaults(CSetup*)` @ `0x005139D0` →
|
||||||
|
`setup->default_stable_id` (the DAT Setup field).
|
||||||
|
2. `CPhysicsObj::set_description(PhysicsDesc*, …)` @ `0x00514F40` →
|
||||||
|
`desc->stable_id` (the wire field in CreateObject/UpdateObject).
|
||||||
|
It **unconditionally releases** the existing table first, then installs the
|
||||||
|
wire one only if non-zero — i.e. a PhysicsDesc with `stable_id == 0` leaves
|
||||||
|
the object with *no* sound table, it does not fall back to Setup.
|
||||||
|
|
||||||
|
`SoundTableData` (retail, `acclient.c:5151`): `num_stdatas_` @ +0x7C and
|
||||||
|
`SoundData* data_` @ +0x80, each entry 16 bytes = `{ DataId sound_id, float
|
||||||
|
priority, float probability, float volume }`. Identical to DatReaderWriter's
|
||||||
|
`SoundEntry` and ACE's `SoundTableData`.
|
||||||
|
|
||||||
|
### Variant picking — retail is NOT a CDF walk (byte-verified)
|
||||||
|
|
||||||
|
`SoundManager::GetSound` @ `0x00550680`. BN elides the FPU multiply; raw bytes
|
||||||
|
(file offset `0x150680`) decode to:
|
||||||
|
|
||||||
|
```
|
||||||
|
8b 48 7c mov ecx,[eax+0x7C] ; num_stdatas_
|
||||||
|
85 c9 / 76 73 test/jbe ; num == 0 -> bail
|
||||||
|
68 00 00 80 3f push 1.0f
|
||||||
|
6a 00 push 0 ; 0.0f
|
||||||
|
e8 .. call Random::RollDice(0.0f, 1.0f) -> st0 = u
|
||||||
|
8b 77 7c mov esi,[edi+0x7C] ; num
|
||||||
|
8d 4e ff lea ecx,[esi-1] ; num - 1 <-- note the -1
|
||||||
|
db 44 24 14 fild [num-1]
|
||||||
|
d8 c9 fmul st(0), st(1) ; (num-1) * u
|
||||||
|
e8 .. call _ftol2 ; eax = TRUNC((num-1)*u)
|
||||||
|
3b c6 / 73 .. cmp eax,esi / jae ; idx >= num -> bail
|
||||||
|
c1 e0 04 shl eax,4 ; * sizeof(SoundData)
|
||||||
|
```
|
||||||
|
|
||||||
|
So **`idx = (int)((num_stdatas_ - 1) * RollDice(0.0f, 1.0f))`**, then the chosen
|
||||||
|
entry's own `probability` gates whether it plays at all.
|
||||||
|
|
||||||
|
`Random::RollDice(float,float)` @ `0x0042C600` decodes to
|
||||||
|
`lo + u01 * (hi - lo)` (with a `min == max → return min` short-circuit and a
|
||||||
|
swap if `min > max`), where `u01` comes from the combined-LCG at `0x0042C4C0`
|
||||||
|
(two Lehmer streams, modulus `0x7FFFFFAB`) — i.e. the classic L'Ecuyer
|
||||||
|
generator whose scaled output is in the open interval (0,1).
|
||||||
|
|
||||||
|
**Consequence (retail quirk, flag it):** with `N` variants the reachable index
|
||||||
|
range is `[0, N-2]`. With two variants retail effectively always plays the
|
||||||
|
first. This is an off-by-one in Turbine's picker, not a decode artifact — the
|
||||||
|
`lea ecx,[esi-1]` is unambiguous in the bytes.
|
||||||
|
|
||||||
|
`SoundManager::PlayProbability(float p)` @ `0x005500E0` — BN renders the branch
|
||||||
|
polarity **inverted**; bytes say:
|
||||||
|
|
||||||
|
```
|
||||||
|
ff 15 84 23 79 00 call rand
|
||||||
|
db 44 24 00 fild [esp]
|
||||||
|
d8 0d 50 af 7c 00 fmul [0x7CAF50] ; const = 3.051851e-05 = 1/32767
|
||||||
|
d8 5c 24 08 fcomp [esp+8] ; vs p
|
||||||
|
df e0 / f6 c4 05 fnstsw / test ah,5
|
||||||
|
7a 07 jp -> return 0
|
||||||
|
b8 01 00 00 00 mov eax,1 ; return 1
|
||||||
|
```
|
||||||
|
|
||||||
|
`test ah,5` isolates C0 (bit0) and C2 (bit2); PF is the parity of the AND
|
||||||
|
result, so `jp` is taken exactly when C0 == C2 == 0 (ordered and not-less).
|
||||||
|
Therefore **plays when `rand()/32767.0 < probability`** — the intuitive
|
||||||
|
reading, opposite to BN's `if (p) return 0` rendering. Do not port BN here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Retail's full sound-trigger catalog
|
||||||
|
|
||||||
|
`SoundManager` is the only audio entry point. Every trigger reaches it through
|
||||||
|
one of six routes. (BN only prints `Sound_*` enum names where type info is
|
||||||
|
attached, so the numeric call sites are sparse in the text dump — the routes
|
||||||
|
below are from the class/vtable structure, which is complete.)
|
||||||
|
|
||||||
|
| # | Route | Retail anchor | Covers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Server Sound event** | `0xF750` → `DispatchSB_SoundEvent` → `HandleSoundEvent` → `CPhysicsObj::play_sound` → `PlaySoundA(SoundType, obj, wireVol)` | everything in §3's ACE table: hit/wound/pain, wield/unwield, pickup/drop/receive, lock/pick, door-locked, lifestone, trigger plates, spell resist/expire, mana depleted, attribute/skill raise, projectile `Collision` |
|
||||||
|
| 2 | **Animation hooks** | `SoundHook::Execute` `0x00526A20`, `SoundTweakedHook::Execute` `0x00526A80`, `SoundTableHook::Execute` `0x00526AB0` (all `CAnimHook` subclasses, `acclient.h:6308-6310`) | **footsteps**, weapon swoosh, bow pull/release, creature attack/damage vocalisations, door open/close, eat/drink, spell chant — anything authored into an animation's hook list |
|
||||||
|
| 3 | **PhysicsScript hooks** | `0xF754`/`0xF755` → `CPhysicsObj::play_script` → the script's `CAnimHook` list, which can include the same three sound hooks | server-triggered effect scripts (portal, cast, destroy) that carry audio |
|
||||||
|
| 4 | **Ambient / environment** | `Ambient::Play` `0x005517A0`, `Ambient::UseTime` `0x00551880`, `Ambient::PlaySoundA` `0x00550D90`; data from `AmbientSTBDesc { stb_id, ambient_sounds, CSoundTable*, play_count }` reached via `CSceneType::sound_table_desc`; entries are `AmbientSoundDesc { SoundType stype, int is_continuous, float volume, float base_chance, float min_rate, float max_rate }` | waterfalls, birds, dungeon drips, wind — interval-queued in a `PQueueArray<double>` keyed on `Timer::cur_time`, gated on `AmbientSound::CanHear()`, positioned or from-centre depending on `GetSoundPos()` |
|
||||||
|
| 5 | **UI / interface** | `SoundManager::PlaySoundFromCenter(SoundType, ClientUISystem::GetUISoundTable())` | portal enter/exit, button press, icon pick-up/drop, slider grab/release, new-target-selected, general query/error, transient message, and the whole `Sound_UI_Roar…Thunder6` block |
|
||||||
|
| 6 | **MediaMachine** | `MediaMachine::Update_Sound` `0x004658B0` | cutscene / media-descriptor audio; `MD_Data_Sound { SoundType m_stype, DataId m_file }`. If `m_stype == Sound_Invalid` it plays `m_file` as a raw wave id; otherwise it resolves `m_file` as a SoundTable (`DBObj::Get(qdid, 0x22)`) and plays `m_stype` from it |
|
||||||
|
|
||||||
|
### Physics-event sounds: the important negative result
|
||||||
|
|
||||||
|
**`CPhysicsObj::play_sound` has exactly ONE caller in the entire binary:
|
||||||
|
`SmartBox::HandleSoundEvent`.** There is no collision, jump-land, water-entry,
|
||||||
|
or step call site. Confirmed by grepping every `play_sound` /
|
||||||
|
`SoundManager::PlaySound*` reference in the 65 MB pseudo-C dump.
|
||||||
|
|
||||||
|
That means, in retail:
|
||||||
|
- **Footsteps are animation-hook-driven** (route 2 — `SoundTableHook` with
|
||||||
|
`Sound_Footstep1/2` / `Sound_Walk1` authored into the walk/run animation
|
||||||
|
frames), *not* physics-tick driven. Nothing in `CTransition` /
|
||||||
|
`SPHEREPATH` / `COLLISIONINFO` plays a sound.
|
||||||
|
- **Collision sounds are server-driven** (route 1). `Sound_Collision (0x2F)`
|
||||||
|
is emitted by the *server* — ACE does it in
|
||||||
|
`WorldObjects/ProjectileCollisionHelper.cs:45`. The client's physics engine
|
||||||
|
never plays a collision sound on its own.
|
||||||
|
- **Jump / land / water-entry have no client-local sound trigger at all.**
|
||||||
|
There is no `Sound_*` for them in the enum and no call site. Any audible
|
||||||
|
landing thump in retail comes from the landing *animation's* hooks.
|
||||||
|
|
||||||
|
So "physics-driven sounds" in retail = "animation hooks that happen to fire
|
||||||
|
during physics-driven motion" + "server tells you". There is no third thing.
|
||||||
|
|
||||||
|
### UI sound table — the dat id
|
||||||
|
|
||||||
|
```
|
||||||
|
ClientUISystem::GetUISoundTable(this) // 0x00563FB0
|
||||||
|
if (this->soundTable == nullptr)
|
||||||
|
this->soundTable = DBObj::GetByEnum(/*fileType*/ 0x22,
|
||||||
|
/*enumIndex*/ 7,
|
||||||
|
/*cache*/ 0x10000003);
|
||||||
|
return this->soundTable;
|
||||||
|
|
||||||
|
DBObj::GetByEnum(type, idx, cache) // 0x00415490
|
||||||
|
DBCache::GetDIDFromEnumStatic(&did, type, idx); // enum -> concrete DID
|
||||||
|
return DBCache::Get(did, cache);
|
||||||
|
```
|
||||||
|
|
||||||
|
i.e. the UI sound table is **not a hard-coded DID** — it is enum slot **7** of
|
||||||
|
DB type **0x22 (`DB_TYPE_STABLE`, SoundTable)**, resolved through
|
||||||
|
`DBCache::GetDIDFromEnumStatic`. (Open item: resolving slot 7 → the actual
|
||||||
|
`0x20xxxxxx` DID needs either `GetDIDFromEnumStatic`'s static table decoded or
|
||||||
|
one cdb `dt` on a live client. Cheap either way; not done here.)
|
||||||
|
|
||||||
|
Only two UI-sound sites survive with named enums in BN's output:
|
||||||
|
`SoundManager::PlaySoundFromCenter(Sound_UI_EnterPortal, GetUISoundTable(...))`
|
||||||
|
@ `0x004D638E` and `Sound_UI_ExitPortal` @ `0x004D7405`. The third named
|
||||||
|
cluster is a switch in **`CPlayerSystem::Handle_Admin__Environs`** @
|
||||||
|
`0x0055DE20`: environment option values `0x65..0x7C` map 1:1 onto
|
||||||
|
`Sound_UI_Roar` (0x65) … `Sound_UI_Thunder6` (0x7C), each played from-centre
|
||||||
|
through the UI sound table, alongside `LScape::m_override_*` fog/ambient
|
||||||
|
overrides and `m_bRadarBlank`. That is the server's `AdminEnvirons` hook into
|
||||||
|
the UI sound bank.
|
||||||
|
|
||||||
|
`PlaySoundFromCenter` gates on `interface_sounds_enabled` (a separate pref from
|
||||||
|
`effect_sounds_enabled` / `ambient_sounds_enabled`) and calls
|
||||||
|
`GetAttenuation(0.0f, vol, &out, 0)` — distance 0, so no attenuation, but the
|
||||||
|
interface-volume pref still applies. Retail's three volume prefs are
|
||||||
|
`Sound.SoundVolume`, `Sound.AmbientSoundVolume`, `Sound.InterfaceSoundVolume`,
|
||||||
|
plus `Sound.SoundDisabled` / `Sound.AmbientSoundDisabled` /
|
||||||
|
`Sound.InterfaceSoundDisabled` / `Sound.PlaySoundOnlyWhenActive` /
|
||||||
|
`Sound.SoundFeatures` (mono/stereo), all registered in
|
||||||
|
`SoundManager::InitPrefs` @ `0x005503F0`.
|
||||||
|
|
||||||
|
Listener position: `SoundManager::SetPlayerPosition(&sb->viewer)` @
|
||||||
|
`0x00452D36` — the **viewer** position, i.e. the camera eye, not the player's
|
||||||
|
feet. (Same coupling as our render visibility; see
|
||||||
|
`project_camera_visibility_coupling`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What ACE actually sends, and when
|
||||||
|
|
||||||
|
`GameMessageSound` send sites (65 in `references/ACE/Source/ACE.Server`).
|
||||||
|
Grouped:
|
||||||
|
|
||||||
|
| Sound | ACE site(s) | trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| `HitFlesh1` (0.5f vol) | `Monster_Combat.cs:314,404`, `Player_Combat.cs:168,544` | every melee/missile hit |
|
||||||
|
| `Wound1/2/3` + pain sounds | `Monster_Combat.cs:324,408`, `Player_Combat.cs:461,563`, `Player_Move.cs:311` (fall damage) | damage taken |
|
||||||
|
| `WieldObject` / `UnwieldObject` | `Creature_Equipment.cs:360,436`, `Player_Inventory.cs:306,405,1831` | equip / unequip |
|
||||||
|
| `PickUpItem` / `DropItem` / `ReceiveItem` | `Player_Inventory.cs` (×14), `Player_Commerce.cs:105,223`, `AdminCommands.cs:2880` | every inventory move, give, buy/sell |
|
||||||
|
| `Collision` | `ProjectileCollisionHelper.cs:45` | **projectile impact** — the only `Sound.Collision` sender |
|
||||||
|
| `OpenFailDueToLock` | `Door.cs:114`, `Chest.cs:110`, `Storage.cs:71` | locked container/door |
|
||||||
|
| `Lockpicking` / `PicklockFail` / `LockSuccess` | `Lock.cs:162,178,276` | lockpick attempts |
|
||||||
|
| `LifestoneOn` | `Lifestone.cs:58` | lifestone attunement |
|
||||||
|
| `TriggerActivated` | `Hotspot.cs:221`, `Switch.cs:46`, `PressurePlate.cs:75` (UseSound) | traps / plates / switches |
|
||||||
|
| `ResistSpell` | `WorldObject_Magic.cs:194,201` | spell resisted |
|
||||||
|
| `SpellExpire` | `EnchantmentManager.cs:331,348` | enchantment drops |
|
||||||
|
| `ItemManaDepleted` | `Player_Tick.cs:684` | item runs dry |
|
||||||
|
| `RaiseTrait` | `Player_Attributes.cs:48`, `Player_Skills.cs:56`, `Player_Vitals.cs:59`, `AttributeTransferDevice.cs:98` | XP spend |
|
||||||
|
| arbitrary (emote-authored) | `EmoteManager.cs:1243` — `(Sound)emote.Sound` | any DAT-authored NPC emote sound |
|
||||||
|
| arbitrary (weenie `UseSound`) | `Gem.cs:181`, `GenericObject.cs:48`, `Food.cs:100` (`GetUseSound()`), `PressurePlate.cs:75` | item use |
|
||||||
|
| generic helper | `WorldObject.cs:708` `EnqueueBroadcast(new GameMessageSound(targetId, soundId, volume))`, `Player.cs:483` | everything else |
|
||||||
|
|
||||||
|
Note `Player_Death.cs:192` has the death sound **commented out** in ACE.
|
||||||
|
|
||||||
|
Two shapes of send: `EnqueueBroadcast(...)` (everyone in range hears it, guid =
|
||||||
|
the acting object) and `Session.Network.EnqueueSend(...)` (only the acting
|
||||||
|
player hears it). Both arrive as the same 0xF750; the difference is purely who
|
||||||
|
receives it. So our handler needs no special-casing — but it *does* mean a
|
||||||
|
0xF750 can name a **remote** guid, and must play at that remote object's
|
||||||
|
position.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. acdream audit — what we have and what we don't
|
||||||
|
|
||||||
|
### 4.1 Server Sound path: **ABSENT**
|
||||||
|
|
||||||
|
- `grep -rn '0xF750' src/` → **zero hits.** No parser, no message record, no
|
||||||
|
`WorldSession` event, no routing.
|
||||||
|
- Our `Core.Net` knows the neighbours: `0xF74A` PickupEvent, `0xF74B` SetState,
|
||||||
|
`0xF74E` VectorUpdate, `0xF751` PlayerTeleport, `0xF754` PlayPhysicsScript,
|
||||||
|
`0xF755` PlayPhysicsScriptType. `0xF750` is the hole in the middle.
|
||||||
|
- Already recorded as a known gap: `docs/research/2026-06-04-wire-message-catalog.md:241`
|
||||||
|
(`| 0xF750 | Sound | S->C | Movement & Physics | missing |`), with a full
|
||||||
|
entry at line 918 and a "next work" callout at line 4654. So this lane
|
||||||
|
confirms a previously-catalogued gap rather than discovering a new one — but
|
||||||
|
the *retail handler semantics* (queue-for-object, no-table drop, wire-volume
|
||||||
|
precedence) were not previously written down anywhere.
|
||||||
|
- Consequence: **every sound in §3's table is silent in acdream.** No hit
|
||||||
|
sounds, no pickup/drop, no wield, no lock, no lifestone, no trap trigger, no
|
||||||
|
spell resist/expire, no projectile collision.
|
||||||
|
|
||||||
|
### 4.2 Animation-hook path: **PRESENT and correctly shaped**
|
||||||
|
|
||||||
|
`src/AcDream.App/Audio/AudioHookSink.cs` implements `IAnimationHookSink` and
|
||||||
|
handles all three retail hook types with the right semantics:
|
||||||
|
|
||||||
|
| retail | ours | verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| `SoundHook::Execute` → `PlaySoundA(gid, obj)` (raw wave DID) | `case SoundHook s` → `Play(waveId: s.Id, volume 1, priority 4)` | matches |
|
||||||
|
| `SoundTableHook::Execute` → `PlaySoundA(sound_type_, obj)` (table lookup, entry volume) | `case SoundTableHook st` → `PlayFromSoundTable` → `SoundCookbook.Roll` → entry's `Volume`/`Priority` | matches in shape; picker algorithm diverges (below) |
|
||||||
|
| `SoundTweakedHook::Execute` → `PlaySoundA(gid, obj, prio, prob, vol)` | `case SoundTweakedHook stw` → direct wave with hook's volume/priority | **missing the `prob` gate** — retail runs `PlayProbability(arg4)` before playing; we play unconditionally |
|
||||||
|
|
||||||
|
Wiring is real and reaches production:
|
||||||
|
- `ContentEffectsAudioComposition.ComposeOptionalAudio` creates the engine +
|
||||||
|
sink and calls `registrations.Register(audioSink)` (line 513) →
|
||||||
|
`AnimationHookRouter` (`src/AcDream.Core/Physics/AnimationHookRouter.cs`).
|
||||||
|
- `AnimationHookFrameQueue.cs:117` and `PhysicsScriptRunner.cs:308` both fan
|
||||||
|
into that router.
|
||||||
|
- Listener is updated per frame: `WorldRenderFrameBuilder.cs:388`
|
||||||
|
`_audio.SetListener(...)`.
|
||||||
|
- Disabled by `ACDREAM_NO_AUDIO=1` (`RuntimeOptions.cs:103`) or an unavailable
|
||||||
|
OpenAL driver, otherwise on.
|
||||||
|
|
||||||
|
**Side effect worth noting:** because `PhysicsScriptRunner` sinks into the same
|
||||||
|
router, we *already* have one indirect server→audio path — a server
|
||||||
|
`0xF754`/`0xF755` PlayScript whose PhysicsScript carries a `SoundHook` will
|
||||||
|
play. That's retail route 3, and it works today.
|
||||||
|
|
||||||
|
### 4.3 `DictionaryEntitySoundTable`: **IS populated** (the hook path is not dead)
|
||||||
|
|
||||||
|
This was the open question. Answer: yes, and from the right field.
|
||||||
|
|
||||||
|
- `LivePresentationComposition.cs:620-625` passes two callbacks into
|
||||||
|
`EntityEffectController`: a remove (`content.Audio?.EntitySoundTables.Remove(ownerId)`)
|
||||||
|
and a set (`... .Set(ownerId, did)`).
|
||||||
|
- The `did` comes from `EntityEffectProfile.CurrentSoundTableDid`
|
||||||
|
(`src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs`), which mirrors retail's
|
||||||
|
precedence exactly and cites it:
|
||||||
|
- ctor from `Setup` → `NormalizeSoundTableDid((uint)setup.DefaultSoundTable)`
|
||||||
|
(retail `CPhysicsObj::InitDefaults` `0x005139D0`);
|
||||||
|
- `ApplyNetworkDescription(PhysicsSpawnData)` →
|
||||||
|
`NormalizeSoundTableDid(physics.SoundTableId.GetValueOrDefault())`
|
||||||
|
(retail `CPhysicsObj::set_description` `0x00514F40`), **unconditionally
|
||||||
|
replacing** the Setup value — same "wire wins, zero means none" rule as
|
||||||
|
retail.
|
||||||
|
- `NormalizeSoundTableDid` gates on `(did & 0xFF000000) == 0x20000000`.
|
||||||
|
- `SoundTableId` is parsed off the wire in
|
||||||
|
`src/AcDream.Core.Net/Messages/CreateObject.cs:766` into
|
||||||
|
`PhysicsSpawnData.SoundTableId` (`PhysicsSpawnData.cs:47`).
|
||||||
|
|
||||||
|
So creature/NPC animation sounds *do* have a table to look up. Good — no
|
||||||
|
silent-by-construction bug here.
|
||||||
|
|
||||||
|
### 4.4 Variant picker: **algorithmically divergent from retail**
|
||||||
|
|
||||||
|
`src/AcDream.Core/Audio/SoundCookbook.cs` does a **cumulative-probability CDF
|
||||||
|
walk**: sample `u ∈ [0,1)`, accumulate `entries[i].Probability`, first entry
|
||||||
|
whose running total exceeds `u` wins; falling off the end returns `null`
|
||||||
|
("silence tail") unless the probabilities sum to ≈1.
|
||||||
|
|
||||||
|
Retail (§1, byte-verified) does something completely different:
|
||||||
|
**uniform index `(int)((N-1) * u)`, then a per-entry
|
||||||
|
`rand()/32767 < entry.probability` gate.**
|
||||||
|
|
||||||
|
Practical differences:
|
||||||
|
- retail's index is uniform over `[0, N-2]` and never reaches the last entry;
|
||||||
|
ours is probability-weighted over all `N`.
|
||||||
|
- retail's `probability` is an independent **play/don't-play** gate on the
|
||||||
|
already-chosen entry; ours treats it as a **selection weight**. For the
|
||||||
|
common `N=1, probability=1.0` case both play the entry — so most sounds
|
||||||
|
sound identical — but for multi-variant tables (footsteps, swooshes,
|
||||||
|
creature vocalisations, exactly the audible ones) the distributions differ.
|
||||||
|
- Our `entries.Count == 1 → return entries[0]` shortcut also skips the
|
||||||
|
probability gate entirely; retail still rolls it. A single-entry sound with
|
||||||
|
`probability < 1` should sometimes be silent in retail and never is in ours.
|
||||||
|
|
||||||
|
This needs a divergence-register row when audio work lands, or a faithful
|
||||||
|
re-port (the faithful version is ~8 lines and strictly simpler than what we
|
||||||
|
have).
|
||||||
|
|
||||||
|
### 4.5 UI sounds: **ABSENT**
|
||||||
|
|
||||||
|
No `Sound_UI_*` / interface-sound concept anywhere in `src/`. `grep -i
|
||||||
|
'interfacesound|uisound|Sound_UI'` returns only unrelated `IsButtonPressed` /
|
||||||
|
`*ButtonPressed` input handlers. Concretely missing:
|
||||||
|
- no UI sound table load (retail: `DBObj::GetByEnum(0x22, 7)`);
|
||||||
|
- no `PlaySoundFromCenter` equivalent (non-positional, interface-volume pref);
|
||||||
|
- no button-press / icon-pickup / icon-drop / slider / new-target-selected /
|
||||||
|
general-error / transient-message cues, despite all of those UI surfaces now
|
||||||
|
existing (spell bar, vendor panel, inventory drag-drop, target selection);
|
||||||
|
- no portal enter/exit cue, despite the portal-space presentation being
|
||||||
|
complete;
|
||||||
|
- no `AdminEnvirons` sound mapping (`0x65..0x7C`) even though we own the
|
||||||
|
`AdminEnvirons` state in Runtime (J6.1).
|
||||||
|
|
||||||
|
### 4.6 Ambient / environment sounds: **ABSENT**
|
||||||
|
|
||||||
|
No `Ambient`, `AmbientSTBDesc`, `AmbientSoundDesc`, ambient-STB reader, or
|
||||||
|
interval-queued ambient scheduler. `grep -i 'ambientsound|AmbientStb'` → only
|
||||||
|
`SoundCookbook`'s doc comment. Retail's `ambient_sound_volume` /
|
||||||
|
`ambient_sounds_enabled` prefs have no counterpart either. Waterfalls, birds,
|
||||||
|
dungeon ambience: silent.
|
||||||
|
|
||||||
|
### 4.7 Physics-event sounds: **N/A — correctly absent**
|
||||||
|
|
||||||
|
Nothing calls the audio engine from the physics path in acdream, and per §2
|
||||||
|
that is *retail-correct*. There is no gap to fill here. The apparent gap
|
||||||
|
("collisions make no noise") is really §4.1: retail hears a collision because
|
||||||
|
the **server** sent `Sound.Collision`. Do not add a client-local collision
|
||||||
|
sound — it would be a divergence, not a fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Summary of gaps, ordered by audible impact
|
||||||
|
|
||||||
|
| # | Gap | Effort | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `0xF750` parse + route to audio | small | Needs: `SoundEvent` record in `Core.Net/Messages`, a `WorldSession` event next to the existing `0xF754`/`0xF755` ones, and a Runtime/App consumer that resolves guid → world position + SoundTableId and calls the engine with the **wire** volume. Must implement retail's *queue-for-unknown-guid* deferral. |
|
||||||
|
| 2 | UI sound bank + `PlaySoundFromCenter` | small-medium | Blocked only on resolving DB-type-0x22 enum slot 7 → concrete DID. Needs a third volume pref (`interface`) and a non-positional play path. |
|
||||||
|
| 3 | `SoundCookbook` → retail picker | tiny | Replace CDF walk with `(int)((N-1)*u)` + per-entry probability gate; add the missing `prob` gate to `SoundTweakedHook`. Register row either way. |
|
||||||
|
| 4 | Ambient / environment sounds | medium | Needs the ambient-STB reader, `AmbientSoundDesc` scheduling (`base_chance`/`min_rate`/`max_rate`, `is_continuous`), the `CanHear` gate, and an ambient volume pref. |
|
||||||
|
| 5 | `AdminEnvirons` 0x65..0x7C → UI sounds | tiny | Falls out of #2; we already own the AdminEnvirons state. |
|
||||||
|
|
||||||
|
Open research items (cheap, not done here):
|
||||||
|
- Resolve `DBCache::GetDIDFromEnumStatic(0x22, 7)` → the concrete UI SoundTable
|
||||||
|
DID (decode the static enum table, or one `dt` in cdb).
|
||||||
|
- Pin the numeric UI-sound call sites (`Sound_UI_ButtonPress = 0x72`,
|
||||||
|
`IconPickUp = 0x6F`, `IconSuccessfulDrop = 0x70`, `IconInvalid_Drop = 0x71`,
|
||||||
|
`GrabSlider = 0x73`, `ReleaseSlider = 0x74`, `NewTargetSelected = 0x75`) to
|
||||||
|
their owning UI classes — BN prints them as bare integers, so they need a
|
||||||
|
numeric grep or a Ghidra xref on the UI sound table getter.
|
||||||
|
|
||||||
|
## 6. Retail anchors (for code comments)
|
||||||
|
|
||||||
|
```
|
||||||
|
CM_Physics::DispatchSB_SoundEvent 0x006AC760 0xF750 dispatch
|
||||||
|
SmartBox::HandleSoundEvent 0x00451FC0 guid resolve / queue / play
|
||||||
|
CPhysicsObj::play_sound 0x0050F460 sound_table null-gate
|
||||||
|
SoundManager::PlaySoundA(SoundType,obj,f)0x00550AF0 wire-volume path
|
||||||
|
SoundManager::PlaySoundA(SoundType,obj) 0x00550B70 entry-volume path (hooks)
|
||||||
|
SoundManager::GetSound 0x00550680 idx = (int)((N-1)*RollDice(0,1))
|
||||||
|
SoundManager::PlayProbability 0x005500E0 rand()/32767 < p -> play
|
||||||
|
SoundManager::PlaySoundInternal 0x00550170 position + attenuation
|
||||||
|
SoundManager::GetAttenuation 0x00550020 effect vs ambient volume pref
|
||||||
|
SoundManager::PlaySoundFromCenter 0x00550950 interface sounds
|
||||||
|
SoundManager::PlayAmbientSound 0x00550820
|
||||||
|
SoundManager::PlayAmbientSoundFromCenter 0x005508B0
|
||||||
|
SoundManager::SetPlayerPosition 0x005503C0 listener = viewer/eye
|
||||||
|
SoundManager::InitPrefs 0x005503F0 the 8 sound prefs
|
||||||
|
Random::RollDice(float,float) 0x0042C600 lo + u01*(hi-lo)
|
||||||
|
SoundHook::Execute 0x00526A20
|
||||||
|
SoundTweakedHook::Execute 0x00526A80
|
||||||
|
SoundTableHook::Execute 0x00526AB0
|
||||||
|
Ambient::Play 0x005517A0
|
||||||
|
Ambient::UseTime 0x00551880
|
||||||
|
Ambient::PlaySoundA 0x00550D90
|
||||||
|
ClientUISystem::GetUISoundTable 0x00563FB0 DBObj::GetByEnum(0x22, 7)
|
||||||
|
DBObj::GetByEnum 0x00415490
|
||||||
|
CPlayerSystem::Handle_Admin__Environs 0x0055DE20 env 0x65..0x7C -> UI sounds
|
||||||
|
MediaMachine::Update_Sound 0x004658B0
|
||||||
|
CPhysicsObj::InitDefaults 0x005139D0 Setup.default_stable_id
|
||||||
|
CPhysicsObj::set_description 0x00514F40 PhysicsDesc.stable_id
|
||||||
|
CM_Physics::DispatchSB_PlayScriptID 0x006ACC40 0xF754 (we have this)
|
||||||
|
CM_Physics::DispatchSB_PlayScriptType 0x006AC6E0 0xF755 (we have this)
|
||||||
|
```
|
||||||
705
docs/research/2026-08-08-audio-retail-soundmanager-core.md
Normal file
705
docs/research/2026-08-08-audio-retail-soundmanager-core.md
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
# Lane 1 — retail `SoundManager` core, decoded, vs acdream's OpenAL engine
|
||||||
|
|
||||||
|
Date: 2026-08-08. Read-only research note.
|
||||||
|
|
||||||
|
Sources
|
||||||
|
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja pseudo-C, PDB-named)
|
||||||
|
- `docs/research/named-retail/acclient.h` (verbatim retail structs)
|
||||||
|
- **Raw byte decode** of `C:\Users\erikn\Downloads\acclient.exe` (v11.4186, PDB-paired,
|
||||||
|
image base `0x00400000`) via capstone — **required**, because the BN pseudo-C for
|
||||||
|
`GetAttenuation` and the pan block has FPU-elided constants (it prints `* 0f` where the
|
||||||
|
binary has `fmul dword [VOL_MIN_DIST_SQ]`) and one misattributed stack slot. Every
|
||||||
|
constant below is read out of the binary, not inferred.
|
||||||
|
|
||||||
|
Compared against
|
||||||
|
- `src/AcDream.App/Audio/OpenAlAudioEngine.cs`
|
||||||
|
- `src/AcDream.App/Audio/OpenAlResourceLifetime.cs`
|
||||||
|
- `src/AcDream.App/Audio/AudioHookSink.cs`
|
||||||
|
- `src/AcDream.Core/Audio/AudioModel.cs`, `SoundCookbook.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Address map (all VAs, 2013 EoR build)
|
||||||
|
|
||||||
|
| Symbol | VA |
|
||||||
|
|---|---|
|
||||||
|
| `SoundManager::PlaySoundInternal(SoundBufRef*, int pan, int volDb)` | `0x0054FEC0` |
|
||||||
|
| `SoundManager::GetAttenuation(float dist, float vol, int* outDb, int ambient)` | `0x00550020` |
|
||||||
|
| `SoundManager::PlayProbability(float)` | `0x005500E0` |
|
||||||
|
| `SoundBufRef::SoundBufRef(DataID)` | `0x00550110` |
|
||||||
|
| `SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float vol, int ambient)` | `0x00550170` |
|
||||||
|
| `SoundManager::ShutDown` | `0x005502B0` |
|
||||||
|
| `SoundManager::SetPlayerPosition(const Position*)` | `0x005503C0` |
|
||||||
|
| `SoundManager::Cleanup` (tailcall → ShutDown) | `0x005503E0` |
|
||||||
|
| `SoundManager::InitPrefs` | `0x005503F0` |
|
||||||
|
| `SoundManager::Init(HWND)` | `0x00550640` |
|
||||||
|
| `SoundManager::GetSound(SoundType, CSoundTable*, SoundData*)` | `0x00550680` |
|
||||||
|
| `SoundManager::PlaySoundA(DataID, CPhysicsObj*)` | `0x00550730` |
|
||||||
|
| `SoundManager::PlaySoundA(DataID, CPhysicsObj*, prio, prob, vol)` | `0x005507A0` |
|
||||||
|
| `SoundManager::PlayAmbientSound(SoundType, table, Position*, vol)` | `0x00550820` |
|
||||||
|
| `SoundManager::PlayAmbientSoundFromCenter(SoundType, table, vol)` | `0x005508B0` |
|
||||||
|
| `SoundManager::PlaySoundFromCenter(SoundType, CSoundTable*)` | `0x00550950` |
|
||||||
|
| `SoundManager::PlaySoundFromCenter(DataID, float vol)` | `0x005509E0` |
|
||||||
|
| `SoundManager::PlaySoundA(SoundType, CPhysicsObj*, float vol)` | `0x00550AF0` |
|
||||||
|
| `SoundManager::PlaySoundA(SoundType, CPhysicsObj*)` | `0x00550B70` |
|
||||||
|
| `SoundManager::CreateSound(DataID)` | `0x00550BF0` |
|
||||||
|
| `SoundManager::DestroySound(DataID)` | `0x00550C60` |
|
||||||
|
| `SoundBuf::ReleaseAll` | `0x00552670` |
|
||||||
|
| `SoundBuf::CopyWaveToBuffer(WaveFile*)` | `0x005526D0` |
|
||||||
|
| `SoundBuf::Stop` | `0x00552830` |
|
||||||
|
| `SoundBuf::GetStatus` | `0x00552850` |
|
||||||
|
| `SoundBuf::~SoundBuf` (tailcall → ReleaseAll) | `0x005528A0` |
|
||||||
|
| `SoundBuf::SoundBuf(const SoundBuf&)` (DuplicateSoundBuffer) | `0x005528B0` |
|
||||||
|
| `SoundBuf::Create(int bStatic)` | `0x00552930` |
|
||||||
|
| `SoundBuf::Restore` | `0x00552B90` |
|
||||||
|
| `SoundBuf::SoundBuf(DataID, tagval, bStatic, b3D)` | `0x00552D00` |
|
||||||
|
| `SoundBuf::Play(int pan, int volDb)` | `0x00552D50` |
|
||||||
|
| `SoundOK` | `0x00552E10` |
|
||||||
|
| `GetDirectSound` | `0x00552E30` |
|
||||||
|
| `SoundCleanup` | `0x00552E40` |
|
||||||
|
| `SoundSetup(HWND)` | `0x00552E70` |
|
||||||
|
| `CDirSound::DirectSoundOK` | `0x00553D00` |
|
||||||
|
| `CDirSound::CDirSound(HWND)` | `0x00553D10` |
|
||||||
|
| `CDirSound::~CDirSound` | `0x00553E40` |
|
||||||
|
| `Ambient::Play(AmbientSound*)` | `0x005517A0` |
|
||||||
|
| `Ambient::UseTime` | `0x00551880` |
|
||||||
|
| `SmartBox::set_viewer(const Position*, int type)` | `0x00452C40` |
|
||||||
|
| `SmartBox::update_viewer` | `0x00453CE0` |
|
||||||
|
| `Position::heading(const Position&)` | `0x005A9520` |
|
||||||
|
| `Position::distance(const Position&)` | `0x005A94B0` |
|
||||||
|
| `Frame::get_heading` | `0x00535760` |
|
||||||
|
|
||||||
|
### Statics
|
||||||
|
|
||||||
|
| Symbol | VA | Type / value |
|
||||||
|
|---|---|---|
|
||||||
|
| `SoundManager::VOL_MIN` | `0x0081F060` | `int32 = -50` (**decibels**) |
|
||||||
|
| `SoundManager::effect_sounds_enabled` | `0x0081F064` | `bool = 1` |
|
||||||
|
| `SoundManager::effect_sound_volume` | `0x0081F068` | `float = 1.0` |
|
||||||
|
| `SoundManager::ambient_sounds_enabled` | `0x0081F06C` | `bool = 1` |
|
||||||
|
| `SoundManager::ambient_sound_volume` | `0x0081F070` | `float = 1.0` |
|
||||||
|
| `SoundManager::interface_sounds_enabled` | `0x0081F074` | `bool = 1` |
|
||||||
|
| `SoundManager::interface_sound_volume` | `0x0081F078` | `float = 1.0` — **write-only, never read** |
|
||||||
|
| `SoundManager::s_bPlaySoundOnlyWhenActive` | `0x0081F07C` | `bool = 1` |
|
||||||
|
| `SoundManager::player_position_` | `0x0081F0E0` | `Position` (listener). `.frame` at `0x0081F0E8` |
|
||||||
|
| `SoundManager::s_SoundFeatures` | `0x0086F3A4` | `uint32 = 0`; enum table at `0x0086F3E8` |
|
||||||
|
| `SoundManager::curr_playing_buffer_` | `0x0086F3A8` | `int32 = 0` — ring cursor |
|
||||||
|
| `SoundManager::s_bInittedPrefs` | `0x0086F3AC` | `bool = 0` |
|
||||||
|
| `SoundManager::sound_hash_` | `0x0086F4A0` | `IntrusiveHashTable<DataID, SoundBufRef*>`, ctor arg `0x40` |
|
||||||
|
| `SoundManager::playing_sounds_` | `0x0086F510` | `SoundPlayingData[0x10]` — **16 voices**, stride `0x10` |
|
||||||
|
| `SoundBuf::useDatabase` | `0x0081F220` | `int32 = 1` |
|
||||||
|
| `VOL_MIN_DIST` | `0x007CAEAC` (rodata) | `float = 5.0` (metres) |
|
||||||
|
| `VOL_MIN_DIST_SQ` | `0x0086F404` (.data) | `float = 25.0`, static-init `5f*5f` |
|
||||||
|
| `INV_LOG_OF_2` | `0x0086F408` (.data) | `double = 1/ln 2 = 1.4426950408889634`, static-init `1.0 / fyl2x(2.0, ln2)` |
|
||||||
|
| dB-per-octave const | `0x007CAF48` (rodata) | `double = 6.0206` (= `20·log10 2`) |
|
||||||
|
| **pan scale** | `0x007CAF58` (rodata) | `double = -15.0` |
|
||||||
|
| probability scale | `0x007CAF50` (rodata) | `float = 3.05185094e-05` (= `1/32767`) |
|
||||||
|
| DEG→RAD | `0x0079B504` | `float = 0.0174532924` |
|
||||||
|
| RAD→DEG | `0x0079B6C8` | `double = 57.29577951308232` |
|
||||||
|
| heading base | `0x0079B6C0` | `double = 450.0` (= `360 + 90`) |
|
||||||
|
| pan deadzone | `0x007991B0` | `double = 5.0` (metres) |
|
||||||
|
|
||||||
|
### Struct layouts (from `acclient.h`, offsets confirmed against the binary)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct SoundData { // 0x10
|
||||||
|
DataID sound_id_; // +0x00
|
||||||
|
float priority_; // +0x04 <-- eviction key
|
||||||
|
float probability_; // +0x08
|
||||||
|
float volume_; // +0x0C
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SoundBufRef { // 0x24, operator new(0x24)
|
||||||
|
DataID m_hashKey; // +0x00
|
||||||
|
SoundBufRef* m_hashNext; // +0x04
|
||||||
|
SoundData data_; // +0x08 .. +0x17
|
||||||
|
int links_; // +0x18 refcount (CreateSound/DestroySound)
|
||||||
|
SoundBuf* sound_buf_; // +0x1C template buffer, duplicated per play
|
||||||
|
int buffer_num_; // +0x20 init 0xFFFFFFFF, unused
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SoundPlayingData { // 0x10
|
||||||
|
SoundBuf* buffer; // +0x00
|
||||||
|
float priority; // +0x04
|
||||||
|
long double start_time; // +0x08 (8 bytes, Timer::cur_time) <-- WRITTEN, NEVER READ
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SoundBuf { // 0x20, operator new(0x20)
|
||||||
|
CDirSound* m_pCDirSound; // +0x00
|
||||||
|
IDirectSoundBuffer* m_pBuf; // +0x04
|
||||||
|
IDirectSound3DBuffer* m_p3DBuf; // +0x08
|
||||||
|
char* m_filename; // +0x0C
|
||||||
|
int m_tagval; // +0x10
|
||||||
|
unsigned m_bufsize; // +0x14
|
||||||
|
int m_3D; // +0x18
|
||||||
|
DataID m_gid; // +0x1C
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CDirSound { // 0x24, operator new(0x24)
|
||||||
|
tWAVEFORMATEX m_defaultFormat; // +0x00 (18B, padded to 0x14)
|
||||||
|
HWND m_hWindow; // +0x14
|
||||||
|
IDirectSound* m_pDirectSoundObj; // +0x18
|
||||||
|
IDirectSound3DListener* m_lpDs3dListener; // +0x1C
|
||||||
|
IDirectSoundBuffer* m_3DSoundBuffer; // +0x20 (primary)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SoundManager { }; // pure statics, no instance
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Pseudocode, function by function
|
||||||
|
|
||||||
|
### `SoundSetup(HWND)` / `CDirSound::CDirSound` / `SoundOK` / `SoundCleanup`
|
||||||
|
|
||||||
|
```
|
||||||
|
SoundSetup(hwnd):
|
||||||
|
if hwnd == 0:
|
||||||
|
Device::Error("SoundSetup requires a valid HWND! Sound will be disabled.",
|
||||||
|
"SoundSetup Error")
|
||||||
|
return 0
|
||||||
|
delete pDirSound # tear down any previous device
|
||||||
|
pDirSound = new CDirSound(hwnd) # 0x24 bytes
|
||||||
|
return (pDirSound && pDirSound->m_pDirectSoundObj != 0)
|
||||||
|
|
||||||
|
CDirSound::CDirSound(hwnd):
|
||||||
|
m_pDirectSoundObj = m_lpDs3dListener = m_3DSoundBuffer = null
|
||||||
|
m_hWindow = hwnd
|
||||||
|
if DirectSoundCreate(NULL, &m_pDirectSoundObj, NULL) != DS_OK: return
|
||||||
|
if m_pDirectSoundObj->SetCooperativeLevel(hwnd, DSSCL_PRIORITY /*2*/) != DS_OK:
|
||||||
|
m_pDirectSoundObj = null; return
|
||||||
|
# primary buffer
|
||||||
|
DSBUFFERDESC s = {0}; s.dwSize = 0x24
|
||||||
|
s.dwFlags = 0x11 # DSBCAPS_PRIMARYBUFFER | DSBCAPS_CTRL3D
|
||||||
|
if CreateSoundBuffer(&s, &m_3DSoundBuffer, NULL) != DS_OK: return
|
||||||
|
if m_3DSoundBuffer->QueryInterface(IID_IDirectSound3DListener, &m_lpDs3dListener) != DS_OK: return
|
||||||
|
m_lpDs3dListener->SetRolloffFactor(0.01f /*0x3C23D70A*/, DS3D_IMMEDIATE)
|
||||||
|
m_lpDs3dListener->SetOrientation(front=(-1,0,0), top=(0,1,0), DS3D_IMMEDIATE)
|
||||||
|
m_lpDs3dListener->CommitDeferredSettings()
|
||||||
|
m_defaultFormat = { PCM, 2ch, 16-bit, 11025 Hz (0x2B11),
|
||||||
|
nBlockAlign 4, nAvgBytesPerSec 44100 (0xAC44), cbSize 0 }
|
||||||
|
m_3DSoundBuffer->SetFormat(&m_defaultFormat)
|
||||||
|
m_3DSoundBuffer->Play(0, 0, DSBPLAY_LOOPING /*1*/) # primary buffer runs forever
|
||||||
|
|
||||||
|
SoundOK() -> pDirSound && pDirSound->m_pDirectSoundObj != 0
|
||||||
|
GetDirectSound()-> pDirSound
|
||||||
|
SoundCleanup() -> delete pDirSound; pDirSound = null
|
||||||
|
```
|
||||||
|
|
||||||
|
**The 3D listener exists but gameplay never uses it.** `SoundBufRef::SoundBufRef` creates
|
||||||
|
its template `SoundBuf` with `b3D = 0`, so `SoundBuf::Create` takes the non-3D branch and
|
||||||
|
sets `m_3D = 0`. Every gameplay/UI/ambient voice is a **2D buffer with CPU-computed pan
|
||||||
|
and volume**. `IDirectSound3DBuffer` is only reachable through a path nothing in
|
||||||
|
SoundManager takes.
|
||||||
|
|
||||||
|
### `SoundManager::Init` / `InitPrefs` / `ShutDown` / `Cleanup`
|
||||||
|
|
||||||
|
```
|
||||||
|
Init(hwnd):
|
||||||
|
SoundSetup(InitPrefs()) # note: InitPrefs() returns void; hwnd reaches
|
||||||
|
# SoundSetup through ecx (__fastcall) — a compiler
|
||||||
|
# artifact, semantics are InitPrefs(); SoundSetup(hwnd)
|
||||||
|
midiSetup()
|
||||||
|
if SoundOK() == 0:
|
||||||
|
effect_sounds_enabled = 0 # hard-disable effects when there is no device
|
||||||
|
return
|
||||||
|
srand(time(0))
|
||||||
|
|
||||||
|
InitPrefs(): # exact preference names, in registration order
|
||||||
|
RegisterPreference(&effect_sound_volume, "Sound Volume") # float, default 1.0
|
||||||
|
RegisterPreference(&ambient_sound_volume, "Ambient Sound Volume") # float, default 1.0
|
||||||
|
RegisterPreference(&interface_sound_volume, "Interface Sound Volume") # float, default 1.0
|
||||||
|
RegisterPreference(&s_SoundFeatures, "Sound Features", enumTable=0x86F3E8, kind=2) # uint, default 0
|
||||||
|
RegisterPreference(&effect_sounds_enabled, "Sound Disabled") # bool, default 1
|
||||||
|
RegisterPreference(&ambient_sounds_enabled, "Ambient Sound Disabled") # bool, default 1
|
||||||
|
RegisterPreference(&interface_sounds_enabled, "Interface Sound Disabled") # bool, default 1
|
||||||
|
RegisterPreference(&s_bPlaySoundOnlyWhenActive, "Play Sound Only When Active") # bool, default 1
|
||||||
|
s_bInittedPrefs = 1
|
||||||
|
|
||||||
|
ShutDown(): # Cleanup() is a tailcall to this
|
||||||
|
for each SoundBufRef in sound_hash_: SoundBuf::Stop(ref->sound_buf_)
|
||||||
|
for i in 0..15:
|
||||||
|
slot = playing_sounds_[(curr_playing_buffer_ + i) mod 16]
|
||||||
|
if slot.buffer: Stop(slot.buffer); ~SoundBuf(slot.buffer); delete slot.buffer
|
||||||
|
midiCleanup(); SoundCleanup()
|
||||||
|
if s_bInittedPrefs: UnregisterPreference(all 8)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the enable flags are named `..._Disabled` in the preference store but the backing
|
||||||
|
variables are `..._enabled` with default 1. Whoever reads the pref file must invert or the
|
||||||
|
pref writer already stores the inverted sense — do not assume the on-disk polarity.
|
||||||
|
|
||||||
|
### `SoundManager::CreateSound` / `DestroySound` — refcounted registration
|
||||||
|
|
||||||
|
```
|
||||||
|
CreateSound(DataID id):
|
||||||
|
ref = sound_hash_.find(id)
|
||||||
|
if ref: ref->links_ += 1; return # refcount bump
|
||||||
|
ref = new SoundBufRef(id) # allocates + creates the template SoundBuf now
|
||||||
|
sound_hash_.add(ref)
|
||||||
|
|
||||||
|
SoundBufRef::SoundBufRef(id):
|
||||||
|
m_hashKey = id; m_hashNext = null
|
||||||
|
SoundData::SoundData(&data_) # zero-init
|
||||||
|
links_ = 1; buffer_num_ = 0xFFFFFFFF
|
||||||
|
sound_buf_ = new SoundBuf(id, tagval=0, bStatic=1, b3D=0) # 2D, DSBCAPS_STATIC
|
||||||
|
|
||||||
|
DestroySound(DataID id):
|
||||||
|
ref = sound_hash_.find(id); if !ref: return
|
||||||
|
if ref->links_-- == 1: # last reference
|
||||||
|
ref = sound_hash_.remove(id)
|
||||||
|
~SoundBuf(ref->sound_buf_); delete ref->sound_buf_
|
||||||
|
delete ref
|
||||||
|
```
|
||||||
|
|
||||||
|
A sound that was never `CreateSound`'d cannot be played: every `PlaySound*` walks
|
||||||
|
`sound_hash_` and silently returns when the id is absent. The wave is decoded and copied
|
||||||
|
into a DirectSound buffer eagerly at `CreateSound` time (see `SoundBuf::Create`), never on
|
||||||
|
first play.
|
||||||
|
|
||||||
|
### `SoundManager::GetSound` — variant selection (**biased**)
|
||||||
|
|
||||||
|
```
|
||||||
|
GetSound(SoundType stype, CSoundTable* table, out SoundData* d) -> SoundBufRef*:
|
||||||
|
if table == 0: return 0
|
||||||
|
if !CSoundTable::Lookup(table, stype, &std): return 0
|
||||||
|
n = std->num_stdatas_ # [+0x7C]
|
||||||
|
if n <= 0: return 0
|
||||||
|
roll = Random::RollDice(0.0f, 1.0f) # [0, 1]
|
||||||
|
idx = (int)( (float)(n - 1) * roll ) # <-- (n-1), TRUNCATED
|
||||||
|
if (unsigned)idx >= n: return 0
|
||||||
|
row = &std->data_[idx] # 16-byte rows at [+0x80]
|
||||||
|
d->sound_id_ = row[0]; d->priority_ = row[4]
|
||||||
|
d->probability_ = row[8]; d->volume_ = row[0xC]
|
||||||
|
if d->sound_id_ == 0: return 0
|
||||||
|
return sound_hash_.find(d->sound_id_)
|
||||||
|
```
|
||||||
|
|
||||||
|
`idx = floor(roll · (n−1))`, **not** `floor(roll · n)`. Consequences:
|
||||||
|
- `n = 1` → always row 0.
|
||||||
|
- `n = 2` → row 0 unless the roll is exactly 1.0; row 1 has probability ≈ 1/32768.
|
||||||
|
- `n = 3` → rows 0 and 1 at ~50% each; row 2 ≈ 1/32768.
|
||||||
|
The last row of every multi-row sound entry is effectively dead in retail. This is retail
|
||||||
|
behaviour, not a decomp artifact — the `fild (n-1)` / `fmul st(1)` / `_ftol2` sequence is
|
||||||
|
unambiguous at `0x005506C8..0x005506E2`.
|
||||||
|
|
||||||
|
### `SoundManager::PlayProbability`
|
||||||
|
|
||||||
|
```
|
||||||
|
PlayProbability(float prob):
|
||||||
|
return ((float)rand() * (1.0f/32767.0f)) < prob # play iff strictly less
|
||||||
|
```
|
||||||
|
|
||||||
|
`probability_` is an **independent gate applied after** the index pick — not a selection
|
||||||
|
weight. Applied by every `SoundType`-keyed overload and by
|
||||||
|
`PlaySoundA(DataID, obj, prio, prob, vol)`; **not** applied by
|
||||||
|
`PlaySoundA(DataID, CPhysicsObj*)` or `PlaySoundFromCenter(DataID, vol)`.
|
||||||
|
|
||||||
|
### `SoundManager::GetAttenuation` — the falloff, exact
|
||||||
|
|
||||||
|
Byte-level decode of `0x00550020`:
|
||||||
|
|
||||||
|
```
|
||||||
|
GetAttenuation(float dist, float vol, int* outDb, int ambient) -> int:
|
||||||
|
# 1. distance term
|
||||||
|
if dist < VOL_MIN_DIST /*5.0 m*/:
|
||||||
|
g = vol
|
||||||
|
else:
|
||||||
|
g = (VOL_MIN_DIST_SQ /*25.0*/ * vol) / (dist * dist) # exact inverse-square,
|
||||||
|
# continuous at 5 m
|
||||||
|
# 2. clamp above
|
||||||
|
if g > 1.0: g = 1.0
|
||||||
|
|
||||||
|
# 3. one, and only one, master multiply
|
||||||
|
g *= (ambient != 0) ? ambient_sound_volume : effect_sound_volume
|
||||||
|
|
||||||
|
# 4. silent gate
|
||||||
|
if g <= 0.0: *outDb = VOL_MIN /*-50*/; return 0 # DO NOT PLAY
|
||||||
|
|
||||||
|
# 5. linear gain -> integer decibels
|
||||||
|
# fldln2; fyl2x => ln(g)
|
||||||
|
# * INV_LOG_OF_2 (1/ln2) => log2(g)
|
||||||
|
# * 6.0206 (20*log10 2) => 20*log10(g)
|
||||||
|
db = (int) ceil( 20.0 * log10(g) )
|
||||||
|
|
||||||
|
*outDb = db
|
||||||
|
if db >= VOL_MIN /*-50*/: return 1 # PLAY at db decibels
|
||||||
|
*outDb = VOL_MIN; return 0 # DO NOT PLAY
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes that matter:
|
||||||
|
- The distance model is **inverse-square with a 5-metre reference**, expressed as
|
||||||
|
`25/d²`, clamped to unity, and it is **hard-cut at −50 dB**. Solving
|
||||||
|
`ceil(20·log10(25·vol·master/d²)) ≥ −50` ⟺ `25·vol·master/d² > 10^(−51/20)`:
|
||||||
|
the audible radius is **≈ 94.2 m** at `vol·master = 1.0`, **≈ 66.6 m** at 0.5,
|
||||||
|
**≈ 29.8 m** at 0.1. Beyond that the sound is *never started* — no voice, no slot.
|
||||||
|
- Output is **integer decibels quantised by `ceil`** — a 1 dB stair-step as you walk
|
||||||
|
toward a source, not a smooth ramp.
|
||||||
|
- `dist` is `Position::distance` (`0x005A94B0`): `sqrt(dx²+dy²+dz²)` of
|
||||||
|
`Position::get_offset`, which resolves the landblock delta first — a true cross-landblock
|
||||||
|
3D metric distance in metres, **including Z**.
|
||||||
|
- `vol` here is whatever the caller passed. Three callers pre-multiply by a master
|
||||||
|
volume, so the master lands **twice** (see §4 quirk).
|
||||||
|
|
||||||
|
### `SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float vol, int ambient)` — pan
|
||||||
|
|
||||||
|
Byte-level decode of `0x00550170`. **BN's pseudo-C is wrong here**: it reuses stack slot
|
||||||
|
`[esp+0xC]` and reports the `< 5.0` test as an *angle* test. In the binary the `_ftol2`
|
||||||
|
at `0x005501F2` converts `[esp+4]` = **distance**, and the x87 stack still holds the
|
||||||
|
angle. It is a *distance* deadzone.
|
||||||
|
|
||||||
|
```
|
||||||
|
PlaySoundInternal(ref, const Position* soundPos, float vol, int ambient):
|
||||||
|
if s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp: return
|
||||||
|
|
||||||
|
listenerHeading = Frame::get_heading(&player_position_.frame) # degrees
|
||||||
|
dist = Position::distance(soundPos, &player_position_) # metres, 3D
|
||||||
|
headingSoundToListener = Position::heading(soundPos, &player_position_) # degrees
|
||||||
|
|
||||||
|
pan = 0
|
||||||
|
if s_SoundFeatures != 1: # 1 == panning disabled
|
||||||
|
delta = fmod(headingSoundToListener - listenerHeading, 360.0)
|
||||||
|
if !(delta <= 180.0): delta -= 360.0 # normalise to (-180, 180]
|
||||||
|
if abs((int)dist) >= 5: # <-- DISTANCE deadzone, 5 m
|
||||||
|
pan = (int)( sin(delta * 0.0174532924f) * -15.0 ) # -15..+15
|
||||||
|
# else pan stays 0: anything inside 5 m plays dead centre
|
||||||
|
|
||||||
|
if GetAttenuation(dist, vol, &db, ambient):
|
||||||
|
PlaySoundInternal(ref, pan, db)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Position::heading(this, other)` (`0x005A9520`) and `Frame::get_heading` (`0x00535760`)
|
||||||
|
share one convention: `fmod(450.0 − atan2(dy, dx)·57.29578, 360.0)` — i.e. **compass
|
||||||
|
degrees, clockwise from +Y (north)**, `+X` (east) = 90°. `Position::heading` returns the
|
||||||
|
heading **from `this` toward `other`**, so `heading(soundPos, listenerPos)` is the
|
||||||
|
*reverse* bearing; combining that reversal with the `-15.0` scale yields the correct
|
||||||
|
handedness. Worked check: sound due east, listener facing north ⇒ delta = −90° ⇒
|
||||||
|
`pan = -15·sin(-90°) = +15` = full right in DirectSound. ✓
|
||||||
|
|
||||||
|
Equivalent forward formulation for a port:
|
||||||
|
|
||||||
|
> `pan_dB = 15 · sin(bearing_of_source_relative_to_listener_facing)`, zero inside 5 m.
|
||||||
|
|
||||||
|
**There is no front/back and no elevation cue.** A source dead ahead and a source directly
|
||||||
|
behind both give `pan = 0`; Z contributes to distance but never to pan.
|
||||||
|
|
||||||
|
### `SoundManager::PlaySoundInternal(SoundBufRef*, int pan, int volDb)` — the 16-voice pool
|
||||||
|
|
||||||
|
Byte-level decode of `0x0054FEC0` (this, not `FUN_00550AD0`, is the voice allocator):
|
||||||
|
|
||||||
|
```
|
||||||
|
PlaySoundInternal(ref, pan, volDb):
|
||||||
|
if s_bPlaySoundOnlyWhenActive && !Device::m_bIsActiveApp: return
|
||||||
|
now = Timer::cur_time # 8-byte double
|
||||||
|
|
||||||
|
# PASS 1 — ring scan from curr_playing_buffer_ for a reusable slot
|
||||||
|
for i in 0 .. 15:
|
||||||
|
s = (curr_playing_buffer_ + i) mod 16 # signed-safe & 0x8000000F fixup
|
||||||
|
buf = playing_sounds_[s].buffer
|
||||||
|
if buf == null: goto CLAIM # never used
|
||||||
|
if buf->m_pBuf == null: goto DESTROY_CLAIM # broken buffer
|
||||||
|
if (SoundBuf::GetStatus(buf) & DSBSTATUS_PLAYING) == 0:
|
||||||
|
goto DESTROY_CLAIM # finished
|
||||||
|
# slot is genuinely busy, keep scanning
|
||||||
|
|
||||||
|
# PASS 2 — all 16 busy: priority eviction, same ring order
|
||||||
|
for j in 0 .. 15:
|
||||||
|
s = (curr_playing_buffer_ + j) mod 16
|
||||||
|
if playing_sounds_[s].priority < ref->data_.priority_: # STRICTLY less
|
||||||
|
SoundBuf::Stop(playing_sounds_[s].buffer)
|
||||||
|
goto DESTROY_CLAIM
|
||||||
|
return # nothing lower-priority -> DROP the new sound
|
||||||
|
|
||||||
|
DESTROY_CLAIM:
|
||||||
|
~SoundBuf(buf); delete buf # a voice is a real DS buffer; it is freed
|
||||||
|
CLAIM:
|
||||||
|
v = new SoundBuf(0x20)
|
||||||
|
if v: SoundBuf::SoundBuf(v, ref->sound_buf_) # IDirectSound::DuplicateSoundBuffer
|
||||||
|
playing_sounds_[s].buffer = v
|
||||||
|
playing_sounds_[s].priority = ref->data_.priority_
|
||||||
|
playing_sounds_[s].start_time = now # written, never read anywhere
|
||||||
|
curr_playing_buffer_ = (s + 1) mod 16
|
||||||
|
SoundBuf::Play(v, pan, volDb)
|
||||||
|
```
|
||||||
|
|
||||||
|
Answers to the slot questions:
|
||||||
|
- **Count:** exactly 16 (`playing_sounds_[0x10]`, `& 0x8000000F` masking).
|
||||||
|
- **Selection:** round-robin from `curr_playing_buffer_`; first slot that is empty, has a
|
||||||
|
null `m_pBuf`, or is no longer `DSBSTATUS_PLAYING`.
|
||||||
|
- **Eviction:** *priority only*, `slot.priority < new.priority`, first match in ring order.
|
||||||
|
**Equal priority never evicts.** Volume/gain is not consulted. `start_time` is recorded
|
||||||
|
but never read, so age only enters through the ring cursor.
|
||||||
|
- **Overflow:** the new sound is silently dropped.
|
||||||
|
|
||||||
|
### `SoundBuf::Create` / `CopyWaveToBuffer` / `Restore` / `Play` / `Stop` / `GetStatus`
|
||||||
|
|
||||||
|
```
|
||||||
|
SoundBuf::SoundBuf(DataID gid, int tagval, int bStatic, int b3D):
|
||||||
|
m_pBuf = m_p3DBuf = m_filename = null; m_bufsize = 0
|
||||||
|
m_tagval = tagval; m_3D = b3D; m_gid = gid
|
||||||
|
m_pCDirSound = GetDirectSound()
|
||||||
|
if m_pCDirSound: Create(bStatic)
|
||||||
|
|
||||||
|
SoundBuf::Create(int bStatic) -> int:
|
||||||
|
ds = m_pCDirSound->m_pDirectSoundObj; if !ds: return 0
|
||||||
|
if m_3D == 0 || m_pCDirSound->m_lpDs3dListener == null:
|
||||||
|
flags = 0x100E0 # GETCURRENTPOSITION2 | CTRLVOLUME | CTRLPAN | CTRLFREQUENCY
|
||||||
|
m_3D = 0
|
||||||
|
else:
|
||||||
|
flags = 0x100B0 # GETCURRENTPOSITION2 | CTRLVOLUME | CTRLFREQUENCY | CTRL3D
|
||||||
|
if bStatic: flags |= DSBCAPS_STATIC /*0x2*/
|
||||||
|
if SoundBuf::useDatabase /*1*/:
|
||||||
|
obj = DBObj::Get(QualifiedDataID(m_gid, 0x0F)) # 0x0F = Wave
|
||||||
|
wave = obj + 0x38
|
||||||
|
DSBUFFERDESC s = {0}; s.dwSize = 0x24; s.dwFlags = flags
|
||||||
|
... lpwfxFormat = wave fmt; dwBufferBytes = wave data size ...
|
||||||
|
if ds->CreateSoundBuffer(&s, &m_pBuf, NULL) == DS_OK:
|
||||||
|
m_bufsize = size
|
||||||
|
if CopyWaveToBuffer(wave): return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
SoundBuf::CopyWaveToBuffer(WaveFile* w) -> int:
|
||||||
|
Lock(0, m_bufsize, &p1,&n1, &p2,&n2, 0)
|
||||||
|
if global ACM stream `phas` != null: acmStreamPrepareHeader/Convert/UnprepareHeader
|
||||||
|
else: memcpy p1 (+ wrap into p2)
|
||||||
|
Unlock(...)
|
||||||
|
|
||||||
|
SoundBuf::SoundBuf(const SoundBuf& src): # per-play voice
|
||||||
|
zero everything; m_pCDirSound = GetDirectSound()
|
||||||
|
if m_pCDirSound->m_pDirectSoundObj->DuplicateSoundBuffer(src.m_pBuf, &m_pBuf) == DS_OK:
|
||||||
|
copy m_bufsize, m_tagval, m_3D, m_gid
|
||||||
|
if m_3D: m_pBuf->QueryInterface(IID_IDirectSound3DBuffer, &m_p3DBuf)
|
||||||
|
|
||||||
|
SoundBuf::Play(int pan, int volDb) -> int:
|
||||||
|
if pan < -15: pan = -15
|
||||||
|
elif pan > 15: pan = 15
|
||||||
|
if pan != 0 && m_pBuf && m_3D == 0: m_pBuf->SetPan(pan * 100) # hundredths of dB
|
||||||
|
if volDb < VOL_MIN /*-50*/: volDb = VOL_MIN
|
||||||
|
if m_pBuf: m_pBuf->SetVolume(volDb * 100) # hundredths of dB
|
||||||
|
if m_pBuf->SetCurrentPosition(0) == DS_OK:
|
||||||
|
hr = m_pBuf->Play(0, 0, 0) # dwFlags 0 — NO LOOPING
|
||||||
|
if hr == DSERR_BUFFERLOST /*0x88780096*/ && Restore():
|
||||||
|
hr = m_pBuf->Play(0, 0, 0)
|
||||||
|
return hr == DS_OK
|
||||||
|
return 0
|
||||||
|
|
||||||
|
SoundBuf::Stop() -> m_pBuf ? (m_pBuf->Stop(), 1) : 0
|
||||||
|
SoundBuf::GetStatus() -> m_pBuf && GetStatus(&st)==DS_OK ? st : -1 # bit0 = DSBSTATUS_PLAYING
|
||||||
|
SoundBuf::Restore() -> re-fetch the wave from the DAT and re-run the Create/CopyWave path
|
||||||
|
SoundBuf::ReleaseAll()-> delete[] m_filename; Release m_pBuf, m_p3DBuf; memset; m_gid = INVALID
|
||||||
|
```
|
||||||
|
|
||||||
|
Units to keep straight: retail's internal volume/pan are **whole decibels**; DirectSound's
|
||||||
|
`SetVolume`/`SetPan` take **hundredths of a decibel**, hence the `× 100`. Retail's floor is
|
||||||
|
`-50 dB` (`-5000`), half of DirectSound's `DSBVOLUME_MIN = -10000`. Pan saturates at
|
||||||
|
`±15 dB` (`±1500`) out of DirectSound's `±10000`, so retail's stereo image is **narrow by
|
||||||
|
construction** — a hard-panned sound is only 15 dB down in the far ear, never silent.
|
||||||
|
|
||||||
|
### Listener: `SetPlayerPosition`
|
||||||
|
|
||||||
|
```
|
||||||
|
SetPlayerPosition(const Position* p):
|
||||||
|
player_position_.objcell_id = p->objcell_id
|
||||||
|
Frame::operator=(&player_position_.frame, &p->frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
Who writes it, and when:
|
||||||
|
|
||||||
|
| Caller | Source | Cadence |
|
||||||
|
|---|---|---|
|
||||||
|
| `SmartBox::set_viewer` (`0x00452D36`) | `SmartBox::viewer` | see below |
|
||||||
|
| `CreatureMode::Render` (`0x00452A83`, `0x00452AAE`) | `creature_view_frame`, then restores the saved `player_position_` | per creature-mode frame |
|
||||||
|
|
||||||
|
`SmartBox::set_viewer(pos, type)` copies `pos` into `SmartBox::viewer` and then hands
|
||||||
|
`&this->viewer` to `SoundManager::SetPlayerPosition`, `LScape::set_sky_position`, and
|
||||||
|
`SceneTool::SetupCamera` — so **the audio listener is the same Position the camera uses.**
|
||||||
|
Its writers:
|
||||||
|
- `SmartBox::update_viewer` (`0x00453CE0`), called from `SmartBox::DrawNoBlit`
|
||||||
|
(`0x00454C34`) — **once per rendered frame**. It runs the third-person camera through a
|
||||||
|
`CTransition` sphere sweep (`viewer_sphere`) and sets the viewer to the *collided camera
|
||||||
|
position* (`type = 0`); on sweep failure it falls back to
|
||||||
|
`set_viewer(&player->m_position, 1)`.
|
||||||
|
- `SmartBox::PlayerPositionUpdated` / `TeleportPlayer` / `BlipPlayer` —
|
||||||
|
`set_viewer(&player->m_position, 1)`, event-driven.
|
||||||
|
|
||||||
|
So: **listener = camera viewer position + that Position's `Frame` heading, in world/cell
|
||||||
|
space (objcell_id + Frame), refreshed every rendered frame.** Only two things are read out
|
||||||
|
of it: the frame origin (distance) and `Frame::get_heading` (pan). No up vector, no
|
||||||
|
velocity ⇒ **no doppler, no roll/pitch influence, no elevation cue**.
|
||||||
|
|
||||||
|
### Looping and the ambient driver
|
||||||
|
|
||||||
|
`SoundBuf::Play` always passes `dwFlags = 0`. **Nothing in SoundManager ever loops.** The
|
||||||
|
only looped buffer in the client is `CDirSound`'s primary buffer.
|
||||||
|
|
||||||
|
Sustained ambience is a **re-trigger scheduler**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Ambient::Play(AmbientSound* a):
|
||||||
|
if !a->CanHear(): a->on_queue = 0; return
|
||||||
|
if a->PlayNow():
|
||||||
|
if a->GetSoundPos(&pos): Ambient::PlaySoundA(stype, table, &pos, a->GetVolume())
|
||||||
|
else: PlayAmbientSoundFromCenter(stype, table, a->GetVolume())
|
||||||
|
Insert(&sound_queue, Timer::cur_time + a->GetPlayInterval(), a) # PQueueArray<double>
|
||||||
|
a->on_queue = 1
|
||||||
|
|
||||||
|
Ambient::UseTime(): # SmartBox::UseTime -> per game tick
|
||||||
|
if !ambient_sounds_enabled: return
|
||||||
|
while sound_queue not empty and sound_queue.top().key <= Timer::cur_time:
|
||||||
|
pop and Ambient::Play(it)
|
||||||
|
|
||||||
|
Ambient::PlaySoundA(stype, table, pos, vol):
|
||||||
|
pos ? PlayAmbientSound(stype, table, pos, vol) : PlayAmbientSoundFromCenter(stype, table, vol)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ConstantSound` (has `current_volume`) and `IntermitSound` (has `play_chance`,
|
||||||
|
`min_dist[8]`, `max_dist[8]`, `num_dir`, `sound_dir[8]`) are the two `AmbientSound`
|
||||||
|
subclasses supplying `GetVolume` / `GetPlayInterval` / `CanHear` / `PlayNow`.
|
||||||
|
`Ambient::AddSound` gates on `Ambient::ambient_sound_max_dist_sq` and weights with
|
||||||
|
`Ambient::CalcWeight` (which uses `ambient_sound_min_dist_sq` / `..._max_dist_sq`).
|
||||||
|
|
||||||
|
### Pitch / frequency
|
||||||
|
|
||||||
|
`DSBCAPS_CTRLFREQUENCY (0x20)` is requested on **every** buffer, and
|
||||||
|
`IDirectSoundBuffer::SetFrequency` is **never called anywhere in the binary**. Retail has
|
||||||
|
**no pitch or frequency variation** on sound effects. There is no `PitchMin`/`PitchMax`
|
||||||
|
concept in `SoundData` — the four fields are `sound_id_`, `priority_`, `probability_`,
|
||||||
|
`volume_`, full stop.
|
||||||
|
|
||||||
|
### Per-frame voice maintenance
|
||||||
|
|
||||||
|
`SetPan` and `SetVolume` are called from exactly one place: `SoundBuf::Play`. There is no
|
||||||
|
SoundManager tick, no `UseTime`, no reposition pass. **A voice keeps the pan and volume it
|
||||||
|
was born with for its entire lifetime.** If a drudge emits a footstep and then runs past
|
||||||
|
you, that footstep does not move. If a source is beyond ≈94 m the sound is never started
|
||||||
|
at all rather than started quietly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. acdream today
|
||||||
|
|
||||||
|
`OpenAlAudioEngine.Play3DWave` is the only live 3D path (called from
|
||||||
|
`AudioHookSink.Play`, i.e. animation `SoundHook` / `SoundTableHook` / `SoundTweakedHook`).
|
||||||
|
It:
|
||||||
|
1. computes `effectiveGain = volume * SfxVolume`, drops if `< 0.001f`;
|
||||||
|
2. uploads/reuses an AL buffer (LRU byte-budgeted, 48 MiB);
|
||||||
|
3. picks a slot: first free-or-not-playing in ring order, else first with
|
||||||
|
`PlayingGain < effectiveGain`, else drop;
|
||||||
|
4. sets `Gain = effectiveGain`, `Pitch`, `Position`, `SourceRelative = false`,
|
||||||
|
`Looping = false`, plays;
|
||||||
|
5. records `PlayingGain`, `PriorityBase = clamp((int)priority, 0, 7)`, advances the cursor.
|
||||||
|
|
||||||
|
Sources are configured once (`Configure3DSource`): `MaxDistance = 1000`,
|
||||||
|
`RolloffFactor = 1`, `ReferenceDistance = 2`, and the global model is
|
||||||
|
`DistanceModel.InverseDistanceClamped` (`SelectRetailDistanceModel`).
|
||||||
|
|
||||||
|
`SetListener` is called per frame from `WorldRenderFrameBuilder.Apply` with the camera
|
||||||
|
position and a real forward/up pair derived from `camera.InverseView`;
|
||||||
|
`MasterVolume` is pushed into `AL_GAIN` on the listener.
|
||||||
|
|
||||||
|
`AudioFalloff.AttenuationAt` and `AudioFalloff.PanFromRelative` in
|
||||||
|
`AcDream.Core/Audio/AudioModel.cs` are **dead code** — `grep` across `src/` and `tests/`
|
||||||
|
finds no caller.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Divergence table
|
||||||
|
|
||||||
|
| # | Aspect | Retail (verified) | acdream | Severity |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D1 | Voice-allocator citation | `SoundManager::PlaySoundInternal(SoundBufRef*,int,int)` at **`0x0054FEC0`** | comment cites `FUN_00550AD0` / `chunk_00550000.c:527`; `0x00550AD0` is inside `IntrusiveHashTable<DataID,SoundBufRef*>::ctor` (`0x00550A60`) — **wrong function** | doc bug, fix the citation |
|
||||||
|
| D2 | Eviction key | `slot.priority < new.priority` (float `SoundData.priority_` from the SoundTable), strictly less; equal never evicts; gain never consulted | `slot.PlayingGain < effectiveGain` (volume × SfxVolume); `PriorityBase` stored but unused | **behavioural — loud-and-unimportant beats quiet-and-important** |
|
||||||
|
| D3 | Priority type/range | `float`, unclamped, straight from the DAT | `clamp((int)priority, 0, 7)`; model comments it "0..7" | flattens the ordering |
|
||||||
|
| D4 | Distance model | inverse **square** with 5 m reference: `min(1, 25·vol/d²)` | OpenAL `InverseDistanceClamped`, ref 2 m, rolloff 1 ⇒ `2/max(d,2)` — inverse **first power**, 2 m reference | **behavioural, large** |
|
||||||
|
| D5 | Dead falloff helper | — | `AttenuationAt(d, minDistance = 1.0f)`: right shape, wrong reference (1 m vs 5 m), and never called | dead + wrong |
|
||||||
|
| D6 | Audible cutoff | hard drop when `ceil(20·log10 g) < −50 dB` ⇒ **≈94.2 m** at vol·master 1.0 (≈66.6 m at 0.5, ≈29.8 m at 0.1); the voice is never allocated | no distance cutoff; only `effectiveGain < 0.001f` (≈ −60 dB, distance-independent) | far sounds audible that retail silences; wasted voices |
|
||||||
|
| D7 | Gain quantisation | `ceil` to whole decibels, floor −50 dB | continuous float gain | subtle; retail stair-steps |
|
||||||
|
| D8 | Pan computation | CPU-side, angular: `pan_dB = (int)(−15·sin(Δheading))`, `Δheading` = normalise180(heading(src→listener) − listenerHeading); saturates at ±15 dB; **zero when `(int)distance < 5`**; no front/back, no elevation | OpenAL panner from full 3D vectors: front/back distinguished, elevation contributes, no 5 m deadzone, full stereo separation | **behavioural — our image is wider and 3D; retail's is a narrow 15 dB angular pan** |
|
||||||
|
| D9 | Dead pan helper | — | `PanFromRelative(relativeX, panRange = 20f)`: linear in relative X, invented 20 m constant, no retail counterpart, never called | dead + wrong |
|
||||||
|
| D10 | Pan disable switch | `s_SoundFeatures == 1` ⇒ pan forced 0 | none | missing pref |
|
||||||
|
| D11 | Volume knobs | 3 sliders (`"Sound Volume"`, `"Ambient Sound Volume"`, `"Interface Sound Volume"`) + 4 bools; **no master, no music volume**; exactly one multiply, inside `GetAttenuation` | `MasterVolume` (AL listener gain), `SfxVolume`, `MusicVolume = 0.7`, `AmbientVolume = 0.8` (last two unused) | different taxonomy; defaults 0.7/0.8 are invented |
|
||||||
|
| D12 | Retail quirk: squared master | `PlaySoundA(DataID, CPhysicsObj*)` passes `effect_sound_volume` as `vol`, and `GetAttenuation` multiplies by `effect_sound_volume` again ⇒ **effect volume squared**. Same for `PlayAmbientSound*`, which pre-multiply by `ambient_sound_volume` ⇒ **ambient volume squared** | single multiply | port decision needed — faithful = squared |
|
||||||
|
| D13 | Retail quirk: dead knob | `interface_sound_volume` is registered and never read; interface sounds are scaled by **`effect_sound_volume`** (`GetAttenuation` called with `ambient = 0`) | n/a | do not implement an interface-volume slider that works |
|
||||||
|
| D14 | Active-app gate | `s_bPlaySoundOnlyWhenActive` (default **1**) + `Device::m_bIsActiveApp` checked in every entry point and in both `PlaySoundInternal` overloads | none — acdream keeps playing when unfocused | missing pref/behaviour |
|
||||||
|
| D15 | Listener source | `SmartBox::viewer` = collided third-person camera Position (falls back to `player->m_position`), per rendered frame; only origin + `Frame::get_heading` are read | camera position **plus** real forward/up from `InverseView`, per frame | ours is richer than retail — and that richness is what creates D8's front/back cue. Not "fixed orientation" as suspected; it is live |
|
||||||
|
| D16 | Doppler / velocity | none (no listener or source velocity ever set) | none set either (AL defaults 0) | ✓ match |
|
||||||
|
| D17 | Per-frame reposition | none. Pan+volume frozen at emission; `SoundPlayingData.start_time` written, never read | source position also set once — but OpenAL re-evaluates distance/pan against the live listener every frame, so our voices **do** sweep as the listener moves | **behavioural: retail voices are frozen in the listener frame; ours are world-static and continuously re-panned** |
|
||||||
|
| D18 | Looping | never (`Play(0,0,0)`); sustained ambience = `Ambient` PQueue re-trigger on `Timer::cur_time + GetPlayInterval()`, drained in `Ambient::UseTime` per tick, gated by `CanHear`/`PlayNow` | `Looping = false` always ✓, but `SoundEntry.Loop` exists unused and `StartAmbient` only reserves a handle — **no ambient layer at all** | whole subsystem missing (not a math divergence) |
|
||||||
|
| D19 | Pitch | `SetFrequency` never called; `SoundData` has no pitch fields | `SoundEntry.PitchMin/PitchMax` invented; `pitch` plumbed but always 1.0 | ✓ matches in effect; model carries fictional fields |
|
||||||
|
| D20 | Variant selection | `idx = (int)(RollDice(0,1) · (n−1))` — last row unreachable (p≈1/32768); then `probability_` is an **independent gate** `rand()/32767 < prob` | `SoundCookbook.Roll` treats `Probability` as a **cumulative weight** with a silence tail | **behavioural — wrong distribution both ways** |
|
||||||
|
| D21 | Probability applied where | `SoundType` overloads + the 5-arg DID overload; **not** `PlaySoundA(DataID,obj)` nor `PlaySoundFromCenter(DataID,vol)` | uniform | minor |
|
||||||
|
| D22 | Buffer residency | refcounted `CreateSound`/`DestroySound`; wave decoded + copied to a DS buffer eagerly at register time; a play of an unregistered id is a silent no-op; each voice is a `DuplicateSoundBuffer` freed on slot reuse | lazy upload on first play, 48 MiB LRU; 16 persistent AL sources | legitimate modern adaptation; note only that our LRU can evict what retail pins, and we can hitch on first play |
|
||||||
|
| D23 | Device init | `DirectSoundCreate` + `SetCooperativeLevel(DSSCL_PRIORITY)`; primary buffer `DSBCAPS_PRIMARYBUFFER|CTRL3D`, format PCM 2ch/16-bit/11025 Hz; 3D listener rolloff 0.01, front (−1,0,0), top (0,1,0) — **all unused because every gameplay buffer is `m_3D = 0`** | OpenAL-Soft default device, 3D sources | fine; do not port the 3D listener |
|
||||||
|
|
||||||
|
### D4/D6 numbers side by side (vol = master = 1.0)
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| 200 m | — | **not played** | 0.0100 | −40.0 |
|
||||||
|
|
||||||
|
Retail is *louder near* and *silent far*; acdream is *quieter near* and *audible
|
||||||
|
everywhere*. This is the single largest audible divergence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Port-ready summary (what a faithful `RetailSoundMixer` needs)
|
||||||
|
|
||||||
|
```
|
||||||
|
Constants
|
||||||
|
VOL_MIN_DIST = 5.0f metres
|
||||||
|
VOL_MIN_DIST_SQ = 25.0f
|
||||||
|
VOL_MIN = -50 decibels
|
||||||
|
PAN_SCALE = -15.0 (applied to sin of the reversed bearing)
|
||||||
|
PAN_DEADZONE = 5 metres, compared against (int)distance
|
||||||
|
VOICES = 16
|
||||||
|
dB(g) = ceil(20 * log10(g)) // g in (0,1]
|
||||||
|
|
||||||
|
Per play (3D):
|
||||||
|
dist = |listener.origin - source.origin| // 3D, cross-landblock, metres
|
||||||
|
g = dist < 5 ? vol : 25*vol/(dist*dist)
|
||||||
|
g = min(g, 1)
|
||||||
|
g *= isAmbient ? ambientVolume : effectVolume // ONE multiply
|
||||||
|
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]
|
||||||
|
if (int)dist < 5: pan = 0
|
||||||
|
allocate voice: ring scan from cursor for free/finished;
|
||||||
|
else first slot with slotPriority < newPriority;
|
||||||
|
else DROP
|
||||||
|
gain_linear = 10^(db/20); pan_linear = ±(1 - 10^(-|pan|/20)) style 15 dB max separation
|
||||||
|
play once (no loop); never touch pan/gain again for this voice
|
||||||
|
```
|
||||||
|
|
||||||
|
For OpenAL specifically: set `AL_SOURCE_RELATIVE = true` and place the source at a
|
||||||
|
synthetic listener-relative point that reproduces the 15 dB pan (or use the stereo-panning
|
||||||
|
extension), with `AL_ROLLOFF_FACTOR = 0` so OpenAL's distance model is out of the loop and
|
||||||
|
the retail dB/pan pair is authoritative. Trying to bend `InverseDistanceClamped` into
|
||||||
|
`25/d²` is not possible — OpenAL's inverse model is first-power only; `AL_INVERSE_DISTANCE`
|
||||||
|
with rolloff cannot produce a squared curve, and `AL_EXPONENT_DISTANCE` with
|
||||||
|
`AL_ROLLOFF_FACTOR = 2` gives `(d/ref)^-2` which *does* match `25/d²` for `ref = 5` —
|
||||||
|
that is the one-line fix if we want to keep the gain in AL rather than on the CPU.
|
||||||
|
(`AL_EXPONENT_DISTANCE_CLAMPED`, `AL_REFERENCE_DISTANCE = 5`, `AL_ROLLOFF_FACTOR = 2`,
|
||||||
|
`AL_MAX_DISTANCE = 94.2` reproduces D4 and D6 together; the ±15 dB pan and the 5 m pan
|
||||||
|
deadzone still have to be CPU-side.)
|
||||||
|
|
||||||
|
## 5. Decomp hazards found (worth a memory note)
|
||||||
|
|
||||||
|
1. **BN elides x87 memory constants.** `GetAttenuation`'s pseudo-C prints
|
||||||
|
`((long double)0f) * arg2 / (arg1*arg1)` and `... * ((long double)0.0) * 6.0206`. The
|
||||||
|
binary has `fmul dword [VOL_MIN_DIST_SQ]` (25.0) and `fmul qword [INV_LOG_OF_2]`
|
||||||
|
(1/ln 2). Reading the pseudo-C alone yields *zero gain at all distances*.
|
||||||
|
2. **BN misattributes reused stack slots.** In `PlaySoundInternal(pos)` it reports the
|
||||||
|
`< 5.0` comparison as an angle test on `var_4`; the binary converts `[esp+4]` =
|
||||||
|
**distance**. Porting the pseudo-C gives a 5-degree pan deadzone instead of a 5-metre
|
||||||
|
one.
|
||||||
|
3. `SoundManager` has **no instance** (`struct SoundManager {}` in `acclient.h`) — every
|
||||||
|
field is a file-scope static. Do not look for a `this`.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue