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