diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 1838976..5b82491 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -127,16 +127,66 @@ Plus polish that doesn't get its own phase number: --- -### Phase E — Long-tail +### Phase E — "Feel alive" — motion hooks + audio + VFX + +Unlocks the sensory layer: footsteps, swing whooshes, impact sparks, +spell auras. All three systems share animation-hook delivery as the +trigger source — motion hooks are the blocker. + +Research backing: `docs/research/deepdives/00-master-synthesis.md` + R3 + R4 + R5. + +- **E.1 — Motion-hook expansion.** Extend MotionInterpreter + AnimationSequencer to deliver all 27 AnimationHookType values (Sound=1, SoundTable=2, CreateParticle=18, AttackFrame=20, SoundTweaked=21, …) during frame advance. See `r03-motion-animation.md`. +- **E.2 — Audio engine.** `AudioEngine` via `Silk.NET.OpenAL`, inverse-square CPU falloff, 16-voice pool. SoundTable + Wave dat decoders. Motion-hook wiring for footsteps + attacks. See `r05-audio-sound.md`. +- **E.3 — Particle system.** `ParticleSystem` on Silk.NET GL (port WorldBuilder's `ParticleBatcher`). 13 motion-type integrators. `PhysicsScript` dispatcher tied to motion-hooks. See `r04-vfx-particles.md`. + +**Acceptance:** walk around, footstep sounds per material; swing a weapon, hear whoosh; combat impact shows sparks + hit sound. + +### Phase F — Fight + cast + gear + +Core gameplay loop on top of Phase E. + +Research: R1 + R2 + R6 + R8 + UI slices 04/05. + +- **F.1 — GameEvent envelope dispatcher.** 94 sub-opcodes in `0xF7B0`. Zero handled today. Start with `PlayerDescription (0x0013)` + property-update events. See `r08-network-protocol-atlas.md`. +- **F.2 — Item + inventory model.** `ItemInstance` / `Container` / `PropertyBundle`. Appraise round-trip (`0x00C8` → `0x00C9`). Burden math. See `r06-items-inventory.md` + `src/AcDream.Core/Items/`. +- **F.3 — Combat math + damage flow.** Damage formula, per-body-part AL, crit, hit-chance sigmoid. Server broadcasts damage events; client displays + HP bar. See `r02-combat-system.md` + `src/AcDream.Core/Combat/`. +- **F.4 — Spell cast state machine.** `SpellCastStateMachine` + active buff tracking. Buffs + recalls first, projectile spells later. Fizzle sigmoid + mana conversion. See `r01-spell-system.md` + `src/AcDream.Core/Spells/`. +- **F.5 — Core panels.** Attributes / Skills / Paperdoll / Inventory / Spellbook — using the retail-ui framework from Phase D.2. See `05-panels.md` under retail-ui. + +**Acceptance:** equip a weapon, swing at a monster, see damage numbers, buff yourself, recall to the lifestone. + +### Phase G — World systems + +Research: R9 + R12 + R13. + +- **G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. +- **G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. +- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. See `r09-dungeon-portal-space.md`. + +**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight. + +### Phase H — Social + progression + +Research: R7 + R10 + R11 + UI slice 05. + +- **H.1 — Chat window.** UI panel + all 6 wire opcodes (Channel, Tell, System, HearSpeech, HearRangedSpeech, TurbineChat). +- **H.2 — Allegiance.** Tree model + XP pass-up math + 5 allegiance chat channels + MOTD. See `r11-allegiance.md`. +- **H.3 — Emote scripts + quests + dialogs.** 122 EmoteType × 39 Trigger mini-VM. Contract tracker UI. NPC dialog rendered via chat with `` markup. See `r10-quest-dialogs.md`. +- **H.4 — Character creation.** `0xE000002 CharGen` dat + 13 heritages + templates + appearance picker + preview renderer. See `r07-character-creation.md`. + +**Acceptance:** create a character from scratch, talk to an NPC, get + complete a quest, gain XP that passes up to the patron. + +### Phase J — Long-tail (deferred / low-priority) Not detailed here; each gets its own brainstorm when it becomes relevant. -- **Dungeon landblocks** (`0xAAAA0000` family) + teleport-on-door-click + server-side portal handling -- **Phase 7.2 multi-floor stair walking** — cells reachable via portals the cell-walker doesn't cross -- **Player character full rig** (held weapons, spell effects, death/revive animation) -- **Weather + day/night cycle** -- **Spellcasting pipeline** -- **Group/fellowship UI** +- Player character full rig (held weapons, spell effects, death/revive animation) +- Group/fellowship UI +- Trade window (multi-player confirms) +- Salvage + tinker UI +- House ownership + hooks +- Society UI +- GM visible tools (we explicitly don't build GM powers; but DEV-mode tools like teleport anywhere are useful) --- @@ -173,16 +223,23 @@ Not detailed here; each gets its own brainstorm when it becomes relevant. | Holtburg sign half-buried | **5 FIXED** ✓ | | Can't walk past the loaded 3×3 window | **A.1 FIXED** ✓ (5×5 default, `ACDREAM_STREAM_RADIUS` to tune) | | Frame hitch crossing landblock boundary | **Phase A.3** (synchronous loader for now; async returns when DatCollection is thread-safe) | -| Walking around doesn't move me on the server | **Phase B (Gameplay)** | -| Can't talk to NPCs | **Phase B** | -| Can't open a door | **Phase B** | -| Portals render as a rotating black disk | **Phase C.1 (VFX)** | -| Chimneys have no smoke | **Phase C.1** | -| Houses have no fireplace fire | **Phase C.1** | -| No fireplace / torch lighting | **Phase C.2** | +| Walking around doesn't move me on the server | **Phase B.3 FIXED** ✓ | +| Can't talk to NPCs | **Phase H.3** (emote scripts + dialogs) | +| Can't open a door | **Phase F** (object-use action) | +| Portals render as a rotating black disk | **Phase E.3** (particle system) | +| Chimneys have no smoke | **Phase E.3** | +| Houses have no fireplace fire | **Phase E.3** | +| No fireplace / torch lighting | **Phase G.2** (dynamic lighting) | | Skin/hair color slightly off | **Phase C.3** | -| No chat window | **Phase D.2** | -| No sound | **Phase D.4** | -| Dungeons / foundry interior missing | **Phase E** | +| No chat window | **Phase H.1** | +| No sound | **Phase E.2** | +| Dungeons / foundry interior missing | **Phase G.3** | +| Can't fight monsters | **Phase F.3** (combat math + damage) | +| Can't cast spells | **Phase F.4** | +| No inventory panel | **Phase F.2 + F.5** | +| No character creation — must use ACE admin | **Phase H.4** | +| Sky is a flat color | **Phase G.1** (weather + day-night) | +| Can't join allegiance | **Phase H.2** | +| No quest tracker | **Phase H.3** | If you see something not on this list, add it here and assign a phase. diff --git a/docs/research/deepdives/00-master-synthesis.md b/docs/research/deepdives/00-master-synthesis.md new file mode 100644 index 0000000..f015eed --- /dev/null +++ b/docs/research/deepdives/00-master-synthesis.md @@ -0,0 +1,448 @@ +# acdream — Retail AC Deep-Dive Master Synthesis + +**Date:** 2026-04-17 +**Total research output:** ~78,000 words across 13 deep-dive slices +(plus the retail-UI set from earlier the same day — 30,000 words more). +Each slice is a self-contained 4-9 KLOC document of research + +pseudocode + C# port plan, with every claim cited to a decompiled +`FUN_` address or an ACE / DatReaderWriter / holtburger / ACViewer +reference. + +This document is the navigation hub and cross-reference map. It does +NOT repeat each slice's findings; it summarizes the headline, lists +cross-system dependencies, and proposes the concrete acdream phase +sequence that falls out of the research. + +--- + +## 1. Slice inventory + +| ID | Topic | File | Words | Key finding | +|-----|---------------------------------|--------------------------------------------------------------|-------|-------------| +| R1 | Spell system | [r01-spell-system.md](r01-spell-system.md) | 5.4K | Sigmoid `1/(1+e^(-0.07·(skill-difficulty)))` fizzle curve; 8 spellbook tabs at panel offset `0x638+0x1C·tab`; spell `0x004A`/`0x0048` wire; spell book unlock via `UpdateSpell (0x02C1)` | +| R2 | Combat | [r02-combat-system.md](r02-combat-system.md) | 5.9K | `PowerMod = Level + 0.5` melee; physical logistic at `k=0.03` / magic at `k=0.07`; 12-quadrant body table; 7 damage types × layered AL; 10%/5% base crit | +| R3 | Motion + animation | [r03-motion-animation.md](r03-motion-animation.md) | 8.2K | 450+ motion commands; cycle key `(style<<16)\|(cmd & 0xFFFFFF)`; 27 hook types; no crossfade — link animations ARE the transition; `-0.65` backward factor; `1.5×` run-turn | +| R4 | VFX / particles | [r04-vfx-particles.md](r04-vfx-particles.md) | 5.8K | 13 ParticleType motion integrators; no flipbook (chain hooks instead); PlayScript enum has 174 values; `PhysicsScriptTable` mod-selection; WorldBuilder's `ParticleBatcher` ports directly | +| R5 | Audio | [r05-audio-sound.md](r05-audio-sound.md) | 5.6K | DirectSound 8 + WinMM MIDI; 16-voice cap; inverse-square CPU falloff (no doppler/cone/HRTF); 204 Sound IDs; motion-driven triggers via animation hooks 1/2/21 | +| R6 | Items + inventory | [r06-items-inventory.md](r06-items-inventory.md) | 7.4K | ItemType is `[Flags]` 32-bit; 31 EquipMask slots; 7 property tables; `Appraise` C2S `0x00C8` / S2C `0x00C9`; burden = 150·STR + STR·bonusBurden; 2-deep pack depth | +| R7 | Character creation | [r07-character-creation.md](r07-character-creation.md) | 6.3K | Dat `0xE000002 CharGen`; 13 heritages × 330 attr credits × 52 skill credits (Olthoi 68); SpecializedCost is a DELTA; `0xF656` → `0xF643` wire round-trip | +| R8 | Network protocol atlas | [r08-network-protocol-atlas.md](r08-network-protocol-atlas.md) | 9.7K | 63 primary GameMessages + 149 GameActions + 94 GameEvents mapped. Of GameEvents (S→C, 0xF7B0 envelope), **zero handled** today — biggest gap | +| R9 | Dungeon + portal space | [r09-dungeon-portal-space.md](r09-dungeon-portal-space.md) | 6.3K | Dungeon detect = all heights 0 & NumCells>0 & no Buildings; `PlayerTeleport (0xF751)` needs LoginComplete re-send; no loading screen — "pink bubble" hides avatar | +| R10 | Quest + NPC dialogs | [r10-quest-dialogs.md](r10-quest-dialogs.md) | 7.1K | "Quests" are emote-script trees, not a first-class system; 122 EmoteType × 39 EmoteCategory; contract tracker via `0x0314/0x0315`; dialog = chat message with `` markup | +| R11 | Allegiance | [r11-allegiance.md](r11-allegiance.md) | 5.4K | Max 11 direct vassals; rank `max(lower+1, higher)` capped 10; XP passup `50+22.5·loyalty/291·(1+RT/730·IG/720)`; no swear cooldown (24h applies to name only) | +| R12 | Weather + day/night | [r12-weather-daynight.md](r12-weather-daynight.md) | 4.5K | 95% client-side — server just sends Portal Year time; determinism = same sky everywhere; no `SetWeather` opcode; sky is geometry not shader ramp | +| R13 | Dynamic lighting | [r13-dynamic-lighting.md](r13-dynamic-lighting.md) | 4.9K | Hard 8-light cap (fixed function D3D); NO distance attenuation inside Range then hard cutoff; terrain baked per-vertex via `AdjustPlanes`; blob shadow at `0x0600102F` | + +**Grand total: 82,500 words of grounded, citation-backed research.** + +--- + +## 2. The big picture — how the systems wire together + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ACE (Asheron's Call Emulator) │ +│ — server authority │ +└────┬────────────────────────────────────────────────────────────┘ + │ UDP packets (ISAAC, fragmented, ack'd) + │ +┌────▼────────────────────────────────────────────────────────────┐ +│ WorldSession (R8 atlas) │ +│ • Login (4 msg), CharList, EnterWorld, CreateObject stream │ +│ • Dispatch of 63 + 94 + 149 opcodes │ +└────┬──────────────┬──────────────┬──────────────┬───────────────┘ + │ │ │ │ + │CreateObject │UpdateMotion │UpdateHealth │UpdateSpell + │ items │ anim │ combat │ buff/debuff + ▼ ▼ ▼ ▼ +┌──────────┐ ┌───────────┐ ┌──────────┐ ┌───────────┐ +│ Inventory│ │ Motion │ │ Combat │ │ Spell │ +│ (R6) │ │ (R3) │ │ (R2) │ │ (R1) │ +│ │ │ │ │ │ │ │ +│ItemInst │──│ DoMotion │──│ Damage │──│ ActiveBuf │ +│Container │ │ MotionTbl │ │ fromCmd │ │ Fizzle │ +│Burden │ │ Hooks ────┼──┤ FootStep │ │ Recall │ +└────┬─────┘ └─┬────┬────┘ └────┬─────┘ └────┬──────┘ + │ │ │ │ │ + │ │ └────hooks──►│ │ + │ │ │ │ + └──────────┴───────►┌────────▼─────────────▼──┐ + │ Audio (R5) + │ + │ VFX/Particles (R4) │ + │ — cosmetic layer │ + └──────────────┬──────────┘ + │ + ▼ + ┌───────────────────────────────┐ + │ UI (retail-ui set) + HUD │ + │ AttrPanel + SpellBar + Chat │ + │ VitalOrbs + Radar + Compass │ + │ (all behind UiRoot scaffold) │ + └───────────────────────────────┘ + + ┌─ World lifecycle + │ +┌───▼─────────────────────┐ +│ Streaming (existing) │ +├─────────────────────────┤ +│ + Dungeon / portal (R9) │ +│ + Weather sky (R12) │ +│ + Dynamic lights (R13) │ +└─────────────────────────┘ + +┌─ Social / progression +│ +┌──────────────────┐ ┌─────────────────┐ +│ Allegiance (R11) │ │ Quest + dialog │ +│ • Tree + ranks │ │ (R10) │ +│ • XP passup │ │ • Emote scripts │ +│ • Chat channels │ │ • Contract tkr │ +└──────────────────┘ └─────────────────┘ + +┌─ Game entry +│ +┌──────────────────┐ +│ CharCreate (R7) │ +│ • Heritage + skl │ +│ • Appearance │ +│ • Starting town │ +└──────────────────┘ +``` + +--- + +## 3. Critical cross-system dependencies + +These are the places where one system MUST be built before another can +land cleanly. They define the port sequence. + +### 3.1 Motion (R3) blocks three other systems + +- **Audio (R5)** — footstep/swing/cast sounds are triggered by + AnimationHooks (types 1=Sound, 2=SoundTable, 21=SoundTweaked). Without + the hook dispatcher expanded (R3 port plan), audio has no trigger + source beyond server-pushed `PlaySound (0xF750)`. +- **VFX (R4)** — spell release, weapon trail, footstep dust attach at + specific animation hook frames (`CreateParticleHook`, hook type 18). +- **Combat (R2)** — the damage application frame is an `AttackFrame` + hook (type 20) inside the attack animation. Without R3 hook + dispatch, combat math has to guess timing. + +**Implication:** R3 motion-hook expansion is a blocker for R2, R4, R5 +to feel retail-faithful. It's a prerequisite for "the game feels +alive". + +### 3.2 Inventory (R6) blocks three UI panels + +- **Paperdoll** — drops items into slots; requires `ItemInstance` model. +- **Spellbook (R1)** — items like scarabs are consumed via R6 burden rules. +- **Quest turn-in (R10)** — emote's "Give" trigger needs item ID + + property bundle to validate. + +**Implication:** R6 is the data-model foundation for any gameplay UI. + +### 3.3 Network (R8) is the permanent bottleneck + +Of 94 GameEvents (0xF7B0 envelope), **zero are handled today**. These +include: +- `PlayerDescription (0x0013)` — full character at login +- `AllegianceInfoResponse (0x007E)` +- `Fellowship*` events (5 opcodes) +- `Identify/Appraise response (0x00C9)` +- `SendClientContractTracker (0x0315)` + +**Implication:** R8 Phase D (chat + session health) is the unlocking +step for the whole social/quest/inventory stack. + +### 3.4 CharCreate (R7) needs CharGen dat + at least one class + +The CharGen `0xE000002` dat has every heritage + template + appearance +config. But we also need: +- `ObjDesc` application pipeline (already in our StaticObjectManager) +- At least one working panel to host the UI (R7 uses our UiRoot scaffold) +- Appearance preview renderer — already works for outdoor characters + +**Implication:** R7 is the smallest unblocker for "user makes a char +in acdream instead of using ACE admin tools". + +### 3.5 Dungeon (R9) extends existing streaming + +R9's `EnvCellStreamer` + `PortalVisibility` + `TeleportController` are +additive to `StreamingController`. Risk is low. + +### 3.6 Weather (R12) and Lighting (R13) share a scene-state UBO + +Both write sun direction + ambient color + fog. A single +`SceneLightingState` UBO fed to `terrain.frag` and `mesh.frag` +consolidates them. R13 reads it; R12 produces it. + +### 3.7 VFX (R4) + Audio (R5) share PhysicsScript/PlayScript + +Retail's `PlayEffect (0xF755)` bundles both particle + sound. Our +`PhysicsScript` dispatcher should fire both R4 and R5 hooks atomically. + +--- + +## 4. Proposed phase sequence + +Based on the dependency graph + what unlocks the most gameplay fastest: + +### Phase E — Core gameplay loop (highest value) + +| Step | What | Research | Unblocks | +|------|--------------------------------------------------------------------|----------|----------| +| E.1 | R3 Motion hooks expansion | R3 | E.2-E.4 | +| E.2 | R5 Audio engine + motion-hook wiring | R5 | immediate feel | +| E.3 | R4 Particle system + motion-hook wiring + PlayScript dispatcher | R4 | immediate feel | +| E.4 | R2 Combat math + server-broadcast damage/health | R2, R8 | can actually fight | +| E.5 | R1 Spell cast state machine (buffs + recalls first, projectiles later) | R1, R8 | can cast spells | + +**Acceptance:** player swings a sword, hears the whoosh, sees the +contact spark, damage number floats up, HP bar drops on the target, +monster dies with footstep/impact sounds. Spell buff lands with +glowing aura + cast sound. + +### Phase F — Inventory + UI panels + +| Step | What | Research | Unblocks | +|------|--------------------------------------------------------------------|----------|----------| +| F.1 | R8 GameEvent envelope (0xF7B0) dispatcher | R8 | F.2+ | +| F.2 | R6 Item model (`ItemInstance`, `PropertyBundle`) | R6 | F.3-F.5 | +| F.3 | Attributes panel (UI slice 05 + R8 `PlayerDescription`) | UI05, R8 | char sheet | +| F.4 | Paperdoll + Inventory panel (drag-drop from UI slice 04) | R6, UI04 | gearing up | +| F.5 | Spellbook panel (R1 tabs + icons) | R1, UI05 | cast via UI | + +**Acceptance:** opens all core panels, drags items between inventory ++ paperdoll, casts spells by clicking icons. + +### Phase G — World systems + +| Step | What | Research | +|------|--------------------------------------------------------------------|----------| +| G.1 | R12 Sky / weather / day-night | R12 | +| G.2 | R13 Dynamic lighting (8-light fixed pipeline) | R13 | +| G.3 | R9 Dungeon streaming + portal transition | R9 | + +**Acceptance:** walk out of Holtburg at dusk, sky gradient rolls, +torches cast light, enter a dungeon via teleport, see the dungeon +interior. + +### Phase H — Social + progression + +| Step | What | Research | +|------|------------------------------------------------|----------| +| H.1 | Chat window (UI slice 05) + all 6 wire ops | UI05, R8 | +| H.2 | R11 Allegiance panel + XP passup display | R11 | +| H.3 | R10 Quest/emote/dialog system | R10 | +| H.4 | R7 Character creation panel | R7 | + +**Acceptance:** talk to an NPC, get a quest, complete it, gain XP, +allegiance MOTD shows in chat. + +--- + +## 5. What's already shipped vs what R-slices unlock + +| Domain | Shipped | Next from deep-dives | +|-------------------------|-----------------------------------------------------------|----------------------| +| World rendering | Terrain + static meshes + scenery + animation + chase cam | R12 sky / R13 lights | +| Streaming | 5×5 landblock window + EnvCell walker | R9 dungeons | +| Login + wire transport | LoginRequest → EnterWorld → CreateObject stream | R8 GameEvent envelope| +| Physics / collision | Retail-faithful BSP + cylinder + sphere-sweep + scenery | R3 motion hooks | +| Input / UI | UI framework (retail-ui set) + debug overlay | R4 VFX + R5 audio panels | +| Movement | WASD + chase cam + jump + autonomous-position sync | R3 motion commands | +| Character appearance | ObjDesc + creature palette + hair/skin/clothing | R7 char-create panel | +| **Everything else** | — | R1/R2/R6/R10/R11 | + +--- + +## 6. Consolidated dat-ID catalog (cross-slice) + +Every dat range touched by the 13 slices, de-duplicated: + +| Range | DBObjType | Source slices | +|-----------------------------|-------------------|---------------| +| `0x02xxxxxx` | Setup | (existing) | +| `0x04xxxxxx` | Palette | UI04 | +| `0x05xxxxxx` | PaletteSet | UI04 | +| `0x06000000..0x06FFFFFF` | RenderSurface | UI03/UI06, R13 blob shadow `0x0600102F` | +| `0x07xxxxxx` | RenderSurface | UI04 | +| `0x08xxxxxx` | Surface | UI04 | +| `0x09xxxxxx` | SurfaceTexture | (existing) | +| `0x0A000000..0x0A00FFFF` | Wave | **R5 audio** | +| `0x0E000002` | CharGen | **R7 chargen**| +| `0x0F000000..0x0FFFFFFF` | SurfaceMaterial | UI04 | +| `0x13000000` | Region | (existing) + R12 sky + R13 lights | +| `0x14xxxxxx` | MasterInputMap | UI06 | +| `0x20000000..0x2000FFFF` | SoundTable | **R5 audio** | +| `0x21xxxxxx` | LayoutDesc | UI06 | +| `0x23/0x24xxxxxx` | StringTable | UI06 | +| `0x2Fxxxxxx` | SpellTable | **R1 spell** | +| `0x30xxxxxx` | SpellComponents | **R1 spell** | +| `0x33xxxxxx` | MotionTable | (existing) + R3 | +| `0x34000000..0x34000FFF` | Animation | (existing) + R3 | +| `0x40000000..0x40000FFF` | Font | UI03 | +| `0x41xxxxxx` | LanguageInfo | UI06 | + +--- + +## 7. Consolidated opcode quick-reference (cross-slice) + +Every opcode mentioned in any slice, for rapid grep-back: + +``` +Login flow + 0x0001 LoginRequest + 0xF7B1 GameAction envelope (C→S) ← 149 sub-opcodes + 0xF7B0 GameEvent envelope (S→C) ← 94 sub-opcodes + 0xF657 CharacterList + 0xF656 Character_SendCharGenResult (R7) + 0xF643 CharGenResponseType / CharacterError (R7) + +Movement (B.3 already ported) + 0xF74C MoveToState / UpdatePosition (partial overlap) + 0xF753 Jump / bi-directional + 0xF748 UpdateMotion (R3) + 0xF751 PlayerTeleport (R9) + +Combat (R2) + 0x0008 AttackTargetRequest + 0x000A CombatStyleRequest + 0x0053 TargetAttack + 0x01B7 MissileAttack + 0xF7xx UpdateHealth / UpdateVital (R2, R6 vitals) + +Spells (R1) + 0x0048 Magic_CastUntargetedSpell (R1) + 0x004A Magic_CastTargetedSpell (R1) + 0x02C1 UpdateSpell (spellbook add) (R1) + 0x02C2-0x02C8 Enchantment lifecycle (R1) + 0x02BB HearSpeech (chatType 0x11 = syllables) (R1) + +Inventory / items (R6) + 0x00C8 AppraiseRequest (R6) + 0x00C9 IdentifyResponse (R6) + 0xF7xx InventoryMove / Give / Drop / Pickup + +Chat (UI05) + 0xF7B0 GameEventChannelBroadcast (0x0147 / envelope) + 0xF7B0 GameEventTell (0x02BD) + 0xF7E0 GameMessageSystemChat + 0x02BB GameMessageHearSpeech + 0x02BC GameMessageHearRangedSpeech + 0xF7DE GameMessageTurbineChat + 0x0295 GameEventSetTurbineChatChannels + +Allegiance (R11) + 0x001D Swear + 0x001E Break + 0x007E AllegianceInfoResponse + 0x00DD AllegianceMotd + 0x02AB AllegianceRecall action + +Quest / contracts (R10) + 0x0314 SendClientContractTrackerTable + 0x0315 SendClientContractTracker + 0xF751 TeleportToPoi (quest-driven recall) + +Audio (R5) + 0xF750 PlaySound + 0xF755 PlayEffect (R4 + R5 bundle) + +Dungeon / portal (R9) + 0xF751 PlayerTeleport (R9) + 0x00A1 LoginComplete (re-send after teleport) + +Attributes (UI05) + 0x02E3 PrivateUpdateAttribute + 0x02E7 PrivateUpdateVital + 0x02E9 PrivateUpdateAttribute2ndLevel + +Admin / world + 0xEA60 AdminEnvirons (colored-fog override) (R12) +``` + +**Full table in `r08-network-protocol-atlas.md`.** + +--- + +## 8. Open questions that surfaced + +1. **keystone.dll** — blocking for truly retail-faithful widget layout. + Would need a second decompile pass. (UI slice 02) +2. **Launcher / patcher** — similarly binary-only. (out of scope) +3. **Retail fizzle mana** — R1 flagged "5 points lost on fizzle" as + tentative; would benefit from retail packet capture. +4. **Power bar timing** — R2 notes the power bar fills over some + seconds, but exact seconds unclear in decompile. +5. **Doppler on / off** — R5 says off, but this is inferred from no + `SetFrequency` on 3D buffers. Verify with retail binary trace if + audio fidelity matters later. +6. **Sky replace animation** — R12 notes `SkyObjReplace` governs dawn + mesh swap but keyframe interp unclear. +7. **Contract tracker reward delivery** — R10 notes the + `SendClientContractTrackerTable` format but quest rewards come via + a separate emote path. +8. **Allegiance passup IG/RT decay** — R11 has the XP formula but the + "generation tax" (grandpatron vs direct patron) needs a golden-value + conformance test against ACE. + +--- + +## 9. Risk assessment for each slice's port + +Low (data models + straightforward translation): +- R6 Inventory (well-typed properties + containers) +- R7 CharCreate (one dat object, one wire roundtrip) +- R8 Protocol atlas (additive handlers; Phase-F work) +- R11 Allegiance (data + math, no renderer work) +- R12 Weather (deterministic from clock) + +Medium (has math or subtle corner cases): +- R1 Spell (sigmoid fizzle, mana conversion, enchant stacking) +- R2 Combat (damage formula sensitivity, crit tables, body-part) +- R3 Motion hooks (27 types, delivery during frame advance) +- R5 Audio (inverse-square falloff, 16-voice eviction) +- R9 Dungeon (portal visibility + teleport state machine) +- R13 Lighting (8-light selection + attenuation curve fidelity) + +High (novel systems for us): +- R4 VFX particle systems (new renderer subsystem) +- R10 Quest / emote scripts (mini-VM with 122 action types) + +--- + +## 10. Port sequencing recommendation + +If the user wants one-and-only-one focus per week: + +- **Week 1 — "Feel alive":** R3 motion hooks → R5 audio → R4 VFX. + Ship: footsteps, swing sounds, impact particles, spell aura particle. +- **Week 2 — "Fight":** R2 combat math + R8 GameEvent envelope + dispatcher (a few critical events). Ship: actual damage flow, HP + updates, death. +- **Week 3 — "Cast":** R1 spell cast state machine (buffs + recall + first, projectile spells later). Ship: heal spell works, buff spell + works, recall spell works. +- **Week 4 — "Have gear":** R6 inventory + Attributes + Paperdoll + panels. Ship: equip weapon, see stats, manage pack contents. +- **Week 5 — "World":** R12 sky + R13 lights + R9 dungeon. + Ship: enter a dungeon, see torch lights, day/night outside. +- **Week 6 — "Social":** Chat + R11 allegiance + R10 quest + + R7 char-create. Ship: complete the loop. + +If the user wants parallel work: R3/R5/R4 triad can be one stream; +R6/R8 infra can be a parallel stream; R12/R13 visual can be a third. + +--- + +## 11. Plugin API implications + +The deep-dives surfaced several places where acdream's plugin API +needs to expose state (R6 item, R1 spell, R11 allegiance, R10 quest). +These are all pure data + event streams, no 3D rendering hooks. The +current `IWorldGameState` + `IEvents` architecture scales to all of +them — each system adds a new read-only interface + an event stream. + +Deferred to a separate phase (plugin-api-expansion) once the core +systems land. diff --git a/docs/research/deepdives/r01-spell-system.md b/docs/research/deepdives/r01-spell-system.md new file mode 100644 index 0000000..87a59b0 --- /dev/null +++ b/docs/research/deepdives/r01-spell-system.md @@ -0,0 +1,1049 @@ +# R01 — Retail Spell-Casting System: Complete Deep-Dive + +**Scope:** the complete spell-casting system as it exists in the decompiled retail +`acclient.exe` (22,225 functions / 688K lines), cross-referenced against the +DatReaderWriter dat layout, ACE server implementation, Chorizite protocol +definitions, and holtburger TUI client. This doc is the primary reference for +any acdream work on magic. + +**Legend for citations:** + +- `chunk_NNNNNNNN.c:line` — decompiled retail `acclient.exe` +- `ACE: path/to/File.cs:line` — the `references/ACE` authority on server rules +- `DRW: path/to/File.cs` — `references/DatReaderWriter` authority on dat shape +- `CHZ: path/to/File.cs` — `references/Chorizite.ACProtocol` protocol XML + gen C# +- `HLT: path/to/File.rs` — `references/holtburger` client-side wire reference + +Everything below has been cross-checked against **at least two** of these +references; a single-source claim is flagged explicitly. + +--- + +## 1. Spell Data Model + +### 1.1 SpellTable (portal.dat 0x0E00000E) + +**On-disk record header** (DRW: +`DatReaderWriter/Generated/DBObjs/SpellTable.generated.cs`): + +``` +DBObjType = SpellTable (0x0E00000E) +HeaderFlags = HasId +Min/Max File ID = 0x0E00000E +``` + +**Body**: + +``` +Spells : PackableHashTable +SpellsSets: PHashTable +``` + +The `Spells` map is keyed by `SpellId` (uint, 1-based) with entries of type +`SpellBase`. There are ~3000+ live entries in end-of-retail data; void/quest +spells populate the 5000-7000 range. + +### 1.2 SpellBase (per-spell record) + +Ordering and types cross-checked against DRW: +`DatReaderWriter/Types/SpellBase.cs:204` (Unpack) and ACE: +`ACE.DatLoader/Entity/SpellBase.cs:58`. The retail client builds the identical +record in `chunk_00590000.c:6594-6620` — note the `uVar5 % 0xbeadcf45 + uVar4 % +0x12107680` line at `chunk_00590000.c:6614`, which is the exact decryption-key +formula for the packed component IDs. + +| Offset (rel.) | Type | Field | Notes | +|---------------|------|-------|-------| +| — | ObfuscatedPString | **Name** | Hashed key input (nameHash) | +| — | ObfuscatedPString | **Description** | Hashed key input (descHash) | +| +0 | int32 | **School** | `MagicSchool` enum | +| +4 | uint32 | **Icon** | Portal dat render-surface DID | +| +8 | uint32 | **Category** | `SpellCategory` enum (1-800+) | +| +12 | int32 | **Bitfield** | `SpellIndex` / `SpellFlags` bitfield | +| +16 | uint32 | **BaseMana** | Base mana cost before ManaConversion | +| +20 | float | **BaseRangeConstant** | Minimum range (meters) | +| +24 | float | **BaseRangeMod** | Range per point of magic skill | +| +28 | uint32 | **Power** | Difficulty (also used for spell Level calc) | +| +32 | float | **SpellEconomyMod** | Legacy, unused by retail on end-of-retail | +| +36 | uint32 | **FormulaVersion** | Seen as 0, 1, 2 | +| +40 | float | **ComponentLoss** | Base burn rate (0.0-1.0) | +| +44 | uint32 | **MetaSpellType** | `SpellType` enum (Enchantment/Projectile/etc) | +| +48 | uint32 | **MetaSpellId** | Almost always re-copies spell id | +| +52 | double | **Duration** | If Enchantment or FellowEnchantment | +| +60 | float | **DegradeModifier** | Enchantment degrade | +| +64 | float | **DegradeLimit** | Enchantment degrade | +| +52 | double | **PortalLifetime** | Overlays Duration when MetaSpellType = PortalSummon | +| — | uint32[8] | **Components (encrypted)** | XOR-subtracted by hash key | +| — | uint32 | **CasterEffect** | `PlayScript` — caster VFX | +| — | uint32 | **TargetEffect** | `PlayScript` — target VFX | +| — | uint32 | **FizzleEffect** | Always 0; retail uses fixed fizzle playscript | +| — | double | **RecoveryInterval** | Always 0 in retail data | +| — | float | **RecoveryAmount** | Always 0 in retail data | +| — | uint32 | **DisplayOrder** | Spellbook UI ordering | +| — | uint32 | **NonComponentTargetType** | `ItemType` enum — target kind for item spells | +| — | uint32 | **ManaMod** | Extra mana per target (fellowship / bane spells) | + +**Component decryption** (DRW: `SpellBase.cs:154`, matches +`chunk_00590000.c:6614`): + +``` +key = (StringHash(Name) % 0x12107680) + (StringHash(Description) % 0xBEADCF45) +comps[i] = (encrypted[i] - key); if (comps[i] > 198) comps[i] &= 0xFF +``` + +`StringHash()` is the Pascal-style 4-bit rotating hash of the CP1252-encoded +string. The "> 198 → mask" correction exists because the highest valid +component ID is 198 ("Essence of Kemeroi"), and extended characters perturb the +hash. + +### 1.3 SpellComponentTable (portal.dat 0x0E00000F) + +DRW: `Generated/DBObjs/SpellComponentTable.generated.cs` + +`Generated/Types/SpellComponentBase.generated.cs`. ACE: +`ACE.DatLoader/FileTypes/SpellComponentsTable.cs:27`. + +Body is `PackableHashTable`. + +**SpellComponentBase:** + +| Field | Type | Notes | +|-------|------|-------| +| Name | ObfuscatedPString | "Lead Scarab", "Ashes of Frost", ... | +| Category | uint32 | Loose grouping | +| Icon | QualifiedDataId | Icon DID | +| Type | uint32 | `ComponentType` enum (see 1.4) | +| Gesture | uint32 | `MotionCommand` for windup/cast gesture | +| Time | float | Cast-time contribution | +| Text | ObfuscatedPString | Spoken syllable for this component | +| CDM | float | Component Destruction Modifier — multiplier for burn | + +### 1.4 ComponentType (DRW `Generated/Enums/ComponentType.generated.cs`) + +``` +Undef=0, Scarab=1, Herb=2, Powder=3, Potion=4, Talisman=5, +Taper=6, PotionPea=7, TalismanPea=5, TaperPea=7 +``` + +### 1.5 MagicSchool (DRW `Generated/Enums/MagicSchool.generated.cs`) + +``` +None=0, WarMagic=1, LifeMagic=2, ItemEnchantment=3, +CreatureEnchantment=4, VoidMagic=5 +``` + +### 1.6 SpellType / MetaSpellType (DRW `Generated/Enums/SpellType.generated.cs`) + +``` +Undef=0, Enchantment=1, Projectile=2, Boost=3, Transfer=4, +PortalLink=5, PortalRecall=6, PortalSummon=7, PortalSending=8, +Dispel=9, LifeProjectile=10, FellowBoost=11, FellowEnchantment=12, +FellowPortalSending=13, FellowDispel=14, EnchantmentProjectile=15 +``` + +### 1.7 SpellFlags / SpellIndex (DRW `Generated/Enums/SpellIndex.generated.cs`, +ACE `ACE.Entity/Enum/SpellFlags.cs`) + +``` +Resistable = 0x00001 +PKSensitive = 0x00002 +Beneficial = 0x00004 +SelfTargeted = 0x00008 +Reversed = 0x00010 // animation plays reversed (buffs) +NotIndoor = 0x00020 +NotOutdoor = 0x00040 +NotResearchable = 0x00080 +Projectile = 0x00100 +CreatureSpell = 0x00200 // monster-cast only +ExcludedFromItemDescriptions = 0x00400 +IgnoresManaConversion = 0x00800 +NonTrackingProjectile = 0x01000 +FellowshipSpell = 0x02000 +FastCast = 0x04000 // no windup motions +IndoorLongRange = 0x08000 +DamageOverTime = 0x10000 +UNKNOWN = 0x20000 // seen set on a handful of spells +``` + +### 1.8 SpellCategory + +249+ values (CHZ: `protocol.xml` `` lines 1-800+). +Values are tightly grouped by purpose: + +| Range | Meaning | +|-------|---------| +| 1-40 | Attribute + weapon-skill raising/lowering | +| 41-68 | Magic-skill raising/lowering (defense, schools) | +| 69-100 | Utility skills (healing, run, loyalty, leadership, vitae) | +| 101-114 | Elemental protection / vulnerability | +| 115-151 | Projectile families (Missile, Seeker, Burst, Blast, Scatter by 7 damage types) | +| 152-199 | Damage / attack / defense modifier enchantments | +| 200-221 | Portal / crafting skills / vitae | +| 222-250+ | Ring / Wall / Strike / Streak / Volley projectile families | + +Categories are used as a **stacking rule key**: two enchantments sharing a +category will not both be active — the higher-power one wins +(`ACE.Server/Managers/EnchantmentManager`). + +### 1.9 SpellTargetType — not a standalone enum + +Retail doesn't have an explicit `SpellTargetType` enum; target validity is +derived from two fields: + +- `Flags.SelfTargeted` — must target caster +- `Flags.FellowshipSpell` (or MetaSpellType in `FellowBoost..FellowDispel`) — + targets all fellows +- `School == ItemEnchantment` → `NonComponentTargetType` gates which item + classes it can be cast on +- Otherwise (LifeMagic / CreatureEnchantment / WarMagic / VoidMagic) → target + must be a `Creature` (see ACE `IsInvalidTarget` below) + +### 1.10 SpellSet (portal.dat, inside SpellTable) + +DRW: `Generated/Types/SpellSet.generated.cs`, +`Generated/Types/SpellSetTiers.generated.cs`. Armor and weapon "sets" (Tusker, +Nariyid, etc.) define a list of spells that activate as more set pieces get +equipped. Keyed by `EquipmentSet` enum. + +--- + +## 2. Cast Validation — The Decision Tree + +Full flow, starting from when the server receives `Magic_CastTargetedSpell` +(opcode 0x004A) or `Magic_CastUntargetedSpell` (0x0048): + +``` +[incoming wire] + │ + ▼ +HandleActionCastTargetedSpell (ACE: Player_Magic.cs:80) +HandleActionMagicCastUnTargetedSpell (Player_Magic.cs:271) + │ + ▼ (checks in order) + 1. CombatMode != Magic → SendUseDone; bail + 2. Physics stance != Magic (FastTick only) → SendUseDone(YoureTooBusy); bail + 3. IsJumping → SendUseDone(YouCantDoThatWhileInTheAir) + 4. PKLogout → SendUseDone(YouHaveBeenInPKBattleTooRecently) + 5. IsBusy && MagicState.CanQueue → queue cast; return (no error) + 6. VerifyBusy() (IsBusy||Teleporting||dead) → SendUseDone(YoureTooBusy); bail + 7. VerifySpell(spellId, casterItem) + - casterItem != null → item.SpellDID == spellId? (0x03FC MagicInvalidSpellType) + - else → SpellIsKnown(spellId)? + 8. (targeted) GetTargetCategory → target, teleporting? (0x0403 TargetNotAcquired) + 9. MagicState.OnCastStart() + snapshot StartPos +10. TurnTo_Magic(target) — rotate in place to face +11. (after rotate/turn) DoWindup → CreatePlayerSpell + │ + ▼ + ValidateSpell(spellId, isWeaponSpell) (Player_Magic.cs:386) + a. new Spell(spellId) — load SpellBase from dat + server DB row + b. spell.NotFound? → YouDontKnowThatSpell or MagicInvalidSpellType + c. !isWeaponSpell && !HasComponentsForSpell(spell) + → YouDontHaveAllTheComponents (0x0400) +12. VerifySpellTarget → IsInvalidTarget(spell, target) (see 2.1) +13. VerifySpellRange → range + NotIndoor/NotOutdoor (see 2.2) +14. GetCastingPreCheckStatus → skill roll (see 2.3) +15. CalculateManaUsage → skill-modulated cost + currentMana check + if (manaUsed > mana) → YouDontHaveEnoughManaToCast (0x0401) +16. Play windup gestures (ACE: DoWindupGestures), spell words + (GameMessageHearSpeech, channel = Spellcasting = 0x11) +17. Cast gesture → DoCastSpell_Inner + a. Consume mana (UpdateVitalDelta) + b. TryBurnComponents(spell) (see 2.4) + c. windup move > 6.0m && PK? → SendSystemChat + fizzle + d. CheckPKStatusVsTarget → maybe InvalidPKStatus + e. switch(castingPreCheckStatus): + Success → CreatePlayerSpell(target, spell, ...) (per-school effect) + InvalidPKStatus → if Projectile, launch anyway (for harmless VFX) + default (Failed) → GameMessageScript(Guid, PlayScript.Fizzle, 0.5f) + + SendWeenieError(YourSpellFizzled) (0x0402) +18. FinishCast() — recovery motion + optional queue-to-next +``` + +### 2.1 IsInvalidTarget (ACE `Player_Magic.cs:427`) + +Returns true (invalid) if any of: + +- `!target.IsEnchantable` +- `spell.SelfTargeted && target != caster` +- `spell.School != ItemEnchantment && targetCreature == null` (War/Life/Creature need living target) +- `targetCreature != null && !(target is Player) && spell.IsBeneficial` (can't buff non-player creatures) +- Spell is negative + target is an item wielded by a player the caster can't PK +- Spell is ItemEnchantment and fails `VerifyNonComponentTargetType` +- Target == caster and spell is negative-redirectable (brittlemail / lure) +- Target is a creature caster "can't damage" (shared-group PKs, etc.) + +### 2.2 Range + indoor/outdoor (ACE `Player_Magic.cs:481`) + +``` +maxRange = min(spell.BaseRangeConstant + magicSkill * spell.BaseRangeMod, + MaxRadarRange_Outdoors) + +if (distanceTo > maxRange) → MissileOutOfRange +if (spell.NotIndoor && (caster.indoors || target.indoors)) → YourSpellCannotBeCastInside (0x0408) +if (spell.NotOutdoor && (caster.outdoors || target.outdoors)) → YourSpellCannotBeCastOutside (0x0407) +``` + +When the caller has no item caster, `magicSkill` for range is **init-level + +ranks** (not the full buffed+augmented skill), matching retail +`DetermineSpellRange` (noted in comment at `Player_Magic.cs:494`). + +### 2.3 Skill / fizzle formula (ACE `Player_Magic.cs:528`, `SkillCheck.cs:19`) + +``` +difficulty = spell.Power // not spell.PowerMod — that's mana-only + +// gating: below difficulty - 50, no roll happens at all +if (magicSkill == 0 || magicSkill < difficulty - 50) + → CastFailed + +// the roll: +chance = 1 / (1 + exp(-0.07 * (magicSkill - difficulty))) // sigmoid, factor=0.07 +rng = random(0.0, 1.0) +status = (chance > rng) ? Success : CastFailed +``` + +Weapon-built-in spells override to `Success` — they cannot fizzle +(`Player_Magic.cs:543`). + +**War↔Void "blood interference"** (`Player_Magic.cs:547`): if caster casts War +within 3-5 s of a successful Void (or vice-versa), the cast is forced to +`CastFailed` with the chat line: `"The energies permeating your blood +cause this magic to fail."` + +### 2.4 Component consumption (ACE `Spell.cs:157`) + +``` +baseRate = spell.ComponentLoss +magicSkill = spell.GetMagicSkill() → player.GetCreatureSkill(...) +skillMod = min(1.0, spell.Power / playerSkill.Current) + +for each component in spell.Formula.CurrentFormula: + burnRate = baseRate * component.CDM * skillMod + if random(0..1) < burnRate: + consumed.Add(component) + +// per-component loop: TryConsumeFromInventoryWithNetworking(item, 1) +// final: GameMessageSystemChat("The spell consumed the following components: ...", Magic) +``` + +Note that components are burned **both on success and on fizzle** — ACE's +`DoCastSpell_Inner` calls `TryBurnComponents` unconditionally before the +switch-on-status. (`Player_Magic.cs:867-868`.) + +### 2.5 Mana calculation (ACE `Creature_Magic.cs:14, GetManaCost:55`) + +``` +baseCost = spell.BaseMana (or castItem.ItemManaCost if casting item's built-in) + +// per-target modifier +if (ItemEnchantment + Enchantment + Category in ArmorValueRaising..AcidicResistanceLowering + && target is Player): + baseCost += spell.ManaMod * numEnchantableEquipped +else if (spell.IsFellowshipSpell): + baseCost += spell.ManaMod * numFellows + +// ManaConversion skill reduction — 2 rolls, additive +if (!IgnoresManaConversion && ManaConversion >= Trained): + difficulty = spell.PowerMod (modified power, not raw) + manaConv = round(ManaConversion.Current * GetWeaponManaConversionModifier) + successChance = SkillCheck.GetSkillChance(manaConv, difficulty/2) + roll = random(0..1); luck = random(0..1) + if (roll < successChance): + cost *= (1.0 - (successChance - roll*luck)) + + // second roll at full difficulty if cost still > 1 + if (cost > 1): + successChance = SkillCheck.GetSkillChance(manaConv, difficulty) + if (random(0..1) < successChance): + cost *= (1.0 - (successChance - roll*luck)) + +return max(cost, 1) +``` + +On `CastFailed` (fizzle) the cost is **hard-coded to 5 mana** (`Player_Magic.cs:572`, +commented "todo: verify with retail"). This is the one place we should +double-check a retail pcap. + +--- + +## 3. Cast Sequence — From Click to Resolution + +### 3.1 Client side (decompiled) + +The spell UI click handler is `FUN_004c7620` at `chunk_004C0000.c:5018-5238`. +Field offsets on the spellbook panel (`param_1`) are: + +| Offset | Purpose | +|--------|---------| +| +0x604 | GUI widget host pointer | +| +0x61C | "has selected item" flag byte | +| +0x620 | Selected item-based-spell object handle | +| +0x624 | Selected item-based spell id | +| +0x634 + tabIdx*0x1C | Tab N: currently-highlighted spell id | +| +0x638 | Life tab list head | +| +0x654 | Creature tab list head | +| +0x670 | Item tab list head | +| +0x68C | War tab list head | +| +0x6A8 | Portal tab list head | +| +0x6C4 | Void tab list head | +| +0x6E0 | Favorites tab list head | +| +0x6FC | Combined/search tab list head | + +Each tab is 0x1C bytes. The pattern +0x10 on the tab-head stores the current +spell count. + +The flow on click: + +1. `FUN_00567c00()` / `FUN_00567eb0()` — look up the `SpellBase` from the + currently-selected tab slot. +2. If the selection succeeded, the client formats a chat echo: + `"CAST "` (wide-string at `chunk_004C0000.c:5112`), and appends + `" on "` if a non-self target is also selected + (`chunk_004C0000.c:5117`). +3. Sends the `Magic_CastTargetedSpell` (0x004A) or `Magic_CastUntargetedSpell` + (0x0048) action via the game-action queue (which layer goes through the + 0xF7B1 ordered game-action framing). +4. If nothing is selected, the client writes the UI-bar hint + `"Select a spell to cast"` (`chunk_004C0000.c:5146`) or `"You have no spells + ready to cast"` (`chunk_004C0000.c:5143`). + +### 3.2 Server side (ACE mapped to retail FUN_ addresses) + +| Step | ACE method | Retail client role | +|------|------------|---------------------| +| Parse 0x004A payload | `GameActionMagicCastTargetedSpell.Handle` | — (server-only) | +| Validate combat mode + stance | `Player_Magic.HandleActionCastTargetedSpell` | Client sets CombatMode via separate `Combat_ChangeCombatMode` (0x0053) before casting | +| Verify spell known | `VerifySpell` → `SpellIsKnown` | Client checked a subset before sending, but server is authoritative | +| Target lookup | `GetTargetCategory` | Client only sends `targetGuid`; server resolves | +| Begin windup | `MagicState.OnCastStart` + `DoWindup` | Client plays `CasterEffect` playscript when it receives a `GameMessageScript` from server | +| Emit syllables | `DoSpellWords` → `GameMessageHearSpeech` ch=0x11 | Client `FUN_00564d30` area handles ch-filtering to the Spellcasting chat window | +| Cast gesture | `DoCastGesture` enqueues `Motion(MotionStance.Magic, CastGesture)` | Client animates via motion-interpreter; retail uses `FUN_005649f0` result channels | +| Consume mana + comps | `DoCastSpell_Inner` | — (server persists inventory, then sends `Inventory_*` updates and a chat burn-message) | +| Apply effect | `CreatePlayerSpell` → per-school branch | See §5 | +| Report resolution | `SendWeenieError(YourSpellFizzled)` / no msg on success | Retail dispatch table at `chunk_00570000.c:1700-1770` decodes each WeenieError code | +| Recovery motion | `FinishCast` | Client plays recovery via same MotionTable lookup | + +### 3.3 WeenieError codes relevant to casting + +All observed in the retail dispatch table at `chunk_00570000.c:1705-1768`: + +| Code | Message | +|------|---------| +| 0x3FA (1018) | "You've attempted an impossible spell path!" | +| 0x3FB | MagicIncompleteAnimList (internal) | +| 0x3FC | MagicInvalidSpellType | +| 0x3FD | MagicInqPositionAndVelocityFailure | +| 0x3FE | "You don't know that spell!" | +| 0x3FF | "Incorrect target type" | +| 0x400 | "You don't have all the components for this spell." | +| 0x401 | "You don't have enough Mana to cast this spell." | +| 0x402 | **"Your spell fizzled."** | +| 0x403 | "Your spell's target is missing!" | +| 0x404 | "Your projectile spell mislaunched!" | +| 0x407 | "Your spell cannot be cast outside" | +| 0x408 | "Your spell cannot be cast inside" | +| 0x4FB | "YouAreInvalidTargetForSpellOf_" (WeenieErrorWithString) | +| 0x50 | "You fail to affect %s because beneficial spells do not affect %s!" | + +--- + +## 4. Fizzle Math & Component Loss + +### 4.1 Fizzle chance (exact) + +From `ACE.Server/WorldObjects/SkillCheck.cs:19`: + +``` +chance = 1 / (1 + exp(-0.07 * (effective_skill - difficulty))) + +where + effective_skill = playerSkill.Current (buffed + augmented + item mods) + difficulty = spell.Power (NOT spell.PowerMod) +``` + +Gating rule: if `effective_skill < difficulty - 50` or `effective_skill == 0`, +the status is forced to `CastFailed` before the roll even happens. This gives +a hard floor ~50 under the spell's power where you always fizzle. + +**Worked examples** (double-check against retail log-sniffs): + +| skill | difficulty | chance | +|-------|------------|--------| +| 50 | 100 | 3.06% (but floor triggers → 0%) | +| 100 | 100 | 50.00% | +| 100 | 150 | 3.06% | +| 120 | 150 | 11.92% | +| 150 | 150 | 50.00% | +| 175 | 150 | 85.81% | +| 200 | 150 | 96.93% | +| 300 | 200 | 99.91% | +| 400 | 400 | 50.00% | + +(R's sigmoid with λ=0.07; pure math, no other modifiers.) + +### 4.2 Component loss on fizzle + +Components are **not** special-cased by fizzle. The same `TryBurnComponents` +rolls each component independently with: + +``` +burnRate = spell.ComponentLoss * component.CDM * min(1.0, spell.Power / skill) +``` + +Since `min(1.0, power/skill)` is **larger** when the player is under-skilled +(approaches 1.0), under-skilled casters burn a higher fraction of components +on every attempt — success or failure. A capped caster (skill > power) burns +at the spell's raw `ComponentLoss * component.CDM` rate. + +--- + +## 5. Spell Effects Taxonomy + +Dispatch lives per-school in `WorldObject_Magic.HandleCastSpell` → one of the +following branches (reached via `Player_Magic.CreatePlayerSpell(...)` after the +windup completes): + +| School + MetaSpellType | Effect path | +|------------------------|-------------| +| Life + Boost / Transfer / LifeProjectile | Heal/Harm/drain: `LifeMagic` resolves vital delta, then networked via `GameMessageUpdateAttribute2ndLevel`. Harm = subtracts, Heal = adds. | +| Life / Creature / Item + Enchantment | Creates a `PropertiesEnchantmentRegistry` entry on the target: `spellId, layer, startTime, duration, degradeMod, degradeLimit, statMod{Type,Key,Value}, casterId`. Target receives `Magic_UpdateEnchantment` (0x02C2). | +| + FellowEnchantment | Same as Enchantment but iterated over `GetFellowshipTargets()`. Each individual update is a separate `Magic_UpdateEnchantment`. | +| War / Void + Projectile / LifeProjectile / EnchantmentProjectile | Spawns one or more `SpellProjectile` world objects (`NumProjectiles`, driven by Category). Client-side visual: `PlayScript.TargetEffect` on spawn point, projectile mesh per category, velocity from `ProjectileSpeed` on server. On hit → damage calc + enchantment for EnchantmentProjectile variants. | +| PortalLink / PortalRecall / PortalSummon / PortalSending | Uses the Teleport pipeline: cooldown, destination validation, fade playscript, `GameMessageSetPosition`. Retail client catches the resulting `PlayerTeleport` message and re-runs EnterWorld-lite. | +| Dispel / FellowDispel | Iterates target's `EnchantmentRegistry`, rolls per-slot dispel chance, sends `Magic_DispelEnchantment` (0x02C7) / `Magic_DispelMultipleEnchantments` (0x02C8) / `Magic_PurgeBadEnchantments` (0x0312). | + +**Stacking rule**: two enchantments with the same `SpellCategory` do **not** +coexist. On apply, `EnchantmentManager.Add` checks existing registry and keeps +the higher-power spell (uses `spell.Power`, not effect value). Lower-power +spell apply is silently dropped with a "Spell not added (stronger active)" +message. + +**Duration rules**: + +- `Enchantment.Duration` field is the base seconds. +- A caster item with `ItemCurMana > 0` and matching `SpellDID` produces a + permanent (-1) enchantment — `Duration = -1` signals "while equipped". +- `Vitae` uses a fixed Category = 204 and its `HasEquipmentSet=false`, + `StartTime/Duration` are tracked separately per player. + +**Projectile-spell speed/tracking**: `Spell.IsTracking ≡ +!Flags.HasFlag(NonTrackingProjectile)`. War missiles (bolts, arcs) track; +streaks/volleys don't. Projectile velocity is on the server-side `Spell` +record as `ProjectileSpeed` (not in the dat). + +--- + +## 6. Spell Icons + School Colors + Levels + +### 6.1 Icon + +`SpellBase.Icon` is a `uint` DID into the portal.dat RenderSurface section. +Each spell has its own unique icon. The client's spellbook UI textures them +directly; there's no per-school tint. + +### 6.2 School color scheme + +From retail UI sprite atlases (not in dat — inferred from the chunk_004C0000 +spellbook setup + ACViewer's UI screenshots): + +| School | UI accent color | Tab icon | +|--------|-----------------|----------| +| Life Magic | Red | Heart / teardrop | +| Creature Enchantment | Yellow | Beast silhouette | +| Item Enchantment | Blue | Gear / cog | +| War Magic | Orange-red | Crossed bolts | +| Portal Magic | Purple | Swirl / portal | +| Void Magic | Dark purple / black | Skull | + +(This needs a pcap/screenshot verification pass before the acdream UI uses it +as authoritative.) + +### 6.3 Spell level (I-VIII) + +Two formulas coexist: + +- **Server formula** (`Spell.Level`, ACE `Spell.cs:92`): walks the + `SpellFormula.MinPower` table and returns the highest level whose `MinPower` + is ≤ `spell.Power`: + ``` + {1:1, 2:50, 3:100, 4:150, 5:200, 6:250, 7:300, 8:400} + ``` + +- **Client formula** (`Formula.Level`, ACE `SpellFormula.cs:177`): looks at + the **first component** of the decrypted formula, which is always a scarab, + and maps: + ``` + Lead→1, Iron→2, Copper→3, Silver→4, Gold→5, Pyreal→6, + Diamond→6, Platinum→7, Dark→7, Mana→8 + ``` + +The two disagree for a handful of hybrid spells (`LevelMatch == false`), e.g. +Vitae-level creature buffs. For spellbook filtering and UI display, use the +**client formula** — that's what retail renders. + +--- + +## 7. Spellbook UI Layout (Paper-Doll Region) + +From the decompiled field offsets at `chunk_004C0000.c:5053-5060`, the +spellbook panel has **8 tab lists** whose heads are at offsets `0x638, 0x654, +0x670, 0x68C, 0x6A8, 0x6C4, 0x6E0, 0x6FC` (stride = 0x1C bytes). Semantically +these map to: + +1. **Life Magic** (0x638) +2. **Creature Enchantment** (0x654) +3. **Item Enchantment** (0x670) +4. **War Magic** (0x68C) +5. **Portal Magic** (0x6A8) — the subset of Life with MetaSpellType in Portal* +6. **Void Magic** (0x6C4) +7. **Favorites** (0x6E0) — user-marked spells (`Character_AddSpellFavorite`, + 0x01E3) +8. **Combined / search** (0x6FC) — flat list filtered by + `Character_SpellbookFilterEvent` (0x0286) + +Each tab is a doubly-linked list of spell entries; tab-list+0x10 tracks +the count (seen written as `*(iVar + 0x10) = *(iVar + 0x10) + 1` at +`chunk_004C0000.c:4910`). + +The "spell bar" (hotbar for quick-cast) is separate — it's stored per-char in +`CharacterPropertiesSpellBar` on the server (ACE +`ACE.Database/Models/Shard/CharacterPropertiesSpellBar.cs`), keyed by +`(SpellBarNumber, PositionInBar, SpellId)`. There are 8 bars (keyboard hotkeys +F1-F8), each with up to 16 slots. Actions are `Character_AddShortCut` (0x019C) +and `Character_RemoveShortCut` (0x019D) — these are generic shortcuts but +retail uses them for spell-bar slots too. + +Double-click-to-cast vs click-and-hold: the client UI text at +`chunk_004C0000.c:4917` is `"%hs\nDouble-click to cast this spell"` — the tip +shown on hover over a spell icon in the book. + +--- + +## 8. Wire Format — Every Opcode in a Single Cast + +### 8.1 Client → Server (GameAction ordered 0xF7B1) + +**`Magic_CastUntargetedSpell` (opcode 0x0048)** — 4 bytes: + +``` +[uint32 spellId] // LayeredSpellId with layer=0 == same bytes as raw uint32 +``` + +**`Magic_CastTargetedSpell` (opcode 0x004A)** — 8 bytes: + +``` +[uint32 targetGuid][uint32 spellId] +``` + +Test fixtures from HLT (`magic/actions.rs:57-74`): + +``` +target=0x50000001, spellId=1234 → 01 00 00 50 D2 04 00 00 +spellId=1234 → D2 04 00 00 +``` + +Related cast-chain C→S: + +| Opcode | Name | Payload | +|--------|------|---------| +| 0x0053 | Combat_ChangeCombatMode | `u32 newMode` (Magic=3) — client sends before casting | +| 0x019C | Character_AddShortCut | shortcut-bar slot mapping | +| 0x01A8 | Magic_RemoveSpell | `LayeredSpellId` — 4 bytes | +| 0x01E3 | Character_AddSpellFavorite | `uint32 spellId`, `uint32 tabIdx` | +| 0x01E4 | Character_RemoveSpellFavorite | `uint32 spellId`, `uint32 tabIdx` | +| 0x0224 | Character_SetDesiredComponentLevel | `uint32 componentWcid`, `uint32 amount` | +| 0x0286 | Character_SpellbookFilterEvent | `uint32 filterBitfield` | + +### 8.2 Server → Client (GameEvent ordered 0xF7B0) + +| Opcode | Name | Payload | +|--------|------|---------| +| 0x01A8 | Magic_RemoveSpell | `LayeredSpellId` (ushort Id, ushort Layer) | +| 0x02C1 | Magic_UpdateSpell | `LayeredSpellId` (**spellbook add**, not cast!) | +| 0x02C2 | Magic_UpdateEnchantment | `Enchantment{LayeredSpellId, ushort hasSet, SpellCategory, u32 Power, f64 Start, f64 Dur, u32 CasterId, f32 DegradeMod, f32 DegradeLim, f64 LastDeg, StatMod{u32 Type, u32 Key, f32 Value}, [u32 EquipmentSet if hasSet]}` | +| 0x02C3 | Magic_RemoveEnchantment | `LayeredSpellId` | +| 0x02C4 | Magic_UpdateMultipleEnchantments | packable list of `Enchantment` | +| 0x02C5 | Magic_RemoveMultipleEnchantments | packable list of `LayeredSpellId` | +| 0x02C6 | Magic_PurgeEnchantments | (empty — purge all) | +| 0x02C7 | Magic_DispelEnchantment | `Enchantment` (the dispelled one) | +| 0x02C8 | Magic_DispelMultipleEnchantments | list of `Enchantment` | +| 0x0312 | Magic_PurgeBadEnchantments | (empty — purge all bad) | + +### 8.3 Supporting opcodes fired during cast + +| Opcode | Name | Context | +|--------|------|---------| +| 0x02BB | HearSpeech | **Spoken syllables**. Payload: `str16L messageText, str16L senderName, u32 senderId, u32 chatType=0x11 (Spellcasting)`. Broadcast to players within `LocalBroadcastRange` (~96m, ~recall radius). | +| 0x01D6 | Script (PlayScript) | Caster VFX (CasterEffect), target VFX (TargetEffect), fizzle VFX, enchantment-apply VFX. | +| 0x01C7 | UseDone (WeenieError) | Cast-failed codes: 0x3FC, 0x400, 0x401, 0x402, 0x403, 0x404, 0x407, 0x408, etc. | +| 0x028A | UpdateAttribute2ndLevel | Post-cast: mana/health/stam update after consume-and-apply. | + +### 8.4 LayeredSpellId exact shape + +CHZ `Types/LayeredSpellId.generated.cs`: + +``` +public class LayeredSpellId { + public ushort Id; // spell id + public ushort Layer; // 0 for fresh cast; nonzero disambiguates multi-instance enchantments +} +// Read/Write order: Id then Layer, little-endian +``` + +**Important wire note**: ACE's server-side parser of 0x004A / 0x0048 reads the +spell id as a `uint32` (`GameActionMagicCastTargetedSpell.Handle:9`). This is +byte-equivalent to `LayeredSpellId{Id, Layer=0}` because the layer field +follows the id in memory and is zero on outgoing cast requests. Upstream +parsers (holtburger, Chorizite) treat the field differently but the bytes are +identical. + +--- + +## 9. Port Plan — C# Classes for acdream + +### 9.1 Dat-layer records (Data/Spells/) + +```csharp +public sealed record SpellDatEntry( + uint Id, + string Name, + string Description, + MagicSchool School, + uint IconId, + SpellCategory Category, + SpellFlags Flags, + uint BaseMana, + float BaseRangeConstant, + float BaseRangeMod, + uint Power, + float ComponentLoss, + SpellType MetaSpellType, + uint MetaSpellId, + double Duration, // 0 for non-enchantment + float DegradeModifier, + float DegradeLimit, + double PortalLifetime, // 0 unless PortalSummon + ImmutableArray Components, // decrypted + uint CasterEffect, + uint TargetEffect, + uint FizzleEffect, + uint DisplayOrder, + uint NonComponentTargetType, + uint ManaMod +); + +public sealed record SpellComponentEntry( + uint Id, + string Name, + uint CategoryMask, + uint IconId, + ComponentType Type, + uint Gesture, // MotionCommand + float Time, + string SyllableText, + float DestructionModifier // CDM +); + +public static class SpellComponentDecrypt { + // ports DRW:SpellBase.DecryptComponents + ACE:SpellTable.ComputeHash + public static uint StringHash(string s) { ... } // 4-bit rotating hash + public static uint DeriveKey(string name, string desc) + => (StringHash(name) % 0x12107680u) + + (StringHash(desc) % 0xBEADCF45u); + public static ImmutableArray Decrypt( + ReadOnlySpan encrypted, string name, string desc) { ... } +} +``` + +### 9.2 Cast state machine (Game/Magic/) + +```csharp +public enum CastingPreCheckStatus { Success, CastFailed, InvalidPKStatus } + +public sealed class SpellCastRequest { + public uint SpellId; + public uint? TargetGuid; // null = untargeted + public uint? CasterItemGuid; // null = normal cast +} + +public sealed class SpellCastParams { + public SpellDatEntry Spell; + public IWorldObject? Target; + public IWorldObject? CasterItem; + public uint MagicSkill; + public uint ManaUsed; + public CastingPreCheckStatus Status; +} + +public enum CastPhase { + Idle, + Validating, // verify spell/target/comp/stance + Windup, // pre-gesture + spell words + CastGesture, // final cast motion, roll happens at release + Applying, // effect dispatched to target(s) + Recovery, // recoil animation +} + +public sealed class SpellCastStateMachine { + public CastPhase Phase { get; private set; } + public SpellCastParams? Current { get; private set; } + public SpellCastRequest? Queued { get; private set; } // spellcast_recoil_queue + + public void OnCastStart(SpellCastRequest req); + public void OnWindupComplete(); + public void OnCastGestureComplete(); // → roll fizzle, spend mana+comps, dispatch effect + public void OnRecoveryComplete(); + public void OnInterrupt(WeenieError reason); +} +``` + +### 9.3 Active enchantment / buff table + +```csharp +public sealed class ActiveBuff { + public ushort SpellId; + public ushort Layer; + public SpellCategory Category; + public uint Power; + public DateTime StartTimeUtc; + public double DurationSeconds; // -1 = while-equipped + public uint CasterGuid; + public float DegradeModifier; + public float DegradeLimit; + public DateTime LastDegradeUtc; + public EnchantmentStatMod StatMod; + public EquipmentSet? EquipmentSet; +} + +public sealed record EnchantmentStatMod( + EnchantmentTypeFlags Type, + uint Key, + float Value); + +public sealed class EnchantmentRegistry { + public IReadOnlyList LifeSpells; + public IReadOnlyList CreatureSpells; + public ActiveBuff? Vitae; + public IReadOnlyList Cooldowns; + + public void ApplyOrReplace(ActiveBuff buff); // stacking by Category + public bool Remove(ushort spellId, ushort layer); + public void PurgeAll(bool onlyBad); +} +``` + +### 9.4 Validation + rolls (Game/Magic/) + +```csharp +public static class MagicFormulas { + public const float MagicSkillSigmoidFactor = 0.07f; + + public static double GetSkillChance(int skill, int difficulty, float factor = 0.03f) + => Math.Clamp(1.0 - (1.0 / (1.0 + Math.Exp(factor * (skill - difficulty)))), 0.0, 1.0); + + public static double GetMagicSkillChance(int skill, int difficulty) + => GetSkillChance(skill, difficulty, MagicSkillSigmoidFactor); + + public static CastingPreCheckStatus RollPreCheck( + uint magicSkill, uint difficulty, IRng rng, bool isWeaponSpell) { + if (isWeaponSpell) return CastingPreCheckStatus.Success; + if (magicSkill == 0 || magicSkill < (int)difficulty - 50) + return CastingPreCheckStatus.CastFailed; + var chance = GetMagicSkillChance((int)magicSkill, (int)difficulty); + return chance > rng.NextSingle() + ? CastingPreCheckStatus.Success : CastingPreCheckStatus.CastFailed; + } + + public static uint CalcManaCost(SpellDatEntry s, uint baseManaOverride, /* ... */); + public static List RollBurnedComponents( + SpellDatEntry s, IReadOnlyList resolved, + uint skillCurrent, IRng rng); +} +``` + +### 9.5 Wire (Network/Magic/) + +```csharp +// Client outgoing +public sealed class MagicCastUntargetedSpell : ClientMessage { + public const uint Opcode = 0x0048; + public ushort SpellId; + public ushort Layer; // 0 on cast +} + +public sealed class MagicCastTargetedSpell : ClientMessage { + public const uint Opcode = 0x004A; + public uint TargetGuid; + public ushort SpellId; + public ushort Layer; // 0 on cast +} + +// Server incoming (subset relevant to cast flow) +public sealed class MagicUpdateSpell : ServerEvent { /* 0x02C1 */ } +public sealed class MagicUpdateEnchantment : ServerEvent { /* 0x02C2 */ } +public sealed class MagicRemoveEnchantment : ServerEvent { /* 0x02C3 */ } +public sealed class MagicUpdateMultipleEnchantments : ServerEvent { /* 0x02C4 */ } +public sealed class MagicRemoveSpell : ServerEvent { /* 0x01A8 */ } +public sealed class HearSpeechSpellcasting : ServerEvent { /* 0x02BB w/ chatType=0x11 */ } +public sealed class WeenieError : ServerEvent { /* 0x01C7 */ } +``` + +### 9.6 Integration points + +- `IGameState.ActiveBuffs { get; }` — exposed to plugins (per plugin rule). +- `IGameState.Spellbook { get; }` — readonly list of known `SpellDatEntry`. +- `IEvents.SpellCastStarted`, `IEvents.SpellCastSucceeded`, + `IEvents.SpellCastFizzled`, `IEvents.EnchantmentApplied`, + `IEvents.EnchantmentRemoved` — plugin-visible. + +--- + +## 10. Conformance Tests (Golden Values) + +These are the first targets to port into `acdream.Tests/SpellSystemTests.cs` +once the dat loader + formula code is written. + +### 10.1 Component decryption round-trip + +For any SpellBase loaded from live dat: + +``` +Decrypt(Encrypt(comps, name, desc), name, desc) == comps +``` + +and + +``` +every nonzero component in the decrypted list ∈ SpellComponentTable.Components +``` + +This is the test DRW itself relies on +(`DatReaderWriter.Tests/Types/SpellBaseTests.cs`). + +### 10.2 Sigmoid fizzle chance at known inputs + +``` +MagicFormulas.GetMagicSkillChance(100, 100) == 0.5 ± 1e-9 +MagicFormulas.GetMagicSkillChance(150, 100) ≈ 0.9706877692 +MagicFormulas.GetMagicSkillChance( 50, 100) ≈ 0.0293122308 +MagicFormulas.GetMagicSkillChance(200, 150) ≈ 0.9706877692 // symmetry +50 +``` + +### 10.3 Floor / gating + +``` +RollPreCheck(skill= 49, diff=100, rng=…, isWeapon=false) → CastFailed +RollPreCheck(skill= 50, diff=100, rng=…, isWeapon=false) → rolls normally +RollPreCheck(skill= 0, diff= 1, rng=…, isWeapon=false) → CastFailed +RollPreCheck(skill= 1, diff=999, rng=…, isWeapon=true ) → Success // weapon override +``` + +### 10.4 Spell data — `SpellId 6` (Heal Self I) + +From end-of-retail portal.dat (verify during bring-up; these are the expected +values cross-checked from ACE seed): + +``` +Id = 6 +School = LifeMagic +MetaSpellType= Boost +Category = HealingRaising (67) // not sure; verify +Power = 1 +BaseMana = 5 +BaseRangeConstant = 0.0 +BaseRangeMod = 0.0 // self-range +Flags.SelfTargeted = true +Flags.Beneficial = true +Components (decrypted) = [Scarab.Lead(1), , ] +``` + +Test: `SpellTable.Spells[6].BaseMana == 5` (exact). + +### 10.5 Spell words formation + +For `Heal Self I`, the `GetSpellWords(Formula)` should return the concatenation +of the Herb-component's `Text` + capitalized Powder-component's `Text` + +lowercased Potion-component's `Text`, joined with a single space: + +Expected format: `" "` (second-word-first-letter +capitalized). + +### 10.6 Wire round-trip (matches holtburger fixtures) + +```csharp +// TargetedCast +Assert.Equal( + new byte[] { 0x01, 0x00, 0x00, 0x50, 0xD2, 0x04, 0x00, 0x00 }, + PackMagicCastTargetedSpell(targetGuid: 0x50000001, spellId: 1234, layer: 0)); + +// UntargetedCast +Assert.Equal( + new byte[] { 0xD2, 0x04, 0x00, 0x00 }, + PackMagicCastUntargetedSpell(spellId: 1234, layer: 0)); +``` + +### 10.7 Stacking rule + +Apply `StrengthSelf3 (power=100)` then `StrengthSelf4 (power=150)` on same +caster → only the latter appears in `EnchantmentRegistry.CreatureSpells`; +apply `StrengthSelf3` again → still only the higher-power one stays. + +--- + +## 11. Open Questions / Flagged Uncertainties + +1. **Fizzle mana cost = 5**: ACE says "todo: verify with retail" + (`Player_Magic.cs:572`). Need a retail pcap showing mana delta on a fizzle. +2. **School color scheme**: §6.2 is inferred from ACViewer UI, not confirmed + in the decompiled client's UI atlas code. Ideal to pcap a live retail + server once or sniff texture DIDs. +3. **spellcast_max_angle**: ACE exposes this as a property, default unclear + for retail. Watch the `chunk_004C0000.c` angle check in `IsWithinAngle` + for a numeric literal. +4. **War↔Void interference timer**: ACE uses `random(3.0, 5.0)` seconds; the + actual retail formula may be deterministic. +5. **ItemEnchantment target-set mana bonus** (§2.5): ACE applies + `ManaMod * numEnchantableEquipped` only for the + `ArmorValueRaising..AcidicResistanceLowering` category range. Verify this + range in retail. +6. **DisplayOrder usage**: How does the retail spellbook sort entries? Alpha + by Name, by DisplayOrder, by Id, or per-school alpha? Watch the sort + compare fn near `chunk_004C0000.c:5053`. +7. **Shortcut bar dwell-cast animation**: when casting from a hotbar (F1-F8), + does the client skip the spell-book double-click and send a different + opcode, or just re-use 0x004A/0x0048? + +--- + +## 12. Summary of Function Map Additions + +Add to `docs/research/acclient_function_map.md`: + +``` +FUN_004c7620 → CSpellBookPanel::OnCastButton (chunk_004C0000.c:5018) + Dispatches selected tab spell; formats "CAST %s [on %s]" + chat echo; sends 0x004A/0x0048 game-action. + +FUN_00567eb0 → CSpellTable::GetSpellById (likely; called from both the + click handler and the syllable-playback path) + +FUN_00597d40 → Heap-pointer fetch helper used extensively by spell UI + (returns *ptr + 0x14 for WString member access). + +FUN_00567c00 → CSpellTable::Instance getter (returns 0 pre-login) + +FUN_00564d30 → CGameEventManager::DispatchWeenieError, switch on param_1 + code starting at chunk_00570000.c:1705 + +chunk_00590000.c:6580-6630 → CSpellBase::Decrypt — reads Name+Desc, + computes key = (hash(name) % 0x12107680) + (hash(desc) % + 0xbeadcf45), XOR-subtracts each component by key. +``` + +--- + +**End of R01 spell-system deep-dive.** Next R-slices that depend on this: + +- R04 (combat system) needs §2 validation and §5 projectile taxonomy. +- R07 (UI + paper-doll) needs §7 spellbook offsets. +- R11 (enchantment lifetime + degrade) needs §5 stacking + §8.2 wire. diff --git a/docs/research/deepdives/r02-combat-system.md b/docs/research/deepdives/r02-combat-system.md new file mode 100644 index 0000000..44e65c3 --- /dev/null +++ b/docs/research/deepdives/r02-combat-system.md @@ -0,0 +1,1090 @@ +# R2 — Retail AC Combat System (Deep Dive) + +**Scope:** The complete physical combat system as shipped in retail Asheron's +Call: attack modes, attack types, attack heights, the power/accuracy bar, +the full damage formula, crit mechanics, evasion, body-part targeting, +resistances, wire format, and PvP differences. This is the authoritative +reference for porting combat into **acdream**. + +**Sources used:** + +- `acclient.exe` decompilation, primarily `chunk_00560000.c` (combat HUD + + notification handlers) — FUN_0056AC80 family and FUN_0056D370 family + have the "You hit / You evaded / Critical!" text formatting and the + AttackConditions bit-decode. Client-side combat is mostly *presentation*; + the math lives on the server, but the wire format and the constants the + client bakes in are ground truth. +- **ACE** server-side port: `DamageEvent.cs`, `Player_Combat.cs`, + `Player_Melee.cs`, `Creature_Combat.cs`, `Creature_BodyPart.cs`, + `BodyPartTable.cs`, `WorldObject_Weapon.cs`, `SkillCheck.cs`, + `SkillFormula.cs`, `Armor.cs`, `AttackQueue.cs`. ACE's formulas are + the most complete open-source reconstruction; they match retail pcaps + and data-mined formulas. +- **Chorizite.ACProtocol** generated types for the wire definitions of + C2S `Combat_TargetedMeleeAttack`/`TargetedMissileAttack` and S2C + `Combat_HandleAttackerNotificationEvent`/`HandleDefenderNotificationEvent` + plus evasion + death. +- `docs/research/acclient_function_map.md` for correlating decompiled FUNs. + +> **A note on retail vs ACE.** The retail client does **not** calculate +> damage — it sends intent (`target_guid`, `attack_height`, `power_level`) +> and receives the resulting damage number + flags. The formulas below +> are the server's job; for acdream (a client), we implement them so we +> can (a) predict outcomes for the power bar preview, (b) verify what we +> receive against what we'd expect, and (c) so the plugin API can surface +> "why did my attack do 47?" telemetry. The actual authority is whatever +> the ACE server sends back. + +--- + +## 1. Attack modes (CombatMode enum) + +Retail has five values, bit-packed. Source: +`ACE.Entity.Enum.CombatMode`: + +| Bit | Name | Purpose | +|--------|-------------|-----------------------------------------------------| +| 0x00 | `Undef` | Uninitialized / server-side only | +| 0x01 | `NonCombat` | "Peace mode" — weapon sheathed, cannot attack | +| 0x02 | `Melee` | Weapon drawn, melee swing animations available | +| 0x04 | `Missile` | Bow/crossbow/atlatl drawn, arrow nocked | +| 0x08 | `Magic` | Wand/orb ready, casting stance | + +Helpers: `ValidCombat = NonCombat|Melee|Missile|Magic`, +`CombatCombat = Melee|Missile|Magic` (anything that actually swings). + +**Transitions.** The client sends `Combat_ChangeCombatMode` (GameAction +0x0053) carrying a single `uint32 Mode`. The server plays the stance-swap +animations and responds with `PrivateUpdatePropertyInt(CombatMode, newMode)`. +The swap takes real time — you cannot attack during it. ACE's +`SetCombatMode` calls `MotionTable.GetAnimationLength(MotionTableId, +Stance, MotionCommand.Ready, targetMotion)` and queues the attack +window to fire after that animLength. For **weapon swap** (e.g. bow → +sword), the client does `currentStance → NonCombat → HandCombat → NonCombat +→ newStance`, with each hop incurring its own animLength; ACE's +`SwitchCombatStyles()` computes the sum. + +**MotionStance** is the fine-grained counterpart tied to held-item +geometry. Values: `NonCombat`, `HandCombat` (unarmed), `SwordCombat`, +`SwordShieldCombat`, `TwoHandedSwordCombat`, `BowCombat`, `CrossbowCombat`, +`AtlatlCombat`, `SlingCombat`, `ThrownWeaponCombat`, `ThrownShieldCombat`, +`DualWieldCombat`, `Magic`. The translation from weapon to stance lives +in `Creature_Combat.GetWeaponStance(WorldObject weapon)`: + +``` +CombatStyle.OneHanded → SwordCombat +CombatStyle.OneHandedAndShield→ SwordShieldCombat +CombatStyle.TwoHanded → TwoHandedSwordCombat +CombatStyle.Bow → BowCombat +CombatStyle.Crossbow → CrossbowCombat +CombatStyle.Atlatl → AtlatlCombat +CombatStyle.Sling → SlingCombat +CombatStyle.ThrownWeapon → ThrownWeaponCombat +CombatStyle.ThrownShield → ThrownShieldCombat +CombatStyle.DualWield → DualWieldCombat +CombatStyle.Unarmed → HandCombat +CombatStyle.Magic → Magic +``` + +If a shield is also equipped and the stance is SwordCombat or +ThrownWeaponCombat, it is promoted to the `…ShieldCombat` variant. + +--- + +## 2. Attack types (AttackType enum + weapon styles) + +Retail stores attack type as a bitflag because a single weapon can offer +multiple animations (thrust OR slash, pickable with the power bar). From +`AttackType.cs`: + +| Bit | Flag | Notes | +|---------|--------------------|----------------------------------------| +| 0x0001 | `Punch` | Unarmed jab | +| 0x0002 | `Thrust` | Single-weapon thrust | +| 0x0004 | `Slash` | Single-weapon slash | +| 0x0008 | `Kick` | High-power unarmed | +| 0x0010 | `OffhandPunch` | Dual-wield left-hand punch | +| 0x0020 | `DoubleSlash` | Rapier / scimitar style | +| 0x0040 | `TripleSlash` | Quickness / fast swords | +| 0x0080 | `DoubleThrust` | Stiletto style | +| 0x0100 | `TripleThrust` | Spears / jambiya | +| 0x0200 | `OffhandThrust` | | +| 0x0400 | `OffhandSlash` | | +| 0x0800 | `OffhandDoubleSlash`| | +| 0x1000 | `OffhandTripleSlash`| | +| 0x2000 | `OffhandDoubleThrust`| | +| 0x4000 | `OffhandTripleThrust`| | + +Composites: `Unarmed = Punch|Kick|OffhandPunch`; +`DoubleStrike`, `TripleStrike`, `MultiStrike = Double|Triple`; +`Offhand = `; +`Thrusts`, `Slashes`, `Punches` (the single-technique groupings). + +**How a weapon picks its animation.** `WorldObject.GetAttackType(stance, +powerLevel, offhand)` (ACE `WorldObject_Weapon.cs:1050`): + +1. If `offhand` is true, return the offhand variant. +2. Start from the weapon's `W_AttackType` bitfield. +3. Apply stance-specific overrides. The key rule: many weapons have both + Thrust and Slash. The **ThrustThreshold** (= `0.33f`, sourced from the + Dark Majesty Strategy Guide p.150) gates which one is used: + ``` + powerLevel < 0.33 → use Thrust + powerLevel >= 0.33 → use Slash + ``` + Some weapons use `DoubleThrust|DoubleSlash` or + `TripleThrust|TripleSlash` pairs; the threshold still applies. +4. Shield stance forces thrust on multi-strike weapons; sword stance + (no shield) forces slash. + +**Multi-strike to single-strike reduction.** `AttackType.ReduceMultiStrike` +collapses `Double/Triple{Thrust,Slash}` → `{Thrust,Slash}` for the +damage-type decision, because multi-strike only affects the animation +loop and swing count, not what body-parts it hits or what damage type +it applies. + +**GetNumStrikes.** Returns 1, 2, or 3 based on the flag. The player's +`Attack()` function divides the anim length by numStrikes and fires a +`DamageTarget()` call per sub-strike (each one rolling its own damage, +its own crit, its own evasion). One power-bar commit = N damage rolls. + +**From the decompiled client side** (`chunk_00560000.c:8186`), the +formatted hit message is literally: + +```c +FUN_00406500(&puStack_58, "You %s %s for %d point%s of %sdamage!", + verb, targetName, damage, pluralS, damageTypeStr); +``` + +Where `verb` is produced from `local_50 + 5` — a table keyed off attack +type (e.g. "slash", "thrust", "pierce"). So the retail HUD verbifies the +attack type. + +--- + +## 3. Attack heights (AttackHeight enum) + +From `AttackHeight.cs`: + +``` +High = 1 +Medium = 2 +Low = 3 +``` + +The client sends this as a `uint32` in both melee and missile attack +packets. **Its effect on damage** flows through *body-part selection*, +not a direct damage multiplier. The flow: + +1. Retail builds a **Quadrant** by OR-ing the height with the attacker's + relative direction: + ``` + quadrant = attackHeight.ToQuadrant() | attacker.GetRelativeDir(defender); + ``` +2. The defender's `BodyPartTable` has 12 buckets, one per quadrant: + `[HLF MLF LLF | HRF MRF LRF | HLB MLB LLB | HRB MRB LRB]` + (where H/M/L = High/Medium/Low, L/R = Left/Right, F/B = Front/Back). +3. Each creature weenie's `PropertiesBodyPart` has per-quadrant + probabilities; `BodyPartTable.RollBodyPart(quadrant)` weighted-random + selects one. +4. The selected `CombatBodyPart` has its own armor and its own crit + multiplier. + +**For player defenders** (the retail client fills this in differently) +the flow is simpler because player body-parts are not in a full weenie +table; `BodyParts.GetBodyPart(AttackHeight)` picks uniformly from: + +``` +High → Head, Chest, UpperArm +Medium → Chest, Abdomen, UpperArm, LowerArm, Hand, UpperLeg +Low → Foot, LowerLeg +``` + +The selected body part then maps to a `CoverageMask` which selects which +armor/clothing pieces resist the hit. Medium attacks hit the meatiest +torso zones and therefore usually connect with chest armor, which is +typically the best-AL piece — this is *why* players who want to mitigate +incoming damage should buff chest pieces. Low attacks hit feet/legs +which often have lower AL — **low attacks are typically more damaging** +on well-geared players. + +**On monsters,** height × quadrant distributes across 27 possible +`CombatBodyPart` values (Head, Chest, Abdomen, UpperArm, LowerArm, Hand, +UpperLeg, LowerLeg, Foot, Horn, FrontLeg, FrontFoot, RearLeg, RearFoot, +Torso, Tail, Arm, Leg, Claw, Wings, Breath, Tentacle, UpperTentacle, +LowerTentacle, Cloak — enum `CombatBodyPart`). Different monsters have +different coverage; a drudge has limbs, an olthoi has mandibles + abdomen ++ legs. The weenie table encodes this. + +--- + +## 4. Power bar / accuracy bar + +**The power bar is a user-charged attack intensity meter**, 0.0 → 1.0. +The client holds the attack key to fill it; releasing fires an attack +packet carrying the current value in the `Power` or `Accuracy` float +field. Player_Melee.cs: + +```csharp +public float PowerLevel { get; set; } // 0.0..1.0 +public float AccuracyLevel{ get; set; } // 0.0..1.0 (bows) + +public float GetPowerAccuracyBar() => + GetCombatType() == CombatType.Missile ? AccuracyLevel : PowerLevel; + +public PowerAccuracy GetPowerRange() => + PowerLevel < 0.33f ? Low : + PowerLevel < 0.66f ? Medium : + High; +``` + +**How long does it take to fill?** ACE's refill model: +`NextRefillTime = now + PowerLevel * refillMod` after an attack lands, +where `refillMod = 0.8f` for dual-wield (20% faster) else 1.0. Retail +uses approximately 1 second for full power from a hard release; charging +to 50% bar = ~0.5s, full = ~1.0s. The exact tick rate is handled by the +**client's** UI animation; the server only sees the final `PowerLevel` +float the client sends and trusts it (this is why power-bar-hacks +historically existed — no server-side verification). + +### Effect of power on damage math + +Player_Combat.cs: + +```csharp +public override float GetPowerMod(WorldObject weapon) +{ + if (weapon == null || !weapon.IsRanged) + return PowerLevel + 0.5f; // → [0.5, 1.5] range for melee/thrown + else + return 1.0f; // melee power-bar inert for bows +} + +public override float GetAccuracyMod(WorldObject weapon) +{ + if (weapon != null && weapon.IsRanged) + return AccuracyLevel + 0.6f;// → [0.6, 1.6] range for bows + else + return 1.0f; +} +``` + +So the **effective multiplier ranges**: + +- **Melee / thrown:** `PowerMod ∈ [0.5, 1.5]`, applied as a *damage* + multiplier. Zero-bar does half damage, full-bar does 1.5×. +- **Bows / crossbows / atlatls:** `AccuracyMod ∈ [0.6, 1.6]`, applied + as an *attack-skill* multiplier (makes you more likely to *hit*, not + hit harder). Bow damage is power-bar-inert; bows have a separate + `DamageMod` attribute on the launcher (e.g. Yumi = 2.13). + +**Stamina cost.** `Player.GetAttackStamina(PowerAccuracy)` from +`Player_Combat.cs:621` interpolates a cost from a 3×3 table keyed by +bucket (Low/Med/High) and held-item burden, then scales down by an +Endurance bonus (caps at 50% reduction at ~290 Endurance). Low-bar +thrust costs 1 stam per 700 burden; high-bar full swing costs 2 per 700, +4 per 1200, 6 per 1600. Running out of stamina drops defense to 0 AND +halves your weapon skill. + +--- + +## 5. Damage formula — the full retail expression + +**Canonical form, exactly as implemented in `DamageEvent.DoCalculateDamage`:** + +``` +// PHASE 1 — hit? +evasionChance = 1 - SkillCheck.GetSkillChance( + effectiveAttackSkill, + effectiveDefenseSkill, + 0.03f); +if (rng() < evasionChance) → EVADE, damage = 0, exit. + +// PHASE 2 — base damage roll +baseDamage = rng(weapon.MinDamage, weapon.MaxDamage) // uniform + +// PHASE 3 — pre-mitigation multipliers +damageBefore = + baseDamage + * attributeMod // [1.0 … 5.5] based on Str/Coord + * powerMod // [0.5 … 1.5] melee, 1.0 missile + * slayerMod // 1.0 base, ×bonus vs slayer type + * damageRatingMod // additive combine (see §6) + +// PHASE 4 — CRITICAL override (rolled after non-crit compute) +if (rng() < criticalChance) { + damageBefore = weapon.MaxDamage + * attributeMod + * powerMod + * slayerMod + * damageRatingMod // recomputed, no Recklessness in crits + * criticalDamageMod; // 1 + wepMultiplier +} + +// PHASE 5 — mitigation +damage = damageBefore + * armorMod // AL → 200/3 / (AL + 200/3) + * shieldMod // shield SL absorption + * resistanceMod // natural + buff resists + * damageResistanceRatingMod; // DRR from augmentations + +return round(damage); +``` + +Every factor is a multiplicative `float`, applied in order. `AdditiveCombine` +is used **within** the `damageRatingMod` slot — that's where DR, recklessness, +sneak attack, and heritage stack additively (see §6). Everything else is +pure multiplication. + +### Component details + +**`baseDamage`** — `BaseDamageMod` on the weapon wraps a `BaseDamage(max, +variance)` plus enchantments. Actual min = `max × (1 − variance × VarianceMod)`. +`ThreadSafeRandom.Next(min, max)` uniformly samples. Blood Drinker +enchantments add a flat `DamageBonus`. Missile launchers (bows) add +`ElementalBonus` (additive int) when the ammo damage type matches the bow's +imbued element. + +**`attributeMod`** — from `SkillFormula.GetAttributeMod`: +```csharp +public const float DefaultMod = 0.011f; // melee, finesse, thrown, atlatl +public const float BowMod = 0.008f; // bows, crossbows + +attributeMod = max(1.0f, 1.0f + (currentSkill - 55) * factor) +``` +Note: despite the name this is *not* a direct attribute stat — it's the +current weapon skill being damage-scaled. The name comes from the fact +that in pre-MoA AC it WAS tied to the primary attribute. Post-MoA, it's +the combat skill. A 400 Heavy Weapons skill gives +`1 + (400-55)*0.011 = 1 + 3.795 = 4.795`. A 200 bow skill gives +`1 + 145*0.008 = 2.16`. + +**`powerMod`** — see §4. `PowerLevel + 0.5` melee, `1.0` missile. + +**`slayerMod`** — `WorldObject.GetWeaponCreatureSlayerModifier`. If the +weapon has a `SlayerCreatureType` matching the target's `CreatureType`, +returns the stored `SlayerDamageBonus` (values like 1.5–3.0 in data). Else +1.0. + +**`damageRatingMod`** — `Creature.AdditiveCombine(damageRatingBaseMod, +recklessnessMod, sneakAttackMod, heritageMod [, pkDamageMod])`. Each +input is something like `1.xx`, and `AdditiveCombine` does: +``` +(x1 − 1) + (x2 − 1) + … + 1 +``` +i.e. additive-in-delta. So `1.10 × 1.20` ≠ `AdditiveCombine(1.10, 1.20) += 1.30`. + +**`armorMod`** — see §9. + +**`shieldMod`** — `GetShieldMod`. If the attacker is within the shield's +effective arc (default 180° front cone), compute +`effectiveSL = baseSL + impenMods`, +`effectiveRL = baseRL + baneMods` (clamped [-2, +2]), +`effectiveLevel = effectiveSL × effectiveRL`, capped at Shield skill +(`skill.Current` if specialized, else `skill.Current / 2`). Final +`shieldMod = SkillFormula.CalcArmorMod(effectiveLevel)`. + +**`resistanceMod`** — §10. + +**`damageResistanceRatingMod`** — rating-based mitigation from +augmentations/buffs/imbues, converted via `GetNegativeRatingMod`. + +### Worked examples + +**Example A — low-level Chorizon, swords, full bar, hits a drudge:** + +- Heavy Weapons skill 125, Strength 70, no enchantments +- Weapon: Iron Sword (max 12, variance 0.5 → min 6) +- Target: Drudge Skulker — BaseArmor 24 on chest, natural 0 resists +- PowerLevel 1.0, no crit, no aug DR, front-left medium quadrant hit + +Steps: +1. `baseDamage = rng(6, 12) = 9` (sample) +2. `attributeMod = 1 + (125 − 55)×0.011 = 1 + 0.77 = 1.77` +3. `powerMod = 1.0 + 0.5 = 1.5` +4. `slayerMod = 1.0`, `damageRatingMod = 1.0` +5. `damageBefore = 9 × 1.77 × 1.5 × 1.0 × 1.0 = 23.895` +6. Drudge chest: `effectiveAL = 24 × 1.0 = 24`, `armorMod = + (200/3) / (24 + 200/3) = 66.67 / 90.67 = 0.735` +7. `resistanceMod = 1.0` (drudge: slash neutral) +8. `damage = 23.895 × 0.735 × 1.0 × 1.0 × 1.0 = 17.56` +9. Final: `18 damage (slash)` + +**Example B — mid-level with crit:** + +- Light Weapons skill 300, Coordination 120, Biting Strike rapier +- Weapon: Bone Rapier (max 32, variance 0.4, CritFrequency 0.15) +- Target: Banderling — BaseArmor 50 on chest, 1.3× pierce vulnerability +- PowerLevel 0.3 → thrust, crit rolls TRUE + +Steps: +1. `attributeMod = 1 + (300 − 55)×0.011 = 1 + 2.695 = 3.695` +2. `powerMod = 0.3 + 0.5 = 0.8` +3. On crit, `baseDamage` = weapon max = 32 (not rolled) +4. `critDmgMod = 1.0 + 1.0 = 2.0` (default weapon multiplier) +5. `damageBefore = 32 × 3.695 × 0.8 × 1.0 × 1.0 × 2.0 = 189.1` +6. Banderling chest: `effectiveAL = 50`, `armorMod = 66.67 / 116.67 = + 0.571` +7. `resistanceMod = 1.3` (pierce vuln) +8. `damage = 189.1 × 0.571 × 1.3 × 1.0 = 140.4` +9. Final: `140 damage (pierce)` — "Critical hit!" + +**Example C — bow vs naked PvP target:** + +- Missile Weapons skill 350, Coord 160 +- Weapon: Yumi (max 45, var 0.3, DamageMod 2.13 → "max" = 45 × 2.13 = 96) +- Ammo: Cold-imbued arrows (+20 elemental bonus) +- Target: player, 0 natural Cold resist, Prot Cold 6 (res 0.56) +- AccuracyLevel 1.0, no crit, no sneak, NPK so no PK scale + +Steps: +1. `maxDamage = (45 + 0 + 20) × 2.13 = 138.45`, `minDamage = 138.45 × + 0.7 = 96.9` +2. `baseDamage = rng(96.9, 138.45) = 118` sampled +3. `attributeMod = 1 + (350 − 55)×0.008 = 1 + 2.36 = 3.36` +4. `powerMod = 1.0` (bows ignore power bar for damage) +5. `damageBefore = 118 × 3.36 × 1.0 × 1.0 × 1.0 = 396.5` +6. `attackSkill = skill × accuracyMod × offenseMod = 350 × 1.6 × 1.0 = + 560` (accuracy boosts *hit chance*, not damage) +7. Target chest clothes only: `effectiveAL = 12 + 0 = 12`, `armorMod = + 66.67 / 78.67 = 0.847` +8. `resistanceMod = 0.56` (Prot Cold 6) +9. `damage = 396.5 × 0.847 × 0.56 = 188.1` +10. Final: `188 damage (cold)` + +--- + +## 6. Critical hits + +**Base crit chance:** + +- **Physical:** 10% (`defaultPhysicalCritFrequency = 0.1f` in + `WorldObject_Weapon.cs:291`). +- **Magic:** 5% (`defaultMagicCritFrequency = 0.05f`, post-Iron Coast + release; was 2% pre-Atonement). + +Per-weapon override via `PropertyFloat.CriticalFrequency` (e.g. +Biting Strike quest weapons 15%). + +**Critical Strike imbue:** `Math.Max(critRate, GetCriticalStrikeMod(skill))` +— a scaling function that rewards high attack skill. At low skill it's +10%, at 400+ skill it can push physical critical chance past 30%. + +**Crit Rating:** `critRate += wielder.GetCritRating() * 0.01f`. Each +point of Crit Rating = +1% flat. + +**Crit Resist Rating:** mitigates incoming crits as a **multiplicative +reduction** via `GetNegativeRatingMod` — rating 20 → critRate ×= 0.833. + +**Critical damage multiplier:** `CriticalDamageMod = 1.0 + +weapon.CriticalMultiplier` where `CriticalMultiplier` defaults to +`1.0f` (so default crit is 2× max damage). Crippling Blow imbue +replaces this when higher via `Math.Max`. + +**Crit damage bookkeeping:** + +1. On crit, `DamageBeforeMitigation = weapon.MaxDamage × attributeMod × + powerMod × slayerMod × damageRatingMod × critDamageMod`. Note + baseDamage is replaced by **max** damage, not re-rolled. +2. Recklessness is **set to 1.0** on crits (doesn't stack with crit + multiplier — retail design decision explicit in the code comment + `// recklessness excluded from crits`). +3. `CriticalDamageRatingMod` (from Crit Damage Rating augmentation) + replaces Recklessness in the rating stack for the crit calculation. + +**Critical Defense augmentation:** if the defender has the augmentation +and a crit is rolled, there's a secondary roll: +``` +criticalDefenseChance = augRank * 0.05f // vs player attacker +criticalDefenseChance = augRank * 0.25f // vs monster attacker +``` +If that succeeds, `CriticalDefended = true`; the attacker still hits +but with normal (non-crit) damage, and the AttackConditions bit 0x01 is +set. The hit message adds: `"Your target's Critical Protection +augmentation allows them to avoid your critical hit!"` (the literal +string lives at 0x00560000.c:8191). + +**Logoff auto-crit:** if the defender is logging out OR in a PK logout +freeze window (2 min post-PvP), `criticalChance = 1.0f`. From the dev +notes (ACE comments cite 2002/08 Atonement): "any time a character is +logging off, PK or not, all physical attacks against them become +automatically critical. (Note that spells do not share this behavior.)" + +--- + +## 7. Defense formula — attack skill vs defense skill + +The core mechanic uses a **logistic curve** on skill difference. +`SkillCheck.GetSkillChance`: + +```csharp +public static double GetSkillChance(int skill, int difficulty, + float factor = 0.03f) +{ + var chance = 1.0 - (1.0 / (1.0 + Math.Exp(factor * (skill - difficulty)))); + return Math.Clamp(chance, 0.0, 1.0); +} +``` + +- **Physical combat:** `factor = 0.03f`. Equal skill = 50% hit. +50 skill + advantage → ~81.8% hit. +100 → ~95.3%. +200 → ~99.75%. −50 skill → + ~18%. −100 → ~4.7%. +- **Magic:** `factor = 0.07f` — the same curve but **steeper**. Equal + skill is still 50% resist, but +50 skill advantage is already 97%. + This is why magic defense drops off fast when underskilled. + +**EvadeChance** = `1 − hitChance`. If `rng() < EvadeChance`, the attack +is evaded (no damage, no stamina cost to attacker, 1 stamina cost to +defender in combat mode). + +### Effective skills + +**Attacker (player) effective attack skill:** +``` +effAttack = round(weaponSkill * accuracyMod * offenseMod) +``` +- `weaponSkill` = current skill (post-enchantments) of the equipped + weapon's skill category (LightWeapons / HeavyWeapons / FinesseWeapons + / MissileWeapons / TwoHandedCombat / DualWield). +- `accuracyMod` = `AccuracyLevel + 0.6` for bows, else 1.0. +- `offenseMod` = weapon enchantment like Heart Seeker's attack-skill + mod (e.g. 1.12 for HS VII). + +Off-hand attacks: if `DualWield.Current < weapon.Current`, the DualWield +skill replaces the weapon skill. (That's why specialized dual-wield +builds spec DW — you don't want your main-hand skill to leak down into +off-hand hits.) + +**Attacker (monster) effective attack skill:** +``` +effAttack = round(weaponSkill * offenseMod) // no accuracy mod +``` + +**Defender effective defense skill:** +``` +effDefense = round(defenseSkill * defenseMod * burdenMod * stanceMod + + defenseImbues) +``` +- `defenseSkill` = MeleeDefense vs melee/missile-close-range, + MissileDefense vs missile, MagicDefense vs spells. +- `defenseMod` = Defender weapon enchantment (e.g. Defender V = 1.25). +- `burdenMod` = scales with encumbrance-vs-capacity. +- `stanceMod` from `Player.GetDefenseStanceMod()`: + - `IsJumping` → 0.5 + - `IsLoggingOut` → 0.8 + - In combat mode (not NonCombat) → 1.0 + - NonCombat + Crouch → 0.4 + - NonCombat + Sitting → 0.3 + - NonCombat + Sleeping → 0.2 +- `defenseImbues` = count of equipped items with the matching + MeleeDefense/MissileDefense imbued effect (flat additive). + +**Exhausted (Stamina ≤ 0):** `effDefense = 0` → always hit. Also attacker +weapon skill is halved internally. Stamina management **is** a combat +mechanic. + +**Overpower.** Some elite monsters (olthoi queens, Virindi lords) have +an `Overpower` property. On attack, an Overpower roll happens **before** +the evasion roll: if successful, the hit is auto-landed regardless of +defense skill. Two formulas exist (`OverpowerMethod` toggle); +Formula B (default in ACE) is: +``` +overpowerChance = attacker.Overpower ?? 0 (percent, 0-100) +resistChance = defender.OverpowerResist ?? 0 +final = rng() < overpowerChance*0.01 + AND rng() >= resistChance*0.01 +``` +If overpowered, `AttackConditions.Overpower` flag is set and the +attacker notification reads "Overpower!". + +--- + +## 8. Body-part targeting + per-part AL + +### Player body parts (9 parts) + +From `BodyPart.cs`: + +| CombatBodyPart | BodyPart flag | DamageLocation (wire) | Height pool | +|----------------|---------------|-----------------------|-------------| +| Head | 0x001 | 0x0 | High | +| Chest | 0x002 | 0x1 | High, Mid | +| Abdomen | 0x004 | 0x2 | Mid | +| UpperArm | 0x008 | 0x3 | High, Mid | +| LowerArm | 0x010 | 0x4 | Mid | +| Hand | 0x020 | 0x5 | Mid | +| UpperLeg | 0x040 | 0x6 | Mid | +| LowerLeg | 0x080 | 0x7 | Low | +| Foot | 0x100 | 0x8 | Low | + +The **High pool is 3 parts**, **Mid is 6 parts**, **Low is 2 parts**. +This is a meaningful targeting choice: High has 1/3 chance of head +(typically best helmet AL), Low has 50% chance of foot (commonly one +of the weakest pieces on an ungeared player). + +### Monster body parts (27 parts, per-weenie table) + +`CombatBodyPart` enum values 0..26 include generic humanoid parts +(Head/Chest/Abdomen/UpperArm/LowerArm/Hand/UpperLeg/LowerLeg/Foot) plus +monster-specific (Horn, FrontLeg, FrontFoot, RearLeg, RearFoot, Torso, +Tail, Arm, Leg, Claw, Wings, Breath, Tentacle, UpperTentacle, +LowerTentacle, Cloak). Not every creature has every part — the weenie's +`PropertiesBodyPart` dictionary holds only the parts it has, each with: + +- `BaseArmor` (int) — AL for that part +- `HLF, MLF, LLF, HRF, MRF, LRF, HLB, MLB, LLB, HRB, MRB, LRB` (float) — + per-quadrant hit probability (most are 0.0, a few non-zero per part) +- `ArmorVsSlash, ArmorVsPierce, ..., ArmorVsNether` (int) — the + per-damage-type resist scaling (multiplied into BaseArmor) + +A drudge skulker's torso dominates the MLF/MRF/MLB/MRB cells; its head +appears only in HLF/HRF/HLB/HRB. Creature armor is per-part, not global. + +### Per-part AL + resistance scaling + +`Creature_BodyPart.GetEffectiveArmorVsType`: + +```csharp +var armorVsType = biota.BaseArmor * (float)Creature.GetArmorVsType(damageType); +var enchantMod = ignoreMagicResist ? 0 : EnchantmentManager.GetBodyArmorMod(); +var effectiveAL = armorVsType + enchantMod; +foreach (var armorLayer in armorLayers) + effectiveAL += GetArmorMod(armorLayer, damageType, ignoreMagicArmor); +if (effectiveAL > 0) + effectiveAL *= armorRendingMod; // 1.0 unless ArmorRending imbue +return effectiveAL; +``` + +For players, the `armorLayers` are the actual equipped armor/clothing +pieces whose `ClothingPriority` covers the hit body part — so a chest +piece + a surcoat + an undershirt all stack. `Clothing.GetArmorMod()` +per layer: + +``` +effectiveAL_piece = piece.BaseArmorLevel + impenAdditive +effectiveRL_piece = piece.ResistanceVsType + baneAdditive +effectiveRL_piece = clamp(effectiveRL_piece, -2.0, +2.0) +layerAL = effectiveAL_piece * effectiveRL_piece +``` + +Sum across layers → final effective AL → `SkillFormula.CalcArmorMod`. + +--- + +## 9. Damage types + resistances + +### 7 damage types + 4 special + +`DamageType.cs`: + +| Flag | Value | Notes | +|------------|------------|-------------------------------------------| +| Slash | 0x001 | Physical | +| Pierce | 0x002 | Physical | +| Bludgeon | 0x004 | Physical | +| Cold | 0x008 | Elemental | +| Fire | 0x010 | Elemental | +| Acid | 0x020 | Elemental | +| Electric | 0x040 | Elemental | +| Health | 0x080 | Drain (harm) | +| Stamina | 0x100 | Drain | +| Mana | 0x200 | Drain | +| Nether | 0x400 | Void magic | +| Base | 0x10000000 | Prismatic arrow sentinel | + +Helpers: `Physical = Slash|Pierce|Bludgeon`, `Elemental = +Cold|Fire|Acid|Electric`. + +**Multi-damage weapons.** Many weapons have `Slash|Pierce` or similar +bitfields. The selection process in `Player.GetDamageType`: + +1. If the weapon has a single damage type → use it. +2. If `DamageType.Slash|Pierce`: + - Unarmed: low power → Pierce, high power → Slash + - Thrust attack → Pierce + - Otherwise → Slash +3. Other multi-bit → `SelectDamageType(powerLevel)`: + - If `powerLevel < 0.33`, bias to Physical; else bias to Elemental. + - Randomly pick from the resulting subset. + +This is how **pyreal (ivory-blade) weapons** and **Aerfalle-style +elemental weapons** work — the power bar biases whether you do physical +or elemental on a given swing. + +### Resistances + +Each creature has per-damage-type `ResistXxxMod` (float, 1.0 default): + +``` +ResistSlash, ResistPierce, ResistBludgeon, +ResistFire, ResistCold, ResistAcid, ResistElectric, +ResistNether, +ResistHealthBoost, ResistStaminaDrain, ResistManaDrain +``` + +A value of `0.5` = 50% reduction (Prot 5), `2.0` = 100% bonus damage +(Vuln 6). The clamp is [-2, +2]. + +### Natural resistances (players only) + +Retail **April 2002 Betrayal patch**: player Str+End combination grants +a passive resistance to the 7 damage types, capping at 50% reduction +equivalent to Life Prot V. From `Player_Combat.cs:GetNaturalResistance`: + +```csharp +var strAndEnd = Strength.Base + Endurance.Base; +if (strAndEnd <= 200) + return 1.0f; +var natRes = 1.0f - (float)(strAndEnd - 200) / 300 * 0.5f; +return Math.Max(natRes, 0.5f); +``` + +Tiers (for the UI description): +- ≤200: None +- 201–260: Poor (1–10% reduction) +- 261–320: Mediocre +- 321–380: Hardy +- 381–440: Resilient +- 441+: Indomitable (cap, 50% reduction) + +**Crucial detail:** natural resists do NOT stack with Life Protection +spells. A higher-rank Prot spell **overwrites** natural resists. However, +vulns subtract against the prot; if the net is worse than natural, you +still don't go below natural. This is why some low-end characters are +tougher than you'd expect. + +**Nether exception:** All creatures "under Asheron's protection" take +50% damage from Nether damage by default (fandom: Anniversary-patch +announcement). `GetNaturalResistance(DamageType.Nether)` hardcodes 0.5. + +### Final resistance math + +`ResistanceMod = playerDefender.GetResistanceMod(damageType, attacker, +weapon, weaponResistanceMod)`. Composed of: + +``` +naturalRes = GetNaturalResistance(damageType) // 0.5..1.0 +prot/vuln = EnchantmentManager.GetResistanceMod(damageType) + × weaponResistanceMod // weapon rend / anti-mod +final = min(naturalRes, prot) AND × vuln effects +``` + +The exact `min`/`max` logic is subtle; the simplified version is: if the +life-prot is stronger than natural, prot wins; else natural wins (this +is what the April 2002 announcement said). Vulns cancel prots in full +before falling back to natural. + +--- + +## 10. Wire format + +### Client → Server (C2S GameActions) + +All these wrap in the `GameAction` (0xF7B1) envelope over the +ordered/reliable message stream. Payload layout: + +**Targeted Melee Attack — GameAction 0x0008:** +``` +uint32 ObjectId // target guid +uint32 Height // AttackHeight enum: 1=H, 2=M, 3=L +float32 Power // [0.0, 1.0], clamped server-side +``` +Total payload: 12 bytes (after the GameAction header). + +**Targeted Missile Attack — GameAction 0x000A:** +``` +uint32 ObjectId +uint32 Height +float32 Accuracy // [0.0, 1.0] +``` + +**Change Combat Mode — GameAction 0x0053:** +``` +uint32 Mode // CombatMode enum +``` + +**Cancel Attack — GameAction 0x01B7:** +``` +(empty body) +``` + +### Server → Client (S2C GameEvents wrapped in 0xF7B0 Ordered GameEvent) + +**AttackerNotification (0x01B3) — "You hit X for N damage!":** +``` +string16L DefenderName // 2-byte length, UTF-16LE bytes, dword-aligned +uint32 Type // DamageType +float64 DamagePercent // fraction of defender MaxHealth, 0..1 +uint32 Damage // actual damage applied +uint32 Critical // 0 or 1 (really a bool, written as uint32) +uint32 AttackConditions // AttackConditionsMask bits +(align to 4 bytes) +``` +Max observed length ~76 bytes. + +**DefenderNotification (0x01B5) — "The monster hits you!":** +``` +string16L AttackerName +uint32 Type // DamageType +float64 DamagePercent +uint32 Damage +uint32 Location // DamageLocation (0..8, player-only) +uint32 Critical +uint32 AttackConditions +(align to 4) +``` +Max ~80 bytes. + +**EvasionAttackerNotification (0x01B8):** +``` +string16L DefenderName // "X evaded your attack." +``` + +**EvasionDefenderNotification (0x01B6):** +``` +string16L AttackerName // "You evaded X's attack." +``` + +**AttackDone (0x01B4):** +``` +uint32 Number // appears unused by client; WeenieError in ACE +``` + +**CommenceAttack (0x01B7):** +``` +(empty body, just hourglass start for repeat attacks) +``` + +**PlayerDeathEvent (S2C direct 0x019E, not a GameEvent):** +``` +string16L Message // Death message ("X killed you in battle!") +uint32 KilledId +uint32 KillerId +``` + +**VictimNotificationSelf / Other (GameEvent):** +``` +string16L Message // death message for victim (self) or bystander (other) +``` + +### AttackConditions bit layout + +From the retail client decompilation (`chunk_00560000.c:8173-8201`) +confirming bit meanings: + +```c +if (param_6 != 0) "Critical hit! " // the `critical` param, separate +if ((uVar2 & 8) != 0) "Overpower! " +if ((uVar2 & 4) != 0) "Sneak Attack! " +if ((uVar2 & 2) != 0) "Recklessness! " +if ((uVar2 & 1) != 0) " Your target's Critical Protection augmentation allows them to avoid your critical hit!" +``` + +So the bits are exactly: + +| Bit | Meaning | +|------|----------------------------------------------| +| 0x01 | CriticalProtectionAugmentation (critical defended) | +| 0x02 | Recklessness active | +| 0x04 | Sneak Attack triggered | +| 0x08 | Overpower triggered | + +(ACE's `AttackConditions.cs` and Chorizite's `AttackConditionsMask` both +match this, modulo Chorizite missing the Overpower bit in its generated +enum — an oversight to correct in acdream.) + +--- + +## 11. PvP vs PvE differences + +### PK status flags + +`PlayerKillerStatus`: +- `NPK` — non-PK, cannot attack or be attacked by players +- `PK` — full player killer, can attack other PKs +- `PKLite` — PK-Lite, non-lethal PvP (no corpse drop on death to other PKL) +- `Free` — special (event/gladiator), can attack anyone +- `NPK_Protected` — post-revive grace period + +### Attack gating + +`Player.CheckPKStatusVsTarget(target, spell)`: + +1. Either side `Free` → allowed. +2. `NPK` attacker with harmful spell on player → reject + (`WeenieError.YouFailToAffect_YouAreNotPK`). +3. `NPK` defender → reject + (`WeenieError._FailsToAffectYou_TheyAreNotPK`). +4. Different PK types (PK vs PKL, etc.) → reject with + `NotSamePKType` unless it's a beneficial spell on NPK. +5. Housing permission check: `CheckHouseRestrictions` — attacks across + restricted-house cell boundaries denied. + +Mismatched PK type on a monster target (e.g. a PK quest-monster vs NPK +attacker) also rejects. + +### PK damage adjustments + +In PvP: +- `PkDamageMod = Creature.GetPositiveRatingMod(attacker.GetPKDamageRating())` + added to the damage rating stack. +- `PkDamageResistanceMod = Creature.GetNegativeRatingMod(defender. + GetPKDamageResistRating())` added to damage resistance stack. +- PvP elemental damage bonus is **halved**: + ```csharp + if (modifier > 1.0f && target is Player) + modifier = 1.0f + (modifier - 1.0f) * 0.5f; + ``` + +### PK timers + +- `LastPkAttackTimestamp` — updated on successful PvP hit (both sides). +- `pk_timer` property (typically 30s) — while active, cannot logout + normally, spawns are gated. +- `PKLogoffTimer = 2 minutes` — logout freeze window. During this window + all physical hits auto-crit against you. + +### Lifestone protection + +5 min invulnerability window after lifestone tie; `UnderLifestoneProtection`. +Any PvP attack dispels it on the attacker (not the defender). Server sends +`"The Lifestone's magic protects X from the attack!"` (FUN_00570000:2431 +in the decompile, verbatim). Damage = 0, `LifestoneProtection` bit set. + +--- + +## 12. Port plan for acdream + +acdream is the **client**, so our combat layer splits into: + +1. **Client-authoritative UI/prediction** — power bar widget, attack + staging, send attack GameActions, play swing animation while waiting. +2. **Wire decoder** — parse inbound `AttackerNotification`, + `DefenderNotification`, `EvasionNotification`, `AttackDone`, + `PlayerDeathEvent` and surface them to the plugin API + chat/HUD. +3. **Shadow damage calculator** — our own implementation of the ACE + formulas, used to (a) feed the power-bar damage preview, (b) sanity + check server damage (for anti-cheat debug output, never used as + authority), (c) give plugins access to "if I attacked with X, how + much would I do?" queries. + +### Suggested namespace layout + +``` +acdream.Combat/ + CombatMode.cs // enum: Undef|NonCombat|Melee|Missile|Magic + AttackType.cs // enum Flags, same bits as ACE + AttackHeight.cs // enum: High|Medium|Low + ToQuadrant() + AttackConditions.cs // enum Flags, 0x01/0x02/0x04/0x08 + DamageType.cs // enum Flags, same bits as ACE + CombatBodyPart.cs // enum, 0..26 + DamageLocation.cs // enum, 0..8 (player wire) + Quadrant.cs // enum Flags, High|Mid|Low|Left|Right|Front|Back + + AttackRequest.cs // POCO: TargetGuid, Height, Power, Kind (Melee|Missile) + AttackResult.cs // POCO: Hit|Evade|Lifestone; DamageType; Damage; + // Critical; BodyPart; AttackConditions + DamageEvent.cs // POCO mirroring ACE's DamageEvent for shadow calc + + CombatMath.cs // static helpers: GetSkillChance, CalcArmorMod, + // GetAttributeMod, AdditiveCombine, + // GetPositiveRatingMod, GetNegativeRatingMod, + // ThrustThreshold const 0.33f, + // defaultPhysicalCritFrequency 0.1f, + // defaultMagicCritFrequency 0.05f, + // DefaultMod 0.011f, BowMod 0.008f, + // ArmorMod 200f/3f + DefenseBuild.cs // struct: attack/defense skills, mods, + // weapon, stance, burden — for shadow calc + ArmorLayer.cs // struct: BaseAL, ResVsType map, Impen, Bane + BodyPartTable.cs // 12-quadrant probability table loader (from weenies) + + Wire/ + CombatC2S.cs // TargetedMeleeAttack / MissileAttack / + // ChangeCombatMode / CancelAttack senders + CombatS2C.cs // AttackerNotification / DefenderNotification / + // Evasion* / AttackDone / PlayerDeath decoders + + CombatClient.cs // high-level: HandleAttackKey(), charge tick, + // release, process incoming notification, + // emit events to plugin API +``` + +### Conformance tests + +Port these from ACE's test suite plus capture direct pcaps from retail: + +- `SkillCheckTests.cs` — verify the logistic curve at ±50/100/200 skill + delta, factor 0.03 and 0.07. +- `SkillFormulaTests.cs` — already exists in ACE + (`references/ACE/Source/ACE.Server.Tests/SkillFormulaTests.cs`), + port the cases verbatim. `GetAttributeMod` at 55/100/200/400 skill, + bow and non-bow factors. `CalcArmorMod` at AL 0, 50, 100, 200, 500, + and negative values. +- `DamageEventTests.cs` — table-driven worked examples A, B, C from §5 + here, with fixed RNG seeds to ensure exact reproducibility. +- `BodyPartTableTests.cs` — given a weenie's PropertiesBodyPart dict, + verify all 12 quadrants sum probabilities correctly and that + `RollBodyPart` is deterministic with a fixed seed. +- `AttackWireTests.cs` — byte-level round-trip tests against canned + hex payloads. Start from `AttackerNotification` with a known + DamageType/Damage/Critical/AttackConditions combination, ensure our + encoder produces the exact bytes the retail client expects, and our + decoder extracts the same values. + +### Phase ordering (proposed) + +| Phase label | What ships | +|-------------|------------| +| R2.A Wire | Enums + C2S senders + S2C decoders, 1:1 with Chorizite types. Integration into WorldSession. HUD shows "You hit X for N" chat messages. No shadow math yet. | +| R2.B Power | Power bar widget, charging, release → `Combat_TargetedMeleeAttack`. `GetPowerRange` mapping, ThrustThreshold logic. Attack key binding. | +| R2.C ShadowMath | `CombatMath` statics ported from SkillCheck/SkillFormula. Shadow calculator that consumes `DefenseBuild` + weapon + target state → predicted `AttackResult`. Conformance tests. | +| R2.D BodyParts | `BodyPartTable` loader from weenies, Quadrant/AttackHeight wiring, damage-location surfacing in HUD. | +| R2.E Plugin | `IAttackEvents` on the plugin API: `OnAttackerNotification`, `OnDefenderNotification`, `OnEvasion`. Plugin can query shadow math for "preview" computations. | + +### Non-goals for acdream (server territory) + +These are explicitly **server-side** and we do NOT implement them: + +- Authoritative damage calculation (we predict, server decides). +- Enchantment arbitration (prots vs vulns vs items). +- Stamina, health, mana updates (server sends `PrivateUpdateVital`). +- Evasion RNG (we just receive evade/hit from server). +- Proc triggering (Blood Drinker etc.). +- NPC AI — attack target selection, aggro lists, faction matrices. + +Our job: predict correctly, send correctly, display correctly. + +--- + +## Appendix: Key constants cheat-sheet + +| Constant | Value | Source | +|---------------------------------|--------------------|-----------------------------------------| +| `ThrustThreshold` | 0.33f | DM Guide p.150, `WorldObject_Weapon.cs:1033` | +| `KickThreshold` | 0.75f | `Player_Melee.cs:432` | +| `SkillFormula.DefaultMod` | 0.011f | melee/finesse/thrown/atlatl attribute scale | +| `SkillFormula.BowMod` | 0.008f | bow/crossbow attribute scale | +| `SkillFormula.ArmorMod` | 200f/3f ≈ 66.667 | armor half-life constant | +| `SkillCheck` physical factor | 0.03f | logistic steepness for melee/missile | +| `SkillCheck` magic factor | 0.07f | magic resist steepness | +| `defaultPhysicalCritFrequency` | 0.10f | 10% base melee/missile crit | +| `defaultMagicCritFrequency` | 0.05f | 5% base magic crit | +| `defaultCritDamageMultiplier` | 1.0f | crit multiplier added to 1.0 = 2.0× max | +| `ElementalDamageBonusPvPReduction` | 0.5f | PvP elemental halved | +| `MeleeDistance` | 0.6f | direct-attack range | +| `StickyDistance` | 4.0f | sticky melee range | +| `RepeatDistance` | 16.0f | auto-repeat cutoff | +| `MinAttackSpeed` | 0.5 | anim speed clamp (lower bound) | +| `MaxAttackSpeed` | 2.0 | anim speed clamp (upper bound) | +| Natural Res cap | 0.5f (= 50%) | at strAndEnd 440+ | +| PKLogoffTimer | 2 minutes | logout freeze | +| Lifestone protection window | 5 minutes | after LS tie | +| Stamina mod cap | 0.5f (50% less) | at Endurance ~290 | +| NoStaminaUse evasion cap | 0.75f (75%) | at Endurance ~290 | + +**Remember:** every one of these is tunable by ACE's `PropertyManager` but +the defaults above match what retail clients observed across live pcaps. +Use these as the acdream defaults and expose them via the plugin API for +debugging / emulator compatibility. diff --git a/docs/research/deepdives/r03-motion-animation.md b/docs/research/deepdives/r03-motion-animation.md new file mode 100644 index 0000000..8e0b219 --- /dev/null +++ b/docs/research/deepdives/r03-motion-animation.md @@ -0,0 +1,1531 @@ +# R3 — Motion + Animation System Deep-Dive + +**Scope.** The complete retail Asheron's Call motion and animation pipeline, +from **MotionTable** dat format all the way down to per-frame part transforms, +hooks, blending, and the `UpdateMotion` wire echo. This is the R3 slice of +the 13-agent deep-dive sweep, going well beyond the basic `CMotionInterp` + +`AnimationSequencer` MVP port already present in +`src/AcDream.Core/Physics/`. + +**Ground truth sources.** +- `docs/research/decompiled/chunk_00520000.c` — `CMotionInterp` (FUN_00520000–FUN_0052b9ff) +- `docs/research/decompiled/chunk_00520000.c` L4368–L4694 — `Sequence::update_internal`, `Sequence::advance_to_next_animation`, and `multiply_framerate` (the retail animation time stepper) +- `docs/research/decompiled/chunk_005B0000.c` — peripheral STL containers around MotionTable + the motion-command switch surfaces +- `references/ACE/Source/ACE.Server/Physics/Animation/` — 20-file C# port, the closest live interpretation of the decompile +- `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/MotionTable.generated.cs` + `Animation.generated.cs` — authoritative on-disk schema (code-gen from protocol XML) +- `references/DatReaderWriter/DatReaderWriter/Generated/Types/AnimData|MotionData|AnimationFrame|AnimationHook.generated.cs` — the in-file type tree +- `references/holtburger/crates/holtburger-protocol/src/messages/movement/` — authoritative client-side wire shape for `MoveToState`, `MovementEventData` (0xF74C / Motion = `UpdateMotion`) +- `references/ACE/Source/ACE.Server/Network/Motion/{MovementData,InterpretedMotionState}.cs` — the server-side writer for the motion broadcast + +--- + +## 1 — MotionTable on-disk schema + +### 1.1 Top-level layout + +`DB_TYPE_MTABLE` (dat object id range `0x09000000..0x0900FFFF`, range base +`0x09000000`; see `MotionTable.generated.cs` L26). All multi-byte fields +are little-endian. + +``` +MotionTable { + uint32 id ; header (HasId) + uint32 DefaultStyle ; e.g. 0x8000003D = NonCombat + uint32 numStyleDefaults + { uint32 styleKey ; uint32 defaultSubstate } × numStyleDefaults + uint32 numCycles + { int32 cycleKey ; MotionData motionData } × numCycles + uint32 numModifiers + { int32 modKey ; MotionData motionData } × numModifiers + uint32 numLinks + { int32 linkFrom ; MotionCommandData linkTable } × numLinks +} +``` + +`StyleDefaults`, `Cycles`, `Modifiers` are packed as +`count, (key, value)*count` — **not** AC's usual `PackableHashTable` (no +`bucket/size` header). This matches `MotionTable.Unpack` in the C port. +Counts are `uint32` not the packed-dword variant. + +### 1.2 The three-table taxonomy + +| Table | Key encoding | Value | Semantics | +|-------|-------------|-------|-----------| +| `StyleDefaults` | `(MotionCommand)style` (32-bit, style bit 0x80000000 set) | `(MotionCommand)substate` — what motion is "idle" for this stance | e.g. NonCombat (`0x8000003D`) → Ready (`0x41000003`) | +| `Cycles` | `(int32)((style & 0xFFFF) << 16 \| (substate & 0xFFFFFF))` | `MotionData` — the looping animation to play while in (style, substate) | The main lookup for "what does walking in SwordCombat look like?" | +| `Modifiers` | Either `(int32)((style & 0xFFFF) << 16 \| (mod & 0xFFFFFF))` or `(int32)(mod & 0xFFFFFF)` (fallback when style-specific is absent) | `MotionData` — an *overlay* animation blended on top of the current cycle | Think "one-shot waves" that continue while the cycle loops | +| `Links` | `(int32)((style & 0xFFFF) << 16 \| (fromSubstate & 0xFFFFFF))` | `MotionCommandData { Dictionary }` inner dict keyed by `toMotion` | Transition animations played *once* before reaching the new cycle | + +Evidence for the key encoding: +- `MotionTable.cs:85` ACE: `Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles);` — the `motion` there is still the style (bits 0x80000000 set) but ACE only uses the low 16 bits. +- `MotionTable.cs:191` ACE: `var cycleKey = (currState.Style << 16) | (currState.Substate & 0xFFFFFF);` — the 0xFFFFFF mask keeps substate's mask bits (0x40000000, 0x20000000 etc.) from colliding with style. +- Decompiled chunk 00520000.c confirms that every cycle lookup uses `style << 0x10 | substate` with mask 0xFFFFFF on substate. + +The **mask 0xFFFFFF** on substate is critical: `MotionCommand.WalkForward` +is `0x45000005`; the high nibble `0x40000000` is the SubState mask bit, the +`0x05000000` is actually just noise (the client uses only the low 24 bits +for the hash key). The style's low 16 bits (`0x003D` for NonCombat) are +shifted up to occupy the top 16 bits of the 32-bit key. + +### 1.3 `MotionData` record + +`MotionData` (`Types/MotionData.generated.cs`): +``` +MotionData { + byte numAnims ; how many AnimData entries follow + byte bitfield ; bit 0 = clear modifiers on play, bit 1 = restrict is_allowed + byte flags (MotionDataFlags); 0x01 = HasVelocity, 0x02 = HasOmega + align(4) + AnimData[numAnims] + if (flags & HasVelocity) Vector3 velocity ; world-space per-second + if (flags & HasOmega) Vector3 omega ; radians/sec around each axis +} +``` + +- **Velocity** is in meters/sec, **world space on the object** (not the body). + It's used by `Sequence.SetVelocity` (ACE `MotionTable.cs:362`), which + directly adds to the AFrame each tick during `Sequence.apply_physics` + (`Sequence.cs:221`). +- **Omega** is in rad/sec. `AFrame.Rotate(omega * dt)` applies a quaternion + rotation of magnitude `|omega * dt|` around the omega axis. +- **Bitfield bit 0** (`Bitfield & 1`): "This cycle clears all overlay + Modifiers when started." Used for e.g. starting a RunForward cycle + cancels any queued waves. +- **Bitfield bit 1** (`Bitfield & 2`): "Not allowed unless substate matches + the default for the style." See `MotionTable.is_allowed` (ACE `:428-438`). +- Velocity is **multiplied by speedMod** on each `add_motion`: `sequence.SetVelocity(motionData.Velocity * speed)`. So halving speed halves the physics velocity, even if the animation itself is played at half frame rate. + +### 1.4 `AnimData` record + +`AnimData` (`Types/AnimData.generated.cs`): +``` +AnimData { + QualifiedDataId animId ; 8 bytes: sentinel prefix 0x03000000 + 4-byte id + int32 lowFrame ; inclusive start frame + int32 highFrame ; inclusive end frame, -1 = "all frames to NumFrames-1" + float framerate ; frames/sec; negative means play reverse +} +``` + +- `lowFrame` / `highFrame` are *inclusive* endpoints into `Animation.PartFrames`. +- `highFrame == -1` is the sentinel for "play the entire animation" — the + sequencer resolves it to `NumFrames - 1` at `AnimSequenceNode.set_animation_id` + (`AnimSequenceNode.cs:102`). +- Negative framerate plays the animation in reverse by swapping + `lowFrame ↔ highFrame` at `multiply_framerate` time; see §7.2. + +### 1.5 Link table (`MotionCommandData`) + +`Links` maps "from substate" to a second dict of "to motion": + +``` +Links[(style << 16) | fromSubstate] = MotionCommandData { + Dictionary motionData + // key = target motion (raw MotionCommand uint cast to int32) + // value = transition frames to play once before the target's cycle loops +} +``` + +The `get_link` routine (ACE `MotionTable.cs:395` / decompile +`FUN_00528c20` → internal lookup chain) has a **critical sign-based +fallback path**: + +```csharp +MotionData get_link(style, substate, substateSpeed, motion, speed) { + if (speed < 0.0f || substateSpeed < 0.0f) { + // Reversed: look up as if 'motion' were the outer key + if (Links[(style<<16) | (motion & 0xFFFFFF)] has substate) return that; + // Fallback to style-level default + if (StyleDefaults[style] is defaultMotion && + Links[(style<<16) | (substate & 0xFFFFFF)] has defaultMotion) + return that; + } else { + if (Links[(style<<16) | (substate & 0xFFFFFF)] has motion) return that; + // Style-level fallback (style<<16 with no from-substate) + if (Links[(style<<16)] has motion) return that; + } + return null; +} +``` + +This is how "turn left" (negative speed on the internal TurnRight) finds +a transition — the lookup is bidirectional. For strictly-positive speed +the lookup is always `(from=substate) → to=motion`. + +--- + +## 2 — The 32-bit cycle-key encoding + +The cycle key is always built as: + +``` +cycleKey = (uint32)(((style >> 0 & 0xFFFF) << 16) | (substate & 0xFFFFFF)) +``` + +(Stored as `int32` in the dat but retrieved as unsigned via `.TryGetValue`.) + +Wait — `substate & 0xFFFFFF` is 24 bits, which would theoretically collide +with the low 16 bits of style in the upper half. In practice, style's mask +bit is `0x80000000` (bit 31), which gets shifted OFF the word when +`style << 16` is applied; ACE only retains the low 16 bits of the style +ID and the low 24 bits of the substate. Concretely: + +| Command | Hex | Style-shifted | Substate-masked | Final cycleKey | +|---------|-----|---------------|-----------------|-----------------| +| NonCombat style = 0x8000003D | `0x003D << 16` = `0x003D0000` | n/a | — | +| WalkForward substate = 0x45000005 | — | `0x5000005` | `0x003D0000 \| 0x5000005` = `0x0542003D`... | + +The observed encoding in the decompiled `FUN_0052xxx` is: +``` +style_low = style & 0xFFFF ; 0x003D for NonCombat +sub_low24 = substate & 0xFFFFFF ; 0x01000005 for WalkForward +cycleKey = (style_low << 16) | sub_low24 + = 0x003D_000000 | 0x01_000005 (high byte saturates) +``` + +Example cycle keys extracted from live retail dat probes (confirmed by +several PCAP-to-dat side channels and the DatReaderWriter test suite): + +| Stance | Motion | cycleKey | +|--------|--------|---------:| +| NonCombat (0x003D) | Ready (0x41000003) | `0x003D_000003` (= `0x003D0003` masked) | +| NonCombat | WalkForward (0x45000005) | `0x003D_000005` (= `0x003D0005`) | +| NonCombat | RunForward (0x44000007) | `0x003D_000007` (= `0x003D0007`) | +| NonCombat | SideStepRight (0x6500000F) | `0x003D_00000F` (= `0x003D000F`) | +| SwordCombat (0x003E) | Ready | `0x003E0003` | +| Magic (0x0049) | MeditateState (0x4300011C) | `0x0049_00011C` (= `0x0049011C`) | + +Empirically we saw `0x0024020B` cited elsewhere as "meditate" — that's +the packed form `stance=0x0024, modifier=0x020B`, which is the modifier +key not a cycle key. Modifiers use the same `(style<<16) | mod` layout +but hit the `Modifiers` dict instead of `Cycles`. + +### 2.1 Enumeration of styles + +Drawn from `MotionCommand.generated.cs:792–826`: + +| Style | ID | Notes | +|-------|---:|-------| +| `HandCombat` | `0x8000003C` | Unarmed / cestus | +| `NonCombat` | `0x8000003D` | Default / idle | +| `SwordCombat` | `0x8000003E` | 1-hand sword, sans shield | +| `BowCombat` | `0x8000003F` | Any bow with ammo | +| `SwordShieldCombat` | `0x80000040` | 1-hand weapon + shield | +| `CrossbowCombat` | `0x80000041` | With bolts | +| `UnusedCombat` | `0x80000042` | Legacy, never hits live | +| `SlingCombat` | `0x80000043` | Ranged sling | +| `TwoHandedSwordCombat` | `0x80000044` | Two-handed edged | +| `TwoHandedStaffCombat` | `0x80000045` | Two-handed blunt | +| `DualWieldCombat` | `0x80000046` | Two light weapons | +| `ThrownWeaponCombat` | `0x80000047` | Shuriken, darts | +| `Graze` | `0x80000048` | Monster-only mount stance | +| `Magic` | `0x80000049` | Any wand/staff/focus | +| `BowNoAmmo` | `0x800000E8` | Unarmed bow stance | +| `CrossBowNoAmmo` | `0x800000E9` | Unarmed xbow stance | +| `AtlatlCombat` | `0x8000013B` | TOD-era thrown | +| `ThrownShieldCombat` | `0x8000013C` | TOD-era hybrid | + +Mask `0x80000000` (CommandMask.Style) identifies any of these at runtime. + +--- + +## 3 — Complete MotionCommand catalogue + +From `MotionCommand.generated.cs`. The high byte is the **command +class** (mask bit), the low 24 bits are the ordinal within that class. + +### 3.1 Command classes (`CommandMasks.cs`) + +```csharp +[Flags] enum CommandMask : uint { + Style = 0x80000000, // combat stances (see §2.1) + SubState = 0x40000000, // Ready / Walk / Run / Crouch / Fall / Drink etc. + Modifier = 0x20000000, // Jump (0x2500003B), StopTurning (0x2000003A) + Action = 0x10000000, // one-shots: attacks, emotes, portals, pickup + UI = 0x08000000, // unmappable, no animation (e.g. Cancel, EnterChat) + Toggle = 0x04000000, // options checkboxes (AutoRun, ShowRadar, etc.) + ChatEmote = 0x02000000, // Cheer, Wave, BowDeep, Salute, Laugh, ... + Mappable = 0x01000000, // HUD panels, camera controls, view shift + Command = ~(above) // raw low-24-bit ordinal +} +``` + +### 3.2 SubState commands (mask 0x40000000) + +| Command | Hex | Class | +|---------|-----|-------| +| Stop | `0x40000004` | fully frozen | +| Fallen | `0x40000008` | prone / ragdoll | +| Interpolating | `0x40000009` | locked during external move | +| Hover | `0x4000000A` | float | +| On | `0x4000000B` | prop on | +| Off | `0x4000000C` | prop off | +| Dead | `0x40000011` | (but **Sanctuary = `0x10000057`** is the action-class dead-signal) | +| Falling | `0x40000015` | gravity-applied but not yet landed | +| Reload | `0x40000016` | bow string draw | +| Unload | `0x40000017` | bow string release | +| Pickup | `0x40000018` | item from floor | +| StoreInBackpack | `0x40000019` | stuff into pack | +| Eat | `0x4000001A` | food consumption | +| Drink | `0x4000001B` | potion consumption | +| Reading | `0x4000001C` | book open | +| JumpCharging | `0x4000001D` | holding space to charge | +| AimLevel..AimHigh15..AimHigh90 | `0x4000001E..24` | bow aim high | +| AimLow15..AimLow90 | `0x40000025..2A` | bow aim low | +| MagicBlast | `0x4000002B` | cast bolt spell | +| MagicSelfHead | `0x4000002C` | cast on self (head touch) | +| MagicSelfHeart | `0x4000002D` | cast on self (heart touch) | +| MagicBonus..MagicPenalty | `0x4000002E..34` | buffs/debuffs | +| MagicTransfer | `0x40000035` | life transfer | +| MagicVision | `0x40000036` | enchanted item sight | +| MagicEnchantItem | `0x40000037` | cast on item | +| MagicPortal | `0x40000038` | gate casting | +| MagicPray | `0x40000039` | portal recall prayer | +| CastSpell | `0x400000D3` | generic cast start | +| UseMagicStaff / UseMagicWand | `0x400000E0 / 0x400000E1` | focus channel | +| TwitchSubstate1..3 | `0x400000E4..E6` | stun twitch cycle | +| Pickup5..Pickup20 | `0x40000136..39` | timed pickup variants (large items) | +| **Ready** | `0x41000003` | **the idle cycle** — class byte 0x41 still ANDs with SubState=0x40000000 | +| Crouch | `0x41000012` | squat idle | +| Sitting | `0x41000013` | sit idle | +| Sleeping | `0x41000014` | sleep idle | +| RunForward | `0x44000007` | **primary locomotion** | +| WalkForward | `0x45000005` | | +| WalkBackwards | `0x45000006` | **uses WalkForward anim in reverse** (see §7.3) | +| TurnRight | `0x6500000D` | turn-in-place right | +| TurnLeft | `0x6500000E` | turn-in-place left (**uses TurnRight anim in reverse**) | +| SideStepRight | `0x6500000F` | lateral movement | +| SideStepLeft | `0x65000010` | lateral movement (**uses SideStepRight in reverse**) | + +### 3.3 Action commands (mask 0x10000000) — one-shot non-emote + +Attacks, portals, skill uses. The client queues these then reverts to +whatever cycle was playing. + +| Command | Hex | +|---------|-----| +| Hop | `0x1000004A` | +| Jumpup | `0x1000004B` | +| ChestBeat | `0x1000004D` | +| TippedLeft / TippedRight | `0x1000004E / 4F` | +| FallDown | `0x10000050` | +| Twitch1..4 | `0x10000051..54` | +| StaggerBackward / StaggerForward | `0x10000055..56` | +| **Sanctuary** | `0x10000057` (death trigger) | +| ThrustMed / Low / High | `0x10000058..5A` | +| SlashHigh / Med / Low | `0x1000005B..5D` | +| BackhandHigh / Med / Low | `0x1000005E..60` | +| Shoot | `0x10000061` (bow loose) | +| AttackHigh1..3 / Med1..3 / Low1..3 | `0x10000062..6A` | +| AttackHigh4..6 / Med4..6 / Low4..6 | `0x10000186..8E` (TOD expansion) | +| HeadThrow | `0x1000006B` (monster) | +| FistSlam | `0x1000006C` | +| BreatheFlame | `0x1000006D` | +| SpinAttack | `0x1000006E` | +| MagicPowerUp01..10 | `0x1000006F..78` (charging glyphs) | +| MagicPowerUp01Purple..10Purple | `0x1000012B..34` (void school) | +| EnterGame / ExitGame | `0x1000009C / 9D` | +| OnCreation / OnDestruction | `0x1000009E / 9F` | +| EnterPortal / ExitPortal | `0x100000A0 / A1` | +| SpecialAttack1..3 | `0x100000CD..CF` | +| MissileAttack1..3 | `0x100000D0..D2` | +| Blink | `0x100000E2` (teleport flash) | +| Bite | `0x100000E3` | +| SkillHealSelf / SkillHealOther | `0x1000010E / 10F` | +| LogOut | `0x1000011E` | +| DoubleSlashLow..High + TripleSlash... | `0x1000011F..24` | +| DoubleThrust... / TripleThrust... | `0x10000125..2A` | +| HouseRecall | `0x1000013A` | +| LifestoneRecall | `0x10000153` | +| Fishing | `0x10000165` | +| MarketplaceRecall | `0x10000166` | +| EnterPKLite | `0x10000167` | +| AllegianceHometownRecall | `0x10000171` | +| PKArenaRecall | `0x10000172` | +| OffhandSlashHigh..Low | `0x10000173..75` (dual wield) | +| OffhandThrustHigh..Low | `0x10000176..78` | +| OffhandDoubleSlash... / TripleSlash... | `0x10000179..7E` | +| OffhandDoubleThrust... / TripleThrust... | `0x1000017F..84` | +| OffhandKick | `0x10000185` | +| PunchFastHigh..Low / PunchSlowHigh..Low | `0x1000018F..94` | +| OffhandPunchFastHigh..Low etc. | `0x10000195..9A` | + +### 3.4 ChatEmote commands (mask 0x02000000 — **but stored with 0x12 / 0x13 class prefix**) + +The high nibble of emotes is `0x13` (Style mask **off**, ChatEmote mask +on). ACE calls these "ChatEmote-class", and retail blocks them in +combat stances (see §9). + +| Command | Hex | +|---------|-----| +| YMCA | `0x1200009B` | +| Flatulence | `0x120000D4` | +| Demonet | `0x120000DF` | +| Cheer | `0x1300004C` | +| ShakeFist | `0x13000079` | +| Beckon | `0x1300007A` | +| BeSeeingYou | `0x1300007B` | +| BlowKiss | `0x1300007C` | +| BowDeep | `0x1300007D` | +| ClapHands | `0x1300007E` | +| Cry | `0x1300007F` | +| Laugh | `0x13000080` | +| MimeEat / MimeDrink | `0x13000081..82` | +| Nod | `0x13000083` | +| Point | `0x13000084` | +| ShakeHead | `0x13000085` | +| Shrug | `0x13000086` | +| Wave | `0x13000087` | +| Akimbo | `0x13000088` | +| HeartyLaugh | `0x13000089` | +| Salute | `0x1300008A` | +| ScratchHead | `0x1300008B` | +| SmackHead | `0x1300008C` | +| TapFoot | `0x1300008D` | +| WaveHigh / WaveLow | `0x1300008E..8F` | +| YawnStretch | `0x13000090` | +| Cringe | `0x13000091` | +| Kneel | `0x13000092` | +| Plead | `0x13000093` | +| Shiver | `0x13000094` | +| Shoo | `0x13000095` | +| Slouch | `0x13000096` | +| Spit | `0x13000097` | +| Surrender | `0x13000098` | +| Woah | `0x13000099` | +| Winded | `0x1300009A` | +| Pray | `0x130000CA` | +| Mock | `0x130000CB` | +| Teapot | `0x130000CC` | +| WarmHands | `0x13000119` | +| Helper | `0x13000135` | +| NudgeLeft / NudgeRight | `0x1300014A..4B` | +| PointLeft / PointRight / PointDown | `0x1300014C..4E` | +| Knock | `0x1300014F` | +| ScanHorizon | `0x13000150` | +| DrudgeDance | `0x13000151` | +| HaveASeat | `0x13000152` | + +### 3.5 Persistent "State" emotes (class 0x43 = Style bit + SubState bit) + +These are the **looping** emote states, not one-shots. They go through the +cycle path, not the action path. + +``` +ShakeFistState..WindedState | 0x430000EA..FD +SnowAngelState | 0x43000118 +CurtseyState | 0x4300011A +AFKState | 0x4300011B +MeditateState | 0x4300011C +SitState..AtEaseState | 0x4300013D..49 +``` + +### 3.6 Jump and meta-commands + +``` +StopTurning = 0x2000003A (Modifier) +Jump = 0x2500003B (Modifier, "0x25" = SubState | Modifier | HoldKey bit) +``` + +`Jump` itself is handled specially through `CMotionInterp::jump` (see §12). + +--- + +## 4 — Stance transitions + +Transitioning from NonCombat → SwordCombat (or any two styles) triggers +a **two-phase blend**: + +1. **Phase 1:** play the `get_link(currentStyle, currentSubstate → DefaultStyle)` + transition — this unwinds the current style (e.g. "put away sword"). +2. **Phase 2:** play the `get_link(DefaultStyle, DefaultStyle's substate → newStyle)` + transition — this brings up the new style (e.g. "draw greatsword"). +3. **Phase 3:** enter the new style's default cycle. + +This is the expanded shape of `MotionTable.GetObjectSequence` (ACE +`:76-119`): + +```csharp +if ((motion & CommandMask.Style) != 0) { + if (currState.Style == motion) return true; // no-op + // Phase 1: from-link inside old style + if (substate != currState.Substate) + motionData = get_link(currState.Style, currState.Substate, SubstateMod, substate, speedMod); + // Phase 2+3: set-up for new style + if (substate != 0) { + Cycles.TryGetValue((motion << 16) | (substate & 0xFFFFFF), out cycles); + if (cycles != null) { + if ((cycles.Bitfield & 1) != 0) currState.clear_modifiers(); + var link = get_link(currState.Style, substate, SubstateMod, motion, speedMod); + if (link == null && currState.Style != motion) { + link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f); + motionData_ = get_link(DefaultStyle, defaultStyleSub, 1.0f, motion, 1.0f); + } + sequence.clear_physics(); + sequence.remove_cyclic_anims(); + add_motion(sequence, motionData, speedMod); // unwind link + add_motion(sequence, link, speedMod); // connector link + add_motion(sequence, motionData_, speedMod); // new-style intro + add_motion(sequence, cycles, speedMod); // new cycle + } + } +} +``` + +**Stance-transition timing.** Each link animation has its own duration +computed by `GetAnimationLength(animData) = (highFrame - lowFrame) / +abs(framerate)`. Cross-style transitions typically chain 3–4 links with +total duration 400 ms (NonCombat→Sword) to 1.8 s (HandCombat→TwoHandedSword +with big weapon swap). There is **no fixed blend time**; retail uses +framerate-driven timing on each AnimData. + +--- + +## 5 — Cycle-internal motion events (animation hooks) + +### 5.1 Per-frame hook list + +`AnimationFrame.Hooks` is a `List` stored after the +per-part transforms. Each frame can carry zero or many hooks. The full +`AnimationHookType` enum (`AnimationHookType.generated.cs:13–71`): + +| Value | HookType | Payload | Fires | +|------:|----------|---------|-------| +| `0x01` | **Sound** | `SoundHook { QualifiedDataId Id }` | Plays wave asset (footstep, armor rustle, voice) | +| `0x02` | SoundTable | `SoundTableHook { SoundType type }` | Plays stance-appropriate sound via cookbook | +| `0x03` | **Attack** | `AttackHook { AttackCone cone }` | Damage frame for melee/thrown (AttackCone has direction + height) | +| `0x04` | AnimationDone | none | Signals sequencer to move on (queued by update_internal itself when crossing cycle boundary) | +| `0x05` | ReplaceObject | `ReplaceObjectHook { int part, uint newObjId }` | Swap a part's GfxObj (weapon draws, etc.) | +| `0x06` | Ethereal | `EtherealHook { int bool }` | Make object non-colliding | +| `0x07` | TransparentPart | `TransparentPartHook { int part, float alpha }` | Per-part alpha | +| `0x08` | Luminous | `LuminousHook { float level }` | Glow | +| `0x09` | LuminousPart | per-part glow | +| `0x0A` | Diffuse | `DiffuseHook { float r, g, b }` | Object tint | +| `0x0B` | DiffusePart | per-part tint | +| `0x0C` | Scale | `ScaleHook { float newScale, float lerpTime }` | Grow/shrink | +| `0x0D` | CreateParticle | `CreateParticleHook { uint scriptId, int part }` | Spawn particle emitter | +| `0x0E` | DestroyParticle | Despawn emitter | +| `0x0F` | StopParticle | Pause emitter | +| `0x10` | NoDraw | toggle rendering | +| `0x11` | DefaultScript | run default particle script | +| `0x12` | DefaultScriptPart | same, per part | +| `0x13` | CallPES | Invoke PES script by id | +| `0x14` | Transparent | whole-object alpha | +| `0x15` | SoundTweaked | Sound with pitch/volume override | +| `0x16` | SetOmega | Set mesh omega (for spinning parts) | +| `0x17` | TextureVelocity | Scrolling texture (water) | +| `0x18` | TextureVelocityPart | per-part | +| `0x19` | SetLight | Dynamic light on/off | +| `0x1A` | CreateBlockingParticle | Same as CreateParticle but blocks anim progression until done | + +### 5.2 Firing semantics + +Hooks have a `Direction` (`AnimationHookDir`): `Forward`, `Backward`, or +`Both`. The client fires a hook when the frame cursor *crosses* the +frame-boundary: + +- **Forward playback** (framerate > 0): as `floor(framePos)` increments + from `i` to `i+1`, hooks on frame `i` with dir `Forward|Both` fire. +- **Backward playback** (framerate < 0): as `floor(framePos)` decrements + from `i` to `i-1`, hooks on frame `i` with dir `Backward|Both` fire. + +The hook list is accumulated on `PhysicsObj.add_anim_hook`, then drained +after the tick to dispatch side-effects (sound, damage, particle spawn). + +This is why `WalkBackwards` (played as reversed WalkForward) fires +different hooks than WalkForward — the LEFT/RIGHT footstep sounds are +tagged `Forward` and `Backward` respectively so they're still semantically +correct when played backward. + +### 5.3 Where attack damage actually lands + +`MotionTable.GetAttackFrames` (DatLoader `:87`) is the canonical way to +find the "hit frame" of an attack: + +```csharp +foreach (anim in motionData.Anims) + foreach (frame in anim.PartFrames) + foreach (hook in frame.Hooks) + if (hook is AttackHook ah) + frames.Add(totalFrames, ah); + totalFrames++; +// return [(time_percent = frameNum / totalFrames, attackHook), ...] +``` + +The server uses this to know *when* during the playback to roll damage — +the client fires an `Attack` hook at that frame, the server independently +computes the same timing and waits before applying damage. + +`AttackCone` carries: +- A direction (which arm / weapon hand) +- A height (high/med/low) +- An optional offset (for reach) + +So a dual-wield triple-slash produces **6 Attack hooks** across the +animation, each with its own cone — that's how one animation deals +multiple hits. + +--- + +## 6 — Blend times and frame epsilons + +**Retail does NOT use a fixed crossfade duration.** Interpolation +happens at two levels: + +### 6.1 Within-animation interpolation + +Between two adjacent AnimationFrames of the *same* AnimNode, the sequencer +slerps/lerps based on the *fractional part* of `_framePosition` (our port's +`AnimationSequencer.cs:619-620`). This is the standard "keyframes at +30Hz but render at 60Hz" smoothing. The fractional `t` is: +``` +t = framePosition - floor(framePosition) // always in [0,1) +``` + +### 6.2 Between-animation transitions + +There is **no crossfade** between the old cycle and the new link +animation. The retail sequencer's `advance_to_next_animation` +(`FUN_00525EB0`) just hops: + +```c +framePos = nextNode.GetStartFramePosition(); // hard cut +// Apply pos_frame delta to AFrame if PosFrames present +// Zero time spent interpolating between last-frame-of-old-node and +// first-frame-of-new-node +``` + +The *link animation itself* serves as the crossfade — designers built +short (5–15 frame) transitions whose job is to smoothly bring the +skeleton from "cycle A pose" to "cycle B pose". So the effective blend +time is the duration of the link MotionData, typically 150–400 ms. + +### 6.3 Frame epsilon + +The decompile repeatedly uses a tiny constant `_DAT_007c92b4` when +positioning the cursor at a frame boundary: + +```c +if (framerate >= 0) + startPos = (double)startFrame; +else + startPos = (double)(endFrame + 1) - EPSILON; // just under end+1 +``` + +This matches our port's `FrameEpsilon = 1e-5`. The purpose is to ensure +that `floor(framePos)` is never equal to the next frame on initialization +— the cursor always starts "just before" its transition boundary. + +The C `_DAT_007c92b4` value is `1.00000000000000082e-5` (double +precision). Our `1e-5` match is bit-identical for all practical +simulation rates. + +### 6.4 Rate epsilon + +`_DAT_007c9264` = the minimum-framerate cutoff = `1e-6`. Below this the +sequencer treats the animation as frozen and doesn't apply +pos_frame/physics deltas. Our port uses `RateEpsilon = 1e-6`. + +--- + +## 7 — Animation frame data + part transforms + +### 7.1 Shape + +``` +Animation { + uint32 id (DBObj header) + uint32 flags (AnimationFlags: PosFrames=0x1) + uint32 numParts ; # of skeleton bones + uint32 numFrames + if (flags & PosFrames) + Frame[numFrames] posFrames ; root motion: origin+orientation delta per frame + AnimationFrame[numFrames] ; each has numParts Frames + hook list +} + +Frame { + Vector3 origin + Quaternion orientation ; x,y,z,w order (IDENTITY = 0,0,0,1) +} + +AnimationFrame { + Frame[numParts] perPartTransform + uint32 numHooks + AnimationHook[numHooks] +} +``` + +`PosFrames` — when present, they define **root motion**. The first +part's root slides/rotates frame-to-frame, and this delta is accumulated +into the `AFrame` (object placement). This is what actually moves the +character forward during WalkForward — the animation bakes in the +displacement. + +### 7.2 How the cursor advances time + +The sequencer's `update_internal` (ACE `Sequence.cs:351-443`, decompile +`FUN_005261D0`): + +``` +frametime = framerate * dt // e.g. 30fps * 0.016s = 0.48 frames +lastFrame = floor(framePos) // where we were last tick +framePos += frametime // advance cursor + +if (frametime > 0): + if (highFrame < floor(framePos)): // overshoot boundary + frameOffset = framePos - highFrame - 1 // how far past end + frameTimeElapsed = frameOffset / framerate // left-over time + framePos = highFrame + animDone = true + while (floor(framePos) > lastFrame): + if (posFrames present): apply posFrame delta to AFrame + if (|framerate| > EPSILON): apply_physics(AFrame, 1/framerate, dt) + execute_hooks(partFrames[lastFrame], Forward) + lastFrame++ +``` + +Frame advance sequence per tick: +1. Compute `newFramePos = oldFramePos + framerate*dt` +2. Walk every integer boundary we crossed (could be multiple if dt large) +3. For each boundary: add posFrame delta + omega/velocity delta + hooks +4. If we overshot the node end, advance to next node with remainder time + +This is a **time-scaled integration**, not framerate-scaled. The actual +render framerate has no effect on animation speed. `framerate` (in +frames/sec) controls how fast the cursor moves. + +### 7.3 The left→right / forward→backward remap + +AC's MotionTable has **no cycles** for TurnLeft, SideStepLeft, or +WalkBackwards. These are remapped in `CMotionInterp::adjust_motion` +(ACE `MotionInterp.cs:394-428`, decompile `FUN_00528C20`): + +```csharp +switch (motion) { + case RunForward: return; // noop + case WalkBackwards: + motion = WalkForward; + speed *= -0.65f; // BackwardsFactor + break; + case TurnLeft: + motion = TurnRight; + speed *= -1.0f; + break; + case SideStepLeft: + motion = SideStepRight; + speed *= -1.0f; + break; +} +if (motion == SideStepRight) + speed *= 0.5f * (3.12f / 1.25f); // SidestepFactor correction + +if (holdKey == HoldKey.Run) + apply_run_to_command(ref motion, ref speed); // §8 +``` + +The speed sign-flip propagates through `multiply_framerate` (decompile +`FUN_005267E0`): any negative framerate swaps `startFrame ↔ endFrame` +so the advance logic works uniformly: + +```c +void multiply_framerate(AnimNode* node, float factor) { + if (factor < 0.0) { + // swap startFrame and endFrame + int tmp = node.startFrame; + node.startFrame = node.endFrame; + node.endFrame = tmp; + } + node.framerate *= factor; +} +``` + +### 7.4 Quaternion interpolation (retail slerp) + +The decompiled `FUN_005360d0` implements slerp with a special degenerate +fallback: + +```c +float dot = dot(q1, q2); +if (dot < 0) { q2 = -q2; dot = -dot; } // shorter arc +if (1-dot <= EPSILON) { // near-parallel + w1 = 1-t; w2 = t; // linear fallback +} else { + omega = acos(dot); sinOmega = sin(omega); + w1 = sin((1-t)*omega)/sinOmega; + w2 = sin(t*omega)/sinOmega; + // Retail-specific: validate results in [0,1] + if (w1 not in [0,1] || w2 not in [0,1]) { + w1 = 1-t; w2 = t; // bail to linear + } +} +result = w1*q1 + w2*q2 +``` + +Our port has this — no changes needed. + +--- + +## 8 — Speed modifiers + +### 8.1 The speed chain + +Speed flows through many multipliers before reaching the body: + +``` +1. Network: client packs RawMotionState.ForwardSpeed (1.0 for walk, 1.5-2x for run with HoldKey) +2. DoMotion: adjust_motion applies: + - BackwardsFactor (-0.65) if WalkBackwards + - Sign flip if TurnLeft/SideStepLeft + - SidestepFactor (0.5 * 3.12/1.25 = 1.248) if SideStepRight + - If HoldKey.Run: walk→run conversion + run-rate multiplier +3. MotionTable.add_motion: velocity = MotionData.Velocity * speedMod +4. Sequence advance: framerate = AnimData.Framerate * speedMod +5. PhysicsBody.set_local_velocity: clamp to min(velocity, RunAnimSpeed * MyRunRate) +``` + +Two constants govern the clamps: + +| Constant | ACE value | Retail DAT | Purpose | +|----------|----------:|-----------:|---------| +| `WalkAnimSpeed` | `3.12f` | `_DAT_007c96e4` | Meters/sec of a 1.0-speed WalkForward | +| `RunAnimSpeed` | `4.0f` | `_DAT_007c96e0` | Meters/sec of a 1.0-speed RunForward | +| `SidestepAnimSpeed` | `1.25f` | `_DAT_007c96e8` | Meters/sec of a 1.0-speed SideStepRight | +| `BackwardsFactor` | `-0.65f` | `_DAT_007c96d8` | Walk-backwards is 65% as fast as forward | +| `SidestepFactor` | `0.5f` | — | Sidestep defaults to half-speed | +| `MaxSidestepAnimRate` | `3.0f` | `_DAT_007c96ec` | Clamp on sidestep speed | +| `RunTurnFactor` | `1.5f` | `_DAT_007c96dc` | Hold-run multiplies turn speed by 1.5 | + +### 8.2 Run-rate (stamina-weighted) + +Players have a **RunRate** (0.5–2.0) based on Run skill + Quickness. +`WeenieObject.InqRunRate(out rate)` returns it. The client caches the +last-reported value in `MyRunRate` (offset `+0x7C`) so it can continue +computing speeds if the Weenie is momentarily unreachable. + +```csharp +// get_state_velocity (FUN_00528960): +velocity = Vector3.Zero; +if (SideStepCommand == SideStepRight) + velocity.X = SidestepAnimSpeed * SideStepSpeed; +if (ForwardCommand == WalkForward) + velocity.Y = WalkAnimSpeed * ForwardSpeed; +else if (ForwardCommand == RunForward) + velocity.Y = RunAnimSpeed * ForwardSpeed; + +rate = WeenieObj.InqRunRate(out _) ? queriedRate : MyRunRate; +maxSpeed = RunAnimSpeed * rate; // typically 4.0 to 8.0 m/s +if (|velocity| > maxSpeed) + velocity = normalize(velocity) * maxSpeed; // clamp diagonal +``` + +**Key insight:** the clamp is on Euclidean length, not per-axis. This +is why retail forward+sidestep diagonal isn't faster than pure forward +at max run rate. + +### 8.3 Framerate vs time scaling + +Both animation framerate and physics velocity scale linearly with +`speedMod`. So running at 2.0× rate: +- Framerate = `baseFramerate * 2.0` → animation plays 2× fast +- Velocity = `baseVelocity * 2.0` → body moves 2× fast +- Result: feet plant at correct spots (no "ice-skating") + +This is **time-scaled, not framerate-scaled**. The animation playback +in game loop ticks advances by `framerate * dt` each frame regardless +of render framerate. + +--- + +## 9 — Motion errors (WeenieError) + +From `WeenieError.cs` L148–L193: + +| Error | Value | Thrown by | Cause | +|-------|------:|-----------|-------| +| `None` | `0x00` | — | Success | +| `NoPhysicsObject` | `0x08` | all CMotionInterp entry points | Object detached / deallocated | +| `NoAnimationTable` | — | MotionTableManager.PerformMovement | MotionTable wasn't loaded | +| `NoMtableData` | `0x0043` | MotionTableManager | Cycle/link not found in table | +| `CantCrouchInCombat` | `0x003F` | DoMotion when in non-NonCombat style | Crouch attempted during combat | +| `CantSitInCombat` | `0x0040` | DoMotion, similar | Sit attempted | +| `CantLieDownInCombat` | `0x0041` | DoMotion | Sleep attempted | +| `CantChatEmoteInCombat` | `0x0042` | DoMotion (`motion & ChatEmote != 0`) | Wave/etc. in combat | +| `CantChatEmoteNotStanding` | `0x0044` | DoMotion indirectly | Emote while sitting/crouched | +| `TooManyActions` | `0x0045` | DoMotion action-path (>6 queued) | Action queue overflow | +| `Hidden` | `0x0046` | (not motion-owned) | Misc | +| `GeneralMovementFailure` | `0x0047` | Fall-through default | Switch default branch | +| `YouCantJumpFromThisPosition` | `0x0048` | motion_allows_jump, jump_is_allowed | Crouching, reading, casting, etc. | +| `CantJumpLoadedDown` | `0x0049` | jump_is_allowed | WeenieObj.CanJump returned false | + +Additional **action-state errors** from DoInterpretedMotion (decompile +`FUN_00528f70` L6967–L7005): + +| Code | Motion checked | Meaning | +|-----:|---------------|---------| +| `0x3F` | Crouch (`0x41000012`) while in combat | Same as CantCrouchInCombat | +| `0x40` | Sitting (`0x41000013`) while in combat | Same as CantSitInCombat | +| `0x41` | Sleeping (`0x41000014`) while in combat | Same as CantLieDownInCombat | +| `0x42` | ChatEmote bit (`0x02000000`) set while in combat | Same as CantChatEmoteInCombat | +| `0x45` | ActionMask bit (`0x10000000`) set and `num_actions >= 6` | Action queue full | + +The action queue limit of **6** is a retail invariant — see +`FUN_00529930:7614 if (5 < uVar3) return 0x45`. This means you can +line up a 3-slash combo + cast start, but a 7th action errors. + +--- + +## 10 — Network motion echoes (UpdateMotion) + +### 10.1 Wire shape of `GameMessageUpdateMotion` (opcode 0xF748 / Motion) + +Sent by server whenever any object's motion state changes. From +ACE `GameMessageUpdateMotion.cs` + `MovementData.cs` + `InterpretedMotionState.cs`: + +``` +GameMessage { + u32 opcode = 0xF748 ; Motion group=SmartboxQueue + ObjectGuid guid ; 4 bytes + u16 objectInstanceSequence ; ordering counter + u16 movementSequence ; incremented every Motion update + u16 serverControlSequence ; if IsAutonomous, use current; else next + u8 isAutonomous ; 1=client requested, 0=server-pushed + align(4) + u8 movementType ; enum: 0=Invalid..9=TurnToHeading + u8 motionFlags ; 0x01=StickToObject, 0x02=StandingLongJump + u16 currentStyle ; truncated to 16 bits (0x003D for NonCombat) + + switch (movementType) { + case Invalid: InterpretedMotionState state + if (motionFlags & 0x01) ObjectGuid stickyObject + case MoveToObject: ObjectGuid target; Origin origin; MoveToParameters; f32 runRate + case MoveToPosition: Origin; MoveToParameters; f32 runRate + case TurnToObject: ObjectGuid target; f32 desiredHeading; TurnToParameters + case TurnToHeading: TurnToParameters + } +} + +InterpretedMotionState { + u32 packedFlagsAndCount ; low 7 bits = MovementStateFlag, upper 25 = numCommands + if (flag & CurrentStyle) u16 currentStyle + if (flag & ForwardCommand) u16 forwardCommand ; low 16 bits of MotionCommand + if (flag & SideStepCommand) u16 sidestepCommand + if (flag & TurnCommand) u16 turnCommand + if (flag & ForwardSpeed) f32 forwardSpeed + if (flag & SideStepSpeed) f32 sidestepSpeed + if (flag & TurnSpeed) f32 turnSpeed + MotionItem[numCommands] ; each is 8 bytes: u16 cmd + u16 packedSeq + f32 speed + align(4) +} + +MotionItem { ; one entry in the "Commands" queue + u16 command + u16 packedSequence ; bit 15: isAutonomous, bits 0-14: sequence + f32 speed +} + +MovementStateFlag { ; 7 bits + CurrentStyle = 0x01 + ForwardCommand = 0x02 + ForwardSpeed = 0x04 + SideStepCommand= 0x08 + SideStepSpeed = 0x10 + TurnCommand = 0x20 + TurnSpeed = 0x40 +} + +Origin { u32 cellId; f32 x,y,z } +MoveToParameters { u32 flags; f32 distanceToObject, minDistance, failDistance, speed, walkRunThreshold, desiredHeading } +TurnToParameters { u32 flags; f32 speed, desiredHeading } +``` + +### 10.2 Client broadcasts vs local simulation + +The client maintains a **RawMotionState** (what it *wants* to do — +driven by keypresses) and an **InterpretedMotionState** (what the +sequencer should *actually* play). The server receives +`MoveToState` packets carrying the raw state, interprets them into +`InterpretedMotionState` via `MovementData(Creature, MoveToState)` +(ACE `:87-165`), and broadcasts `UpdateMotion` to observers. + +**Client simulation loop:** +1. Input thread sets `RawMotionState.ForwardCommand = WalkForward` + `HoldKey.Run` +2. `CMotionInterp::apply_raw_movement` calls `adjust_motion` → `WalkForward + Run` becomes `RunForward` with speed ×runRate +3. Animation sequencer starts the `(NonCombat, RunForward)` cycle +4. Physics body's local velocity = `RunAnimSpeed * runRate` +5. Client sends `0xF61B MoveToState` packet with the *raw* state +6. Server validates, emits `0xF748 UpdateMotion` with interpreted state to nearby observers +7. Observers' clients receive UpdateMotion, apply via `move_to_interpreted_state` + +**Observation of own player:** When the server broadcasts *my own* +motion back to me, I compare the received sequence to my local one via +`action.Stamp` (15-bit ring counter at offset `+0x78`). If server is +ahead of me, I catch up; otherwise I ignore. See `FUN_005295D0` L7363–L7388. + +### 10.3 The RawMotionState → InterpretedMotionState conversion + +This is the **server's** job, but it's worth understanding: + +```csharp +// MovementData.cs:87-165 +var holdKey = rawState.CurrentHoldKey; +var speed = (holdKey == HoldKey.Run) ? creature.GetRunRate() : 1.0f; +if (rawState.ForwardCommand == WalkForward || WalkBackwards) { + interp.ForwardCommand = (holdKey==Run && WalkForward) ? RunForward : WalkForward; + interp.ForwardSpeed = speed; + if (WalkBackwards) interp.ForwardSpeed *= -0.65f; +} +if (rawState.SidestepCommand != 0) { + interp.SidestepCommand = SideStepRight; + interp.SidestepSpeed = speed * (3.12/1.25) * 0.5 * (LeftSign); + interp.SidestepSpeed = clamp(-3, 3); +} +if (rawState.TurnCommand != 0) { + interp.TurnCommand = TurnRight; + interp.TurnSpeed = (Run)?1.5:1.0 * TurnSign; + // Mouselook override: if rawState.TurnSpeed <= 1.5 use it verbatim +} +``` + +--- + +## 11 — Audio-motion coupling + +### 11.1 Footstep sounds + +**Per-surface footsteps** are NOT encoded by the Sound hook directly — +they use `SoundTable` hooks (`AnimationHookType.SoundTable = 0x02`) +which carry a `SoundType` selector. The engine then looks up the +actual Wave id from the creature's `PhysicsObj.SoundTable`, keyed by +the surface material of the cell the creature is standing on. + +So `WalkForward` has two `SoundTable` hooks at the foot-plant frames +(frames 5 and 15 of a ~20-frame cycle typically). The SoundType might +be `StepLeft` / `StepRight`, and the resolved wave depends on whether +you're on grass (dirt+leaves whoosh), stone (metallic clack), water +(splash), etc. + +Surface-material lookup is outside R3 scope (lives in R9 terrain +system), but the attack/motion side is: **the hooks fire at fixed +frame indexes regardless of surface**, then the audio subsystem picks +the right wave. + +### 11.2 Attack whoosh + +Melee attacks have a `Sound` hook (not SoundTable) firing 2–3 frames +*before* the `Attack` hook — i.e. the wind-up whoosh precedes the +damage frame. The same Wave id is used regardless of weapon (it's +material-agnostic "swoosh"). Weapon-specific sounds (metal clang on +hit) fire from the server side via `GameMessageHearSound` after +damage resolves. + +### 11.3 Armor rustle + +An optional layer driven by `SoundTable` hooks on *every* animation +frame cycle where `RawMotionFlags.ArmorEncumbrance` was set on the +body. This is throttled client-side so you don't hear 60Hz rustling. +There is no per-frame hook for this; it's a PhysicsObj post-tick +callback. + +--- + +## 12 — Complete pseudocode for `DoInterpretedMotion` and friends + +### 12.1 DoInterpretedMotion (FUN_00528F70) — expanded + +``` +int DoInterpretedMotion(CMotionInterp* self, uint motion, MovementParameters* params) { + if (!self->physicsObj) return 0x08; // NoPhysicsObject + int allowed = contact_allows_move(motion); + if (!allowed) { + if (motion & 0x10000000) // Action bit + return 0x24; // YouCantJumpWhileInTheAir (0x48 in ACE) + // Non-action motion is silently accepted (state updates, no anim) + } else if (self->standingLongJump && (motion==WalkForward || motion==RunForward || motion==SideStepRight)) { + // No-op: standing long jump blocks all ground motion + } else { + if (motion == Dead) self->physicsObj->RemoveLinkAnimations(); + int result = self->physicsObj->DoInterpretedMotion(motion, params); + if (result == 0) { + int jumpErr; + if (params->flags & 0x20000) // DisableJumpDuringLink + jumpErr = 0x48; // YouCantJumpFromThisPosition + else { + jumpErr = motion_allows_jump(motion); + if (jumpErr == 0 && !(motion & 0x10000000)) + jumpErr = motion_allows_jump(self->interpretedState.ForwardCommand); + } + add_to_queue(params->contextId, motion, jumpErr); + if (params->flags & 0x4000) // ModifyInterpretedState + InterpretedState_ApplyMotion(&self->interpretedState, motion, params); + } + } + if (self->physicsObj && self->physicsObj->curCell == NULL) + self->physicsObj->RemoveLinkAnimations(); + return result; +} +``` + +### 12.2 adjust_motion (FUN_00528C20) — left/right/backward remap + +``` +void adjust_motion(uint* motion, float* speed, int holdKey) { + if (self->weenieObj != NULL && !self->weenieObj->IsCreature()) return; + switch (*motion) { + case 0x44000007: return; // RunForward: no-op + case 0x45000006: // WalkBackwards → WalkForward reversed + *motion = 0x45000005; *speed *= -0.65f; break; + case 0x6500000E: // TurnLeft → TurnRight reversed + *motion = 0x6500000D; *speed *= -1.0f; break; + case 0x65000010: // SideStepLeft → SideStepRight reversed + *motion = 0x6500000F; *speed *= -1.0f; break; + } + if (*motion == 0x6500000F) // SideStepRight: apply sidestep factor + *speed *= 0.5f * (3.12f / 1.25f); // = 1.248 + if (holdKey == HoldKey.Invalid) + holdKey = self->rawState.currentHoldKey; + if (holdKey == HoldKey.Run) + apply_run_to_command(motion, speed); +} +``` + +### 12.3 apply_run_to_command (FUN_005287F0) + +``` +void apply_run_to_command(uint* motion, float* speed) { + float rate = DEFAULT_RUN_RATE; + if (self->weenieObj) { + float r; + if (self->weenieObj->InqRunRate(&r)) rate = r; + else rate = self->myRunRate; + } + switch (*motion) { + case 0x45000005: // WalkForward + Run = RunForward + if (*speed > 0) *motion = 0x44000007; + *speed *= rate; break; + case 0x6500000D: // TurnRight + Run + *speed *= 1.5f; // RunTurnFactor + break; + case 0x6500000F: // SideStepRight + Run + *speed *= rate; + if (|*speed| > 3.0f) *speed = sign(*speed) * 3.0f; + break; + } +} +``` + +### 12.4 PerformMovement (FUN_00529A90) top-level + +``` +int PerformMovement(CMotionInterp* self, MovementStruct* mvs) { + int result; + switch (mvs->type) { + case 1: result = DoMotion(mvs->motion, &mvs->params); break; + case 2: result = DoInterpretedMotion(mvs->motion, &mvs->params); break; + case 3: result = StopMotion(mvs->motion, &mvs->params); break; + case 4: result = StopInterpretedMotion(mvs->motion, &mvs->params);break; + case 5: result = StopCompletely(); break; + default: return 0x47; // GeneralMovementFailure + } + self->physicsObj->CheckForCompletedMotions(); // drain PendingMotions queue + return result; +} +``` + +### 12.5 GetObjectSequence — the motion table walker + +``` +bool GetObjectSequence(MotionTable* mt, uint motion, MotionState* state, + Sequence* seq, float speedMod, uint* numAnims, bool stopMods) { + *numAnims = 0; + if (state->style == 0 || state->substate == 0) return false; + + uint styleDefault = mt->styleDefaults[state->style]; + if (motion == styleDefault && !stopMods && (state->substate & Modifier)) return true; + + // === STYLE CHANGE PATH === + if (motion & Style) { + if (state->style == motion) return true; + MotionData* fromLink = NULL; + if (styleDefault != state->substate) + fromLink = get_link(state->style, state->substate, state->substateMod, styleDefault, speedMod); + if (styleDefault != 0) { + MotionData* cycles = mt->cycles[(motion<<16)|styleDefault]; + if (cycles) { + if (cycles->bitfield & 1) state->clear_modifiers(); + MotionData* connectorLink = get_link(state->style, styleDefault, state->substateMod, motion, speedMod); + MotionData* secondLink = NULL; + if (!connectorLink && state->style != motion) { + connectorLink = get_link(state->style, styleDefault, 1.0f, mt->defaultStyle, 1.0f); + secondLink = get_link(mt->defaultStyle, mt->styleDefaults[mt->defaultStyle], 1.0f, motion, 1.0f); + } + seq->clear_physics(); + seq->remove_cyclic_anims(); + add_motion(seq, fromLink, speedMod); + add_motion(seq, connectorLink, speedMod); + add_motion(seq, secondLink, speedMod); + add_motion(seq, cycles, speedMod); + state->substate = styleDefault; + state->style = motion; + state->substateMod = speedMod; + re_modify(seq, state); + *numAnims = totalAnimsAcrossAllFour - 1; + return true; + } + } + } + + // === SUBSTATE CHANGE PATH (Walk → Run etc.) === + if (motion & SubState) { + uint motionID = motion & 0xFFFFFF; + MotionData* target = mt->cycles[(state->style<<16)|motionID]; + if (!target) target = mt->cycles[(mt->defaultStyle<<16)|motionID]; + if (target && is_allowed(motion, target, state)) { + if (motion == state->substate && seq->HasAnims() && + sign(speedMod) == sign(state->substateMod)) { + // Same command, just tweak speed + change_cycle_speed(seq, target, state->substateMod, speedMod); + subtract_motion(seq, target, state->substateMod); + combine_motion(seq, target, speedMod); + state->substateMod = speedMod; + return true; + } + if (target->bitfield & 1) state->clear_modifiers(); + MotionData* link = get_link(state->style, state->substate, state->substateMod, motion, speedMod); + MotionData* altLink = NULL; + if (!link || sign(speedMod)!=sign(state->substateMod)) { + uint def = mt->styleDefaults[state->style]; + link = get_link(state->style, state->substate, state->substateMod, def, 1.0f); + altLink = get_link(state->style, def, 1.0f, motion, speedMod); + } + seq->clear_physics(); + seq->remove_cyclic_anims(); + if (altLink) { + add_motion(seq, link, state->substateMod); + add_motion(seq, altLink, speedMod); + } else { + float ns = (state->substateMod < 0 && speedMod > 0) ? -speedMod : speedMod; + add_motion(seq, link, ns); + } + add_motion(seq, target, speedMod); + if (state->substate != motion && (state->substate & Modifier)) { + uint def = mt->styleDefaults[state->style]; + if (def != motion) state->add_modifier_no_check(state->substate, state->substateMod); + } + state->substateMod = speedMod; + state->substate = motion; + re_modify(seq, state); + return true; + } + } + + // === ACTION PATH (one-shot) === + if (motion & Action) { + uint cycleKey = (state->style<<16) | (state->substate & 0xFFFFFF); + MotionData* cycles = mt->cycles[cycleKey]; + if (cycles) { + MotionData* link = get_link(state->style, state->substate, state->substateMod, motion, speedMod); + if (link) { + state->add_action(motion, speedMod); + seq->clear_physics(); + seq->remove_cyclic_anims(); + add_motion(seq, link, speedMod); + add_motion(seq, cycles, state->substateMod); + re_modify(seq, state); + return true; + } + // ... fallback via defaultStyleSub path (lines 209-230 of MotionTable.cs) + } + } + + // === MODIFIER PATH (overlay) === + if (motion & Modifier) { + MotionData* base = mt->cycles[(state->style<<16) | (state->substate & 0xFFFFFF)]; + if (base && !(base->bitfield & 1)) { + MotionData* mod = mt->modifiers[(state->style<<16)|(motion & 0xFFFFFF)]; + if (!mod) mod = mt->modifiers[motion & 0xFFFFFF]; // style-agnostic fallback + if (mod) { + if (!state->add_modifier(motion, speedMod)) { + StopSequenceMotion(motion, 1.0f, state, seq, numAnims); + if (!state->add_modifier(motion, speedMod)) return false; + } + combine_motion(seq, mod, speedMod); // ADD velocities, don't replace + return true; + } + } + } + return false; +} +``` + +--- + +## 13 — Port plan (extensions to MotionInterpreter / AnimationSequencer) + +### 13.1 Gaps vs current port + +Current `MotionInterpreter` is ~660 LOC and covers: +- RawMotionState, InterpretedMotionState structs ✅ +- PerformMovement switch-5 ✅ +- DoMotion/DoInterpretedMotion (partial — no CantCrouch etc.) ✅ +- StopMotion/StopInterpretedMotion/StopCompletely ✅ +- get_state_velocity with max-speed clamp ✅ +- apply_current_movement (grounded only) ✅ +- Jump chain (jump, jump_is_allowed, get_leave_ground_velocity, LeaveGround, HitGround) ✅ + +Current `AnimationSequencer` covers: +- SetCycle with left→right remap ✅ +- Enqueue link + cyclic nodes, advance time, wrap to firstCyclic ✅ +- Retail slerp ✅ +- BUT: treats MotionData as mono-cycle. No style-transition chain, no + modifiers, no actions, no attack hooks, no sound hooks. + +### 13.2 Extensions required for R3 + +**`MotionInterpreter.cs` additions:** + +1. `public MotionState MotionState` (style/substate/substateMod + modifier list + action list) +2. `public enum HoldKey { Invalid, None, Run }` + `RawState.CurrentHoldKey` field +3. `CombatStyleGuards` in `DoMotion`: + ```csharp + if (InterpretedState.CurrentStyle != NonCombat) { + switch(motion) { + case Crouch: return CantCrouchInCombat; + case Sitting: return CantSitInCombat; + case Sleeping: return CantLieDownInCombat; + } + if ((motion & ChatEmote) != 0) return CantChatEmoteInCombat; + } + ``` +4. `ActionQueueLimit`: track `numActions`; throw `TooManyActions` at 6. +5. `adjust_motion` full port (currently only in AnimationSequencer.SetCycle — pull it up into MotionInterpreter so DoMotion/StopMotion both use it). +6. `apply_run_to_command` — handle HoldKey.Run + walk→run conversion + sidestep clamp. +7. `SetHoldKey(HoldKey k, bool cancelMoveTo)` + `set_hold_run(int, bool)`. +8. `motion_allows_jump(uint substate)` — the ranged-check helper: + ```csharp + // Reload..Pickup, TripleThrust..MagicPowerUp10Purple, MagicPowerUp01..10, + // Crouch..Sleeping, AimLevel..MagicPray, Falling → YouCantJumpFromThisPosition + ``` +9. `move_to_interpreted_state(InterpretedMotionState state)` — receive + and replay the server's authoritative state, including action-stamp + comparison logic from `FUN_005295D0`. +10. `enter_default_state()` and `HandleEnterWorld()/HandleExitWorld()`. + +**`AnimationSequencer.cs` additions:** + +1. **`EnqueueStyleTransition(uint oldStyle, uint oldSub, uint newStyle, float speed)`** — the multi-link chain from §4. +2. **`AddModifier(uint modCommand, float speed)` / `RemoveModifier`** — maintain the overlay list. When a modifier is active, its `MotionData.Velocity` and `Omega` are *added* to the base cycle's (not replaced). See `CombinePhysics` / `subtract_physics` in ACE Sequence.cs. +3. **`AddAction(uint actionCommand, float speed)`** — queue a one-shot + link+cycle pair. The action plays to completion then pops. +4. **Per-frame hook dispatch** — populate a `PendingHooks: List` each tick. Callers consume it via a new `IReadOnlyList ConsumePendingHooks()` method. +5. **`PosFrames` integration** — if the current Animation has PosFrames, + apply the per-frame origin+orientation delta to a running AFrame + (exposed as a `PartTransform` on "part -1" or a separate `RootMotion` property). +6. **Speed-change mid-cycle** — when the same command is re-issued with a + different speed, don't restart; call `multiply_cyclic_animation_framerate(newSpeed/oldSpeed)` — see `MotionTable.change_cycle_speed`. Currently `SetCycle`'s fast-path returns early for same motion but doesn't handle speed changes. + +**New classes:** + +- `MotionState` — mirror of ACE's (Style, Substate, SubstateMod, Modifiers, Actions). +- `Motion` (sequence node): `{ uint ID; float SpeedMod }` for modifier/action tracking. +- `MotionTableWalker` — a new type that owns the GetObjectSequence logic, separate from both MotionInterpreter (which is physics-focused) and AnimationSequencer (which is time-stepper-focused). Mirror of ACE's `MotionTable` + `MotionTableManager`. +- `AttackHook`/`SoundHook`/`SoundTableHook` types (already in DatReaderWriter; just need our own switch dispatch). + +**Wire layer (R10 adjacent but referenced here):** + +- `MovementData` reader/writer matching ACE's layout byte-for-byte. +- `InterpretedMotionState` + `RawMotionState` + `MotionItem` codecs — holtburger has working Rust implementations to mirror. +- Server→client `0xF748 Motion` handler that constructs `MovementStruct { type=2, motion=interp.ForwardCommand, ... }` and calls `MotionInterpreter.PerformMovement`. + +--- + +## 14 — Conformance test golden cases + +1. **Cycle key for (NonCombat, WalkForward) = `0x003D0005`** + ```csharp + Assert.Equal(0x003D0005, MotionTable.MakeCycleKey(MotionStance.NonCombat, MotionCommand.WalkForward)); + ``` + +2. **Cycle key for (Magic, MeditateState) = `0x0049011C`** + ```csharp + Assert.Equal(0x0049011C, MotionTable.MakeCycleKey(MotionStance.Magic, MotionCommand.MeditateState)); + ``` + +3. **TurnLeft remaps to TurnRight with speed ×-1** + ```csharp + uint motion = (uint)MotionCommand.TurnLeft; float speed = 1.0f; + interp.AdjustMotion(ref motion, ref speed, HoldKey.None); + Assert.Equal((uint)MotionCommand.TurnRight, motion); + Assert.Equal(-1.0f, speed); + ``` + +4. **WalkBackwards remaps to WalkForward ×-0.65** + ```csharp + uint motion = (uint)MotionCommand.WalkBackwards; float speed = 1.0f; + interp.AdjustMotion(ref motion, ref speed, HoldKey.None); + Assert.Equal((uint)MotionCommand.WalkForward, motion); + Assert.Equal(-0.65f, speed); + ``` + +5. **Hold-Run + WalkForward becomes RunForward × runRate** + ```csharp + uint motion = (uint)MotionCommand.WalkForward; float speed = 1.0f; + interp.MyRunRate = 1.5f; + interp.AdjustMotion(ref motion, ref speed, HoldKey.Run); + Assert.Equal((uint)MotionCommand.RunForward, motion); + Assert.Equal(1.5f, speed, 1e-6); + ``` + +6. **SideStepRight applies SidestepFactor** + ```csharp + uint motion = (uint)MotionCommand.SideStepRight; float speed = 1.0f; + interp.AdjustMotion(ref motion, ref speed, HoldKey.None); + Assert.Equal(1.248f, speed, 1e-3); // 0.5 * 3.12 / 1.25 + ``` + +7. **Seven actions queued → TooManyActions on 7th** + ```csharp + var interp = MakeInterp(); + for (int i = 0; i < 6; i++) + Assert.Equal(WeenieError.None, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters())); + Assert.Equal(WeenieError.TooManyActions, interp.DoMotion(MotionCommand.SlashHigh, new MovementParameters())); + ``` + +8. **Crouch in SwordCombat fails** + ```csharp + interp.InterpretedState.CurrentStyle = (uint)MotionStance.SwordCombat; + Assert.Equal(WeenieError.CantCrouchInCombat, + interp.DoMotion(MotionCommand.Crouch, new MovementParameters())); + ``` + +9. **Jump while airborne fails** + ```csharp + phys.TransientState &= ~TransientStateFlags.OnWalkable; + Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, interp.Jump(1.0f, 0)); + ``` + +10. **Wave emote in NonCombat succeeds** + ```csharp + interp.InterpretedState.CurrentStyle = (uint)MotionStance.NonCombat; + Assert.Equal(WeenieError.None, + interp.DoMotion(MotionCommand.Wave, new MovementParameters())); + ``` + +11. **AnimationSequencer.SetCycle with speed=0 doesn't advance** + ```csharp + seq.SetCycle(NonCombat, RunForward, speedMod: 0f); + var pre = seq.Advance(0.016f); + var post = seq.Advance(0.016f); + AssertAllTransformsEqual(pre, post); + ``` + +12. **Negative framerate reverses playback** (golden from decompiled multiply_framerate): + ```csharp + var node = new AnimNode(anim, framerate: -30f, startFrame: 0, endFrame: 10, isLooping: true); + // After multiply_framerate, cursor should start near endFrame+1 - ε + Assert.True(node.GetStartFramePosition() > 10.0 - 1e-3); + ``` + +13. **MotionData.Velocity scales linearly with speedMod** + ```csharp + var md = new MotionData { Velocity = new Vector3(0, 4, 0) }; // 4 m/s forward + seq.AddMotion(md, speedMod: 0.5f); + Assert.Equal(new Vector3(0, 2, 0), seq.Velocity); + ``` + +14. **Attack hook fires exactly once per animation cycle** + ```csharp + seq.SetCycle(SwordCombat, SlashHigh, speedMod: 1f); + var hitCount = 0; + for (int i = 0; i < 100; i++) { + seq.Advance(0.016f); + hitCount += seq.ConsumePendingHooks().Count(h => h is AttackHook); + } + Assert.Equal(1, hitCount); + ``` + +--- + +## 15 — Cross-cut notes + +- **Thread-safety:** `MotionTable` is read-only after unpack; walking it + is safe from any thread. `Sequence` and `MotionInterpreter` are + per-entity and expected to run on the sim thread. The client uses + a single dedicated thread for all motion updates (see chunk_00450000.c). + +- **Memory layout sensitivity:** `CMotionInterp` struct offsets are + cited throughout (`+0x20` ForwardCommand, `+0x4C` interp-ForwardCommand, + `+0x70` StandingLongJump, `+0x74` JumpExtent, `+0x78` ActionStamp, + `+0x7C` MyRunRate). Our C# port doesn't need to preserve these but + the decompiled references do, and when debugging from raw memory + dumps they're useful. + +- **PendingMotions queue:** Each `DoInterpretedMotion` success adds a + `MotionNode { contextID, motion, jumpErrorCode }` to the + PendingMotions linked list. This list drains when the sequencer + reports the animation has *fully* played (via `HookObj.add_anim_hook(AnimationDone)`). + Until then, `physicsObj->IsAnimating = true` and `motions_pending() == true`. + +- **Link-removal optimization:** `MotionTableManager.remove_redundant_links` + scans the PendingAnimations list; when two consecutive entries have + the same motion ID (e.g. double-tap of WalkForward), it truncates + the older one by setting its NumAnims to 0 and calling + `sequence.remove_link_animations(totalAnims)`. This is how + rapid-fire key-mashing doesn't pile up. + +--- + +## References cited + +- `docs/research/decompiled/chunk_00520000.c`: + L3080–L3117 (PerformMovement switch), + L4368–L4677 (update_internal + advance_to_next_animation), + L4698–L4784 (multiply_framerate, GetStart/EndFramePosition), + L6433–L6504 (add_to_queue, apply_run_to_command), + L6565–L6611 (get_state_velocity), + L6616–L6641 (StopCompletely), + L6750–L6800 (adjust_motion), + L6803–L6955 (get_leave_ground_velocity), + L6955–L7004 (DoInterpretedMotion), + L7045–L7088 (StopMotion), + L7132–L7189 (apply_current_movement), + L7194–L7213 (jump), + L7217–L7235 (apply_raw_movement), + L7341–L7391 (move_to_interpreted_state / FUN_005295D0), + L7395–L7441 (HitGround / LeaveGround), + L7554–L7622 (DoMotion), + L7627–L7658 (PerformMovement dispatcher). + +- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs`: + L51–L110 DoInterpretedMotion, L112–L158 DoMotion, L236–L262 PerformMovement, + L301–L327 StopCompletely, L394–L428 adjust_motion, L430–L504 apply_interpreted_movement, + L506–L523 apply_raw_movement, L525–L562 apply_run_to_command, L678–L700 get_state_velocity, + L710–L727 jump, L729–L779 jump_is_allowed / motion_allows_jump. + +- `references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs`: + L55–L257 GetObjectSequence (style/substate/action/modifier paths), + L266–L291 SetDefaultState, L293–L356 StopObject*, L358–L393 add_motion/combine/subtract, + L395–L426 get_link, L428–L438 is_allowed, L440–L458 re_modify. + +- `references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs`: + L83–L87 CombinePhysics, L145–L201 advance_to_next_animation, + L203–L216 append_animation, L218–L230 apply_physics, L232–L243 apricot, + L262–L270 execute_hooks, L277–L287 multiply_cyclic_animation_framerate, + L351–L443 update_internal (time stepper). + +- `references/DatReaderWriter/DatReaderWriter/Generated/`: + `DBObjs/MotionTable.generated.cs` (L27–L57 schema fields), + `DBObjs/Animation.generated.cs` (L27–L68), + `Types/MotionData.generated.cs`, `Types/AnimData.generated.cs`, + `Types/AnimationFrame.generated.cs`, `Types/AnimationHook.generated.cs` (L45–L128 polymorphic unpack), + `Enums/AnimationHookType.generated.cs`, `Enums/MotionDataFlags.generated.cs`, + `Enums/MotionCommand.generated.cs` (the full 450+ entry command catalog). + +- `references/holtburger/crates/holtburger-protocol/src/messages/movement/`: + `messages/motion.rs` (MovementEventData wire layout), + `types.rs` (InterpretedMotionState, RawMotionState, MotionItem codec). + +- `references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs`, + `InterpretedMotionState.cs` (server-side writer). + +- `references/ACE/Source/ACE.Entity/Enum/CommandMasks.cs` (Style/SubState/etc. bits), + `references/ACE/Source/ACE.Entity/Enum/WeenieError.cs` L148–L193 (error codes). diff --git a/docs/research/deepdives/r04-vfx-particles.md b/docs/research/deepdives/r04-vfx-particles.md new file mode 100644 index 0000000..c2f2153 --- /dev/null +++ b/docs/research/deepdives/r04-vfx-particles.md @@ -0,0 +1,1056 @@ +# R4 — VFX / Particle System Deep Dive + +Research pass over the complete retail AC particle / VFX stack: dat layout, +aggregation types, simulation math, emitter attachment model, known effects, +rendering pipeline, and a concrete port plan for acdream (C# .NET 10 + +Silk.NET GL). Cross-referenced against five codebases: + +- **Retail client decompilation** — `docs/research/decompiled/chunk_005A*.c` + through `chunk_006A*.c`. Symbols are mostly stripped (no + `particle_emitter` strings survive in symbol form); references confirm via + UI hook ("`Particles Rendered`", "`Particle Systems`" in + `chunk_005D0000.c:8723-8728`). +- **DatReaderWriter** — source of truth for the on-disk layout. + `DBObjs/ParticleEmitter.generated.cs`, + `DBObjs/ParticleEmitterInfo.generated.cs`, + `DBObjs/PhysicsScript.generated.cs`, + `DBObjs/PhysicsScriptTable.generated.cs`, + `Enums/ParticleType.generated.cs`, + `Enums/EmitterType.generated.cs`, + `Enums/PlayScript.generated.cs`, and the animation-hook types. +- **ACE server** — `ACE.Server/Physics/Particles/*.cs` is the direct + ACE-to-retail port of the simulation loop. Nearly identical shape to + decompiled code. +- **ACViewer** — `Physics/Particles/*.cs` plus the MonoGame render pipeline + in `Render/ParticleBatch.cs`, `Render/ParticleBatchDraw.cs`, + `Render/ParticleTextureFormat.cs`, `Model/ParticleDeclaration.cs`, and + the HLSL `Content/texture.fx` `PointSprite` technique. +- **WorldBuilder** — our exact Silk.NET stack. Complete GL port at + `Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`, + `Lib/ParticleEmitterRenderer.cs`, shaders at + `Chorizite.OpenGLSDLBackend/Shaders/Particle.vert` and `Particle.frag`. +- **WorldBuilder-ACME-Edition** — the most refined, client-faithful CPU + simulator: + `WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs`. This is the + drop-in reference for acdream's simulation side. + +--- + +## 1. `ParticleEmitterInfo` dat layout + +The particle system has **two co-existing dat record classes**: + +| Type | DB const | DID range | Role | +|------|----------|-----------|------| +| `ParticleEmitter` | `DB_TYPE_PARTICLE_EMITTER` | `0x32000000 – 0x3200FFFF` | Named "alias" wrapper | +| `ParticleEmitterInfo` | `DB_TYPE_PARTICLE_EMITTER` | (same range) | Equivalent struct (alias) | +| `PhysicsScript` | `DB_TYPE_PHYSICS_SCRIPT` | `0x33000000 – 0x3300FFFF` | Timeline of hooks | +| `PhysicsScriptTable` | `DB_TYPE_PHYSICS_SCRIPT_TABLE` | `0x34000000 – 0x3400FFFF` | `PlayScript → {Mod,ScriptID}[]` | + +DatReaderWriter generates two classes for `0x32*` because the retail client +exposes both spellings (`ParticleEmitter` and `ParticleEmitterInfo`) with +byte-identical layouts. ACE's DatLoader picks `ParticleEmitterInfo` as the +canonical class and that's what most of the rest of the code keys on. + +### Byte-exact unpack order + +From `DatReaderWriter/Generated/DBObjs/ParticleEmitter.generated.cs:175-208`: + +``` +uint Id (inherited DBObj header) +uint Unknown // zeroed in every sample, pad or debug flag +int EmitterType // see enum below +int ParticleType // see enum below +uint GfxObjId // software-path mesh (palette-indexed billboard) +uint HwGfxObjId // hardware-path mesh (DXT/BGRA billboard). USE THIS. +double Birthrate // seconds between spawns (BirthratePerSec) OR meters (BirthratePerMeter) +int MaxParticles // concurrent cap +int InitialParticles // spawn-at-creation count +int TotalParticles // lifetime cap (0 = unlimited) +double TotalSeconds // emitter shutdown (0 = infinite) +double Lifespan // base particle lifetime seconds +double LifespanRand // ± random jitter on lifespan +Vector3 OffsetDir // spawn disk axis (see GetRandomOffset below) +float MinOffset // min radial distance along offset disk +float MaxOffset // max radial distance +Vector3 A // base vector A (usually initial velocity) +float MinA / MaxA // magnitude multiplier range for A +Vector3 B // base vector B (usually acceleration) +float MinB / MaxB +Vector3 C // base vector C (usually angular velocity or swirl radius) +float MinC / MaxC +float StartScale +float FinalScale +float ScaleRand +float StartTrans // 0 = opaque, 1 = invisible (transparency, not alpha) +float FinalTrans +float TransRand +bool IsParentLocal // stream particles in parent space vs world (MSVC writes 4 bytes; reader tolerates either) +``` + +### EmitterType enum — emission cadence + +`Enums/EmitterType.generated.cs` (and ACE's mirror +`ACE.Entity/Enum/EmitterType.cs`): + +| Value | Name | Behavior | +|-------|------|----------| +| 0 | Unknown | Treated as BirthratePerSec by ACE + WorldBuilder fallbacks | +| 1 | `BirthratePerSec` | Emit when `now - lastEmitTime > Birthrate` (seconds) | +| 2 | `BirthratePerMeter` | Emit when `|parentPos - lastEmitPos|² > 0` (meter-based for tracer trails) | + +### ParticleType enum — motion integrator + +`Enums/ParticleType.generated.cs`: + +| Value | Name | Meaning | +|-------|------|---------| +| 0 | Unknown | Zero motion (Still fallback) | +| 1 | `Still` | Position = parent + offset, no drift | +| 2 | `LocalVelocity` | `A` is baked into parent space at spawn (swims with parent rotation) | +| 3 | `ParabolicLVGA` | Local velocity + **Global** Acceleration (e.g. gravity) | +| 4 | `ParabolicLVGAGR` | LVGA + Global Rotation (angular velocity applies in world frame) | +| 5 | `Swarm` | Sinusoidal bees pattern: `cos/sin(B·t) * C + A·t` drift | +| 6 | `Explode` | One-shot detonation: randomized direction on unit sphere, scaled by `C` | +| 7 | `Implode` | Particles home in: `cos(A.x·t)·C + t²·B` with C bound to offset | +| 8 | `ParabolicLVLA` | Local velocity + Local Acceleration (both rotate with parent) | +| 9 | `ParabolicLVLALR` | LVLA + Local Rotation | +| 10 | `ParabolicGVGA` | Global velocity + Global Acceleration (pure projectile) | +| 11 | `ParabolicGVGAGR` | GVGA + Global Rotation | +| 12 | `GlobalVelocity` | Position = parent + offset + A·t, A in world frame | +| 13 | NumParticleType | Terminator | + +Mnemonic: **L**ocal **V**elocity means A is multiplied by the parent's +rotation at spawn time and "frozen." **G**lobal means the vector is already +in world space and never rotated. **LA**/**GA** distinguishes acceleration +vector B. **GR**/**LR** suffix means the **C** vector is an angular velocity +that rotates the billboard/mesh orientation over time. + +### Spawn-volume shape + +`GetRandomOffset()` (ACE `ParticleEmitterInfo.cs:173-187`, matches +`AcParticleEmitterSimulator.GetRandomOffset` at +`WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs:428-447`): + +``` +rng = Vector3(rand[-1,1], rand[-1,1], rand[-1,1]) +perp = rng - OffsetDir * dot(OffsetDir, rng) // project rng to plane ⊥ OffsetDir +if |perp| < ε: return Vector3.Zero // degenerate case +perp = normalize(perp) +magnitude = MinOffset + rand[0,1] * (MaxOffset - MinOffset) +return perp * magnitude +``` + +**Geometric meaning**: particles spawn on a **disk annulus** whose plane is +perpendicular to `OffsetDir`, with inner radius `MinOffset` and outer +radius `MaxOffset`. This is the one and only spawn volume shape in AC — +there are no cones, boxes, or sphere-volume emitters at the data level. +Sphere-like emission (omnidirectional puff) is achieved by picking a near- +zero `OffsetDir` and relying on the random vector; truly spherical bursts +use `ParticleType.Explode` which re-rolls a random unit vector per particle. + +### Color over time + +**AC has no per-particle color field.** All color comes from the texture on +the `GfxObj` (mesh or point-sprite billboard). Over-time visual variation is +expressed via: + +- `StartScale` → `FinalScale` — linear interpolation over lifespan +- `StartTrans` → `FinalTrans` — linear interpolation (1 = invisible) +- Texture UV flipbook if the `GfxObj` contains a multi-frame surface + +Where retail effects need a color shift (e.g., blue spell-aura) they use a +different texture/`GfxObj`, not a per-frame tint. This is why every spell +school has its own dat chain. + +--- + +## 2. `EmitterInfo` aggregation — `PhysicsScript`/`PhysicsScriptTable` + +A single `ParticleEmitter` dat record defines ONE emitter. Real effects +almost always combine several emitters into a "script" timeline. + +### PhysicsScript — one effect's timeline + +`DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs`: + +``` +PhysicsScript { + List ScriptData +} + +PhysicsScriptData { + double StartTime // offset into the script when this hook fires + AnimationHook Hook // tagged union (polymorphic) +} +``` + +`AnimationHook` subclasses that concern VFX: + +| Hook | Fields | Role | +|------|--------|------| +| `CreateParticleHook` | `EmitterInfoId`, `PartIndex`, `Offset (Frame)`, `EmitterId` | Spawn a named emitter bound to a body part | +| `CreateBlockingParticleHook` | — | Identical to CreateParticle but blocks motion-table advance until the emitter retires (used to gate spell cast sequences) | +| `StopParticleHook` | `EmitterId` | Mark emitter stopped (no new spawns, existing particles finish) | +| `DestroyParticleHook` | `EmitterId` | Instantly despawn all remaining particles | +| `DefaultScriptHook` | — | Used inside animation tables; implicit | + +The `EmitterId` field is **the effect-local ID** (a small int, often 1–16), +NOT the dat file ID. Multiple hooks with the same `EmitterId` address the +same live emitter: you create with id 5, then at `StartTime=1.2s` you +stop id 5, then at `StartTime=2.0s` you destroy id 5. This lets scripts +emit, pause, re-emit, and despawn trails over time. + +### PhysicsScriptTable — `PlayScript` multi-variant dispatch + +`DBObjs/PhysicsScriptTable.generated.cs`: + +``` +PhysicsScriptTable { + Dictionary ScriptTable +} + +PhysicsScriptTableData { + List Scripts // sorted by Mod descending +} + +ScriptAndModData { + float Mod // intensity threshold (0.0–1.0 typically) + QualifiedDataId ScriptId // DID into 0x33000000 range +} +``` + +The server sends `GameMessageScript(objectGuid, PlayScript, mod)` to clients. +The client picks the `Scripts` entry whose `Mod` is the largest value ≤ the +incoming `mod`. That's how a Frost I missile and a Frost VII missile can +share a `PlayScript.Launch` entry but show different-intensity effects: the +spell sets mod=0.3 vs mod=0.95, and the table picks the matching +`PhysicsScript` DID. This is the dispatch lookup done by +`ParticleViewer.GetModIdx()` (`ACViewer/ParticleViewer.cs:46-53`). + +### PlayScript enum — the well-known effect namespace + +`DatReaderWriter/Generated/Enums/PlayScript.generated.cs` has **174 entries**. +Catalog by category (IDs in hex): + +**Generic** (0x01–0x05): +- `Test1/2/3`, `Launch`, `Explode` + +**Attribute buffs/debuffs** (0x06–0x11): six colors × up/down +- `AttribUpRed`, `AttribDownRed`, …, `AttribDownPurple` + +**Skill buffs/debuffs** (0x12–0x1E): six colors × up/down + `SkillDownBlack` +- `SkillUpRed` … `SkillDownPurple` + +**Vital buffs/debuffs** (0x1F–0x2A): Health / Regen, 3 colors × up/down +- `HealthUpRed`, `HealthDownRed`, `HealthUpBlue`, `HealthDownBlue`, + `HealthUpYellow`, `HealthDownYellow`, `RegenUp*`, `RegenDown*` + +**Shield / Enchant / Swap** (0x2B–0x50): all six colors each +- `ShieldUp*`, `ShieldDown*`, `EnchantUp*`, `EnchantDown*`, + `SwapHealth_*_To_*` (stamina/mana transfers), `TransUp/DownWhite/Black` + +**Spells** (0x51–0x57): +- `Fizzle`, `PortalEntry`, `PortalExit`, `BreatheFlame`, `BreatheFrost`, + `BreatheAcid`, `BreatheLightning` + +**Lifecycle** (0x58–0x5A): +- `Create`, `Destroy`, `ProjectileCollision` + +**Combat splatters** (0x5B–0x72): 12 splatter + 12 spark directions +- `SplatterLow/Mid/UpLeft/RightBack/Front`, + `SparkLow/Mid/UpLeft/RightBack/Front` + +**Environment** (0x73): +- `PortalStorm` + +**Visibility** (0x74–0x77): +- `Hide`, `UnHide`, `Hidden`, `DisappearDestroy` + +**Special states** (0x78–0x81): 10 state slots +- `SpecialState0` … `SpecialState9` + +**Special state colors** (0x82–0x89): 8 colors +- `SpecialStateRed` … `SpecialStateBlack` + +**Progression** (0x8A): +- `LevelUp` + +**Enchant variants** (0x8B–0x8F): +- `EnchantUp/DownGrey`, `WeddingBliss`, `EnchantUp/DownWhite` + +**Social / GM** (0x90–0x97): +- `CampingMastery`, `CampingIneptitude`, `DispelLife`, `DispelCreature`, + `DispelAll`, `BunnySmite`, `BaelZharonSmite`, `WeddingSteele` + +**Restriction / Aug** (0x98–0xAD): +- `RestrictionEffect{Blue,Green,Gold}`, `LayingofHands`, + `AugmentationUse{Attribute,Skill,Resistances,Other}`, `BlackMadness`, + `AetheriaLevelUp`, `AetheriaSurge*` (5 types), `*DownVoid` (nether + damage), `DirtyFighting*` (4 debuffs) + +The scripts in every PhysicsScriptTable are what drive the user-facing +effect vocabulary. An acdream PhysicsScript interpreter must implement +these 174 effect IDs at minimum — but the interpretation is generic +(walk the `ScriptData` list, execute each hook) so we only write the +dispatch once. + +--- + +## 3. Billboard vs 3D particle types + +Every particle is rendered as a small `GfxObj`. The `GfxObj` dictates whether +the particle is camera-facing (billboard) or a fixed 3D mesh. + +### Heuristic — ACViewer's rule + +`ACViewer/ParticleViewer.cs:167-173`: + +``` +bool isPointSprite = gfxObj._gfxObj.SortCenter.NearZero() + && gfxObj._gfxObj.Id != 0x0100283B; +``` + +If the GfxObj's `SortCenter` is ~zero (the mesh is a centered single-quad / +single-tri) AND it's not the specific exempt GfxObj, treat it as a point +sprite: build a unit quad at the particle position and face-camera it in +the vertex shader. + +Otherwise, use the full mesh. Parabolic*GR particle types deliberately +use full meshes (swords, arrows, sparks) because they carry visible +orientation. + +### WorldBuilder's refinement + +`Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs:80-88`: + +``` +bool isPointSprite = (_gfxRenderData == null); +if (_gfxRenderData != null) { + var degradeId = _gfxRenderData.DIDDegrade; + if (degradeId != 0) { + if (dats.Portal.TryGet(degradeId, out var degrades) + && degrades.Degrades.Count > 0) { + _isPointSprite = degrades.Degrades[0].DegradeMode == 2; + } + } +} +``` + +More robust: an attached `GfxObjDegradeInfo` with `DegradeMode == 2` flags +the emitter's mesh as a sprite target. ACME/WorldBuilder use this; we +should too. + +### Render split + +- **Billboards**: one shared quad VBO, per-particle instance of + `(worldPos, scale, opacity, textureIndex, size)`; vertex shader expands + to a camera-facing quad. +- **3D mesh particles**: per-particle full model matrix + `(translate ⋅ rotate ⋅ scale)`; standard mesh pipeline but batched into + the particle draw-call group. + +Particle meshes in practice are always either a 1-quad billboard or a +2-quad "cross" (two perpendicular billboards for volumetric smoke/fire). +No particle is an animated skeletal mesh. + +--- + +## 4. Alpha blending modes + +`ACViewer.Render.ParticleTextureFormat` (`Render/ParticleTextureFormat.cs:22`): + +``` +IsAdditive = surfaceType.HasFlag(SurfaceType.Additive); +``` + +The `SurfaceType` flag on the `Surface` struct of the particle's `GfxObj` +is the discriminator. There are exactly **two** blend modes used: + +| SurfaceType flag | Render mode | Used for | +|------------------|-------------|----------| +| `Additive` | `SrcAlpha + One` (GL: `SrcAlpha, One`) | Fire, spell glows, sparks, spell swirls | +| (default) | `SrcAlpha + OneMinusSrcAlpha` | Smoke, splatters, portal sweep, realistic fog | + +WorldBuilder confirms (`ParticleBatcher.cs:179-185`): + +``` +if (_currentIsAdditive) gl.BlendFunc(SrcAlpha, One); +else gl.BlendFunc(SrcAlpha, OneMinusSrcAlpha); +``` + +**Pre-multiplied alpha is not used** — AC textures are straight RGBA +(BGRA in memory). The HLSL `PointSprite` shader in ACViewer samples direct, +multiplies alpha by `xOpacity`, and clips fully-transparent fragments. +WorldBuilder's fragment shader does `discard` under 0.005: + +``` +if (color.a < 0.005) discard; +``` + +### Sort order + +Back-to-front per-frame, per the following WorldBuilder pattern +(`ParticleBatcher.cs:149-153`): + +``` +_allParticles.Sort((a, b) => b.DistanceSq.CompareTo(a.DistanceSq)); +``` + +After the 3D opaque pass and before UI, all particles are bucketed by +`(TextureArray, IsAdditive)` and drawn in one `glDrawElementsInstanced` per +bucket, with depth test on but depth writes off +(`gl.DepthMask(false)`). Additive can blend in any order, but +non-additive (smoke) must still be sorted to avoid occlusion artifacts; +batching by texture-first-then-sort-within is ACViewer's compromise and +WorldBuilder's approach. Retail, running on DX8, sorted within each +PhysicsScript's draw batch and relied on alpha-clip for crisp cutouts. + +--- + +## 5. Sprite animation + +There is **no UV flipbook** on a single particle. Sprite animation is achieved +by chaining multiple emitters in one `PhysicsScript`: + +- Explosion: `PhysicsScript` emits three sequential bursts with different + `GfxObj` textures at `StartTime=0.0, 0.15, 0.35`. Each frame is a new + emitter with `TotalParticles=1` and a short `Lifespan`. +- Spell burst: 4–6 `CreateParticle` hooks offset by ~30ms each, using + different-colored swirl textures. + +Per-frame animation inside a single particle's lifetime would require the +`GfxObj` itself to reference a sequence texture (the dat supports animated +surfaces via `Surface.OriginalFormatAndFlags` with `SurfaceType.Animated`), +but examination of retail effect dats shows they don't rely on this for +particles — all flipbook-like visuals are hook chains. + +--- + +## 6. Emitter attach points + +The trickiest part. An emitter can be attached to: + +1. **World only** — `PartIndex = -1`, `IsParentLocal = false`. Particles + world-space from spawn; parent motion doesn't move them. Chimney smoke + on a building works this way. +2. **Entity origin** — `PartIndex = -1`, `IsParentLocal = true`. Particles + live in the parent's frame (`Parent.Position.Frame`). Breathing, a + floating aura. Moves with the creature as a whole but ignores + individual body-part animation. +3. **Entity body part** — `PartIndex = N ≥ 0`, `IsParentLocal = true`. + Particles attach to part N of the setup (e.g., right hand = 13, spell + wand = 1). The emitter reads + `Parent.PartArray.Parts[PartIndex].Pos.Frame` each frame so the emitter + **follows the part through its animation**. + +`PartIndex` → body-part index in the creature's `Setup.Parts` array. The +mapping is setup-specific; for humanoids the well-known indices are: + +| Index | Part | +|-------|------| +| 1 | Right hand (weapon, spell comp) | +| 2 | Left hand | +| 3 | Head | +| 5–8 | Torso / hip | +| 13–14 | Feet | + +(Actual values vary by setup; decompiled retail reads +`SetupID → Setup.Parts[i].GfxObjID` to find the matching part.) + +### Offset Frame + +`CreateParticleHook.Offset` is a `Frame` (position + quaternion) applied +inside the parent-part's local space. For a torch: `PartIndex=0` (torch +mesh), `Offset=(0, 0, 0.45)` spawns the fire emitter at the top of the +torch regardless of how the torch is held. The frame is stored in dat +coordinates; our port multiplies it into the part-local transform with +standard `Matrix4x4.CreateFromQuaternion(Offset.Orientation) * +Matrix4x4.CreateTranslation(Offset.Origin)` and composes with the part's +world matrix each frame. + +### Retail `set_parent` call + +ACE port (`ParticleEmitter.cs:40-48`): + +```csharp +public bool SetParenting(int partIdx, AFrame frame) +{ + if (PhysicsObj == null || !PhysicsObj.set_parent(Parent, partIdx, frame)) + return false; + PartIndex = partIdx; + ParentOffset = new AFrame(frame); + return true; +} +``` + +`PhysicsObj.set_parent(parent, partIdx, offsetFrame)` registers the child +with the parent's shadow-cell tracking so the emitter gets despawn/freeze +events when the parent is occluded, destroyed, or teleported. We will +reproduce this in acdream with a `ParentReference { IEntity Entity; int +PartIndex; Matrix4x4 Offset; }` record attached to each live emitter. + +### `IsParentLocal` semantics + +`ParticleEmitter.UpdateParticles()` in ACE (`:216-238`): + +```csharp +if (Info.IsParentLocal) { + if (PartIndex == -1) frame = Parent.Position.Frame; + else frame = Parent.PartArray.Parts[PartIndex].Pos.Frame; +} +else frame = Particles[i].StartFrame; +``` + +**IsParentLocal = true** recomputes the reference frame every tick, so +particles ride the parent. **IsParentLocal = false** caches the +`StartFrame` at particle spawn, so the particle is "released" into the +world at that frame and the parent can walk away without dragging the +particles. Buff auras (`EnchantUpBlue` etc.) use IsParentLocal = true; +spell-launch explosions use false. + +Retail keeps a special case: `ParticleType.Still` with IsParentLocal=true +also updates the offset vector each tick so it effectively just pins to +the parent (used for stationary glows attached to a moving creature). + +--- + +## 7. Well-known effects — catalog + +Effect → PhysicsScriptTable path. These are the DIDs the server issues via +`GameMessageScript`. The tables themselves live in `client_portal.dat` in +the `0x34000000` range. Exact DIDs are specific to the dat release; below +are the well-established canonical IDs corroborated by ACE constants and +ACME/ACViewer's test captures. + +| Effect (PlayScript) | Typical emitter chain | Notes | +|---------------------|-----------------------|-------| +| **Portal swirl (static portal)** | emitted by the portal-weenie's `Setup.DefaultScript` (a `PhysicsScript`, not via PlayScript) — chains 3–4 `CreateParticleHook` emitters with `ParticleType.Swarm`, `Lifespan` infinite, `IsParentLocal=true`, `TotalSeconds=0` | Geometry = single billboard point-sprite with a radially-gradient texture; C vector drives swirl radius | +| **Chimney smoke** | static building's `Setup.DefaultScript` → `CreateParticleHook` with `PartIndex` = chimney part, `ParticleType.ParabolicLVGA` with tiny upward A and zero-g B | Grey-alpha billboard, slow birthrate, 8–15 maxParticles | +| **Fireplace fire** | same pattern, `ParticleType.LocalVelocity`, additive flame texture, high birthrate (~0.05 s), 20–40 maxParticles | Usually 2 emitters: big-flame + small-spark | +| **Torch flame** | `ParticleType.Swarm` for flicker, `IsParentLocal=true`, attached to torch's head part | Identical recipe to fireplace but tighter scale | +| **Campfire** | 3 emitters: flame (additive), smoke (alpha), sparks (Explode type) | Cumulative effect | +| **Spell buff aura** (e.g. `EnchantUpBlue`) | PhysicsScriptTable with 3–5 Mod tiers. `PhysicsScript` = single `CreateParticle` using ParticleType.Swarm with `ParentLocal=true`, `TotalSeconds=0.5–2.5` then auto-destroy | Color from texture only; "Blue" → blue-tinted GfxObj | +| **Spell projectile trail** | attached on missile creation. `ParticleType.ParabolicLVLA` with `A` = -velocity-direction (trails behind) | Uses `EmitterType.BirthratePerMeter` so the tail thickens/thins with speed | +| **Impact puff** (`Splatter*`, `Spark*`) | Short-lived burst. `ParticleType.Explode`, `InitialParticles` = 8–20, `TotalParticles` = same (no re-spawn), `Lifespan` ≈ 0.4s | 12 directional variants map splat to the hit hemisphere | +| **Lifestone glow** | Scenery weenie's `Setup.DefaultScript`. Two emitters: crystal aura (additive, ParticleType.Swarm) + falling sparkles (ParabolicGVGA, gravity-affected) | Distinctive amber texture | +| **Rain / snow** | Regional effect hooked at landblock entry. `EmitterType.BirthratePerSec` very fast (~0.002s), `ParticleType.GlobalVelocity` with A = down vector. A single world-anchored emitter per landblock, IsParentLocal=false | Retail didn't rain on foliage in most areas; most outdoor weather is a single hook-in per region | +| **Portal Storm warning** (`PortalStorm`) | Screen-wide ambient emitter triggered by server; spawns in a ring around the player | Corresponds to `chunk_00560000.c:3164-3275` strings | +| **LevelUp** | Dense burst of EnchantUp-like particles, all colors chained | The celebration pyrotechnics | + +### Portal visual specifics + +A retail portal gate is NOT a procedural mesh. It's one of: + +1. **A billboard disk** (most common): `ParticleEmitterInfo.ParticleType = + Swarm`, `ParentLocal = true`, `TotalSeconds = 0`, `Lifespan = 1.2s`, + `Birthrate = 0.08s`, `MaxParticles = 40`. Particles orbit the emitter + origin in a disk; the `C` vector = swirl radius, `B` = angular velocity + (sinusoid). The visual illusion of a spinning portal is the pattern of + many small sprite particles flowing through the Swarm path. +2. **A two-part GfxObj emitter**: one non-billboard mesh (a flat textured + disk rotating via `ParabolicLVLALR`'s `C` angular-velocity term) plus a + particle cloud in front of it. This is how the more elaborate + dungeon-summon portals work. + +The portal disk has **no scrolling UV** — apparent rotation is the +rotation of the particles themselves (via Swarm or *LR types), plus the +underlying GfxObj if it's the mesh variant. + +--- + +## 8. Emission triggering — when effects fire + +Five distinct trigger pathways, discovered by tracing ACE server usage: + +1. **On-spawn / on-create**: creature or object Setup's `DefaultScript` or + `DefaultScriptTable[PlayScript.Create]` fires automatically when the + PhysicsObj enters the world. ACE: + `WorldObject.EnterWorld` → `ApplyVisualEffects(PlayScript.Create)` + (`WorldObject.cs:726-727`). +2. **Permanent / ambient**: Setup's `DefaultScriptTable` with an entry + keyed to a state (e.g. `SpecialState*`) that the creature keeps active. + The torch, fireplace, portal gate, lifestone all use this — the + emitter just runs forever. +3. **Motion-driven (animation hook)**: inside a `MotionTable` animation + sequence, frames can carry `CreateParticleHook` embedded in the + `AnimationFrame.Hooks`. E.g. casting start fires a swirl around the + hands mid-animation; swing motion fires a swoosh trail from the weapon + tip at frame 8. These fire during animation playback and are + auto-cleaned by matching `DestroyParticleHook` in later frames. +4. **Network-driven (`GameMessageScript`)**: server sends + `{ Guid, PlayScript, Mod }`. Client looks up the PhysicsScriptTable on + the target's Setup, picks the matching entry via `mod`, then dispatches + the PhysicsScript (which is a sequence of hooks with StartTimes). The + timer system fires each hook when its StartTime is reached. +5. **Collision-driven**: physics-collision resolver fires + `PlayScript.ProjectileCollision` or a directional `Splatter*`/`Spark*` + at the hit point. The emitter is parented to the victim with + `PartIndex = -1, IsParentLocal = true, Offset = hitLocalFrame`. + +### Timed hook execution — the "timer" inside PhysicsScript + +The client's PhysicsScript runner keeps a `runningSince` timestamp per active +script. Each tick: + +``` +foreach script in activeScripts: + elapsed = now - script.runningSince + while script.nextHookIdx < script.ScriptData.Count + and script.ScriptData[script.nextHookIdx].StartTime <= elapsed: + execute(script.ScriptData[script.nextHookIdx].Hook) + script.nextHookIdx++ + if nextHookIdx >= count and no-live-emitters: script retire +``` + +This is ACE's `ScriptManager`-equivalent (ACE calls it via the +PhysicsObj directly). Retail used a sorted timer priority queue inside +`CPhysicsObj::UpdateScripts` to amortize. + +--- + +## 9. Particle pooling — retail's caps + +From ACE's `ParticleEmitter` (`:115-122`): + +```csharp +PartStorage = new PhysicsPart[Info.MaxParticles]; // pre-allocated +for (i = 0; i < Info.MaxParticles; i++) + PartStorage[i] = PhysicsPart.MakePhysicsPart(Info.HWGfxObjID); +Particles = new Particle[Info.MaxParticles]; +for (i = 0; i < Info.MaxParticles; i++) + Particles[i] = new Particle(); +``` + +Each emitter pre-allocates a `MaxParticles`-sized slab of `PhysicsPart`s +plus a corresponding `Particle[]` struct-array. On emit, +`GetNextParticleIdx()` linear-scans for a null entry and fills it. On +kill, the `Particles[i]` struct is retained (just timestamp-reset on +persistent emitters) and the `Parts[i]` pointer nulls. + +WorldBuilder matches this with a fixed-length `AcParticleSlot[]` and an +`Active` flag. ACME uses `Math.Clamp(_def.MaxParticles, 1, 512)` as a hard +cap — retail's absolute upper bound was 512 per emitter, and +`chunk_005D0000.c:8723-8728` hooks into UI overlays that show running +totals ("Particles Rendered", "Particle Systems"). + +### No global LRU + +Retail did not implement a global particle LRU. Instead, each emitter +enforced its `MaxParticles` hard cap and relied on distance-degrade: + +`PhysicsObj.ShouldDrawParticles(degradeDistance)` (ACE +`PhysicsObj.cs:1540-1544`): + +```csharp +public bool ShouldDrawParticles(float degradeDistance) { + if (!ExaminationObject) return true; + return !(CYpt > degradeDistance || CurCell == null); +} +``` + +Particles beyond a per-emitter `DegradeDistance` (ACE stubs it to +`float.MaxValue`) get `SetNoDraw(true)` — they keep simulating (so the +server-side script advances) but aren't rendered. Real retail probably +used a per-`GfxObjDegradeInfo` value; for acdream we can set a sensible +default (50 m outdoor, 15 m indoor) and override via a PluginAPI hook. + +--- + +## 10. Performance — draw-call batching + +WorldBuilder's `ParticleBatcher.Flush()` is the model: + +1. Sort all live particle instances by distance (back-to-front). +2. Walk the sorted list, grouping runs with identical + `(TextureArray, IsAdditive)`. +3. For each group: upload the instance range to the instance VBO with + `glBufferSubData`, bind the `texture2DArray` (with the per-particle + texture slice indexed by `iTextureIndex` attribute), set the blend + mode, issue **one** `glDrawElementsInstanced(Triangles, 6, …, count)`. + +The instance struct is 56 bytes (`ParticleInstance` = +`Position:3f + ScaleOpacityActive:3f + TextureIndex:1f + Rotation:4f + +Size:2f + IsBillboard:1f = 14 floats`). With a 64K instance buffer we can +carry 64,000 live particles without reallocation. + +State-changes minimized: + +- One VAO for all particles +- One vertex buffer (unit quad 4 verts) + one element buffer (6 indices) +- One texture array per "atlas" (grouped by dimensions + format) +- Depth-write off, depth-test on, cull off +- Two blend-state changes max per draw cycle (additive vs alpha) +- Shader bound once + +This is two orders of magnitude fewer draw-calls than retail's DX8 +per-emitter DrawIndexedPrimitive. We can afford up to tens of thousands +of particles per frame on modern hardware. + +--- + +## 11. Alpha & sorting — finer points + +| Concern | Retail | acdream / WorldBuilder | +|---------|--------|------------------------| +| Particle ↔ particle depth | No z-write, sorted back-to-front per-emitter | No z-write, sorted back-to-front globally | +| Particle ↔ world depth | z-test against opaque scene | z-test against opaque scene | +| Particle ↔ translucent geometry (water, glass) | Drawn after translucent pass | Same | +| Sub-pixel alpha threshold | alpha-clip < 1 fully-opaque, < 1 + clip for trans | `if (a < 0.005) discard;` | +| Alpha-test cutout (leaves-style) | PointSpritePS technique splits opaque and trans into two passes | Single pass using a discard | + +The single-pass discard WorldBuilder uses loses retail's early-Z benefit +on cutout particles, but at these particle counts the fragment-shader +cost is negligible on modern GPUs. + +--- + +## 12. Port plan — acdream types & integration + +### C# class layout + +```csharp +// acdream/Rendering/Particles/EmitterDesc.cs (thin adapter over DatReaderWriter.ParticleEmitter) +public sealed class EmitterDesc { + public required ParticleEmitter Dat; // DatReaderWriter type, direct + public uint GfxObjId => Dat.HwGfxObjId.DataId != 0 ? Dat.HwGfxObjId.DataId : Dat.GfxObjId.DataId; + public bool IsPersistentUnlimited => Dat.TotalParticles == 0 && Dat.TotalSeconds == 0; + public float SortingSphereRadius { get; init; } // computed in Bind() + // Serves ParticleEmitterInfo role from ACE. +} + +// acdream/Rendering/Particles/ParticleSlot.cs (struct, hot-loop) +public struct ParticleSlot { + public bool Active; + public double BirthTime; + public float Lifespan; + public Vector3 Offset; + public Vector3 A, B, C; // resolved at spawn per ParticleType rules + public Quaternion StartRotation; + public Matrix4x4 StartFrame; // cached when !IsParentLocal + public float StartScale, FinalScale; + public float StartTrans, FinalTrans; + public Vector3 CachedWorldPos; // recomputed each tick +} + +// acdream/Rendering/Particles/LiveEmitter.cs +public sealed class LiveEmitter { + public required EmitterDesc Desc; + public required IEntityHandle ParentEntity; // game-object reference + public int PartIndex = -1; + public Matrix4x4 OffsetFrame = Matrix4x4.Identity; + public int ScriptLocalId; // matches CreateParticleHook.EmitterId + + // Hot loop state + private ParticleSlot[] _slots; + private int _totalEmitted; + private double _creationTime; + private double _lastEmitTime; + private Vector3 _lastEmitOrigin; + private bool _stopped; + + public void Advance(double now, float dt); // simulation (CPU) + public void Collect(ParticleBatcher batch, Vector3 camPos); // per-frame buffer fill + public void Stop(); // no new spawns + public void Destroy(); // clear live slots +} + +// acdream/Rendering/Particles/ParticleManager.cs +public sealed class ParticleManager { + private readonly Dictionary _byLocalId = new(); + private int _nextAutoId = 1; + + public int CreateParticleEmitter(IEntityHandle parent, uint emitterInfoDid, int partIndex, Matrix4x4 offset, int scriptLocalId); + public bool StopEmitter(int localId); + public bool DestroyEmitter(int localId); + public void UpdateAll(double now, float dt); + public void DestroyAll(); +} + +// acdream/Rendering/Particles/ParticleBatcher.cs (the Silk.NET GL batcher; port of WorldBuilder's) +// Same struct layout as WorldBuilder.ParticleBatcher — GL-level, one VAO, instanced draw. + +// acdream/Rendering/Particles/PhysicsScriptRunner.cs (hook timeline walker) +public sealed class PhysicsScriptRunner { + // One runner per active PhysicsScript on each entity. + // Walks ScriptData ordered by StartTime and fires hooks into ParticleManager. + // Also handles blocking-particle wait states for motion-sequence chaining. +} + +// acdream/Rendering/Particles/PlayScriptDispatcher.cs +// Maps incoming (entityGuid, PlayScript, mod) to (PhysicsScript DID) and hands off to the runner. +``` + +### Integration points (render pipeline) + +The render pass ordering must be: + +``` +RenderFrame: + 1. Terrain pass (opaque, z-write on) + 2. Static scenery pass (opaque, z-write on) + 3. Creature/entity pass (opaque, z-write on) + 4. Translucent geometry pass (z-write off, back-to-front) + 5. --> Particle pass <-- (z-write off, depth-test on) + a. ParticleManager.UpdateAll(now, dt) (simulation; CPU) + b. For each LiveEmitter: emitter.Collect(batcher, cam) + c. batcher.Flush() — sort, group, draw + 6. Debug lines / overlays + 7. UI +``` + +Simulation runs every frame regardless of visibility (matches retail's +`ShouldDrawParticles` behavior: hidden emitters still advance their +script). This costs a bit of CPU but keeps semantics correct — when an +ally buff expires behind a wall, it should still be gone when you see +them again. + +### Silk.NET shader sketch + +`Shaders/Particle.vert` (port WorldBuilder's verbatim; the logic is +correct): + +```glsl +#version 330 core + +layout(location = 0) in vec3 aPosition; // unit quad vertex (-0.5..0.5) +layout(location = 1) in vec2 aTexCoord; + +layout(location = 2) in vec3 iPosition; +layout(location = 3) in vec3 iScaleOpacityActive; +layout(location = 4) in float iTextureIndex; +layout(location = 5) in vec4 iRotation; // quaternion +layout(location = 6) in vec2 iSize; +layout(location = 7) in float iIsBillboard; + +uniform mat4 uViewProjection; +uniform vec3 uCameraUp; +uniform vec3 uCameraRight; + +out vec2 TexCoord; +out float Opacity; +out float TextureIndex; + +vec3 rotateByQ(vec3 v, vec4 q) { return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); } + +void main() { + TexCoord = aTexCoord; + Opacity = iScaleOpacityActive.y; + TextureIndex = iTextureIndex; + float scale = iScaleOpacityActive.x; + vec3 worldPos; + if (iIsBillboard > 0.5) { + worldPos = iPosition + + uCameraRight * aPosition.x * iSize.x * scale + + uCameraUp * aPosition.z * iSize.y * scale; + } else { + vec3 local = vec3(aPosition.x * iSize.x * scale, 0.0, aPosition.z * iSize.y * scale); + worldPos = iPosition + rotateByQ(local, iRotation); + } + gl_Position = uViewProjection * vec4(worldPos, 1.0); +} +``` + +`Shaders/Particle.frag`: + +```glsl +#version 330 core +in vec2 TexCoord; +in float Opacity; +in float TextureIndex; +uniform sampler2DArray uTextureArray; +out vec4 FragColor; +void main() { + vec4 c = texture(uTextureArray, vec3(TexCoord, TextureIndex)); + c.a *= Opacity; + if (c.a < 0.005) discard; + FragColor = c; +} +``` + +### Decompile-first checklist for the port + +Per CLAUDE.md mandatory workflow, for each of these sub-tasks find the +matching retail function BEFORE porting: + +1. **`GetRandomOffset` / disk sampling** — retail reference: + `AcParticleEmitterSimulator.GetRandomOffset` + (`WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs:428-447`). + ACE mirror at `ParticleEmitterInfo.cs:173-187`. Confirmed match. +2. **`Particle.Init` switch on ParticleType** — retail: + ACE `Particle.cs:47-108`; ACME `AcParticleEmitterSimulator.cs:219-273`. + Both have the seven vector-space conventions. Use ACME's (it's the + more recent, more-testably-correct form). +3. **`Particle.Update` motion math** — retail: ACE `Particle.cs:123-180`; + ACME `AcParticleEmitterSimulator.cs:285-348`. Port ACME; add a + conformance test comparing XYZ trajectories for all 13 ParticleTypes + to the ACME simulator output. +4. **`ShouldEmitParticle` cadence** — ACE + `ParticleEmitterInfo.cs:155-171`; ACME + `AcParticleEmitterSimulator.cs:142-151`. +5. **Parenting / `set_parent`** — ACE `ParticleEmitter.cs:40-48`; resolve + via our own `IEntityHandle` abstraction. This is new code; no direct + decompiled reference needed beyond the call signature. +6. **PhysicsScriptRunner hook loop** — no direct ACE class (ACE handles + scripts server-side as just `GameMessageScript` broadcasts). Model on + ACViewer's `ParticleViewer.InitEmitter` + time-based hook dispatch. The + decompiled form is inside `CPhysicsObj::UpdateScripts` (not yet + individually mapped; it's in chunk 0x005D or 0x005E). +7. **Texture atlas packing** — port WorldBuilder's + `ParticleBatcher.ParticleInstance` + texture-array construction + verbatim. Non-AC specific; standard modern GL pattern. + +### Conformance testing + +For each `ParticleType`, spawn one particle with fixed deterministic +random inputs and record the XYZ trajectory at t = 0.1, 0.5, 1.0, 2.0 +seconds. Assert bytewise-identical floats against the ACME simulator. +This catches subtle dispatch errors (swapped A/B/C handling per type) in +CI rather than by visual inspection. + +For the emitter-cadence test: spawn with +`EmitterType=BirthratePerSec, Birthrate=0.1, MaxParticles=10, +TotalSeconds=1.0` and assert exactly 10 emissions by t=1.0s ±1. + +--- + +## 13. Render ordering and plugin layering + +Per the architecture doc, VFX has three distinct lifecycle owners: + +| Lifecycle | Source of truth | Starts | Stops | +|-----------|-----------------|--------|-------| +| Landblock-level ambient (chimney, portal gate) | Scenery/Setup `DefaultScript` on load | Enter world | Landblock unload | +| Entity-level ambient (torch, lifestone) | Setup `DefaultScriptTable[PlayScript.Create]` | WorldObject spawn | WorldObject destroy | +| Spell/buff/hit effect (one-shot or timed) | `GameMessageScript` from server | Message received | Script's totalSeconds or explicit destroy | + +All three use the same `ParticleManager` + `PhysicsScriptRunner`. The +distinction is only in who owns the live-script reference: + +- **Landblock** owns a set of `PhysicsScriptRunner` instances, destroyed + on landblock eviction. +- **Entity** owns a set of runners on creation, destroyed with the + entity. +- **Spell chain (R1)** creates runners as network events arrive; they + self-retire when the script completes. + +The **plugin API** surface: + +```csharp +// acdream.PluginApi.Particles +public interface IParticleEffects { + int Play(IEntityHandle target, uint playScriptOrEffectDid, float mod = 0f); + int PlayAt(Vector3 worldPos, uint emitterInfoDid); + void Stop(int liveEmitterId); + void Destroy(int liveEmitterId); + IReadOnlyList Active { get; } +} +``` + +Spellcasting (R1 spell chain) calls `IParticleEffects.Play(self, +PlayScript.WindupFlame, intensity)` at the cast-start message and is +what `ParticleEmitter::CreateParticleEmitter` resolves down to. The +BlockingParticle hook type is handled by the MotionSequencer waiting on +the runner's completion flag before advancing to the next motion. + +Portal gates are always-on: spawned as part of landblock load when the +portal weenie enters the client world, destroyed only on teleport/cell +unload. They do not go through `PlayScript.Create` dispatch — their +emitters are rooted in `Setup.DefaultScript`. + +Chimney smoke is landblock-level: its emitter is on the building setup's +`DefaultScript`, IsParentLocal=false, so it stays put while the player +orbits the building. + +--- + +## 14. Attachment-model subtleties (the "ultrathink" item) + +Getting "particles follow this body part as the creature moves" right +hinges on three things: + +1. **Frame update timing**. Every frame the animation sequencer must run + FIRST (populating `Parent.PartArray.Parts[PartIndex].Pos.Frame`), + THEN the `ParticleManager.UpdateAll` must read those frames, THEN the + renderer collects and draws. If particle update runs before + animation, particles lag the skeleton by one frame (visible jitter on + fast movements like a swing). + +2. **Offset composition order**. The particle's world frame is computed + as: + ``` + partWorldFrame = parentEntity.WorldMatrix * parentEntity.PartFrame[PartIndex] + emitterAnchor = partWorldFrame * offsetFrame // CreateParticleHook.Offset + // if IsParentLocal: use emitterAnchor every tick + // else: cache emitterAnchor at spawn, reuse + ``` + Both ACE and ACME agree on this order; the decompiled client's + `C_Frame::multiply(inner, outer)` composes left-to-right, so our + matrix convention must too. **Test the order** by porting ACME's + conformance test for a rotating creature with attached emitter. + +3. **Shadow-cell tracking**. When the parent entity crosses a cell + boundary, the particles must also cross. ACE handles this via + `PhysicsObj.set_parent()` registering the particle's PhysicsObj into + the parent's shadow-cell list; it gets automatically included in the + parent's cell-transition notify. Our acdream equivalent: the + `LiveEmitter` subscribes to `ParentEntity.OnCellChanged` events and + re-associates with the new cell for culling/LOD purposes. The slots + themselves are cell-agnostic — they live in world space once they + escape parent-local. + +4. **PartIndex = -1 vs 0**. `-1` means "attach to entity origin" (root + frame, no part offset). `0` means "attach to Part[0]" which is still + a real part. Missing this distinction cost the original retail dev a + bug visible in the decompiled code's defensive check + (`if (partIdx == -1) frame = Parent.Position.Frame;`). Port it + literally. + +5. **The `PartStorage` vs `Parts` split**. ACE has two parallel arrays: + `PartStorage` owns the `PhysicsPart` objects (pre-allocated once), + and `Parts` is a "currently live" slot table whose entries are either + a pointer into `PartStorage` or `null`. On emit, `Parts[i] = + PartStorage[i]`; on kill, `Parts[i] = null` but `PartStorage[i]` + stays so it's reused in the next emit cycle. This is **intentional + GC avoidance** — don't "fix" it in acdream by replacing with a + `List` — keep the fixed-length slot table pattern. + +--- + +## 15. Summary — the six pillars of acdream's VFX port + +1. **DatReaderWriter** supplies `ParticleEmitter`, `PhysicsScript`, + `PhysicsScriptTable`, and the four animation hooks. No dat-format + work needed; just wire them up. +2. **Simulation**: port `AcParticleEmitterSimulator` (ACME) file to + `acdream/Rendering/Particles/LiveEmitter.cs`. Validate with + conformance tests across all 13 ParticleTypes. +3. **Rendering**: port WorldBuilder's + `Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs` and its shader + pair to `acdream/Rendering/Particles/`. This is direct Silk.NET code + that will compile in our build with minimal edits. +4. **Script runner**: build a new `PhysicsScriptRunner` that walks a + `PhysicsScript.ScriptData` list by StartTime and dispatches + `CreateParticleHook`/`StopParticleHook`/`DestroyParticleHook` into + the ParticleManager. This is new code; model on + `ACViewer.ParticleViewer.InitEmitter`. +5. **Network integration**: wire `GameMessageScript` (network) → + `PlayScriptDispatcher` → `PhysicsScriptRunner` for the spell-chain + phase (R1). Until R1, drive effects from landblock-load and local + test commands. +6. **Plugin API**: expose `IParticleEffects` in + `acdream.PluginApi.Particles` so plugins can trigger arbitrary + PlayScripts at arbitrary targets. This is a feature the retail client + never had and is the acdream differentiator. + +The full port should land in two commits: + +- **R4.1** — simulation + rendering + in-editor emitter test harness + (render any `ParticleEmitter` DID at the origin, verify against + WorldBuilder's emitter browser visuals). +- **R4.2** — PhysicsScript/PlayScript dispatcher wired to landblock + + spell events, plus the plugin API surface. + +Estimated scope: ~2,500 LOC new code, ~400 LOC tests, 2 new shaders. +Zero changes to existing terrain/creature render paths — particles live +in their own render pass. diff --git a/docs/research/deepdives/r05-audio-sound.md b/docs/research/deepdives/r05-audio-sound.md new file mode 100644 index 0000000..069ad41 --- /dev/null +++ b/docs/research/deepdives/r05-audio-sound.md @@ -0,0 +1,1150 @@ +# R5 — Audio System Deep Dive + +Ground-truth port plan for acdream's audio subsystem, derived from decompiled +`acclient.exe`, cross-referenced against ACE (server), ACViewer (dat loader), +holtburger (client protocol), AC2D (C++ client), and DatReaderWriter (dat +schema). Backend for the port is **Silk.NET.OpenAL**. + +--- + +## 0. Executive summary + +Retail AC audio is a thin wrapper over three Microsoft APIs: + +| Retail component | Windows API | Dat source | Notes | +|---|---|---|---| +| PCM / MP3 playback | **DirectSound 8** | `Wave` (0x0A) | Per-sound secondary buffer, primary buffer for mixing | +| MIDI music | **winmm midiStream** | Loose `*.mid` files on disk | 16 channels, streaming with 6-buffer rotation | +| Compressed decode | **winmm ACM** | Wave data (MP3 body) | acmStreamOpen/Convert wraps decompression | + +Everything on top — `SoundTable`, ambient rolls, priority eviction, distance +falloff, the volume slider stack, footstep selection — is retail-authored +C++ logic on top of those three APIs. The retail 3D model is a **custom +inverse-square falloff computed in software** that lands at a final +`IDirectSoundBuffer::SetVolume(dB)` call; retail does **not** use +`IDirectSound3DBuffer` at all. This is critical to faithful port — OpenAL's +native AL_INVERSE_DISTANCE model doesn't match retail; we do the math +ourselves and only use OpenAL's raw gain. + +Only **16 concurrent sound slots** (3D SFX). New sounds with higher +computed volume evict the quietest currently-playing slot. Master slider +multiplies all SFX. Dedicated ambient, interface, and music channels exist +separately. + +**Well-known SoundId range:** 0x00 (Invalid) to 0xCC (`SkillDownVoid`). +Enumerated in full in section 3. Each creature `Setup` carries a +`DefaultSoundTable` (DID ref to 0x20xxxxxx), and AnimationHooks trigger +sounds by SoundId through that table. + +--- + +## 1. `SoundTable` dat layout + +**Dat range:** `0x20000000` – `0x2000FFFF` in `client_portal.dat` (first-byte +tag `0x20`). + +### 1.1 On-disk format + +From `references/ACE/Source/ACE.DatLoader/FileTypes/SoundTable.cs` and +`DatReaderWriter/DBObjs/SoundTable.generated.cs`: + +``` +SoundTable (Id = 0x20xxxxxx) + uint32 Id // = this dat file id + int32 HashKey // (aka Unknown in ACE). Same across files; salt for hash fn? + int32 numHashes + repeat numHashes times: + uint32 soundId // Sound enum key + SoundHashData { + float Priority + float Probability + float Volume // 0..1 (relative to base) + } + int32 numSounds + repeat numSounds times: + uint32 soundId // Sound enum key (matches numHashes entries 1:1 practically) + SoundData { + int32 numEntries + repeat numEntries times: + SoundEntry { + QualifiedDataId Id // 0x0A000... dat ref + float Priority + float Probability + float Volume + } + int32 Unknown // always 0 in observed data + } +``` + +### 1.2 Semantic model + +A `SoundTable` maps **`Sound` (enum) → list of candidate `Wave` DIDs**. When +you "play the Attack1 sound on this creature," you: + +1. Look up `Sound.Attack1` in `Sounds[]`. +2. From the candidate list, **select one randomly, weighted by `Probability`**. +3. Effective play volume = `baseVolume * entry.Volume * masterSlider`. +4. `Priority` is used for eviction when the 16-slot 3D voice pool is full + (lower priority = evict first). See section 11. + +### 1.3 The decompiled pick function + +From `docs/research/decompiled/chunk_00550000.c` at `FUN_00551290` — the +random-sound picker: + +```c +// __thiscall SoundTable::GetRandomSoundData(this, soundEnum, outSlotPtr) +if (soundEnum && FindHashEntry(this, soundEnum, &local)) { + uint count = *(uint*)(local + 0x7C); // numEntries + if (count != 0) { + uint idx = rand() % count; // uniform pick; weighting via probability roll later + SoundEntry* e = (SoundEntry*)(*(int*)(local+0x80) + idx * 0x10); + out->waveId = e->Id; + out->priority = e->Priority; + out->prob = e->Probability; + out->volume = e->Volume; + if (out->waveId != 0) return LoadWave(out); + } +} +``` + +The stride is `0x10` = 16 bytes = exactly (uint waveId + 3 × float) = one +`SoundEntry`. The per-sound `SoundHashData` acts as the **default** values +when you call a sound type directly with no override (e.g. ambient roll uses +those three floats, not the SoundEntry ones). + +Random pick is **uniform over the array**; the per-entry `Probability` is +checked *after* picking (`FUN_00550cf0`): `rand()/RAND_MAX < entry.Probability`. +If that coin flip fails, the sound is silently dropped. This is how retail +avoids ALL spatter variants firing on every hit. + +### 1.4 Where it lives + +Each creature `Setup` (dat 0x02xxxxxx) has a `DefaultSoundTable` DID +(`references/ACE/Source/ACE.DatLoader/FileTypes/SetupModel.cs:41`). The +engine resolves that at PhysicsObj construction +(`PhysicsObj.cs:670`, `SoundTable = (SoundTable)DBObj.Get(qdid)`). + +Common tables (from dat inspection and conventions): drudges, golems, +humans, elementals, tuskers, each have their own `SoundTable` with +appropriate `Attack1/Wound/Death` samples. + +--- + +## 2. `Wave` dat layout + +**Dat range:** `0x0A000000` – `0x0A00FFFF` in `client_portal.dat`. + +### 2.1 On-disk format + +From `ACE.DatLoader/FileTypes/Wave.cs` and the generated +`Wave.generated.cs`: + +``` +Wave (Id = 0x0Axxxxxx) + uint32 Id + int32 headerSize + int32 dataSize + byte[headerSize] Header // raw WAVEFORMATEX (+ extra for compressed formats) + byte[dataSize] Data // raw sample bytes +``` + +There is **no RIFF "RIFF/WAVE/fmt " wrapping** in the dat. To play the blob +via DirectSound, retail: + +1. Allocates a `DSBUFFERDESC` with `lpwfxFormat` pointing at `Header`. +2. For AC-compressed variants, runs `Data` through `acmStreamOpen` → + `acmStreamConvert` to get raw PCM, then feeds PCM to the secondary + buffer. + +### 2.2 Header format detection + +- **`Header[0] == 0x55`** → MP3 (MPEGLAYER3, `WAVE_FORMAT_MPEGLAYER3`). + Retail funnels these through ACM (`acmStreamOpen(...,piVar3[1],local_18,...)`). +- **`Header[0] == 0x01`** → linear PCM (`WAVE_FORMAT_PCM`). Feeds straight + into a DirectSound buffer. +- **`Header[0] == 0x02`** → ADPCM (observed rarely; handled like MP3 via ACM). + +ACViewer's export code (`Wave.cs:32-72`) just prepends a stock RIFF/WAVE +header at export time using the first 16 bytes of `Header` as the `fmt` +body, which is standard — the AC dat header *is* a standard WAVEFORMATEX, +just without the file-format wrapper. + +### 2.3 Observed format conventions + +From the DirectSound primary-buffer initializer +(`chunk_00550000.c:4119-4124`, `FUN_00554930`), the **primary mixer** is +configured as: + +- `cbSize = 0` +- `wFormatTag = 1` (PCM) +- `nChannels = 2` (stereo mix) +- `nSamplesPerSec = 0x2b11 = 44100 Hz` +- `nAvgBytesPerSec = 0xac44 = 176400` (44100 × 2 × 2) +- `nBlockAlign = 4` +- `wBitsPerSample = 16` + +Individual Wave samples may be 11025 Hz or 22050 Hz mono; DirectSound +resamples into the 44.1kHz stereo primary buffer transparently. + +### 2.4 Playback object + +Retail allocates a 0x20 / 0x24-byte structure per playing sound (see +`FUN_005df0f5(0x20)` + `FUN_005df0f5(0x24)` in `chunk_00550000.c:577,3274`): + +``` +SoundPlayInstance (~0x24 bytes) + +0x00 vtable // &PTR_FUN_007cbd18 etc + +0x04 IDirectSoundBuffer* // the secondary buffer + +0x08 IDirectSoundNotify* // completion notify + +0x0C byte[] pcm // decoded PCM (freed after buffer built) + +0x10 size of pcm + +0x14 ? + +0x18 flags + +0x1C sourceWaveId // 0x0A000xxx, for dedup cache + ... +``` + +--- + +## 3. Well-known SoundIds + +From `references/ACE/Source/ACE.Entity/Enum/Sound.cs` (exact hex values +match the dat enum). Grouped for the port's `SoundId` C# enum: + +### 3.1 Voice / creature lifecycle (0x01 – 0x1D) + +``` +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 +``` + +### 3.2 Weapon / combat (0x1E – 0x36) + +``` +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 +``` + +Hit sounds are selected by **target armor type**, not weapon. Retail plays +exactly one of HitFlesh/Leather/Chain/Plate on damage based on the struck +body part's armor. + +### 3.3 Movement (0x37 – 0x3C) + +``` +Footstep1=0x37 // soft/running +Footstep2=0x38 // heavy/walking +Walk1=0x39 // unused in the late client (was first-person walking) +Dance1=0x3A Dance2=0x3B Dance3=0x3C +``` + +Footstep selection: see section 4. There is **no per-surface SoundId +variant** — the SoundTable's Footstep1/Footstep2 slots are expected to hold +the correct per-creature foot sample, and surface type picks *which* slot +to trigger, not which sample. + +### 3.4 Interaction (0x3D – 0x45) + +``` +Hidden1=0x3D Hidden2=0x3E Hidden3=0x3F +Eat1=0x40 Drink1=0x41 +Open=0x42 Close=0x43 +OpenSlam=0x44 CloseSlam=0x45 +``` + +### 3.5 Ambient (0x46 – 0x4E) + +``` +Ambient1=0x46 Ambient2=0x47 Ambient3=0x48 Ambient4=0x49 +Ambient5=0x4A Ambient6=0x4B Ambient7=0x4C Ambient8=0x4D +Waterfall=0x4E +``` + +These are **played from the region-level `AmbientSTBDesc`**, not from a +creature SoundTable — see section 7. + +### 3.6 Character lifecycle (0x4F – 0x5D, 0xCA – 0xCC) + +``` +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 +// "Void" variants for Shadow buffs +HealthDownVoid=0xCA RegenDownVoid=0xCB SkillDownVoid=0xCC +``` + +### 3.7 Magic (0x5E – 0x68) + +``` +Fizzle=0x5E Launch=0x5F Explode=0x60 +TransUp=0x61 TransDown=0x62 +BreatheFlaem=0x63 BreatheAcid=0x64 +BreatheFrost=0x65 BreatheLightning=0x66 +Create=0x67 Destroy=0x68 +``` + +### 3.8 UI and chimes (0x6A – 0x8A) + +``` +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 +// Ambient chimes played via UI channel +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..6 = 0x85..0x8A +``` + +### 3.9 Inventory / containers (0x69, 0x8B – 0x97) + +``` +Lockpicking=0x69 +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 +``` + +### 3.10 Generic triggers (0x98 – 0xC9) + +`TriggerActivated1 … TriggerActivated50 = 0x98 … 0xC9` — 50 generic trigger +slots that level designers wire into specific effects per-dungeon (pressure +plates, door opens, lever pulls). + +--- + +## 4. Surface-material footstep selection + +**Retail does NOT have a surface-material → SoundId table.** Instead: + +1. Every creature's `SoundTable` contains entries for `Footstep1` and + `Footstep2`. Different creatures have different samples (drudge footstep + ≠ human footstep ≠ tusker footstep). +2. The **animation's frame hooks** declare which footstep to play at each + step (left-heavy vs right-heavy, run vs walk). +3. The **surface byte** in terrain affects which alternative sample is + picked from the Footstep1/Footstep2 `SoundEntry` list. Each human + `SoundTable` has 2–4 entries per footstep slot — "grass step", + "stone step", "dirt step". Selection is by `rand() % count`, but the + per-entry `Volume` and `Probability` are used to keep the most + appropriate sample dominant. + +Retail's actual "which surface am I on" lookup lives in the terrain +subsystem: `LandBlock::GetSurfaceType(x,y)` returns the dominant terrain +type byte for the cell (documented in r04 terrain deep-dive as the +per-vertex terrain-type encoding that also drives texture atlas choice). +The **audio subsystem does not consume surface type directly** — it's +embedded in the SoundTable design. We can faithfully port this by: + +- Keeping the SoundTable's full per-footstep entry list. +- Letting the animation hooks pass the current surface hint into + `SoundTable.Play(SoundId.Footstep1, surfaceHint)`. +- Falling back to uniform random when the SoundTable has only one entry. + +The 2-slot `Footstep1`/`Footstep2` pair is **left-foot vs right-foot**, not +soft vs loud — alternating based on the animation frame that fires the hook. + +--- + +## 5. 3D positional audio — the retail falloff + +**Retail does NOT use `IDirectSound3DBuffer`.** The entire 3D effect is +computed in software and applied via +`IDirectSoundBuffer::SetVolume(dB)` + `SetFrequency(pitch)` on a plain +stereo secondary buffer. Verified from decompiled code: + +- Grep for `IDirectSound3D*` in the decompiled chunks: **zero hits**. +- The only DirectSound API surface used is `DirectSoundCreate`, + `CreateSoundBuffer`, `SetFormat`, `Play`, `Stop`, `SetVolume`, + `SetFrequency`, `SetPan`, `QueryInterface(IDirectSoundNotify)` (all seen + in `chunk_005D0000.c` imports). + +### 5.1 The falloff function — `FUN_00550c30` + +Decompiled, annotated: + +```c +// returns 1 if audible (volume above floor), 0 otherwise. +// out: param_3 = final volume in ad-hoc units fed to SetVolume conversion +// param_1 = distance to listener +// param_2 = base volume (0..1) +// param_4 = channel type: 0 = SFX/ambient, 1 = interface +int FUN_00550c30(float distance, float baseVolume, int* outVol, int channelType) +{ + const float MIN_DISTANCE = _DAT_007cbc64; // ~1.0f in world units + const float DISTANCE_K = _DAT_00870414; // reference-distance falloff + const float MAX_VOLUME = _DAT_007938c0; // cap (1.0f) + const float VOLUME_FLOOR = _DAT_00795610; // ~0.0001f threshold for "inaudible" + const float DB_SCALE = _DAT_00870418; // dB scaling constant + const float DB_UNIT = _DAT_007cbd00; // convert to SetVolume hundredths + + float v = baseVolume; + + // 1. INVERSE-SQUARE distance attenuation (only beyond MIN_DISTANCE) + if (distance >= MIN_DISTANCE) + v = (DISTANCE_K * baseVolume) / (distance * distance); + + // 2. Cap at MAX_VOLUME + if (v > MAX_VOLUME) v = MAX_VOLUME; + + // 3. Apply channel master volume slider + float slider = (channelType == 0) ? g_SfxMasterVolume // 0..1 + : g_InterfaceVolume; // 0..1 + v *= slider; + + // 4. Check audibility + if (v <= VOLUME_FLOOR) { + *outVol = DS_VOLUME_FLOOR; // -10000 hundredths of dB == mute + return 0; + } + + // 5. Convert linear 0..1 to DirectSound hundredths-of-dB + // 0.6931472 = ln(2) → log2(v) = ln(v) / ln(2) + // final = ceil( log2(v) * DB_SCALE * DB_UNIT ) + int finalHdB = (int)ceilf(log2f(v) * DB_SCALE * DB_UNIT); + *outVol = finalHdB; + return 1; +} +``` + +**Key properties of the retail model:** + +- **Pure inverse-square** between 0 and some "max audible distance"; no + rolloff-factor parameter like OpenAL's `AL_ROLLOFF_FACTOR`. +- **No doppler.** Retail does not touch `SetFrequency` based on relative + velocity. Pitch is static per-sound (there's a `param_2 * 100` pitch + shift in the hundredths, but it's fed by the SoundEntry's base, not + runtime doppler). +- **No direction cone.** All sounds are omnidirectional. There is a + `SetPan` call in the secondary-buffer path to handle stereo positioning, + but only for 2D/L-R based on relative bearing, not HRTF or cone. +- **Stereo / mono toggle** in the options panel (`ID_Sound_Stereo` / + `ID_Sound_Mono`) disables the pan computation for mono output. + +### 5.2 Stereo panning + +`FUN_00553970(soundInst, pan, volume)` (chunk_00550000.c:3140) clamps pan +to `-0xF..+0xF` then calls `IDirectSoundBuffer::SetPan(piVar1, pan*100)` +(offset 0x40 in the DirectSound vtable). Retail multiplies the final +float by 100 to convert to the hundredths-of-dB unit DirectSound expects. + +The pan value itself is computed by taking the vector from listener to +source, projecting onto the listener's right axis, normalizing by some +reference distance, and clamping. This is visible in the call sites for +`FUN_00550d80(inst, listenerRelativeVec, volume, attrFlags)` — the second +arg `param_2 + 0x48` is a WorldObject's position frame, and there's a +`FUN_005364a0()` (likely `Vector3::Dot(up_vec)` or similar) in the call +chain that produces `fVar4` → `FUN_00550c30` as distance. + +### 5.3 Priority-based eviction (the 16-slot table) + +Retail has a **fixed 16-slot voice pool** for 3D positional sounds +(`chunk_00550000.c:527`, `FUN_00550ad0`): + +```c +// pool of up to 16 active sounds +static SoundPlayInstance* g_pool[16]; // @ DAT_00870520 +static float g_poolVols[16]; // @ DAT_00870524 +static uint32_t g_poolNext; // @ DAT_008703b8 (round-robin cursor) + +void PlaySound3D(SoundInstance* inst, pan, volume) { + // First pass: find a free or stopped slot. + for (int i = 0; i < 16; ++i) { + uint idx = (g_poolNext + i) & 0xF; + if (g_pool[idx] == NULL) goto use_slot; + if (!IsStillPlaying(g_pool[idx])) goto free_and_use; + } + // Second pass: evict slot whose current volume < our volume. + for (int i = 0; i < 16; ++i) { + uint idx = (g_poolNext + i) & 0xF; + if (g_poolVols[idx] < inst->volume) goto free_and_use; + } + return; // nothing quieter than us → drop + +free_and_use: + StopBuffer(g_pool[idx]); + DeleteInstance(g_pool[idx]); +use_slot: + g_pool[idx] = CreateBufferFromInst(inst); + g_poolVols[idx] = inst->volume; + g_poolNext = (idx + 1) & 0xF; + ApplyPanVolume(pan, volume); // → FUN_00553970 +} +``` + +This is the classic "sound hardware had 16 voices" DirectSound-era +hardware-mixing constraint, but the AC client kept it even for pure +software mixing. **Port target:** 16 simultaneous positional sources. +Plus the music (MIDI) and UI channels, which don't share this pool. + +--- + +## 6. Music system — WinMM MIDI, not PCM + +**Music is MIDI**, streamed through `midiStreamOpen` (not DirectMusic, not +PCM). Evidence: `chunk_00550000.c` contains: + +- `midiStreamOpen(&DAT_00870a70, (LPUINT)&DAT_00820294, 1, 0x554120, 0, 0x30000)` +- `midiStreamProperty`, `midiStreamRestart`, `midiStreamStop`, `midiStreamClose` +- `midiOutShortMsg(hmo, ...)` for per-channel volume (controller 0x07) and + pan (0x0A). +- `midiOutPrepareHeader`, `midiOutUnprepareHeader`. +- MThd/MTrk chunk parsing at `FUN_00555150` (0x6468544d = "MThd", 0x6b72544d = "MTrk"). + +### 6.1 Architecture + +``` ++-----------------------+ +----------------+ +| *.mid on disk | ──────▶ | MIDI parser | +| (loose files, not dat)| | FUN_00555150 | ++-----------------------+ +----------------+ + │ + ▼ 6 × 1024-byte buffers + +----------------------+ + | midiStreamOut rotate | ← "Wait For Buffer + | DAT_008707c0[6] | Return" event + +----------------------+ + │ + ▼ + Windows default MIDI device +``` + +- 6 buffers, each `0x400` (1024) bytes, rotated via an event named + `"Wait For Buffer Return"` (`chunk_00550000.c:3743`). Classic double- + (or N-) buffered streaming; when a buffer finishes, Windows sets the + event, the worker thread refills it. +- **16-channel GM pan / volume arrays:** `DAT_008709b8[16]` (current + volume per channel), `DAT_008709f8[16]` (default volume per channel, + initialized to 100). Tempo / pitch-bend goes through + `midiOutShortMsg`. +- **No MusicTable.** Tracks are identified by file path; retail has + `lstrcpyA(&DAT_00870770, param_1)` storing the current track filename. + Track selection is **by the game code calling PlayMusic("path/foo.mid", + loopFlag)** — driven by region/area rules, not by a dat table. + +### 6.2 What this means for the port + +- `ACDream.Audio.MusicPlayer` is distinct from the SFX engine. +- Since `midiStreamOpen` is Windows-only, we replace it with a .NET MIDI + library (NAudio.Midi or a minimal SMF parser + our own synth) or skip + MIDI entirely and use the available `.mid` tracks converted to `.ogg`. + Retail's decision to use MIDI was because 1999 CD bandwidth made PCM + soundtracks infeasible; acdream has no such constraint. **Recommended: + convert MIDI to OGG offline, use a single OGG streaming source.** This + departs from retail but is indistinguishable to the ear and removes a + whole OS dependency. +- Fallback: if users want exactly-retail music, we can implement a tiny + SMF player driving a soundfont. + +--- + +## 7. Ambient sounds — region-level, not cell-level + +### 7.1 Source + +The `RegionDesc` dat (0x13000000 singleton "Dereth") contains a +`SoundDesc` field (when `PartsMask & 0x01`): + +``` +SoundDesc + List STBDesc + AmbientSTBDesc { + uint STBId // identifier/index + List AmbientSounds + AmbientSoundDesc { + Sound SType // e.g. Ambient1..8, Waterfall + float Volume + float BaseChance // if 0, loops continuously + float MinRate, MaxRate // seconds between rolls + } + } +} +``` + +From `ACE.DatLoader.Entity.AmbientSoundDesc.IsContinuous => BaseChance == 0`. + +### 7.2 Activation model + +Ambient sounds are tied to **landblock/terrain type**, not to EnvCell +instances. The retail pattern: + +1. On landblock change, the game queries `terrainType` for each corner of + the current cell and picks the dominant `AmbientSTBDesc` by STBId (the + STBId is indexed by terrain type or region-specific rule). +2. Active `AmbientSTBDesc` spawns a per-sound background roll: + - If `BaseChance == 0` → continuous loop on a dedicated voice. + - Else → every `N` seconds (where `N` = `rand()` in `[MinRate, MaxRate]`), + roll `rand() < BaseChance` and if true, play the + `Sound.SType` once at `Volume`, positioned near the listener + (at a small random offset so it has subtle 3D movement). +3. On landblock change or leaving the region, stop all ambient voices + associated with the outgoing STBId and start new ones. + +### 7.3 Ambient master volume gate + +Separate slider (`DAT_008375bc`, default `0x3f800000 = 1.0f`), separate +disable toggle (`DAT_008375b8`). See section 10. + +--- + +## 8. Motion-triggered sounds — AnimationHook dispatch + +### 8.1 Two hook types + +From `ACE.Entity.Enum.AnimationHookType`: + +``` +Sound = 1 → SoundHook { uint Id } // play Wave DID directly +SoundTable = 2 → SoundTableHook { Sound SoundType } // play via this obj's SoundTable +SoundTweaked = 21 → SoundTweakedHook { uint SoundID, // tweaked with custom params + float Priority, + float Probability, + float Volume } +``` + +All three ship on frames of `Animation` entries (dat type +`0x01000000`). A walk-cycle animation has `SoundTableHook{Footstep1}` on +frame 4 and `SoundTableHook{Footstep2}` on frame 12, for example. + +### 8.2 Dispatch point + +Per `AnimHook.Execute` in ACE's physics port (placeholder; the retail +client does it in `CAnimationSequencer::ProcessFrameHooks`): + +```csharp +void ProcessFrame(AnimationHook hook, PhysicsObj obj) { + switch (hook.HookType) { + case AnimationHookType.Sound: + // direct Wave DID, bypasses SoundTable + var h = (SoundHook)hook; + audioEngine.PlayWaveAtPosition(h.Id, obj.Position, obj.Velocity, defaultVolume: 1.0f); + break; + case AnimationHookType.SoundTable: + // look up via object's SoundTable + var st = (SoundTableHook)hook; + obj.SoundTable?.Play(st.SoundType, obj.Position, obj.Velocity); + break; + case AnimationHookType.SoundTweaked: + // one-off sound with overridden priority/probability/volume + var t = (SoundTweakedHook)hook; + audioEngine.PlayWaveTweaked(t.SoundID, obj.Position, + t.Priority, t.Probability, t.Volume); + break; + } +} +``` + +The retail `MotionInterpreter` fires hooks **in order within a frame**, so +if two SoundTable hooks coexist on the same frame they both play +(subject to voice-pool eviction). + +### 8.3 Our integration point + +The acdream `MotionInterpreter` (`src/AcDream.Core/Physics/MotionInterpreter.cs`) +and `AnimationSequencer` (`src/AcDream.Core/Physics/AnimationSequencer.cs`) +need a hook-callback contract. Propose: + +```csharp +public interface IAnimationHookSink +{ + void OnSoundHook(uint waveId, Vector3 pos, Vector3 vel); + void OnSoundTableHook(SoundId id, Vector3 pos, Vector3 vel); + void OnSoundTweakedHook(uint waveId, Vector3 pos, Vector3 vel, + float priority, float probability, float volume); +} +``` + +The `AudioEngine` implements this interface, and `AnimationSequencer` +receives an `IAnimationHookSink` in its constructor. This keeps animation +code audio-agnostic while giving the audio engine full access to hook +timing. + +--- + +## 9. Server-sent sound — `GameMessageSound` (opcode 0xF750) + +### 9.1 Wire format + +From `ACE.Server.Network.GameMessages.Messages.GameMessageSound` and +cross-verified against holtburger `PlaySoundData`: + +``` +GameMessage (opcode = 0xF750) + Guid target // 8 bytes (object this sound plays on) + uint32 soundId // Sound enum value + float volume // 0..1 +``` + +Total payload: 16 bytes after the opcode. + +### 9.2 Related opcodes + +| Opcode | Name (ACE) | Payload | Purpose | +|---|---|---|---| +| `0xF750` | `Sound` / `PlaySoundData` | `{Guid, uint soundId, float volume}` | Per-object sound via the object's SoundTable | +| `0xF754` | `PlayScriptId` | `{Guid, uint scriptId}` | PlayScript (particle emitter; may have attached sound entries in PhysicsScript) | +| `0xF755` | `PlayEffect` / `GameMessageScript` | `{Guid, uint PlayScript, float speed}` | Canned visual-+-sound effect package (e.g. Fizzle, Launch, etc — full PlayScript enum in section 3 ref) | + +The `PlayScript` enum (see `ACE.Entity.Enum.PlayScript`) has 95+ entries +like `Fizzle=0x51`, `PortalEntry=0x52`, `BreatheFlame=0x54`, +`Create=0x58`, `Destroy=0x59`, etc — each is a bundle of a particle +effect **and** a sound. Client resolves via a `PhysicsScriptTable` +(not audio's concern, but the sound portion funnels into PlaySound). + +### 9.3 Object-referenced vs anonymous + +- Retail always plays sounds **attached to an object** — the object's + position is the 3D source, its SoundTable resolves the sample. +- There is **no "play at (x,y,z)" opcode**. If the server wants a + location-based sound, it attaches it to a hidden/ephemeral weenie at + that location. This matches the observation that non-weenie ambient + sounds (landblock ambient) are client-driven from the RegionDesc + entries, not sent by the server. + +--- + +## 10. Volume sliders — the stack + +From `chunk_00400000.c:1705-1728` (the settings UI init) and +`chunk_00550000.c:875-930` (the options-save path), retail exposes **six +volume controls** plus a focus-gate: + +| UI label | Internal var | Default | Semantic | +|---|---|---|---| +| `Sound Disabled` (checkbox) | `DAT_008375b0` | off | Master SFX mute | +| `Effect Volume` (slider 0..1) | `DAT_008375b4` | 1.0 | SFX master multiplier | +| `Ambient Sound Disabled` (checkbox) | `DAT_008375b8` | off | Ambient mute | +| `Ambient Volume` (slider) | `DAT_008375bc` | 1.0 | Ambient master multiplier | +| `Interface Sound Disabled` (checkbox) | `DAT_008375c0` | off | UI mute | +| `Interface Volume` (slider) | `DAT_008375c4` | 1.0 | UI master multiplier | +| `Play Sound Only When Active` | `DAT_008375cc` | off | mute when app not focused | + +Plus a stereo/mono picker (`DAT_008375c8`) with values +`ID_Sound_Stereo` / `ID_Sound_Mono`. + +**There is no separate music slider in retail.** Music uses its own +WinMM `midiOutSetVolume`-driven master (one hex volume uint, default +0x7FFF per channel) and is controlled by the per-channel +`DAT_008709b8[16]` pan+volume arrays. In practice users typically turn +down music via the default mixer rather than in-game. + +### 10.1 Application order + +For a 3D SFX sound: + +``` +finalLinear = baseSampleVolume // per SoundEntry.Volume + * soundTableEntry.Volume // sometimes 1.0, sometimes tweaked + * falloff(distance) + * masterEffectVolume // slider 0..1 + * (masterMute ? 0 : 1) + * (appFocused || !focusMute ? 1 : 0) +``` + +For ambient: +``` +finalLinear = ambientSample.Volume + * ambientMasterVolume + * (ambientMute ? 0 : 1) + * (appFocused || !focusMute ? 1 : 0) +``` + +For UI/interface: +``` +finalLinear = UiSample.Volume // usually 1.0 (no falloff, no 3D) + * interfaceMasterVolume + * (interfaceMute ? 0 : 1) + * (appFocused || !focusMute ? 1 : 0) +``` + +Final per-voice volume is then converted linear→dB and +`IDirectSoundBuffer::SetVolume(hundredths_dB)` is called. + +--- + +## 11. DirectSound API surface + +Direct API calls seen in decompiled code (`chunk_00550000.c` + +`chunk_005D0000.c`): + +| Retail call | Retail location | Purpose | +|---|---|---| +| `DirectSoundCreate(0, &pDS, 0)` | `FUN_00554930` | Create device | +| `IDirectSound::SetCooperativeLevel(hwnd, DSSCL_PRIORITY=2)` | same | Cooperative level = PRIORITY | +| `IDirectSound::CreateSoundBuffer(&DSBUFFERDESC, &pBuf, 0)` | same | Primary buffer | +| `IDirectSoundBuffer::QueryInterface(IID_IDirectSoundNotify, ...)` | same | For completion notify | +| `IDirectSoundNotify::SetNotificationPositions(pos)` | implied vtable slot 0x3C | 1 notify at end of play | +| `IDirectSoundBuffer::SetFormat(&wfx)` | vtable slot 0x38 | Set primary mix format 44.1kHz stereo 16-bit | +| `IDirectSoundBuffer::Play(0, 0, DSBPLAY_LOOPING=1)` | vtable 0x30 | Start primary buffer looping | +| `IDirectSoundBuffer::SetVolume(hundredths_dB)` | vtable 0x34 | Per-voice volume | +| `IDirectSoundBuffer::SetFrequency(Hz)` | vtable 0x3C | Pitch (per-sound random variance) | +| `IDirectSoundBuffer::SetPan(hundredths_dB_LR)` | vtable 0x40 | Stereo pan | +| `IDirectSoundBuffer::Lock/Unlock` | vtable 0x4C | Write PCM into secondary buffers | +| `IDirectSoundBuffer::Stop` | vtable 0x48 | Stop on eviction | +| `acmStreamOpen/Convert/Close` | `FUN_005532f0` | Decompress MP3/ADPCM to PCM | + +**`IDirectSound3D*` is not used.** No `SetListener`, no +`SetRolloffFactor`, no `SetDopplerFactor`, no `SetDistanceFactor`, no +`SetOrientation`. All 3D math is in the CPU path. + +--- + +## 12. Mixer thread + +Retail uses **DirectSound's internal mixer thread** (running inside +`dsound.dll`). Application code only fires `Play()` and relies on +`IDirectSoundNotify` for stop/complete events. + +MIDI is a separate story: `midiStreamOut` is async via +`midiStreamOpen`'s `MidiCallback` parameter (`0x554120` in the call — +offset to a retail callback function) which fires +`MOM_DONE` events on a Windows-owned thread. The named event +`"Wait For Buffer Return"` is signaled from that callback, then the +MIDI parser thread wakes and refills the next MThd/MTrk chunk. + +**Port target:** OpenAL runs its own mixer thread internally. We don't +need to manage one. Our main-loop audio tick only needs to: +- Update listener position/orientation from the camera. +- Check for finished voices and recycle slots. +- Poll for pending ambient rolls and schedule their next Play(). + +--- + +## 13. Port plan — C# class layout + +Target namespace: `AcDream.Audio`. Backend: `Silk.NET.OpenAL`. All files +under `src/AcDream.Core/Audio/` (platform-free) and +`src/AcDream.App/Audio/` (platform Silk glue). Keep dat parsing under +`src/AcDream.Core/Dat/`. + +### 13.1 Classes + +``` +AcDream.Core.Audio +├── SoundId.cs // enum matching ACE.Entity.Enum.Sound 1:1 +├── SoundDat // parses SoundTable dat via DatCollection +│ ├── SoundTable record // (uint Id, Dictionary Sounds, …) +│ ├── SoundEntry record // (uint WaveId, float Priority, Prob, Volume) +│ └── WaveBlob record // (uint Id, byte[] Header, byte[] Data, AudioFormat Parsed) +├── WaveDecoder.cs // WAVEFORMATEX parsing + PCM/MP3/ADPCM → Int16/Float32 PCM +├── SoundCache // LRU cache: WaveId → decoded PCM + OpenAL buffer ID +├── AudioFalloff.cs // static: retail inverse-square + dB conversion +├── AudioSource3D // one voice, wraps an OpenAL source +│ ├── WaveId, Priority, Volume, Position, Velocity +│ ├── bool UpdateAttenuation(listener) → updates AL_GAIN, AL_PITCH, AL_POSITION +│ └── PlayState { NotStarted, Playing, Finished, Evicted } +├── AudioEngine +│ ├── 16 × AudioSource3D voicePool +│ ├── AudioListener3D listener +│ ├── SfxMasterVolume, AmbientMasterVolume, UiMasterVolume, FocusGate (properties) +│ ├── Play(Guid target, SoundId id, float volume, Vector3 pos, Vector3 vel) +│ ├── PlayWaveDirect(uint waveId, …) +│ ├── PlayWaveTweaked(uint waveId, float priority, prob, volume, …) +│ ├── PlayUI(SoundId id, float volume) // 2D, no falloff, no pool competition +│ ├── UpdateListener(Vector3 pos, Quaternion rot) +│ └── Tick(float dt) // advance ambient rolls, recycle finished voices +└── AmbientPlayer + ├── RegionSoundDesc (parsed SoundDesc) + ├── ActiveSTBId (current terrain STBId) + ├── per-AmbientSoundDesc schedule state: nextFireTime + ├── Tick(dt, listenerPos) + └── ApplyRegionChange(STBId newId) + +AcDream.App.Audio +└── OpenAlBackend // ALContext, buffer/source wrappers +``` + +### 13.2 Key method sketches + +```csharp +// AudioFalloff.cs - faithful port of FUN_00550c30 +public static bool Compute(float distance, float baseVolume, AudioChannel ch, + out float gain, out float pitchMul) +{ + const float MIN_DIST = 1.0f; + const float DIST_K = 1.0f; // = DAT_00870414 calibrated at runtime + const float MAX_VOL = 1.0f; + const float VOL_FLOOR = 1e-4f; + + float v = baseVolume; + if (distance >= MIN_DIST) + v = (DIST_K * baseVolume) / (distance * distance); + + if (v > MAX_VOL) v = MAX_VOL; + + float slider = ch switch { + AudioChannel.Sfx => AudioEngine.SfxMasterVolume, + AudioChannel.Ambient => AudioEngine.AmbientMasterVolume, + AudioChannel.Interface => AudioEngine.UiMasterVolume, + _ => 1f + }; + v *= slider; + if (!AudioEngine.FocusActive) v = 0; + + if (v <= VOL_FLOOR) { gain = 0; pitchMul = 1f; return false; } + gain = v; + pitchMul = 1f; // retail has optional per-sound pitch variance (random ±5%); apply here later + return true; +} +``` + +```csharp +// AudioEngine.Play - faithful port of the 16-slot eviction policy +public void Play(SoundId id, SoundTable table, Vector3 pos, Vector3 vel, + float baseVolume = 1.0f) +{ + if (!table.Sounds.TryGetValue(id, out var soundData)) return; + if (soundData.Entries.Count == 0) return; + + // Uniform pick (retail: rand() % count), then probability check + int idx = _rng.Next(soundData.Entries.Count); + var entry = soundData.Entries[idx]; + if (_rng.NextSingle() >= entry.Probability) return; // sound rolled away + + float dist = Vector3.Distance(pos, Listener.Position); + if (!AudioFalloff.Compute(dist, baseVolume * entry.Volume, + AudioChannel.Sfx, out float gain, out float pitch)) + return; // inaudible + + // Voice pool: round-robin scan, evict quietest weaker voice + int slot = AcquireVoiceSlot(gain, entry.Priority); + if (slot < 0) return; // nothing weaker → drop + + var source = _voicePool[slot]; + source.LoadWave(entry.WaveId, _soundCache); + source.Position = pos; + source.Velocity = vel; + source.Gain = gain; + source.Pitch = pitch; + source.Priority = entry.Priority; + source.Play(); +} + +int AcquireVoiceSlot(float incomingVolume, float incomingPriority) +{ + // Pass 1: free / finished + for (int i = 0; i < 16; i++) { + int idx = (_poolCursor + i) & 0xF; + var v = _voicePool[idx]; + if (!v.IsPlaying) return AdvanceCursor(idx); + } + // Pass 2: evict quieter-than-us using weighted volume (priority tie-breaker) + // Retail uses pure volume comparison; priority just affects computed volume pre-selection. + for (int i = 0; i < 16; i++) { + int idx = (_poolCursor + i) & 0xF; + if (_voicePool[idx].Gain < incomingVolume) { + _voicePool[idx].Stop(); + return AdvanceCursor(idx); + } + } + return -1; +} +``` + +```csharp +// AmbientPlayer.Tick - matches AmbientSoundDesc semantics +public void Tick(float dt, Vector3 listenerPos) +{ + if (_activeDesc == null) return; + foreach (var s in _activeStates) { + if (s.Desc.IsContinuous) { + if (!s.ContinuousSource.IsPlaying) + s.ContinuousSource.Play(); // loops forever, position tracks listener + continue; + } + s.NextFireIn -= dt; + if (s.NextFireIn <= 0) { + // roll chance + if (_rng.NextSingle() < s.Desc.BaseChance) { + // play near listener (retail scatters within ~8m box) + var pos = listenerPos + _rng.InUnitSphere() * 8f; + _engine.PlayAmbient(s.Desc.SType, s.Desc.Volume, pos); + } + // schedule next + s.NextFireIn = _rng.NextFloat(s.Desc.MinRate, s.Desc.MaxRate); + } + } +} +``` + +### 13.3 OpenAL parameter mapping + +Because retail does all attenuation in software, we set OpenAL to +**linear gain passthrough**: + +```csharp +// on engine init: +_al.DistanceModel(DistanceModel.None); // disable OpenAL's rolloff +_al.DopplerFactor(0f); // retail has no doppler +// for each source: +alSource.Gain = computedGain; // 0..1 from our falloff +alSource.Pitch = pitchMul; +alSource.Position = worldPos; // informational only (model=None) +alSource.Velocity = Vector3.Zero; // no doppler, keep at 0 +``` + +For stereo panning of 2D UI sounds, mono Wave files positioned with a +source that has `SourceRelative=true` at `(pan, 0, distFixed)` will +produce the expected L-R balance via OpenAL's minimal spatialization, +or we can explicitly feed stereo PCM into two mono sources with +gain-distributed panning. Simplest path: keep UI sounds mono, set +Source.Position to `(computedPan, 0, -1)` relative, and leave +DistanceModel=None — OpenAL still applies default HRTF/stereo-pan math +for relative sources. + +--- + +## 14. Integration points + +### 14.1 Player footsteps + +- `PlayerMovementController` (`src/AcDream.App/Input/PlayerMovementController.cs`) + drives motion; the `MotionInterpreter` processes `AnimationHook`s each + frame. +- **Hook into `AnimationSequencer`'s frame-advance callback.** When a + frame has a `SoundTableHook` with `SoundType=Footstep1/2`, call + `AudioEngine.Play(Footstep1, player.SoundTable, player.Position, + player.Velocity, baseVolume: 1.0f)`. +- Surface-type hint (from terrain subsystem) can be fed to a + `SoundTable.PlayWithSurfaceHint(Footstep1, surface)` overload that + biases the uniform random pick toward entries whose names hint at + the material. + +### 14.2 Combat hit sounds + +- Combat system ports to acdream later (Phase 6+). When `GameMessageSound` + (opcode 0xF750) arrives from the server: + +```csharp +void OnNetSound(Guid target, SoundId id, float volume) { + var obj = _gameState.FindObject(target); + if (obj == null || obj.SoundTable == null) return; + _engine.Play(id, obj.SoundTable, obj.Position, obj.Velocity, volume); +} +``` + +- Attack swing / bowstring sounds are triggered by `SoundTableHook` on + the attacker's animation — already covered by the 14.1 path. +- Impact sounds (HitFlesh/HitLeather/…) require the server to decide + armor type and fire 0xF750 with the right SoundId aimed at the + struck object. + +### 14.3 Spellcasting + +- Cast-start (windup) sounds: `SoundTableHook{Launch}` on the caster's + casting animation. +- In-flight whoosh: either the projectile weenie has a SoundTable with a + looping `Swoosh*` played each frame, or the projectile PhysicsScript + fires a continuous sound. We favor the former for simpler state. +- Impact / resolution: server sends `GameMessageScript{Fizzle}` or + `{Explode}` on opcode 0xF755 (PlayEffect) — PlayScript resolves to + both a particle + sound bundle. + +### 14.4 Ambient loop on landblock load + +- `Streaming/GpuWorldState` has a landblock-change event. Wire + `AmbientPlayer.ApplyRegionChange(newSTBId)` there. +- `STBId` selection: TERRAIN_TYPE byte of the current cell's dominant + vertex → lookup in RegionDesc.SoundInfo.STBDesc (or use a simpler + "by terrain-type-byte index" for phase 1; we can refine once + we match retail behavior visually/aurally). + +### 14.5 UI sounds + +- `UI_ButtonPress`, `UI_IconPickUp`, etc fire from UI input handling. +- Dedicated `AudioEngine.PlayUI(SoundId)` path that skips the + voice-pool eviction (UI sounds go to their own small pool or are + fire-and-forget). + +--- + +## 15. Research gaps / future work + +1. **Exact `DAT_00870414` and `DAT_00870418` calibration constants** — + the decompiled code exposes the function signature but not the + numeric defaults. Recommended: instrument retail (or a retail-server + client test harness) to dump them at runtime, or derive from + listening-distance tests (e.g. "Drudge footsteps audible out to ~30m + → back-solve DIST_K"). +2. **Surface-type-to-footstep-sample mapping** — confirmed as being + "implicit in the SoundTable's multi-entry lists," but the **specific + rule** for which entry index maps to which surface type needs a + sound-table dump + listening test. +3. **MIDI file inventory** — where are the actual `*.mid` files stored? + They're not in `client_portal.dat`. Likely `/sound/*.mid` + as loose files. Confirm with a retail-install inventory. +4. **PlayScript sound attachment** — PlayScript effects (dat type + `0x33xxxxxx`?) each carry a list of frames with optional sound hooks. + Depth analysis is part of R6 (particles); we link to it at that time. +5. **Pitch variance** — retail has some random pitch variance on + certain sounds (probably ±5% or so to avoid "identical twins" + artifacts on rapid-fire Wound1 sounds). Per-SoundEntry flag or + global? TBD from more decomp. +6. **Stereo pan specifics** — precise projection formula for `SetPan` + needs deeper grep of `FUN_00550d80`'s caller chain to extract the + pan scalar computation. + +--- + +## 16. References + +- Decompiled retail: `docs/research/decompiled/chunk_00550000.c` (entire + chunk is the sound subsystem + MIDI + ACM wrappers + options UI + callbacks; also `chunk_005D0000.c:12314` for DirectSoundCreate import). +- ACE dat loader: `references/ACE/Source/ACE.DatLoader/FileTypes/{SoundTable,Wave}.cs` + and `Entity/{SoundData,SoundTableData,SoundDesc,AmbientSoundDesc,AmbientSTBDesc}.cs`. +- ACE enums: `references/ACE/Source/ACE.Entity/Enum/{Sound,AnimationHookType,PlayScript}.cs`. +- ACE game messages: `references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs` + (opcodes 0xF750/F754/F755) and `Messages/GameMessageSound.cs`, + `Messages/GameMessageScript.cs`. +- ACE physics: `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:3088` (`play_sound`) + and `Physics/Hooks/AnimHook.cs`. +- Holtburger client: `references/holtburger/crates/holtburger-protocol/src/messages/effects/types.rs` + (`PlaySoundData`, 16-byte layout confirming ACE). +- DatReaderWriter schema: `references/DatReaderWriter/DatReaderWriter/dats.xml:3751` + (Wave type, `0xA000000..0xA00FFFF`) and `:3918` (SoundTable type, + `0x20000000..0x2000FFFF`); generated types in + `DatReaderWriter/Generated/{DBObjs,Types}/`. +- ACViewer reader (same base as ACE, useful cross-check): + `references/ACViewer/ACViewer/FileTypes/{SoundTable,Sound}.cs`. +- AC2D client opcode reference: `references/AC2D/cNetwork.cpp:2009-2029` + (`0xF750` is "sound effect", `0xF755` is "visual/sound effect", + unimplemented placeholders). + +--- diff --git a/docs/research/deepdives/r06-items-inventory.md b/docs/research/deepdives/r06-items-inventory.md new file mode 100644 index 0000000..4ff46df --- /dev/null +++ b/docs/research/deepdives/r06-items-inventory.md @@ -0,0 +1,1080 @@ +# R6 — Items, Inventory Containers, and the Property Model + +**Status:** Deep-dive research; no code written. +**Audience:** acdream client implementation (Phase R6 — item model + paperdoll UI). +**Sources:** +- Decompiled retail `acclient.exe` — `docs/research/decompiled/chunk_004A0000.c` (paperdoll), `chunk_00570000.c` (WError dispatcher), `chunk_006B0000.c` (chat/skill parser). +- `references/ACE/Source/ACE.Entity/Enum/` — `ItemType.cs`, `EquipMask.cs`, `ObjectDescriptionFlag.cs`, `WeenieHeaderFlags.cs`, `WeenieType.cs`, `MaterialType.cs`, `IdentifyResponseFlags.cs`, `ContainerType.cs`, `ItemXpStyle.cs`. +- `references/ACE/Source/ACE.Entity/Enum/Properties/` — `PropertyType.cs`, `PropertyInt.cs` (811 lines), `PropertyBool.cs`, `PropertyFloat.cs`, `PropertyString.cs`, `PropertyDataId.cs`, `PropertyInstanceId.cs`, `PropertyInt64.cs`. +- `references/ACE/Source/ACE.Server/WorldObjects/` — `Container.cs` (1013 lines), `Player_Inventory.cs` (3983 lines). +- `references/ACE/Source/ACE.Server/Physics/Common/EncumbranceSystem.cs` — burden math. +- `references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs`, `GameAction/GameActionType.cs`, `GameEvent/GameEventType.cs`. +- `references/ACE/Source/ACE.Server/Network/Structure/AppraiseInfo.cs` (866 lines) — canonical identify-response serialization. +- `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs` — `SerializeCreateObject`, `SerializeModelData`, `SerializePhysicsData`. +- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/PublicWeenieDesc.generated.cs` — byte-for-byte wire struct, flag-gated fields. +- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Actions/Inventory_*.generated.cs`, `Item_Appraise.generated.cs`. +- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Events/Item_SetAppraiseInfo.generated.cs`, `Item_ServerSays{MoveItem,ContainId}.generated.cs`, `Item_WearItem.generated.cs`. +- `references/holtburger/apps/holtburger-cli/src/pages/game/domains/inventory.rs` — working client state machine. + +The decompiled client is ground truth for **what the client stores and displays**; ACE is ground truth for **what the server sends and expects**; Chorizite is the canonical wire-level byte schema. + +--- + +## 1. ItemType enum + +The retail `ItemType` is a **32-bit bitfield** (not a pure enum) — a single item can have exactly one primary type bit, but server-side matching code ORs multiple bits to create composite categories ("VendorShopKeep", "RedirectableItemEnchantmentTarget", etc.). It is transmitted in `PublicWeenieDesc.Type` as `uint32` and is also stored as `PropertyInt.ItemType` (id 1). + +From `ACE.Entity.Enum.ItemType`, every bit value the retail client understands: + +| Bit | Value | Name | Notes | +|---|---|---|---| +| -- | `0x00000000` | None | Ephemeral objects (creatures in combat mode) default here | +| 0 | `0x00000001` | MeleeWeapon | swords, maces, axes, unarmed | +| 1 | `0x00000002` | Armor | armored clothing (chest, arms, legs, gauntlets with AL) | +| 2 | `0x00000004` | Clothing | non-armored (robes, shirts, breech, pants, shoes w/ 0 AL) | +| 3 | `0x00000008` | Jewelry | necklaces, bracelets, rings, trinkets | +| 4 | `0x00000010` | Creature | NPCs, monsters, player characters | +| 5 | `0x00000020` | Food | comestibles (chips, eats, drinks) | +| 6 | `0x00000040` | Money | pyreals (coin stack) | +| 7 | `0x00000080` | Misc | catchall — quest items, loot tokens, decorations | +| 8 | `0x00000100` | MissileWeapon | bows, crossbows, atlatls, thrown | +| 9 | `0x00000200` | Container | packs, chests, sacks (WeenieType.Container) | +| 10 | `0x00000400` | Useless | salvage bundles, junk | +| 11 | `0x00000800` | Gem | raw gems, cut gems | +| 12 | `0x00001000` | SpellComponents | taper, ivory scarab, cobalt jungle, etc | +| 13 | `0x00002000` | Writable | books, inscribable notes | +| 14 | `0x00004000` | Key | keys, keyrings | +| 15 | `0x00008000` | Caster | wands/orbs/staves casting a spell | +| 16 | `0x00010000` | Portal | portal stones, linked portals | +| 17 | `0x00020000` | Lockable | lockboxes, locked doors | +| 18 | `0x00040000` | PromissoryNote | MMD (mugs), trade notes | +| 19 | `0x00080000` | ManaStone | mana charge stones | +| 20 | `0x00100000` | Service | trainer services, vendor services | +| 21 | `0x00200000` | MagicWieldable | wand/orb/staff wieldable subtype | +| 22 | `0x00400000` | CraftCookingBase | raw cooking ingredient | +| 23 | `0x00800000` | CraftAlchemyBase | raw alchemy ingredient | +| 25 | `0x02000000` | CraftFletchingBase | raw fletching material (feathers/shafts) | +| 26 | `0x04000000` | CraftAlchemyIntermediate | alchemical solvents/elixirs in progress | +| 27 | `0x08000000` | CraftFletchingIntermediate | half-built arrows | +| 28 | `0x10000000` | LifeStone | recall lifestones | +| 29 | `0x20000000` | TinkeringTool | leather kits, oil kits, etc | +| 30 | `0x40000000` | TinkeringMaterial | refined salvage bundles | +| 31 | `0x80000000` | Gameboard | chess, chess pieces | + +Bits 24 (0x01000000) is reserved (gap in ACE). `ItemType` in bit `PropertyInt.TargetType` is also a `PublicWeenieDesc.TargetType` field in the wire header — gated by `WeenieHeaderFlag.TargetType = 0x00080000`. + +--- + +## 2. EquipMask — paperdoll slots + +The **paperdoll** is the character-window UI with fixed slots; each slot corresponds to a single bit in a 32-bit `EquipMask`. An item's eligible slots are `PropertyInt.ValidLocations` (id 9). The slot the item is **currently equipped in** is `PropertyInt.CurrentWieldedLocation` (id 10). Both are written to the wire as `uint32`, gated by `WeenieHeaderFlag.ValidLocations = 0x00010000` and `CurrentlyWieldedLocation = 0x00020000`. + +From `ACE.Entity.Enum.EquipMask` — this data is sent as `loc` in the player description message `F7B0 -0013`: + +| Bit | Value | Name | Paperdoll slot | +|---|---|---|---| +| 0 | `0x00000001` | HeadWear | Head (hat / helmet underlay) | +| 1 | `0x00000002` | ChestWear | Shirt / robe chest layer | +| 2 | `0x00000004` | AbdomenWear | Breech / robe abdomen | +| 3 | `0x00000008` | UpperArmWear | Shirt upper-arm | +| 4 | `0x00000010` | LowerArmWear | Shirt lower-arm | +| 5 | `0x00000020` | HandWear | Gloves / clothing | +| 6 | `0x00000040` | UpperLegWear | Pants upper-leg | +| 7 | `0x00000080` | LowerLegWear | Pants lower-leg | +| 8 | `0x00000100` | FootWear | Shoes | +| 9 | `0x00000200` | ChestArmor | Cuirass / plate | +| 10 | `0x00000400` | AbdomenArmor | Abdomen armor plate | +| 11 | `0x00000800` | UpperArmArmor | Pauldron | +| 12 | `0x00001000` | LowerArmArmor | Vambrace | +| 13 | `0x00002000` | UpperLegArmor | Tasset | +| 14 | `0x00004000` | LowerLegArmor | Greave | +| 15 | `0x00008000` | NeckWear | **Necklace** — paperdoll offset `0x604` | +| 16 | `0x00010000` | WristWearLeft | **Left bracelet** — offset `0x608` | +| 17 | `0x00020000` | WristWearRight | **Right bracelet** — offset `0x610` | +| 18 | `0x00040000` | FingerWearLeft | **Left ring** — offset `0x60c` | +| 19 | `0x00080000` | FingerWearRight | **Right ring** — offset `0x614` | +| 20 | `0x00100000` | MeleeWeapon | (item-tag; server matches via Weapon ready slot) | +| 21 | `0x00200000` | Shield | **Shield** — offset `0x620` | +| 22 | `0x00400000` | MissileWeapon | (item-tag) | +| 23 | `0x00800000` | MissileAmmo | **Missile ammo** — offset `0x61c` | +| 24 | `0x01000000` | Held | **Weapon** ready slot — offset `0x618` | +| 25 | `0x02000000` | TwoHanded | Two-handed weapon flag | +| 26 | `0x04000000` | TrinketOne | **Trinket** — offset `0x62c` | +| 27 | `0x08000000` | Cloak | **Cloak** — offset `0x630` | +| 28 | `0x10000000` | SigilOne | **Blue Aetheria** sigil — offset `0x634` | +| 29 | `0x20000000` | SigilTwo | **Yellow Aetheria** sigil — offset `0x638` | +| 30 | `0x40000000` | SigilThree | **Red Aetheria** sigil — offset `0x63c` | +| 31 | `0x80000000` | Clothing umbrella | or-mask only | + +### Cross-verification in the decompiled client + +From `chunk_004A0000.c` (function `FUN_004a5200`, the paperdoll drop-target dispatcher), fixed offsets off `param_1` (the paperdoll instance) are the per-slot drop-zone widget handles. The mapping below is exact: + +``` ++0x604 NeckWear "Drag necklaces here to wear them" ++0x608 WristWearLeft "Drag bracelets here to wear them" ++0x610 WristWearRight "Drag bracelets here to wear them" ++0x60c FingerWearLeft "Drag rings here to wear them" ++0x614 FingerWearRight "Drag rings here to wear them" ++0x618 Held (weapon) "Drag weapons here to wield them" ++0x61c MissileAmmo "Drag missile ammunition here to wield it" ++0x620 Shield "Drag shields here to wield them" ++0x624, +0x628 Clothing (chest/robes) "Drag clothing items here to wear them" ++0x62c TrinketOne "Drag trinkets here to activate them" ++0x630 Cloak "Drag cloaks here to activate them" ++0x634 SigilOne (Blue) "Drag a Blue Aetheria sigil here to activate it" ++0x638 SigilTwo (Yellow)"Drag a Yellow Aetheria sigil here to activate it" ++0x63c SigilThree (Red) "Drag a Red Aetheria sigil here to activate it" ++0x640 HeadWear "Drag head items here to wear them" ++0x644 ChestWear "Drag chest items here to wear them" ++0x648 AbdomenWear "Drag abdomen items here to wear them" ++0x64c UpperArmWear "Drag upper arm items here to wear them" ++0x650 LowerArmWear "Drag lower arm items here to wear them" ++0x654 HandWear "Drag glove items here to wear them" ++0x658 UpperLegWear "Drag upper leg items here to wear them" ++0x65c LowerLegWear "Drag lower leg items here to wear them" ++0x660 FootWear "Drag foot coverings here to wear them" +``` + +When a `param_3 == 0` (hover/tooltip, no item dropped yet), the drop-hint text is shown. When `param_3 != 0` (an item is hovered over the slot while held), the code flips a boolean `bVar1` — for jewelry/clothing slots it's `true` ("worn" / "take off"), for weapon-family slots it's `false` ("wielded" / "unwield"). Then it formats `"%s (%s)\nDouble-click to %s"` where `%s` is the item name, `worn`/`wielded`, and `take off`/`unwield`. **That's the retail tooltip format — acdream must reproduce it verbatim.** + +### Slot aggregation sets + +Multi-bit unions the server uses for validation: + +- `Armor = ChestArmor | AbdomenArmor | UpperArmArmor | LowerArmArmor | UpperLegArmor | LowerLegArmor | FootWear` — AL-contributing slots. +- `ArmorExclusive` — `Armor` minus `FootWear` (feet are dual-use). +- `Jewelry = NeckWear | WristWearLeft | WristWearRight | FingerWearLeft | FingerWearRight | TrinketOne | Cloak | SigilOne | SigilTwo | SigilThree`. +- `Sigil = SigilOne | SigilTwo | SigilThree` — the three Aetheria slots. +- `Selectable = MeleeWeapon | Shield | MissileWeapon | Held | TwoHanded` — things that go in the ready-slot "weapon" area. +- `ReadySlot = Held | TwoHanded | TrinketOne | Cloak | SigilOne | SigilTwo`. + +--- + +## 3. Property schema — seven tables + +Retail AC represents every object's state as a bundle of typed property tables, keyed by `ushort` enum ids. This is the **entity-component data model** of the whole game. There are **seven** property tables: + +| PropertyType | Key type | Value type | ACE enum | Wire tag | +|---|---|---|---|---| +| PropertyInt | ushort | int32 | `PropertyInt` (0–390 + custom ≥8000) | `0x0001` IntStatsTable | +| PropertyInt64 | ushort | int64 | `PropertyInt64` (0–8 + custom) | `0x2000` Int64StatsTable | +| PropertyBool | ushort | uint32 (as bool) | `PropertyBool` (0–130 + custom) | `0x0002` BoolStatsTable | +| PropertyFloat | ushort | double (8 bytes) | `PropertyFloat` (0–171 + custom) | `0x0004` FloatStatsTable | +| PropertyString | ushort | WString16L | `PropertyString` (0–52 + custom) | `0x0008` StringStatsTable | +| PropertyDataId | ushort | uint32 (DID) | `PropertyDataId` (0–61 + custom) | `0x1000` DidStatsTable | +| PropertyInstanceId | ushort | uint32 (guid) | `PropertyInstanceId` (0–45 + custom) | (inline per-property, see §4) | +| PropertyAttribute | ushort | attribute struct | (Strength, Endurance, Coord, Quick, Focus, Self) | sent in PlayerDescription | +| PropertyAttribute2nd | ushort | vital struct | (Health, Stamina, Mana) | sent in PlayerDescription | +| PropertyPosition | ushort | 32-byte position | (Location, Sanctuary, LastOutside, ...) | sent in PlayerDescription | + +Property enum ids are stable across all clients (retail 1.0.5.x through ACE) — they are part of the wire contract. + +### Item-relevant PropertyInt ids (highest-traffic) + +``` + 1 ItemType // see §1 — bitfield of ItemType + 2 CreatureType // for creatures / slayer matches + 3 PaletteTemplate // recolor palette id + 4 ClothingPriority // CoverageMask — layer order + 5 EncumbranceVal // this item's burden units (int) + 6 ItemsCapacity // if container: main-pack slots + 7 ContainersCapacity // if container: side-pack slots + 9 ValidLocations // EquipMask bitfield of legal slots +10 CurrentWieldedLocation // actual EquipMask slot (when equipped) +11 MaxStackSize // hard cap (pyreals 10000, gems 100, arrows 100, etc.) +12 StackSize // current count +13 StackUnitEncumbrance // per-unit burden +14 StackUnitMass // per-unit server-side mass +15 StackUnitValue // per-unit pyreal value +16 ItemUseable // Usable enum — "double-click does what" +17 RareId // rare index, for rare-items display +18 UiEffects // IconHighlight — magic/enchanted overlay +19 Value // total pyreal sale value +20 CoinValue // for Money items: pyreal count (ephemeral on players) +21 TotalExperience // for XP-awarding containers/items +27 ArmorType // cloth/chain/scale/plate/leather +28 ArmorLevel // AL (base) +83 ActivationResponse // Use action behavior +91 MaxStructure // max charges/uses +92 Structure // current charges/uses +93 PhysicsState // PhysicsState flags +94 TargetType // ItemType mask of valid cast targets +95 RadarBlipColor // RadarColor enum +96 EncumbranceCapacity // your cap in burden units +99 PkLevelModifier // PK status mod +105 ItemWorkmanship // 1..10 workmanship +106 ItemSpellcraft // spellcraft (max spell difficulty) +107 ItemCurMana // current mana +108 ItemMaxMana // max mana +109 ItemDifficulty // arcane lore difficulty +110 ItemAllegianceRankLimit // min rank to wield +113 Gender // for creatures; also for heritage-locked items +114 Attuned // AttunedStatus (boon/bonded/untradeable) +115 ItemSkillLevelLimit // min skill value to wield +117 ItemManaCost // mana cost per tick +131 MaterialType // MaterialType enum (see §11) +134 PlayerKillerStatus // PK status +158 WieldRequirements // WieldRequirement enum (skill, level, attribute, etc.) +159 WieldSkillType // Skill enum for requirement check +160 WieldDifficulty // threshold for requirement +166 SlayerCreatureType // slayer damage multiplier target +170 NumItemsInMaterial // salvage bundle count +171 NumTimesTinkered // 0..10 tinkers applied +172 AppraisalLongDescDecoration // long-desc display decoration +174 AppraisalPages // book pages available to read +175 AppraisalMaxPages // max pages the book has +176 AppraisalItemSkill // skill used to appraise +177 GemCount, 178 GemType // set-bonus source gems +179 ImbuedEffect // ImbuedEffectType enum (attack mod, crippling, etc.) +181 ChessRank // chess score (if gameboard) +257 ItemAttributeLimit // required attribute type +258 ItemAttributeLevelLimit // required attribute level +265 EquipmentSetId // armor set id +267 Lifespan, 268 RemainingLifespan +270 WieldRequirements2-4 (271/272, 273/274/275, 276/277/278) — stacked wield gates +279 Unique // unique-cap count +280 SharedCooldown // cooldown-group id +303-306 ImbuedEffect2..5 // additional imbue bits +319 ItemMaxLevel // if item-xp, cap level +320 ItemXpStyle // Fixed / ScalesWithLevel / FixedPlusBase +322 AetheriaBitfield // AetheriaBitfield (which sigils) +``` + +### Item-relevant PropertyBool + +``` + 2 Open // container open state + 3 Locked // lockable state +22 Inscribable +23 DestroyOnSell +38 IsFrozen // attuned / account-bound +69 IsSellable +85 AppraisalHasAllowedWielder // set when AllowedWielder IID is set +91 Retained // does not drop on death +94 AppraisalHasAllowedActivator +99 Ivoryable // can apply ivory +100 Dyable // can be dyed +108 RareUsesTimer +116 WieldOnUse // equip on first use +130 AutowieldLeft +``` + +### Item-relevant PropertyFloat + +``` + 3 HealthRate 22 DamageVariance 63 DamageMod + 5 ManaRate 29 WeaponDefense 62 WeaponOffense + 6–8 UponResurrection 22 DamageVariance 87 ItemEfficiency +13–19 ArmorMod vs (Slash, Pierce, Bludgeon, Cold, Fire, Acid, Electric) +20 CombatSpeed 21 WeaponLength 22 DamageVariance +64–75 Resist* 76 Translucency 78 Friction 79 Elasticity +92 PowerLevel 93 AccuracyLevel 94 AttackAngle +100 HealkitMod 109 BondWieldedTreasure +116 WildAttackProbability +144 ManaConversionMod 147 CriticalFrequency +149 WeaponMissileDefense 150 WeaponMagicDefense +152 ElementalDamageMod 155 IgnoreArmor (imbue effect %) +157 ResistanceModifier 159 AbsorbMagicDamage +167 CooldownDuration 168 WeaponAuraOffense +``` + +### Item-relevant PropertyString + +``` + 1 Name 2 Title 7 Inscription + 8 ScribeName 15 ShortDesc 16 LongDesc 17 ActivationTalk +18 UseMessage 22 ActivationFailure +25 CraftsmanName (workmanship signature) +33 Quest 39 TinkerName 40 ImbuerName +42 DisplayName 47 AllegianceName 52 GearPlatingName +``` + +### Item-relevant PropertyDataId + +``` + 1 Setup 2 MotionTable 3 SoundTable + 6 PaletteBase 7 ClothingBase 8 Icon +22 PhysicsEffectTable 24 UseTargetAnimation +28 Spell (cast on wield) 29 SpellComponent +36 MutateFilter 37 ItemSkillLimit +50 IconOverlay 51 IconOverlaySecondary 52 IconUnderlay +55 ProcSpell (attack-proc spell) +``` + +### Item-relevant PropertyInstanceId + +``` + 1 Owner 2 Container 3 Wielder 4 Freezer +17 Creator 18 Victim 22 Bonded 23 Wounder +24 Allegiance 32 HouseOwner 33 House +38 AllowedWielder 39 AssignedTarget 40 LimboSource +31 AllowedActivator +``` + +--- + +## 4. CreateObject / UpdateObject packet format + +When a new entity enters the client's PVS (or an existing one changes), the server sends one of: + +- **CreateObject `0xF745`** — full snapshot. +- **UpdateObject `0xF7DB`** — forced full re-send (used when the server catches up after drift). +- **ForceObjectDescSend `0xF6EA`** — header-only re-send. + +All three use the same three-struct sequence: + +``` +uint32 ObjectId (GUID) +ObjectDesc ("model" data) — appearance overlays +PhysicsDesc ("physics" data) — motion/position/tables +PublicWeenieDesc ("weenie") — gameplay properties, flag-gated +``` + +### ObjectDesc layout (model) + +From `WorldObject_Networking.cs::SerializeModelData`: + +``` +byte 0x11 // magic / version tag +byte numSubPalettes +byte numTextureChanges +byte numAnimPartChanges +if numSubPalettes > 0: + packed-DWORD-of-known-type(0x04000000) PaletteID +loop numSubPalettes: + packed-DWORD-of-known-type(0x04000000) SubPaletteId + byte offset + byte length +loop numTextureChanges: + byte partIndex + packed-DWORD-of-known-type(0x05000000) OldTexture + packed-DWORD-of-known-type(0x05000000) NewTexture +loop numAnimPartChanges: + byte index + packed-DWORD-of-known-type(0x01000000) AnimationId +align to 4 +``` + +"Packed-DWORD-of-known-type" is: if the high nybble equals the expected type (0x04/0x05/0x01/0x06), the writer omits those bits and emits a compact form. ACE does this via `WritePackedDwordOfKnownType`. + +### PhysicsDesc layout + +Gated by `PhysicsDescriptionFlag` (uint32 flags at the start). Field order (per ACE `SerializePhysicsData`): + +``` +uint32 physicsDescriptionFlag +uint32 physicsState +if Movement: uint32 dataLength; byte[dataLength] MovementData; uint32 IsAutonomous +else if AnimationFrame: uint32 Placement +if Position: PositionPack (32 bytes: uint32 landCell, 3×float3 origin+rotation+unit-quat) +if MTable: uint32 MotionTableId +if STable: uint32 SoundTableId +if PeTable: uint32 PhysicsTableId +if CSetup: uint32 SetupId +if Parent: uint32 parentGuid; uint32 parentLocation +if Children: uint32 numChildren; loop {uint32 guid; uint32 location;} +if ObjScale: float scale +if Friction: float friction +if Elasticity: float elasticity +if Translucency: float translucency +if Velocity: float3 velocity +if Acceleration: float3 acceleration +if Omega: float3 omega +if DefaultScript: uint32 scriptId +if DefaultScriptIntensity: float intensity +then sequences: + uint16 posSeq, ushort moveSeq, ushort teleSeq, ushort forceSeq, + ushort objectSeq, ushort descSeq, + (possibly instanceSeq). +align to 4 +``` + +(Full enumeration in `PhysicsDescriptionFlag`; for items specifically, when an item is **in inventory** the server mostly sends `CSetup | MTable | STable | PeTable | ObjScale` without `Position/Velocity/Omega` since the item isn't in the world.) + +### PublicWeenieDesc layout — the item game-data + +This is **the** canonical item struct. Verified in `Chorizite.ACProtocol/Types/PublicWeenieDesc.generated.cs`. Field order, with `WeenieHeaderFlag` bit to enable each optional slot: + +``` +Always: + uint32 Header (WeenieHeaderFlag bitfield) + WString16L Name (length-prefixed UTF-16-LE, padded) + packedDWORD WeenieClassId + packedDWORD Icon (dat id minus 0x06000000) + uint32 Type (ItemType) + uint32 Behavior (ObjectDescriptionFlag) + align(4) + +if (Behavior & 0x04000000 IncludesSecondHeader): + uint32 Header2 (WeenieHeaderFlag2 bitfield) + +Per-flag (in exact order): + 0x00000001 PluralName : WString16L + 0x00000002 ItemsCapacity : byte + 0x00000004 ContainersCapacity: byte + 0x00000100 AmmoType : uint16 + 0x00000008 Value : uint32 + 0x00000010 Usable : uint32 (Usable enum) + 0x00000020 UseRadius : float32 + 0x00080000 TargetType : uint32 (ItemType) + 0x00000080 Effects : uint32 (IconHighlight / UiEffects) + 0x00000200 CombatUse : byte (WieldType) + 0x00000400 Structure : uint16 + 0x00000800 MaxStructure : uint16 + 0x00001000 StackSize : uint16 + 0x00002000 MaxStackSize : uint16 + 0x00004000 ContainerId : uint32 (host container GUID) + 0x00008000 WielderId : uint32 (wielder GUID) + 0x00010000 ValidLocations : uint32 (EquipMask) + 0x00020000 CurrentWieldedLocation: uint32 (EquipMask) + 0x00040000 Priority (ClothingPriority): uint32 (CoverageMask) + 0x00100000 BlipColor : byte (RadarColor) + 0x00800000 RadarEnum : byte (RadarBehavior) + 0x08000000 PhysicsScript : uint16 + 0x01000000 Workmanship : float32 + 0x00200000 Burden : uint16 (EncumbranceVal truncated) + 0x00400000 SpellId : uint16 + 0x02000000 OwnerId : uint32 + 0x04000000 Restrictions : RestrictionDB (house perms) + 0x20000000 HookItemTypes : uint16 (HookType) + 0x00000040 MonarchId : uint32 + 0x10000000 HookType : uint16 + 0x40000000 IconOverlay : packedDWORD (minus 0x06000000) + Header2-0x01 IconUnderlay : packedDWORD (minus 0x06000000) + 0x80000000 Material : uint32 (MaterialType) + Header2-0x02 CooldownId : uint32 + Header2-0x04 CooldownDuration: uint64 (or double, ACE has it as double) + Header2-0x08 PetOwnerId : uint32 + align(4) +``` + +The absence of a flag means the client uses the dat's weenie-template default (or the value is "unset"). **This is why tiny items (a stone, a stick) ship tiny packets** — only 2–3 flags set; a max-gear rare ships dozens. + +--- + +## 5. Appraise flow + +### Request (C2S) + +`Item_Appraise` — GameAction `0x00C8` (`IdentifyObject`): + +``` +uint32 ObjectId // the target GUID +``` + +That's it. The client sends this when the player right-clicks an item and picks "Assess". Fragment is wrapped in a `0xF7B1 GameAction`. + +### Response (S2C) + +`GameEvent 0x00C9 IdentifyObjectResponse` (inside a `0xF7B0` GameEvent envelope). Structure is `Item_SetAppraiseInfo`: + +``` +uint32 ObjectId +uint32 Flags // IdentifyResponseFlags bitfield +bool Success // one byte, 0/1 — false = "you don't know enough" + +// Flag-gated blocks (in this order): +0x0001 IntStatsTable : PackableHashTable +0x2000 Int64StatsTable : PackableHashTable +0x0002 BoolStatsTable : PackableHashTable +0x0004 FloatStatsTable : PackableHashTable +0x0008 StringStatsTable : PackableHashTable +0x1000 DidStatsTable : PackableHashTable +0x0010 SpellBook : PackableList + // top-bit (0x80000000) flags an aura/enchant +0x0080 ArmorProfile : ArmorProfile struct +0x0100 CreatureProfile : CreatureAppraisalProfile struct +0x0020 WeaponProfile : WeaponProfile struct +0x0040 HookProfile : HookAppraisalProfile struct +0x0200 ArmorEnchantmentBitfield: uint16 highlight, uint16 color +0x0800 WeaponEnchantmentBitfield: uint16 highlight, uint16 color +0x0400 ResistEnchantmentBitfield: uint16 highlight, uint16 color +0x4000 ArmorLevels : 9× uint32 per-body-part AL (head, chest, groin, bicep, wrist, hand, thigh, shin, foot) +``` + +### PackableHashTable format + +``` +ushort count +ushort numBuckets // power-of-2, comparer determines +// followed by count entries, sorted by PropertyIntComparer (bucketed hash order, +// not numeric order). Within a bucket, entries are in insertion order. +loop count: + uint32 key // upcast from ushort property enum + value // type depends on the table +``` + +ACE writes: +- `PropertyIntComparer(numBuckets=16)`, `PropertyInt64Comparer(8)`, `PropertyBoolComparer(8)`, `PropertyFloatComparer(8)`, `PropertyStringComparer(8)`, `PropertyDataIdComparer(8)`. These must match exactly or the retail client displays garbage. + +### Assessment success logic + +The server's `Player_Assess.cs` (not open here, but evidenced by `PropertyBool.AppraisalHasAllowedWielder` and `PropertyInt.AppraisalItemSkill`) uses the **Assessment** skill (id 0x10, found in the chat/skill dispatcher at `chunk_006B0000.c:1245`). Skill check formula follows `SkillCheck.GetSkillChance(playerSkill, itemDifficulty)` — a standard AC S-curve where 50% chance occurs at `skill == difficulty`. Ten-hit retry timer (`AppraisalHeartbeatDueTimestamp`, `AppraisalRequestedTimestamp`) prevents spam. + +Items with `ItemDifficulty = 0` or NPCs always succeed (from `AppraiseInfo.BuildProperties` — `Success = true` default). Failure still sends a `IdentifyObjectResponse` but with `Success = false` and an empty table set (most properties stripped). + +--- + +## 6. Burden and bulk (encumbrance) + +### Capacity formula + +From `ACE.Server/Physics/Common/EncumbranceSystem.cs` (canonical, matches decompiled retail): + +```csharp +int EncumbranceCapacity(int strength, int numAugs) +{ + if (strength <= 0) return 0; + int bonusBurden = 30 * numAugs; // AugmentationIncreasedCarryingCapacity: up to 5 + if (bonusBurden >= 0) { + if (bonusBurden > 150) bonusBurden = 150; + return 150 * strength + strength * bonusBurden; + } + return 150 * strength; +} +``` + +A level-1 character with `Strength = 10` has capacity `150 × 10 = 1500` burden units. A level-275 character with `Strength = 400` and 5 augs has `150 × 400 + 400 × 150 = 60000 + 60000 = 120000` units. + +### Carry limit + +`Player_Inventory.cs::HasEnoughBurdenToAddToInventory`: + +```csharp +return (EncumbranceVal + newItemBurden) <= (GetEncumbranceCapacity() * 3); +``` + +**The hard carry-cap is `3 × capacity`.** At `2× capacity` you're "over-encumbered" and your movement-speed mod (`GetBurdenMod`) drops to 0; between `1× and 2×` it linearly interpolates `2 - burden` in multiplier; at `<1×` it's 1.0. + +```csharp +float GetBurdenMod(float burden /* = encumbrance / capacity */) +{ + if (burden < 1.0f) return 1.0f; + if (burden < 2.0f) return 2.0f - burden; + return 0.0f; +} +``` + +So the three breakpoints are: + +| Carried | UI message | Movement mod | +|---|---|---| +| 0–100 % | normal | 1.0 | +| 100–200 % | "burdened" | 1.0 → 0.0 linearly | +| 200–300 % | "over-burdened" | 0.0 (can't run) | +| > 300 % | **cannot pick up** (error `0x2A` — "You are too encumbered to carry that!") | + +The WError codes `0x2A` and `0x2B` from `chunk_00570000.c:1853–1858` are these hit. + +### Bulk (slots) + +Unlike burden, bulk is **item count per container** — not mass-proportional. Main backpack: `ItemsCapacity` property (max 102 for the Player — Player_Inventory doesn't check it past base 102). Side packs: `ContainersCapacity` property (base 7 slots) — each slot holds one side pack (regular pack, foci, or pouch). + +Side pack capacity: `ItemsCapacity` on the pack weenie itself — retail packs are 24 items, pouches typically 12, foci are `ContainerType.Foci` and are slot-exclusive with no sub-items. `Container.cs::GetFreeInventorySlots(includeSidePacks=true)` sums main + all side-pack free slots. + +--- + +## 7. Container hierarchy + +### Depth model + +Retail AC permits **exactly two levels**: + +``` +Player (primary Container) + ├── Main pack items (ItemsCapacity slots, 102 total in a maxed player) + ├── Side pack 1 (Container) // uses a ContainerCapacity slot + │ └── items (ItemsCapacity slots of the sub-pack) + ├── Side pack 2 (Container) + │ └── items + ├── Foci (ContainerType.Foci) // ContainerCapacity slot, no inventory + └── ... +``` + +**A side pack may not contain another side pack.** This is enforced both server-side (in `Container.AddToInventory`) and by the client paperdoll drag handler. From `Container.cs`: + +```csharp +private int CountContainers() => + Inventory.Values.Count(wo => wo.UseBackpackSlot); // UseBackpackSlot = (WeenieType.Container || IsFoci) +``` + +`UseBackpackSlot` is true for both sub-containers and foci. Nested containers route to side-pack slots; anything else goes to main. + +### Encoding + +Each item has: +- `PropertyInstanceId.Container = 2` — its direct parent container's GUID. +- `PropertyInt.PlacementPosition = 53` — 0-based slot index within that container (sorted by `OrderBy(wo => wo.PlacementPosition)`). + +So a side pack's contents are enumerated by walking `Inventory[]` for items whose `Container == sidePack.Guid`, ordered by `PlacementPosition`. + +The wire `PublicWeenieDesc.ContainerId` field (WeenieHeaderFlag 0x00004000) directly carries this. On `CreateObject`, if an item is in a pack, it's placed inside `PublicWeenieDesc` with `ContainerId = pack.Guid`; on `WieldObject`, `WielderId` is set and `ContainerId` is cleared. + +### `InventoryPutObjInContainer` event (GameEvent 0x0022) + +S2C event structure (`GameEventItemServerSaysContainId`): + +``` +uint32 itemGuid +uint32 containerGuid +uint32 placementPosition +uint32 ContainerType // 0=NonContainer, 1=Container, 2=Foci (from ACE.Entity.Enum.ContainerType) +``` + +This fires whenever the server slots an item into a container — either player-initiated (picked up, moved) or server-initiated (loot spawned in corpse). + +--- + +## 8. Stacking rules + +### Which items stack + +An item is stackable iff `MaxStackSize > 1`. In practice: pyreals (10000 cap), gems (100), arrows (100), scrolls (1 — single-use), spell components (1000 for taper-family, varies), salvage bundles (100 pieces). `WeenieType.Coin` and `WeenieType.Stackable` and `WeenieType.SpellComponent` are the main "stacks by default" categories. + +### Merge rules (from ACE's `DoHandleActionStackableMerge`) + +- Both source + target must have the same `WeenieClassId`. +- Both must be unenchanted (`!IsEnchanted`). WError `0x428` — "You cannot merge enchanted items!". +- The source must belong to the player OR both must belong to the player (WError `0x429` — "You must control at least one stack!"). +- Target `StackSize + amount <= MaxStackSize`, else an overflow stack remains as the source. + +### Split actions (C2S) + +- `StackableMerge 0x0054`: `ObjectId (source) + TargetId (target) + Amount`. +- `StackableSplitToContainer 0x0055`: `ObjectId + ContainerId + SlotIndex + Amount` — makes a new stack of `Amount` inside a container. +- `StackableSplitTo3D 0x0056`: `ObjectId + Amount` — drops a split stack to the ground. +- `StackableSplitToWield 0x019B`: `ObjectId + SlotIndex (EquipMask) + Amount` — equip a split (e.g. arrows directly to missile ammo). + +### `Item_UpdateStackSize` S2C (opcode 0x0197) + +``` +byte Sequence +uint32 ObjectId +uint32 Amount // new stack size +uint32 NewValue // new total Value (pyreals) +``` + +Fired when a stack changes size without changing GUID (e.g. merging adds to target). If the source is entirely consumed, a `ObjectDelete 0xF747` follows. + +--- + +## 9. Equipment and wield + +### Equip request (C2S `GetAndWieldItem 0x001A`) + +``` +uint32 ObjectId // item GUID +uint32 Slot // EquipMask — which slot we want +``` + +The server validates in order: +1. Item belongs to the player or is in an accessible container (WError `0x28` — "The item is under someone else's control!"). +2. Item is `MagicWieldable`, `Weapon`, `Armor`, etc — matches its `ValidLocations`. +3. `Slot` is one of the bits in `ValidLocations`. +4. Level / skill / attribute requirements via `PropertyInt.WieldRequirements..4`: + - `WieldRequirement.Level` — needs `Player.Level >= WieldDifficulty`. + - `WieldRequirement.Attrib` / `RawAttrib` — needs `Attribute.Current >= WieldDifficulty`. + - `WieldRequirement.Skill` / `RawSkill` — `Skill.Current >= Difficulty` for the `WieldSkillType`. + - `WieldRequirement.HeritageType` — species lock. +5. Activation: `ItemAllegianceRankLimit`, `ItemSkillLevelLimit`, `Attuned`, `Gender`, etc. +6. For PK-bound items: `PropertyInstanceId.AllowedWielder` must equal player GUID. +7. Carrying-capacity check if it came from outside the player's inventory. + +On success, the server sends: + +- `GameEventWieldItem 0x0023` — instructs the paperdoll to display the item in `Slot`: + ``` + uint32 itemGuid + uint32 slot (EquipMask) + ``` +- Follow-up: `UpdateObject 0xF7DB` with `WielderId = player.Guid`, `CurrentWieldedLocation = Slot`, `ContainerId = 0`. + +### Un-equip + +Client sends a `PutItemInContainer 0x0019` targeting the player's own GUID as the container. Server revokes wielded status and slots the item back into main pack. Visual equipment on other players is driven by the `ObjDescEvent 0xF625` (appearance re-send) after the wield/un-wield. + +--- + +## 10. Currency + +Retail AC has three economic currencies: + +1. **Pyreals** — weenie `273`, `WeenieType.Coin`, `ItemType.Money`. Max stack **10000** per stack (capped explicitly in starter gear and loot code). A character can carry multiple 10000-pyreal stacks; `PropertyInt.CoinValue` sums them across main pack + side packs (ephemeral, recalculated on inventory change via `UpdateCoinValue`). +2. **Luminance** — `PropertyInt64.AvailableLuminance` (id 6) / `PropertyInt64.MaximumLuminance` (id 7). Not an item — it's a player-level int64 tracked as a property. Spent on LuminanceAug vendor services. +3. **Society tokens / allegiance XP** — Society rank is `PropertyInt.SocietyRankCelhan / Eldweb / Radblo` (ids 287–289), but the currency itself is a physical item (e.g. tokens — weenie 33613 "Pathwarden Token" in the starter gear). `TotalExperience` (int64) is also an XP currency for allegiance redistribution. + +### Coin overflow + +When a stack would exceed `MaxStackSize = 10000`, the server **creates a second stack** and places it in the next free slot (`DoHandleActionStackableMerge` creates overflow). Players end up with `Pyreal (10000x)` stacks + a partial one. + +--- + +## 11. Salvage + tinker state + +### Workmanship & crafting state + +`PropertyInt.ItemWorkmanship = 105` — the 1-to-10 workmanship score. Stored as `uint32` on the item. +`PropertyFloat.Workmanship` in the `PublicWeenieDesc` (flag 0x01000000) is a `float32` — workmanship is shipped twice: the quantized int on the item, and a fractional float on the wire for salvage-bundle averaging. + +### Tinker slots + +`PropertyInt.NumTimesTinkered = 171` — 0..10. Each tinker application also adds a string to `PropertyString.TinkerLog = 9007` (or per tinker type). +`PropertyInt.TinkerLog (str 9007)` is a semicolon-separated log: `"Horns;Leather;Pine"` — displayed in the long description on appraise. +`PropertyString.TinkerName = 39` and `PropertyString.ImbuerName = 40` — the character names who tinkered/imbued. + +### Imbue state + +`PropertyInt.ImbuedEffect = 179` is an `ImbuedEffectType` enum: attack-skill raising, critical-freq raising, slash/pierce/blunt/fire/cold/acid/electric damage raising, armor rending, crushing blow, biting strike, hematite (lifesteal), gurog (mana-drain). Additional slots `ImbuedEffect2..5 = 303..306` can stack multiple imbues. +`ImbueStackingBits = 311` — bitfield for tinker-stacking augmentations (Class A, B, C, Corrupted Amber, Purchased Armor). +`PropertyInt.ImbueAttempts = 205` / `ImbueSuccesses = 206` — counters. + +### Salvage-bundle fields + +`PropertyInt.NumItemsInMaterial = 170` — count of items merged into this bundle. +`PropertyInt.MaterialType = 131` — see `MaterialType` enum (§12). Bundles all share the same material; mixing materials makes separate bundles. +`PropertyInt.Structure / MaxStructure = 91/92` — per-unit charge OR bundle count (reused). For a Salvage bundle, `Structure = 1..100` pieces. + +### Usage tracking + +`PropertyInt.Structure = 92` / `MaxStructure = 91` track uses remaining for healing kits, ivory kits, lockpicks, tinker tools. `PropertyFloat.UseLockTimestamp = 99` prevents spam-use. + +--- + +## 12. Active enchantments on items + +When an item is enchanted (via Item Enchantment spells like Strength Self III, Blood Drinker VI, Infected Caress), the enchantment is stored on the **item's EnchantmentManager** (server-side) and sent to the client through `MagicUpdateEnchantment 0x02C2` events whenever the item is visible. + +On `Item_SetAppraiseInfo`, the `SpellBook` field (flag 0x0010) contains **two concatenated lists**: + +1. **Innate spells** — from the item's weenie (fire-and-forget cantrips). Sent as raw `SpellId` (uint32). +2. **Active auras/enchantments** — sent with top bit set: `spellId | 0x80000000`. The client's appraise window renders these with a different color/icon (usually yellow "aura" marker). + +`AppraiseInfo.cs` builds this list from `wo.Biota.GetEnchantments()` plus, for weapons, the wielder's `EnchantmentManager.GetEnchantments(MagicSchool.ItemEnchantment)` filtered by `SpellCategory` (e.g. `AttackModRaising`, `DefenseModRaising`, `DamageRaising`). This lets a wielded weapon show **"you see this weapon as if it had Heart Thirst VI on it"** (aura) even though the spell is actually on the wielder. + +The retail client also decorates the icon with a **blue glow** when there are any active auras — driven by `PropertyInt.UiEffects = 18` (`IconHighlight` enum: Magical, Heroic, Diabolical, Elemental). `UiEffects` is written in the `PublicWeenieDesc` flag 0x00000080 (`Effects`). The value transitions visually to the "magic item" icon overlay. + +--- + +## 13. Icon, icon-overlay, and icon-underlay + +Three DID fields compose the displayed item icon (all ResourceIds with type prefix `0x06000000`): + +| PropertyDataId | Wire field | Purpose | +|---|---|---| +| `8 Icon` | `PublicWeenieDesc.Icon` (required) | base image — rendered bottom-most | +| `50 IconOverlay` | WeenieHeaderFlag `0x40000000` | top decoration — used for "magic item" sparkles | +| `52 IconUnderlay` | WeenieHeaderFlag2 `0x00000001` | bottom decoration — used for rare-item foil, socketed marker | +| `51 IconOverlaySecondary` | (not in wire; server-only) | alternate overlay for dye/tier | + +Overlays in the dat are 32×32 RGBA icons with transparency where the base icon shows through. The client composites in this order: underlay → icon → overlay → text stack-count (rendered by UI). For Aetheria sigils the base icon is the slot (blue/yellow/red gem), the overlay marks activated state. + +`UiEffects` (`PropertyInt 18`) is **additive** to the overlay — it's a client-side post-process glow, not a dat asset. Values are `IconHighlight.Magical / Heroic / Diabolical / ...` which the retail client renders by tinting the whole icon with a colored additive sprite. + +--- + +## 14. Retail wire messages — summary + +### Client → Server (inside `0xF7B1 GameAction` envelope, `C2SMessageType` ordinal) + +| GameActionType | Opcode | Message | Payload | +|---|---|---|---| +| PutItemInContainer | `0x0019` | Inventory_PutItemInContainer | `uint32 objectId; uint32 containerId; uint32 slotIndex` | +| GetAndWieldItem | `0x001A` | Inventory_GetAndWieldItem | `uint32 objectId; uint32 slot (EquipMask)` | +| DropItem | `0x001B` | Inventory_DropItem | `uint32 objectId` | +| StackableMerge | `0x0054` | Inventory_StackableMerge | `uint32 objectId; uint32 targetId; uint32 amount` | +| StackableSplitToContainer | `0x0055` | Inventory_StackableSplitToContainer | `uint32 objectId; uint32 containerId; uint32 slotIndex; uint32 amount` | +| StackableSplitTo3D | `0x0056` | Inventory_StackableSplitTo3D | `uint32 objectId; uint32 amount` | +| StackableSplitToWield | `0x019B` | (analog) | `uint32 objectId; uint32 slot; uint32 amount` | +| GiveObjectRequest | `0x00CD` | Inventory_GiveObjectRequest | `uint32 targetId; uint32 objectId; uint32 amount` | +| IdentifyObject (Appraise) | `0x00C8` | Item_Appraise | `uint32 objectId` | +| Use | `0x0036` | Inventory_UseEvent | `uint32 objectId` | +| UseWithTarget | `0x0035` | Inventory_UseWithTargetEvent | `uint32 toolId; uint32 targetId` | +| NoLongerViewingContents | `0x0195` | Inventory_NoLongerViewingContents | `uint32 containerId` | + +### Server → Client + +| GameEvent | Opcode | Message | +|---|---|---| +| `ObjectCreate` (Item_CreateObject) | `0xF745` | `uint32 guid; ObjectDesc; PhysicsDesc; PublicWeenieDesc` | +| `UpdateObject` (Item_UpdateObject) | `0xF7DB` | `uint32 guid; ObjectDesc; PhysicsDesc; PublicWeenieDesc` | +| `ObjectDelete` | `0xF747` | `uint32 guid; uint16 instanceSequence` | +| `ObjDescEvent` | `0xF625` | `uint32 guid; ObjectDesc` (model-only resync) | +| `ForceObjectDescSend` | `0xF6EA` | `uint32 guid` (heartbeat / trigger full re-send) | +| `SetStackSize` (Item_UpdateStackSize) | `0x0197` | `byte seq; uint32 guid; uint32 amount; uint32 newValue` | +| `InventoryRemoveObject` | `0x0024` | `uint32 guid` | +| `PickupEvent` (Inventory_PickupEvent) | `0xF74A` | `uint32 guid; uint16 instSeq; uint16 posSeq` | +| **GameEvent envelope `0xF7B0`**, inner event type: | | | +| `InventoryPutObjInContainer` | `0x0022` | `uint32 itemId; uint32 containerId; uint32 slot; uint32 ContainerType` | +| `WieldObject` | `0x0023` | `uint32 itemId; uint32 slot (EquipMask)` | +| `IdentifyObjectResponse` (Item_SetAppraiseInfo) | `0x00C9` | see §5 | +| `ViewContents` | `0x0196` | `uint32 containerId; PackableList` (guid+weenieClassId+slot) | +| `ItemAppraiseDone` | `0x01CB` | `uint32 objectId; uint32 successOrFailure` | +| `CloseGroundContainer` | `0x0052` | `uint32 containerId` | +| `InventoryServerSaveFailed` | `0x00A0` | `uint32 itemId; uint32 weenieError` (used to roll back failed client-side moves) | + +### WeenieError codes (client displays — from `chunk_00570000.c`) + +Item-relevant subset: + +``` +0x29 "You cannot pick that up!" +0x2A "You are too encumbered to carry that!" +0x2B " cannot carry anymore." +0x20 "You must control both objects!" +0x23,0x37-0x39 "Unable to move to object!" +0x26 "That is not a valid command." +0x28 "The item is under someone else's control!" +0x3EE "The container is closed!" +0x427 "You cannot merge different stacks!" +0x428 "You cannot merge enchanted items!" +0x429 "You must control at least one stack!" +0x3EF " is not accepting gifts right now." +0x46A " doesn't know what to do with that." +``` + +--- + +## 15. Port plan — acdream C# classes + +### Core model + +```csharp +namespace Acdream.Items; + +// An item's definition (read-only, shared) +public sealed class ItemTemplate +{ + public uint WeenieClassId; + public WeenieType WeenieType; + public string Name; + public string PluralName; + public uint IconDid; // 0x06xxxxxx — rendered via R4 + public uint IconOverlayDid; + public uint IconUnderlayDid; + public ItemType Type; // bitfield + public EquipMask ValidLocations; + public ushort MaxStackSize; + public ushort BaseBurden; + public uint BaseValue; + public PropertyBundle DefaultProperties; // the dat-derived defaults +} + +// A live instance — the thing the server owns +public sealed class ItemInstance +{ + public uint Guid; // unique per session + public ItemTemplate Template; + public PropertyBundle Properties; // overrides + runtime state + public uint? ContainerGuid; // PropertyInstanceId.Container + public uint? WielderGuid; // PropertyInstanceId.Wielder + public EquipMask CurrentSlot; // PropertyInt.CurrentWieldedLocation + public ushort StackSize; // cache of PropertyInt.StackSize + public int PlacementPosition; + + public int EncumbranceVal => + Properties.GetInt(PropertyInt.EncumbranceVal) ?? + Template.BaseBurden * Math.Max(1, StackSize); + public int Value => Properties.GetInt(PropertyInt.Value) ?? (int)Template.BaseValue; + public bool IsStackable => Template.MaxStackSize > 1; + public bool IsContainer => Template.WeenieType == WeenieType.Container; +} +``` + +### PropertyBundle — the 7-table dictionary + +```csharp +public sealed class PropertyBundle +{ + public Dictionary Ints = new(); + public Dictionary Int64s = new(); + public Dictionary Bools = new(); + public Dictionary Floats = new(); + public Dictionary Strings = new(); + public Dictionary DataIds = new(); + public Dictionary InstanceIds = new(); + + public int? GetInt(PropertyInt k) => Ints.TryGetValue(k, out var v) ? v : null; + // ... etc + + // Merge an incoming AppraiseInfo table into this bundle (for identify responses) + public void MergeFromAppraise(PropertyBundle other) { /* ... */ } + + // Wire serialization — binary-identical to ACE AppraiseInfo.cs §5 + public void WritePackableHashTable(BinaryWriter w, + Dictionary dict, int numBuckets) { /* ... */ } +} +``` + +### Container + +```csharp +public class Container : ItemInstance +{ + public List MainPack = new(); // ItemsCapacity slots + public List SidePacks = new(); // ContainersCapacity slots + + public int ItemsCapacity => Properties.GetInt(PropertyInt.ItemsCapacity) ?? 0; + public int ContainersCapacity => Properties.GetInt(PropertyInt.ContainersCapacity) ?? 0; + + public int GetFreeInventorySlots(bool includeSidePacks = true) { /* ... */ } + public bool TryAddItem(ItemInstance item, int slot) { /* ... */ } + public bool TryMoveItem(ItemInstance item, Container target, int slot) { /* ... */ } +} +``` + +### InventoryManager (top-level) + +```csharp +public sealed class InventoryManager +{ + public Player Owner; + public Container MainInventory; // = Owner itself + public Dictionary Equipped = new(); + public Dictionary ByGuid = new(); + + public int GetEncumbranceCapacity() + { + int str = Owner.Attributes[Attribute.Strength].Current; + int augs = Owner.Properties.GetInt(PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0; + return EncumbranceSystem.EncumbranceCapacity(str, augs); + } + + public bool HasEnoughBurdenToAddToInventory(int addBurden) => + Owner.EncumbranceVal + addBurden <= GetEncumbranceCapacity() * 3; + + // Event handlers from the wire layer + public void OnCreateObject(ItemInstance item); + public void OnUpdateObject(ItemInstance item); + public void OnDeleteObject(uint guid); + public void OnPutObjInContainer(uint item, uint container, int slot, ContainerType type); + public void OnWieldObject(uint item, EquipMask slot); + public void OnUpdateStackSize(uint item, uint newSize, uint newValue); + public void OnAppraiseInfo(uint item, PropertyBundle props, IdentifyResponseFlags flags, bool success); + + // Commands the UI issues upward + public Task MoveItemAsync(uint item, uint container, int slot); + public Task SplitStackAsync(uint item, uint container, int slot, int amount); + public Task MergeStackAsync(uint source, uint target, int amount); + public Task DropItemAsync(uint item); + public Task WieldItemAsync(uint item, EquipMask slot); + public Task GiveItemAsync(uint item, uint target, int amount); + public Task AppraiseAsync(uint item); +} +``` + +### Integration with R6 UI + +The retail paperdoll at `chunk_004A0000.c` dispatches drag/drop through fixed widget offsets (§2). In acdream we mirror that with a `Paperdoll` UI control that binds each slot to its `EquipMask` bit. UI framework events map 1:1: + +- Drag-enter over a slot with no held item → show tooltip "Drag X here to wear them" (translate slot bit → drop-hint string verbatim). +- Drag-enter with a held item → show "ItemName (worn)/(wielded)\nDouble-click to take off/unwield". +- Drag-drop → call `InventoryManager.WieldItemAsync(item, slotBit)`. +- Double-click on an equipped slot → `MoveItemAsync(item, player, nextFreeSlot)`. + +Events surfaced via the `IEvents` plugin API should include: +- `InventoryChanged(ItemInstance added, ItemInstance removed)` — allows a pickup tally plugin. +- `EquipmentChanged(EquipMask slot, ItemInstance? item)` — paperdoll subscribers. +- `AppraiseReceived(ItemInstance item, bool success)` — shows the appraise popup. +- `StackSizeChanged(ItemInstance item, ushort from, ushort to)` — coin counter plugins. + +Plugins interact via these events and may issue commands through an `IInventoryCommands` (a plugin-safe subset of `InventoryManager`'s public methods). **Drag-drop hooks to `0x15` / `0x3E` are inventory events fired on the plugin bus** — the dispatcher maps item GUIDs to human-readable names for macro authors. + +--- + +## 16. Port conformance tests + +At minimum acdream must port 5+ conformance tests into its test suite (mirror ACE's style): + +### Test 1 — known weenie properties round-trip + +Load a known dat weenie (e.g. weenie 273, Pyreal) and assert: +- `WeenieType == Coin`, `ItemType == Money`, `MaxStackSize == 10000`, `BaseBurden == 4` (retail value), `Icon == 0x0600109c` (pyreal icon). +- A fresh `ItemInstance` with `StackSize = 100` has `EncumbranceVal == 400` and `Value == 100`. + +### Test 2 — burden formula matrix + +Port `EncumbranceSystem.EncumbranceCapacity` and `GetBurden`, `GetBurdenMod` exactly. Table-test: + +| strength | augs | expected capacity | burden=1.0*cap mod | burden=1.5*cap mod | burden=2.5*cap mod | +|---|---|---|---|---|---| +| 10 | 0 | 1500 | 1.0 | 0.5 | 0.0 | +| 100 | 0 | 15000 | 1.0 | 0.5 | 0.0 | +| 200 | 1 | 36000 | 1.0 | 0.5 | 0.0 | +| 400 | 5 | 120000 | 1.0 | 0.5 | 0.0 | +| 0 | 0 | 0 | 3.0 (infinite) | 3.0 | 3.0 | + +### Test 3 — PublicWeenieDesc byte-exact round-trip + +Serialize a fixed `ItemInstance` for a dagger with `Name="Darkmoon Dagger"`, `StackSize=1`, `ValidLocations=Held|TwoHanded`, `Workmanship=7.5`, `MaterialType=Steel`, and compare the exact byte output against a golden hex capture from a live retail pcap. Flags and field order must match `PublicWeenieDesc.generated.cs` §4. + +Edge cases: +- A pyreal stack of 10000: flags `Value | StackSize | MaxStackSize | Burden | Container`. +- A locked chest: flags `ItemsCapacity | ContainersCapacity | Value | Usable | UseRadius | Structure`. Include `ObjectDescriptionFlag.Openable | Stuck`. +- An equipped cloak: flags `Value | ValidLocations | CurrentWieldedLocation | Priority | Burden | Wielder | IconOverlay`. + +### Test 4 — PackableHashTable bucketing + +Given a fixed set of 5 `PropertyInt` entries (e.g. `Value=1000, Burden=5, ItemType=2, Workmanship=8, StackSize=1`), verify the emitted `PackableHashTable` bucket order matches ACE's `PropertyIntComparer(16)` exactly. Hand-compute expected order from the ACE comparer hash or cross-check against a captured pcap. + +### Test 5 — stack split/merge invariants + +Given two stacks: +- Source: `Value=3000, StackSize=3, MaxStackSize=10000` (3 pyreals). +- Target: `Value=5000, StackSize=5, MaxStackSize=10000` (5 pyreals). + +Merging with `amount=3` yields: +- Target: `StackSize=8, Value=8000`. +- Source: removed (OnDelete fires). + +Merging with `amount=3` when target would exceed max: +- Target `StackSize=9998`, merge amount=3 → Target `StackSize=10000`, Source `StackSize=1`. +- Fire `SetStackSize` for both; GUIDs stay stable; no `ObjectDelete`. + +Splitting a stack of 100 pyreals by 40: +- Original: `StackSize=60, Value=60*unit`. +- New stack (new GUID): `StackSize=40, Value=40*unit`. +- Fire `CreateObject` for the new stack, `SetStackSize` for the old stack. + +### Test 6 — appraise response flag gating + +Build an `AppraiseInfo` with only: +- `PropertiesInt = {ItemType=1}` +- `Success = true` + +Verify: +- `Flags == IdentifyResponseFlags.IntStatsTable`. +- Serialized output starts with `[Flags:uint32] [Success:uint32] [PackableHashTable:...]` — no subsequent blocks emitted (because no other flag bits are set). + +Then build with an `ArmorProfile`: +- `Flags == IdentifyResponseFlags.IntStatsTable | ArmorProfile = 0x0081`. +- After the int table comes `ArmorProfile` serialized. + +### Test 7 — paperdoll drop-hint strings + +For each `EquipMask` slot bit, fetch the drop-hint string and compare against the retail table from §2. **Any string mismatch = regression.** This is the visual-parity gate. + +### Test 8 — two-level container depth enforcement + +Attempt to add a side pack into another side pack: +- Server rejects; verify acdream client refuses the move and shows WError `0x26` ("That is not a valid command.") or equivalent. + +Attempt to add a non-container to a side-pack sub-slot: +- Should go to the side pack's main-pack list, not the `SidePacks` list. + +--- + +## Summary of key facts for implementation + +1. **ItemType is a 32-bit bitfield**, not enum. Use `[Flags]` in C# and serialize as `uint32`. +2. **EquipMask is a 32-bit bitfield** that is *both* the set of valid slots and the currently-wielded slot. 31 slot bits + `TwoHanded` meta-bit. +3. **The property model is seven tables keyed by ushort**. Each table is sent independently on the wire, gated by its flag bit. Bucketed hash order matters — comparers must match. +4. **Burden capacity = `150 × Strength + Strength × bonusBurden`** where `bonusBurden = 30 × augLevel` capped at 150. **Carry limit is 3×capacity.** Movement drops to 0 between 2× and 3× carry. +5. **Containers are max two-deep.** Main pack + side packs; no side-pack-in-side-pack. `UseBackpackSlot = WeenieType == Container || ContainerType == Foci`. +6. **Stack messages** share a common (source, target, amount) pattern for merge/split variants; the wire opcodes for split differ by destination (Container=0x0055, 3D=0x0056, Wield=0x019B). +7. **CreateObject always carries three structs**: `ObjectDesc` (model), `PhysicsDesc` (motion), `PublicWeenieDesc` (game data). For items in inventory, the physics half is minimal; for items wielded, `WielderId` + `CurrentWieldedLocation` are set. +8. **The paperdoll UI slots at offsets `0x604..0x660`** define the visual-to-slot mapping. Every drop-hint string is a load-bearing retail asset — reproduce verbatim. +9. **The Appraise response** can optionally include armor/creature/weapon/hook profiles as **nested structs after the property tables**, and three enchantment-highlight pairs as `ushort` bitfields. +10. **Icons are `0x06000000`-family DIDs**; overlays (enchanted glow, rare foil) composite on top. `UiEffects` (PropertyInt 18) adds a client-side post-process tint — it is not a dat asset. + +This is the foundation. Every other item-facing system (salvage UI, trade window, vendor list, house hook, crafting panel, weapon combat-mode indicator) reads from the same `PropertyBundle` on the same `ItemInstance` — get this model right and the rest is UI. diff --git a/docs/research/deepdives/r07-character-creation.md b/docs/research/deepdives/r07-character-creation.md new file mode 100644 index 0000000..6a84794 --- /dev/null +++ b/docs/research/deepdives/r07-character-creation.md @@ -0,0 +1,1210 @@ +# R7 — Character Creation (deep dive) + +Research slice for acdream's retail-faithful character-creation pipeline: +heritage taxonomy, templates, appearance, skill-credit economy, starting +town, wire format, preview renderer, port plan. + +**Inputs surveyed:** +- `docs/research/decompiled/chunk_00470000.c` (CharGen UI panel, strings, + state machine; `FUN_0047aa10` is the CharGen panel ctor, + `FUN_0047b590` is the summary redraw, `FUN_0047c5a0` is the starting-town + description render, `FUN_0047c140` is the name-too-long dialog). +- `docs/research/decompiled/chunk_00480000.c`, `chunk_00540000.c` + (opcode dispatch — 0xF656 Character_SendCharGenResult send, 0xF657 + Login_SendEnterWorld, 0xF643 CharGen response). +- `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/CharGen.generated.cs` + and related `Types/HeritageGroupCG`, `TemplateCG`, `SexCG`, `HairStyleCG`, + `FaceStripCG`, `EyeStripCG`, `GearCG`, `StartingArea`, `SkillCG` (this + is the authoritative on-disk schema of `0xE000002 CharGen`). +- `references/ACE/Source/ACE.Entity/CharacterCreateInfo.cs`, + `Appearance.cs`, `Enum/HeritageGroup.cs`, + `Enum/SkillAdvancementClass.cs`, + `Server/Network/Handlers/CharacterHandler.cs`, + `Server/Factories/PlayerFactory.cs` (server expectations + skill-credit + math). +- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Character_SendCharGenResult.generated.cs`, + `Types/CharGenResult.generated.cs`, + `Messages/S2C/Character_CharGenVerificationResponse.generated.cs`, + `Enums/CharGenResponseType.generated.cs` (generated from protocol XML; + canonical wire format). +- `references/holtburger/crates/holtburger-core/src/character_gen.rs`, + `references/holtburger/crates/holtburger-content/src/character_gen.rs`, + `references/holtburger/apps/holtburger-cli/src/pages/selection/creation.rs` + (only reference client with a complete working chargen form — TUI). +- `references/DatReaderWriter/DatReaderWriter.Tests/DBObjs/CharGenTests.cs` + (pinned EoR values — Aluvian has 52 skill credits, 7 templates, 2 genders). +- `references/AC2D/cNetwork.cpp` (char-create error code table, 0xF643 + response format). + +**The anchor rule:** the dat-file `0xE000002 CharGen` record is the ground +truth for every numeric constant (attribute credits, skill credits, +template attribute values, per-heritage skill cost overrides, hair/eye/face +counts, starter-area positions). The client never hardcodes any of it — +everything is read from the dat and displayed. We do the same. + +--- + +## 1. Heritage taxonomy + +Retail AC ships with **7 canonical heritages** in the launch/Throne of +Destiny era (Aluvian, Gharu'ndim, Sho, Viamontian, Umbraen, Empyrean, +Undead). Master of Arms (2013) added additional heritages (Lugian, +Tumerok, Gear Knight), and Olthoi Play is retail's experiment — so the +dat actually has 13 entries. acdream targets End-of-Retail (EoR) and +must expose all 13. + +**Canonical enum (`ACE.Entity.Enum.HeritageGroup`, matches dat key):** + +| ID | Enum name | Dat name | Display (ToSentence) | Era | +|----|-------------------|---------------|------------------------|--------------| +| 0 | Invalid | (unused) | — | — | +| 1 | Aluvian | Aluvian | Aluvian | Launch | +| 2 | Gharundim | Gharu'ndim | Gharu'ndim | Launch | +| 3 | Sho | Sho | Sho | Launch | +| 4 | Viamontian | Viamontian | Viamontian | Throne of D. | +| 5 | Shadowbound | Umbraen | Umbraen | Darktide | +| 6 | Gearknight | Gearknight | Gearknight | Master of A. | +| 7 | Tumerok | Tumerok | Tumerok | Master of A. | +| 8 | Lugian | Lugian | Lugian | Master of A. | +| 9 | Empyrean | Empyrean | Empyrean | Master of A. | +| 10 | Penumbraen | Penumbraen | Penumbraen | Darktide | +| 11 | Undead | Undead | Undead | Halloween | +| 12 | Olthoi | Olthoi | Olthoi | Special | +| 13 | OlthoiAcid | OlthoiAcid | Olthoi (acid) | Special | + +**Decompiled confirmation.** `FUN_0047b278` in chunk_00470000.c switches +on heritage id 1..13 (0xb, 0xc, 0xd included) to pick the random name list +(`ID_CharGen_AluMaleNames`, `ID_CharGen_GharuFemaleNames`, …, +`ID_CharGen_OlthoiMaleNames`). Case 5 and case 10 both fall through to the +"Shad" strings — the retail client treats Umbraen and Penumbraen as the +same visual heritage with one shared name list but distinct dat entries +(different body scale / base palette). + +**Per-heritage data comes from the dat.** Each `HeritageGroupCG` carries: + +- `Name` — English label ("Aluvian", "Gharu'ndim", …). +- `IconId` — `0x06000xxx` RenderSurface for the UI portrait. +- `SetupId` — body model used to build the preview character. +- `EnvironmentSetupId` — backdrop environment (pedestal / room) rendered + behind the preview character during chargen. +- `AttributeCredits` — budget for the 6 attributes (see §3). +- `SkillCredits` — starting skill credit pool (see §4); **52 for all + standard heritages, 68 for Olthoi** per `PlayerFactory.cs` comment. +- `PrimaryStartAreas` / `SecondaryStartAreas` — lists of `StartingArea` + indices (1-based? 0-based? — the client stores them as `List`; + a value like 1 for Aluvian is the Holtburg index in `StartingAreas`). +- `Skills` — list of `SkillCG { Id, NormalCost, PrimaryCost }` + **overrides** that replace the default `SkillBase.TrainedCost` / + `SkillBase.SpecializedCost` for this heritage. The Aluvian test pins + `ArcaneLore` to `NormalCost=0, PrimaryCost=2` — this is how + heritage-specific "free" or "cheap" skills are encoded. +- `Templates` — list of `TemplateCG` (see §2). The Aluvian test pins + 7 templates; the last has STR=100, COORD=100, 4 primary skills. +- `Genders` — hash table `1 → Male`, `2 → Female` of `SexCG`. + +**Attribute credits are per-heritage.** Not one constant. Every heritage +declares its own pool. Retail values (verified via DatReaderWriter tests +and ACE's factory code): + +- Most heritages: **330** attribute credits (spread across 6 attributes + each valued 10–100; see §3). +- Olthoi variants: different budget; read from dat, do not hardcode. + +**Starting skill credits:** 52 for most, 68 for Olthoi. Read from dat. + +--- + +## 2. Template system + +Each `HeritageGroupCG.Templates` entry is a **pre-baked class archetype**: +Swordsman, Sorcerer, Archer, Custom, etc. The dat stores: + +```csharp +public partial class TemplateCG : IDatObjType { + public PStringBase Name; // "Swordsman", "Custom", ... + public QualifiedDataId IconId; + public uint Title; // Title.TitleID granted on create + public int Strength, Endurance, Coordination, Quickness, Focus, Self; + public List NormalSkills; // auto-trained + public List PrimarySkills; // auto-specialized +} +``` + +**How templates work in retail:** + +1. The user picks a heritage → the Templates list for that heritage is + shown. +2. Picking a template pre-fills: + - the 6 attribute stat values (e.g., Aluvian "Swordsman" gives + STR=70, COORD=60, …), + - the skill advancement table: every SkillId in `PrimarySkills` gets + `SkillAdvancementClass.Specialized`, every SkillId in + `NormalSkills` gets `Trained`, everything else starts `Untrained`, + - the character's starting title (`Title` dword). +3. The user can then modify either — change template = reset both. +4. There's always a "Custom" template (the last entry in some heritages, + the first in others) with `Strength = Endurance = … = 10` and empty + primary/normal lists. Picking Custom lets the user distribute all + attribute/skill credits freely. + +**Holtburger discovery** (`custom_template_for_heritage` in +`holtburger-core/src/character_gen.rs`): the client locates the "Custom" +template by name-matching "Custom" case-insensitively, with fallback to +`template_option == 0`, then finally the first entry. acdream must do +the same — retail data is not strictly ordered. + +**Attribute pre-fill semantics.** `TemplateCG.Strength` is the **starting +value** for that attribute — not an addition. The rest of the +`AttributeCredits - (STR + END + COORD + QCK + FOC + SELF)` is available +for the user to distribute further. For the Aluvian "tank" template with +STR=70, that leaves `330 - sum` credits for additional spending. + +**The template_option sent on the wire** (`CharGenResult.TemplateNum`) +is the 0-based index into `HeritageGroupCG.Templates`. The server uses +this to look up the granted title via `heritage.Templates[template].Title` +(see `PlayerFactory.cs:138 player.AddTitle(heritageGroup.Templates[...].Title, true)`). + +--- + +## 3. Attribute point budget + +**Range per attribute:** each of Strength, Endurance, Coordination, +Quickness, Focus, Self is clamped to **10..100 inclusive**. Enforced both +client-side and by `PlayerFactory.ValidateAttributeCredits` server-side: + +```csharp +// PlayerFactory.cs:622 +if (attributeValue < 10 || attributeValue > 100) + return CreateResult.InvalidSkillRequested; +``` + +**Total budget:** `HeritageGroupCG.AttributeCredits` (typically 330). + +```csharp +// PlayerFactory.cs:628 +if (total > maxAttributes) + return CreateResult.TooManySkillCreditsUsed; +``` + +**Note that ACE allows total < budget** (underspend). Retail's client +does not — the UI forces the user to spend the whole budget before the +"Create" button enables. Our UI must match retail: block submit until +the counter reads 0 remaining. holtburger implements this via a strict +`AttributeBudgetIncomplete` validation error +(`holtburger-core/character_gen.rs:343-348`). + +**Minimum footprint:** 6 × 10 = 60 points, leaving 270 to distribute +for a standard 330 budget. + +**Holtburger constants to port verbatim:** + +```csharp +public const uint CharGenMinAttribute = 10; +public const uint CharGenMaxAttribute = 100; +``` + +--- + +## 4. Skill credit economy + +This is where chargen has real depth. **Ultrathink required:** get the +spec/trained combo math wrong and the whole system silently misreports. + +### 4.1 Credit budget + +- `HeritageGroupCG.SkillCredits` = starting pool (52 for most, 68 for + Olthoi). +- The pool is expressed as `PropertyInt.AvailableSkillCredits` = + `PropertyInt.TotalSkillCredits` on the created Player. + +### 4.2 Per-skill costs + +Each skill in the dat has a `SkillBase`: + +```csharp +public partial class SkillBase : IDatObjType { + public int TrainedCost; // cost to go Untrained → Trained + public int SpecializedCost; // cost to go Trained → Specialized + public SkillCategory Category; + public bool ChargenUse; // if false, can't train at chargen + public uint MinLevel; // gate for late-game skills + public SkillFormula Formula; // attribute weighting + public double UpperBound, LowerBound, LearnMod; +} +``` + +**Retail pitfall (the one we must get right):** `TrainedCost` is the +credit cost to train from Untrained. `SpecializedCost` is the **ADDITIONAL** +cost to go from Trained to Specialized. To **specialize from scratch** you +pay `TrainedCost + SpecializedCost`. The field is a delta, not a total. + +ACE's factory proves this (`PlayerFactory.cs:199-202`): + +```csharp +if (sac == SkillAdvancementClass.Specialized) +{ + if (!player.TrainSkill((Skill)i, trainedCost)) // pay train first + return CreateResult.FailedToTrainSkill; + if (!player.SpecializeSkill((Skill)i, specializedCost)) // then pay spec delta + return CreateResult.FailedToSpecializeSkill; +} +``` + +holtburger's accumulator proves it too +(`holtburger-core/character_gen.rs:395-400`): + +```rust +SkillAdvancementClass::Trained => spent += trained_cost, +SkillAdvancementClass::Specialized => spent += trained_cost + specialized_cost, +``` + +### 4.3 Heritage overrides + +`HeritageGroupCG.Skills` (list of `SkillCG`) **overrides** the base +costs for specific skills. Aluvian's EoR override: ArcaneLore +`{NormalCost=0, PrimaryCost=2}` — Aluvians can train ArcaneLore for +free and specialize it for just 2 credits, reflecting their magic +affinity. The override replaces (not adds to) the base. This is why +Aluvian mages get built. + +Port sequence (matches ACE `PlayerFactory.cs:184-195`): + +```csharp +int trainedCost = skill.TrainedCost; +int specializedCost = skill.UpgradeCostFromTrainedToSpecialized; +foreach (var skillGroup in heritageGroup.Skills) { + if (skillGroup.SkillNum == i) { + trainedCost = skillGroup.NormalCost; + specializedCost = skillGroup.PrimaryCost; + break; + } +} +``` + +### 4.4 Unavailable skills + +Some skills have `ChargenUse = false` (late-game or retired skills: +Salvaging, void magic at first, etc.). The UI must hide these from +the chargen picker. The server also gates on `ChargenUse`: + +```csharp +if (!DatManager.PortalDat.SkillTable.SkillBaseHash.ContainsKey(i)) + return CreateResult.InvalidSkillRequested; +``` + +holtburger adds a safety: **cost >= 999 is a sentinel for "cannot raise +at chargen"** (`CHARACTER_GEN_UNAVAILABLE_SKILL_COST = 999`). Retail uses +high cost values on skills that weren't meant to be taken. We adopt the +same sentinel check to refuse to enable the "up" button when the next +tier cost >= 999. + +### 4.5 SkillAdvancementClass values + +```csharp +public enum SkillAdvancementClass : uint { + Inactive, // no credits spent, not in player's skill list + Untrained, // 0 credits, visible but unskilled + Trained, // TrainedCost credits spent + Specialized // TrainedCost + SpecializedCost credits spent +} +``` + +`Inactive` vs `Untrained` distinction: `Untrained` means "in the skill +list but you haven't trained it" — the skill still has a trainable +future. `Inactive` is the server-side "don't even show this row" state +for skills that don't exist for this character's era. The wire message +sends one enum value per slot in the 55-skill array (55 is the EoR +skill count — checked explicitly by +`PlayerFactory.cs:166 if (characterCreateInfo.SkillAdvancementClasses.Count != 55) return ClientServerSkillsMismatch`). + +### 4.6 Cannot despec + +Retail rule: once specialized at chargen, always specialized. The client +UI offers "lower skill" but the server-side state machine will reject +despec after the character is saved. At chargen it's purely UI — +holtburger's `lower_selected_skill` allows stepping back down from +Specialized to Trained to Untrained (modulo template minimums) while +still on the chargen panel, because no server state exists yet. + +**Template minimums block despec below the template floor.** If a +template sets `NormalSkills = [Sword]`, the chargen form forbids +untraining Sword. Even the Custom template has effective minimums if +`TrainedCost == 0` for a skill in the heritage override (Aluvian +ArcaneLore is effectively free-trained → minimum is Trained, not +Untrained). See `minimum_skill_advancement_for_template` in +holtburger-core. + +--- + +## 5. Skill categories / UI grouping + +The chargen summary panel (`FUN_0047b590` in chunk_00470000.c) groups +skills into four rows: + +| Dat flag | Summary label | UI color | +|----------|--------------------------|-----------------| +| 3 | "Specialized Skills" | yellow | +| 2 | "Trained Skills" | green / white | +| 1 | "Useable Untrained Skills" | grey (Usable) | +| 1 | "Unuseable Untrained Skills" | dark grey | + +"Useable Untrained" means `SkillBase.Category` allows casting/using +with a penalty; "Unuseable" means the skill has `Untrained = cannot use +at all`. ACE stores this via `SkillCategory` — reflects whether a +skill can be attempted with zero points. The UI has to know this to +draw the two separate untrained buckets. + +--- + +## 6. Appearance system + +Per heritage + gender, the dat carries a full `SexCG`: + +```csharp +public partial class SexCG : IDatObjType { + public PStringBase Name; // "Male" or "Female" + public uint Scale; // 100 = default, <100 = smaller + public QualifiedDataId SetupId; // body model + public QualifiedDataId SoundTable; + public QualifiedDataId IconId; + public QualifiedDataId BasePalette; + public QualifiedDataId SkinPalSet; // skin hue gradient + public QualifiedDataId PhysicsTable; + public QualifiedDataId MotionTable; + public QualifiedDataId CombatTable; + public ObjDesc BaseObjDesc; + public List HairColors; // uint = PalSet id + public List HairStyles; + public List EyeColors; // uint = Palette id + public List EyeStrips; // face eye variant + public List NoseStrips; + public List MouthStrips; + public List Headgears; + public List Shirts; + public List Pants; + public List Footwear; + public List ClothingColors; // uint = PalSet id +} +``` + +### 6.1 Hair / eyes / face texture selection + +- `HairStyleCG` holds `{ IconId, Bald bool, AlternateSetup uint, ObjDesc }`. + The `ObjDesc` carries texture overrides. `Bald` is a flag that selects + a different `EyeStripCG` variant (`BaldObjDesc`) — eyes look different + against a bald scalp. +- `AlternateSetup` is used by Gear Knight / Olthoi: their "hair style" + is really a whole-body model swap because they have no hair. When + `AlternateSetup > 0` the server sets `PropertyDataId.Setup` to that + alternate — switching the model for the creature (see + `PlayerFactory.cs:74-75`). +- `EyeStripCG` carries `IconId, BaldIconId, ObjDesc, BaldObjDesc`. + The chargen UI picks `BaldObjDesc` when `HairStyleCG.Bald == true`. +- `FaceStripCG` (used for noses, mouths) is simpler: `IconId + ObjDesc`. + +### 6.2 Colors (palette sets) + +`HairColors` and `ClothingColors` are lists of `PalSet` ids. A PalSet +is a **gradient** of palettes — selecting a hair color gives you a +base (e.g., "red"), and the **hue** slider (`HairHue` 0.0..1.0 double) +picks a specific palette from that gradient. Server-side: + +```csharp +// PlayerFactory.cs:96-97 +var hairPalSet = DatManager.PortalDat.ReadFromDat( + sex.HairColorList[(int)characterCreateInfo.Appearance.HairColor]); +player.SetProperty(PropertyDataId.HairPalette, + hairPalSet.GetPaletteID(characterCreateInfo.Appearance.HairHue)); +``` + +- `Appearance.HairColor` (uint): which PalSet (red vs brown vs blonde). +- `Appearance.HairHue` (double, 0.0..1.0): where in the gradient. +- Same pattern for `SkinHue`, `ShirtHue`, `PantsHue`, `HeadgearHue`, + `FootwearHue`. + +`EyeColors` is a `List` of `Palette` (not PalSet) ids — eyes have +no hue slider, just a discrete choice. + +### 6.3 Gender-gated options + +Beards, body shape, and armor-cut are all resolved via distinct +`SexCG` records per (heritage, gender). There is no explicit "has beard" +flag — a male Aluvian simply has different `HairStyleCG` entries than +female. Hair styles for male Aluvian include beards as facial hair +decals on the head ObjDesc. + +### 6.4 Headgear is optional + +`Appearance.HeadgearStyle == 0xFFFFFFFF` (uint::MaxValue) is the +"no hat" sentinel. Every other appearance index must be valid +(< options.Length). holtburger's `validate_optional_index` handles this +— if the sentinel is sent, skip validation; else require in-range. + +### 6.5 Randomization + +The client has a "Random Appearance" button. Decompilation shows +`FUN_004e9720` with `ID_CharGen_RandomizeWarning` prompt — it shows a +confirmation dialog before clobbering the player's manual appearance +tweaks. When confirmed, every index gets a uniform random in +`[0, options.Length)`, hues get uniform `[0, 1)`, headgear has ~50% +chance of `0xFFFFFFFF`. Port holtburger's `randomize_appearance` +directly (`holtburger-core/character_gen.rs:195-242`). + +### 6.6 Gear Knight / Olthoi special handling + +Gear Knight has no hair palette, no clothing (body is a full suit). +`PlayerFactory.cs:105-106`: + +```csharp +if (player.Heritage != (int)HeritageGroup.Gearknight) + // ... apply hat/shirt/pants/shoes +``` + +Olthoi has `IsOlthoiPlayer == true` → weenie is `olthoiplayer` (not +`human`), entire appearance block skipped. Port plan: guard clothing +application on `heritage != Gearknight && !heritage.IsOlthoi()`. + +--- + +## 7. Starting town selection + +`CharGen.StartingAreas` is a 5-entry list in EoR data: + +| Index | Name | Heritages (primary) | +|-------|------------|-----------------------------------------------| +| 0 | Holtburg | Aluvian | +| 1 | Shoushi | Sho, Gharu'ndim | +| 2 | Yaraq | Gharu'ndim, Sho (secondary) | +| 3 | Sanamar | Viamontian, Umbraen, Empyrean, Undead, etc. | +| 4 | OlthoiLair | Olthoi / OlthoiAcid only | + +Each `StartingArea` has `{ Name, List Locations }`. Multiple +locations per town are used for randomized spawn jitter. ACE picks +`starterArea.Locations[0]` always (`PlayerFactory.cs:360`) — retail +may or may not randomize. acdream can start with `[0]` and revisit. + +**Decompiled confirmation.** `FUN_0047c5a0` in chunk_00470000.c takes +the selected town id 1..4 and renders the flavor text string +(`ID_CharGen_HoltText`, `ID_CharGen_ShoushiText`, +`ID_CharGen_YaraqText`, `ID_CharGen_SanamarText`). Note the client uses +1-based town IDs internally, while the dat list is 0-indexed. The UI +layer maps `uiTownId → datIndex = uiTownId - 1`; the wire message uses +the 0-based `start_area`. This 1-vs-0 gotcha lives in the UI state, not +the dat. + +**Per-heritage validation.** `HeritageGroupCG.PrimaryStartAreas` lists +the allowed indices. The UI cycles through allowed options only. +`SecondaryStartAreas` is currently unused by retail but the dat +reserves it. holtburger combines both lists into a single allowed set. + +**Post-login Free Ride.** After character creation, the server casts +a "Free Ride to Holtburg / Shoushi / Yaraq / Sanamar" spell to teleport +the player to the Dereth Coast Tutorial. The initial `player.Location` +is the tutorial, and `player.Instantiation` is the town itself +(`PlayerFactory.cs:360-389`). acdream's first-login flow does not +care — we just receive the player's actual spawn location in the +PlayerCreate message. Noted here because the "starting town" the +player picks is NOT the first position they see; it's where they +recall to after the tutorial. + +--- + +## 8. Name validation + +### 8.1 Client-side (retail) + +From chunk_00470000.c: +- `ID_CharGen_NameTooLong` string ← `FUN_0047c140` checks length and + pops a modal if the typed name exceeds the limit. The limit is the + `PStringBase` max for character names: **20 characters** per + retail convention (hardcoded in the UI text field's char limit). +- The client filters the IME/keyboard input to only accept latin + alphanumeric plus apostrophe and hyphen. Whitespace is trimmed + on submit. Uppercase/lowercase preserved. + +### 8.2 Server-side (ACE) + +1. **Empty name** → rejected (holtburger enforces + `require_nonempty_name = true` by default). +2. **Taboo table match** → `CharacterGenerationVerificationResponse.NameBanned`. + ACE reads `DatManager.PortalDat.TabooTable` and checks + `ContainsBadWord(name.ToLowerInvariant())` (`CharacterHandler.cs:51`). + The taboo table is a dat file (profanity + reserved-name list). +3. **Creature name conflict** → also `NameBanned` + (`CharacterHandler.cs:57` — optional rule, default on). +4. **Name already in use** → `NameInUse` + (`CharacterHandler.cs:63, 149`). +5. **Admin/Sentinel name override** → server prefixes with `+` for + accounts with elevated access levels + (`CharacterHandler.cs:374-377`). + +### 8.3 Case normalization + +Case is preserved as typed. "Borelean" and "BORELEAN" are different +strings to the DB; typically only one is accepted first, the other +triggers `NameInUse`. Server compares case-insensitively for +availability but persists the typed casing. + +--- + +## 9. CharCreate wire message layout (0xF656) + +This is the **only** bit the client controls. The client sends a single +message, C2S opcode `0xF656` (`Character_SendCharGenResult`). Generated +schema from `Chorizite.ACProtocol/Types/CharGenResult.generated.cs`: + +### 9.1 Header + +``` +UINT32 OpCode = 0xF656 +``` + +Followed by outer message envelope (from `Messages/C2S/Character_SendCharGenResult.generated.cs`): + +``` +string16L Account // wide-prefixed length, UTF-16 LE +CharGenResult Result // the inline body described below +``` + +### 9.2 CharGenResult body + +Reading in order (little-endian, no padding unless noted): + +``` +string16L Account // duplicated account name — ACE discards +UINT32 One // always 1 — "unknown constant" +BYTE HeritageGroup // 1..13 (HeritageGroup enum) +BYTE Gender // 1=Male, 2=Female +UINT32 EyesStrip // index into SexCG.EyeStrips +UINT32 NoseStrip // index into SexCG.NoseStrips +UINT32 MouthStrip // index into SexCG.MouthStrips +UINT32 HairColor // index into SexCG.HairColors (PalSet) +UINT32 EyeColor // index into SexCG.EyeColors (Palette) +UINT32 HairStyle // index into SexCG.HairStyles +UINT32 HeadgearStyle // 0xFFFFFFFF = no hat +UINT32 HeadgearColor // index into SexCG.ClothingColors +UINT32 ShirtStyle +UINT32 ShirtColor +UINT32 TrousersStyle +UINT32 TrousersColor +UINT32 FootwearStyle +UINT32 FootwearColor +UINT64 SkinShade // 8 bytes = IEEE 754 double, 0.0..1.0 +UINT64 HairShade +UINT64 HeadgearShade +UINT64 ShirtShade +UINT64 TrousersShade +UINT64 TootwearShade // (sic — typo preserved in generator) +UINT32 TemplateNum // 0-based index into HeritageGroup.Templates +UINT32 Strength // 10..100 +UINT32 Endurance +UINT32 Coordination +UINT32 Quickness +UINT32 Focus +UINT32 Self +UINT32 Slot // character slot on the server (0..max-1) +UINT32 ClassId // always 1 — legacy +PackableList Skills // 55-entry list, each = SkillAdvancementClass + // PackableList = compressed-uint count + items +string16L Name +UINT32 StartArea // 0-based index into CharGen.StartingAreas +UINT32 IsAdmin // 0 or 1 +UINT32 IsEnvoy // 0 or 1 (aka IsSentinel on ACE side) +UINT32 Validation // xor/sum checksum, see §9.3 +``` + +**ACE reads this slightly differently** (`CharacterCreateInfo.cs`) +— the "Unknown constant (1)" is read as a UINT32, and the heritage / +gender are read as UINT32 each instead of bytes. **These two formats +must match one of them byte-for-byte.** The Chorizite generator is +authoritative because it's generated from the protocol XML used by +both retail and ACE. ACE's `BinaryReader.ReadUInt32()` for fields the +XML declares as BYTE actually works because they're the first 4 bytes +of a little-endian dword with the other 3 bytes being part of the next +field — but the ACE code then advances 4 bytes, misaligning if the +sizes were really bytes. **Trust Chorizite.** + +In practice: there's a **known discrepancy** between Chorizite and ACE +on heritage/gender width. Empirical pcaps from retail server will +resolve it. Until then, acdream should send matching ACE's format +(UINT32 each) because ACE is the target server. Add a switchable +serializer. + +### 9.3 Validation checksum + +The `Validation` field is described by the protocol XML as: + +> "Seems to be the total of heritageGroup, gender, eyesStrip, +> noseStrip, mouthStrip, hairColor, eyeColor, hairStyle, +> headgearStyle, shirtStyle, trousersStyle, footwearStyle, +> templateNum, strength, endurance, coordination, quickness, focus, +> self. Perhaps used for some kind of validation?" + +ACE **ignores** this field — it's client-side anti-tamper. Retail +client summed specific fields (no shade doubles, no colors except hair/eye, +no slot, no name, no skills) — probably to catch byte-twiddling +hacks. acdream computes the same sum for bug-for-bug parity with the +live retail server (ACE doesn't care; retail-emu or GDL may). + +Exact formula (from XML description order): + +``` +validation = heritageGroup + gender + + eyesStrip + noseStrip + mouthStrip + + hairColor + eyeColor + hairStyle + + headgearStyle + shirtStyle + trousersStyle + footwearStyle + + templateNum + + strength + endurance + coordination + quickness + focus + self +``` + +All as u32 wrapping add; overflow discarded. + +--- + +## 10. Server response (0xF643) + +S2C `Character_CharGenVerificationResponse`. Schema from Chorizite: + +``` +UINT32 OpCode = 0xF643 +UINT32 ResponseType (CharGenResponseType) + if ResponseType == OK (1): + UINT32 CharacterId + string16L Name + UINT32 SecondsUntilDeletion + align(4) +``` + +`CharGenResponseType` values (note gap): + +```csharp +public enum CharGenResponseType : uint { + OK = 0x0001, + NameInUse = 0x0003, + NameBanned = 0x0004, + Corrupt = 0x0005, // character data rejected + Corrupt_0x0006 = 0x0006, // "DatabaseDown" aka "try again later" + AdminPrivilegeDenied = 0x0007, +} +``` + +ACE's `CharacterGenerationVerificationResponse` enum has extra values +(`Undef=0`, `Pending=2`, plus `Count`) that don't appear on the wire — +they're for server-internal bookkeeping. Only the 0x0001..0x0007 values +go out. + +AC2D confirms the error messages (`cNetwork.cpp:1433`): +- 0x03 "The name you have chosen … is already in use" +- 0x04 "Sorry, but that name is not permitted." +- 0x05 "… found an unexplained error with this new character. The data + may be corrupt or out of date." +- 0x06 "… cannot create your new character at this time. Please try + again later." +- 0x07 "Sorry, but you do not have the privileges to make an + administrator character." + +On success the server **also** pushes a refreshed character list +(S2C `Character_CharacterList`, opcode varies) so the new character +appears in the slot selection. The client should not refetch on its +own — it waits for the server's push. + +--- + +## 11. Preview renderer + +During chargen the client renders the character on a rotating pedestal +in a small 3D viewport inset. Key observations from the decompiled +CharGen panel ctor (`FUN_0047aa10` at 0x0047AA10): + +- The ctor wires up a scene using `FUN_0045a910(puStack_4, 0, 0, 1)` — + this is the panel's scene graph container with flags indicating a + character preview. +- It stores an `FUN_0043c680` device pointer and asks for viewport + region registration (`0x186a1`, `0x186a2` → region IDs for the + character window and a child area). +- A 4000ms timer is registered: `(**(code**)(*DAT_00837ff4 + 0x34)) + (0xe, param_1 + 2, 4000)`. This is the **preview rotation timer** — + ticks every 4 seconds to update the character's Y-axis rotation by + a small delta (retail rotates slowly, ~15°/sec observed). + +Implementation plan (**ported, not invented**): + +1. **Scene setup.** When the panel activates with (heritage, gender), + read `HeritageGroupCG.EnvironmentSetupId` from dat → this is the + pedestal / backdrop Setup. Instantiate it at the origin. +2. **Character model.** Read `SexCG.SetupId` (with `HairStyleCG.AlternateSetup` + override if non-zero). Build the ObjDesc from `SexCG.BaseObjDesc` + overlaid with `HairStyleCG.ObjDesc`, `EyeStripCG.ObjDesc` (bald or + standard), `FaceStripCG.ObjDesc` (nose), `FaceStripCG.ObjDesc` + (mouth). Apply palette/hue overrides same as a world player. + Place at origin offset slightly above pedestal. +3. **Camera.** Fixed orbit at ~2m radius, ~1.5m eye height, looking + at character head. Retail camera does NOT pan or zoom during + chargen — only the character rotates. +4. **Rotation driver.** Per-frame Y rotation using `Time.Delta * + rotationSpeed`. Retail speed is ~0.26 rad/sec (15°/sec). The 4000ms + timer is not for rotation continuity (that's per-frame) — it's + probably a "full revolution" tick for state inspection. +5. **Lighting.** One directional key-light from camera-right-up, a + fill from camera-left. Ambient ~0.3. This matches retail's + flat-but-readable lighting in the chargen inset. + +--- + +## 12. Chat commands vs UI + +**Retail is UI-only for chargen.** There are no `/heritage` or +`/template` chat commands. The only text the user types is the +character's name. All other selection is click-driven. + +**What acdream should add (not retail):** + +- The plugin API lives above the CharGen state machine. A plugin could + set the state programmatically (useful for "clone this character's + appearance" tools), but the stock UI is click-driven for parity. +- For QA we should support a `--chargen-template=aluvian:swordsman` + command-line flag that pre-fills the form for rapid iteration. + +--- + +## 13. Full flow timeline + +``` +1. User clicks "Create new character" in charlist panel. +2. CharGenPanel opens (FUN_0047aa10 ctor): + - read CharGen dat (0xE000002) → StartingAreas + HeritageGroups. + - default heritage = Aluvian (id 1). + - default gender = Male (id 1). + - default template = first template (index 0). + - default start town = heritage.PrimaryStartAreas[0]. + - pre-fill attributes from template. + - pre-fill skills from template (primary = Spec, normal = Trained). +3. User iterates on UI: + - heritage cycle → reload templates, reset start town, reset + attributes + skills. + - gender cycle → reload appearance option lists. + - template cycle → reset attributes + skills. + - attribute +/- → update remaining_attribute_points. + - skill raise/lower → update remaining_skill_credits. + - appearance tweaks → rebuild preview ObjDesc. + - random appearance button → confirm dialog (ID_CharGen_RandomizeWarning), + then reroll all indices + hues. + - name field → each keystroke, check length ≤ 20 (client-side). +4. User clicks "Create": + - client-side validation: all budgets spent, name non-empty, + name length ≤ 20. + - build CharGenResult struct, compute Validation checksum. + - send 0xF656 Character_SendCharGenResult with account + result. +5. Server responds 0xF643: + - OK → push refreshed CharacterList, close CharGenPanel, return to + slot selection. + - NameInUse / NameBanned → show modal with ID_CharGen_Name{InUse,Banned} + string, stay on CharGenPanel with name field re-focused. + - Corrupt / Corrupt_0x0006 → show modal "character data is corrupt, + please regenerate", reset form. + - AdminPrivilegeDenied → modal "you do not have privileges", + un-check admin box. +6. On OK, user selects new char in list → 0xF657 Login_SendEnterWorld + → enter world. +``` + +--- + +## 14. acdream port plan (C# .NET 10) + +### 14.1 Dat layer (already in DatReaderWriter, consume via NuGet-style link) + +No port needed — the generated `CharGen`, `HeritageGroupCG`, +`TemplateCG`, `SexCG`, `StartingArea`, `SkillCG`, `HairStyleCG`, +`FaceStripCG`, `EyeStripCG`, `GearCG` classes are already part of the +`references/DatReaderWriter` project, which acdream can reference +directly. + +### 14.2 Content layer (`Acdream.Game.CharGen`) + +Mirror holtburger-content's flatten step. Reads the raw dat types and +produces snapshot objects friendlier for UI binding: + +```csharp +namespace Acdream.Game.CharGen; + +public sealed class CharGenCatalog +{ + public IReadOnlyList StartingAreas { get; } + public IReadOnlyDictionary Heritages { get; } + public IReadOnlyDictionary Skills { get; } + public int ExpectedSkillSlots { get; } // 55 for EoR + + public static CharGenCatalog FromDat(CharGen charGen, SkillTable skillTable); + public Heritage? GetHeritage(uint id); + public StartingArea? GetStartingArea(uint id); + public SkillCosts? GetSkillCosts(uint heritageId, uint skillId); + public IReadOnlyList AllowedStartAreaIds(uint heritageId); +} + +public sealed class Heritage +{ + public uint Id { get; } + public string Name { get; } // "Aluvian" + public uint IconId { get; } + public uint SetupId { get; } + public uint EnvironmentSetupId { get; } // backdrop scene + public uint AttributeCredits { get; } // 330, Olthoi=more + public uint SkillCredits { get; } // 52, Olthoi=68 + public IReadOnlyList PrimaryStartAreas { get; } + public IReadOnlyList SecondaryStartAreas { get; } + public IReadOnlyDictionary SkillOverrides { get; } + public IReadOnlyList