78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.
Research (docs/research/deepdives/):
- 00-master-synthesis.md (navigation hub + dependency graph)
- r01-spell-system.md 5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md 5.9K words (damage formula, crit, body table)
- r03-motion-animation.md 8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md 5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md 5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md 7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md 6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md 7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md 5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md 4.5K words (deterministic client-side)
- r13-dynamic-lighting.md 4.9K words (8-light cap, hard Range cutoff)
Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.
Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).
C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs — ItemType/EquipMask enums, ItemInstance,
Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs — SpellDatEntry, SpellComponentEntry,
SpellCastStateMachine, ActiveBuff,
SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs — CombatMode/AttackType/DamageType/BodyPart,
DamageEvent record, CombatMath (hit-chance
sigmoids, power/accuracy mods, damage formula),
ArmorBuild
- Audio/AudioModel.cs — SoundId enum, SoundEntry, WaveData,
IAudioEngine / ISoundCache contracts,
AudioFalloff (inverse-square)
- Vfx/VfxModel.cs — 13 ParticleType integrators, EmitterDesc,
PhysicsScript + hooks, Particle struct,
ParticleEmitter, IParticleSystem contract
All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.
Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)
Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
1150 lines
44 KiB
Markdown
1150 lines
44 KiB
Markdown
# R5 — Audio System Deep Dive
|
||
|
||
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).
|
||
|
||
---
|