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)); + } + } +}