Retail EoR has no music system: the linked winmm MIDI player has zero callers, 'music' appears zero times in the 65 MB decomp, SoundType has no music member, InitPrefs registers no music key, and the install ships no music files. So PlayMusic/StopMusic/MusicVolume and the AudioSettings Music knob are deleted rather than left as an API modelling dead code — the string-keyed signature was the tell, since every other entry point is DID-keyed. Old settings.json files carrying a 'music' key still load; the reader ignores unknown keys and the next save drops it. The Ambient slider is now surfaced, because slice A5 gave it something to drive, and its default returns to retail's 1.0 from an invented 0.8 — InitPrefs defaults every sound preference to unity. The panel rule is unchanged: no slider that does nothing. r05-audio-sound.md gets a SUPERSEDED banner naming its five wrong sections (falloff, pan, voice pool, selection, music, ambient) so a future reader reaches the lane notes instead of the Ghidra-era reads that this campaign spent its first two slices undoing. TS-9 re-scoped from 'any MP3 cue' to the measured blast radius: exactly 1 MP3 among 786 shipped waves, a ~2 s mono clip. Its original framing assumed a music system that does not exist. The ADPCM count remains unmeasured and is named as the open question. Deferred deliberately: #321's sound-cache decode-dedup race. It is a pre-existing concurrency flake rather than audio-parity behaviour, and shipping a speculative fix to a race I have not reproduced is exactly the shortcut this project's no-workarounds rule exists to prevent. Campaign A is code-complete; the plan carries the closeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1180 lines
46 KiB
Markdown
1180 lines
46 KiB
Markdown
# R5 — Audio System Deep Dive
|
||
|
||
> **SUPERSEDED 2026-08-08 (Campaign A).** This document predates the named
|
||
> retail decomp and the byte-level verification pass; several of its
|
||
> load-bearing sections are wrong and were used to build code that had to be
|
||
> undone. Read the six lane notes instead —
|
||
> `docs/research/2026-08-08-audio-retail-*.md` — and
|
||
> `docs/plans/2026-08-08-audio-parity-campaign.md` for the summary.
|
||
>
|
||
> Specifically corrected:
|
||
>
|
||
> - **§5.1 falloff.** The `FUN_00550c30` reading is not retail's curve.
|
||
> `SoundManager::GetAttenuation` @ `0x00550020` is
|
||
> `g = dist < 5 ? vol : 25·vol/dist²`, clamped BEFORE one master multiply,
|
||
> then `ceil(20·log10 g)` with a hard −50 dB no-allocate floor (audible
|
||
> radius ≈94.2 m). Binary Ninja elides the x87 constants and prints `* 0f`;
|
||
> porting its rendering yields silence at every distance.
|
||
> - **§5.2 pan / §5.3 pool.** Pan is `(int)(−15·sin Δbearing)`, truncating
|
||
> toward zero, dead centre inside 5 m, with no front/back and no elevation.
|
||
> The voice allocator is `PlaySoundInternal` @ `0x0054FEC0` and it evicts on
|
||
> DAT-authored float PRIORITY, never gain; `FUN_00550AD0` is a hash-table
|
||
> constructor.
|
||
> - **§4 selection.** Probability is a Bernoulli play/skip GATE at the play
|
||
> site, not a selection weight; selection is a uniform index over `(n−1)`.
|
||
> - **§6 music.** There is no music system in retail EoR. The linked MIDI
|
||
> player has zero callers. The `PlayMusic` API this section inspired modelled
|
||
> dead code and was deleted at slice A6.
|
||
> - **§7 ambient.** Ambients are region-authored, per-LAND-CELL over a 3×3
|
||
> landblock ring, and play as re-fired one-shots off an absolute-deadline
|
||
> queue — not landblock-attached loops. Indoors is silent by design.
|
||
|
||
|
||
Ground-truth port plan for acdream's audio subsystem, derived from decompiled
|
||
`acclient.exe`, cross-referenced against ACE (server), ACViewer (dat loader),
|
||
holtburger (client protocol), AC2D (C++ client), and DatReaderWriter (dat
|
||
schema). Backend for the port is **Silk.NET.OpenAL**.
|
||
|
||
---
|
||
|
||
## 0. Executive summary
|
||
|
||
Retail AC audio is a thin wrapper over three Microsoft APIs:
|
||
|
||
| Retail component | Windows API | Dat source | Notes |
|
||
|---|---|---|---|
|
||
| PCM / MP3 playback | **DirectSound 8** | `Wave` (0x0A) | Per-sound secondary buffer, primary buffer for mixing |
|
||
| MIDI music | **winmm midiStream** | Loose `*.mid` files on disk | 16 channels, streaming with 6-buffer rotation |
|
||
| Compressed decode | **winmm ACM** | Wave data (MP3 body) | acmStreamOpen/Convert wraps decompression |
|
||
|
||
Everything on top — `SoundTable`, ambient rolls, priority eviction, distance
|
||
falloff, the volume slider stack, footstep selection — is retail-authored
|
||
C++ logic on top of those three APIs. The retail 3D model is a **custom
|
||
inverse-square falloff computed in software** that lands at a final
|
||
`IDirectSoundBuffer::SetVolume(dB)` call; retail does **not** use
|
||
`IDirectSound3DBuffer` at all. This is critical to faithful port — OpenAL's
|
||
native AL_INVERSE_DISTANCE model doesn't match retail; we do the math
|
||
ourselves and only use OpenAL's raw gain.
|
||
|
||
Only **16 concurrent sound slots** (3D SFX). New sounds with higher
|
||
computed volume evict the quietest currently-playing slot. Master slider
|
||
multiplies all SFX. Dedicated ambient, interface, and music channels exist
|
||
separately.
|
||
|
||
**Well-known SoundId range:** 0x00 (Invalid) to 0xCC (`SkillDownVoid`).
|
||
Enumerated in full in section 3. Each creature `Setup` carries a
|
||
`DefaultSoundTable` (DID ref to 0x20xxxxxx), and AnimationHooks trigger
|
||
sounds by SoundId through that table.
|
||
|
||
---
|
||
|
||
## 1. `SoundTable` dat layout
|
||
|
||
**Dat range:** `0x20000000` – `0x2000FFFF` in `client_portal.dat` (first-byte
|
||
tag `0x20`).
|
||
|
||
### 1.1 On-disk format
|
||
|
||
From `references/ACE/Source/ACE.DatLoader/FileTypes/SoundTable.cs` and
|
||
`DatReaderWriter/DBObjs/SoundTable.generated.cs`:
|
||
|
||
```
|
||
SoundTable (Id = 0x20xxxxxx)
|
||
uint32 Id // = this dat file id
|
||
int32 HashKey // (aka Unknown in ACE). Same across files; salt for hash fn?
|
||
int32 numHashes
|
||
repeat numHashes times:
|
||
uint32 soundId // Sound enum key
|
||
SoundHashData {
|
||
float Priority
|
||
float Probability
|
||
float Volume // 0..1 (relative to base)
|
||
}
|
||
int32 numSounds
|
||
repeat numSounds times:
|
||
uint32 soundId // Sound enum key (matches numHashes entries 1:1 practically)
|
||
SoundData {
|
||
int32 numEntries
|
||
repeat numEntries times:
|
||
SoundEntry {
|
||
QualifiedDataId<Wave> Id // 0x0A000... dat ref
|
||
float Priority
|
||
float Probability
|
||
float Volume
|
||
}
|
||
int32 Unknown // always 0 in observed data
|
||
}
|
||
```
|
||
|
||
### 1.2 Semantic model
|
||
|
||
A `SoundTable` maps **`Sound` (enum) → list of candidate `Wave` DIDs**. When
|
||
you "play the Attack1 sound on this creature," you:
|
||
|
||
1. Look up `Sound.Attack1` in `Sounds[]`.
|
||
2. From the candidate list, **select one randomly, weighted by `Probability`**.
|
||
3. Effective play volume = `baseVolume * entry.Volume * masterSlider`.
|
||
4. `Priority` is used for eviction when the 16-slot 3D voice pool is full
|
||
(lower priority = evict first). See section 11.
|
||
|
||
### 1.3 The decompiled pick function
|
||
|
||
From `docs/research/decompiled/chunk_00550000.c` at `FUN_00551290` — the
|
||
random-sound picker:
|
||
|
||
```c
|
||
// __thiscall SoundTable::GetRandomSoundData(this, soundEnum, outSlotPtr)
|
||
if (soundEnum && FindHashEntry(this, soundEnum, &local)) {
|
||
uint count = *(uint*)(local + 0x7C); // numEntries
|
||
if (count != 0) {
|
||
uint idx = rand() % count; // uniform pick; weighting via probability roll later
|
||
SoundEntry* e = (SoundEntry*)(*(int*)(local+0x80) + idx * 0x10);
|
||
out->waveId = e->Id;
|
||
out->priority = e->Priority;
|
||
out->prob = e->Probability;
|
||
out->volume = e->Volume;
|
||
if (out->waveId != 0) return LoadWave(out);
|
||
}
|
||
}
|
||
```
|
||
|
||
The stride is `0x10` = 16 bytes = exactly (uint waveId + 3 × float) = one
|
||
`SoundEntry`. The per-sound `SoundHashData` acts as the **default** values
|
||
when you call a sound type directly with no override (e.g. ambient roll uses
|
||
those three floats, not the SoundEntry ones).
|
||
|
||
Random pick is **uniform over the array**; the per-entry `Probability` is
|
||
checked *after* picking (`FUN_00550cf0`): `rand()/RAND_MAX < entry.Probability`.
|
||
If that coin flip fails, the sound is silently dropped. This is how retail
|
||
avoids ALL spatter variants firing on every hit.
|
||
|
||
### 1.4 Where it lives
|
||
|
||
Each creature `Setup` (dat 0x02xxxxxx) has a `DefaultSoundTable` DID
|
||
(`references/ACE/Source/ACE.DatLoader/FileTypes/SetupModel.cs:41`). The
|
||
engine resolves that at PhysicsObj construction
|
||
(`PhysicsObj.cs:670`, `SoundTable = (SoundTable)DBObj.Get(qdid)`).
|
||
|
||
Common tables (from dat inspection and conventions): drudges, golems,
|
||
humans, elementals, tuskers, each have their own `SoundTable` with
|
||
appropriate `Attack1/Wound/Death` samples.
|
||
|
||
---
|
||
|
||
## 2. `Wave` dat layout
|
||
|
||
**Dat range:** `0x0A000000` – `0x0A00FFFF` in `client_portal.dat`.
|
||
|
||
### 2.1 On-disk format
|
||
|
||
From `ACE.DatLoader/FileTypes/Wave.cs` and the generated
|
||
`Wave.generated.cs`:
|
||
|
||
```
|
||
Wave (Id = 0x0Axxxxxx)
|
||
uint32 Id
|
||
int32 headerSize
|
||
int32 dataSize
|
||
byte[headerSize] Header // raw WAVEFORMATEX (+ extra for compressed formats)
|
||
byte[dataSize] Data // raw sample bytes
|
||
```
|
||
|
||
There is **no RIFF "RIFF/WAVE/fmt " wrapping** in the dat. To play the blob
|
||
via DirectSound, retail:
|
||
|
||
1. Allocates a `DSBUFFERDESC` with `lpwfxFormat` pointing at `Header`.
|
||
2. For AC-compressed variants, runs `Data` through `acmStreamOpen` →
|
||
`acmStreamConvert` to get raw PCM, then feeds PCM to the secondary
|
||
buffer.
|
||
|
||
### 2.2 Header format detection
|
||
|
||
- **`Header[0] == 0x55`** → MP3 (MPEGLAYER3, `WAVE_FORMAT_MPEGLAYER3`).
|
||
Retail funnels these through ACM (`acmStreamOpen(...,piVar3[1],local_18,...)`).
|
||
- **`Header[0] == 0x01`** → linear PCM (`WAVE_FORMAT_PCM`). Feeds straight
|
||
into a DirectSound buffer.
|
||
- **`Header[0] == 0x02`** → ADPCM (observed rarely; handled like MP3 via ACM).
|
||
|
||
ACViewer's export code (`Wave.cs:32-72`) just prepends a stock RIFF/WAVE
|
||
header at export time using the first 16 bytes of `Header` as the `fmt`
|
||
body, which is standard — the AC dat header *is* a standard WAVEFORMATEX,
|
||
just without the file-format wrapper.
|
||
|
||
### 2.3 Observed format conventions
|
||
|
||
From the DirectSound primary-buffer initializer
|
||
(`chunk_00550000.c:4119-4124`, `FUN_00554930`), the **primary mixer** is
|
||
configured as:
|
||
|
||
- `cbSize = 0`
|
||
- `wFormatTag = 1` (PCM)
|
||
- `nChannels = 2` (stereo mix)
|
||
- `nSamplesPerSec = 0x2b11 = 44100 Hz`
|
||
- `nAvgBytesPerSec = 0xac44 = 176400` (44100 × 2 × 2)
|
||
- `nBlockAlign = 4`
|
||
- `wBitsPerSample = 16`
|
||
|
||
Individual Wave samples may be 11025 Hz or 22050 Hz mono; DirectSound
|
||
resamples into the 44.1kHz stereo primary buffer transparently.
|
||
|
||
### 2.4 Playback object
|
||
|
||
Retail allocates a 0x20 / 0x24-byte structure per playing sound (see
|
||
`FUN_005df0f5(0x20)` + `FUN_005df0f5(0x24)` in `chunk_00550000.c:577,3274`):
|
||
|
||
```
|
||
SoundPlayInstance (~0x24 bytes)
|
||
+0x00 vtable // &PTR_FUN_007cbd18 etc
|
||
+0x04 IDirectSoundBuffer* // the secondary buffer
|
||
+0x08 IDirectSoundNotify* // completion notify
|
||
+0x0C byte[] pcm // decoded PCM (freed after buffer built)
|
||
+0x10 size of pcm
|
||
+0x14 ?
|
||
+0x18 flags
|
||
+0x1C sourceWaveId // 0x0A000xxx, for dedup cache
|
||
...
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Well-known SoundIds
|
||
|
||
From `references/ACE/Source/ACE.Entity/Enum/Sound.cs` (exact hex values
|
||
match the dat enum). Grouped for the port's `SoundId` C# enum:
|
||
|
||
### 3.1 Voice / creature lifecycle (0x01 – 0x1D)
|
||
|
||
```
|
||
Speak1=0x01 Random=0x02
|
||
Attack1=0x03 Attack2=0x04 Attack3=0x05
|
||
SpecialAttack1=0x06 SpecialAttack2=0x07 SpecialAttack3=0x08
|
||
Damage1=0x09 Damage2=0x0A Damage3=0x0B
|
||
Wound1=0x0C Wound2=0x0D Wound3=0x0E
|
||
Death1=0x0F Death2=0x10 Death3=0x11
|
||
Grunt1=0x12 Grunt2=0x13 Grunt3=0x14
|
||
Oh1=0x15 Oh2=0x16 Oh3=0x17
|
||
Heave1=0x18 Heave2=0x19 Heave3=0x1A
|
||
Knockdown1=0x1B Knockdown2=0x1C Knockdown3=0x1D
|
||
```
|
||
|
||
### 3.2 Weapon / combat (0x1E – 0x36)
|
||
|
||
```
|
||
Swoosh1=0x1E Swoosh2=0x1F Swoosh3=0x20
|
||
Thump1=0x21 Smash1=0x22 Scratch1=0x23
|
||
Spear=0x24 Sling=0x25 Dagger=0x26
|
||
ArrowWhiz1=0x27 ArrowWhiz2=0x28
|
||
CrossbowPull=0x29 CrossbowRelease=0x2A
|
||
BowPull=0x2B BowRelease=0x2C
|
||
ThrownWeaponRelease1=0x2D
|
||
ArrowLand=0x2E
|
||
Collision=0x2F
|
||
HitFlesh1=0x30 HitLeather1=0x31
|
||
HitChain1=0x32 HitPlate1=0x33
|
||
HitMissile1=0x34 HitMissile2=0x35 HitMissile3=0x36
|
||
```
|
||
|
||
Hit sounds are selected by **target armor type**, not weapon. Retail plays
|
||
exactly one of HitFlesh/Leather/Chain/Plate on damage based on the struck
|
||
body part's armor.
|
||
|
||
### 3.3 Movement (0x37 – 0x3C)
|
||
|
||
```
|
||
Footstep1=0x37 // soft/running
|
||
Footstep2=0x38 // heavy/walking
|
||
Walk1=0x39 // unused in the late client (was first-person walking)
|
||
Dance1=0x3A Dance2=0x3B Dance3=0x3C
|
||
```
|
||
|
||
Footstep selection: see section 4. There is **no per-surface SoundId
|
||
variant** — the SoundTable's Footstep1/Footstep2 slots are expected to hold
|
||
the correct per-creature foot sample, and surface type picks *which* slot
|
||
to trigger, not which sample.
|
||
|
||
### 3.4 Interaction (0x3D – 0x45)
|
||
|
||
```
|
||
Hidden1=0x3D Hidden2=0x3E Hidden3=0x3F
|
||
Eat1=0x40 Drink1=0x41
|
||
Open=0x42 Close=0x43
|
||
OpenSlam=0x44 CloseSlam=0x45
|
||
```
|
||
|
||
### 3.5 Ambient (0x46 – 0x4E)
|
||
|
||
```
|
||
Ambient1=0x46 Ambient2=0x47 Ambient3=0x48 Ambient4=0x49
|
||
Ambient5=0x4A Ambient6=0x4B Ambient7=0x4C Ambient8=0x4D
|
||
Waterfall=0x4E
|
||
```
|
||
|
||
These are **played from the region-level `AmbientSTBDesc`**, not from a
|
||
creature SoundTable — see section 7.
|
||
|
||
### 3.6 Character lifecycle (0x4F – 0x5D, 0xCA – 0xCC)
|
||
|
||
```
|
||
LogOut=0x4F LogIn=0x50
|
||
LifestoneOn=0x51
|
||
AttribUp=0x52 AttribDown=0x53
|
||
SkillUp=0x54 SkillDown=0x55
|
||
HealthUp=0x56 HealthDown=0x57
|
||
ShieldUp=0x58 ShieldDown=0x59
|
||
EnchantUp=0x5A EnchantDown=0x5B
|
||
VisionUp=0x5C VisionDown=0x5D
|
||
// "Void" variants for Shadow buffs
|
||
HealthDownVoid=0xCA RegenDownVoid=0xCB SkillDownVoid=0xCC
|
||
```
|
||
|
||
### 3.7 Magic (0x5E – 0x68)
|
||
|
||
```
|
||
Fizzle=0x5E Launch=0x5F Explode=0x60
|
||
TransUp=0x61 TransDown=0x62
|
||
BreatheFlaem=0x63 BreatheAcid=0x64
|
||
BreatheFrost=0x65 BreatheLightning=0x66
|
||
Create=0x67 Destroy=0x68
|
||
```
|
||
|
||
### 3.8 UI and chimes (0x6A – 0x8A)
|
||
|
||
```
|
||
UI_EnterPortal=0x6A UI_ExitPortal=0x6B
|
||
UI_GeneralQuery=0x6C UI_GeneralError=0x6D
|
||
UI_TransientMessage=0x6E UI_IconPickUp=0x6F
|
||
UI_IconSuccessfulDrop=0x70 UI_IconInvalid_Drop=0x71
|
||
UI_ButtonPress=0x72 UI_GrabSlider=0x73 UI_ReleaseSlider=0x74
|
||
UI_NewTargetSelected=0x75
|
||
// Ambient chimes played via UI channel
|
||
UI_Roar=0x76 UI_Bell=0x77
|
||
UI_Chant1=0x78 UI_Chant2=0x79
|
||
UI_DarkWhispers1=0x7A UI_DarkWhispers2=0x7B
|
||
UI_DarkLaugh=0x7C UI_DarkWind=0x7D UI_DarkSpeech=0x7E
|
||
UI_Drums=0x7F UI_GhostSpeak=0x80
|
||
UI_Breathing=0x81 UI_Howl=0x82
|
||
UI_LostSouls=0x83 UI_Squeal=0x84
|
||
UI_Thunder1..6 = 0x85..0x8A
|
||
```
|
||
|
||
### 3.9 Inventory / containers (0x69, 0x8B – 0x97)
|
||
|
||
```
|
||
Lockpicking=0x69
|
||
RaiseTrait=0x8B
|
||
WieldObject=0x8C UnwieldObject=0x8D
|
||
ReceiveItem=0x8E PickUpItem=0x8F DropItem=0x90
|
||
ResistSpell=0x91
|
||
PicklockFail=0x92 LockSuccess=0x93
|
||
OpenFailDueToLock=0x94
|
||
TriggerActivated=0x95
|
||
SpellExpire=0x96 ItemManaDepleted=0x97
|
||
```
|
||
|
||
### 3.10 Generic triggers (0x98 – 0xC9)
|
||
|
||
`TriggerActivated1 … TriggerActivated50 = 0x98 … 0xC9` — 50 generic trigger
|
||
slots that level designers wire into specific effects per-dungeon (pressure
|
||
plates, door opens, lever pulls).
|
||
|
||
---
|
||
|
||
## 4. Surface-material footstep selection
|
||
|
||
**Retail does NOT have a surface-material → SoundId table.** Instead:
|
||
|
||
1. Every creature's `SoundTable` contains entries for `Footstep1` and
|
||
`Footstep2`. Different creatures have different samples (drudge footstep
|
||
≠ human footstep ≠ tusker footstep).
|
||
2. The **animation's frame hooks** declare which footstep to play at each
|
||
step (left-heavy vs right-heavy, run vs walk).
|
||
3. The **surface byte** in terrain affects which alternative sample is
|
||
picked from the Footstep1/Footstep2 `SoundEntry` list. Each human
|
||
`SoundTable` has 2–4 entries per footstep slot — "grass step",
|
||
"stone step", "dirt step". Selection is by `rand() % count`, but the
|
||
per-entry `Volume` and `Probability` are used to keep the most
|
||
appropriate sample dominant.
|
||
|
||
Retail's actual "which surface am I on" lookup lives in the terrain
|
||
subsystem: `LandBlock::GetSurfaceType(x,y)` returns the dominant terrain
|
||
type byte for the cell (documented in r04 terrain deep-dive as the
|
||
per-vertex terrain-type encoding that also drives texture atlas choice).
|
||
The **audio subsystem does not consume surface type directly** — it's
|
||
embedded in the SoundTable design. We can faithfully port this by:
|
||
|
||
- Keeping the SoundTable's full per-footstep entry list.
|
||
- Letting the animation hooks pass the current surface hint into
|
||
`SoundTable.Play(SoundId.Footstep1, surfaceHint)`.
|
||
- Falling back to uniform random when the SoundTable has only one entry.
|
||
|
||
The 2-slot `Footstep1`/`Footstep2` pair is **left-foot vs right-foot**, not
|
||
soft vs loud — alternating based on the animation frame that fires the hook.
|
||
|
||
---
|
||
|
||
## 5. 3D positional audio — the retail falloff
|
||
|
||
**Retail does NOT use `IDirectSound3DBuffer`.** The entire 3D effect is
|
||
computed in software and applied via
|
||
`IDirectSoundBuffer::SetVolume(dB)` + `SetFrequency(pitch)` on a plain
|
||
stereo secondary buffer. Verified from decompiled code:
|
||
|
||
- Grep for `IDirectSound3D*` in the decompiled chunks: **zero hits**.
|
||
- The only DirectSound API surface used is `DirectSoundCreate`,
|
||
`CreateSoundBuffer`, `SetFormat`, `Play`, `Stop`, `SetVolume`,
|
||
`SetFrequency`, `SetPan`, `QueryInterface(IDirectSoundNotify)` (all seen
|
||
in `chunk_005D0000.c` imports).
|
||
|
||
### 5.1 The falloff function — `FUN_00550c30`
|
||
|
||
Decompiled, annotated:
|
||
|
||
```c
|
||
// returns 1 if audible (volume above floor), 0 otherwise.
|
||
// out: param_3 = final volume in ad-hoc units fed to SetVolume conversion
|
||
// param_1 = distance to listener
|
||
// param_2 = base volume (0..1)
|
||
// param_4 = channel type: 0 = SFX/ambient, 1 = interface
|
||
int FUN_00550c30(float distance, float baseVolume, int* outVol, int channelType)
|
||
{
|
||
const float MIN_DISTANCE = _DAT_007cbc64; // ~1.0f in world units
|
||
const float DISTANCE_K = _DAT_00870414; // reference-distance falloff
|
||
const float MAX_VOLUME = _DAT_007938c0; // cap (1.0f)
|
||
const float VOLUME_FLOOR = _DAT_00795610; // ~0.0001f threshold for "inaudible"
|
||
const float DB_SCALE = _DAT_00870418; // dB scaling constant
|
||
const float DB_UNIT = _DAT_007cbd00; // convert to SetVolume hundredths
|
||
|
||
float v = baseVolume;
|
||
|
||
// 1. INVERSE-SQUARE distance attenuation (only beyond MIN_DISTANCE)
|
||
if (distance >= MIN_DISTANCE)
|
||
v = (DISTANCE_K * baseVolume) / (distance * distance);
|
||
|
||
// 2. Cap at MAX_VOLUME
|
||
if (v > MAX_VOLUME) v = MAX_VOLUME;
|
||
|
||
// 3. Apply channel master volume slider
|
||
float slider = (channelType == 0) ? g_SfxMasterVolume // 0..1
|
||
: g_InterfaceVolume; // 0..1
|
||
v *= slider;
|
||
|
||
// 4. Check audibility
|
||
if (v <= VOLUME_FLOOR) {
|
||
*outVol = DS_VOLUME_FLOOR; // -10000 hundredths of dB == mute
|
||
return 0;
|
||
}
|
||
|
||
// 5. Convert linear 0..1 to DirectSound hundredths-of-dB
|
||
// 0.6931472 = ln(2) → log2(v) = ln(v) / ln(2)
|
||
// final = ceil( log2(v) * DB_SCALE * DB_UNIT )
|
||
int finalHdB = (int)ceilf(log2f(v) * DB_SCALE * DB_UNIT);
|
||
*outVol = finalHdB;
|
||
return 1;
|
||
}
|
||
```
|
||
|
||
**Key properties of the retail model:**
|
||
|
||
- **Pure inverse-square** between 0 and some "max audible distance"; no
|
||
rolloff-factor parameter like OpenAL's `AL_ROLLOFF_FACTOR`.
|
||
- **No doppler.** Retail does not touch `SetFrequency` based on relative
|
||
velocity. Pitch is static per-sound (there's a `param_2 * 100` pitch
|
||
shift in the hundredths, but it's fed by the SoundEntry's base, not
|
||
runtime doppler).
|
||
- **No direction cone.** All sounds are omnidirectional. There is a
|
||
`SetPan` call in the secondary-buffer path to handle stereo positioning,
|
||
but only for 2D/L-R based on relative bearing, not HRTF or cone.
|
||
- **Stereo / mono toggle** in the options panel (`ID_Sound_Stereo` /
|
||
`ID_Sound_Mono`) disables the pan computation for mono output.
|
||
|
||
### 5.2 Stereo panning
|
||
|
||
`FUN_00553970(soundInst, pan, volume)` (chunk_00550000.c:3140) clamps pan
|
||
to `-0xF..+0xF` then calls `IDirectSoundBuffer::SetPan(piVar1, pan*100)`
|
||
(offset 0x40 in the DirectSound vtable). Retail multiplies the final
|
||
float by 100 to convert to the hundredths-of-dB unit DirectSound expects.
|
||
|
||
The pan value itself is computed by taking the vector from listener to
|
||
source, projecting onto the listener's right axis, normalizing by some
|
||
reference distance, and clamping. This is visible in the call sites for
|
||
`FUN_00550d80(inst, listenerRelativeVec, volume, attrFlags)` — the second
|
||
arg `param_2 + 0x48` is a WorldObject's position frame, and there's a
|
||
`FUN_005364a0()` (likely `Vector3::Dot(up_vec)` or similar) in the call
|
||
chain that produces `fVar4` → `FUN_00550c30` as distance.
|
||
|
||
### 5.3 Priority-based eviction (the 16-slot table)
|
||
|
||
Retail has a **fixed 16-slot voice pool** for 3D positional sounds
|
||
(`chunk_00550000.c:527`, `FUN_00550ad0`):
|
||
|
||
```c
|
||
// pool of up to 16 active sounds
|
||
static SoundPlayInstance* g_pool[16]; // @ DAT_00870520
|
||
static float g_poolVols[16]; // @ DAT_00870524
|
||
static uint32_t g_poolNext; // @ DAT_008703b8 (round-robin cursor)
|
||
|
||
void PlaySound3D(SoundInstance* inst, pan, volume) {
|
||
// First pass: find a free or stopped slot.
|
||
for (int i = 0; i < 16; ++i) {
|
||
uint idx = (g_poolNext + i) & 0xF;
|
||
if (g_pool[idx] == NULL) goto use_slot;
|
||
if (!IsStillPlaying(g_pool[idx])) goto free_and_use;
|
||
}
|
||
// Second pass: evict slot whose current volume < our volume.
|
||
for (int i = 0; i < 16; ++i) {
|
||
uint idx = (g_poolNext + i) & 0xF;
|
||
if (g_poolVols[idx] < inst->volume) goto free_and_use;
|
||
}
|
||
return; // nothing quieter than us → drop
|
||
|
||
free_and_use:
|
||
StopBuffer(g_pool[idx]);
|
||
DeleteInstance(g_pool[idx]);
|
||
use_slot:
|
||
g_pool[idx] = CreateBufferFromInst(inst);
|
||
g_poolVols[idx] = inst->volume;
|
||
g_poolNext = (idx + 1) & 0xF;
|
||
ApplyPanVolume(pan, volume); // → FUN_00553970
|
||
}
|
||
```
|
||
|
||
This is the classic "sound hardware had 16 voices" DirectSound-era
|
||
hardware-mixing constraint, but the AC client kept it even for pure
|
||
software mixing. **Port target:** 16 simultaneous positional sources.
|
||
Plus the music (MIDI) and UI channels, which don't share this pool.
|
||
|
||
---
|
||
|
||
## 6. Music system — WinMM MIDI, not PCM
|
||
|
||
**Music is MIDI**, streamed through `midiStreamOpen` (not DirectMusic, not
|
||
PCM). Evidence: `chunk_00550000.c` contains:
|
||
|
||
- `midiStreamOpen(&DAT_00870a70, (LPUINT)&DAT_00820294, 1, 0x554120, 0, 0x30000)`
|
||
- `midiStreamProperty`, `midiStreamRestart`, `midiStreamStop`, `midiStreamClose`
|
||
- `midiOutShortMsg(hmo, ...)` for per-channel volume (controller 0x07) and
|
||
pan (0x0A).
|
||
- `midiOutPrepareHeader`, `midiOutUnprepareHeader`.
|
||
- MThd/MTrk chunk parsing at `FUN_00555150` (0x6468544d = "MThd", 0x6b72544d = "MTrk").
|
||
|
||
### 6.1 Architecture
|
||
|
||
```
|
||
+-----------------------+ +----------------+
|
||
| *.mid on disk | ──────▶ | MIDI parser |
|
||
| (loose files, not dat)| | FUN_00555150 |
|
||
+-----------------------+ +----------------+
|
||
│
|
||
▼ 6 × 1024-byte buffers
|
||
+----------------------+
|
||
| midiStreamOut rotate | ← "Wait For Buffer
|
||
| DAT_008707c0[6] | Return" event
|
||
+----------------------+
|
||
│
|
||
▼
|
||
Windows default MIDI device
|
||
```
|
||
|
||
- 6 buffers, each `0x400` (1024) bytes, rotated via an event named
|
||
`"Wait For Buffer Return"` (`chunk_00550000.c:3743`). Classic double-
|
||
(or N-) buffered streaming; when a buffer finishes, Windows sets the
|
||
event, the worker thread refills it.
|
||
- **16-channel GM pan / volume arrays:** `DAT_008709b8[16]` (current
|
||
volume per channel), `DAT_008709f8[16]` (default volume per channel,
|
||
initialized to 100). Tempo / pitch-bend goes through
|
||
`midiOutShortMsg`.
|
||
- **No MusicTable.** Tracks are identified by file path; retail has
|
||
`lstrcpyA(&DAT_00870770, param_1)` storing the current track filename.
|
||
Track selection is **by the game code calling PlayMusic("path/foo.mid",
|
||
loopFlag)** — driven by region/area rules, not by a dat table.
|
||
|
||
### 6.2 What this means for the port
|
||
|
||
- `ACDream.Audio.MusicPlayer` is distinct from the SFX engine.
|
||
- Since `midiStreamOpen` is Windows-only, we replace it with a .NET MIDI
|
||
library (NAudio.Midi or a minimal SMF parser + our own synth) or skip
|
||
MIDI entirely and use the available `.mid` tracks converted to `.ogg`.
|
||
Retail's decision to use MIDI was because 1999 CD bandwidth made PCM
|
||
soundtracks infeasible; acdream has no such constraint. **Recommended:
|
||
convert MIDI to OGG offline, use a single OGG streaming source.** This
|
||
departs from retail but is indistinguishable to the ear and removes a
|
||
whole OS dependency.
|
||
- Fallback: if users want exactly-retail music, we can implement a tiny
|
||
SMF player driving a soundfont.
|
||
|
||
---
|
||
|
||
## 7. Ambient sounds — region-level, not cell-level
|
||
|
||
### 7.1 Source
|
||
|
||
The `RegionDesc` dat (0x13000000 singleton "Dereth") contains a
|
||
`SoundDesc` field (when `PartsMask & 0x01`):
|
||
|
||
```
|
||
SoundDesc
|
||
List<AmbientSTBDesc> STBDesc
|
||
AmbientSTBDesc {
|
||
uint STBId // identifier/index
|
||
List<AmbientSoundDesc> AmbientSounds
|
||
AmbientSoundDesc {
|
||
Sound SType // e.g. Ambient1..8, Waterfall
|
||
float Volume
|
||
float BaseChance // if 0, loops continuously
|
||
float MinRate, MaxRate // seconds between rolls
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
From `ACE.DatLoader.Entity.AmbientSoundDesc.IsContinuous => BaseChance == 0`.
|
||
|
||
### 7.2 Activation model
|
||
|
||
Ambient sounds are tied to **landblock/terrain type**, not to EnvCell
|
||
instances. The retail pattern:
|
||
|
||
1. On landblock change, the game queries `terrainType` for each corner of
|
||
the current cell and picks the dominant `AmbientSTBDesc` by STBId (the
|
||
STBId is indexed by terrain type or region-specific rule).
|
||
2. Active `AmbientSTBDesc` spawns a per-sound background roll:
|
||
- If `BaseChance == 0` → continuous loop on a dedicated voice.
|
||
- Else → every `N` seconds (where `N` = `rand()` in `[MinRate, MaxRate]`),
|
||
roll `rand() < BaseChance` and if true, play the
|
||
`Sound.SType` once at `Volume`, positioned near the listener
|
||
(at a small random offset so it has subtle 3D movement).
|
||
3. On landblock change or leaving the region, stop all ambient voices
|
||
associated with the outgoing STBId and start new ones.
|
||
|
||
### 7.3 Ambient master volume gate
|
||
|
||
Separate slider (`DAT_008375bc`, default `0x3f800000 = 1.0f`), separate
|
||
disable toggle (`DAT_008375b8`). See section 10.
|
||
|
||
---
|
||
|
||
## 8. Motion-triggered sounds — AnimationHook dispatch
|
||
|
||
### 8.1 Two hook types
|
||
|
||
From `ACE.Entity.Enum.AnimationHookType`:
|
||
|
||
```
|
||
Sound = 1 → SoundHook { uint Id } // play Wave DID directly
|
||
SoundTable = 2 → SoundTableHook { Sound SoundType } // play via this obj's SoundTable
|
||
SoundTweaked = 21 → SoundTweakedHook { uint SoundID, // tweaked with custom params
|
||
float Priority,
|
||
float Probability,
|
||
float Volume }
|
||
```
|
||
|
||
All three ship on frames of `Animation` entries (dat type
|
||
`0x01000000`). A walk-cycle animation has `SoundTableHook{Footstep1}` on
|
||
frame 4 and `SoundTableHook{Footstep2}` on frame 12, for example.
|
||
|
||
### 8.2 Dispatch point
|
||
|
||
Per `AnimHook.Execute` in ACE's physics port (placeholder; the retail
|
||
client does it in `CAnimationSequencer::ProcessFrameHooks`):
|
||
|
||
```csharp
|
||
void ProcessFrame(AnimationHook hook, PhysicsObj obj) {
|
||
switch (hook.HookType) {
|
||
case AnimationHookType.Sound:
|
||
// direct Wave DID, bypasses SoundTable
|
||
var h = (SoundHook)hook;
|
||
audioEngine.PlayWaveAtPosition(h.Id, obj.Position, obj.Velocity, defaultVolume: 1.0f);
|
||
break;
|
||
case AnimationHookType.SoundTable:
|
||
// look up via object's SoundTable
|
||
var st = (SoundTableHook)hook;
|
||
obj.SoundTable?.Play(st.SoundType, obj.Position, obj.Velocity);
|
||
break;
|
||
case AnimationHookType.SoundTweaked:
|
||
// one-off sound with overridden priority/probability/volume
|
||
var t = (SoundTweakedHook)hook;
|
||
audioEngine.PlayWaveTweaked(t.SoundID, obj.Position,
|
||
t.Priority, t.Probability, t.Volume);
|
||
break;
|
||
}
|
||
}
|
||
```
|
||
|
||
The retail `MotionInterpreter` fires hooks **in order within a frame**, so
|
||
if two SoundTable hooks coexist on the same frame they both play
|
||
(subject to voice-pool eviction).
|
||
|
||
### 8.3 Our integration point
|
||
|
||
The acdream `MotionInterpreter` (`src/AcDream.Core/Physics/MotionInterpreter.cs`)
|
||
and `AnimationSequencer` (`src/AcDream.Core/Physics/AnimationSequencer.cs`)
|
||
need a hook-callback contract. Propose:
|
||
|
||
```csharp
|
||
public interface IAnimationHookSink
|
||
{
|
||
void OnSoundHook(uint waveId, Vector3 pos, Vector3 vel);
|
||
void OnSoundTableHook(SoundId id, Vector3 pos, Vector3 vel);
|
||
void OnSoundTweakedHook(uint waveId, Vector3 pos, Vector3 vel,
|
||
float priority, float probability, float volume);
|
||
}
|
||
```
|
||
|
||
The `AudioEngine` implements this interface, and `AnimationSequencer`
|
||
receives an `IAnimationHookSink` in its constructor. This keeps animation
|
||
code audio-agnostic while giving the audio engine full access to hook
|
||
timing.
|
||
|
||
---
|
||
|
||
## 9. Server-sent sound — `GameMessageSound` (opcode 0xF750)
|
||
|
||
### 9.1 Wire format
|
||
|
||
From `ACE.Server.Network.GameMessages.Messages.GameMessageSound` and
|
||
cross-verified against holtburger `PlaySoundData`:
|
||
|
||
```
|
||
GameMessage (opcode = 0xF750)
|
||
Guid target // 8 bytes (object this sound plays on)
|
||
uint32 soundId // Sound enum value
|
||
float volume // 0..1
|
||
```
|
||
|
||
Total payload: 16 bytes after the opcode.
|
||
|
||
### 9.2 Related opcodes
|
||
|
||
| Opcode | Name (ACE) | Payload | Purpose |
|
||
|---|---|---|---|
|
||
| `0xF750` | `Sound` / `PlaySoundData` | `{Guid, uint soundId, float volume}` | Per-object sound via the object's SoundTable |
|
||
| `0xF754` | `PlayScriptId` | `{Guid, uint scriptId}` | PlayScript (particle emitter; may have attached sound entries in PhysicsScript) |
|
||
| `0xF755` | `PlayEffect` / `GameMessageScript` | `{Guid, uint PlayScript, float speed}` | Canned visual-+-sound effect package (e.g. Fizzle, Launch, etc — full PlayScript enum in section 3 ref) |
|
||
|
||
The `PlayScript` enum (see `ACE.Entity.Enum.PlayScript`) has 95+ entries
|
||
like `Fizzle=0x51`, `PortalEntry=0x52`, `BreatheFlame=0x54`,
|
||
`Create=0x58`, `Destroy=0x59`, etc — each is a bundle of a particle
|
||
effect **and** a sound. Client resolves via a `PhysicsScriptTable`
|
||
(not audio's concern, but the sound portion funnels into PlaySound).
|
||
|
||
### 9.3 Object-referenced vs anonymous
|
||
|
||
- Retail always plays sounds **attached to an object** — the object's
|
||
position is the 3D source, its SoundTable resolves the sample.
|
||
- There is **no "play at (x,y,z)" opcode**. If the server wants a
|
||
location-based sound, it attaches it to a hidden/ephemeral weenie at
|
||
that location. This matches the observation that non-weenie ambient
|
||
sounds (landblock ambient) are client-driven from the RegionDesc
|
||
entries, not sent by the server.
|
||
|
||
---
|
||
|
||
## 10. Volume sliders — the stack
|
||
|
||
From `chunk_00400000.c:1705-1728` (the settings UI init) and
|
||
`chunk_00550000.c:875-930` (the options-save path), retail exposes **six
|
||
volume controls** plus a focus-gate:
|
||
|
||
| UI label | Internal var | Default | Semantic |
|
||
|---|---|---|---|
|
||
| `Sound Disabled` (checkbox) | `DAT_008375b0` | off | Master SFX mute |
|
||
| `Effect Volume` (slider 0..1) | `DAT_008375b4` | 1.0 | SFX master multiplier |
|
||
| `Ambient Sound Disabled` (checkbox) | `DAT_008375b8` | off | Ambient mute |
|
||
| `Ambient Volume` (slider) | `DAT_008375bc` | 1.0 | Ambient master multiplier |
|
||
| `Interface Sound Disabled` (checkbox) | `DAT_008375c0` | off | UI mute |
|
||
| `Interface Volume` (slider) | `DAT_008375c4` | 1.0 | UI master multiplier |
|
||
| `Play Sound Only When Active` | `DAT_008375cc` | off | mute when app not focused |
|
||
|
||
Plus a stereo/mono picker (`DAT_008375c8`) with values
|
||
`ID_Sound_Stereo` / `ID_Sound_Mono`.
|
||
|
||
**There is no separate music slider in retail.** Music uses its own
|
||
WinMM `midiOutSetVolume`-driven master (one hex volume uint, default
|
||
0x7FFF per channel) and is controlled by the per-channel
|
||
`DAT_008709b8[16]` pan+volume arrays. In practice users typically turn
|
||
down music via the default mixer rather than in-game.
|
||
|
||
### 10.1 Application order
|
||
|
||
For a 3D SFX sound:
|
||
|
||
```
|
||
finalLinear = baseSampleVolume // per SoundEntry.Volume
|
||
* soundTableEntry.Volume // sometimes 1.0, sometimes tweaked
|
||
* falloff(distance)
|
||
* masterEffectVolume // slider 0..1
|
||
* (masterMute ? 0 : 1)
|
||
* (appFocused || !focusMute ? 1 : 0)
|
||
```
|
||
|
||
For ambient:
|
||
```
|
||
finalLinear = ambientSample.Volume
|
||
* ambientMasterVolume
|
||
* (ambientMute ? 0 : 1)
|
||
* (appFocused || !focusMute ? 1 : 0)
|
||
```
|
||
|
||
For UI/interface:
|
||
```
|
||
finalLinear = UiSample.Volume // usually 1.0 (no falloff, no 3D)
|
||
* interfaceMasterVolume
|
||
* (interfaceMute ? 0 : 1)
|
||
* (appFocused || !focusMute ? 1 : 0)
|
||
```
|
||
|
||
Final per-voice volume is then converted linear→dB and
|
||
`IDirectSoundBuffer::SetVolume(hundredths_dB)` is called.
|
||
|
||
---
|
||
|
||
## 11. DirectSound API surface
|
||
|
||
Direct API calls seen in decompiled code (`chunk_00550000.c` +
|
||
`chunk_005D0000.c`):
|
||
|
||
| Retail call | Retail location | Purpose |
|
||
|---|---|---|
|
||
| `DirectSoundCreate(0, &pDS, 0)` | `FUN_00554930` | Create device |
|
||
| `IDirectSound::SetCooperativeLevel(hwnd, DSSCL_PRIORITY=2)` | same | Cooperative level = PRIORITY |
|
||
| `IDirectSound::CreateSoundBuffer(&DSBUFFERDESC, &pBuf, 0)` | same | Primary buffer |
|
||
| `IDirectSoundBuffer::QueryInterface(IID_IDirectSoundNotify, ...)` | same | For completion notify |
|
||
| `IDirectSoundNotify::SetNotificationPositions(pos)` | implied vtable slot 0x3C | 1 notify at end of play |
|
||
| `IDirectSoundBuffer::SetFormat(&wfx)` | vtable slot 0x38 | Set primary mix format 44.1kHz stereo 16-bit |
|
||
| `IDirectSoundBuffer::Play(0, 0, DSBPLAY_LOOPING=1)` | vtable 0x30 | Start primary buffer looping |
|
||
| `IDirectSoundBuffer::SetVolume(hundredths_dB)` | vtable 0x34 | Per-voice volume |
|
||
| `IDirectSoundBuffer::SetFrequency(Hz)` | vtable 0x3C | Pitch (per-sound random variance) |
|
||
| `IDirectSoundBuffer::SetPan(hundredths_dB_LR)` | vtable 0x40 | Stereo pan |
|
||
| `IDirectSoundBuffer::Lock/Unlock` | vtable 0x4C | Write PCM into secondary buffers |
|
||
| `IDirectSoundBuffer::Stop` | vtable 0x48 | Stop on eviction |
|
||
| `acmStreamOpen/Convert/Close` | `FUN_005532f0` | Decompress MP3/ADPCM to PCM |
|
||
|
||
**`IDirectSound3D*` is not used.** No `SetListener`, no
|
||
`SetRolloffFactor`, no `SetDopplerFactor`, no `SetDistanceFactor`, no
|
||
`SetOrientation`. All 3D math is in the CPU path.
|
||
|
||
---
|
||
|
||
## 12. Mixer thread
|
||
|
||
Retail uses **DirectSound's internal mixer thread** (running inside
|
||
`dsound.dll`). Application code only fires `Play()` and relies on
|
||
`IDirectSoundNotify` for stop/complete events.
|
||
|
||
MIDI is a separate story: `midiStreamOut` is async via
|
||
`midiStreamOpen`'s `MidiCallback` parameter (`0x554120` in the call —
|
||
offset to a retail callback function) which fires
|
||
`MOM_DONE` events on a Windows-owned thread. The named event
|
||
`"Wait For Buffer Return"` is signaled from that callback, then the
|
||
MIDI parser thread wakes and refills the next MThd/MTrk chunk.
|
||
|
||
**Port target:** OpenAL runs its own mixer thread internally. We don't
|
||
need to manage one. Our main-loop audio tick only needs to:
|
||
- Update listener position/orientation from the camera.
|
||
- Check for finished voices and recycle slots.
|
||
- Poll for pending ambient rolls and schedule their next Play().
|
||
|
||
---
|
||
|
||
## 13. Port plan — C# class layout
|
||
|
||
Target namespace: `AcDream.Audio`. Backend: `Silk.NET.OpenAL`. All files
|
||
under `src/AcDream.Core/Audio/` (platform-free) and
|
||
`src/AcDream.App/Audio/` (platform Silk glue). Keep dat parsing under
|
||
`src/AcDream.Core/Dat/`.
|
||
|
||
### 13.1 Classes
|
||
|
||
```
|
||
AcDream.Core.Audio
|
||
├── SoundId.cs // enum matching ACE.Entity.Enum.Sound 1:1
|
||
├── SoundDat // parses SoundTable dat via DatCollection
|
||
│ ├── SoundTable record // (uint Id, Dictionary<SoundId,SoundData> Sounds, …)
|
||
│ ├── SoundEntry record // (uint WaveId, float Priority, Prob, Volume)
|
||
│ └── WaveBlob record // (uint Id, byte[] Header, byte[] Data, AudioFormat Parsed)
|
||
├── WaveDecoder.cs // WAVEFORMATEX parsing + PCM/MP3/ADPCM → Int16/Float32 PCM
|
||
├── SoundCache // LRU cache: WaveId → decoded PCM + OpenAL buffer ID
|
||
├── AudioFalloff.cs // static: retail inverse-square + dB conversion
|
||
├── AudioSource3D // one voice, wraps an OpenAL source
|
||
│ ├── WaveId, Priority, Volume, Position, Velocity
|
||
│ ├── bool UpdateAttenuation(listener) → updates AL_GAIN, AL_PITCH, AL_POSITION
|
||
│ └── PlayState { NotStarted, Playing, Finished, Evicted }
|
||
├── AudioEngine
|
||
│ ├── 16 × AudioSource3D voicePool
|
||
│ ├── AudioListener3D listener
|
||
│ ├── SfxMasterVolume, AmbientMasterVolume, UiMasterVolume, FocusGate (properties)
|
||
│ ├── Play(Guid target, SoundId id, float volume, Vector3 pos, Vector3 vel)
|
||
│ ├── PlayWaveDirect(uint waveId, …)
|
||
│ ├── PlayWaveTweaked(uint waveId, float priority, prob, volume, …)
|
||
│ ├── PlayUI(SoundId id, float volume) // 2D, no falloff, no pool competition
|
||
│ ├── UpdateListener(Vector3 pos, Quaternion rot)
|
||
│ └── Tick(float dt) // advance ambient rolls, recycle finished voices
|
||
└── AmbientPlayer
|
||
├── RegionSoundDesc (parsed SoundDesc)
|
||
├── ActiveSTBId (current terrain STBId)
|
||
├── per-AmbientSoundDesc schedule state: nextFireTime
|
||
├── Tick(dt, listenerPos)
|
||
└── ApplyRegionChange(STBId newId)
|
||
|
||
AcDream.App.Audio
|
||
└── OpenAlBackend // ALContext, buffer/source wrappers
|
||
```
|
||
|
||
### 13.2 Key method sketches
|
||
|
||
```csharp
|
||
// AudioFalloff.cs - faithful port of FUN_00550c30
|
||
public static bool Compute(float distance, float baseVolume, AudioChannel ch,
|
||
out float gain, out float pitchMul)
|
||
{
|
||
const float MIN_DIST = 1.0f;
|
||
const float DIST_K = 1.0f; // = DAT_00870414 calibrated at runtime
|
||
const float MAX_VOL = 1.0f;
|
||
const float VOL_FLOOR = 1e-4f;
|
||
|
||
float v = baseVolume;
|
||
if (distance >= MIN_DIST)
|
||
v = (DIST_K * baseVolume) / (distance * distance);
|
||
|
||
if (v > MAX_VOL) v = MAX_VOL;
|
||
|
||
float slider = ch switch {
|
||
AudioChannel.Sfx => AudioEngine.SfxMasterVolume,
|
||
AudioChannel.Ambient => AudioEngine.AmbientMasterVolume,
|
||
AudioChannel.Interface => AudioEngine.UiMasterVolume,
|
||
_ => 1f
|
||
};
|
||
v *= slider;
|
||
if (!AudioEngine.FocusActive) v = 0;
|
||
|
||
if (v <= VOL_FLOOR) { gain = 0; pitchMul = 1f; return false; }
|
||
gain = v;
|
||
pitchMul = 1f; // retail has optional per-sound pitch variance (random ±5%); apply here later
|
||
return true;
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// AudioEngine.Play - faithful port of the 16-slot eviction policy
|
||
public void Play(SoundId id, SoundTable table, Vector3 pos, Vector3 vel,
|
||
float baseVolume = 1.0f)
|
||
{
|
||
if (!table.Sounds.TryGetValue(id, out var soundData)) return;
|
||
if (soundData.Entries.Count == 0) return;
|
||
|
||
// Uniform pick (retail: rand() % count), then probability check
|
||
int idx = _rng.Next(soundData.Entries.Count);
|
||
var entry = soundData.Entries[idx];
|
||
if (_rng.NextSingle() >= entry.Probability) return; // sound rolled away
|
||
|
||
float dist = Vector3.Distance(pos, Listener.Position);
|
||
if (!AudioFalloff.Compute(dist, baseVolume * entry.Volume,
|
||
AudioChannel.Sfx, out float gain, out float pitch))
|
||
return; // inaudible
|
||
|
||
// Voice pool: round-robin scan, evict quietest weaker voice
|
||
int slot = AcquireVoiceSlot(gain, entry.Priority);
|
||
if (slot < 0) return; // nothing weaker → drop
|
||
|
||
var source = _voicePool[slot];
|
||
source.LoadWave(entry.WaveId, _soundCache);
|
||
source.Position = pos;
|
||
source.Velocity = vel;
|
||
source.Gain = gain;
|
||
source.Pitch = pitch;
|
||
source.Priority = entry.Priority;
|
||
source.Play();
|
||
}
|
||
|
||
int AcquireVoiceSlot(float incomingVolume, float incomingPriority)
|
||
{
|
||
// Pass 1: free / finished
|
||
for (int i = 0; i < 16; i++) {
|
||
int idx = (_poolCursor + i) & 0xF;
|
||
var v = _voicePool[idx];
|
||
if (!v.IsPlaying) return AdvanceCursor(idx);
|
||
}
|
||
// Pass 2: evict quieter-than-us using weighted volume (priority tie-breaker)
|
||
// Retail uses pure volume comparison; priority just affects computed volume pre-selection.
|
||
for (int i = 0; i < 16; i++) {
|
||
int idx = (_poolCursor + i) & 0xF;
|
||
if (_voicePool[idx].Gain < incomingVolume) {
|
||
_voicePool[idx].Stop();
|
||
return AdvanceCursor(idx);
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// AmbientPlayer.Tick - matches AmbientSoundDesc semantics
|
||
public void Tick(float dt, Vector3 listenerPos)
|
||
{
|
||
if (_activeDesc == null) return;
|
||
foreach (var s in _activeStates) {
|
||
if (s.Desc.IsContinuous) {
|
||
if (!s.ContinuousSource.IsPlaying)
|
||
s.ContinuousSource.Play(); // loops forever, position tracks listener
|
||
continue;
|
||
}
|
||
s.NextFireIn -= dt;
|
||
if (s.NextFireIn <= 0) {
|
||
// roll chance
|
||
if (_rng.NextSingle() < s.Desc.BaseChance) {
|
||
// play near listener (retail scatters within ~8m box)
|
||
var pos = listenerPos + _rng.InUnitSphere() * 8f;
|
||
_engine.PlayAmbient(s.Desc.SType, s.Desc.Volume, pos);
|
||
}
|
||
// schedule next
|
||
s.NextFireIn = _rng.NextFloat(s.Desc.MinRate, s.Desc.MaxRate);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 13.3 OpenAL parameter mapping
|
||
|
||
Because retail does all attenuation in software, we set OpenAL to
|
||
**linear gain passthrough**:
|
||
|
||
```csharp
|
||
// on engine init:
|
||
_al.DistanceModel(DistanceModel.None); // disable OpenAL's rolloff
|
||
_al.DopplerFactor(0f); // retail has no doppler
|
||
// for each source:
|
||
alSource.Gain = computedGain; // 0..1 from our falloff
|
||
alSource.Pitch = pitchMul;
|
||
alSource.Position = worldPos; // informational only (model=None)
|
||
alSource.Velocity = Vector3.Zero; // no doppler, keep at 0
|
||
```
|
||
|
||
For stereo panning of 2D UI sounds, mono Wave files positioned with a
|
||
source that has `SourceRelative=true` at `(pan, 0, distFixed)` will
|
||
produce the expected L-R balance via OpenAL's minimal spatialization,
|
||
or we can explicitly feed stereo PCM into two mono sources with
|
||
gain-distributed panning. Simplest path: keep UI sounds mono, set
|
||
Source.Position to `(computedPan, 0, -1)` relative, and leave
|
||
DistanceModel=None — OpenAL still applies default HRTF/stereo-pan math
|
||
for relative sources.
|
||
|
||
---
|
||
|
||
## 14. Integration points
|
||
|
||
### 14.1 Player footsteps
|
||
|
||
- `PlayerMovementController` (`src/AcDream.App/Input/PlayerMovementController.cs`)
|
||
drives motion; the `MotionInterpreter` processes `AnimationHook`s each
|
||
frame.
|
||
- **Hook into `AnimationSequencer`'s frame-advance callback.** When a
|
||
frame has a `SoundTableHook` with `SoundType=Footstep1/2`, call
|
||
`AudioEngine.Play(Footstep1, player.SoundTable, player.Position,
|
||
player.Velocity, baseVolume: 1.0f)`.
|
||
- Surface-type hint (from terrain subsystem) can be fed to a
|
||
`SoundTable.PlayWithSurfaceHint(Footstep1, surface)` overload that
|
||
biases the uniform random pick toward entries whose names hint at
|
||
the material.
|
||
|
||
### 14.2 Combat hit sounds
|
||
|
||
- Combat system ports to acdream later (Phase 6+). When `GameMessageSound`
|
||
(opcode 0xF750) arrives from the server:
|
||
|
||
```csharp
|
||
void OnNetSound(Guid target, SoundId id, float volume) {
|
||
var obj = _gameState.FindObject(target);
|
||
if (obj == null || obj.SoundTable == null) return;
|
||
_engine.Play(id, obj.SoundTable, obj.Position, obj.Velocity, volume);
|
||
}
|
||
```
|
||
|
||
- Attack swing / bowstring sounds are triggered by `SoundTableHook` on
|
||
the attacker's animation — already covered by the 14.1 path.
|
||
- Impact sounds (HitFlesh/HitLeather/…) require the server to decide
|
||
armor type and fire 0xF750 with the right SoundId aimed at the
|
||
struck object.
|
||
|
||
### 14.3 Spellcasting
|
||
|
||
- Cast-start (windup) sounds: `SoundTableHook{Launch}` on the caster's
|
||
casting animation.
|
||
- In-flight whoosh: either the projectile weenie has a SoundTable with a
|
||
looping `Swoosh*` played each frame, or the projectile PhysicsScript
|
||
fires a continuous sound. We favor the former for simpler state.
|
||
- Impact / resolution: server sends `GameMessageScript{Fizzle}` or
|
||
`{Explode}` on opcode 0xF755 (PlayEffect) — PlayScript resolves to
|
||
both a particle + sound bundle.
|
||
|
||
### 14.4 Ambient loop on landblock load
|
||
|
||
- `Streaming/GpuWorldState` has a landblock-change event. Wire
|
||
`AmbientPlayer.ApplyRegionChange(newSTBId)` there.
|
||
- `STBId` selection: TERRAIN_TYPE byte of the current cell's dominant
|
||
vertex → lookup in RegionDesc.SoundInfo.STBDesc (or use a simpler
|
||
"by terrain-type-byte index" for phase 1; we can refine once
|
||
we match retail behavior visually/aurally).
|
||
|
||
### 14.5 UI sounds
|
||
|
||
- `UI_ButtonPress`, `UI_IconPickUp`, etc fire from UI input handling.
|
||
- Dedicated `AudioEngine.PlayUI(SoundId)` path that skips the
|
||
voice-pool eviction (UI sounds go to their own small pool or are
|
||
fire-and-forget).
|
||
|
||
---
|
||
|
||
## 15. Research gaps / future work
|
||
|
||
1. **Exact `DAT_00870414` and `DAT_00870418` calibration constants** —
|
||
the decompiled code exposes the function signature but not the
|
||
numeric defaults. Recommended: instrument retail (or a retail-server
|
||
client test harness) to dump them at runtime, or derive from
|
||
listening-distance tests (e.g. "Drudge footsteps audible out to ~30m
|
||
→ back-solve DIST_K").
|
||
2. **Surface-type-to-footstep-sample mapping** — confirmed as being
|
||
"implicit in the SoundTable's multi-entry lists," but the **specific
|
||
rule** for which entry index maps to which surface type needs a
|
||
sound-table dump + listening test.
|
||
3. **MIDI file inventory** — where are the actual `*.mid` files stored?
|
||
They're not in `client_portal.dat`. Likely `<install>/sound/*.mid`
|
||
as loose files. Confirm with a retail-install inventory.
|
||
4. **PlayScript sound attachment** — PlayScript effects (dat type
|
||
`0x33xxxxxx`?) each carry a list of frames with optional sound hooks.
|
||
Depth analysis is part of R6 (particles); we link to it at that time.
|
||
5. **Pitch variance** — retail has some random pitch variance on
|
||
certain sounds (probably ±5% or so to avoid "identical twins"
|
||
artifacts on rapid-fire Wound1 sounds). Per-SoundEntry flag or
|
||
global? TBD from more decomp.
|
||
6. **Stereo pan specifics** — precise projection formula for `SetPan`
|
||
needs deeper grep of `FUN_00550d80`'s caller chain to extract the
|
||
pan scalar computation.
|
||
|
||
---
|
||
|
||
## 16. References
|
||
|
||
- Decompiled retail: `docs/research/decompiled/chunk_00550000.c` (entire
|
||
chunk is the sound subsystem + MIDI + ACM wrappers + options UI
|
||
callbacks; also `chunk_005D0000.c:12314` for DirectSoundCreate import).
|
||
- ACE dat loader: `references/ACE/Source/ACE.DatLoader/FileTypes/{SoundTable,Wave}.cs`
|
||
and `Entity/{SoundData,SoundTableData,SoundDesc,AmbientSoundDesc,AmbientSTBDesc}.cs`.
|
||
- ACE enums: `references/ACE/Source/ACE.Entity/Enum/{Sound,AnimationHookType,PlayScript}.cs`.
|
||
- ACE game messages: `references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs`
|
||
(opcodes 0xF750/F754/F755) and `Messages/GameMessageSound.cs`,
|
||
`Messages/GameMessageScript.cs`.
|
||
- ACE physics: `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:3088` (`play_sound`)
|
||
and `Physics/Hooks/AnimHook.cs`.
|
||
- Holtburger client: `references/holtburger/crates/holtburger-protocol/src/messages/effects/types.rs`
|
||
(`PlaySoundData`, 16-byte layout confirming ACE).
|
||
- DatReaderWriter schema: `references/DatReaderWriter/DatReaderWriter/dats.xml:3751`
|
||
(Wave type, `0xA000000..0xA00FFFF`) and `:3918` (SoundTable type,
|
||
`0x20000000..0x2000FFFF`); generated types in
|
||
`DatReaderWriter/Generated/{DBObjs,Types}/`.
|
||
- ACViewer reader (same base as ACE, useful cross-check):
|
||
`references/ACViewer/ACViewer/FileTypes/{SoundTable,Sound}.cs`.
|
||
- AC2D client opcode reference: `references/AC2D/cNetwork.cpp:2009-2029`
|
||
(`0xF750` is "sound effect", `0xF755` is "visual/sound effect",
|
||
unimplemented placeholders).
|
||
|
||
---
|