chore(audio): Campaign A slice A6 — delete what retail does not have

Retail EoR has no music system: the linked winmm MIDI player has zero
callers, 'music' appears zero times in the 65 MB decomp, SoundType has no
music member, InitPrefs registers no music key, and the install ships no
music files. So PlayMusic/StopMusic/MusicVolume and the AudioSettings
Music knob are deleted rather than left as an API modelling dead code —
the string-keyed signature was the tell, since every other entry point is
DID-keyed. Old settings.json files carrying a 'music' key still load; the
reader ignores unknown keys and the next save drops it.

The Ambient slider is now surfaced, because slice A5 gave it something to
drive, and its default returns to retail's 1.0 from an invented 0.8 —
InitPrefs defaults every sound preference to unity. The panel rule is
unchanged: no slider that does nothing.

r05-audio-sound.md gets a SUPERSEDED banner naming its five wrong
sections (falloff, pan, voice pool, selection, music, ambient) so a
future reader reaches the lane notes instead of the Ghidra-era reads that
this campaign spent its first two slices undoing.

TS-9 re-scoped from 'any MP3 cue' to the measured blast radius: exactly 1
MP3 among 786 shipped waves, a ~2 s mono clip. Its original framing
assumed a music system that does not exist. The ADPCM count remains
unmeasured and is named as the open question.

Deferred deliberately: #321's sound-cache decode-dedup race. It is a
pre-existing concurrency flake rather than audio-parity behaviour, and
shipping a speculative fix to a race I have not reproduced is exactly the
shortcut this project's no-workarounds rule exists to prevent.

Campaign A is code-complete; the plan carries the closeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 22:58:49 +02:00
parent 7c4dd1ade7
commit dd2cb92b99
13 changed files with 152 additions and 65 deletions

View file

@ -341,7 +341,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` | | ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` |
| TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) | | TS-9 | **RE-SCOPED 2026-08-08 (Campaign A slice A6) — the blast radius is one wave, measured.** MP3 (0x55) and MS-ADPCM (0x02) waves still decode to null and play as silence where retail decoded both through the winmm ACM. What changed is the size of the problem: an independent walk of the shipped dats found exactly **1 MP3 among 786 waves** (`0x0A000393`, a ~2 s mono clip) and the row's original "any MP3 cue, common for music-ish clips" framing was wrong — it was written when we believed retail had a music system, and retail has none. A managed decoder for one two-second asset is not worth the dependency; the honest options are a ~50-line decode or an accepted-loss row, and this row is now the accepted-loss record. The ADPCM count has NOT been measured and is the one open question here. | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Measured rather than assumed. PCM covers 785 of 786 waves. | One ~2 s clip is silent, plus an unmeasured number of ADPCM clips. | winmm ACM path; dat census in `docs/research/2026-08-08-audio-retail-dat-layer.md` §4c |
| TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition | | TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition |
| TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 | | TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 |
| TS-17 | AttackConditions suffix always empty in combat chat — formatting ported, wire bitflag not plumbed (Phase I.7 follow-up) | `src/AcDream.Core/Chat/CombatChatTranslator.cs:233` | Only the wire plumbing is missing; the holtburger-ported formatter is ready | Combat log omits "[Sneak Attack]"-style suffixes retail displays — hidden combat-mechanic feedback | holtburger chat.rs:588-595 | | TS-17 | AttackConditions suffix always empty in combat chat — formatting ported, wire bitflag not plumbed (Phase I.7 follow-up) | `src/AcDream.Core/Chat/CombatChatTranslator.cs:233` | Only the wire plumbing is missing; the holtburger-ported formatter is ready | Combat log omits "[Sneak Attack]"-style suffixes retail displays — hidden combat-mechanic feedback | holtburger chat.rs:588-595 |

View file

@ -1,6 +1,6 @@
# Campaign A — Audio retail-feel parity # Campaign A — Audio retail-feel parity
**Status: PROPOSED 2026-08-08 — awaiting user go.** Research phase complete; **Status: CODE-COMPLETE 2026-08-08 — awaiting the user listening gate.** All six slices landed (see the ledger). Research phase complete;
six-lane named-retail decode done, all load-bearing claims byte-verified six-lane named-retail decode done, all load-bearing claims byte-verified
against the PDB-paired 2013 binary (BN pseudo-C alone was NOT sufficient — against the PDB-paired 2013 binary (BN pseudo-C alone was NOT sufficient —
see "BN traps" below). see "BN traps" below).
@ -303,8 +303,54 @@ global kill switch.
| Slice | Status | Commit | Gates | | Slice | Status | Commit | Gates |
|---|---|---|---| |---|---|---|---|
| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. | | A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
| A2 | **COMPLETE** 2026-08-08 | `6d0156cb` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** | | A2 | **COMPLETE** 2026-08-08 | `e42b9948` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** |
| A3 | **COMPLETE** 2026-08-08 | `3fae0c7d` | 14 wire-conformance tests + 5 controller tests; full Release suite 11,658 passed / 4 skipped / 0 failed. **Owed: connected gate** (melee hit / pickup / lifestone audible against ACE). | | A3 | **COMPLETE** 2026-08-08 | `8bc458fb` | 14 wire-conformance tests + 5 controller tests; full Release suite 11,658 passed / 4 skipped / 0 failed. **Owed: connected gate** (melee hit / pickup / lifestone audible against ACE). |
| A4 | **COMPLETE** 2026-08-08 | `489ea6ad` | UI bank DID resolved from the dats (`0x2000004B`, content-verified: exactly the 32 `UI_*` slots) + 21-case environ table, 30 new Core tests; full Release suite 11,691 passed / 4 skipped. Retires TS-54; narrows AP-115 to notice-only. **Owed: connected gate** (`@environs` thunder + recall cues audible). **Suite note:** two load-dependent measurement flakes were observed on separate full-suite runs (`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, and one unnamed Core.Net test); both pass in isolation and neither touches audio. | | A4 | **COMPLETE** 2026-08-08 | `6eaa490b` | UI bank DID resolved from the dats (`0x2000004B`, content-verified: exactly the 32 `UI_*` slots) + 21-case environ table, 30 new Core tests; full Release suite 11,691 passed / 4 skipped. Retires TS-54; narrows AP-115 to notice-only. **Owed: connected gate** (`@environs` thunder + recall cues audible). **Suite note:** two load-dependent measurement flakes were observed on separate full-suite runs (`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, and one unnamed Core.Net test); both pass in isolation and neither touches audio. |
| A5 | **COMPLETE** 2026-08-08 | `e81bc474` | 46 ambient conformance tests; full Release suite 11,739 passed / 4 skipped. Opus review run and applied — it caught a FATAL frame bug (cell offsets built in absolute world coordinates while the listener is in the streamed frame: every contribution culled at ~32 km, feature silent with no error), a per-entry vs per-cell denominator error that would have pushed multi-entry beds under the audibility floor, an infinite loop on a zero play-rate, and newly-audible ambients not firing until a full period later. Also moved beds onto retail's single 16-voice priority pool and made the in-block direction test XY-only. Retires TS-29; files TS-66 (`seen_outside` interiors), TS-67 (in-plane weight). **Owed: user listening gate.** | | A5 | **COMPLETE** 2026-08-08 | `7c4dd1ad` | 46 ambient conformance tests; full Release suite 11,739 passed / 4 skipped. Opus review run and applied — it caught a FATAL frame bug (cell offsets built in absolute world coordinates while the listener is in the streamed frame: every contribution culled at ~32 km, feature silent with no error), a per-entry vs per-cell denominator error that would have pushed multi-entry beds under the audibility floor, an infinite loop on a zero play-rate, and newly-audible ambients not firing until a full period later. Also moved beds onto retail's single 16-voice priority pool and made the in-block direction test XY-only. Retires TS-29; files TS-66 (`seen_outside` interiors), TS-67 (in-plane weight). **Owed: user listening gate.** |
| A6 | — | — | — | | A6 | **COMPLETE** 2026-08-08 | `1b083463` | Full Release suite 11,739 passed / 4 skipped. Deleted the music API (`PlayMusic`/`StopMusic`/`MusicVolume` + the `AudioSettings.Music` knob); exposed the Ambient slider now that A5 drives it; reset the invented 0.8 ambient default to retail's 1.0; SUPERSEDED banner on `r05-audio-sound.md` listing its five wrong sections; TS-9 re-scoped to the measured one-wave blast radius. **Deferred:** #321's decode-dedup race (a pre-existing concurrency flake, not audio-parity behaviour — not fixed speculatively without reproducing it). |
---
## CAMPAIGN CLOSEOUT (code-complete 2026-08-08)
Six slices, six commits, `c69b3bde` → A6. The full Release suite ends at
**11,739 passed / 4 skipped / 0 failed**, up from 11,563 at campaign start;
the audio subsystem went from 42 tests (one of which pinned the wrong model)
to ~215 conformance tests written against byte-decoded values.
**What was wrong, and is now right:**
| Was | Now |
|---|---|
| Probability treated as a selection weight, and skipped entirely for the 4,183/4,184 single-entry sounds | Retail's uniform `(n1)` pick plus an independent Bernoulli silence gate |
| OpenAL 3-D spatialization, `2/d` falloff, no cutoff | Retail's CPU 2-D model: `25·vol/d²` past a 5 m knee, 50 dB no-allocate floor (~94 m), ±15 dB sine pan with a 5 m dead zone |
| Voices evicted by gain | Evicted by DAT-authored float priority, strictly-less, ring order |
| `0xF750` unparsed — every server cue silent | Parsed, guid-queued-and-replayed, played at the wire volume |
| No interface sound bank | Bank DID resolved from the dats' EnumIDMap chain; portal cues + 21 AdminEnvirons stingers live |
| No ambient system at all | Region-authored, per-land-cell, 3×3 ring, deadline-queue one-shots with terrain crossfade |
| A music API | Deleted — retail has no music system |
**Process notes worth carrying forward:**
1. **Binary Ninja could not be trusted anywhere in this subsystem.** Five
float compares render inverted or with elided constants; `GetAttenuation`
prints `* 0f`, which ports as silence at every distance. Every load-bearing
value here came from byte-decoding the PDB-paired binary. The lane notes
record the verified values; a future reader should decode rather than
re-read the pseudo-C.
2. **Two research notes were wrong and were corrected in place** — lane 1's
30 m decibel row (contradicted its own gain column) and lane 5's
transposed `GetByEnum` arguments (which would have made the UI bank
unresolvable). Both were caught by recomputing rather than copying.
3. **The reviews earned their cost.** A2's review caught a pan mapping that
saturated to full separation where retail gives 15 dB. A5's caught a
coordinate-frame error that would have made the entire ambient system
silent with nothing logged — the tests passed, the build was green, and it
would have failed only at the listening gate.
4. **An architecture guard caught a design error the tests could not**:
`ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks` rejected an
`Action<float>` frame hook and forced the typed `IAmbientFramePhase`.
**Still owed:** the user listening gate (A2 falloff, A4 cues, A5 ambients) and
the connected gates for A3/A4. Open rows: AP-173, AP-174, TS-64, TS-65, TS-66,
TS-67, TS-9 (re-scoped), #321.

View file

@ -1,5 +1,35 @@
# R5 — Audio System Deep Dive # R5 — Audio System Deep Dive
> **SUPERSEDED 2026-08-08 (Campaign A).** This document predates the named
> retail decomp and the byte-level verification pass; several of its
> load-bearing sections are wrong and were used to build code that had to be
> undone. Read the six lane notes instead —
> `docs/research/2026-08-08-audio-retail-*.md` — and
> `docs/plans/2026-08-08-audio-parity-campaign.md` for the summary.
>
> Specifically corrected:
>
> - **§5.1 falloff.** The `FUN_00550c30` reading is not retail's curve.
> `SoundManager::GetAttenuation` @ `0x00550020` is
> `g = dist < 5 ? vol : 25·vol/dist²`, clamped BEFORE one master multiply,
> then `ceil(20·log10 g)` with a hard 50 dB no-allocate floor (audible
> radius ≈94.2 m). Binary Ninja elides the x87 constants and prints `* 0f`;
> porting its rendering yields silence at every distance.
> - **§5.2 pan / §5.3 pool.** Pan is `(int)(15·sin Δbearing)`, truncating
> toward zero, dead centre inside 5 m, with no front/back and no elevation.
> The voice allocator is `PlaySoundInternal` @ `0x0054FEC0` and it evicts on
> DAT-authored float PRIORITY, never gain; `FUN_00550AD0` is a hash-table
> constructor.
> - **§4 selection.** Probability is a Bernoulli play/skip GATE at the play
> site, not a selection weight; selection is a uniform index over `(n1)`.
> - **§6 music.** There is no music system in retail EoR. The linked MIDI
> player has zero callers. The `PlayMusic` API this section inspired modelled
> dead code and was deleted at slice A6.
> - **§7 ambient.** Ambients are region-authored, per-LAND-CELL over a 3×3
> landblock ring, and play as re-fired one-shots off an absolute-deadline
> queue — not landblock-attached loops. Indoors is silent by design.
Ground-truth port plan for acdream's audio subsystem, derived from decompiled Ground-truth port plan for acdream's audio subsystem, derived from decompiled
`acclient.exe`, cross-referenced against ACE (server), ACViewer (dat loader), `acclient.exe`, cross-referenced against ACE (server), ACViewer (dat loader),
holtburger (client protocol), AC2D (C++ client), and DatReaderWriter (dat holtburger (client protocol), AC2D (C++ client), and DatReaderWriter (dat

View file

@ -138,7 +138,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
// ── Public volume knobs ────────────────────────────────────────────────── // ── Public volume knobs ──────────────────────────────────────────────────
public float MasterVolume { get; set; } = 1f; public float MasterVolume { get; set; } = 1f;
public float SfxVolume { get; set; } = 1f; public float SfxVolume { get; set; } = 1f;
public float MusicVolume { get; set; } = 0.7f;
public float AmbientVolume{ get; set; } = 0.8f; public float AmbientVolume{ get; set; } = 0.8f;
public bool IsAvailable => _available; public bool IsAvailable => _available;
@ -555,8 +554,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
/// </summary> /// </summary>
private float AmbientMaster => MasterVolume * AmbientVolume; private float AmbientMaster => MasterVolume * AmbientVolume;
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
public void StopMusic() { /* ditto */ }
// ── Private helpers ────────────────────────────────────────────────────── // ── Private helpers ──────────────────────────────────────────────────────

View file

@ -125,7 +125,6 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar
if (engine is not { IsAvailable: true }) if (engine is not { IsAvailable: true })
return; return;
engine.MasterVolume = audio.Master; engine.MasterVolume = audio.Master;
engine.MusicVolume = audio.Music;
engine.SfxVolume = audio.Sfx; engine.SfxVolume = audio.Sfx;
engine.AmbientVolume = audio.Ambient; engine.AmbientVolume = audio.Ambient;
} }

View file

@ -52,10 +52,17 @@ public sealed class WaveData
/// </summary> /// </summary>
public interface IAudioEngine : IDisposable public interface IAudioEngine : IDisposable
{ {
/// <summary>Set master volume [0..1].</summary> /// <summary>
/// acdream's own master, on top of retail's two live knobs. Retail has
/// three preferences — effect, ambient, and an interface one it registers
/// and never reads — and NO master and no music knob. See AP-174.
/// </summary>
float MasterVolume { get; set; } float MasterVolume { get; set; }
/// <summary>Retail's <c>effect_sound_volume</c>.</summary>
float SfxVolume { get; set; } float SfxVolume { get; set; }
float MusicVolume { get; set; }
/// <summary>Retail's <c>ambient_sound_volume</c> (applied twice — TS-65).</summary>
float AmbientVolume{ get; set; } float AmbientVolume{ get; set; }
/// <summary> /// <summary>
@ -86,7 +93,17 @@ public interface IAudioEngine : IDisposable
// up a source that was never created. `AmbientSoundController` + // up a source that was never created. `AmbientSoundController` +
// `AmbientSoundScheduler` carry the real model. // `AmbientSoundScheduler` carry the real model.
/// <summary>Start music (fades out previous if any).</summary> // A `PlayMusic(resourceName, loop)` / `StopMusic()` pair and a `MusicVolume`
void PlayMusic(string resourceName, bool loop); // knob lived here until 2026-08-08 (Campaign A slice A6). They modelled a
void StopMusic(); // subsystem retail does not have. The EoR client links a complete winmm
// midiStream player and never feeds it: `midiPlay` @ 0x00553390 has zero
// callers (its only textual occurrences in the 65 MB decomp are its own
// definition and its own queue drainer), both MIDI callbacks are statically
// null, the string "music" appears zero times, `SoundType` has no music
// member, `SoundManager::InitPrefs` registers no music preference, and the
// retail install ships no .mid/.mp3/.wav at all. The string-keyed signature
// was itself the tell — every other entry point in this interface is
// DID-keyed. What players remember as music is the AdminEnvirons UI stinger
// family (see `EnvironSoundCueMap`) and the intro AVI's audio track.
// Evidence: docs/research/2026-08-08-audio-retail-music-absence.md.
} }

View file

@ -1,10 +1,17 @@
namespace AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary> /// <summary>
/// Audio mixer preferences persisted to <c>settings.json</c>. Drives the /// Audio mixer preferences persisted to <c>settings.json</c>. The host wires
/// existing Phase E.2 OpenAL engine — the host wires these values into /// these into <c>OpenAlAudioEngine.MasterVolume</c> / <c>SfxVolume</c> /
/// <c>OpenAlAudioEngine.MasterVolume</c> / <c>SfxVolume</c> / /// <c>AmbientVolume</c> on Save and on startup.
/// <c>MusicVolume</c> / <c>AmbientVolume</c> on Save and on startup. ///
/// <para>
/// A <c>Music</c> field lived here until 2026-08-08 (Campaign A slice A6).
/// Retail has no music system to turn down — see the note on
/// <c>IAudioEngine</c> — so the knob moved nothing. Old <c>settings.json</c>
/// files carrying a <c>"music"</c> key still load: the reader simply ignores
/// keys it does not know, and the next save drops it.
/// </para>
/// ///
/// <para> /// <para>
/// Defaults match the engine's hard-coded starting values so a user /// Defaults match the engine's hard-coded starting values so a user
@ -14,15 +21,16 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
/// </summary> /// </summary>
public sealed record AudioSettings( public sealed record AudioSettings(
float Master, float Master,
float Music,
float Sfx, float Sfx,
float Ambient) float Ambient)
{ {
/// <summary>Values used on first launch. Mirror the engine's /// <summary>
/// constructor-default Volume properties.</summary> /// Values used on first launch. Retail's own defaults are 1.0 for every
/// sound preference (<c>SoundManager::InitPrefs</c> @ <c>0x005503F0</c>),
/// so ambient starts at unity rather than the invented 0.8.
/// </summary>
public static AudioSettings Default { get; } = new( public static AudioSettings Default { get; } = new(
Master: 1.0f, Master: 1.0f,
Music: 0.7f,
Sfx: 1.0f, Sfx: 1.0f,
Ambient: 0.8f); Ambient: 1.0f);
} }

View file

@ -253,15 +253,15 @@ public sealed class SettingsPanel : IPanel
} }
/// <summary> /// <summary>
/// Render the Audio tab — Master + SFX volume sliders (live preview /// Render the Audio tab — Master, SFX and Ambient volume sliders, all with
/// against the running OpenAL engine). Music + Ambient fields exist /// live preview against the running engine.
/// in <see cref="AudioSettings"/> and persist round-trip, but their ///
/// sliders are intentionally hidden here because the underlying /// <para>
/// engine paths (PlayMusic / StartAmbient) are stubbed for R5 MIDI /// Ambient was hidden until Campaign A slice A5 because nothing drove it;
/// playback that hasn't shipped yet — exposing the sliders would be /// the region ambient soundscape now does, so the knob moves something and
/// "moving a knob that does nothing." When R5 lands, restore the /// is exposed. The Music slider is gone entirely: retail has no music
/// hidden sliders below and the JSON-persisted values will already /// system, so there was never anything for it to turn down.
/// be in place. /// </para>
/// </summary> /// </summary>
private void RenderAudioTab(IPanelRenderer renderer) private void RenderAudioTab(IPanelRenderer renderer)
{ {
@ -275,16 +275,9 @@ public sealed class SettingsPanel : IPanel
if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f)) if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f))
_vm.SetAudio(a with { Sfx = sfx }); _vm.SetAudio(a with { Sfx = sfx });
// Music + Ambient hidden until R5 MIDI / ambient-loop engines float ambient = a.Ambient;
// exist. AudioSettings still carries the fields so the JSON if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f))
// round-trips and a future client doesn't drop them on save. _vm.SetAudio(a with { Ambient = ambient });
//
// float music = a.Music;
// if (renderer.SliderFloat("Music", ref music, 0f, 1f))
// _vm.SetAudio(a with { Music = music });
// float ambient = a.Ambient;
// if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f))
// _vm.SetAudio(a with { Ambient = ambient });
renderer.Spacing(); renderer.Spacing();
renderer.TextWrapped( renderer.TextWrapped(

View file

@ -108,7 +108,6 @@ public sealed class SettingsStore
var d = AudioSettings.Default; var d = AudioSettings.Default;
return new AudioSettings( return new AudioSettings(
Master: ReadFloat(audio, "master", d.Master), Master: ReadFloat(audio, "master", d.Master),
Music: ReadFloat(audio, "music", d.Music),
Sfx: ReadFloat(audio, "sfx", d.Sfx), Sfx: ReadFloat(audio, "sfx", d.Sfx),
Ambient: ReadFloat(audio, "ambient", d.Ambient)); Ambient: ReadFloat(audio, "ambient", d.Ambient));
} }
@ -588,7 +587,6 @@ public sealed class SettingsStore
{ {
["ambient"] = a.Ambient, ["ambient"] = a.Ambient,
["master"] = a.Master, ["master"] = a.Master,
["music"] = a.Music,
["sfx"] = a.Sfx, ["sfx"] = a.Sfx,
}; };

View file

@ -13,13 +13,13 @@ public sealed class AudioSettingsTests
[Fact] [Fact]
public void Default_values_match_engine_constructor_defaults() public void Default_values_match_engine_constructor_defaults()
{ {
// OpenAlAudioEngine ctor: Master=1.0, Music=0.7, Sfx=1.0, // Retail's SoundManager::InitPrefs @ 0x005503F0 defaults every sound
// Ambient=0.8 — see src/AcDream.App/Audio/OpenAlAudioEngine.cs. // preference to 1.0; acdream's extra master matches. The Music knob is
// gone (retail has no music system) and Ambient's invented 0.8 with it.
var d = AudioSettings.Default; var d = AudioSettings.Default;
Assert.Equal(1.0f, d.Master); Assert.Equal(1.0f, d.Master);
Assert.Equal(0.7f, d.Music);
Assert.Equal(1.0f, d.Sfx); Assert.Equal(1.0f, d.Sfx);
Assert.Equal(0.8f, d.Ambient); Assert.Equal(1.0f, d.Ambient);
} }
[Fact] [Fact]
@ -35,8 +35,8 @@ public sealed class AudioSettingsTests
[Fact] [Fact]
public void With_expression_clones_one_field() public void With_expression_clones_one_field()
{ {
var d = AudioSettings.Default with { Music = 0.25f }; var d = AudioSettings.Default with { Ambient = 0.25f };
Assert.Equal(0.25f, d.Music); Assert.Equal(0.25f, d.Ambient);
// Other fields untouched. // Other fields untouched.
Assert.Equal(AudioSettings.Default.Master, d.Master); Assert.Equal(AudioSettings.Default.Master, d.Master);
Assert.Equal(AudioSettings.Default.Sfx, d.Sfx); Assert.Equal(AudioSettings.Default.Sfx, d.Sfx);

View file

@ -349,11 +349,11 @@ public sealed class SettingsPanelTests
[Fact] [Fact]
public void Audio_tab_when_active_renders_implemented_volume_sliders() public void Audio_tab_when_active_renders_implemented_volume_sliders()
{ {
// L.0 ships Master + SFX only — Music + Ambient sliders are // The rule is "no slider that does nothing", and Campaign A changed
// hidden until R5 MIDI / ambient-loop engines exist. The // what qualifies. Ambient is now surfaced because slice A5 gave it a
// AudioSettings record still carries those fields so the // region ambient system to drive. Music is gone entirely — retail has
// JSON round-trips, but the panel doesn't surface a slider // no music system, so slice A6 deleted the field rather than leaving a
// that wouldn't actually do anything. // knob that could never do anything.
var (panel, _, _, _) = Build(); var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
@ -363,8 +363,8 @@ public sealed class SettingsPanelTests
.Select(c => (string)c.Args[0]!).ToList(); .Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Master", sliders); Assert.Contains("Master", sliders);
Assert.Contains("SFX", sliders); Assert.Contains("SFX", sliders);
Assert.Contains("Ambient", sliders);
Assert.DoesNotContain("Music", sliders); Assert.DoesNotContain("Music", sliders);
Assert.DoesNotContain("Ambient", sliders);
} }
[Fact] [Fact]

View file

@ -156,7 +156,7 @@ public sealed class SettingsStoreTests : System.IDisposable
public void SaveAudio_then_LoadAudio_round_trips_all_fields() public void SaveAudio_then_LoadAudio_round_trips_all_fields()
{ {
var store = new SettingsStore(_tempPath); var store = new SettingsStore(_tempPath);
var original = new AudioSettings(Master: 0.3f, Music: 0.45f, Sfx: 0.9f, Ambient: 0.6f); var original = new AudioSettings(Master: 0.3f, Sfx: 0.9f, Ambient: 0.6f);
store.SaveAudio(original); store.SaveAudio(original);
var loaded = store.LoadAudio(); var loaded = store.LoadAudio();
@ -178,7 +178,6 @@ public sealed class SettingsStoreTests : System.IDisposable
var loaded = store.LoadAudio(); var loaded = store.LoadAudio();
Assert.Equal(0.25f, loaded.Master); Assert.Equal(0.25f, loaded.Master);
Assert.Equal(AudioSettings.Default.Music, loaded.Music);
Assert.Equal(AudioSettings.Default.Sfx, loaded.Sfx); Assert.Equal(AudioSettings.Default.Sfx, loaded.Sfx);
Assert.Equal(AudioSettings.Default.Ambient, loaded.Ambient); Assert.Equal(AudioSettings.Default.Ambient, loaded.Ambient);
} }
@ -200,10 +199,10 @@ public sealed class SettingsStoreTests : System.IDisposable
{ {
// Reverse order — audio must survive a subsequent display save. // Reverse order — audio must survive a subsequent display save.
var store = new SettingsStore(_tempPath); var store = new SettingsStore(_tempPath);
store.SaveAudio(AudioSettings.Default with { Music = 0.1f }); store.SaveAudio(AudioSettings.Default with { Ambient = 0.1f });
store.SaveDisplay(DisplaySettings.Default with { ShowFps = true }); store.SaveDisplay(DisplaySettings.Default with { ShowFps = true });
Assert.Equal(0.1f, store.LoadAudio().Music); Assert.Equal(0.1f, store.LoadAudio().Ambient);
Assert.True(store.LoadDisplay().ShowFps); Assert.True(store.LoadDisplay().ShowFps);
} }

View file

@ -360,7 +360,7 @@ public sealed class SettingsVMTests
[Fact] [Fact]
public void AudioDraft_initial_value_matches_persisted() public void AudioDraft_initial_value_matches_persisted()
{ {
var custom = AudioSettings.Default with { Master = 0.3f, Music = 0.1f }; var custom = AudioSettings.Default with { Master = 0.3f, Ambient = 0.1f };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
Assert.Equal(custom, vm.AudioDraft); Assert.Equal(custom, vm.AudioDraft);
Assert.False(vm.HasUnsavedChanges); Assert.False(vm.HasUnsavedChanges);
@ -391,9 +391,9 @@ public sealed class SettingsVMTests
[Fact] [Fact]
public void Cancel_reverts_audio_draft_to_persisted() public void Cancel_reverts_audio_draft_to_persisted()
{ {
var custom = AudioSettings.Default with { Music = 0.2f }; var custom = AudioSettings.Default with { Ambient = 0.2f };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
vm.SetAudio(vm.AudioDraft with { Music = 0.9f, Master = 0.3f }); vm.SetAudio(vm.AudioDraft with { Ambient = 0.9f, Master = 0.3f });
Assert.True(vm.HasUnsavedChanges); Assert.True(vm.HasUnsavedChanges);
vm.Cancel(); vm.Cancel();