diff --git a/CLAUDE.md b/CLAUDE.md
index 23986101..b3859520 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1473,8 +1473,8 @@ already-running ACE session via the handshake race.
## Reference repos: cross-check the relevant ones
-The `references/` tree holds **six** vendored projects (ACE, ACViewer,
-WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in
+The `references/` tree holds **five** vendored projects (ACE, ACViewer,
+WorldBuilder, Chorizite.ACProtocol, holtburger). They overlap in
some areas and disagree in others. Before committing to an approach,
**cross-reference at least two of them** for the domain you're working
in — the per-domain hierarchy in the next section tells you which to
@@ -1483,7 +1483,7 @@ the relevant references is almost always the truth. The user has
repeatedly had to remind me about this when I narrowly searched one ref
and missed obvious answers in another.
-The six references:
+The five references:
- **`references/ACE/`** — ACEmulator server. Authority on the wire
protocol (packet framing, ISAAC, game message opcodes, serialization
@@ -1533,15 +1533,15 @@ The six references:
the message-builder layer. ACE shows what the server expects;
holtburger shows what a real client actually sends.
-- **`references/AC2D/`** — **C++ AC client emulator.** Oldest reference,
- fixed-function OpenGL, but has the **real AC terrain split formula**
- (`FSplitNESW` with constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`,
- `0x519B8F25`) which differs from WorldBuilder's physics-path formula.
- Also has the complete `0xF61C` movement packet format with flag bits
- and the `stMoveInfo` sequence counters. Key lesson from AC2D: it does
- NOT do client-side terrain Z — it sends movement keys to the server
- and uses the server's authoritative Z. See
- `docs/research/2026-04-12-movement-deep-dive.md` for the full analysis.
+**AC2D is a retired reference (2026-07-29).** It was a C++ AC client demo
+and the sixth entry in this list; it is no longer vendored under
+`references/` and must not be re-cloned. Everything we took from it is
+already written down and still stands: the terrain split formula
+`FSplitNESW` (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`,
+`0x519B8F25`), the `0xF61C` movement packet layout, and the finding that a
+client need not compute terrain Z itself. The historical analysis lives in
+`docs/research/2026-04-12-movement-deep-dive.md`; the UI dat-id work it fed
+is in `docs/research/retail-ui/`. Cite those, not the repo.
### Reference hierarchy by domain
@@ -1566,9 +1566,9 @@ decompiled client code and would have fixed it in minutes.
| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. |
| **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. |
| **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. |
-| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. |
-| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. |
-| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. |
+| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | `docs/research/named-retail/` | ACE shows the server side; holtburger shows the client side. AC2D was the second client-side cross-check here — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. |
+| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | `docs/research/named-retail/` | holtburger is the most complete. AC2D was the simpler confirmed-working cross-check — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. |
+| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | `docs/research/named-retail/` | AC2D `cNetwork.cpp:2592-2664` was the `0xF61C` format secondary — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`, which carries the packet layout and the terrain-split formula verbatim. |
| **Server expectations** (what ACE accepts/rejects, validation thresholds) | **ACE** `Source/ACE.Server/Network/` | — | Only ACE knows what the server actually validates. |
| **Silk.NET / .NET 10 idioms** (GL calls, shader setup, VAO patterns) | **WorldBuilder original** | ACME (same stack) | Both use the same backend; original has cleaner isolated examples. |
| **Protocol field order** (packed dwords, type prefixes, flag enums) | **Chorizite.ACProtocol** `Types/*.cs` | holtburger (cross-check) | Generated from protocol XML; has accurate field comments. |
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 502f9459..4e892324 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -1135,7 +1135,7 @@ For acdream-specific code (renderer, plugin API, streaming):
| Animation | `docs/research/named-retail/` + ACE Animation/ | — |
| Terrain | ACME ClientReference.cs | named retail / older decompiled chunks |
| Rendering | WorldBuilder (Silk.NET; its GL calls now read as RHI intent) | ACViewer |
-| Protocol | holtburger | AC2D |
+| Protocol | holtburger | `docs/research/named-retail/` (AC2D was the secondary until 2026-07-29 — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`) |
| Server behavior | ACE | — |
---
diff --git a/docs/plans/2026-07-29-post-vulkan-work-intake.md b/docs/plans/2026-07-29-post-vulkan-work-intake.md
index c83224ba..5ef9d752 100644
--- a/docs/plans/2026-07-29-post-vulkan-work-intake.md
+++ b/docs/plans/2026-07-29-post-vulkan-work-intake.md
@@ -20,6 +20,7 @@ once Campaign V closes.
| **VERIFY all enums** — **DONE 2026-07-29**, see [`docs/research/2026-07-29-enum-verification-campaign.md`](../research/2026-07-29-enum-verification-campaign.md) | The 2026-06-04 divergence note turned out to be missing from the repo entirely (open question 1 in the successor), so the sweep was regenerated from scratch. | Seven `Property*` tables created (864 members); `DamageType` and `ItemType` value bugs fixed; equipment/physics/item families closed against retail. Twelve open questions remain, headed by five empty `references/` repos. |
| **Verify networking stack** | `docs/research/2026-06-04-wire-message-catalog.md`: 256 opcodes catalogued, 114 parsed. | Audit parsed-vs-stub-vs-missing against the catalog; prioritize by what live servers actually send (Coldeve session logs are now a real corpus); holtburger remains the client-side oracle. |
| **Physics review — retail landing bounce et al.** | The named-retail decomp workflow; carried debt #116 (slide response), TS-50/51/53 in the divergence register. | A focused fidelity pass: start with the user's observed case — long-jump landing should bounce/recoil as retail does (grep `named-retail` for the landing/impact path in `MovementManager`/`SetMotion` land states before writing anything). Each fix follows grep→decompile→pseudocode→port→conformance. |
+| **Audio fidelity review** — user-sourced, 2026-07-29 | User reports sound "not working that good" generally. `AudioHookSink` + `OpenAlAudioEngine` are the current path; `SoundId` now carries retail's full 205-entry `SoundType` table (adopted 2026-07-29), so the catalog is no longer the gap. | Scope: sound *triggering*, *selection*, and *attenuation* versus retail — which hooks fire, how a SoundTable entry is chosen among its probability-weighted alternatives, and the falloff/pan curve. Separate from the `SoundId` catalog adoption, which was naming only. Oracles: named-retail's sound path, ACE, and the DAT SoundTable. |
## C. New feature bodies — milestone candidates, no existing spec
diff --git a/docs/research/2026-07-29-enum-verification-campaign.md b/docs/research/2026-07-29-enum-verification-campaign.md
index fdc0ecea..b05cd946 100644
--- a/docs/research/2026-07-29-enum-verification-campaign.md
+++ b/docs/research/2026-07-29-enum-verification-campaign.md
@@ -19,6 +19,10 @@ empty directories** (`references/*` is gitignored; only WorldBuilder is checked
in). The campaign therefore re-anchored on the oracles that *are* present, which
is no loss of rigour — CLAUDE.md ranks the first of them above ACE anyway:
+> This paragraph describes the environment the campaign *ran in*, later the same
+> day. It is no longer current: §7 records the restore, and AC2D was retired, so
+> CLAUDE.md now names five vendored repos rather than six.
+
| # | Oracle | What it is | Weight |
|---|---|---|---|
| 1 | `docs/research/named-retail/acclient.h` | The Sept 2013 EoR retail header, verbatim. 348 parseable `enum` blocks. | **Decides.** CLAUDE.md: "beats every other reference for what the real client does." |
@@ -201,11 +205,8 @@ CLAUDE.md these are recorded rather than guessed.
`PublicWeenieDescPackHeader` (34), which is the *pack-header* bitfield — a
different thing. `PublicWeenieDesc::BitfieldIndex` (31 members) is the likely
real counterpart. **Blocker:** pairing unconfirmed; not diffed.
-7. **`SoundId` is a curated 23-member local subset** of retail's `SoundType`
- (206), with acdream-local names (`FootstepGrass`, `BuffApplied`) that do not
- correspond 1:1 to retail members. **Blocker:** needs a decision — adopt
- retail's table wholesale, or record the subset as an intentional divergence
- with a register row. Neither was done.
+7. ~~**`SoundId` is a curated 23-member local subset**~~ **RESOLVED 2026-07-29**
+ by user decision: retail's table adopted wholesale. See §8.2.
8. **`ChatType` / `BlobType` / `DispatchType`** (TurbineChat) share no members
with retail's `eChatTypes` (26) or `ChatTypeEnum` (12); they describe chat
*channels*, not text-display types. **Blocker:** correct counterpart not
@@ -261,11 +262,19 @@ absolute paths into `references/`). The method, in order:
## 7. The reference tree, restored (2026-07-29)
-Open question 2 is closed. All six vendored repos were re-cloned from their
-upstreams into the main checkout at
-`C:\Users\erikn\source\repos\acdream\references\`, which every worktree shares.
-`references/*` is gitignored (only `WorldBuilder` is checked in as a gitlink), so
-none of this enters our history.
+Open question 2 is closed. The vendored repos were re-cloned from their upstreams
+into the main checkout at `C:\Users\erikn\source\repos\acdream\references\`, which
+every worktree shares. `references/*` is gitignored (only `WorldBuilder` is
+checked in as a gitlink), so none of this enters our history.
+
+**AC2D was cloned, then dropped by user decision the same day and deleted along
+with its directory.** It is a retired reference: do not re-clone it. What we took
+from it — the `FSplitNESW` terrain split constants, the `0xF61C` movement packet
+layout, and the finding that a client need not compute terrain Z itself — is
+already written down in `docs/research/2026-04-12-movement-deep-dive.md` and the
+`docs/research/retail-ui/` dat-id work, and those citations stand. CLAUDE.md's
+reference list and hierarchy table, and the architecture doc's protocol row, were
+updated to say so rather than to erase the history.
Each candidate URL was cross-checked against the description in CLAUDE.md and
`memory/reference_repos.md` before cloning, and the clone was verified to contain
@@ -277,7 +286,7 @@ the files those descriptions name.
| ACViewer | `https://github.com/ACEmulator/ACViewer.git` | `c5c54fb3` | 97 MB | cited by URL in `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md`; also the remote of `repos/ACViewer`. Cloned `--recurse-submodules`, so its `ACE.DatLoader` submodule (`382edb23`) is present as the docs describe |
| Chorizite.ACProtocol | `https://github.com/Chorizite/Chorizite.ACProtocol` | `ff7dffd6` | 4.5 MB | Chorizite org + exact repo name; contains the `Chorizite.ACProtocol` + `.SourceGen` projects CLAUDE.md describes as XML-generated |
| holtburger | `https://github.com/merklejerk/holtburger` | `a7e806cb` | 25 MB | named by URL in `docs/research/2026-05-10-holtburger-network-stack-study.md`; also the remote of `repos/holtburger` |
-| AC2D | `https://github.com/deregtd/AC2D` | `3451f0d2` | 17 MB | no URL was recorded anywhere in the repo, so the candidate was verified by content: it is C++, and it holds `cNetwork.cpp`, `cInterface.cpp`, `cPictureBox.h`/`cStaticText.h`/`cEditBox.h`, and `FSplitNESW` in `Landblocks.cpp` — every file the docs attribute to AC2D |
+| ~~AC2D~~ | ~~`https://github.com/deregtd/AC2D`~~ | ~~`3451f0d2`~~ | — | **Retired 2026-07-29, deleted.** No URL was recorded anywhere in the repo, so the candidate had been verified by content: C++, holding `cNetwork.cpp`, `cInterface.cpp`, `cPictureBox.h`/`cStaticText.h`/`cEditBox.h`, and `FSplitNESW` in `Landblocks.cpp` — every file the docs attributed to AC2D. Identification was sound; the reference is simply no longer kept |
| DatReaderWriter | `https://github.com/Chorizite/DatReaderWriter` | `c5359870` | 4.2 MB | cited by URL in `docs/research/2026-04-26-datreaderwriter-reference.md`. Not on the restore list, but it was empty for the same reason and is unambiguously identified |
---
@@ -329,3 +338,50 @@ naming a code does not make it render. The only site touched is
The enum also moved out of `MotionInterpreter.cs` into its own file at the same
namespace; at 372 members it no longer belongs inside a physics class file.
+
+### 8.2 `SoundId` — retail's full `SoundType` catalog
+
+Open question 7, closed by user decision. This one was not a subset; it was an
+invention. The 23 members were acdream-local names on acdream-local values, and
+the values were wrong in the way that matters: the old `FootstepDefault = 0x02`
+is retail's `Random`, `SwingSword = 0x10` is retail's `Death2`, `Death = 0x60` is
+retail's `Explode`. The header comment called it "a sparse subset of retail
+enums", which it never was.
+
+Nothing referenced any of the 23 by name — `grep` for `SoundId.` across `src/`
+and `tests/` returns nothing — so this was a trap rather than a live defect,
+exactly the shape the campaign found in `DamageType` (§2.1). All 22 invented
+names are deleted; retail's 205 replace them.
+
+Three oracles agree exactly, on every name and every value:
+
+| oracle | members | agreement |
+|---|---|---|
+| retail `acclient.h:4569 enum SoundType` | 205 named sounds | decides |
+| ACE `ACE.Entity.Enum.Sound` | 205 | identical names and values to retail |
+| DatReaderWriter `DatReaderWriter.Enums.Sound` | 205 | identical names and values to retail |
+
+The third is the interesting one. `AudioHookSink` already resolves SoundTable
+lookups through `DatReaderWriter.Enums.Sound`, so that enum is what acdream
+actually reads at runtime — the adoption makes our own catalog agree with the
+values already flowing through the dat path, and a conformance test pins the two
+together so they cannot drift.
+
+**On "206".** Retail's block holds 207 entries: 205 sounds, then
+`NUM_SOUND_TYPES = 0xCD` and `FORCE_SoundType_32_BIT`. The first is a count and
+the second a width pin. Counting `NUM_SOUND_TYPES` is where the 206 figure in
+open question 7 came from. Neither is a member here, matching how §3.2 treated
+`NUM_ATTACK_HEIGHTS` and `Num_HoldKeys` — a count is not a value the wire can
+carry.
+
+**Behaviour: unchanged**, for the same reason as §8.1 and more strongly — the
+enum had no consumers at all. `IAudioEngine`'s three `SoundId` overloads
+(`PlayUi`, `Play3D`, `StartAmbient`) are no-op stubs; the live audio path takes
+wave ids and `DatReaderWriter.Enums.Sound` values, not this type.
+
+The enum moved out of `AudioModel.cs` into its own file at the same namespace.
+
+**Follow-up filed.** The user reports sound "not working that good" generally.
+That is a triggering/selection/attenuation question, not a catalog question, and
+is now a Bucket B row in
+[`docs/plans/2026-07-29-post-vulkan-work-intake.md`](../plans/2026-07-29-post-vulkan-work-intake.md).
diff --git a/src/AcDream.Core/Audio/AudioModel.cs b/src/AcDream.Core/Audio/AudioModel.cs
index 527974be..a4072c2b 100644
--- a/src/AcDream.Core/Audio/AudioModel.cs
+++ b/src/AcDream.Core/Audio/AudioModel.cs
@@ -9,37 +9,9 @@ namespace AcDream.Core.Audio;
// Runtime backend (Silk.NET.OpenAL) lives in AcDream.App.
// ─────────────────────────────────────────────────────────────────────
-///
-/// Enumerated retail sound IDs. See r05 §5 for the full 204-entry list.
-/// Only the commonly-fired ones are given names here.
-///
-public enum SoundId : uint
-{
- None = 0,
- FootstepDefault = 0x02,
- FootstepGrass = 0x03,
- FootstepWater = 0x04,
- FootstepDirt = 0x05,
- FootstepStone = 0x06,
- FootstepWood = 0x07,
- SwingSword = 0x10,
- SwingAxe = 0x11,
- HitMetalOnMetal = 0x20,
- HitMetalOnLeather = 0x21,
- HitFlesh = 0x22,
- ArrowWhoosh = 0x30,
- ArrowThud = 0x31,
- SpellCastWar = 0x40,
- SpellCastLife = 0x41,
- SpellCastItem = 0x42,
- SpellCastCreature = 0x43,
- PortalWhoosh = 0x50,
- LifestoneTie = 0x51,
- Death = 0x60,
- PotionDrink = 0x70,
- BuffApplied = 0x80,
- // More constants populated during R5 audio port.
-}
+// SoundId moved to its own file on 2026-07-29, when the hand-curated
+// 23-member subset was replaced by retail's full 205-entry SoundType
+// catalog: src/AcDream.Core/Audio/SoundId.cs (same namespace).
///
/// Per-SoundId entry from the SoundTable dat (0x20000000..0x2000FFFF).
diff --git a/src/AcDream.Core/Audio/SoundId.cs b/src/AcDream.Core/Audio/SoundId.cs
new file mode 100644
index 00000000..e0255767
--- /dev/null
+++ b/src/AcDream.Core/Audio/SoundId.cs
@@ -0,0 +1,242 @@
+namespace AcDream.Core.Audio;
+
+///
+/// Retail's SoundType catalog — the sound *slot* an object's SoundTable
+/// is indexed by, not a wave id. All 205 named members, at retail values.
+///
+///
+/// 2026-07-29 full adoption. Until this slice the enum was a
+/// 23-member local invention: names like FootstepGrass and
+/// BuffApplied that retail does not have, on values that mean something
+/// else in retail (the old FootstepDefault = 0x02 is retail
+/// Random; SwingSword = 0x10 is retail Death2). Nothing
+/// referenced any of them by name, so they were a trap rather than a live
+/// defect — the same shape of trap the enum campaign found in
+/// DamageType. All 22 invented names are gone.
+///
+///
+///
+/// Three oracles agree exactly, on every name and every value: retail
+/// acclient.h:4569 enum SoundType, ACE ACE.Entity.Enum.Sound, and
+/// DatReaderWriter DatReaderWriter.Enums.Sound — which is the enum the
+/// dat-side hook path already flows through, so this table now agrees with the
+/// values acdream actually reads at runtime.
+///
+///
+///
+/// Retail's block holds two more entries after SkillDownVoid:
+/// NUM_SOUND_TYPES = 0xCD and FORCE_SoundType_32_BIT. The first
+/// is a count and the second is a width pin; neither is a sound, and neither is
+/// a member here, matching how the enum campaign treated
+/// NUM_ATTACK_HEIGHTS and Num_HoldKeys. Counting
+/// NUM_SOUND_TYPES is where the "206 retail sounds" figure comes from.
+///
+///
+public enum SoundId : uint
+{
+ Invalid = 0x00,
+ Speak1 = 0x01,
+ Random = 0x02,
+ Attack1 = 0x03,
+ Attack2 = 0x04,
+ Attack3 = 0x05,
+ SpecialAttack1 = 0x06,
+ SpecialAttack2 = 0x07,
+ SpecialAttack3 = 0x08,
+ Damage1 = 0x09,
+ Damage2 = 0x0A,
+ Damage3 = 0x0B,
+ Wound1 = 0x0C,
+ Wound2 = 0x0D,
+ Wound3 = 0x0E,
+ Death1 = 0x0F,
+ Death2 = 0x10,
+ Death3 = 0x11,
+ Grunt1 = 0x12,
+ Grunt2 = 0x13,
+ Grunt3 = 0x14,
+ Oh1 = 0x15,
+ Oh2 = 0x16,
+ Oh3 = 0x17,
+ Heave1 = 0x18,
+ Heave2 = 0x19,
+ Heave3 = 0x1A,
+ Knockdown1 = 0x1B,
+ Knockdown2 = 0x1C,
+ Knockdown3 = 0x1D,
+ Swoosh1 = 0x1E,
+ Swoosh2 = 0x1F,
+ Swoosh3 = 0x20,
+ Thump1 = 0x21,
+ Smash1 = 0x22,
+ Scratch1 = 0x23,
+ Spear = 0x24,
+ Sling = 0x25,
+ Dagger = 0x26,
+ ArrowWhiz1 = 0x27,
+ ArrowWhiz2 = 0x28,
+ CrossbowPull = 0x29,
+ CrossbowRelease = 0x2A,
+ BowPull = 0x2B,
+ BowRelease = 0x2C,
+ ThrownWeaponRelease1 = 0x2D,
+ ArrowLand = 0x2E,
+ Collision = 0x2F,
+ HitFlesh1 = 0x30,
+ HitLeather1 = 0x31,
+ HitChain1 = 0x32,
+ HitPlate1 = 0x33,
+ HitMissile1 = 0x34,
+ HitMissile2 = 0x35,
+ HitMissile3 = 0x36,
+ Footstep1 = 0x37,
+ Footstep2 = 0x38,
+ Walk1 = 0x39,
+ Dance1 = 0x3A,
+ Dance2 = 0x3B,
+ Dance3 = 0x3C,
+ Hidden1 = 0x3D,
+ Hidden2 = 0x3E,
+ Hidden3 = 0x3F,
+ Eat1 = 0x40,
+ Drink1 = 0x41,
+ Open = 0x42,
+ Close = 0x43,
+ OpenSlam = 0x44,
+ CloseSlam = 0x45,
+ Ambient1 = 0x46,
+ Ambient2 = 0x47,
+ Ambient3 = 0x48,
+ Ambient4 = 0x49,
+ Ambient5 = 0x4A,
+ Ambient6 = 0x4B,
+ Ambient7 = 0x4C,
+ Ambient8 = 0x4D,
+ Waterfall = 0x4E,
+ LogOut = 0x4F,
+ LogIn = 0x50,
+ LifestoneOn = 0x51,
+ AttribUp = 0x52,
+ AttribDown = 0x53,
+ SkillUp = 0x54,
+ SkillDown = 0x55,
+ HealthUp = 0x56,
+ HealthDown = 0x57,
+ ShieldUp = 0x58,
+ ShieldDown = 0x59,
+ EnchantUp = 0x5A,
+ EnchantDown = 0x5B,
+ VisionUp = 0x5C,
+ VisionDown = 0x5D,
+ Fizzle = 0x5E,
+ Launch = 0x5F,
+ Explode = 0x60,
+ TransUp = 0x61,
+ TransDown = 0x62,
+ BreatheFlaem = 0x63,
+ BreatheAcid = 0x64,
+ BreatheFrost = 0x65,
+ BreatheLightning = 0x66,
+ Create = 0x67,
+ Destroy = 0x68,
+ Lockpicking = 0x69,
+ UI_EnterPortal = 0x6A,
+ UI_ExitPortal = 0x6B,
+ UI_GeneralQuery = 0x6C,
+ UI_GeneralError = 0x6D,
+ UI_TransientMessage = 0x6E,
+ UI_IconPickUp = 0x6F,
+ UI_IconSuccessfulDrop = 0x70,
+ UI_IconInvalid_Drop = 0x71,
+ UI_ButtonPress = 0x72,
+ UI_GrabSlider = 0x73,
+ UI_ReleaseSlider = 0x74,
+ UI_NewTargetSelected = 0x75,
+ UI_Roar = 0x76,
+ UI_Bell = 0x77,
+ UI_Chant1 = 0x78,
+ UI_Chant2 = 0x79,
+ UI_DarkWhispers1 = 0x7A,
+ UI_DarkWhispers2 = 0x7B,
+ UI_DarkLaugh = 0x7C,
+ UI_DarkWind = 0x7D,
+ UI_DarkSpeech = 0x7E,
+ UI_Drums = 0x7F,
+ UI_GhostSpeak = 0x80,
+ UI_Breathing = 0x81,
+ UI_Howl = 0x82,
+ UI_LostSouls = 0x83,
+ UI_Squeal = 0x84,
+ UI_Thunder1 = 0x85,
+ UI_Thunder2 = 0x86,
+ UI_Thunder3 = 0x87,
+ UI_Thunder4 = 0x88,
+ UI_Thunder5 = 0x89,
+ UI_Thunder6 = 0x8A,
+ RaiseTrait = 0x8B,
+ WieldObject = 0x8C,
+ UnwieldObject = 0x8D,
+ ReceiveItem = 0x8E,
+ PickUpItem = 0x8F,
+ DropItem = 0x90,
+ ResistSpell = 0x91,
+ PicklockFail = 0x92,
+ LockSuccess = 0x93,
+ OpenFailDueToLock = 0x94,
+ TriggerActivated = 0x95,
+ SpellExpire = 0x96,
+ ItemManaDepleted = 0x97,
+ TriggerActivated1 = 0x98,
+ TriggerActivated2 = 0x99,
+ TriggerActivated3 = 0x9A,
+ TriggerActivated4 = 0x9B,
+ TriggerActivated5 = 0x9C,
+ TriggerActivated6 = 0x9D,
+ TriggerActivated7 = 0x9E,
+ TriggerActivated8 = 0x9F,
+ TriggerActivated9 = 0xA0,
+ TriggerActivated10 = 0xA1,
+ TriggerActivated11 = 0xA2,
+ TriggerActivated12 = 0xA3,
+ TriggerActivated13 = 0xA4,
+ TriggerActivated14 = 0xA5,
+ TriggerActivated15 = 0xA6,
+ TriggerActivated16 = 0xA7,
+ TriggerActivated17 = 0xA8,
+ TriggerActivated18 = 0xA9,
+ TriggerActivated19 = 0xAA,
+ TriggerActivated20 = 0xAB,
+ TriggerActivated21 = 0xAC,
+ TriggerActivated22 = 0xAD,
+ TriggerActivated23 = 0xAE,
+ TriggerActivated24 = 0xAF,
+ TriggerActivated25 = 0xB0,
+ TriggerActivated26 = 0xB1,
+ TriggerActivated27 = 0xB2,
+ TriggerActivated28 = 0xB3,
+ TriggerActivated29 = 0xB4,
+ TriggerActivated30 = 0xB5,
+ TriggerActivated31 = 0xB6,
+ TriggerActivated32 = 0xB7,
+ TriggerActivated33 = 0xB8,
+ TriggerActivated34 = 0xB9,
+ TriggerActivated35 = 0xBA,
+ TriggerActivated36 = 0xBB,
+ TriggerActivated37 = 0xBC,
+ TriggerActivated38 = 0xBD,
+ TriggerActivated39 = 0xBE,
+ TriggerActivated40 = 0xBF,
+ TriggerActivated41 = 0xC0,
+ TriggerActivated42 = 0xC1,
+ TriggerActivated43 = 0xC2,
+ TriggerActivated44 = 0xC3,
+ TriggerActivated45 = 0xC4,
+ TriggerActivated46 = 0xC5,
+ TriggerActivated47 = 0xC6,
+ TriggerActivated48 = 0xC7,
+ TriggerActivated49 = 0xC8,
+ TriggerActivated50 = 0xC9,
+ HealthDownVoid = 0xCA,
+ RegenDownVoid = 0xCB,
+ SkillDownVoid = 0xCC,
+}
diff --git a/tests/AcDream.Core.Tests/Audio/SoundIdConformanceTests.cs b/tests/AcDream.Core.Tests/Audio/SoundIdConformanceTests.cs
new file mode 100644
index 00000000..dcf25d4b
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Audio/SoundIdConformanceTests.cs
@@ -0,0 +1,272 @@
+//
+// Golden conformance table for SoundId. See
+// docs/research/2026-07-29-enum-verification-campaign.md §8.2.
+//
+using System;
+using System.Linq;
+using AcDream.Core.Audio;
+using Xunit;
+
+namespace AcDream.Core.Tests.Audio;
+
+///
+/// Pins every member of to its retail value. The table is
+/// transcribed from acclient.h:4569 enum SoundType and independently agrees
+/// with ACE Sound and DatReaderWriter Sound on every name and value.
+/// Retail's trailing NUM_SOUND_TYPES and FORCE_SoundType_32_BIT are a
+/// count and a width pin, not sounds, and are deliberately absent.
+///
+public sealed class SoundIdConformanceTests
+{
+ private static readonly (string Name, uint Value)[] Golden =
+ [
+ ("Invalid", 0x00),
+ ("Speak1", 0x01),
+ ("Random", 0x02),
+ ("Attack1", 0x03),
+ ("Attack2", 0x04),
+ ("Attack3", 0x05),
+ ("SpecialAttack1", 0x06),
+ ("SpecialAttack2", 0x07),
+ ("SpecialAttack3", 0x08),
+ ("Damage1", 0x09),
+ ("Damage2", 0x0A),
+ ("Damage3", 0x0B),
+ ("Wound1", 0x0C),
+ ("Wound2", 0x0D),
+ ("Wound3", 0x0E),
+ ("Death1", 0x0F),
+ ("Death2", 0x10),
+ ("Death3", 0x11),
+ ("Grunt1", 0x12),
+ ("Grunt2", 0x13),
+ ("Grunt3", 0x14),
+ ("Oh1", 0x15),
+ ("Oh2", 0x16),
+ ("Oh3", 0x17),
+ ("Heave1", 0x18),
+ ("Heave2", 0x19),
+ ("Heave3", 0x1A),
+ ("Knockdown1", 0x1B),
+ ("Knockdown2", 0x1C),
+ ("Knockdown3", 0x1D),
+ ("Swoosh1", 0x1E),
+ ("Swoosh2", 0x1F),
+ ("Swoosh3", 0x20),
+ ("Thump1", 0x21),
+ ("Smash1", 0x22),
+ ("Scratch1", 0x23),
+ ("Spear", 0x24),
+ ("Sling", 0x25),
+ ("Dagger", 0x26),
+ ("ArrowWhiz1", 0x27),
+ ("ArrowWhiz2", 0x28),
+ ("CrossbowPull", 0x29),
+ ("CrossbowRelease", 0x2A),
+ ("BowPull", 0x2B),
+ ("BowRelease", 0x2C),
+ ("ThrownWeaponRelease1", 0x2D),
+ ("ArrowLand", 0x2E),
+ ("Collision", 0x2F),
+ ("HitFlesh1", 0x30),
+ ("HitLeather1", 0x31),
+ ("HitChain1", 0x32),
+ ("HitPlate1", 0x33),
+ ("HitMissile1", 0x34),
+ ("HitMissile2", 0x35),
+ ("HitMissile3", 0x36),
+ ("Footstep1", 0x37),
+ ("Footstep2", 0x38),
+ ("Walk1", 0x39),
+ ("Dance1", 0x3A),
+ ("Dance2", 0x3B),
+ ("Dance3", 0x3C),
+ ("Hidden1", 0x3D),
+ ("Hidden2", 0x3E),
+ ("Hidden3", 0x3F),
+ ("Eat1", 0x40),
+ ("Drink1", 0x41),
+ ("Open", 0x42),
+ ("Close", 0x43),
+ ("OpenSlam", 0x44),
+ ("CloseSlam", 0x45),
+ ("Ambient1", 0x46),
+ ("Ambient2", 0x47),
+ ("Ambient3", 0x48),
+ ("Ambient4", 0x49),
+ ("Ambient5", 0x4A),
+ ("Ambient6", 0x4B),
+ ("Ambient7", 0x4C),
+ ("Ambient8", 0x4D),
+ ("Waterfall", 0x4E),
+ ("LogOut", 0x4F),
+ ("LogIn", 0x50),
+ ("LifestoneOn", 0x51),
+ ("AttribUp", 0x52),
+ ("AttribDown", 0x53),
+ ("SkillUp", 0x54),
+ ("SkillDown", 0x55),
+ ("HealthUp", 0x56),
+ ("HealthDown", 0x57),
+ ("ShieldUp", 0x58),
+ ("ShieldDown", 0x59),
+ ("EnchantUp", 0x5A),
+ ("EnchantDown", 0x5B),
+ ("VisionUp", 0x5C),
+ ("VisionDown", 0x5D),
+ ("Fizzle", 0x5E),
+ ("Launch", 0x5F),
+ ("Explode", 0x60),
+ ("TransUp", 0x61),
+ ("TransDown", 0x62),
+ ("BreatheFlaem", 0x63),
+ ("BreatheAcid", 0x64),
+ ("BreatheFrost", 0x65),
+ ("BreatheLightning", 0x66),
+ ("Create", 0x67),
+ ("Destroy", 0x68),
+ ("Lockpicking", 0x69),
+ ("UI_EnterPortal", 0x6A),
+ ("UI_ExitPortal", 0x6B),
+ ("UI_GeneralQuery", 0x6C),
+ ("UI_GeneralError", 0x6D),
+ ("UI_TransientMessage", 0x6E),
+ ("UI_IconPickUp", 0x6F),
+ ("UI_IconSuccessfulDrop", 0x70),
+ ("UI_IconInvalid_Drop", 0x71),
+ ("UI_ButtonPress", 0x72),
+ ("UI_GrabSlider", 0x73),
+ ("UI_ReleaseSlider", 0x74),
+ ("UI_NewTargetSelected", 0x75),
+ ("UI_Roar", 0x76),
+ ("UI_Bell", 0x77),
+ ("UI_Chant1", 0x78),
+ ("UI_Chant2", 0x79),
+ ("UI_DarkWhispers1", 0x7A),
+ ("UI_DarkWhispers2", 0x7B),
+ ("UI_DarkLaugh", 0x7C),
+ ("UI_DarkWind", 0x7D),
+ ("UI_DarkSpeech", 0x7E),
+ ("UI_Drums", 0x7F),
+ ("UI_GhostSpeak", 0x80),
+ ("UI_Breathing", 0x81),
+ ("UI_Howl", 0x82),
+ ("UI_LostSouls", 0x83),
+ ("UI_Squeal", 0x84),
+ ("UI_Thunder1", 0x85),
+ ("UI_Thunder2", 0x86),
+ ("UI_Thunder3", 0x87),
+ ("UI_Thunder4", 0x88),
+ ("UI_Thunder5", 0x89),
+ ("UI_Thunder6", 0x8A),
+ ("RaiseTrait", 0x8B),
+ ("WieldObject", 0x8C),
+ ("UnwieldObject", 0x8D),
+ ("ReceiveItem", 0x8E),
+ ("PickUpItem", 0x8F),
+ ("DropItem", 0x90),
+ ("ResistSpell", 0x91),
+ ("PicklockFail", 0x92),
+ ("LockSuccess", 0x93),
+ ("OpenFailDueToLock", 0x94),
+ ("TriggerActivated", 0x95),
+ ("SpellExpire", 0x96),
+ ("ItemManaDepleted", 0x97),
+ ("TriggerActivated1", 0x98),
+ ("TriggerActivated2", 0x99),
+ ("TriggerActivated3", 0x9A),
+ ("TriggerActivated4", 0x9B),
+ ("TriggerActivated5", 0x9C),
+ ("TriggerActivated6", 0x9D),
+ ("TriggerActivated7", 0x9E),
+ ("TriggerActivated8", 0x9F),
+ ("TriggerActivated9", 0xA0),
+ ("TriggerActivated10", 0xA1),
+ ("TriggerActivated11", 0xA2),
+ ("TriggerActivated12", 0xA3),
+ ("TriggerActivated13", 0xA4),
+ ("TriggerActivated14", 0xA5),
+ ("TriggerActivated15", 0xA6),
+ ("TriggerActivated16", 0xA7),
+ ("TriggerActivated17", 0xA8),
+ ("TriggerActivated18", 0xA9),
+ ("TriggerActivated19", 0xAA),
+ ("TriggerActivated20", 0xAB),
+ ("TriggerActivated21", 0xAC),
+ ("TriggerActivated22", 0xAD),
+ ("TriggerActivated23", 0xAE),
+ ("TriggerActivated24", 0xAF),
+ ("TriggerActivated25", 0xB0),
+ ("TriggerActivated26", 0xB1),
+ ("TriggerActivated27", 0xB2),
+ ("TriggerActivated28", 0xB3),
+ ("TriggerActivated29", 0xB4),
+ ("TriggerActivated30", 0xB5),
+ ("TriggerActivated31", 0xB6),
+ ("TriggerActivated32", 0xB7),
+ ("TriggerActivated33", 0xB8),
+ ("TriggerActivated34", 0xB9),
+ ("TriggerActivated35", 0xBA),
+ ("TriggerActivated36", 0xBB),
+ ("TriggerActivated37", 0xBC),
+ ("TriggerActivated38", 0xBD),
+ ("TriggerActivated39", 0xBE),
+ ("TriggerActivated40", 0xBF),
+ ("TriggerActivated41", 0xC0),
+ ("TriggerActivated42", 0xC1),
+ ("TriggerActivated43", 0xC2),
+ ("TriggerActivated44", 0xC3),
+ ("TriggerActivated45", 0xC4),
+ ("TriggerActivated46", 0xC5),
+ ("TriggerActivated47", 0xC6),
+ ("TriggerActivated48", 0xC7),
+ ("TriggerActivated49", 0xC8),
+ ("TriggerActivated50", 0xC9),
+ ("HealthDownVoid", 0xCA),
+ ("RegenDownVoid", 0xCB),
+ ("SkillDownVoid", 0xCC),
+ ];
+
+ [Fact]
+ public void EveryRetailSoundHasItsExactValue()
+ {
+ foreach (var (name, value) in Golden)
+ {
+ Assert.True(Enum.IsDefined(typeof(SoundId), name), $"SoundId.{name} is missing");
+ Assert.Equal(value, (uint)Enum.Parse(name));
+ }
+ }
+
+ [Fact]
+ public void DeclaresNothingRetailDoesNot()
+ {
+ var expected = Golden.Select(g => g.Name).OrderBy(n => n, StringComparer.Ordinal);
+ var actual = Enum.GetNames().OrderBy(n => n, StringComparer.Ordinal);
+ Assert.Equal(expected, actual);
+ }
+
+ /// Retail numbers the sounds densely from 0 to 0xCC, and
+ /// NUM_SOUND_TYPES is 0xCD. A gap here means a member was dropped.
+ [Fact]
+ public void CoversTheDenseRetailRangeWithNoGaps()
+ {
+ Assert.Equal(205, Enum.GetNames().Length);
+ var values = Enum.GetValues().Cast().OrderBy(v => v).ToArray();
+ Assert.Equal(Enumerable.Range(0, 205).Select(i => (uint)i), values);
+ Assert.Equal(0xCCu, values[^1]);
+ }
+
+ /// The dat-side hook path resolves sounds through DatReaderWriter's
+ /// own Sound enum. The two must not drift apart, or a SoundTable lookup
+ /// named through would fetch a different slot.
+ [Fact]
+ public void AgreesWithTheDatReaderEnumUsedAtRuntime()
+ {
+ foreach (var (name, value) in Golden)
+ {
+ Assert.True(Enum.IsDefined(typeof(DatReaderWriter.Enums.Sound), name),
+ $"DatReaderWriter.Enums.Sound.{name} is missing");
+ Assert.Equal(value, (uint)Enum.Parse(name));
+ }
+ }
+}