acdream/docs/research/2026-08-08-audio-retail-music-absence.md
Erik ffa5087527 docs: Campaign A (audio parity) — six-lane retail decode + campaign plan
Full review of the audio subsystem against the named 2013 retail decomp,
with byte-verification of every load-bearing float compare (five BN
polarity/constant elisions caught). Headlines: retail is a CPU-side 2D
pan+gain engine (no 3D listener in use); the SoundTable probability field
is a Bernoulli SILENCE gate our SoundCookbook never applies (4,183/4,184
entries are single-entry and we short-circuit them); 0xF750 server sounds
are entirely unhandled; ambients are region-authored weighted one-shots
(indoors silent by design); and retail EoR has NO music system at all.

Plan proposes slices A1-A6; awaiting user go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:55:33 +02:00

491 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Lane 6 — Retail MUSIC system (MediaMachine / MD_Data_Sound / winmm MIDI)
Read-only research note. Sources: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(Sept 2013 EoR build, PDB-named), `acclient.h` (verbatim retail structs),
`symbols.json`, plus `references/DatReaderWriter/`, `references/ACViewer/`,
the retail install at `C:\Turbine\Asheron's Call\`, and the live
`UserPreferences.ini`.
---
## 0. Headline finding — retail EoR HAS NO MUSIC SYSTEM
This is the load-bearing result and it contradicts the standing assumption in
`docs/research/deepdives/r05-audio-sound.md` §6.
Three independent pieces of evidence, all from the exact PDB-paired 2013 build:
1. **`midiPlay` has zero callers.** The only three occurrences of address
`0x00553390` in the whole 65 MB pseudo-C are the function's own
definition/open/close lines (`acclient_2013_pseudo_c.txt:350072,350074,350133`).
The single internal call is `midiPlayNext``midiPlay`
(`:350147`), and `midiPlayNext` is itself only reachable from the
`MidiProc` buffer-done callback (`:350223`) — i.e. it only ever advances a
*queue that nothing ever fills*.
2. **Both MIDI callbacks are permanently null.** `midiEventCallback` and
`midiStartCallback` are statically initialised to 0
(`:1185597`, `:1185598`) and there is no assignment site anywhere in the
image. `MidiProc` null-checks them on every event and no-ops.
3. **No music preference and no music files.** `SoundManager::InitPrefs` /
`ShutDown` register exactly eight sound preferences —
`SoundDisabled`, `SoundVolume`, `AmbientSoundDisabled`,
`AmbientSoundVolume`, `InterfaceSoundDisabled`, `InterfaceSoundVolume`,
`SoundFeatures`, `PlaySoundOnlyWhenActive` (`:346764``:346810` region,
Unregister list at `:00550367``:005503ad`). The live
`%USERPROFILE%\Documents\Asheron's Call\UserPreferences.ini` `[Sound]`
section contains exactly those keys — **no music volume, no music toggle**.
And `C:\Turbine\Asheron's Call\` contains **zero `.mid` / `.rmi` / `.mp3` /
`.wav`** files; the only media file on disk is `turbine_logo_ac.avi`.
4. **The word "music" does not appear anywhere in the 65 MB pseudo-C**
(case-insensitive grep: 0 hits), and `SoundType` (the 0x000xCC enum,
`acclient.h:4569`) has **no music member**.
So: the client *links* a complete Microsoft-sample-derived SMF streaming
player, initialises it at startup (`SoundManager::Init``midiSetup()`,
`:346764`), tears it down at shutdown (`SoundManager::ShutDown`
`midiCleanup()`, `:346655`), and **never hands it a file**. It is dead
infrastructure — a vestige of a 1999 design decision that was cut.
What players actually hear as "music" in retail EoR is one of three things,
all of them ordinary DAT `Wave` (0x0A) PCM played through the normal
DirectSound path:
| Perceived as | Actually is | Retail mechanism |
|---|---|---|
| Login / splash score | audio track of `turbine_logo_ac.avi` | `MD_Data_Movie` → DirectShow `IGraphBuilder` |
| Dungeon "chanting/drums/whispers" atmosphere | UI SoundTable stingers | `CPlayerSystem::Handle_Admin__Environs` codes 0x650x7C |
| Outdoor/dungeon soundscape | region ambient sound rolls | `Ambient` priority queue + `AmbientSTBDesc` |
---
## 1. The MIDI subsystem (documented for completeness / correction of r05)
Free functions, all at `0x00552f60``0x00553840`. It is a near-verbatim port of
Microsoft's `MIDIPLYR` SDK sample, including the literal event name
`"Wait For Buffer Return"` (`:350321`).
| Symbol | Addr | Role |
|---|---|---|
| `midiSetup()` | `0x00553770` | `midiOutGetNumDevs`, fill `dwVolCache[16]`/`dwVolPctCache[16]` with 100, `CreateEventA("Wait For Buffer Return")`, `midiStreamOpen(&hStream, &uMIDIDeviceID, 1, MidiProc, 0, 0x30000)`. Sets `MidiIsSetup`. |
| `StreamBufferSetup(char* path)` | `0x00553030` | allocates **6 buffers × 0x400 bytes** (`LocalAlloc(LMEM_ZEROINIT, 0x423)` then 32-byte-aligned), `ConverterInit(path)`, `midiStreamProperty(…, 0x80000001)` = set time division, primes all 6 via `ConvertToBuffer` + `midiOutPrepareHeader` + `midiStreamOut`. |
| `midiPlay(path, loop, immediate, tempoMul)` | `0x00553390` | if already playing and `immediate==0` → stash into `pending`/`pending_loop`/`dwQueuedTempoMultiplier`, set `is_pending`, return (that is the "queue next track" path). Otherwise `midiStop()`, `StreamBufferSetup`, then per-channel `midiOutShortMsg(0xB0|ch, ctrl 7, vol)` for all 16 channels, `midiStreamRestart`. `tempoMul` is a percentage (default `0x64` = 100). |
| `midiPlayNext()` | `0x005534c0` | pops `pending``midiPlay(pending, pending_loop, 1, …)`. |
| `MidiProc` | `0x00553500` | `MOM_DONE` (0x3C9) refills/rotates buffers mod 6; on end-of-data (`uCallbackStatus == 0x12C`) waits for all 6 buffers back, then either `midiPlayNext()` if a track is queued or `midiStop()`. `MOM_POSITIONCB` (0x3CA) sniffs the stream: caches controller-7 volume per channel and forwards note-on/off + volume on **channels 14/15 only** to `midiEventCallback` (a game-sync hook — never installed). |
| `midiStop()` | `0x00553240` | `midiStreamStop`, `midiOutReset`, `WaitForSingleObject(hBufferReturnEvent, 0x7D0 = 2000 ms)`, `ConverterCleanup`, `FreeBuffers`, close+reopen the stream, reset `is_pending`/`pending_loop`/`dwQueuedTempoMultiplier = 100`. |
| `SetChannelVolume(ch, pct)` | `0x00552f60` | `midiOutShortMsg(0xB0|ch, ctrl 7, dwVolCache[ch]*pct/100)`. The only volume control; **no fade, no crossfade, no ramp anywhere**. |
| `ConverterInit(path)` | `0x00554530` | `CreateFileA(path, GENERIC_READ, …)` → reads `'MThd'` (`0x6468544D`), byte-swaps header, `dwFormat`/`dwTrackCount`/`dwTimeDivision`, then per track reads `'MTrk'` (`0x6B72544D`) into a 0x400 window. **Standard MIDI File from a loose disk path — never from a DAT.** |
### Corrections to `r05-audio-sound.md` §6
| r05 claim | Verdict |
|---|---|
| "Music is MIDI, streamed through midiStreamOpen" | **Correct as to mechanism**, wrong as to it being used. |
| 6 × 1024-byte buffers, "Wait For Buffer Return" event, 16-channel volume arrays | **Confirmed.** |
| "MThd/MTrk parsing at FUN_00555150" | Right idea, wrong address in the named build: `ConverterInit` @ `0x00554530`. |
| "pan (0x0A)" via `midiOutShortMsg` | **Not found.** Only controller **7 (volume)** is written (`SetChannelVolume`, `midiPlay`). No pan CC. |
| "Track selection is by the game code calling `PlayMusic(path, loop)` — driven by region/area rules" | **REFUTED.** No such caller and no region→track table exists. This sentence is the source of the `PlayMusic(string resourceName, bool loop)` shape in `IAudioEngine` — that signature is an invention, not a retail port. |
| "Recommended: convert MIDI to OGG offline" | Moot — there is no MIDI content to convert. |
**Consequence for the port:** a MIDI synth is *not* needed. Neither is an OGG
music bus. There is nothing to be faithful to.
---
## 2. `MediaMachine` — what it actually is (a per-UI-element media bytecode VM)
`MediaMachine` is **not** the music system. It is the interpreter for the
*media script* attached to every UI element **state** in the LayoutDesc DAT.
Playing a sound is one of its eleven instructions.
### Ownership chain
```
LayoutDesc (DBObj) acclient.h:33881
└─ ElementDesc : StateDesc acclient.h:33693
└─ StateDesc acclient.h:33640
└─ SmartArray<MediaDesc*> m_media ← the script
UIElement
└─ MediaMachine m_mediaMachine acclient.h:33786
├─ UIElement* m_owner
├─ SmartArray<MediaDesc*> m_array ← deep copy of the active state's m_media
└─ unsigned m_curIndex ← the program counter
```
`MediaMachine : UIListener` (`acclient.h:33873`).
### State machine (this is the whole thing)
- `MediaMachine::Reset(const SmartArray<MediaDesc*>&)` @ `0x00465d90`
(`:112681`): `Cleanup()`, then deep-copy every `MediaDesc` via
`MediaDesc::CreateMediaType(const MediaDesc*)`, set `m_curIndex = 0`, and
immediately `Update()`. Called from `UIElement::SetState`
`MediaMachine::Reset(&m_mediaMachine, &m_desc.m_media)` (`:108863`), and on
element copy (`:111688`, `:111745`).
- `MediaMachine::Update()` @ `0x00465ba0` (`:112526`) — the interpreter loop:
1. `UIListener::UnRegisterForGlobalMessage(this, 3)`.
2. While `m_curIndex < m_array.m_num`: dispatch on `m_type - 1` through an
11-entry jump table (`jump_table_465cc0`, `:112621`) to the matching
`Update_X(desc)`.
3. **The return value is "may I advance?"** — non-zero ⇒ `m_curIndex++` and
continue in the same call; **zero ⇒ break** (the instruction is still
blocking).
4. On break, `UIListener::RegisterForGlobalMessage(this, 3)` — i.e. subscribe
to the per-tick global message so the machine resumes next frame.
- `MediaMachine::ListenToGlobalMessage(msg, _)` @ `0x00465cf0`: `if (msg == 3)
Update()`. **Global message 3 is the machine's clock.** There is no
dedicated music/media tick.
- `Cleanup()` @ `0x00465af0`: virtual-deletes every owned `MediaDesc`, zeroes
the array. Called by dtor and by `Reset`.
Termination: the machine runs off the end of the array and stops (no
re-registration). A `Jump` instruction is what makes a script loop forever.
### Instruction set (`MediaDesc::m_type`, 1-based)
Verified from `MediaDesc::CreateMediaType(uint32_t)` @ `0x0069d420`
(`:675786`) and each ctor's `MediaDesc::MediaDesc(this, N)`:
| # | Type | Struct (acclient.h) | Blocking? | Semantics |
|---|---|---|---|---|
| 1 | Movie | `MD_Data_Movie` :34160 — `PStringBase m_strFileName`, `bool m_StretchToFullScreen`, `MovieTheatre*` | yes, until done | `MD_Data_Movie::Update(owner)` @ `0x0069d489`; DirectShow (`ATL::CComPtr<IGraphBuilder>` :34191). Requires owner visible-bit `(m_owner+0x554) >> 0x11 & 1`. |
| 2 | Alpha | `MD_Data_Alpha` :34118 — `DID m_file` | no | alpha mask image |
| 3 | Anim | `MD_Data_Anim` :34101 — `float m_duration`, `m_drawMode`, `SmartArray<DID> m_frames`, `double m_StartTime`, `int m_displayedFrameNum` | yes, for `m_duration` | flipbook; latches `m_StartTime` on first visit (sentinel `-1.0`) |
| 4 | Cursor | `MD_Data_Cursor` :34168 — `DID m_file`, `int m_xHotspot`, `m_yHotspot` | no | `UIElement::SetCursor` |
| 5 | Image | `MD_Data_Image` :34111 — `DID m_file`, `m_drawMode` | no | set the element's picture |
| 6 | Jump | `MD_Data_Jump` :34132 — `uint m_jumpItemIndex`, `float m_probability` | no | `RollDice(0,1)` vs probability; on pass `m_curIndex = m_jumpItemIndex - 1` (then the loop's `++` lands exactly on `m_jumpItemIndex`). **This is the loop primitive.** |
| 7 | Message | `MD_Data_Message` :34139 — `uint m_messageID`, `float m_probability` | no | `UIElement::BroadcastElementMessage(owner, m_messageID, 0, 0)` |
| 8 | Pause | `MD_Data_Pause` :34124 — `float m_minDuration`, `m_maxDuration`, `double m_endTime` | **yes** | first visit: `m_endTime = Timer::compute_time() + RollDice(min,max)`; blocks until now ≥ endTime, then resets `m_endTime = -1.0` |
| 9 | **Sound** | `MD_Data_Sound` :34146 — `DID m_file`, `SoundType m_stype` | no | see §3 |
| 10 | State | `MD_Data_State` :34153 — `uint m_stateID`, `float m_probability` | terminal | probabilistic `owner->SetState(m_stateID)`; **always returns 0** (`:112068`) so the machine stops — the new state's `Reset` takes over |
| 11 | Fade | `MD_Data_Fade` :34176 — `float m_startAlpha`, `m_endAlpha`, `m_duration`, `double m_startTime` | **yes** | see §4 |
Sentinel convention: `m_StartTime` / `m_endTime` / `m_startTime` use the
double `-1.0` (`0xBFF00000` in the high word) as "not yet started"; each
blocking instruction resets it to `-1.0` when it completes, so a `Jump` back
over it re-arms it.
### Text/serialised form
`MediaDesc::ToFileNode` / `CreateFromFileNode` (`0x0069d740` / `0x0069d7e0`)
read a `MediaType` node (keyword `"MediaType"`, `KW_MEDIATYPE` @ `:821851`)
under a `"Media"` node (`KW_MEDIA` @ `:821841`), enum-name table 14. Per-type
keywords seen in the init block around `:821780``:821960`:
`MinDuration`, `MaxDuration`, `Probability`, `SoundName`, `SoundTable`,
`StartAlpha`, `EndAlpha`, `Duration`, `StateID`, `StretchToFullScreen`,
`NoDBFile`, `PassToChildren`. `StateDesc::LoadMedia` @ `0x0069c950`
(`:674969`) appends each parsed desc; `StateDesc::ConcatenateMedia`
@ `0x0069c9b0` merges a parent state's script into a child's.
`DatReaderWriter` already models all of this:
`references/DatReaderWriter/DatReaderWriter/Generated/Types/MediaDesc.generated.cs`
(abstract + `MediaType` dispatch) and `MediaDescSound.generated.cs`
(`uint File`, `Sound Sound`). **The binary layout is `int32 mediaType, int32
type, uint32 file, uint32 sound`** — note the doubled type field, which the
generated reader reproduces.
---
## 3. `MediaMachine::Update_Sound` — the authored-sound instruction
`0x004658b0` (`:112264`). Full decode:
```
Update_Sound(MD_Data_Sound* d):
if (d == null || m_owner == null) return 0; // 0 = block (dead-end)
if (d->m_stype == Sound_Invalid) // 0
# m_file is a direct Wave DID
if (d->m_file.id != 0)
SoundManager::PlaySoundFromCenter(d->m_file, 1.0f) # volume literal 0x3F800000
return 1
else
# m_file is a SoundTable DID; look it up as DBO type 0x22
CSoundTable* st = DBObj::Get(QualifiedDataID(d->m_file.id, 0x22))
if (st != null)
SoundManager::PlaySoundFromCenter(d->m_stype, st)
return 1
return 1 # falls through, still advances
```
Two shapes, discriminated by `m_stype`:
- `m_stype == Sound_Invalid` ⇒ **`m_file` is a `Wave` DID (0x0A……)**, played at
literal volume 1.0 via the `PlaySoundFromCenter(DID, float)` overload
(`0x005509e0`, `:346951`) which looks the wave up in
`SoundManager::sound_hash_`.
- `m_stype != Sound_Invalid` ⇒ **`m_file` is a `SoundTable` DID (0x20……)** and
`m_stype` selects the row; `PlaySoundFromCenter(SoundType, CSoundTable*)`
(`0x00550950`, `:346927`) rolls `SoundManager::GetSound` (weighted pick over
`SoundTableData::data_[]` with priority/probability/volume) and plays it.
DBO types confirmed: `CSoundTable::GetDBOType() == 0x22` (`:349100`),
`DBWave::Get()` uses `QualifiedDataID(id, 0xF)` (`:349327`).
**Both go through the *interface* channel**, not a music channel:
`PlaySoundFromCenter` gates on `SoundManager::interface_sounds_enabled` and
`s_bPlaySoundOnlyWhenActive && Device::m_bIsActiveApp`, then
`GetAttenuation(0f, vol, &out, /*isAmbient=*/0)` and
`PlaySoundInternal(buf, /*position=*/0, vol)` — position 0 ⇒ non-positional,
"from center". So the UI/authored-media bus == the interface-sound bus, and
the retail Interface Sound Volume slider is its only volume control.
This is the closest thing retail has to "the client plays a piece of authored
audio because a UI state was entered" — and it is the mechanism the login
screen and any title-card audio would use.
---
## 4. Fades — the only fade math in the media system (and it is alpha, not audio)
`MediaMachine::Update_Fade` @ `0x00465930` (`:112307`):
```
now = Timer::compute_time()
target = owner->m_object ?? owner->m_parent->GetObjectA() # the drawable
if (d->m_startTime == -1.0) d->m_startTime = now # latch on first visit
dur = d->GetDuration() # == m_duration
if (fabs(dur) >= 0.000199999995f) # EPSILON = 2e-4 s
t = (now - d->m_startTime) / dur
else
t = 1.0f
t = clamp(t, 0.0f, 1.0f) # two-sided clamp
alpha = d->m_startAlpha + (d->m_endAlpha - d->m_startAlpha) * t # plain lerp
target->vtable[0x48](alpha) # set element alpha
if (t >= 1.0f) { d->m_startTime = -1.0; return 1 } # done → advance
return 0 # still fading → block
```
Notable: **linear** interpolation, no easing; duration epsilon
`2e-4 s`; a zero/short duration snaps to `endAlpha` in one tick.
**There is no audio fade, no crossfade, and no volume ramp anywhere in
`MediaMachine` or in the MIDI code.** `SetChannelVolume` is an instantaneous
CC-7 write. Any crossfade in an acdream music feature would be new design, not
a port.
---
## 5. Trigger map — every code site that starts "music-adjacent" audio
| Event | Site | What plays |
|---|---|---|
| UI element enters a state | `UIElement::SetState` → `MediaMachine::Reset` (`:108863`) → `Update_Sound` (`:112589`) | authored `MD_Data_Sound` (Wave DID or SoundTable+SoundType) |
| Per-tick continuation of a blocked script | `MediaMachine::ListenToGlobalMessage(3)` → `Update` (`:112642`) | next instruction in the script |
| UI element cleanup / hide | `MediaMachine::Cleanup` (`:109993`), `Update` (`:110007`) | — (stops the script, does **not** stop already-playing sounds) |
| Splash / intro | `MD_Data_Movie::Update` (`:675489`) → DirectShow on `m_strFileName` | `turbine_logo_ac.avi` (its audio track *is* the theme) |
| Teleport start (portal in) | `gmSmartBoxUI` teleport anim, `:218903` | `Sound_UI_EnterPortal` (0x6A) from `ClientUISystem::GetUISoundTable()` |
| Teleport end (portal out, `TAS_WORLD_FADE_IN`) | `:219745` | `Sound_UI_ExitPortal` (0x6B) |
| **Server-pushed atmosphere cue** | `CPlayerSystem::Handle_Admin__Environs(uint)` @ `0x0055de20` (`:362360`+) | codes below |
| Landblock / cell change | `CellManager::ChangePosition` @ `0x004559b0` → `Ambient::InitSounds` (`:94714`), `LScape::add_ambient_sounds` (`:94718`), `Ambient::UpdatePlayQueue` (`:94722`), `Ambient::ReleaseSoundTables` (`:94726`) | region ambient rolls (§6) |
| Per-frame ambient pump | `Ambient::UseTime` @ `0x00551880`, called from `:94200` | due entries from the ambient PQueue |
### `Handle_Admin__Environs` — the retail "set the mood" opcode
`AdminEnvirons` (acdream: `0xEA60`, `WorldSession.cs:1915`). Two disjoint
ranges:
- `1..6` → lighting/fog overrides (`LScape::m_override_*`: ambient level,
ambient colour, fog colour, fog min/max; case 6 also sets
`m_bRadarBlank = 1`). acdream already ports these.
- `0x65..0x7C` → **one-shot UI-SoundTable stinger**, each
`PlaySoundFromCenter(Sound_UI_*, GetUISoundTable())`, gated on the player
physics object existing:
| code | SoundType | | code | SoundType |
|---|---|---|---|---|
| 0x65 | `Sound_UI_Roar` (0x76) | | 0x6E | `Sound_UI_Drums` (0x7F) |
| 0x66 | `Sound_UI_Bell` (0x77) | | 0x6F | `Sound_UI_GhostSpeak` (0x80) |
| 0x67 | `Sound_UI_Chant1` (0x78) | | 0x70 | `Sound_UI_Breathing` (0x81) |
| 0x68 | `Sound_UI_Chant2` (0x79) | | 0x71 | `Sound_UI_Howl` (0x82) |
| 0x69 | `Sound_UI_DarkWhispers1` (0x7A) | | 0x72 | `Sound_UI_LostSouls` (0x83) |
| 0x6A | `Sound_UI_DarkWhispers2` (0x7B) | | 0x75 | `Sound_UI_Squeal` (0x84) |
| 0x6B | `Sound_UI_DarkLaugh` (0x7C) | | 0x760x7A | `Sound_UI_Thunder1..5` (0x850x89) |
| 0x6C | `Sound_UI_DarkWind` (0x7D) | | (0x7B/0x7C) | tail of the Thunder run |
| 0x6D | `Sound_UI_DarkSpeech` (0x7E) | | | |
Codes `0x73`/`0x74` have no case (fall through, nothing plays). Note the
environ code and the `SoundType` are **offset by 0x11** but not uniformly —
the switch is explicit, so port it as an explicit table, never as arithmetic.
This is *the* trigger for what players remember as dungeon "music".
---
## 6. Region ambient soundscape — retail's real "area audio"
Not music, but it is the system a "music by region" feature would have to sit
next to, and it is the only region→audio authored data in the DATs.
```
CRegionDesc (acclient.h:53230)
└─ CSoundDesc* sound_info :53200 — SmartArray<AmbientSTBDesc*>
CSceneType :53240 — { name, scenes[], AmbientSTBDesc* sound_table_desc }
AmbientSTBDesc :35486 — { DID stb_id, bool stb_not_found,
SmartArray<AmbientSoundDesc*> ambient_sounds,
CSoundTable* sound_table, uint play_count }
AmbientSoundDesc :35496 — { SoundType stype, bool is_continuous, float volume,
float base_chance, float min_rate, float max_rate }
```
- Selection is **per land cell, from the terrain word**: in
`CLandBlock::add_ambient_sounds` (`:314270`+) each cell reads
`terrainType = (t >> 2) & 0x1F` and `sceneIdx = t >> 11`, then
`CRegionDesc::GetSTBDesc(region, terrainType, sceneIdx)` →
`Ambient::AddSound(ambient, stbDesc, cellVertexPos)` (`:314293`).
- `AmbientSound` is polymorphic: `ConstantSound` (continuous, tracks
`current_volume`) and `IntermitSound` (per-`LandDefs::Direction` `min_dist[8]`
/ `max_dist[8]` arrays + `play_chance`) — `acclient.h:52830`, `:52856`.
- Scheduling is an absolute-deadline priority queue:
`Ambient::Play` @ `0x005517a0` (`:347826`) → `CanHear()`, `PlayNow()`,
`GetSoundPos()`; positional ⇒ `PlayAmbientSound`, non-positional ⇒
`PlayAmbientSoundFromCenter`; then
`PQueueArray<double>::Insert(sound_queue, Timer::cur_time + GetPlayInterval(), snd)`
and `on_queue = 1`. `Ambient::UseTime` pops due entries.
- Ambient volume path: `PlayAmbientSoundFromCenter` @ `0x005508b0`
multiplies by `SoundManager::ambient_sound_volume`, rolls
`rand() * 3.05185094e-05f` (= `1/32768`) against the entry probability, then
`GetAttenuation(0, vol, &out, /*isAmbient=*/1)`.
---
## 7. Cross-reference results
- **`references/ACViewer/`** — grep for `midi|music` over all `*.cs`: only
three hits, all unrelated (`WeenieClassName.cs`, `SoulEmote.cs`). **ACViewer
implements no music and no MIDI.** It is not an oracle here, and its silence
is itself corroboration that there is nothing to load.
- **`references/DatReaderWriter/`** — no music/MIDI DBObj type exists. Audio
surface is exactly two DBObjs:
- `Wave` — `DBObjType.Wave`, range **`0x0A000000``0x0A00FFFF`**, layout
`int32 headerSize, int32 dataSize, byte[] header, byte[] data`
(`DBObjs/Wave.generated.cs`). The `header` is a `WAVEFORMATEX` blob;
the body can be PCM **or MP3** (retail decodes via winmm ACM).
- `SoundTable` — `DBObjType.SoundTable`, range **`0x20000000``0x2000FFFF`**,
`int32 HashKey`, `Dictionary<uint, SoundHashData>`,
`Dictionary<Sound, SoundData>` with `SoundEntry { QualifiedDataId<Wave> Id,
float Priority, Probability, Volume }`.
- `MediaDesc*` types are all present (§2), including `MediaDescSound`.
- **There is no `0x25……` music table and no MIDI DAT type.** (`0x25……` is
the `RegionDesc`/`Region` family, not music.)
---
## 8. Answers to the five questions
**1. Formats and where the bytes live.**
Retail's *only* music-capable path is winmm `midiStream*` fed a **Standard MIDI
File read from a loose disk path via `CreateFileA`** — never from a DAT. No
DirectMusic, no MP3-as-music, no `0x25……` music table. That path is never
invoked and **no `.mid` ships with the client**. All audio the client actually
plays is DAT `Wave` (`0x0A000000``0x0A00FFFF`, PCM or MP3-in-WAVEFORMATEX)
selected either directly by DID or through `SoundTable`
(`0x20000000``0x2000FFFF`). The one long-form musical asset that ships is the
audio track of `turbine_logo_ac.avi`, played by DirectShow.
**2. The MediaMachine state machine.**
It is not a music machine — it is an 11-opcode media bytecode VM per UI
element state, with `m_curIndex` as PC, "may I advance?" booleans as the
blocking protocol, global message 3 as the clock, `Jump` as the loop
primitive, `State` as the terminal instruction, and `-1.0` double sentinels for
"not yet armed". Full opcode table and per-opcode decode in §2; `Update_Sound`
in §3; `Update_Fade` (linear alpha lerp, 2e-4 s duration epsilon) in §4.
**3. Trigger map.** §5. Region/landblock entry drives *ambient*, not music.
Portal in/out and the 24 `AdminEnvirons` codes `0x650x7C` drive the
atmosphere stingers. UI state entry drives authored `MD_Data_Sound`. Login/
intro music is a movie file. Nothing anywhere starts a music track.
**4. Does a modern port need a MIDI synth?** **No.** There is no MIDI content
and no code that would play it; EoR-era audio is 100% sampled. ACViewer
implements nothing here either. Building a synth (or an OGG music bus) would be
new feature design with no retail referent — and per CLAUDE.md that is a
"different feature", not a port, so it needs explicit approval.
**5. Rough port scope.**
- **Delete the false surface (smallest, highest value).**
`IAudioEngine.PlayMusic(string resourceName, bool loop)` /
`StopMusic()` in `src/AcDream.Core/Audio/AudioModel.cs:102-103`, their
no-op bodies in `src/AcDream.App/Audio/OpenAlAudioEngine.cs:386-387`, and
`MusicVolume` at `AudioModel.cs:84` / `OpenAlAudioEngine.cs` are modelled on
a retail feature **that does not exist**. `resourceName` as a *string path*
is itself a tell — every other audio entry point in the engine is DID-keyed.
A retail-faithful engine has three buses (SFX / Ambient / Interface), not
four. Removing them retires a divergence rather than creating one; if they
stay, they need a `retail-divergence-register.md` row explaining that they
model dead retail code. Also worth a look:
`src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs:260` comments
the music path as "stubbed for R5 MIDI", and retail's Settings has no music
slider at all.
- **Correct the record.** `docs/research/deepdives/r05-audio-sound.md` §6 and
its executive-summary table row ("MIDI music | winmm midiStream | Loose
`*.mid` files on disk") should carry the "infrastructure present, never
invoked, no content ships" finding — see the correction table in §1. That
table row is what produced the phantom API.
- **The genuinely missing retail behavior, in cost order:**
1. **`AdminEnvirons` sound cues** (24 codes → `Sound_UI_*` via the UI
SoundTable). acdream already parses these and already has
`RuntimeEnvironmentSoundCue`, with
`src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs:214-239`
logging `audio binding pending`. This is a table + one `PlayUi` call —
the cheapest real retail-fidelity win in the whole lane.
2. **`MediaDescSound` in the LayoutDesc importer.**
`src/AcDream.App/UI/Layout/LayoutImporter.cs:464-489` reads
`MediaDescImage` and `MediaDescCursor` from each state's media list and
ignores everything else. Adding the Sound opcode (two shapes per §3, both
routed to the interface bus) gives retail's authored UI sounds for free —
`DatReaderWriter` already parses the type.
3. **Region ambient system** (§6): `AmbientSTBDesc` selection from the
terrain word on landblock change, `ConstantSound`/`IntermitSound`, the
absolute-deadline PQueue, `ambient_sound_volume`. This is the real
"area audio" and it is what `StartAmbient`/`StopAmbient`
(`OpenAlAudioEngine.cs:367-385`, currently handle-reservation only) exist
to serve. Multi-commit; belongs in a roadmap phase, not an issue.
4. **`MediaMachine` proper** (Pause/Jump/Anim/Fade/State scripting) — only
if animated UI states become a goal. Not required for audio.
- **Explicitly out of scope for a faithful port:** MIDI playback, a soundfont
synth, crossfades, a music bus, region→track tables. None exist in retail.
---
## 9. Anchors (for citation in code comments)
| Symbol | Address | pseudo-C line |
|---|---|---|
| `SoundManager::Init` (calls `midiSetup`) | `0x00550640` | 346753 |
| `SoundManager::ShutDown` (calls `midiCleanup`) | `0x005502b0` | ~346580 |
| `midiSetup` | `0x00553770` | 350282 |
| `midiPlay` (**no callers**) | `0x00553390` | 350072 |
| `midiPlayNext` | `0x005534c0` | 350138 |
| `midiStop` | `0x00553240` | 349978 |
| `midiCleanup` | `0x00553350` | 350047 |
| `MidiProc` | `0x00553500` | 350152 |
| `StreamBufferSetup` | `0x00553030` | 349843 |
| `ConverterInit` (MThd/MTrk) | `0x00554530` | 351254 |
| `SetChannelVolume` | `0x00552f60` | 349787 |
| `midiEventCallback` / `midiStartCallback` (= 0) | `0x0086fa70` / `0x0086fa74` | 11855978 |
| `MediaMachine::Update` | `0x00465ba0` | 112526 |
| `MediaMachine::Reset` | `0x00465d90` | 112681 |
| `MediaMachine::Cleanup` | `0x00465af0` | 112475 |
| `MediaMachine::ListenToGlobalMessage` | `0x00465cf0` | 112638 |
| `MediaMachine::Update_Sound` | `0x004658b0` | 112264 |
| `MediaMachine::Update_Fade` | `0x00465930` | 112307 |
| `MediaMachine::Update_Pause` | `0x00465520` | 111937 |
| `MediaMachine::Update_Jump` | `0x004655b0` | 111983 |
| `MediaDesc::CreateMediaType(uint)` | `0x0069d420` | 675786 |
| `MD_Data_Sound::MD_Data_Sound` (type 9) | `0x0069e5f0` | 677163 |
| `MD_Data_Sound::Serialize` | `0x0069e670` | 677192 |
| `StateDesc::LoadMedia` | `0x0069c950` | 674969 |
| `SoundManager::PlaySoundFromCenter(SoundType, CSoundTable*)` | `0x00550950` | 346927 |
| `SoundManager::PlaySoundFromCenter(DID, float)` | `0x005509e0` | 346951 |
| `SoundManager::PlayAmbientSoundFromCenter` | `0x005508b0` | 346893 |
| `CPlayerSystem::Handle_Admin__Environs` | `0x0055de20` | ~362360 |
| `Ambient::Play` | `0x005517a0` | 347826 |
| `Ambient::UseTime` | `0x00551880` | 347957 |
| `CellManager::ChangePosition` (ambient re-init) | `0x004559b0` | 94601 |
| `CSoundTable::GetDBOType` (= 0x22) | `0x00552560` | 349100 |
| `DBWave::Get` (QDID type 0xF) | `0x00552880` | 349327 |
| `enum SoundType` (0x000xCC) | — | `acclient.h:4569` |
| `MediaMachine` / `MediaDesc` / `MD_Data_*` structs | — | `acclient.h:33873, 33907, 3410134182` |