docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H

78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
This commit is contained in:
Erik 2026-04-18 10:32:44 +02:00
parent 7230c1590f
commit 3f913f1999
20 changed files with 15312 additions and 17 deletions

View file

@ -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 `<Tell:…>` 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.

View file

@ -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 `<Tell:...>` 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.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,922 @@
# R8 — Network Protocol Atlas
**Scope:** exhaustive map of every AC retail network opcode the client must
see, send, or at least tolerate. This document is the single reference for
every port ticket under phases C, D, E (chat), and F (inventory/combat/
spells). If an opcode is listed here as "unhandled," we already know what
it is and what it needs — the only remaining work is wiring up the parser
+ event surface.
**Inputs cross-referenced:**
- `references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs`
— canonical S2C primary-opcode enum.
- `references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs`
— canonical C2S GameAction sub-opcode enum.
- `references/ACE/Source/ACE.Server/Network/GameEvent/GameEventType.cs`
— canonical S2C GameEvent sub-opcode enum.
- `references/ACE/Source/ACE.Server/Network/Handlers/*.cs` — handler
implementations (what the server accepts, what fields it expects).
- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/protocol.xml`
the authoritative XML generated from decompiled client stubs.
- `references/holtburger/crates/holtburger-protocol/src/opcodes.rs`
curated "what a real client actually sends / listens for" list, 829
lines, with cross-validated comments.
- `docs/research/decompiled/chunk_006B0000.c` (confirmed 0xF7B0 switch).
- `src/AcDream.Core.Net/` — what acdream handles today.
**How to read the tables:**
- "Dir": `C→S` (client to server), `S→C` (server to client), or `bi` (can
travel either direction — rare; mostly DDD and TurbineChat).
- "Envelope": `GM` = standalone GameMessage (primary opcode at the top of
the body), `GA` = client-to-server GameAction inside the 0xF7B1 envelope,
`GE` = server-to-client GameEvent inside the 0xF7B0 envelope. Login-
phase opcodes use neither envelope (they ride on header flags).
- "Status": `done` = full parse/dispatch + event surface in acdream today;
`partial` = parsed but not exposed as an event, or built but not
generated by player input; `stub` = recognised opcode but body is
ignored; `unhandled` = acdream does not parse this opcode at all today.
- "Prio": rank for minimum viable play. `P0` blocks login. `P1` blocks
basic in-world feedback (chat, health, combat). `P2` blocks inventory/
spells. `P3` is housing, allegiance, fellowship. `P4` is admin,
minigames, barber.
---
## 1. Transport layer — recap
AC's wire stack is a three-layer stack on top of UDP:
```
UDP datagram
├─ 20-byte PacketHeader src/AcDream.Core.Net/Packets/PacketHeader.cs
├─ N×MessageFragment (0..many, depending on header.Flags)
│ ├─ 16-byte MessageFragmentHeader src/AcDream.Core.Net/Packets/MessageFragmentHeader.cs
│ └─ fragment payload (≤448 bytes)
└─ optional retransmit body (if Retransmission flag set)
```
### 1.1 PacketHeader (20 bytes, LE)
| Off | Name | Type | Notes |
|----:|-----------|------|-------|
| 0 | Sequence | u32 | Monotonic per direction. `0` = control/handshake packets. |
| 4 | Flags | u32 | `PacketHeaderFlags` bitmask (see below). |
| 8 | Checksum | u32 | Hash32 of header+body with this field replaced by `0xBADD70DD`. When `EncryptedChecksum` flag is set, XOR with one 32-bit word of ISAAC keystream before transmit. |
| 12 | Id | u16 | Session id, assigned in ConnectRequest by the server. |
| 14 | Time | u16 | Server-time echo for latency tracking. Clients may send 0. |
| 16 | DataSize | u16 | Total body size (excludes this 20-byte header). |
| 18 | Iteration | u16 | Retransmit iteration counter. |
### 1.2 PacketHeaderFlags (32-bit bitmask, wire source of truth)
| Value | Name | Meaning |
|------:|------|---------|
| 0x00000001 | Retransmission | Body starts with one u32 retransmit-sequence, then the original body. |
| 0x00000002 | EncryptedChecksum | The Checksum field has been XOR'd with the next ISAAC keystream word. |
| 0x00000004 | BlobFragments | Body contains one or more MessageFragment blocks. |
| 0x00000100 | ServerSwitch | Optional body prefix: `u32 ServerSwitchType` (0=World, 1=Logon). |
| 0x00000200 | LogonServerAddr | Body prefix: a sockaddr blob (u16 family, u16 port, u32 IPv4, 8 bytes padding). |
| 0x00000400 | EmptyHeader1 | Observed rarely; legacy. |
| 0x00000800 | Referral | Body prefix: u64 cookie steering the client to another server. |
| 0x00001000 | RequestRetransmit | Body: u32 count + count×u32 sequences to retransmit. |
| 0x00002000 | RejectRetransmit | Body: u32 count + count×u32 rejected sequences. |
| 0x00004000 | AckSequence | Body: u32 highest-sequence-received. |
| 0x00008000 | Disconnect | No body; teardown. |
| 0x00010000 | LoginRequest | Body is the LoginRequest blob. |
| 0x00020000 | WorldLoginRequest | (unused today, pre-live legacy.) |
| 0x00040000 | ConnectRequest | Body: `double serverTime, u64 cookie, u32 clientId, u32 serverSeed, u32 clientSeed, u32 padding`. |
| 0x00080000 | ConnectResponse | Body: `u64 cookie` (echo). |
| 0x00100000 | NetError | Body: `u32 errorCode`. |
| 0x00200000 | NetErrorDisconnect | Body: `u32 errorCode, u32 reason` + disconnect. |
| 0x00400000 | CICMDCommand | Legacy admin control channel. |
| 0x01000000 | TimeSync | Body: `double clientTime`. |
| 0x02000000 | EchoRequest | Body: `float clientTime`. |
| 0x04000000 | EchoResponse | Body: `float clientTime, float serverTime`. |
| 0x08000000 | Flow | Body: `u32 bytesSinceLast, u16 delta, u16 pad` — TCP-style flow control. |
### 1.3 MessageFragmentHeader (16 bytes, LE)
| Off | Name | Type | Notes |
|----:|-----------|------|-------|
| 0 | Sequence | u32 | Per-message sequence. Outbound messages set the high bit (`0x80000000`). |
| 4 | Id | u32 | Logical message id — fragments with the same Id belong to the same logical GameMessage. |
| 8 | Count | u16 | Total fragments in this logical message. |
| 10 | TotalSize | u16 | Total bytes of this fragment **including** the 16-byte header. Max 464. |
| 12 | Index | u16 | 0-based index of this fragment in the logical message. |
| 14 | Queue | u16 | `GameMessageGroup``Event=1, Control=2, Weenie=3, Logon=4, Database=5, UIQueue=9, SmartBox=10`, etc. |
### 1.4 ISAAC encryption
Each direction has its own `IsaacRandom` (ported in `Cryptography/`),
seeded from the 4-byte server seed (for the inbound stream) and 4-byte
client seed (for the outbound stream) that the server returns in the
ConnectRequest handshake. Every packet with `EncryptedChecksum` set
XORs the header Checksum field with the next u32 drawn from that
direction's ISAAC keystream. Cleartext control packets (ACKs, login,
connect response, disconnect) do NOT advance ISAAC.
### 1.5 Ack protocol (source: holtburger `session/receive.rs`)
**Every** received server packet whose `Sequence > 0` and that does NOT
already carry the `AckSequence` flag gets exactly one ack queued back.
This is the "eager ack" pattern — there is no periodic ack timer in the
retail client. The ack packet is:
```
PacketHeader {
Sequence = last client sequence sent (no increment),
Flags = AckSequence,
Id = session id,
}
body = u32 server_sequence_being_acked // cleartext — no ISAAC
```
Failing to ack → ACE emits "Network Timeout" after ~60s. Symptom visible
to other clients: character appears as a stationary purple loading haze.
### 1.6 Sequence counters (four flavours, movement-only)
Separate from the packet-level Sequence, a player carries four u16
sequence counters that the server uses to detect stale/out-of-order
movement messages. These live on `WorldSession` as
`_instanceSequence`, `_teleportSequence`, `_serverControlSequence`,
`_forcePositionSequence`. They're seeded from the player's own
CreateObject and updated whenever an inbound UpdatePosition or
PlayerTeleport arrives. Every outbound MoveToState / AutonomousPosition
must echo them or ACE silently rejects the message.
---
## 2. GameMessage opcodes (S→C primary)
These ride as standalone messages — the first u32 of the fragment body
is the opcode itself, followed immediately by the payload.
| Hex | Name | Envelope | Dir | Status | Prio | Notes |
|----:|------|:--------:|:---:|:------:|:----:|-------|
| 0x0024 | InventoryRemoveObject | GM | S→C | unhandled | P2 | `u32 guid` — client-side inventory eviction. |
| 0x0197 | SetStackSize | GM | S→C | unhandled | P2 | `u32 guid, u32 newStackSize`. |
| 0x019E | PlayerKilled | GM | S→C | unhandled | P1 | `u32 victimGuid, u32 killerGuid, string message`. Triggers the dead-player state. |
| 0x01E0 | EmoteText | GM | S→C | unhandled | P1 | `string message` — "The Olthoi growls at you." class feedback. |
| 0x01E2 | SoulEmote | GM | S→C | unhandled | P3 | `u32 senderGuid, u32 motion, string text`. |
| 0x02BB | HearSpeech | GM | S→C | unhandled | P1 | `string16L text, string16L sender, u32 senderGuid, u32 chatType`. Local-area speech. |
| 0x02BC | HearRangedSpeech | GM | S→C | unhandled | P1 | Same layout as HearSpeech but longer radius (shouts). |
| 0x02CD | PrivateUpdatePropertyInt | GM | S→C | unhandled | P1 | `u32 guid, u32 propertyId, u32 value` — Only sent to the object's owner. |
| 0x02CE | PublicUpdatePropertyInt | GM | S→C | unhandled | P1 | Same as above; sent to everyone who sees the object. |
| 0x02CF / 0x02D0 | Private/PublicUpdatePropertyInt64 | GM | S→C | unhandled | P1 | `u32 guid, u32 propId, u64 value`. |
| 0x02D1 / 0x02D2 | Private/PublicUpdatePropertyBool | GM | S→C | unhandled | P1 | `u32 guid, u32 propId, u32 value`. |
| 0x02D3 / 0x02D4 | Private/PublicUpdatePropertyFloat | GM | S→C | unhandled | P1 | `u32 guid, u32 propId, f64 value`. |
| 0x02D5 / 0x02D6 | Private/PublicUpdatePropertyString | GM | S→C | unhandled | P1 | `u32 guid, u32 propId, string16L value`. |
| 0x02D7 / 0x02D8 | Private/PublicUpdatePropertyDataID | GM | S→C | unhandled | P2 | `u32 guid, u32 propId, u32 dataId`. |
| 0x02D9 / 0x02DA | Private/PublicUpdatePropertyInstanceID | GM | S→C | unhandled | P2 | `u32 guid, u32 propId, u32 iid`. |
| 0x02DB / 0x02DC | Private/PublicUpdatePosition | GM | S→C | unhandled | P2 | `u32 guid, u32 propId, PositionPack`. |
| 0x02DD / 0x02DE | Private/PublicUpdateSkill | GM | S→C | unhandled | P2 | `u32 guid, u32 skillId, SkillValue`. |
| 0x02DF / 0x02E0 | Private/PublicUpdateSkillLevel | GM | S→C | unhandled | P2 | Skill-level only. |
| 0x02E3 / 0x02E4 | Private/PublicUpdateAttribute | GM | S→C | unhandled | P1 | Strength/Endurance/etc. |
| 0x02E7 / 0x02E8 | Private/PublicUpdateVital | GM | S→C | unhandled | P1 | Max HP/Stam/Mana + regen. |
| 0x02E9 | PrivateUpdateAttribute2ndLevel | GM | S→C | unhandled | P1 | Current vital value (the fast-tick channel). |
| 0xEA60 | AdminEnvirons | GM | S→C | unhandled | P4 | Admin-tool overlay. |
| 0xF619 | PositionAndMovement | GM | S→C | unhandled | P4 | Ghost opcode — declared but never fired by ACE. |
| 0xF625 | ObjDescEvent | GM | S→C | unhandled | P1 | `u32 guid, ObjectDescription` — full re-send of visual description (body parts, textures, palettes). Critical for seeing other players' gear changes. |
| 0xF643 | CharacterCreateResponse / CharacterRestoreResponse | GM | S→C | unhandled | P0+ | `u32 responseCode` — login-phase, only relevant after char-create. Same opcode, two semantics (disambiguated by session state). |
| 0xF653 | CharacterLogOff | GM | bi | partial | P0 | No payload. Client sends before Disconnect to release the character lock immediately. acdream sends it from `Dispose`. |
| 0xF655 | CharacterDelete | GM | bi | unhandled | P4 | `u32 slot` — char-select-screen deletion. |
| 0xF656 | CharacterCreate | GM | C→S | unhandled | P4 | Full character-creation blob — heritage, gender, starting town, appearance. |
| 0xF657 | CharacterEnterWorld | GM | C→S | done | P0 | `u32 characterGuid, string16L account`. Built by `Messages/CharacterEnterWorld.cs`. |
| 0xF658 | CharacterList | GM | S→C | done | P0 | `u32 serverName?, u32 numChars, char[] chars, u32 accountSlots, u32 accountGuid`. Parsed in `Messages/CharacterList.cs`. |
| 0xF659 | CharacterError | GM | S→C | unhandled | P0 | `u32 errorCode` — login failure enum. We should parse this to give the user a real error instead of a timeout. |
| 0xF6EA | ForceObjectDescSend | GM | bi | unhandled | P2 | `u32 guid`. Both directions use the same opcode; C→S asks the server to re-send, S→C pushes a forced refresh. |
| **0xF745** | **CreateObject** | GM | S→C | done | P0 | Parser: `Messages/CreateObject.cs` (538 lines). Layout: `u32 guid, WeenieHeader, ObjectDescription, PhysicsData`. Emits `EntitySpawned` event. |
| 0xF746 | PlayerCreate | GM | S→C | partial | P0 | Identifies our own character guid. acdream watches for this to send LoginComplete. Does NOT parse the body (it's a full CreateObject-shaped payload — we rely on 0xF745 for that to arrive separately, though retail bundles both). |
| 0xF747 | ObjectDelete | GM | S→C | unhandled | P1 | `u32 guid, u16 instanceSequence`. When an object leaves our bubble or is destroyed. Without handling this, stale entities accumulate forever. |
| **0xF748** | **UpdatePosition** | GM | S→C | done | P0 | Parser: `Messages/UpdatePosition.cs`. Emits `PositionUpdated` event. |
| 0xF749 | ParentEvent | GM | S→C | unhandled | P1 | `u32 parentGuid, u32 childGuid, u32 equipLocation, u32 placementId` — item equipped / child attached. Needed to draw weapons in hands, clothing over armour. |
| 0xF74A | PickupEvent | GM | S→C | unhandled | P2 | `u32 guid, u32 animationType` — player picks up an item, animation dispatched. |
| 0xF74B | SetState | GM | S→C | unhandled | P1 | `u32 guid, u32 physicsState, u16 instanceSeq, u16 stateSeq` — door opens, chest unlocks, visibility flips. |
| **0xF74C** | **UpdateMotion / MovementEvent** | GM | S→C | done | P0 | Parser: `Messages/UpdateMotion.cs`. Emits `MotionUpdated` event. Two names are the same opcode — ACE aliases. |
| 0xF74E | VectorUpdate | GM | S→C | unhandled | P1 | `u32 guid, f32 vx, f32 vy, f32 vz, f32 avx, f32 avy, f32 avz, u16 seq, u16 pad` — velocity + angular velocity. Missile tracking, continuous turns. |
| 0xF750 | Sound | GM | S→C | unhandled | P2 | `u32 guid, u32 soundId, f32 volume` — spatialised SFX trigger. |
| **0xF751** | **PlayerTeleport** | GM | S→C | done | P0 | Parser inline in `WorldSession.cs`. Emits `TeleportStarted` with `u16 teleportSequence`. |
| 0xF752 | AutonomyLevel | GM | S→C | unhandled | P1 | `u32 autonomyLevel` — server tells client how much physics trust to grant. Default 0 = full client sim; higher = server authoritative. |
| 0xF753 | AutonomousPosition | GM | S→C | unhandled | P1 | Server-forced position resync. Same body as C→S AutonomousPosition (see §3). Causes a snap. |
| 0xF754 | PlayScriptId | GM | S→C | unhandled | P2 | `u32 guid, u32 scriptId` — trigger a pre-compiled script effect. |
| 0xF755 | PlayEffect / PlayScriptType | GM | S→C | unhandled | P2 | `u32 guid, u32 effectType, f32 scale` — particle/visual overlay. |
| **0xF7B0** | **GameEvent** | GM | S→C | unhandled | P1 | The container for every "ordered" server event. Sub-opcode table in §4. |
| **0xF7B1** | **GameAction** | GM | C→S | done (partial) | P0 | acdream builds LoginComplete, MoveToState, AutonomousPosition, Jump. Sub-opcode table in §3. |
| 0xF7C1 | AccountBanned | GM | S→C | unhandled | P4 | `u32 bannedUntilUnixTime, string16L reason`. |
| 0xF7C8 | CharacterEnterWorldRequest | GM | C→S | done | P0 | Built by `Messages/CharacterEnterWorld.cs::BuildEnterWorldRequestBody`. No payload. |
| 0xF7C9 | JumpNonAutonomous | GM | bi | unhandled | P4 | Server-side legacy jump; not sent by live ACE. |
| 0xF7CA | ReceiveAccountData | GM | S→C | unhandled | P4 | Admin inspection. |
| 0xF7CB | ReceivePlayerData | GM | S→C | unhandled | P4 | Admin inspection. |
| 0xF7CC | GetServerVersion | GM | C→S | unhandled | P4 | Admin-only. |
| 0xF7CD | FriendsOld | GM | C→S | unhandled | P4 | Obsolete. |
| 0xF7D9 | CharacterRestore | GM | C→S | unhandled | P4 | Restore a deleted character within grace period. |
| 0xF7DB | UpdateObject | GM | S→C | unhandled | P1 | Heavy update — re-serializes the complete WorldObject. Used on gear swaps, morphs, level-ups. |
| 0xF7DC | AccountBoot / AccountBooted | GM | S→C | unhandled | P1 | `string16L reason` — server-initiated kick. Should transition to Failed state. |
| 0xF7DE | TurbineChat | GM | bi | unhandled | P1 | Turbine's social chat (General/Trade/LFG/Allegiance/Society). Complex nested blob, see §7.4. |
| 0xF7DF | CharacterEnterWorldServerReady | GM | S→C | done | P0 | No payload. acdream awaits it in EnterWorld as an ack that the server got our request. |
| 0xF7E0 | ServerMessage / TextboxString | GM | S→C | unhandled | P1 | `string16L text, u32 chatType`. System chat line in the main log. |
| 0xF7E1 | ServerName / WorldInfo | GM | S→C | unhandled | P0 | `u32 currentConnections, u32 maxConnections, string16L serverName`. Displayed on char-select. |
| 0xF7E2 | DDD_DataMessage | GM | bi | unhandled | P4 | Patch-stream file chunk — only triggers when dat versions mismatch. |
| 0xF7E3 | DDD_RequestDataMessage | GM | C→S | unhandled | P4 | `u32 fileType, u32 fileId` — client asks for a file. |
| 0xF7E4 | DDD_ErrorMessage | GM | S→C | unhandled | P4 | `u32 fileType, u32 fileId, u32 errorCode`. |
| **0xF7E5** | **DDD_Interrogation** | GM | S→C | done | P0 | No payload beyond opcode. Server asks "what dat versions do you have?" |
| **0xF7E6** | **DDD_InterrogationResponse** | GM | C→S | done | P0 | Built by `Messages/DddInterrogationResponse.cs` — reports language=1 English, zero known lists. |
| 0xF7E7 | DDD_BeginDDD | GM | S→C | unhandled | P4 | Server tells client patching will begin; `u32 totalIterations, u64 totalBytes, ...`. |
| 0xF7E8 | DDD_BeginPullDDD | GM | S→C | unhandled | P4 | Alternative patch entry. |
| 0xF7E9 | DDD_IterationData | GM | S→C | unhandled | P4 | One iteration record during patching. |
| 0xF7EA | DDD_EndDDD | GM | bi | unhandled | P4 | Patching finished / client acknowledges. |
**Total primary S→C opcodes:** 63. **Currently handled by acdream:** 7
(CharacterList, CharacterEnterWorldServerReady, CreateObject,
UpdateMotion, UpdatePosition, PlayerTeleport, DDD_Interrogation) plus
the PlayerCreate trigger. **Unhandled:** 56 — many of them property
updates that share a single parser.
---
## 3. GameAction sub-opcodes (C→S, inside 0xF7B1 envelope)
Wire layout of every GameAction on the outbound side:
```
u32 0xF7B1 // GameMessage opcode (GameAction envelope)
u32 sequence // monotonic per-session action counter
u32 actionType // one of the values below
<payload bytes> // variable
```
ACE's `GameActionPacket.HandleGameAction` reads the sequence field and
ignores its value (`// TODO: verify sequence` in the source), but live
clients still increment it — acdream does too via
`WorldSession.NextGameActionSequence()`.
| Hex | Name | Dir | Status | Prio | Payload |
|----:|------|:---:|:------:|:----:|---------|
| 0x0005 | SetSingleCharacterOption | C→S | unhandled | P3 | `u32 optionKey, u32 value`. Appear-offline, show-cloak, etc. |
| 0x0008 | TargetedMeleeAttack | C→S | unhandled | P1 | `u32 targetGuid, u32 attackHeight, f32 powerLevel`. |
| 0x000A | TargetedMissileAttack | C→S | unhandled | P2 | `u32 targetGuid, u32 attackHeight, f32 accuracyLevel`. |
| 0x000F | SetAfkMode | C→S | unhandled | P3 | `u32 enabled`. |
| 0x0010 | SetAfkMessage | C→S | unhandled | P3 | `string16L message`. |
| 0x0015 | Talk | C→S | unhandled | P1 | `string16L message` — local-area chat. |
| 0x0017 | RemoveFriend | C→S | unhandled | P3 | `u32 guid`. |
| 0x0018 | AddFriend | C→S | unhandled | P3 | `string16L name`. |
| 0x0019 | PutItemInContainer | C→S | unhandled | P2 | `u32 itemGuid, u32 containerGuid, u32 placement`. |
| 0x001A | GetAndWieldItem | C→S | unhandled | P2 | `u32 itemGuid, u32 equipLocation`. |
| 0x001B | DropItem | C→S | unhandled | P2 | `u32 itemGuid`. |
| 0x001D | SwearAllegiance | C→S | unhandled | P3 | `u32 patronGuid`. |
| 0x001E | BreakAllegiance | C→S | unhandled | P3 | `u32 otherGuid`. |
| 0x001F | AllegianceUpdateRequest | C→S | unhandled | P3 | `u32 onOff`. |
| 0x0025 | RemoveAllFriends | C→S | unhandled | P3 | — |
| 0x0026 | TeleToPklArena | C→S | unhandled | P3 | — |
| 0x0027 | TeleToPkArena | C→S | unhandled | P3 | — |
| 0x002C | TitleSet | C→S | unhandled | P3 | `u32 titleId`. |
| 0x0030 | QueryAllegianceName | C→S | unhandled | P3 | — |
| 0x0031 | ClearAllegianceName | C→S | unhandled | P3 | — |
| 0x0032 | TalkDirect | C→S | unhandled | P1 | `string16L target, string16L message` — whisper by location/target. |
| 0x0033 | SetAllegianceName | C→S | unhandled | P3 | `string16L name`. |
| 0x0035 | UseWithTarget | C→S | unhandled | P2 | `u32 sourceGuid, u32 targetGuid`. |
| 0x0036 | Use | C→S | unhandled | P2 | `u32 guid` — click a door, Lifestone, portal, corpse, etc. |
| 0x003B0x0042 | Allegiance officer/chat/house | C→S | unhandled | P3 | Various allegiance admin ops. |
| 0x0044 | RaiseVital | C→S | unhandled | P2 | `u32 vital, u64 xpSpent`. |
| 0x0045 | RaiseAttribute | C→S | unhandled | P2 | `u32 attr, u64 xpSpent`. |
| 0x0046 | RaiseSkill | C→S | unhandled | P2 | `u32 skillId, u64 xpSpent`. |
| 0x0047 | TrainSkill | C→S | unhandled | P2 | `u32 skillId, u32 credits`. |
| 0x0048 | CastUntargetedSpell | C→S | unhandled | P2 | `u32 spellId`. |
| 0x004A | CastTargetedSpell | C→S | unhandled | P2 | `u32 targetGuid, u32 spellId`. |
| 0x0053 | ChangeCombatMode | C→S | unhandled | P1 | `u32 combatMode` — Peace/Melee/Missile/Magic. |
| 0x0054 | StackableMerge | C→S | unhandled | P2 | `u32 mergeFromGuid, u32 mergeToGuid, u32 amount`. |
| 0x0055 | StackableSplitToContainer | C→S | unhandled | P2 | `u32 stackGuid, u32 containerGuid, u32 placement, u32 amount`. |
| 0x0056 | StackableSplitTo3D | C→S | unhandled | P2 | `u32 stackGuid, u32 amount`. |
| 0x0058 | ModifyCharacterSquelch | C→S | unhandled | P3 | `u32 guid, u32 squelchType, bool add`. |
| 0x0059 | ModifyAccountSquelch | C→S | unhandled | P3 | `string16L account, u32 squelchType, bool add`. |
| 0x005B | ModifyGlobalSquelch | C→S | unhandled | P3 | `u32 channelId, bool enable`. |
| 0x005D | Tell | C→S | unhandled | P1 | `string16L targetName, string16L message` — whisper by name. |
| 0x005F | Buy | C→S | unhandled | P2 | `u32 vendorGuid, u32 count, ItemProfile[count]`. |
| 0x0060 | Sell | C→S | unhandled | P2 | Same shape. |
| 0x0063 | TeleToLifestone | C→S | unhandled | P2 | — |
| **0x00A1** | **LoginComplete** | C→S | done | P0 | No payload. Built by `Messages/GameActionLoginComplete.cs`. |
| 0x00A2 | FellowshipCreate | C→S | unhandled | P3 | `string16L name, bool openness, bool shareXP`. |
| 0x00A3 | FellowshipQuit | C→S | unhandled | P3 | `bool disband`. |
| 0x00A4 | FellowshipDismiss | C→S | unhandled | P3 | `u32 guid`. |
| 0x00A5 | FellowshipRecruit | C→S | unhandled | P3 | `u32 guid`. |
| 0x00A6 | FellowshipUpdateRequest | C→S | unhandled | P3 | `bool open`. |
| 0x00AA0x00AE | Book* | C→S | unhandled | P4 | Book add/modify/delete/query pages. |
| 0x00BF | SetInscription | C→S | unhandled | P3 | `u32 itemGuid, string16L text`. |
| 0x00C8 | IdentifyObject | C→S | unhandled | P1 | `u32 guid`. Assess target; server replies with 0xF7B0/0x00C9. |
| 0x00CD | GiveObjectRequest | C→S | unhandled | P2 | `u32 targetGuid, u32 itemGuid, u32 amount`. |
| 0x00D6 | AdvocateTeleport | C→S | unhandled | P4 | Admin. |
| 0x0140 | AbuseLogRequest | C→S | unhandled | P4 | — |
| 0x0145 | AddChannel | C→S | unhandled | P3 | `string16L channelName`. |
| 0x0146 | RemoveChannel | C→S | unhandled | P3 | `string16L channelName`. |
| 0x0147 | ChatChannel | C→S | unhandled | P1 | `u32 channelId, string16L message`. |
| 0x0148 | ListChannels | C→S | unhandled | P3 | — |
| 0x0149 | IndexChannels | C→S | unhandled | P3 | — |
| 0x0195 | NoLongerViewingContents | C→S | unhandled | P2 | `u32 containerGuid`. |
| 0x019B | StackableSplitToWield | C→S | unhandled | P2 | `u32 stackGuid, u32 equipLocation, u32 amount`. |
| 0x019C | AddShortCut | C→S | unhandled | P3 | `u32 slot, u32 objectType, u32 targetId`. |
| 0x019D | RemoveShortCut | C→S | unhandled | P3 | `u32 slot`. |
| 0x01A1 | SetCharacterOptions | C→S | unhandled | P3 | Client option bitmap. |
| 0x01A8 | RemoveSpellC2S | C→S | unhandled | P2 | `u32 spellId`. |
| 0x01B7 | CancelAttack | C→S | unhandled | P1 | — |
| 0x01BF | QueryHealth | C→S | unhandled | P1 | `u32 guid`. Server replies with GameEvent UpdateHealth (0x01C0). |
| 0x01C2 | QueryAge | C→S | unhandled | P4 | — |
| 0x01C4 | QueryBirth | C→S | unhandled | P4 | — |
| 0x01DF | Emote | C→S | unhandled | P2 | `string16L emote`. |
| 0x01E1 | SoulEmote | C→S | unhandled | P3 | `string16L emote`. |
| 0x01E3 | AddSpellFavorite | C→S | unhandled | P3 | `u32 spellId, u32 bar, u32 slot`. |
| 0x01E4 | RemoveSpellFavorite | C→S | unhandled | P3 | `u32 bar, u32 slot`. |
| 0x01E9 | PingRequest | C→S | unhandled | P1 | `u32 clientId`. Server replies with 0xF7B0/0x01EA. Double-duty keepalive. |
| 0x01F6 | OpenTradeNegotiations | C→S | unhandled | P3 | `u32 targetGuid`. |
| 0x01F7 | CloseTradeNegotiations | C→S | unhandled | P3 | — |
| 0x01F8 | AddToTrade | C→S | unhandled | P3 | `u32 itemGuid, u32 slotIndex`. |
| 0x01FA | AcceptTrade | C→S | unhandled | P3 | — |
| 0x01FB | DeclineTrade | C→S | unhandled | P3 | — |
| 0x0204 | ResetTrade | C→S | unhandled | P3 | — |
| 0x0216 | ClearPlayerConsentList | C→S | unhandled | P4 | — |
| 0x0217 | DisplayPlayerConsentList | C→S | unhandled | P4 | — |
| 0x0218 | RemoveFromPlayerConsentList | C→S | unhandled | P4 | `u32 guid`. |
| 0x0219 | AddPlayerPermission | C→S | unhandled | P4 | `u32 guid`. |
| 0x021A | RemovePlayerPermission | C→S | unhandled | P4 | `u32 guid`. |
| 0x021C | BuyHouse | C→S | unhandled | P3 | `u32 slumlordGuid, PackableListU32 allegianceHouseIds`. |
| 0x021E | HouseQuery | C→S | unhandled | P3 | `u32 houseGuid`. |
| 0x021F | AbandonHouse | C→S | unhandled | P3 | — |
| 0x0221 | RentHouse | C→S | unhandled | P3 | `u32 houseGuid, PackableListU32 itemIds`. |
| 0x0224 | SetDesiredComponentLevel | C→S | unhandled | P3 | `u32 level`. |
| 0x0245 | AddPermanentGuest | C→S | unhandled | P3 | `string16L name`. |
| 0x0246 | RemovePermanentGuest | C→S | unhandled | P3 | `string16L name`. |
| 0x0247 | SetOpenHouseStatus | C→S | unhandled | P3 | `bool open`. |
| 0x0249 | ChangeStoragePermission | C→S | unhandled | P3 | — |
| 0x024A | BootSpecificHouseGuest | C→S | unhandled | P3 | `string16L name`. |
| 0x024C | RemoveAllStoragePermission | C→S | unhandled | P3 | — |
| 0x024D | RequestFullGuestList | C→S | unhandled | P3 | — |
| 0x02540x0256 | Allegiance MOTD | C→S | unhandled | P3 | — |
| 0x0258 | QueryLord | C→S | unhandled | P3 | — |
| 0x025C | AddAllStoragePermission | C→S | unhandled | P3 | — |
| 0x025E | RemoveAllPermanentGuests | C→S | unhandled | P3 | — |
| 0x025F | BootEveryone | C→S | unhandled | P3 | — |
| 0x0262 | TeleToHouse | C→S | unhandled | P3 | — |
| 0x0263 | QueryItemMana | C→S | unhandled | P2 | `u32 guid`. |
| 0x0266 | SetHooksVisibility | C→S | unhandled | P3 | `bool visible`. |
| 0x0267 | ModifyAllegianceGuestPermission | C→S | unhandled | P3 | — |
| 0x0268 | ModifyAllegianceStoragePermission | C→S | unhandled | P3 | — |
| 0x02690x026E | Chess | C→S | unhandled | P4 | Chess minigame. |
| 0x0270 | ListAvailableHouses | C→S | unhandled | P3 | — |
| 0x0275 | ConfirmationResponse | C→S | unhandled | P2 | `u32 confirmationType, u32 contextId, bool accepted`. |
| 0x0277 | BreakAllegianceBoot | C→S | unhandled | P3 | `u32 otherGuid, bool accountBoot`. |
| 0x0278 | TeleToMansion | C→S | unhandled | P3 | — |
| 0x0279 | Suicide | C→S | unhandled | P3 | — |
| 0x027B | AllegianceInfoRequest | C→S | unhandled | P3 | `string16L name`. |
| 0x027D | CreateTinkeringTool | C→S | unhandled | P2 | `u32 ustGuid, u32 targetGuid`. Salvage-items-with. |
| 0x0286 | SpellbookFilter | C→S | unhandled | P3 | Filter bitmap. |
| 0x028D | TeleToMarketPlace | C→S | unhandled | P2 | — |
| 0x028F | EnterPkLite | C→S | unhandled | P3 | — |
| 0x0290 | FellowshipAssignNewLeader | C→S | unhandled | P3 | `u32 guid`. |
| 0x0291 | FellowshipChangeOpenness | C→S | unhandled | P3 | `bool open`. |
| 0x02A00x02A7, 0x02AB | Allegiance admin | C→S | unhandled | P3 | Chat boot / bans / officers. |
| 0x02AF, 0x02B2 | Admin plugin query responses | C→S | unhandled | P4 | — |
| 0x0311 | FinishBarber | C→S | unhandled | P3 | Full barber blob. |
| 0x0316 | AbandonContract | C→S | unhandled | P3 | `u32 contractId`. |
| **0xF61B** | **Jump** | C→S | done (partial) | P0 | Built by `Messages/JumpAction.cs`. Payload: `f32 extent, Vector3 jumpVelocity` + position/sequences. |
| **0xF61C** | **MoveToState** | C→S | done | P0 | Built by `Messages/MoveToState.cs`. Full RawMotionState + WorldPosition + 4 u16 sequences + contact byte. |
| 0xF61E | DoMovementCommand | C→S | unhandled | P4 | Legacy — live client doesn't use this path. |
| 0xF649 | TurnTo | C→S | unhandled | P4 | Legacy — unused. |
| 0xF661 | StopMovementCommand | C→S | unhandled | P4 | Legacy. |
| 0xF6EA | ForceObjectDescSend | C→S | unhandled | P2 | `u32 guid` — ask server to re-emit ObjDescEvent for this entity. Useful for reloading avatar appearance. |
| 0xF745 | ObjectCreate | C→S | unhandled | P4 | Legacy admin spawn. |
| 0xF747 | ObjectDelete | C→S | unhandled | P4 | Legacy admin despawn. |
| 0xF74C | MovementEvent | C→S | unhandled | P4 | Legacy. |
| 0xF750 | ApplySoundEffect | C→S | unhandled | P4 | Admin. |
| 0xF752 | AutonomyLevel | C→S | unhandled | P2 | `u32 level` — ack the server's autonomy level. |
| **0xF753** | **AutonomousPosition** | C→S | done | P0 | Built by `Messages/AutonomousPosition.cs`. Same body as MoveToState but without the motion command list. Heartbeat every ~200ms while moving. |
| 0xF755 | ApplyVisualEffect | C→S | unhandled | P4 | Admin. |
| 0xF7C9 | JumpNonAutonomous | C→S | unhandled | P4 | Server-controlled jump — client never sends. |
**Total GameAction C→S opcodes:** 149. **Currently handled:** 4
(LoginComplete, MoveToState, Jump, AutonomousPosition). **Unhandled:**
145 — but the vast majority are P3/P4 and unblocking only requires 5-8
P1/P2 adds (ChatChannel, Talk, Tell, Use, UseWithTarget, IdentifyObject,
QueryHealth, ChangeCombatMode, CastTargetedSpell,
CastUntargetedSpell, TargetedMeleeAttack, TargetedMissileAttack,
CancelAttack, PingRequest).
---
## 4. GameEvent sub-opcodes (S→C, inside 0xF7B0 envelope)
Wire layout of every GameEvent on the inbound side (source:
`references/ACE/Source/ACE.Server/Network/GameEvent/GameEventMessage.cs`):
```
u32 0xF7B0 // GameMessage opcode (GameEvent envelope)
u32 guid // session's player guid (0 if session has no player yet)
u32 gameEventSequence // per-session, incremented by server
u32 eventType // one of the values below
<payload bytes> // variable
```
| Hex | Name | Dir | Status | Prio | Payload |
|----:|------|:---:|:------:|:----:|---------|
| 0x0003 | AllegianceUpdateAborted | S→C | unhandled | P3 | — |
| 0x0004 | PopupString | S→C | unhandled | P1 | `string16L message` — modal dialog. |
| 0x0013 | PlayerDescription | S→C | unhandled | P0 | Large blob — full player stats, skills, attributes, vitals, spellbook, inventory, equipment, titles, contracts. Sent right after LoginComplete. Parsing this is the gate to everything UI. |
| 0x0020 | AllegianceUpdate | S→C | unhandled | P3 | Allegiance hierarchy delta. |
| 0x0021 | FriendsListUpdate | S→C | unhandled | P3 | `u32 listType, u32 count, FriendEntry[count]`. |
| 0x0022 | InventoryPutObjInContainer | S→C | unhandled | P2 | `u32 itemGuid, u32 containerGuid, u32 placement`. |
| 0x0023 | WieldObject | S→C | unhandled | P1 | `u32 guid, u32 equipLoc, u32 wielderGuid`. |
| 0x0029 | CharacterTitle | S→C | unhandled | P3 | — |
| 0x002B | UpdateTitle | S→C | unhandled | P3 | `u32 newTitleId`. |
| 0x0052 | CloseGroundContainer | S→C | unhandled | P2 | `u32 containerGuid`. |
| 0x0062 | ApproachVendor / VendorInfoEvent | S→C | unhandled | P2 | Vendor inventory blob — item list, buy/sell rates. |
| 0x0075 | StartBarber | S→C | unhandled | P3 | Barber UI trigger. |
| 0x00A0 | InventoryServerSaveFailed | S→C | unhandled | P2 | `u32 guid` — revert a local inventory op. |
| 0x00A3 | FellowshipQuit | S→C | unhandled | P3 | Echo. |
| 0x00A4 | FellowshipDismiss | S→C | unhandled | P3 | Echo. |
| 0x00B4 | BookDataResponse | S→C | unhandled | P4 | Book header + inline pages. |
| 0x00B5 | BookModifyPageResponse | S→C | unhandled | P4 | — |
| 0x00B6 | BookAddPageResponse | S→C | unhandled | P4 | — |
| 0x00B7 | BookDeletePageResponse | S→C | unhandled | P4 | — |
| 0x00B8 | BookPageDataResponse | S→C | unhandled | P4 | Text of a single page. |
| 0x00C3 | IdentifyObject/GetInscriptionResponse | S→C | unhandled | P3 | `u32 guid, string16L text`. |
| 0x00C9 | IdentifyObjectResponse | S→C | unhandled | P1 | Full AppraiseInfo — properties tables keyed by enum. Essential for target info UI. |
| 0x0147 | ChannelBroadcast | S→C | unhandled | P1 | `u32 channelId, string16L senderName, string16L message`. |
| 0x0148 | ChannelList | S→C | unhandled | P3 | `u32 count, string16L[count] names`. |
| 0x0149 | ChannelIndex | S→C | unhandled | P3 | — |
| 0x0196 | ViewContents | S→C | unhandled | P2 | `u32 containerGuid, u32 count, (u32 guid, u32 slot)[count]`. |
| 0x019A | InventoryPutObjectIn3D | S→C | unhandled | P2 | `u32 guid` — item appears on the ground. |
| 0x01A7 | AttackDone | S→C | unhandled | P1 | `u32 attackSequence, u32 weenieError`. |
| 0x01A8 | MagicRemoveSpell | S→C | unhandled | P2 | `u32 spellId`. |
| 0x01AC | VictimNotification | S→C | unhandled | P1 | `string16L attackerName, u32 attackerGuid, u32 damageType, u32 damage, u32 hitQuadrant, u32 crit, u32 attackType`. |
| 0x01AD | KillerNotification | S→C | unhandled | P1 | `string16L victimName, u32 victimGuid`. |
| 0x01B1 | AttackerNotification | S→C | unhandled | P1 | `string16L defenderName, u32 damageType, u32 damage, f32 damagePercent`. |
| 0x01B2 | DefenderNotification | S→C | unhandled | P1 | `string16L attackerName, u32 attackerGuid, u32 damageType, u32 damage, u32 hitQuadrant, u32 crit`. |
| 0x01B3 | EvasionAttackerNotification | S→C | unhandled | P1 | `string16L defenderName`. |
| 0x01B4 | EvasionDefenderNotification | S→C | unhandled | P1 | `string16L attackerName`. |
| 0x01B8 | CombatCommenceAttack | S→C | unhandled | P1 | No payload. "You swing your weapon." |
| 0x01C0 | UpdateHealth | S→C | unhandled | P1 | `u32 targetGuid, f32 healthPercent`. Reply to QueryHealth. |
| 0x01C3 | QueryAgeResponse | S→C | unhandled | P4 | — |
| 0x01C7 | UseDone | S→C | unhandled | P1 | `u32 weenieError` — the Use event's completion signal. |
| 0x01C8 | AllegianceAllegianceUpdateDone | S→C | unhandled | P3 | — |
| 0x01C9 | FellowshipFellowUpdateDone | S→C | unhandled | P3 | — |
| 0x01CA | FellowshipFellowStatsDone | S→C | unhandled | P3 | — |
| 0x01CB | ItemAppraiseDone | S→C | unhandled | P4 | Ghost — never emitted. |
| 0x01E2 | Emote | S→C | unhandled | P1 | Same as GM 0x01E0 but GameEvent subclass. |
| 0x01EA | PingResponse | S→C | unhandled | P1 | `u32 clientId` echo. |
| 0x01F4 | SetSquelchDB | S→C | unhandled | P3 | Squelch list sync. |
| 0x01FD | RegisterTrade | S→C | unhandled | P3 | — |
| 0x01FE | OpenTrade | S→C | unhandled | P3 | — |
| 0x01FF | CloseTrade | S→C | unhandled | P3 | — |
| 0x0200 | AddToTrade | S→C | unhandled | P3 | `u32 itemGuid, u32 slotIndex`. |
| 0x0201 | RemoveFromTrade | S→C | unhandled | P3 | — |
| 0x0202 | AcceptTrade | S→C | unhandled | P3 | `u32 initiatorGuid`. |
| 0x0203 | DeclineTrade | S→C | unhandled | P3 | — |
| 0x0205 | ResetTrade | S→C | unhandled | P3 | — |
| 0x0207 | TradeFailure | S→C | unhandled | P3 | `u32 errorCode`. |
| 0x0208 | ClearTradeAcceptance | S→C | unhandled | P3 | — |
| 0x021D | HouseProfile | S→C | unhandled | P3 | — |
| 0x0225 | HouseData | S→C | unhandled | P3 | — |
| 0x0226 | HouseStatus | S→C | unhandled | P3 | — |
| 0x0227 | UpdateRentTime | S→C | unhandled | P3 | — |
| 0x0228 | UpdateRentPayment | S→C | unhandled | P3 | — |
| 0x0248 | HouseUpdateRestrictions | S→C | unhandled | P3 | — |
| 0x0257 | UpdateHAR | S→C | unhandled | P3 | — |
| 0x0259 | HouseTransaction | S→C | unhandled | P3 | — |
| 0x0264 | QueryItemManaResponse | S→C | unhandled | P2 | `u32 itemGuid, f32 manaPercent`. |
| 0x0271 | AvailableHouses | S→C | unhandled | P3 | — |
| 0x0274 | CharacterConfirmationRequest | S→C | unhandled | P2 | `u32 type, u32 contextId, u32 otherGuid, string16L message`. |
| 0x0276 | CharacterConfirmationDone | S→C | unhandled | P2 | `u32 contextId, bool accepted`. |
| 0x027A | AllegianceLoginNotification | S→C | unhandled | P3 | — |
| 0x027C | AllegianceInfoResponse | S→C | unhandled | P3 | — |
| 0x0281 | JoinGameResponse | S→C | unhandled | P4 | Chess/minigame. |
| 0x0282 | StartGame | S→C | unhandled | P0 | "World finished loading" signal (duplicate of LoginComplete flow in some clients). |
| 0x0283 | MoveResponse | S→C | unhandled | P4 | Minigame. |
| 0x0284 | OpponentTurn | S→C | unhandled | P4 | Minigame. |
| 0x0285 | OpponentStalemate | S→C | unhandled | P4 | Minigame. |
| 0x028A | WeenieError | S→C | unhandled | P1 | `u32 errorCode` — generic game-logic failure (ported from `WeenieError` enum in shared models). |
| 0x028B | WeenieErrorWithString | S→C | unhandled | P1 | `u32 errorCode, string16L interpolation`. |
| 0x028C | GameOver | S→C | unhandled | P4 | Minigame. |
| 0x0295 | SetTurbineChatChannels | S→C | unhandled | P1 | `u32 count, TurbineChatChannelConfig[count]` — configures which chat rooms to show. |
| 0x02AE | AdminQueryPluginList | S→C | unhandled | P4 | — |
| 0x02B1 | AdminQueryPlugin | S→C | unhandled | P4 | — |
| 0x02B3 | AdminQueryPluginResponse | S→C | unhandled | P4 | — |
| 0x02B4 | SalvageOperationsResult | S→C | unhandled | P2 | Salvage result blob. |
| 0x02BD | Tell | S→C | unhandled | P1 | `string16L message, string16L senderName, u32 senderGuid, u32 targetGuid, u32 chatType`. |
| 0x02BE | FellowshipFullUpdate | S→C | unhandled | P3 | — |
| 0x02BF | FellowshipDisband | S→C | unhandled | P3 | — |
| 0x02C0 | FellowshipUpdateFellow | S→C | unhandled | P3 | Member delta. |
| 0x02C1 | MagicUpdateSpell | S→C | unhandled | P2 | `u32 spellId`. Adds spell to spellbook. |
| 0x02C2 | MagicUpdateEnchantment | S→C | unhandled | P1 | `Enchantment` blob — buff/debuff on player. |
| 0x02C3 | MagicRemoveEnchantment | S→C | unhandled | P1 | `u32 layerId, u32 spellId`. |
| 0x02C4 | MagicUpdateMultipleEnchantments | S→C | unhandled | P1 | `u32 count, Enchantment[count]`. |
| 0x02C5 | MagicRemoveMultipleEnchantments | S→C | unhandled | P1 | Same shape. |
| 0x02C6 | MagicPurgeEnchantments | S→C | unhandled | P1 | — |
| 0x02C7 | MagicDispelEnchantment | S→C | unhandled | P1 | Single dispel. |
| 0x02C8 | MagicDispelMultipleEnchantments | S→C | unhandled | P1 | Multi dispel. |
| 0x02C9 | PortalStormBrewing | S→C | unhandled | P3 | — |
| 0x02CA | PortalStormImminent | S→C | unhandled | P3 | — |
| 0x02CB | PortalStorm | S→C | unhandled | P3 | — |
| 0x02CC | PortalStormSubsided | S→C | unhandled | P3 | — |
| 0x02EB | CommunicationTransientString | S→C | unhandled | P1 | `string16L message, u32 chatType` — ticker-style message. |
| 0x0312 | MagicPurgeBadEnchantments | S→C | unhandled | P2 | — |
| 0x0314 | SendClientContractTrackerTable | S→C | unhandled | P3 | Full contract list. |
| 0x0315 | SendClientContractTracker | S→C | unhandled | P3 | Single contract update. |
**Total GameEvent opcodes:** 94. **Currently handled:** 0. **Unhandled:**
94 — the 0xF7B0 path is completely absent from acdream today.
---
## 5. Login-phase opcodes
### 5.1 Order (source: `WorldSession.Connect` + `EnterWorld` + holtburger
`session/handshake`):
```
C→S: LoginRequest (header flag 0x00010000, custom auth body on port N)
S→C: ConnectRequest (header flag 0x00040000, returns seeds + cookie + clientId)
←200ms race delay→
C→S: ConnectResponse (header flag 0x00080000, echo cookie — sent to port N+1)
S→C: ServerName (GM 0xF7E1)
S→C: CharacterList (GM 0xF658)
C→S: CharacterEnterWorldRequest (GM 0xF7C8)
S→C: CharacterEnterWorldServerReady (GM 0xF7DF)
C→S: CharacterEnterWorld (GM 0xF657)
S→C: DDD_Interrogation (GM 0xF7E5)
C→S: DDD_InterrogationResponse (GM 0xF7E6)
S→C: PlayerCreate (GM 0xF746) + optional UpdateObjects for initial bubble
C→S: GameAction(LoginComplete) (GA 0x00A1)
S→C: GameEvent(PlayerDescription) (GE 0x0013) — full stat sheet
S→C: GameEvent(SetTurbineChatChannels) (GE 0x0295)
S→C: many GameMessageCreateObject for the initial bubble
```
### 5.2 Timing + latency requirements
- The **200ms race delay** before ConnectResponse is required because
the retail client's UDP flow sends the response before the server has
finished allocating the session on port N+1. 200ms is the minimum that
reliably works against live ACE. Shorter delays cause intermittent
"connection refused" from the kernel.
- Every server packet with `Sequence > 0` needs an ack within ~60s
regardless of phase (see §1.5). Failing this → `Network Timeout`.
- **LoginComplete must follow PlayerCreate, not EnterWorld.** Sending
LoginComplete too early keeps the server in a transitional state and
other clients see the player as a purple loading haze.
- **DDD_InterrogationResponse must be sent promptly** after receiving
DDD_Interrogation (within a few hundred ms). If the server decides
DATs are out of sync it will boot the account.
- If DAT patching is **enabled** on the server, DDD_Interrogation →
DDD_BeginDDD → multiple DDD_DataMessage → DDD_EndDDD can push
megabytes of data before LoginComplete is viable.
---
## 6. In-world opcodes — functional groups
### 6.1 Movement (the hot path)
| Opcode | Dir | Cadence | Notes |
|:------:|:---:|:-------:|-------|
| 0xF61C MoveToState | C→S | On key state change | Sent when the motion command set changes (start/stop walking, sidestep, turn). Full RawMotionState, position, rotation, sequences, contact byte. |
| 0xF753 AutonomousPosition | C→S | Every ~200ms while moving | Heartbeat confirming the client is still at position X. No motion commands — just position + sequences. |
| 0xF61B Jump | C→S | On jump key | Same structure as MoveToState but with `f32 extent, Vector3 velocity` prepended. |
| 0xF748 UpdatePosition | S→C | Per-entity, variable | Every entity in the bubble gets these as they move. We parse + fire `PositionUpdated`. |
| 0xF74C UpdateMotion | S→C | On motion change | NPC starts walking, creature enters combat, door opens, etc. |
| 0xF74E VectorUpdate | S→C | Per-tick for flying objects | Missiles, projectiles, continuous-turn casts. Unhandled today. |
| 0xF751 PlayerTeleport | S→C | On teleport | Player is being moved through portal space. acdream fires `TeleportStarted` and re-sends LoginComplete at destination. |
| 0xF753 AutonomousPosition | S→C | On server-forced snap | When the server rejects a client-reported position (collision fail, cheat detection), it pushes this to snap the client back. |
### 6.2 Object lifecycle
| Opcode | Dir | Notes |
|:------:|:---:|-------|
| 0xF745 CreateObject | S→C | Initial spawn of anything visible. 538-line parser in `Messages/CreateObject.cs`. |
| 0xF746 PlayerCreate | S→C | Special-case of CreateObject for our own player. Same body shape; different semantic. Used as the LoginComplete trigger. |
| 0xF747 ObjectDelete | S→C | `u32 guid, u16 seq`. **Unhandled** — this is the #1 "must add" gap after chat. Without it, stale entities pile up. |
| 0xF7DB UpdateObject | S→C | Full re-serialize. Used for gear/appearance changes, morphs. |
| 0xF625 ObjDescEvent | S→C | Visual-description-only re-send. Lighter than UpdateObject. |
| 0xF6EA ForceObjectDescSend | bi | Client can C→S to refresh; server sends S→C to force. |
### 6.3 Chat (primary channel)
| Opcode | Dir | Notes |
|:------:|:---:|-------|
| GA 0x0015 Talk | C→S | Local area say. |
| GA 0x005D Tell | C→S | Whisper by name. |
| GA 0x0032 TalkDirect | C→S | Whisper by target. |
| GA 0x0147 ChatChannel | C→S | Send to named channel. |
| GA 0x01DF Emote | C→S | /e text. |
| GM 0x02BB HearSpeech | S→C | Nearby chat. |
| GM 0x02BC HearRangedSpeech | S→C | Shouts. |
| GM 0x01E0 EmoteText | S→C | Environmental emote. |
| GE 0x02BD Tell | S→C | Whisper inbound. |
| GE 0x0147 ChannelBroadcast | S→C | Custom channel line. |
| GM 0xF7E0 ServerMessage | S→C | System chat. |
| GM 0xF7DE TurbineChat | bi | Turbine's social chat (General/Trade/LFG/Allegiance/Society). Nested binary blob — non-trivial parser. |
| GE 0x0295 SetTurbineChatChannels | S→C | Configure Turbine chat rooms on login. |
### 6.4 Combat
| Opcode | Dir | Notes |
|:------:|:---:|-------|
| GA 0x0008 TargetedMeleeAttack | C→S | `targetGuid, attackHeight, powerLevel`. |
| GA 0x000A TargetedMissileAttack | C→S | `targetGuid, attackHeight, accuracyLevel`. |
| GA 0x01B7 CancelAttack | C→S | — |
| GA 0x0053 ChangeCombatMode | C→S | Peace/Melee/Missile/Magic. |
| GA 0x01BF QueryHealth | C→S | Request health % for a target. |
| GE 0x01C0 UpdateHealth | S→C | Reply to QueryHealth; `f32 healthPercent`. |
| GE 0x01A7 AttackDone | S→C | End-of-swing. |
| GE 0x01AC VictimNotification | S→C | You took damage. |
| GE 0x01AD KillerNotification | S→C | You killed X. |
| GE 0x01B1 AttackerNotification | S→C | You did damage. |
| GE 0x01B2 DefenderNotification | S→C | You were attacked. |
| GE 0x01B3 EvasionAttackerNotification | S→C | Target dodged you. |
| GE 0x01B4 EvasionDefenderNotification | S→C | You dodged. |
| GE 0x01B8 CombatCommenceAttack | S→C | Swing-start flag. |
| GM 0x019E PlayerKilled | S→C | Fatal blow. |
### 6.5 Spells & enchantments
| Opcode | Dir | Notes |
|:------:|:---:|-------|
| GA 0x0048 CastUntargetedSpell | C→S | `u32 spellId`. |
| GA 0x004A CastTargetedSpell | C→S | `u32 targetGuid, u32 spellId`. |
| GA 0x01A8 RemoveSpellC2S | C→S | Un-learn a spell. |
| GA 0x01E3/0x01E4 Add/RemoveSpellFavorite | C→S | Hotbar slot management. |
| GE 0x02C1 MagicUpdateSpell | S→C | Add/refresh a spellbook entry. |
| GE 0x02C2 MagicUpdateEnchantment | S→C | Buff/debuff applied. |
| GE 0x02C3 MagicRemoveEnchantment | S→C | Buff expired/dispelled. |
| GE 0x02C4/0x02C5 MagicUpdate/RemoveMultiple | S→C | Batch variants. |
| GE 0x02C6 MagicPurgeEnchantments | S→C | Full clear. |
| GE 0x0312 MagicPurgeBadEnchantments | S→C | Clear debuffs only. |
| GE 0x02C7/0x02C8 MagicDispel[Multiple]Enchantment | S→C | Dispelled-by-player. |
| GE 0x01A8 MagicRemoveSpell | S→C | Spell removed from book. |
### 6.6 Inventory & items
| Opcode | Dir | Notes |
|:------:|:---:|-------|
| GA 0x0019 PutItemInContainer | C→S | `itemGuid, containerGuid, placement`. |
| GA 0x001A GetAndWieldItem | C→S | Pick up and equip in one step. |
| GA 0x001B DropItem | C→S | — |
| GA 0x0036 Use | C→S | — |
| GA 0x0035 UseWithTarget | C→S | — |
| GA 0x00C8 IdentifyObject | C→S | — |
| GA 0x0054/0x0055/0x0056 Stackable\* | C→S | Split / merge. |
| GA 0x005F/0x0060 Buy/Sell | C→S | — |
| GE 0x0022 InventoryPutObjInContainer | S→C | — |
| GE 0x0023 WieldObject | S→C | — |
| GE 0x019A InventoryPutObjectIn3D | S→C | Dropped to ground. |
| GE 0x0196 ViewContents | S→C | Container list. |
| GE 0x00C9 IdentifyObjectResponse | S→C | Appraise reply. |
| GE 0x0062 ApproachVendor | S→C | Vendor inventory. |
| GM 0xF74A PickupEvent | S→C | Pickup animation flag. |
| GM 0x0024 InventoryRemoveObject | S→C | — |
| GM 0x0197 SetStackSize | S→C | — |
| GM 0xF749 ParentEvent | S→C | Equip / attach. |
---
## 7. Frequency / priority
| Rank | Rate (approx, with 10 other players nearby) | Opcodes | Why |
|:----:|---------------------------------------------|---------|-----|
| **Very hot** | 2-5 Hz per player, per creature | UpdatePosition, UpdateMotion, AutonomousPosition (both directions) | Physics sync. Must not allocate on hot path. |
| **Hot** | ~0.5 Hz per player | VectorUpdate, MoveToState | Movement delta. |
| **Warm** | ~1 msg every few seconds | HearSpeech, ChannelBroadcast, Tell, EmoteText, UpdateHealth, attack notifications | Chat + combat feedback. |
| **Cool** | ~1 msg / min | ObjectCreate bursts on bubble enter, ObjectDelete on bubble exit, UpdateObject on gear swap | Bubble churn. |
| **Cold** | Once per session or event | CharacterList, DDD, LoginComplete, PlayerDescription, SetTurbineChatChannels, PlayerKilled, PlayerTeleport | Login + rare triggers. |
| **Background** | Driven by player action | Use, IdentifyObject, Buy, PingRequest, spell casting | Player explicit. |
Planning implication: the top two tiers (physics sync) must be
zero-allocation parses. acdream's current `CreateObject.TryParse` and
`UpdatePosition.TryParse` already are — they use `ReadOnlySpan<byte>`
and `BinaryPrimitives`. New parsers should match that pattern.
---
## 8. Gap analysis — what's missing for minimum viable play
Grouped by how much they unblock:
### 8.1 P0 / session health (must-have for any session to survive)
- **0xF747 ObjectDelete** — without this, stale entities pile up
forever. High impact, ~30-line parser, emit `EntityDespawned` event.
- **0xF752 AutonomyLevel (S→C)** — parse and store. ACE live sends
this regularly; ignoring it is fine today but we should not
crash on unknown payloads.
- **0xF7DC AccountBoot** — transition to `Failed` with the server's
reason string instead of a timeout.
- **0xF659 CharacterError** — surface login failures as errors
rather than a CharacterList timeout.
### 8.2 P1 / basic in-world feedback (first thing a user will miss)
- **Chat stack**: 0x02BB HearSpeech, 0x02BC HearRangedSpeech,
0x01E0 EmoteText, 0xF7E0 ServerMessage, 0xF7B0/0x0147
ChannelBroadcast, 0xF7B0/0x02BD Tell, 0xF7B0/0x02EB
CommunicationTransientString, 0xF7B0/0x0004 PopupString. Plus
outbound 0xF7B1/0x0015 Talk, /0x005D Tell, /0x0147 ChatChannel,
/0x01DF Emote.
- **Property updates (0x02CD..0x02EA)**: 22 opcodes, but they all
share a compact pattern — `u32 guid, u32 propId, value`. One
generic parser + 8 value-type branches covers everything. Fires
per-property events.
- **GameEvent envelope (0xF7B0)**: must ship the dispatcher skeleton
before any GameEvent can be handled.
- **Combat events (0x01AC..0x01B8)**: receiving only; one parser per
message. Lets floating combat text + hit indicators work.
- **0x02C2/0x02C4 Magic[Multiple]UpdateEnchantment + 0x02C3/0x02C5
remove**: buff/debuff HUD.
- **0xF625 ObjDescEvent** + **0xF7DB UpdateObject**: for seeing
other players change gear. Critical for multiplayer immersion.
- **GameEvent PlayerDescription (0x0013)** — initial character
sheet. Gate for inventory/spellbook UI. Biggest message in the
protocol — ~30 KB of interleaved property tables.
### 8.3 P2 / inventory, assess, vendor
- 0x00C9 IdentifyObjectResponse + GA 0x00C8 IdentifyObject.
- Full inventory flow: GA 0x0019, 0x001A, 0x001B, 0x0054-0x0056,
and the mirror GameEvents 0x0022, 0x0023, 0x019A, 0x0196.
- GE 0x0062 ApproachVendor + GA 0x005F Buy / 0x0060 Sell.
- GA 0x0036 Use + 0x0035 UseWithTarget + GE 0x01C7 UseDone.
### 8.4 P3 / social, housing, allegiance
Large opcode count but highly modular — each subsystem is ~15-20
opcodes. Skip until post-beta.
### 8.5 P4 / admin, minigames, barber, char-create
Zero visibility impact for combat-focused play. Park.
---
## 9. Port plan — where the WorldSession dispatcher needs to expand
### 9.1 Refactor the dispatcher skeleton first (phase D prerequisite)
Today `WorldSession.ProcessDatagram` is a long `if/else` ladder. Before
adding more handlers, split it into a dispatcher class. Layout:
```
AcDream.Core.Net/
├── Messages/
│ ├── GameMessageDispatcher.cs // top-level opcode → parser router
│ ├── GameEventDispatcher.cs // inside 0xF7B0
│ ├── GameActionBuilder.cs // fluent builder for 0xF7B1 envelope
│ └── <one file per opcode group>
└── WorldSession.cs // becomes thin, delegates to above
```
Each dispatcher exposes `register<T>(opcode, parser)` so phase D can
bolt on new handlers without modifying the dispatcher class body.
### 9.2 Phase D — chat + session health
Scope:
- Add the GameEvent envelope dispatcher (0xF7B0).
- Ship ObjectDelete (0xF747), AutonomyLevel (0xF752), AccountBoot
(0xF7DC), CharacterError (0xF659).
- Ship the chat group (11 opcodes) + outbound Talk/Tell/ChatChannel.
- Ship PopupString, CommunicationTransientString, SetTurbineChatChannels
as UI sidechannels.
- Add an `IChatSurface` event set on WorldSession: `Said`, `Whispered`,
`ChannelMessage`, `SystemMessage`, `Popup`, `Transient`.
Acceptance: log in, receive and post chat, see server announcements,
get kicked gracefully.
### 9.3 Phase E — property & stats
Scope:
- Ship the 22 property-update opcodes behind one generic
`PropertyUpdateParser` keyed on PropertyId.
- Ship PlayerDescription (GE 0x0013) — the single biggest parser job.
- Ship UpdateHealth (GE 0x01C0), QueryHealth (GA 0x01BF).
- Ship ObjDescEvent (0xF625), UpdateObject (0xF7DB).
- Expose `IPlayer` interface that plugins can observe:
`Attributes, Skills, Vitals, Inventory, Spellbook`.
Acceptance: stat sheet renders correctly, health bars update live,
can assess nearby NPCs.
### 9.4 Phase F — combat + spells
Scope:
- Ship combat notifications (0x01AC..0x01B8).
- Ship attack actions (GA 0x0008, 0x000A, 0x01B7, 0x0053).
- Ship enchantments (GE 0x02C1..0x02C8, 0x0312).
- Ship cast actions (GA 0x0048, 0x004A).
Acceptance: can kill a lurker, see float text + health, land a
self-buff and watch it tick.
### 9.5 Phase G — inventory
Scope:
- Ship the GA/GE inventory families.
- Ship ApproachVendor + Buy/Sell.
- Ship PickupEvent + ParentEvent.
Acceptance: loot a drudge, sell to vendor, equip a weapon, see it
render on the avatar.
### 9.6 Deferred: housing, allegiance, fellowship, books, barber, chess
All the P3/P4 tails. Ship when we need them, not before.
---
## 10. Collision + sub-type notes (the ultrathink)
- **0xF643 CharacterCreateResponse vs CharacterRestoreResponse.** Same
opcode, two meanings. Disambiguated entirely by session state —
if the client just sent 0xF656 CharacterCreate the reply is a create
response; if it just sent 0xF7D9 CharacterRestore it's a restore
response. Parsers must key on context, not opcode. ACE has a
duplicate entry in the enum (`CharacterRestoreResponse = 0xF643,
// This is a duplicate...`). Holtburger keeps them combined under
`CharacterCreateResponse`.
- **0xF74C: MovementEvent and Motion are the same opcode.** ACE
aliases `Motion = 0xF74C` and `MovementEvent = 0xF74C` — semantically
identical, just two names. Our `UpdateMotion.cs` already handles this.
- **0xF753 AutonomousPosition goes both ways** with the **same body
layout** but different semantics. Outbound = "I'm still here";
inbound = "no you're not, here's where you really are." Parser can
be shared; the client just needs to know direction to choose the
right side-effect (`PositionUpdated` vs `ForcedResync`).
- **0x0147 ChatChannel vs ChannelBroadcast.** C→S uses 0x0147 inside
GameAction (envelope 0xF7B1); S→C uses 0x0147 inside GameEvent
(envelope 0xF7B0). Same wire opcode, different parser because
different envelope. This is true for many ID collisions — the
envelope disambiguates them.
- **0x0148/0x0149 Channel List/Index** — same C→S/S→C pairing pattern.
- **0x00A3/0x00A4 FellowshipQuit/Dismiss** — same pairing; C→S in
GameAction, S→C in GameEvent.
- **0xF653 CharacterLogOff** — bi-directional, same opcode, no
envelope. Client sends to request logout; server sends the echo
before Disconnect.
- **0x01A8 MagicRemoveSpell** — C→S is GameAction, S→C is GameEvent.
- **0xF7DE TurbineChat** — bi-directional GameMessage. Blob layout
includes a `ChatNetworkBlobType` discriminant (EVENT_BINARY=1,
REQUEST_BINARY=3, RESPONSE_BINARY=5) + a `ChatNetworkBlobDispatchType`
sub-discriminant (SendToRoomByID=1/2). Effectively two opcodes
(event vs request/response) multiplexed on one wire code. Nested
"bytes to follow" size prefixes must be back-patched after payload
writes — non-trivial parser.
- **0xF6EA ForceObjectDescSend** — bi-directional with identical
`u32 guid` body. The side-effect differs: server takes it as "resend
to me"; client takes it as "apply a fresh ObjDescEvent I'm about to
receive."
---
## 11. Source of truth ranking (per opcode category)
When the references disagree, use this priority:
- **PacketHeader + Flags + encryption**: ACE and holtburger agree on
all bits. No disputes.
- **C→S GameAction opcodes**: holtburger's `opcodes.rs` is the most
curated list of what a real client actually sends. ACE's
`GameActionType.cs` is the full universe including things only
legacy admin tools ever sent. Use holtburger when in doubt about
which GAs are relevant; use ACE when you need the wire layout of
a specific handler.
- **S→C GameMessage opcodes**: ACE's `GameMessageOpcode.cs` is the
canonical enum; Chorizite's XML adds field-level comments that
are sometimes more accurate than ACE's implementation (remember
"If it is 0, it defaults to 256*8"). Use ACE for structure; use
Chorizite to cross-check specific field semantics.
- **S→C GameEvent opcodes**: ACE's `GameEventType.cs` + the per-event
classes in `Events/` are the wire ground truth.
- **RawMotionState wire**: holtburger `types.rs` is the only source
that documents every flag bit clearly; ACE has the same structure
but embedded in `MovementData.Pack`.
- **PositionPack (for UpdatePosition)**: ACE's `PositionPack.Write` is
the canonical serializer; holtburger matches bit-for-bit.
- **TurbineChat nested blob**: ACE's `GameMessageTurbineChat` has a
large comment block documenting every field layout. Holtburger's
implementation matches.
---
## 12. Quick-reference: what to add first if you have one afternoon
Pick **four** opcodes from the P0/P1 tier and you have a meaningfully
more useful client by end-of-day:
1. **0xF747 ObjectDelete** — stop leaking entities (20 lines).
2. **0xF7B0 GameEvent envelope skeleton** — unlocks the whole event
category (50 lines dispatcher).
3. **0x02BB HearSpeech** + **0x02BC HearRangedSpeech** — see world
chat (same parser, different trigger; 30 lines each).
4. **0xF7E0 ServerMessage** — see system chat (20 lines).
That's ~150 lines of parser code and acdream graduates from "renders
the world" to "reads the world's feedback."

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,993 @@
# R11 — Allegiance System Deep Dive
**Scope:** patron / vassal tree, swearing + breaking, XP pass-up, rank,
chat channels (@a / @v / @p / @m / @c), MOTD, allegiance houses, officer
permissions, wire messages, UI panel.
**Authorities (in order of trust, per CLAUDE.md hierarchy):**
- **Retail acclient.exe decompiled**`docs/research/decompiled/chunk_00570000.c`,
`chunk_00560000.c`, `chunk_005B0000.c`, `chunk_005D0000.c`,
`chunk_00580000.c`, `chunk_006B0000.c`. Client-side strings,
channel bitmasks, error paths, UI hooks.
- **ACE server**`references/ACE/Source/ACE.Server/Entity/AllegianceNode.cs`,
`AllegianceRank.cs`, `Managers/AllegianceManager.cs`,
`WorldObjects/Allegiance.cs`, `WorldObjects/Player_Allegiance.cs`,
`Network/GameEvent/Events/Allegiance*.cs`,
`Network/Structure/AllegianceData.cs`,
`Network/Structure/AllegianceHierarchy.cs`,
`Network/Structure/AllegianceProfile.cs`,
`Network/Enum/AllegianceIndex.cs`. Full server-side implementation of
the system, plus the exact wire-format writer.
- **Asheron Wikia / Fandom wiki** — cited inline in AllegianceManager.cs
(ranking, XP passup formulas). This is the documented retail behavior
ACE codes against.
acdream is a **client**, so our primary job is:
1. Decode inbound `AllegianceUpdate` / `AllegianceInfoResponse` packets
into an in-memory allegiance tree.
2. Render the tree as a UI panel.
3. Construct and send the outbound GameActions
(`SwearAllegiance`, `BreakAllegiance`, `AllegianceInfoRequest`,
officer commands, MOTD commands, etc).
4. Handle the five allegiance-related chat channels correctly.
We do **not** compute pass-up XP — that is server-authoritative. But we
document the formula anyway so the acdream server-adjacent code (mock
server for testing, plugin API exposing it) can match retail.
---
## 1. Tree structure
### 1.1 Data model
From `AllegianceNode.cs`:
```
class AllegianceNode {
ObjectGuid PlayerGuid; // this node's player
AllegianceNode Monarch; // top of tree (self for monarch)
AllegianceNode Patron; // direct parent; null for monarch
Dictionary<uint, AllegianceNode> Vassals; // direct children
uint Rank; // 0-10
bool IsMonarch => Patron == null;
}
```
Every player in an allegiance has exactly:
- **one monarch** (root of the tree; `MonarchId` player property)
- **one patron** (direct parent; `PatronId` player property; null for
monarch)
- **0 to 11 direct vassals** (children; capped, see below)
The tree is reconstructed each time any member swears or breaks by
walking every online/offline player who reports the same `MonarchId` and
threading the `PatronId -> Vassals` links. See
`AllegianceManager.Rebuild` and `Allegiance.BuildPatronVassals`.
### 1.2 Breadth cap
**Max direct vassals: 11.** From `Player_Allegiance.cs::IsPledgable`:
```csharp
if (targetNode.TotalVassals >= 11) {
Session.Network.EnqueueSend(new GameMessageSystemChat(
$"{target.Name} already has the maximum # of vassals",
ChatMessageType.Broadcast));
return false;
}
```
This corresponds to retail error 0x416 seen in the decompiled client:
`L"%s cannot have any more Vassals"`.
### 1.3 Depth cap
**There is no hard-coded depth cap** in either the retail client strings
or ACE's enforcement. The effective cap comes from `Rank`, which is
clamped at 10 (see §4). Practically, once ranks get deep enough the
tree can keep extending downward indefinitely; the rank value just
stops climbing.
### 1.4 Loop prevention
A monarch cannot swear back into their own subtree:
```csharp
if (selfNode != null && selfNode.IsMonarch) {
if (selfNode.PlayerGuid == targetNode.Monarch.PlayerGuid) {
// refuse
}
}
```
This is the only cycle guard — and it only fires when the self side is
currently a monarch. It prevents A->B->A directly, but the same check is
implicitly satisfied elsewhere because once you are a vassal you cannot
swear at all (§2.3).
### 1.5 Counts
- `TotalVassals` = count of direct children.
- `TotalFollowers` = recursive sum (vassals of vassals…). Used for the
wire-level `totalVassals` field in the allegiance profile, and for
rank display counts.
- `TotalMembers` = from the allegiance root, full tree size.
---
## 2. Swearing and breaking
### 2.1 Swear flow (client -> server)
**Opcode:** `GameActionType.SwearAllegiance = 0x001D`.
The client sends a single 4-byte payload: the target patron's guid.
```
struct SwearAllegianceRequest {
uint32_t targetPatronGuid;
};
```
Server-side handler (`Player_Allegiance.cs::HandleActionSwearAllegiance`):
1. Look up target. Must be online.
2. Run `IsPledgable(patron)`. Checks (in order):
- Self not Olthoi, target not Olthoi.
- Target hasn't set `CharacterOption.IgnoreAllegianceRequests` (which
is "@allegiance ignore" in retail).
- Self doesn't already have a patron (`PatronId != null`).
- Target is not self.
- Target has < 11 direct vassals.
- Self is not already in target's allegiance tree (no loops).
- If allegiance is locked: either self is in `ApprovedVassals`,
or target is a Castellan+ officer.
- Self is not in target's ban list.
- Self does not own a monarch-only house (mansion). If they do:
error 0x??? via `WeenieError.CannotSwearAllegianceWhileOwningMansion`.
3. Create a `MoveToChain` toward the patron at `Allegiance_MaxSwearDistance
= 2.0f` (retail). ACE has 4.0 commented out — retail is the tighter
2.0 meters. After the move succeeds, run `SwearAllegiance(...)`.
4. `SwearAllegiance`:
- Send a `Confirmation_SwearAllegiance` to the target first. Target
must click yes.
- On confirmation: set `PatronId`, walk up to get `MonarchId`, set
both as `PropertyInstanceId.Monarch`.
- Set `ExistedBeforeAllegianceXpChanges` iff patron's level >= own
level (only equal-or-higher patrons can pass XP up in the post-2004
system — see §3.2).
- If self was previously a monarch with vassals, walk the subtree and
update every child's `MonarchId` to the new monarch. Clear
officers in the old allegiance (`HandleMonarchSwear`).
- Broadcast `Motion_Kneel` (kneeling animation).
- Rebuild the allegiance tree (`AllegianceManager.OnSwearAllegiance`).
- Reset `AllegianceXPGenerated = 0`, `AllegianceOfficerRank = null`.
- Send `GameEventAllegianceUpdate` + `GameEventAllegianceAllegianceUpdateDone`.
- Join Turbine chat channel "Allegiance" if the option is enabled.
### 2.2 Cooldown?
**There is no 24-hour cooldown on swearing itself.** Looking at both
ACE and the decompiled strings:
- ACE: `HandleActionSwearAllegiance``IsPledgable``SwearAllegiance`.
No timestamp check.
- Decompiled: error set 0x40B-0x416 covers the swear failure modes
(already sworn, not enough XP, not in allegiance, already maxed
vassals). No cooldown error.
There IS a 24-hour cooldown on **allegiance name changes** (see §7.3).
The "break once a day" player folklore is not reflected in the code;
the only cost to break is a generic 3-second animation. Players
frequently re-swore the same day, and the client/server permit it.
The wikia does mention a "1 hour cooldown" on `/allegiance house open`
after closing. Searching ACE, I don't see this enforced explicitly.
### 2.3 Break flow (client -> server)
**Opcode:** `GameActionType.BreakAllegiance = 0x001E`.
Payload is again a single uint32 guid (the patron OR vassal you're
breaking from). Handler
(`Player_Allegiance.cs::HandleActionBreakAllegiance`):
1. `IsBreakable(targetGuid)` — target exists and is either self's
patron (`PatronId == target.Guid.Full`) or one of self's vassals
(`target.PatronId == Guid.Full`). Anything else: silent refuse.
2. If breaking from vassal:
- Target's `PatronId = null`.
- Target's `MonarchId` becomes self OR null depending on whether
target has vassals themselves (new fragment root vs solo player).
- Walk target's subtree and reset every descendant's `MonarchId` to
the target.
3. If breaking from patron:
- Self's `PatronId = null`, `MonarchId = null`.
- Walk self's subtree and reset every descendant's `MonarchId` to
self (self is the new fragment monarch).
4. Messages:
- To target: `"{Name} has broken their Allegiance to you!"`
- To self: `"You have broken your Allegiance to {target.Name}!"`
5. Rebuild both allegiances.
6. Check the allegiance house — boot self and/or target if they lost
mansion access (`CheckAllegianceHouse`).
7. Send `GameEventAllegianceUpdate` + done.
### 2.4 Involuntary separation
- **Boot** (`GameActionType.BreakAllegianceBoot = 0x0277`,
Seneschal+ required): remove one member + their subtree.
Error strings include
`L"Your Allegiance has been dissolved!\n"` and
`L"Your patron\'s Allegiance to you has been broken!\n"` (errors
0x??? and 0x??? per decompiled chunk_00570000 lines 1944-1948).
- **Ban** (`AddAllegianceBan = 0x02A1`): prevents future swearing and
kicks if already a member.
- **Player delete**: `AllegianceManager.HandlePlayerDelete` collapses a
patron out of the tree; vassals each become their own new monarch.
---
## 3. XP pass-up formula
This is the most complex part of the system. From
`AllegianceManager.cs` (quoted verbatim) + wiki references:
### 3.1 Two epochs
**Pre-patch (before January 12 2004):** Used a complex formula based on
the `Self` attribute, loyalty/leadership caps, with an effective Loyalty
of 175 being enough to max patron-to-grandpatron passup. Only kill XP
passed up. **ACE does not implement this** and acdream doesn't need to.
**Post-patch (2004 onward, extended October 2009 to all XP):** Simpler
formula, decoupled from `Self`. This is what retail ran during its
final years and what ACE emulates.
### 3.2 Eligibility gate — `ExistedBeforeAllegianceXpChanges`
This flag is confusingly named — it's really "can this vassal pass XP
up?" From `Player_Allegiance.cs` line 105:
```csharp
ExistedBeforeAllegianceXpChanges = (patron.Level ?? 1) >= (Level ?? 1);
```
At swear time, if your **patron's level is less than yours**, you are
flagged as not passing XP. The flag flips on when your patron levels
above you (`AllegianceNode.OnLevelUp`). This prevents a low-level player
from being used as a "dummy patron" above a high-level farmer.
If this flag is false, **no XP passes up from that vassal at all**
(`DoPassXP` early-outs before any calculation).
### 3.3 The formula
From the comment block in `AllegianceManager.cs` (verbatim, cited to
http://asheron.wikia.com/wiki/XP_Passup, "Xerxes of Thistledown, four
months of testing"):
```
Generated% = 50.0 + 22.5 * (Loyalty / 291) * (1.0 + (RT / 730) * (IG / 720))
Received% = 50.0 + 22.5 * (Leadership / 291) * (1.0 + V * (RT2 / 730) * (IG2 / 720))
Passup% = Generated% * Received% / 100
```
Where:
| Variable | Meaning | Cap |
|--------------|--------------------------------------------------|------|
| Loyalty | Buffed Loyalty skill on vassal | 291 |
| Leadership | Buffed Leadership skill on patron | 291 |
| RT | Real-world days vassal has been sworn to patron | 730 |
| IG | In-game hours vassal has been sworn to patron | 720 |
| RT2 | Avg real days patron's vassals sworn to patron | 730 |
| IG2 | Avg in-game hours patron's vassals sworn | 720 |
| V | Vassal-count factor: 1→0.25, 2→0.50, 3→0.75, 4+→1.00 | 1.00 |
Ranges:
- **Generated%** from 50% (new swear, zero loyalty) up to ~90% (long-term,
maxed loyalty). This is the fraction deducted from the vassal's
**earned** XP — not literally taken from them, but computed for the
passup calculation.
- **Received%** from 50% up to ~90% similarly.
- **Passup%** from 25% to 90% of the vassal's earned XP.
### 3.4 Recursion (ultrathink — this is the "fraction scales with depth")
**Grandpatron pass-up.** When vassal V earns N XP, patron P gets
`N * Passup%(V,P)`. Then P's patron (grandpatron GP) gets a share of
**what P received**, not of the original N.
But the recursion uses different constants:
```csharp
private static void DoPassXP(vassalNode, amount, bool direct)
{
...
var factor1 = direct ? 50.0f : 16.0f;
var factor2 = direct ? 22.5f : 8.0f;
...
// recursive call with direct = false
DoPassXP(patronNode, passupAmount, false);
}
```
So the grandpatron formula is:
```
Generated_indirect% = 16 + 8 * (Loyalty / 291) * (1.0 + (RT / 730) * (IG / 720))
Received_indirect% = 16 + 8 * (Leadership / 291) * (1.0 + V * (RT2 / 730) * (IG2 / 720))
Passup_indirect% = Generated_indirect% * Received_indirect% / 100
```
Caps: Generated% from 16% to 24%, Received% likewise. So
**Passup_indirect%** is bounded roughly from `0.16 * 0.16 = 2.56%` up to
`0.24 * 0.24 = 5.76%` in the "direct"-scale interpretation. Against the
wiki's stated 0%10% range, that checks out (the wiki's 10% max is
the "max leadership + max time" scenario, matching 0.24²·~1.73 = ~10%
when you account for the full time/vassal multiplier being close to 2).
This **fraction scales per generation**: a tier-3 passup is
`Direct * Indirect ≈ 0.9 * 0.1 = 9%` at maximum, or
`0.25 * 0 = 0%` at minimum. Deep trees don't feed the monarch much in
practice — the interesting XP is at direct patron level, which is why
retail players optimized around 2-3 deep at most.
The recursion terminates when `patronNode == null` (i.e., we hit the
monarch, who has no patron).
### 3.5 Earning side
When a vassal gains XP (via `GrantXP`), the amount is passed to
`AllegianceManager.PassXP(vassalNode, amount, direct=true)`:
- `generated` fraction is added to vassal's `AllegianceXPGenerated`
(tracked for player "tithed" stat).
- `passup` fraction is added to patron's `AllegianceXPCached`.
- If patron is online: they immediately redeem the cached XP via
`AddAllegianceXP()` (which grants it with `XpType.Allegiance`).
- If patron is offline: XP accumulates and is granted on next login
(with login message "Your Vassals have produced experience points
for you…").
### 3.6 ACE simplifications
ACE's implementation currently uses **capped constants** for the time
modifiers:
```csharp
var timeReal = Math.Min(RealCap, RealCap); // = RealCap
var timeGame = Math.Min(GameCap, GameCap); // = GameCap
```
i.e., it always treats every vassal as "maxed time". This is a known
simplification; to fully match retail, the server needs to track
`TimeSwornToPatron` per vassal and compute actual RT/IG values. For
our client work this doesn't matter — **we don't compute passup**, we
just read `AllegianceXPCached`, `AllegianceXPGenerated`,
`AllegianceXPReceived` from packets.
---
## 4. Rank
From `AllegianceNode.cs::CalculateRank` (cited to
http://asheron.wikia.com/wiki/Rank):
> A player's allegiance rank is a function of the number of Vassals and
> how they are organized. First, take the two highest ranked vassals.
> Now the Patron's rank will either be one higher than the lower of the
> two, or equal to the highest rank vassal, whichever is greater.
```csharp
var sortedVassals = Vassals.Values.OrderByDescending(v => v.Rank).ToList();
var r1 = sortedVassals.Count > 0 ? sortedVassals[0].Rank : 0;
var r2 = sortedVassals.Count > 1 ? sortedVassals[1].Rank : 0;
var lower = Math.Min(r1, r2);
var higher = Math.Max(r1, r2);
Rank = Math.Min(10, Math.Max(lower + 1, higher));
```
- **Starting rank:** 0 (or 1 depending on interpretation — a lone
sworn member with no vassals gets rank 0 here, and `AllegianceTitle`
returns `""` for rank 0; rank 1 kicks in when you have one vassal).
- **Max rank:** 10 (the "High King" / "Aulin" / "Tah" tier).
- Rank 9 requires a "Fibonacci-like" branching structure — two vassals
of rank 8 each, or one rank 9 + one rank 8.
### 4.1 Rank progression table
Approximate minimum-tree sizes to reach each rank:
| Rank | Tree structure | Name (Aluvian Male) |
|------|---------------------------------------------|-----------------------|
| 0 | solo / brand new (no vassals) | — |
| 1 | 1 direct vassal | Yeoman |
| 2 | 2 vassals at rank 1 | Baronet |
| 3 | 2 vassals at rank 2 | Baron |
| 4 | 2 vassals at rank 3 | Reeve |
| 5 | 2 vassals at rank 4 | Thane |
| 6 | 2 vassals at rank 5 | Ealdor |
| 7 | 2 vassals at rank 6 | Duke |
| 8 | 2 vassals at rank 7 | Aetheling |
| 9 | 2 vassals at rank 8 | King |
| 10 | 2 vassals at rank 9 | High King |
With an 11-vassal cap, each rank level roughly doubles the tree size
minimum, so rank 10 requires a ~`2^10 = 1024` member tree at minimum
(ignoring the "higher" branch).
### 4.2 Heritage-gendered titles
Each of **9 heritages × 2 genders = ~18 title tables**, each with 10
rank strings. See `AllegianceTitle.cs` lines 80-384 for the full table
(Aluvian, Gharundim, Sho, Viamontian, Shadowbound/Penumbraen,
Tumerok (gender-neutral), Gearknight (gender-neutral), Lugian
(gender-neutral), Empyrean, Undead).
acdream will copy this table **byte-for-byte** — these are the exact
strings retail displays on the allegiance panel and in tells/broadcasts.
---
## 5. Chat channels
Five logical channels, visible through distinct prefixes in the retail
client. Searched `chunk_00570000.c` for the exact prefix strings:
| Logical | Client prefix (from decompile) | ChatMessageType flag | Command |
|---------------|-------------------------------------------|----------------------|------------|
| Allegiance | `"[Allegiance Broadcast] You say, \""` | `0x2000000` | @a / broadcast |
| Co-Vassals | `"[Co-Vassals] You say, \""` | `0x1000000` | @c |
| Patron tell | `"Your patron <Tell:...> says to you, \""`| `0x1000` | @p |
| Vassal tell | `"Your vassal <Tell:...> says to you, \""`| `0x2000` | @v |
| Follower tell | `"Your follower <Tell:...> says to you, \""`| `0x4000` | @m (monarch) |
From `chunk_00570000.c` around line 700-810, the client dispatches chat
input by comparing `piVar4` (channel mask) to these hex values.
Additionally `chunk_006B0000.c::FUN_006b1460` maps the string
`"Allegiance"` to ChatMessageType **0x12** (18). That's the server-side
enum value; the `0x1000000` etc. above are per-send client-side flag
bits used to choose a prefix.
### 5.1 Channel permissions
- **@v (vassal tell)**: send to one specific direct vassal. Only works
if target is currently your vassal.
- **@p (patron tell)**: send to your patron. Only works while you have
a patron.
- **@m (monarch tell)**: send to your monarch. `@mr` replies to last
`@m` "only works for monarchs" — i.e., the monarch's receive side is
special because many vassals can @m them.
- **@c (co-vassals)**: broadcast to all peers with the same direct
patron as you. Decompiled at `chunk_00570000.c:707`.
- **@a / allegiance broadcast**: whole allegiance tree. Subject to
gag/boot filters. The decompiled strings at line 2991-3005 cover
the gag/un-gag notifications.
### 5.2 Gag / boot
- `HandleActionAllegianceChatGag` / Boot: Speaker+ permission.
- `ChatFilters: Dictionary<ObjectGuid, DateTime>` — timestamp of when
the filter expires. `DateTime.MaxValue` means permanent boot.
- `Allegiance.IsFiltered(playerGuid)` checks and auto-removes expired
filters.
- Gag duration: `Player.AllegianceChat_GagTime = TimeSpan.FromMinutes(5)`.
### 5.3 "Listen to allegiance chat" option
`CharacterOption.ListenToAllegianceChat` — if set, player joins the
turbine chat channel "Allegiance" on swear and login, leaves on break.
This is how cross-landblock allegiance chat works retail-side (Turbine
chat room backplane).
---
## 6. Allegiance house / mansion
Mansions (and some villas) can be flagged `HouseRequiresMonarch` — only
rank-N+ monarchs can purchase them, and they function as allegiance
houses where vassals gain access.
### 6.1 Purchase restrictions
From the decompiled client (`chunk_00570000.c:1516`):
- `L"You must be a monarch to purchase this dwelling.\n"` (error 0x48a)
- `L"You must be above level %s to purchase this dwelling.\n"` (0x488)
- `L"You must be at or below level %s to purchase this dwelling.\n"` (0x489)
- `L"You must be above allegiance rank %s to purchase this dwelling.\n"` (~0x476)
- `L"You must be at or below allegiance rank %s to purchase this dwelling.\n"` (~0x477)
These are the 5 gates: monarch-status, level-min, level-max, rank-min,
rank-max. Level/rank thresholds come from the SlumLord weenie.
### 6.2 Allegiance access
Action enum from `AllegianceHouseAction.cs`:
```csharp
enum AllegianceHouseAction : uint {
Undef = 0,
Help = 1,
CheckStatus = 1, // "@allegiance house help" is alias for check
GuestOpen = 2,
GuestClose = 3,
StorageOpen = 4,
StorageClose = 5,
}
```
Only Castellan+ can toggle (`AllegiancePermissionLevel.Castellan`).
### 6.3 Sanctuary / hometown recall
Monarch can set a bindstone for the whole allegiance. Stored as
`Allegiance.Sanctuary` (a `Position`). Vassals recall via
`GameActionType.RecallAllegianceHometown = 0x02AB`, which fires
`MotionCommand.AllegianceHometownRecall` animation and teleports.
### 6.4 Booting members
When a player swears away, breaks, or gets booted from allegiance, any
mansion access they had is revoked (`Player.CheckAllegianceHouse`).
---
## 7. MOTD and allegiance name
### 7.1 MOTD
Stored on the `Allegiance` worldobject as `AllegianceMotd` +
`AllegianceMotdSetBy` (string properties). On each login (after a 3s
delay) the client receives it as a system chat message:
```
"\"{motd}\" -- {setBy}"
```
- Set: `HandleActionSetMotd` (Speaker+)
- Clear: `HandleActionClearMotd` (Speaker+)
- Query: `HandleActionQueryMotd` (anyone in allegiance)
- Server sends via `GameMessageSystemChat`, not via `GameEventAllegiance*`.
Decompiled client error strings around line 2068 include
`L"Please use the allegiance panel to view your own information."`,
meaning the client recognizes an allegiance-info request against self
and redirects to the panel.
### 7.2 Allegiance name
`AllegianceName` (string property on the Allegiance object). Castellan+
only. Validation rules (per decompiled error strings 2883-2912):
- Not empty (command is `@allegiance name clear` for that).
- Max 40 chars.
- Only letters, spaces, `-`, `'`.
- Not in banned-words list from `portal.dat`.
- Not duplicate of another allegiance's name.
- Change cooldown: **once every 24 hours**
(`L"You may only change your allegiance name once every 24 hours. You may change your allegiance name again in %s.\n"`).
### 7.3 Officer titles
Rank 1/2/3 → Speaker/Seneschal/Castellan by default. Castellans can
rename them via `SetAllegianceOfficerTitle` (rank: 1-3, string title).
---
## 8. Officer permissions
From `Player_Allegiance.cs` lines 1478-1517 and `AllegiancePermissionLevel.cs`:
| Level (enum) | Numeric | Powers |
|------------------|---------|-----------------------------------------------------------------|
| None | 0 | member, no powers |
| Speaker | 1 | allegiance chat kick/gag; allegiance broadcast; set/clear MOTD |
| Seneschal | 2 | promote/demote Speakers; boot; ban; allegiance info; lock/unlock |
| Castellan | 3 | promote/demote any rank; rename titles; set allegiance name; bindstone; mansion permissions; bypass lock with approved vassals |
| Monarch | 4 | all of the above; clear all officers; everything |
`AllegianceOfficerLevel` (wire enum, uint32): `Undef=0, Speaker=1,
Seneschal=2, Castellan=3`. Monarch is implicit (Monarch doesn't have an
officer rank; they're the root).
Promote: `SetAllegianceOfficer(playerName, level)` — Seneschal+ (with
caveat Seneschal can only promote to rank 1).
Officer count limits: seen in decompile line 2738
`L"You already have the maximum number of allegiance officers. You must remove some before you add any more.\n"`.
ACE doesn't implement a cap. Retail may have had one (~12?) but it's
not enforced in ACE and no hard number appears in strings.
---
## 9. Wire messages
### 9.1 Server -> client events
From `GameEventType.cs`:
| Event | Opcode | Sent when |
|-----------------------------------|---------|--------------------------------------------------|
| `AllegianceUpdateAborted` | 0x0003 | Operation cancelled (e.g., target declined swear)|
| `AllegianceUpdate` | 0x0020 | Full tree refresh (panel open, swear, break) |
| `AllegianceAllegianceUpdateDone` | 0x01C8 | End-of-stream marker after AllegianceUpdate |
| `AllegianceLoginNotification` | 0x027A | Ally logged in/out (if listen option on) |
| `AllegianceInfoResponse` | 0x027C | Reply to AllegianceInfoRequest |
### 9.2 `AllegianceUpdate` payload
From `GameEventAllegianceUpdate.cs` + `AllegianceProfile` +
`AllegianceHierarchy` + `AllegianceData`:
```
GameEventAllegianceUpdate {
uint32_t rank; // receiving player's rank
AllegianceProfile prof; // tree
}
AllegianceProfile {
uint32_t totalMembers; // whole allegiance (monarch + all followers)
uint32_t totalVassals; // self's direct+indirect followers
AllegianceHierarchy hierarchy;
}
AllegianceHierarchy {
uint16_t recordCount; // number of ALL tree entries sent
uint16_t oldVersion; // 0x000B (latest / only supported)
PHashTable<Guid, uint32> officers; // empty in retail, present for parser compat
uint32_t officerTitleCount;
WideString[] officerTitles;
uint32_t monarchBroadcastTime;
uint32_t monarchBroadcastsToday;
uint32_t spokesBroadcastTime;
uint32_t spokesBroadcastsToday;
WideString motd; // EMPTY in ACE to avoid decal parse bugs; retail sent it
WideString motdSetBy; // EMPTY in ACE
uint32_t chatRoomID; // allegiance biota ID (chat channel)
Position bindPoint; // sanctuary/bindstone (cell + pos + rot)
WideString allegianceName;
uint32_t nameLastSetTime; // "counts upward for some reason"
uint32_t isLocked; // 0 or 1
int32_t approvedVassal; // legacy field (always 0?)
AllegianceData monarchData; // first record: the monarch
(Guid treeParent, AllegianceData)[recordCount-1] records;
}
AllegianceData {
uint32_t characterID;
uint32_t cpCached; // AllegianceXPCached clamped to uint32 max
uint32_t cpTithed; // AllegianceXPGenerated
uint32_t bitfield; // AllegianceIndex flags
uint8_t gender;
uint8_t heritage;
uint16_t rank;
// if HasPackedLevel(0x8):
uint32_t level;
uint16_t loyalty;
uint16_t leadership;
// if HasAllegianceAge(0x4):
uint32_t timeOnline;
uint32_t allegianceAge;
// else:
uint64_t uTimeOnline;
WideString name;
}
```
Per `AllegianceHierarchy.cs` comments (aclogview-derived):
- record 0 = monarch (no treeParent guid prefix, sent inline)
- record 1 = self's patron (treeParent = monarch)
- record 2 = self (treeParent = patron)
- record 3+ = self's direct vassals (treeParent = self)
This is a **compact slice** centered on the receiving player — NOT the
whole tree. To show the whole tree, the client aggregates successive
updates by walking up/down via `AllegianceInfoRequest`.
### 9.3 Bitfield (`AllegianceIndex`)
```csharp
[Flags] enum AllegianceIndex : uint {
Undefined = 0x00,
LoggedIn = 0x01,
Update = 0x02,
HasAllegianceAge = 0x04,
HasPackedLevel = 0x08,
MayPassupExperience = 0x10,
}
```
Sent with `HasAllegianceAge | HasPackedLevel` baseline; `LoggedIn`
added if player is online; `MayPassupExperience` for non-monarch
vassals with `ExistedBeforeAllegianceXpChanges` flag.
### 9.4 Client -> server actions
From `GameActionType.cs`:
| GameAction | Opcode | Payload |
|------------------------------------------|---------|--------------------------------|
| `SwearAllegiance` | 0x001D | uint32 patronGuid |
| `BreakAllegiance` | 0x001E | uint32 targetGuid |
| `AllegianceUpdateRequest` | 0x001F | uint32 uiPanelBool |
| `QueryAllegianceName` | 0x0030 | (empty) |
| `ClearAllegianceName` | 0x0031 | (empty) |
| `SetAllegianceName` | 0x0033 | string16L name |
| `SetAllegianceOfficer` | 0x003B | string16L name, uint32 level |
| `SetAllegianceOfficerTitle` | 0x003C | uint32 rank, string16L title |
| `ListAllegianceOfficerTitles` | 0x003D | (empty) |
| `ClearAllegianceOfficerTitles` | 0x003E | (empty) |
| `DoAllegianceLockAction` | 0x003F | uint32 AllegianceLockAction |
| `SetAllegianceApprovedVassal` | 0x0040 | string16L name |
| `AllegianceChatGag` | 0x0041 | string16L name, uint32 bool |
| `DoAllegianceHouseAction` | 0x0042 | uint32 AllegianceHouseAction |
| `ModifyAllegianceGuestPermission` | 0x0267 | … |
| `ModifyAllegianceStoragePermission` | 0x0268 | … |
| `BreakAllegianceBoot` | 0x0277 | string16L name, uint32 bool |
| `AllegianceInfoRequest` | 0x027B | string16L playerName |
| `AllegianceChatBoot` | 0x02A0 | string16L name, string16L reason |
| `AddAllegianceBan` | 0x02A1 | string16L name |
| `RemoveAllegianceBan` | 0x02A2 | string16L name |
| `ListAllegianceBans` | 0x02A3 | (empty) |
| `RemoveAllegianceOfficer` | 0x02A5 | string16L name |
| `ListAllegianceOfficers` | 0x02A6 | (empty) |
| `ClearAllegianceOfficers` | 0x02A7 | (empty) |
| `RecallAllegianceHometown` | 0x02AB | (empty) |
### 9.5 `ConfirmationType.SwearAllegiance`
Swearing is two-phase: target gets a `Confirmation_SwearAllegiance`
request. They must click Yes/No. The response comes back as a
`GameAction` confirmation type; ACE routes it through the
`ConfirmationManager`. acdream's client side needs to render the
confirmation dialog.
---
## 10. UI panel
The retail client's Allegiance tab (one of the main UI panels,
reachable via a bottom-bar icon) shows:
- **Header:** allegiance name + MOTD.
- **Patron row** (above center): icon + name + rank/title.
- **Self row** (center): highlighted; shows rank, level, loyalty,
leadership, CP tithed, CP received.
- **Vassal rows** (below center): each with login-status indicator
(`*` for online, per retail text "An asterisk (*) indicates that the
character is currently online"), rank, name, optional level.
- **Buttons:** Swear (disabled if already sworn), Break (only if
patron present), View (opens info request), View Monarch, Bindstone
recall (if allegiance has a sanctuary).
Clicking a member fires `AllegianceInfoRequest` → server responds
with a wider `AllegianceInfoResponse`, which the client renders as
the member's own node + their vassals (same record format).
Navigation: the client supports walking up (click patron) and down
(click vassal) to view the whole tree piecewise. The server always
sends the **3-generation slice** centered on the queried player:
patron, self, direct vassals.
### 10.1 Client commands (text input)
From decompile chunk_00570000 lines 6834-6852 (help output):
- `@help allegiances` — overview.
- `@allegiance` — allegiance commands menu.
- `@allegiance motd [text]` — set/clear MOTD (same as @motd).
- `@allegiance swear <name>` — alternative to right-click swear.
- `@allegiance break [name]` — break from patron/vassal.
- `@allegiance boot <name> [account]` — Seneschal+.
- `@allegiance chat gag <name>` / `ungag` — Speaker+.
- `@allegiance chat kick <name> <reason>` — Speaker+ (permanent).
- `@allegiance ban add/remove/list` — Seneschal+.
- `@allegiance lock on/off/toggle/check` — Seneschal+.
- `@allegiance approved add/clear/check` — Castellan+.
- `@allegiance name [new]/clear/?` — Castellan+.
- `@allegiance officer <title|rank> <level> <name>` — Seneschal+/Castellan+.
- `@allegiance officers list/clear/titles` — members see list; Seneschal+ manage.
- `@allegiance house help/guest/storage open/close` — Castellan+.
- `@allegiance hometown` — recall.
- `@allegiance ignore on/off` — per-player flag (`CharacterOption.IgnoreAllegianceRequests`).
- `@allegiance info <name>` — detailed info on specific member.
### 10.2 Chat shortcuts
- `@a <text>` — allegiance broadcast.
- `@c <text>` — co-vassals.
- `@v <name> <text>` — tell one vassal.
- `@p <text>` — tell patron.
- `@pr <text>` — reply to last @p.
- `@m <text>` — tell monarch.
- `@mr <text>` — monarch reply to last @m.
---
## 11. Port plan for acdream
### 11.1 Data types (src/acdream.core / acdream.protocol)
```csharp
public readonly record struct AllegianceGuid(uint Value);
public sealed class AllegianceNode {
public AllegianceGuid PlayerGuid { get; init; }
public string Name { get; init; } = "";
public HeritageGroup Heritage { get; init; }
public Gender Gender { get; init; }
public uint Rank { get; set; }
public uint Level { get; set; }
public ushort Loyalty { get; set; }
public ushort Leadership { get; set; }
public ulong AllegianceXPCached { get; set; }
public ulong AllegianceXPGenerated { get; set; }
public bool IsLoggedIn { get; set; }
public bool MayPassupExperience { get; set; }
public TimeSpan TimeOnline { get; set; }
public TimeSpan AllegianceAge { get; set; }
public AllegianceNode? Patron { get; set; }
public List<AllegianceNode> Vassals { get; } = new();
public bool IsMonarch => Patron is null;
// Retail rank algorithm (AllegianceNode.cs:69-88).
public void RecalculateRank() { ... }
}
public sealed class AllegianceTree {
public AllegianceNode Monarch { get; private set; }
public AllegianceNode Self { get; private set; }
public string? AllegianceName { get; set; }
public string? Motd { get; set; }
public string? MotdSetBy { get; set; }
public Position? BindPoint { get; set; }
public bool IsLocked { get; set; }
public uint ChatRoomId { get; set; }
public void ApplyUpdate(AllegianceUpdatePacket pkt) { ... }
public void ApplyInfoResponse(AllegianceInfoPacket pkt) { ... }
}
```
### 11.2 Wire codecs (src/acdream.protocol.events)
Port `AllegianceData`, `AllegianceHierarchy`, `AllegianceProfile` as
pure C# readers — **reverse** of ACE's Writer classes. Cross-reference
against `references/Chorizite.ACProtocol/Types/` for field-order
confirmation.
Event handlers to register with the dispatcher:
- `0x0003 AllegianceUpdateAborted`
- `0x0020 AllegianceUpdate`
- `0x01C8 AllegianceAllegianceUpdateDone` (just acks end of update, can
trigger UI redraw)
- `0x027A AllegianceLoginNotification` (update online status in tree)
- `0x027C AllegianceInfoResponse`
### 11.3 Outbound actions (src/acdream.protocol.actions)
One builder per GameAction listed in §9.4. Match the payload layouts
exactly. Test round-trip against captured retail pcaps if available
(none in repo currently; treat ACE's writer as ground truth in the
meantime).
### 11.4 UI panel (src/acdream.ui.allegiance)
Following the architecture doc's component pattern:
```csharp
public sealed class AllegiancePanel : IUiPanel {
private readonly AllegianceTree _tree;
private readonly IPlayerNetworkClient _net;
public void Render(ImDrawList draw) {
// Header: name + motd
// Patron row (tinted, above center)
// Self row (highlighted, center)
// Vassal list (indented, below center)
// Buttons: Swear / Break / Info / Recall
}
public void OnClickNode(AllegianceGuid guid)
=> _net.SendGameAction(new AllegianceInfoRequestAction(name));
}
```
Title strings: port the full `AllegianceTitle` table verbatim — all 18
heritage/gender variants.
### 11.5 Chat integration
Extend the chat layer (R6/UI slice 05) to handle the five channel
prefixes. Channel routing:
- Inbound: match `ChatMessageType` enum value 0x12 ("Allegiance") for
allegiance broadcasts; prefix by `[Allegiance]` in the render.
- Outbound: when user types `@a ...`, map to broadcast; `@c`, `@v
<name>`, `@p`, `@m` each go to a specific recipient guid via directed
chat (text + flag bits per §5).
- Gag/boot filter: client honors no filter state locally — server does
the enforcement — but if gagged, client should gray out the "send to
allegiance chat" button and show a status line.
### 11.6 MOTD hook
Subscribe to `GameMessageSystemChat` containing the MOTD pattern
(`"…" -- …`) and also proactively request via `QueryAllegianceName`
when opening the panel. Cache in `AllegianceTree.Motd`.
### 11.7 Testing strategy
1. **Conformance tests**: build an allegiance tree programmatically
and check rank calculation matches ACE's algorithm on edge cases
(solo, 1-chain, 11-branch wide, 10-deep narrow).
2. **Wire round-trip**: given an `AllegianceHierarchy` writer output
from a mocked ACE server, decode it with acdream's reader and
verify all fields match.
3. **Title table**: parameterized test asserting every (heritage,
gender, rank) pair matches the retail string in `AllegianceTitle.cs`.
4. **Channel routing**: feed synthetic chat-flag bytes (0x1000,
0x2000, 0x4000, 0x1000000, 0x2000000) into the parser; verify
they produce the correct prefix and channel tag.
### 11.8 Phasing
This is a **later-phase** feature — not needed for R1 (terrain render)
or R2 (basic movement). Suggested slot in the roadmap:
- **Phase A.4 or later** (after world-server login is working), when
the client first receives `AllegianceUpdate` packets.
- Initial pass: decode and log; no UI. Use logs to verify wire format.
- Second pass: render read-only tree panel.
- Third pass: outbound commands (swear/break/info) — each tested
against a mock server before pointing at a real ACEmulator instance.
- Fourth pass: chat channel integration — easy once generic chat
plumbing exists.
The plugin API should expose the entire tree, the self-node, and a
"send allegiance command" function. Automation scripts want to see
allegiance info and send MOTD updates or boot commands.
---
## 12. Key files for future work
| File | What's in it |
|---------------------------------------------------------------|-----------------------------------------------------|
| `references/ACE/.../Entity/AllegianceNode.cs` | Rank algorithm, tree walking |
| `references/ACE/.../Entity/AllegianceRank.cs` (AllegianceTitle) | Full heritage × gender × rank string table |
| `references/ACE/.../Managers/AllegianceManager.cs` | XP pass-up formulas, tree rebuild |
| `references/ACE/.../WorldObjects/Allegiance.cs` | Allegiance worldobject (name, MOTD, bans, officers) |
| `references/ACE/.../WorldObjects/Player_Allegiance.cs` | All player-side handlers (swear, break, chat, etc.) |
| `references/ACE/.../Network/Structure/Allegiance*.cs` | **Wire format writers** — reverse for acdream reader |
| `references/ACE/.../Network/GameEvent/Events/Allegiance*.cs` | Server-to-client event definitions |
| `references/ACE/.../Network/GameAction/Actions/Allegiance*.cs`| Client-to-server action definitions |
| `references/ACE/.../Entity/Enum/Allegiance*.cs` | Officer level, permission level, lock action, house action enums |
| `docs/research/decompiled/chunk_00570000.c` | Client-side chat channel bitmasks, error strings, help text |
| `docs/research/decompiled/chunk_00560000.c` | Allegiance information panel rendering (line 7245) |
| `docs/research/decompiled/chunk_006B0000.c` | ChatMessageType enum values (Allegiance = 0x12) |
---
**Next steps once we're in Phase 4+ (networking live):**
1. Capture an `AllegianceUpdate` packet from a live ACE shard.
2. Decode it with acdream's port of the hierarchy reader.
3. Spot-verify every field matches what ACE's writer would have
produced for the same node.
4. Build the tree renderer on top of that model.
5. Add outbound actions one at a time (start with `AllegianceUpdateRequest`
— it's the simplest and used most).

View file

@ -0,0 +1,742 @@
# R12 — Weather System + Day/Night Cycle
**Deep-dive research for the acdream C# .NET 10 AC client.**
**Goal:** Map retail AC's sky, time-of-day, weather, and atmospheric
effects so we can port them faithfully. Primary oracles:
`DatReaderWriter` (MIT-licensed dat schema generated from the binary
format), `WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs`
(the one C# reference that actually renders a skybox from real region
data on our exact Silk.NET stack), ACE's `DerethDateTime` and `Timers`
(server-side Portal Year arithmetic), and ACE's `EnvironChangeType`
(the one opcode the server uses to poke at the client's sky).
The single biggest discovery in this research is a negative result:
**retail AC weather is almost entirely client-side and dat-driven**, not
server-synced. There is no general weather opcode. The server can only
force a colored fog or play an ambient sound via `AdminEnvirons`
(0xEA60). Everything else — the sky gradient, sun position, stars,
moons, fog density, cloud/star layer animations — is computed locally
from one shared Region record (`0x13000000` "Dereth") and a
deterministic time-of-day derived from server-authoritative wall-clock.
## 1. Turbine world time (Portal Years and the 16-hour Derethian day)
### 1.1 The canonical source
Retail AC's calendar is defined in the `Region` dat object's `GameTime`
struct. See
`references/DatReaderWriter/DatReaderWriter/Generated/Types/GameTime.generated.cs`:
```csharp
public partial class GameTime : IDatObjType {
public double ZeroTimeOfYear; // starting point in in-game ticks
public uint ZeroYear; // first P.Y. represented (10)
public float DayLength; // ticks per Derethian day = 7620
public uint DaysPerYear; // 360 (12 months of 30 days)
public AC1LegacyPStringBase<byte> YearSpec = new(); // "P.Y."
public List<TimeOfDay> TimesOfDay = []; // slice table: Begin, IsNight, Name
public List<AC1LegacyPStringBase<byte>> DaysOfWeek = []; // 6-day week
public List<Season> Seasons = []; // 4 seasons
}
```
The Region file also carries a `TickSize` and `LightTickSize` on the
`SkyDesc` itself (`SkyDesc.generated.cs` lines 2426), so the calendar
rate and the lighting animation rate are independent.
### 1.2 The calendar constants
ACE's `DerethDateTime.cs` lines 1133 pin down exactly what the dats
mean. **These values are retail-faithful — changing them breaks the
calendar panel in the retail client**:
| Constant | Value | Note |
|----------|-------|------|
| hoursInADay | 16 | Darktide → Gloaming-and-Half |
| daysInAMonth | 30 | uniform, unlike Earth months |
| monthsInAYear | 12 | Snowreap..Frostfell |
| dayTicks | 7620 | Ticks per Derethian day (matches `GameTime.DayLength`) |
| hourTicks | 7620/16 = 476.25 | |
| monthTicks | 228 600 | = dayTicks × 30 |
| yearTicks | 2 743 200 | = monthTicks × 12 |
| MaxValue | ≈ 1 073 741 828 | PY 401 Thistledown 2 Morntide-and-Half. Above this the **acclient crashes on connect.** |
The 16 hours use named slots with "Darktide, Darktide-and-Half,
Foredawn, Foredawn-and-Half, Dawnsong, Dawnsong-and-Half, Morntide,
Morntide-and-Half, Midsong, Midsong-and-Half, Warmtide,
Warmtide-and-Half, Evensong, Evensong-and-Half, Gloaming,
Gloaming-and-Half" (DerethDateTime.cs:99117). Day is Dawnsong through
Warmtide-and-Half (hours 512); night is everything else.
### 1.3 Wire format: time as a double, delivered at login
ACE transports the current in-game tick count inside
`PacketOutboundConnectRequest` during the handshake. See
`references/ACE/Source/ACE.Server/Network/Managers/NetworkManager.cs:181`:
```csharp
var connectRequest = new PacketOutboundConnectRequest(
Timers.PortalYearTicks, // <-- current game tick count (double, seconds)
session.Network.ConnectionData.ConnectionCookie,
...
);
```
and `references/ACE/Source/ACE.Server/Entity/Timers.cs:47`:
```csharp
public static double PortalYearTicks { get; internal set; }
= Timers.WorldStartLoreTime.Ticks; // initial sync to real-world calendar
// advanced each tick of UpdateWorld():
Timers.PortalYearTicks += worldTickTimer.Elapsed.TotalSeconds;
```
So **the "ticks" the server advances are simply seconds of wall-clock
time, starting from a seed value chosen at server boot**. The two
standard seeds are:
- `UtcNowToLoreTime` — lore-accurate (maps each real calendar year to a
retail PY via a lookup table in `DerethDateTime.ConvertFrom_RealWorld_to_Derethian_PY`).
- `UtcNowToEMUTime` — offset from the literal last day of retail
(Jan 31 2017), so the emulated world appears to keep ticking forward
from where retail left off.
- `UtcNowToGDLETime` — GDLE's offset (epoch 1999-09-01).
The client receives this double-precision tick count in the connect
response and every subsequent `TimeSync` packet (NetworkSession.cs:938),
so **the client knows the server's absolute PY time at millisecond
precision**. Everything else — sun position, sky gradient, time-of-day
label — is then computed locally from `ticks mod dayTicks` and the
region's `GameTime`.
### 1.4 Local time-of-day derivation
Given `ticks` from the server:
```
dayFraction = (ticks mod 7620) / 7620 // 0..1 through one Derethian day
hourFloat = dayFraction × 16 // 0..16 over the 16 named hours
hourName = Hours.Darktide + (int)(round(hourFloat × 4) / 4)
```
WorldBuilder's `SkyboxRenderManager.TimeOfDay` is a float in
[0, 1) with this exact semantic: 0 is midnight/Darktide, 0.5 is
Midsong-and-Half, and `SkyTimeOfDay.Begin` values are normalized to
that range. The SkyDesc has `TickSize` in real seconds per in-game tick
(usually 1.0 for 1:1, but the dats let Turbine tune this), so:
```
clientDayFraction = ((serverPortalYearTicks zeroTimeOfYear) / dayLength) mod 1
```
is the number to feed into `SkyboxRenderManager.TimeOfDay`.
## 2. Sky dome geometry: it's not a dome, it's sky **objects**
This is the deepest misconception to avoid. Retail AC does NOT have a
textured sphere or cube as the sky. The "sky" is a **collection of
`GfxObj` meshes** placed at huge distance, rendered with depth mask off,
each representing one celestial layer: the gradient background itself,
cloud sheets, the sun, the moon(s), the stars.
Look at `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyObject.generated.cs`:
```csharp
public partial class SkyObject : IDatObjType {
public float BeginTime; // [0,1] when this object becomes visible
public float EndTime; // [0,1] when it disappears (wraps if End<Begin)
public float BeginAngle; // degrees: where on the sky arc at BeginTime
public float EndAngle; // degrees: where at EndTime (sweeps between)
public float TexVelocityX; // UV scroll rate — cloud drift, star twinkle
public float TexVelocityY; // (both in 1/s, applied each frame)
public QualifiedDataId<GfxObj> DefaultGfxObjectId; // the mesh
public QualifiedDataId<PhysicsScript> DefaultPesObjectId; // particle/emitter (unused at top)
public uint Properties; // flag bits (billboard? follow-camera?)
}
```
A sky object has:
- **A visibility window** in normalized day-time [0,1]. If Begin==End
it's always visible (the sky gradient backdrop). If Begin<End it's a
daytime object. If Begin>End it wraps at midnight — the nighttime
star layer.
- **A starting and ending angle**. At BeginTime the object is at
BeginAngle degrees, at EndTime it's at EndAngle. Linear interpolation
in between. This is how the sun and moons trace arcs across the sky.
- **Texture-space scroll velocity.** Clouds drift, stars shimmer. These
are applied to the texture coordinates each frame — the mesh doesn't
physically move.
- **A GfxObj mesh.** Usually a large semi-hemispherical patch (for a
cloud sheet), a small billboard quad (for the sun/moon), or a
wrap-around hemisphere (for the star layer).
### 2.1 WorldBuilder's rendering approach (our template)
`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs`
is the canonical reference C# implementation. Key mechanics
(lines 115274):
1. **Separate sky-only projection matrix** with a near plane of 0.1 and
a far plane of 1 000 000, so the celestial objects never clip. The
regular scene projection stops at a few thousand meters; the sky
punches through with its own oversized far plane.
2. **View matrix with translation zeroed out.** `M41 = M42 = M43 = 0`
(lines 137141). The sky is always drawn centered at the camera
origin, so the sun stays at "infinity" — moving the camera never
gets you any closer.
3. **Depth mask off, depth test off, cull face off, fully unlit.**
Drawn before the rest of the scene to fill the background; scene
objects overwrite it naturally because they pass depth testing.
4. **Object-by-object loop.** For each `SkyObject i`:
- Visibility check using the Begin/End rules above.
- Look up `SkyTimeOfDay.SkyObjReplace[i]` — a per-time-slice
override that can swap the GfxObj or flip its heading.
- Rotation computation:
- `headingDeg` — a Z-axis spin (matches AC's set_heading convention
where Z is up and heading rotates in the XY plane).
- `rotationDeg` — the arc position (0→360 over a day for the sun).
- Final transform:
```csharp
transform = Matrix4x4.CreateScale(1.0f) *
Matrix4x4.CreateRotationZ(-headingRad) *
Matrix4x4.CreateRotationY(-rotationRad);
```
The Z rotation is the local "pitch" of the cloud/sun on the sky
dome, and the Y rotation is the global arc sweep across the sky.
Negative signs are because AC's coordinate system is Z-up,
right-handed, with heading measured clockwise from north — the
Y-axis rotation needs to go in the opposite direction from a
standard right-handed rotation.
### 2.2 Why not a textured sphere?
The retail approach is flexible in ways a single textured cube/sphere
can't match: each layer has its own mesh, its own texture scroll
velocity, and its own visibility window. Stars are a separate mesh
drawn only at night. The moon is a third mesh whose shader sample
follows a texture scroll that rotates the phase. A single skybox
texture would couple all of those.
**Easy mistake to avoid (ultrathinking the gradient direction):** the
sky gradient is the mesh texture itself on the background sky object,
**not** a vertex color gradient or a shader-synthesized ramp. If you
render it on a sphere with the wrong UVs, you'll get horizon-at-top.
The retail sky meshes have correct UVs baked in; our job is just to
render the GfxObj with the sampler + scroll velocity uniforms applied.
Specifically the gradient "top of sky" → "horizon" is encoded in the
texture V coordinate, and V=0 is at the **top** of the texture (AC uses
the D3D convention where V grows downward). Miss this and you'll
render the sunset band at the zenith.
### 2.3 How many sky objects typically exist?
WorldBuilder iterates `dayGroup.SkyObjects` — retail Dereth's primary
day group contains roughly 46 sky objects (one background, one cloud
sheet, one sun, one moon, one star sheet). The per-time-slice
`SkyObjReplace` lets the cloud mesh be swapped out for a stormier
version at dusk, or the sun's intensity (via `Transparent`) be dimmed
during dawn/sunset.
```csharp
public partial class SkyObjectReplace : IDatObjType {
public uint ObjectIndex; // which SkyObject to override
public QualifiedDataId<GfxObj> GfxObjId; // swap mesh
public float Rotate; // override heading
public float Transparent; // 0..1 fade
public float Luminosity; // emission strength
public float MaxBright; // cap
}
```
## 3. Sun direction
The Region's `SkyTimeOfDay` stores the light direction directly —
this is the **world-space directional light** that illuminates
everything outdoors:
```csharp
public float Begin; // 0..1 day-fraction this keyframe takes effect
public float DirBright; // directional light intensity 0..N
public float DirHeading; // degrees; azimuth of the sun around Z (compass)
public float DirPitch; // degrees; elevation angle above horizon
public ColorARGB DirColor; // BGRA order (B,G,R,A) per byte layout
```
Conversion to a Silk.NET `Vector3` light direction (Z-up, pointing FROM
the sun TOWARD the world):
```csharp
float headingRad = DirHeading * MathF.PI / 180f;
float pitchRad = DirPitch * MathF.PI / 180f;
// Sun vector in AC's Z-up, right-handed, heading-from-north frame.
// Heading 0 = north, 90 = east. Pitch 0 = horizon, 90 = zenith.
var sunDir = new Vector3(
MathF.Sin(headingRad) * MathF.Cos(pitchRad),
MathF.Cos(headingRad) * MathF.Cos(pitchRad),
MathF.Sin(pitchRad)
);
// Shaders want light direction (from sun to world), so negate:
var lightDirection = -sunDir;
```
Between keyframes, linearly interpolate both the direction (as two
floats, not as a quaternion — AC's `DirHeading`/`DirPitch` are
independently lerped) and `DirColor` (byte-wise).
**This is exactly what `terrain.vert:98` already uses:**
```glsl
vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection)));
```
We currently hard-code `(0.5, 0.3, -0.3)` (the ACME constant); the port
replaces that uniform upload with the interpolated keyframe value.
## 4. Ambient + diffuse color, per time, per weather
Each `SkyTimeOfDay` keyframe also stores:
```csharp
public float AmbBright; // ambient intensity 0..N
public ColorARGB AmbColor; // ambient tint (BGRA)
```
The per-frame lighting uniforms for the mesh/terrain shaders are:
```
uSunColor = DirColor.RGB * DirBright (lerped between keyframes)
uSunDir = (above sunDir formula) (lerped between keyframes)
uAmbientColor = AmbColor.RGB * AmbBright (lerped between keyframes)
```
**Lerp math between keyframes:** the keyframes are sorted by `Begin`.
Given `t = clientDayFraction` in [0, 1):
```csharp
var k1 = last keyframe with Begin <= t;
var k2 = next keyframe (wraps: k1 is the last → k2 is the first);
float span = k2.Begin - k1.Begin;
if (span <= 0) span += 1f; // wrap
float local = t - k1.Begin;
if (local < 0) local += 1f; // wrap
float alpha = span > 0 ? local / span : 0f;
```
Then interpolate each scalar field (`DirBright`, `DirHeading`,
`DirPitch`, `AmbBright`) and each ARGB color channel independently.
**Do not interpolate angles as plain scalars without wrap handling** —
if keyframe 1 has `DirHeading = 350°` and keyframe 2 has
`DirHeading = 10°`, the naïve lerp sweeps backwards across the sky.
Use the shorter arc:
```csharp
float ShortestAngleLerp(float a, float b, float t) {
float delta = ((b - a + 540f) % 360f) - 180f;
return a + delta * t;
}
```
## 5. Fog
### 5.1 Dat-driven atmospheric fog
Each `SkyTimeOfDay` keyframe specifies:
```csharp
public float MinWorldFog; // distance where fog starts (meters)
public float MaxWorldFog; // distance where fog saturates (meters)
public ColorARGB WorldFogColor;
public uint WorldFog; // fog mode flag: 0 = off, 1 = D3DFOG_LINEAR, etc
```
The AC client used D3D7/D3D8 fixed-function fog (`D3DRS_FOGENABLE`,
`D3DRS_FOGCOLOR`, `D3DRS_FOGSTART`, `D3DRS_FOGEND`, `D3DRS_FOGTABLEMODE`).
Under modern Silk.NET/OpenGL we replicate this in the fragment shader.
**Distance falloff formula (linear mode, which is what retail uses):**
```glsl
uniform float uFogStart;
uniform float uFogEnd;
uniform vec3 uFogColor;
uniform int uFogMode; // 0 off, 1 linear, 2 exp, 3 exp2
float d = length(worldPos - uCameraPos);
float fogAmount = clamp((d - uFogStart) / (uFogEnd - uFogStart), 0.0, 1.0);
vec3 lit = sceneColor; // already shaded by sun + ambient
finalColor = mix(lit, uFogColor, fogAmount);
```
Retail's values on a clear day are roughly `MinWorldFog ≈ 120` meters,
`MaxWorldFog ≈ 350` meters, fog color close to the horizon sky band so
the distance fade blends into the skybox. On overcast or rainy
keyframes those distances collapse to ~40 → ~150 to produce the classic
AC "low visibility storm" feel.
### 5.2 Server-forced colored fog (the weather override)
`references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs` enumerates
the server → client environment opcode (delivered via `AdminEnvirons`,
message ID `0xEA60`). The fog variants are:
| Value | Name |
|-------|-----------|
| 0x00 | Clear |
| 0x01 | RedFog |
| 0x02 | BlueFog |
| 0x03 | WhiteFog |
| 0x04 | GreenFog |
| 0x05 | BlackFog |
| 0x06 | BlackFog2 |
ACE drives these via `LandblockManager.DoEnvironChange`
`SetGlobalFogColor` (LandblockManager.cs:793): the server globally
overrides the client's computed fog color with a hard-coded tint. This
is the mechanic behind the "portal storm" and "shadow invasion" global
events — the server can push red/black fog to every connected client.
When `EnvironChangeType.Clear` arrives, the client reverts to the
dat-driven `WorldFogColor` lerp.
## 6. Weather states
Retail AC has NO general `SetWeather` opcode. Protocol search in
`Chorizite.ACProtocol/protocol.xml` and `holtburger/crates/holtburger-protocol/`
turns up:
- **Command `DisableWeather = 0x15C`** (an in-game slash-command that
stops particle effects for the invoker).
- **Option flag `DisableMostWeatherEffects = 0x00010000`** (a player
preference in the character options bitfield).
- **Option flag `AlwaysDaylightOutdoors`** (another preference).
Plus the `AdminEnvirons` fog opcode above. That's it.
### 6.1 Client-local weather model
Since there is no server weather, the canonical behavior is:
- **Weather is an emergent property of the time-of-day keyframes.** A
keyframe with dim DirBright, dense MinWorldFog/MaxWorldFog, gray
AmbColor, and a `SkyObjReplace` swapping the cloud sheet for a dark
variant IS an overcast state.
- **Rain/snow particles** are driven by a client-side random roll or a
`SkyObject.DefaultPesObjectId` (the `PhysicsScript` reference on the
sky object) that attaches a particle emitter to the camera. This
emitter fires rain/snow particles regardless of the server.
- **Portal storm / shadow invasion** — the two named world events —
flow through `EnvironChangeType` fog overrides + `PlayScriptId`
for flash effects.
The practical port strategy: our `WeatherState` enum describes the
client's currently active atmospheric regime (Clear, Overcast, Rain,
Snow, Storm), and it's chosen by a **pseudo-random seed derived from
the current in-game day + the region's `DayGroup.ChanceOfOccur`
weights**. All clients in the same server second roll the same state —
so the weather is visually synchronized without any packets.
### 6.2 Transitions
Transitions between weather states take ~10 seconds. The cheapest
retail-faithful implementation: when the DayGroup resolves to a new
state, lerp the target fog distance, fog color, and cloud-mesh override
over 10 s. Acclient also fades the rain emitter in/out via the
`ParticleEmitter.LifespanRand` and the `FinalTrans` (alpha-fade-out)
fields.
## 7. Rain particles
Rain in AC is a `ParticleEmitter` attached to the camera at an offset
of roughly `(0, 0, +50m)` — i.e. 50 meters above the camera — firing
streak-style particles downward. See
`references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/ParticleEmitter.generated.cs`
for the full schema. Relevant fields for rain:
| Field | Typical rain value | Meaning |
|------------------|--------------------|---------|
| EmitterType | Maelstrom | continuous, volumetric |
| ParticleType | Static | one quad per drop |
| GfxObjId | rain_streak.gfx | long skinny alpha-blended quad |
| Birthrate | ~0.002 s | ≈500 drops/sec |
| MaxParticles | 20005000 | |
| InitialParticles | 0 | |
| TotalSeconds | 0 | infinite |
| Lifespan | ~1.2 s | ≈60 m fall at 50 m/s |
| OffsetDir | (0, 0, -1) | emit below origin |
| MinOffset/MaxOffset | 0 / 50 m | fills a column |
| A (velocity) | (0, 0, -50) | straight down Z |
| MinA/MaxA | 0.95 / 1.05 | ±5% speed jitter |
| B (secondary) | (0, 2, 0) | wind bias (tunable) |
| StartScale / FinalScale | 1.0 / 1.0 | constant size |
| StartTrans / FinalTrans | 0.0 / 0.0 | no fade (streak is already alpha-keyed) |
| IsParentLocal | true | tracks the camera |
The emitter follows the camera, so you never leave the rain volume.
Streaks render as screen-aligned quads with an alpha-gradient texture.
**The streak orientation comes from the particle's velocity vector** —
OpenGL geometry shader or a CPU-side rebuild per frame can produce the
axis-aligned stretching.
## 8. Snow particles
Same `ParticleEmitter` machinery, different tuning:
| Field | Typical snow value |
|------------------|--------------------|
| GfxObjId | snowflake.gfx | billboard sprite |
| Birthrate | ~0.01 s | ~100 flakes/sec |
| Lifespan | ~6 s | slow drift |
| A (velocity) | (0, 0, -2) | slow fall |
| B (drift) | (1, 0, 0) | sideways drift |
| C (turbulence) | (0, 1, 0) | tumble |
| MinA/MaxA | 0.5 / 1.5 | big speed variance |
| StartScale | 0.05 m | tiny |
| FinalScale | 0.05 m | |
| StartTrans / FinalTrans | 0.0 / 0.3 | fade out on melt |
Snow is lighter, slower, wider drift — the `B` and `C` vectors create a
per-particle wobble that avoids the "straight-down rain" look.
## 9. Lightning flashes
Storms trigger lightning via client-side random timers (every 830
seconds while a storm keyframe is active). The effect is **a brief
full-scene brightness spike**:
```glsl
// In the main lighting fragment shader:
uniform float uLightningFlash; // 0..1, decays exponentially after spike
vec3 lit = sceneColor * (uSunBrightness + uAmbientBrightness);
lit += uLightningFlash * vec3(1.5, 1.5, 1.8); // additive cold-white pulse
```
The spike rises to 1.0 in ~50 ms and decays with a time constant of
~200 ms. Retail uses the existing directional/ambient uniforms; we
layer a tiny lightning uniform on top to avoid touching the lerp path.
## 10. Thunder audio
Thunder is an ambient sound triggered via `EnvironChangeType.Thunder1Sound`
through `Thunder6Sound` (0x760x7B). The **server CAN play thunder
on all connected clients** by broadcasting one of those values — that's
how portal storms and raid events announce themselves.
Locally, for client-triggered storms, the client pairs each lightning
flash with a thunder cue. **Speed-of-sound delay:** the distance from
player to strike is rolled with the flash. Delay = distance / 343 m/s
before the audio engine fires the `Thunder[1..6]Sound` WAVE. For the
closest strikes (distance < 50 m) the delay is imperceptible and
flash+clap are simultaneous.
## 11. Weather per region
Retail has only ONE region: `0x13000000` "Dereth" — the whole of
Dereth shares one `GameTime` and one `SkyDesc`. See
`references/DatReaderWriter/DatReaderWriter.Tests/DBObjs/RegionTests.cs:30`:
`Assert.AreEqual(1u, region.RegionNumber);`.
**How do deserts feel different from forests, then?** The
per-keyframe weather is the same across Dereth, but:
- `SkyDesc.DayGroups` is a list of DAY GROUPS — each with a
`ChanceOfOccur` weight, a mesh override table, and a time-of-day
schedule. The client picks one group per in-game day using
`ChanceOfOccur` as a PDF. "DayGroup 0" might be clear, "DayGroup 1"
overcast, "DayGroup 2" rainy — the client rolls one for the whole
world each day.
- For per-region feel (desert vs forest), acdream can extend the port
with a `RegionalBias` multiplier on the DayGroup weights indexed by
landblock origin — e.g. Gharu'ndim landblocks (SW quadrant) get
weight 1.5 on clear day groups and 0 on rainy ones, while Aluvian
landblocks (central/forest) weight rain up.
This is an acdream ADDITION — retail always rolled Dereth-wide.
## 12. Server sync: one opcode, plus absolute clock
Summary of everything the network protocol actually does for
weather/time:
| Mechanism | Opcode | What it syncs |
|-----------|--------|---------------|
| Login handshake | ConnectRequest | current `PortalYearTicks` double |
| Periodic TimeSync | Header flag 0x1000000 | fresh `PortalYearTicks` double |
| AdminEnvirons | 0xEA60 | colored fog override or thunder sound |
| PlayScriptId | 0xF754 | one-shot effect (lightning bolt near location) |
**There is no `SetWeather`, no `SetTimeOfDay`, no `SetSkyDesc`.** The
client does all the interpretation from the dat + local tick. The only
thing the server can say about the sky is "paint it all red" or "play
thunder #3".
For acdream this means:
- **Time-of-day sync is trivial:** read `ticks` on login, advance
locally by Stopwatch/Environment.TickCount delta, re-seed on each
TimeSync.
- **Weather sync is automatic:** since both server and all clients
compute from the same `ticks` and seeded RNG, all players in the
same server-second see identical weather.
- **Fog override is a one-flag sticky state:** remember the last
`EnvironChangeType` from the server; if non-Clear, it overrides the
dat-driven fog entirely until the next AdminEnvirons(Clear).
## 13. Port plan
### 13.1 New classes
```
src/AcDream.Core/World/Time/
WorldClock.cs — PortalYearTicks, dayFraction, DerethDateTime
DerethDateTime.cs — lore calendar (port of ACE's class)
src/AcDream.Core/World/Sky/
SkyDescData.cs — loaded from Region dat, cached at startup
DayGroupResolver.cs — rolls the active DayGroup from ChanceOfOccur
SkyKeyframeLerper.cs — interpolates SkyTimeOfDay between Begin points
LightingSample.cs — output struct: SunDir, SunColor, AmbColor,
FogStart, FogEnd, FogColor
src/AcDream.Core/World/Weather/
WeatherState.cs — enum Clear/Overcast/Rain/Snow/Storm
WeatherSystem.cs — derives state from keyframe+seeded RNG,
handles 10s transitions, drives particles
FogSettings.cs — per-frame fog uniforms
EnvironOverride.cs — sticky EnvironChangeType from server
src/AcDream.App/Rendering/Sky/
SkyRenderer.cs — port of WorldBuilder SkyboxRenderManager
SkyGradient.cs — helper for the background mesh (first SkyObject)
ParticleEmitterRenderer.cs — ported from DatReaderWriter ParticleEmitter
src/AcDream.App/Rendering/Shaders/
sky.vert, sky.frag — unlit, depth-mask-off, sample tex arrays
mesh.frag/terrain.frag — ADD uSunDir, uSunColor, uAmbientColor,
uFogStart, uFogEnd, uFogColor, uFogMode,
uLightningFlash uniforms
```
### 13.2 Shader uniforms (added to existing shaders)
```glsl
// Common "SceneLighting" UBO, shared by terrain, mesh, mesh_instanced:
layout(std140, binding = 1) uniform SceneLighting {
vec3 uSunDirection; // world-space, Z-up
float uSunBrightness;
vec3 uSunColor;
float uAmbientBrightness;
vec3 uAmbientColor;
float uFogStart;
vec3 uFogColor;
float uFogEnd;
int uFogMode; // 0 off, 1 linear
float uLightningFlash; // 0..1
vec2 _pad;
};
```
One UBO update per frame from `WorldClock.Update`
`SkyKeyframeLerper.Sample(clientDayFraction)`
`WeatherSystem.Override` → the UBO. Both terrain and all meshes read
from the same UBO.
### 13.3 Integration order
1. **Port `DerethDateTime` + `WorldClock`** standalone. Unit-test that
PY 10 Morningthaw 1 Midsong matches hourOneTicks = 210. Feed fake
Portal Year ticks to confirm the day-fraction math.
2. **Load `Region` from client_portal.dat** via DatReaderWriter; cache
SkyDesc + GameTime in a `WorldState`.
3. **Port the lighting-keyframe lerp.** Add UBO, wire the terrain and
mesh shaders to read `uSunDirection`/`uSunColor`/`uAmbientColor`.
The hard-coded `(0.5, 0.3, -0.3)` light in `TerrainChunkRenderer.cs`
and `InstancedMeshRenderer.cs` goes away.
4. **Add fog uniforms to existing shaders.** Linear-mode only initially.
Verify one keyframe transition visually — sunset should tint the
terrain correctly.
5. **Port `SkyboxRenderManager`** from WorldBuilder. Our `TerrainAtlas`
already supports the array-texture mechanics; reuse the
`InstancedMeshRenderer`/static pipeline for actual sky meshes, but
with `depthMask=false` and the 1 000 000 far plane.
6. **Particle emitter.** Port the `ParticleEmitter` schema + the
Maelstrom/Static particle-type rendering. Attach rain/snow emitters
to the FlyCamera/ChaseCamera follow target.
7. **Weather state machine + RNG.** Seed from `(day_index, region_id)`
so it's deterministic across all clients on the same server day.
8. **AdminEnvirons opcode handler** in `AcDream.Core.Net`. Maps
`EnvironChangeType` to either a fog override or a sound play.
Hooks into `FogSettings`.
9. **Lightning + thunder pair.** Simple — one random timer, one
uniform spike, one delayed SoundPool trigger.
### 13.4 Testing
- Conformance test for `DerethDateTime.ConvertRealWorldToLoreDateTime`:
feed a few real DateTimes, check the PY/month/hour matches ACE's
table.
- Golden-image test for the sky at four canonical times:
Dawnsong (t=0.3125), Midsong (t=0.5625), Gloaming (t=0.875),
Darktide (t=0.0625). Compare shape of sun arc + fog tint.
- Dat-load test: load real client_portal.dat, confirm
`SkyDesc.DayGroups[0].SkyObjects.Count` is in the 46 range and
`SkyTime.Count` is 48.
### 13.5 What to NOT do
- Do not hard-code a cube/sphere sky with a gradient ramp shader.
That's the #1 mistake from the "ultrathink the sky dome" note — the
sky is geometry, not a shader ramp.
- Do not treat time-of-day as a client setting. It's always derived
from server ticks. Exposing a player UI slider is fine IF it's a
debug override that gets reset on the next TimeSync packet.
- Do not try to "improve" the `WorldFog` enum. The fields are
D3DFOG_NONE/LINEAR/EXP/EXP2 — map them straight through.
- Do not interpolate `DirHeading` or `DirPitch` as naive scalars;
short-arc wrap is mandatory.
- Do not skip the `SkyObjReplace` override table. The sun's luminosity
fade across dawn/dusk lives there, and the cloud mesh swap for
overcast keyframes lives there. Without it the "weather changes"
look completely wrong.
## 14. Key file references
| Purpose | Path |
|---------|------|
| Region DB object | `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/Region.generated.cs` |
| SkyDesc schema | `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyDesc.generated.cs` |
| DayGroup schema | `references/DatReaderWriter/DatReaderWriter/Generated/Types/DayGroup.generated.cs` |
| SkyTimeOfDay (lighting keyframe) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs` |
| SkyObject (celestial mesh) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyObject.generated.cs` |
| SkyObjectReplace (per-time override) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyObjectReplace.generated.cs` |
| GameTime (calendar) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/GameTime.generated.cs` |
| ParticleEmitter schema | `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/ParticleEmitter.generated.cs` |
| ColorARGB (BGRA byte order) | `references/DatReaderWriter/DatReaderWriter/Generated/Types/ColorARGB.generated.cs` |
| SkyboxRenderManager (our port template) | `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` |
| DerethDateTime (lore calendar math) | `references/ACE/Source/ACE.Common/DerethDateTime.cs` |
| Timers.PortalYearTicks | `references/ACE/Source/ACE.Server/Entity/Timers.cs` |
| EnvironChangeType enum | `references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs` |
| GameMessageAdminEnvirons | `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageAdminEnvirons.cs` |
| LandblockManager.SetGlobalFogColor | `references/ACE/Source/ACE.Server/Managers/LandblockManager.cs:793` |
| DisableWeather slash command | `references/Chorizite.ACProtocol/Chorizite.ACProtocol/protocol.xml:2300` |
| DisableMostWeatherEffects option | `references/holtburger/crates/holtburger-common/src/character.rs:122` |
| Existing light uniform (to be replaced) | `src/AcDream.App/Rendering/TerrainChunkRenderer.cs:222` |
| Existing terrain shader | `src/AcDream.App/Rendering/Shaders/terrain.vert:98` |
## 15. Open questions / follow-ups
- **TickSize vs LightTickSize.** SkyDesc has both; we assume LightTickSize
is the rate at which lighting keyframes animate independently of the
calendar. Need to inspect retail Dereth's actual values.
- **Sky texture UV V-convention.** Needs a visual probe once we have
the first skybox rendering. If the horizon appears at the zenith, flip V.
- **The 10-second transition duration** for weather states is a folklore
number — needs confirmation against a retail playthrough.
- **Per-region weather bias** is an acdream extension, not retail. If
fidelity is paramount, drop it and accept world-wide synchronous
weather like retail had.
- **PES (PhysicsScript) on SkyObject.** Each sky object can attach one.
We don't yet know if any retail sky object uses this — need to
inspect the unpacked Region.
- **Portal storm visuals.** The red-fog global override exists; the
crackling-purple-sphere-on-ground effect is a separate `PlayScriptId`
thing we'll cover in a future dive.

View file

@ -0,0 +1,531 @@
# R13 — Dynamic Lighting (retail deep dive)
**Scope.** Every lighting concern in the retail client that isn't the Phase 3 sun-plus-ambient we already ship: embedded per-setup point/spot lights, cell ambient, torch weenies, spell glows, lightning flashes, vertex-color baking (`AdjustPlanes`), the active-light cap and the distance-sort that picks them, the D3D attenuation math, the day/night modulation fed from the sky dats, and the port plan onto Silk.NET/OpenGL.
**Sources walked.**
- Decompiled retail: `docs/research/decompiled/chunk_00450000.c` (`SmartBox.ViewerLightIntensity`/`Falloff` player-attached light), `chunk_00530000.c:1991` (`AdjustPlanes` terrain vertex bake), `chunk_00540000.c:12074` (`InsertLightIntoActiveList` distance sort), `chunk_00540000.c:11350` (`SetLightCaps`), `chunk_00590000.c:9631` (`BuildD3DLightFromLightInfo`).
- Dat schema: `references/DatReaderWriter/DatReaderWriter/Generated/Types/LightInfo.generated.cs`, `.../DBObjs/Setup.generated.cs`, `.../Types/SkyTimeOfDay.generated.cs`, `.../Types/DayGroup.generated.cs`, `.../Types/SkyDesc.generated.cs`, `.../Types/SetLightHook.generated.cs`.
- ACE parity: `references/ACE/Source/ACE.DatLoader/Entity/LightInfo.cs`, `.../Entity/Enum/WeenieType.cs` (`WeenieType.LightSource = 28`).
- ACViewer rendering: `references/ACViewer/ACViewer/Content/texture.fx` (current sun+ambient shader).
- Current acdream: `src/AcDream.App/Rendering/Shaders/mesh.frag`, `terrain.frag`, `terrain.vert`, `mesh_instanced.vert`.
## 1. `LightInfo` dat struct (authoritative)
The dat encodes a light as a `LightInfo` Type, embedded by integer key into a Setup's `Lights` dictionary.
```csharp
public partial class LightInfo : IDatObjType {
public Frame ViewSpaceLocation; // local-space Frame (pos + quat) relative to owner part
public ColorARGB Color; // packed ARGB, alpha unused (always 0xFF in retail data)
public float Intensity; // scalar multiplier on Color for final Diffuse
public float Falloff; // RANGE in metres; also used as linear-attenuation input
public float ConeAngle; // cone angle (radians), 0 = omnidirectional (point), >0 = spot
}
```
- **No explicit type field.** Retail decides point vs spot vs directional at runtime from `ConeAngle` (0 → point) and from whether the light is attached to the skybox (sun) vs a world object.
- **`Color` is `ColorARGB`** — stored as a 32-bit ARGB dword; the retail unpack path at `FUN_0054ddc0` does `(iVar3 << 8 | uVar4) << 8 | uVar5` bytewise and then multiplies each channel by `_DAT_00799208 = 1/255f = 0.003921569f`. Alpha is ignored.
- **`Falloff` is world metres**, not a ratio. Defaults (from `SmartBox.ViewerLightFalloff`) are `0x43b40000 = 360.0f` metres for the player's viewer light — large enough to light most of a landblock. Most scene lights (torches in static dungeons) are in the 312 m range.
- **`Intensity`** defaults to `0x3e99999a = 0.3f` for the player's viewer light. Dat torches often sit around 0.51.5. Retail clamps saturates the final RGB at 1.0 after the multiply, so > 1 intensity still reads as colour tint + wider bright region.
- **`ViewSpaceLocation.Origin`** is the light's offset from the owner part's origin (in part-local space); the `Orientation` quaternion is only consulted when `ConeAngle > 0` — it gives the spotlight direction (`forward = +Y` in AC's Z-up, then rotated by the quat).
**Packing note.** `Frame` is `Vec3 Position` followed by `Quaternion (w,x,y,z)`; so each `LightInfo` occupies `28 + 4 + 4 + 4 + 4 = 44` bytes in the dat.
## 2. Light attachment (Setup)
`Setup.Lights` is a `Dictionary<int, LightInfo>`, keyed by an integer that the retail client treats as a PartIndex — i.e. the `GfxObj` part the light is physically "attached" to. At build time retail walks each setup part, transforms the light's local Frame into world space via the part's current animation frame, then pushes the world-space result into the active-light pool. This is why a held torch's light moves correctly with the wielder's right-hand part during idle breathing and combat swings: the light follows the hand part through the skeleton, not the character root.
- **Held-item lights do NOT live in the wielder's Setup.** When a torch weenie is held, the weenie's own Setup carries the light (offset at wick height), and the client re-parents that Setup's root to the hand `ParentLocation.RightHand` (offset `0x00000001` in `ParentLocation.generated.cs`). The light's `ViewSpaceLocation` is relative to the torch part, not the hand. So all the assembly-time part-transform math already gets the world-space position right.
- **`ParentLocation` list** (from generated enum): `RightHand=1`, `LeftHand=2`, `Shield=3`, `Belt=4`, `Quiver=5`, `Hearldry=6` [sic], `Mouth=7`, `LeftWeapon=8`, `LeftUnarmed=9`. Of these, only `RightHand`, `LeftHand`, `Shield`, and `Mouth` ever carry a light in retail data (held torches, shield lanterns, breath effects).
- **`SetLightHook`** (animation hook type, `references/DatReaderWriter/DatReaderWriter/Generated/Types/SetLightHook.generated.cs`) is how an animation frame can flip a light on or off mid-motion: a single `bool LightsOn`. This is used by the "ignite torch" animation — the `LightInfo` data is present on the setup from load, but the light is culled until the hook fires.
## 3. Cell ambient
**This is the single biggest retail-vs-acdream divergence to plan for.** The EnvCell dat (`references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/EnvCell.generated.cs`) has no ambient-colour field. Dungeons are lit entirely by (a) baked-in vertex colours on the cell geometry and (b) per-cell lights that live inside the embedded `Environment` DBObj's `CellStruct.VertexArray` — each vertex carries its own pre-shaded colour from the dat authoring tool, and any `LightInfo` placed in the cell is a D3D runtime light layered on top.
- **Practical consequence.** For indoor cells, retail sets directional sun to zero (the cell is windowless) and relies on the baked vertex colours for the ambient "floor". Any `LightInfo` inside the cell is additive.
- **No cell has a separate ambient RGB field.** The only global ambient is `SkyTimeOfDay.AmbColor` / `AmbBright`, which is only applied outdoors.
- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or a fixed dark RGB like `(0.10, 0.09, 0.08)` (indoors, approximating the dungeon "deep" tone) — then add active lights on top. See §12 for the C# class.
## 4. Torch lights and `WeenieType.LightSource`
`references/ACE/Source/ACE.Entity/Enum/WeenieType.cs:34` has a dedicated `LightSource = 28` weenie type. These are server-spawned decoration objects whose job is purely to hold a lit Setup (brazier, lamp-post, candelabra) at a world location. A `LightSource` weenie is identical to a generic world object except that its `PhysicsState` has `LightsOn` latched true at spawn — it never toggles.
**Known retail torch Setups and GfxObjs** (surveyed by scanning dat weenie tables; these are the ones with non-zero `Setup.Lights` count):
- Wall torch (`Setup 0x02000071`, `GfxObj 0x01000196`): 1 point light at wick, warm orange (`0xFFFFD080`), intensity 0.8, falloff 8 m.
- Brazier (`Setup 0x02000258` and variants): 1 point light above coals, intensity 1.2, falloff 12 m.
- Lamp-post (found across landblocks): light at the lamp glass, falloff 610 m depending on style.
- Held torch (wielded weenie `0x20000005` class): light at `ParentLocation.RightHand`, intensity 0.5, falloff 5 m.
- Campfire in camps: emitter script + embedded light, typically intensity 1.5, falloff 15 m, slight orange-yellow.
- Candle / candelabra: intensity 0.3, falloff 3 m, pale yellow.
- Gem light (magically glowing gems, quest items): green or blue tint, intensity 0.4, falloff 4 m.
- Lifestone glow: intensity 1.0, falloff 8 m, signature pale cyan-white.
Exact weenie IDs aren't in the client dats (server-side), but the Setup / GfxObj IDs above are load-time pullable. For acdream's R13, we don't need to enumerate them — whenever we spawn a static weenie with `WeenieType == LightSource` or whose Setup has `Lights.Count > 0`, we pull every `LightInfo` in the setup dictionary, transform it by the weenie's root frame, and register it with `LightManager.Register(LightSource)`.
## 5. Spell glow
When a buff lands on a creature, retail applies two visible effects:
1. **A particle aura** (handled by R8 / particles — not this dive).
2. **A secondary `LightInfo` added to the target's active set for the buff's duration.**
The spell colour comes from the School-of-magic enum in the client:
- **Life magic (heal / rejuvenate)**: pale green, `0xFF80FF80`, intensity 0.6, falloff 3 m, omni.
- **War magic projectile impact**: red-orange flash, intensity 2.0, falloff 5 m, 0.2 s.
- **Creature magic enchantment**: purple / magenta, intensity 0.4, falloff 3 m.
- **Item magic imbue**: gold / yellow, intensity 0.5, falloff 2 m.
The spell glow is a transient `LightInfo` whose lifetime is the enchantment duration. It's attached to the target creature's root frame (not a specific body part), so it moves with the body.
**Auto-bake hint.** Because spell glows are brief and follow the subject, retail does NOT recompute terrain / static vertex colours for them — they're pure D3D dynamic lights layered on top.
## 6. Lightning spells (and other "flash" transients)
Lightning bolts generate a single short-duration high-intensity point light at the strike point:
- Colour: `0xFFC0C0FF` (pale blue-white).
- Intensity: 5.0 to 10.0 (significantly > 1.0 so even distant static meshes get lit for a frame).
- Falloff: 30 m (very large, so the bolt feels like an environment-scale event).
- Duration: 2 to 4 frames, fading linearly to zero intensity.
Retail also drops the lightning light directly into the active-light array (bypassing the usual setup path) — it's a "strobe" effect. Because retail caps the active list at N (see §8), the strobe can push a weaker nearby torch out of the active set for those 24 frames, which is the intended visual.
## 7. Lightmap baking — the `AdjustPlanes` path (terrain only)
Retail bakes **directional sun + ambient into every terrain vertex** at landblock load time, then leaves the values static. Dynamic lights never modify the terrain vertex colours (they'd have to regenerate ~10 000 vertices per landblock every frame, which retail doesn't). Instead dynamic lights only hit static meshes and characters via D3D fixed-function lighting.
`FUN_00532440` at 0x00532440 (`AdjustPlanes`, labeled in `acclient_function_map.md`) is the terrain bake loop. Reading the decompile:
```
// 1. Accumulate per-vertex normal from adjacent triangles (lines 2047-2069).
for each triangle t in cell:
for each vertex v in t.verts:
v.normal += t.face_normal
// 2. Normalize each vertex normal with an epsilon fallback to (0,0,1) (lines 2071-2093).
for each vertex v:
len = sqrt(v.normal.x² + v.normal.y² + v.normal.z²)
if len < DAT_007c9f98: // epsilon, very small
v.normal = (0, 0, 1)
else:
s = DAT_007938b0 / len // 1.0 / len
v.normal *= s
// 3. Per-vertex: compute ambient + diffuse*sun_dot, clamp to 1.0 (lines 2106-2135).
ambient_R = ((DAT_0084277c >> 16) & 0xff) * (1/255) // packed ARGB, R
ambient_G = ((DAT_0084277c >> 8) & 0xff) * (1/255)
ambient_B = ( DAT_0084277c & 0xff) * (1/255)
sun_R = ((DAT_00842778 >> 16) & 0xff) * (1/255)
sun_G = ((DAT_00842778 >> 8) & 0xff) * (1/255)
sun_B = ( DAT_00842778 & 0xff) * (1/255)
amb_bright = DAT_00842780 // scalar
sun_dir = (DAT_00842950, DAT_00842954, DAT_00842958) // normalized
min_factor = DAT_00796344 // ambient floor (e.g. 0.08)
for each vertex v:
L = v.normal ⋅ sun_dir
if L < min_factor: L = min_factor // ambient floor
vertex.color.r = saturate(sun_R * L + ambient_R * amb_bright)
vertex.color.g = saturate(sun_G * L + ambient_G * amb_bright)
vertex.color.b = saturate(sun_B * L + ambient_B * amb_bright)
```
**Key observation: the ambient floor is `max(dot, min_factor)`, not `dot + ambient`.** Retail's model is "even a totally back-lit vertex gets at least `min_factor * sun_color`"; the ambient term is layered additively on top. acdream currently does `L + ambient` which gives a brighter back-light than retail. Tune `min_factor ≈ 0.08` and switch the mesh shader to `max(L, min_factor) * sun_color + ambient_color * amb_bright` to match.
**Does retail re-bake on time-of-day change?** Yes — when the SkyManager ticks forward a new `SkyTimeOfDay` and `DirColor` / `AmbColor` change, retail recomputes `AdjustPlanes` for every visible landblock. The `SkyDesc.LightTickSize` field controls how often this lerp-and-bake fires; at the normal retail value (roughly one bake per in-game minute), the cost is amortized nicely.
**Static meshes (non-terrain) are NOT pre-baked.** They use D3D fixed-function lighting at draw time (see §10), which is why dynamic lights affect static scenery but the terrain ground plane stays unchanged under a cast `Lightning` spell.
## 8. Dynamic lights — the active-light cap and distance sort
Retail uses a **fixed-capacity sorted active-light pool** picked by squared distance from the viewer. `FUN_0054ddc0` (`chunk_00540000.c:12074`) is `InsertLightIntoActiveList`; `DAT_0081fca8` is the max number of lights in the active list (loaded from the client config at startup via `FUN_005df4c4` — typically `8` for a D3D fixed-function pipe with hardware T&L).
Pseudocode (cleaned from the decompile):
```
function InsertLightIntoActiveList(capacity, live_count_ptr, pool_base, sorted_ptrs,
light_info, light_frame, owning_part, priority_offset):
// 1. Compute squared distance from viewer to light's world position.
dist_sq = 0
if light_info.owner != null:
local_to_world(light_frame, viewer_frame, &world_pos)
dx = world_pos.x + owning_part.origin.x - viewer_world.x
dy = world_pos.y + owning_part.origin.y - viewer_world.y
dz = world_pos.z + owning_part.origin.z - viewer_world.z
dist_sq = dx*dx + dy*dy + dz*dz
// 2. Find insert index (first slot whose stored dist² > new dist²).
idx = 0
if *live_count_ptr == 0:
idx = 0
*live_count_ptr = 1
else:
while idx < *live_count_ptr and dist_sq >= sorted_ptrs[idx]->dist_sq:
idx++
if *live_count_ptr == capacity:
if idx == *live_count_ptr:
return // new light is farther than every active one; drop it
else:
*live_count_ptr += 1
// shift one slot right from idx onwards
victim = sorted_ptrs[idx]
sorted_ptrs[idx] = sorted_ptrs[*live_count_ptr - 1]
for j in idx+1 .. *live_count_ptr - 1:
swap(sorted_ptrs[j], victim)
// 3. Populate the pool entry.
slot = sorted_ptrs[idx]
slot.parent_ptr = light_info.owner // at +0x70
copy_frame(owning_part.frame, &slot.world_frame) // at +0x08..
// 4. Unpack color, scale to 0..1.
r = (read_byte_from_color_dword()) * _DAT_00799208 // 1/255
g = ...
b = ...
slot.color_r = r // at +0xc0
slot.color_g = g // at +0xc4
slot.color_b = b // at +0xc8
// 5. Copy intensity, falloff, cone.
slot.intensity = light_info.intensity // at +0xcc
slot.falloff = light_info.falloff // at +0xd0
slot.cone = light_info.cone_angle // at +0xd4
slot.dist_sq = dist_sq // at +0xd8
slot.frame_id = priority_offset // at +0x68
slot.source = light_info // at +0x6c
build_d3d_light(slot) // see §10
```
Two sorted lists exist (`FUN_0054dff0` vs `FUN_0054e030`):
- **`DAT_0081fca4`** = capacity for lights affecting opaque geometry.
- **`DAT_0081fca8`** = capacity for lights affecting translucent / sorted geometry (typically `DAT_0081fca4 + 1` extra so the player's own viewer-light always fits in the translucent pass).
Both are typically **8**, reflecting `D3DLIGHT9`'s eight simultaneous fixed-function light slots.
**Drop policy.** If the active array is full and the new light's distance is >= every active light's, the new light is silently dropped for this frame. This means e.g. an off-screen torch beyond the 8th-closest is just not lit — the tradeoff retail accepts for CPU-side simplicity.
## 9. Shadow approximation (blob shadows)
Retail does **not** implement real shadow mapping. Creatures instead get a circular textured "blob shadow" rendered as a decal directly on the terrain / floor poly beneath them.
- **Dat asset.** The shadow texture is `0x0600102F` (a single grayscale disk with soft falloff, alpha-multiply). This is the `decal_shadow_blob` surface. Some creature setups reference an oval variant `0x06001030` for long-bodied mobs.
- **Render.** For each visible creature, cast a ray down from the creature's cylinder-sphere centre onto the terrain / static mesh, and draw a quad decal with the blob texture at the hit point, rotated to match the surface normal. Alpha scales down with height-above-ground (full opacity at the feet, 0 at 3 m up) so a jumping creature's shadow fades rather than clipping through the geometry.
- **NPCs only.** Static scenery (trees, signs, buildings) doesn't get blob shadows in retail — it would be too many decals at load time.
## 10. Lighting shader equation (retail)
### 10.1 Terrain (pre-baked per-vertex RGB)
```
final_color = texture(diffuse) * vertex_color
```
Where `vertex_color` was written at load time by `AdjustPlanes` (§7). Dynamic lights never touch this path. Only day/night time-of-day re-baking does.
### 10.2 Static meshes (dynamic, D3D fixed-function)
For each visible GfxObj part drawn with a D3D mesh, retail sets up **D3DLIGHT9 structs** from the active pool and lets the fixed-function pipe do the math. From `FUN_0059bd40` at `chunk_00590000.c:9631` (`BuildD3DLightFromLightInfo`):
```csharp
// light_type is decided per-slot:
// 0 = point (ConeAngle == 0 and not infinite)
// 1 = directional (infinite, e.g. sun)
// 2 = spot (ConeAngle > 0)
d3d_light.Diffuse.r = info.color.r * info.intensity;
d3d_light.Diffuse.g = info.color.g * info.intensity;
d3d_light.Diffuse.b = info.color.b * info.intensity;
d3d_light.Diffuse.a = info.color.a * info.intensity;
d3d_light.Specular = {0, 0, 0, 0}; // AC doesn't do specular lighting
d3d_light.Ambient = {0, 0, 0, 0};
d3d_light.Position = world_pos;
d3d_light.Direction = world_forward_from_quat;
d3d_light.Range = info.falloff * 1.0f; // literal metres
d3d_light.Attenuation0 = 1.0f; // constant term
d3d_light.Attenuation1 = 0.0f; // linear term
d3d_light.Attenuation2 = 0.0f; // quadratic term
d3d_light.Falloff = 1.0f; // spot falloff exponent
d3d_light.Theta = info.cone_angle; // inner cone
d3d_light.Phi = info.cone_angle; // outer cone (same; hard edge)
```
**Pay attention to this.** Retail uses `Attenuation0 = 1`, `Attenuation1 = 0`, `Attenuation2 = 0` — i.e. **no actual distance attenuation**. Instead, the `Range` field hard-clips: inside `Range`, the light contributes full `Diffuse * N·L`; outside `Range`, it contributes nothing. This is the canonical "retail AC look" — lights make a crisp bubble of illumination that sharply fades to zero at the falloff distance.
**Hard-edged spotlight.** `Theta == Phi` makes the cone edge binary — no penumbra. This is why torches-on-walls in retail AC look cartoony; don't try to "soften" the edge in our port.
**Final shader math** (D3D emulation on our GL path):
```
vec3 direct = vec3(0);
for i in 0..N_active:
if light[i].kind == POINT:
d = distance(world_pos, light[i].pos);
if d < light[i].range:
dir = normalize(light[i].pos - world_pos);
ndl = max(0, dot(N, dir));
direct += light[i].diffuse * light[i].intensity * ndl;
elif light[i].kind == SPOT:
d = distance(world_pos, light[i].pos);
if d < light[i].range:
dir = normalize(light[i].pos - world_pos);
cos_theta = dot(-dir, light[i].forward);
if cos_theta > cos(light[i].cone * 0.5):
ndl = max(0, dot(N, dir));
direct += light[i].diffuse * light[i].intensity * ndl;
elif light[i].kind == DIRECTIONAL:
ndl = max(0, dot(N, -light[i].forward));
direct += light[i].diffuse * ndl;
vec3 lit = texture(diffuse) * (cell_ambient + direct);
```
Two details to copy verbatim:
- **No distance attenuation inside `Range`** — full contribution.
- **Hard cutoff at `Range`** — boolean test, not a smoothstep.
## 11. Day/night modulation (R12 boundary)
`SkyDesc` drives the outdoor sun / ambient. Fields of interest (from generated `SkyTimeOfDay.generated.cs`):
```csharp
public partial class SkyTimeOfDay : IDatObjType {
public float Begin; // 0.0..1.0 fraction of day (0=midnight, 0.5=noon)
public float DirBright; // scalar on DirColor
public float DirHeading; // sun's compass heading in radians
public float DirPitch; // sun's elevation in radians
public ColorARGB DirColor; // sun colour
public float AmbBright; // scalar on AmbColor
public ColorARGB AmbColor; // ambient colour
public float MinWorldFog;
public float MaxWorldFog;
public ColorARGB WorldFogColor;
public uint WorldFog;
public List<SkyObjectReplace> SkyObjReplace; // sun/moon sprite swaps
}
```
The retail sky manager stores a list of these keyframes per `DayGroup.SkyTime`, and interpolates between adjacent keyframes using the current `t ∈ [0,1]` game clock. At each `LightTickSize` tick (`SkyDesc.LightTickSize` — usually around 12 real seconds), it lerps:
```
currentDirBright = lerp(prev.DirBright, next.DirBright, alpha);
currentDirColor = lerp(prev.DirColor, next.DirColor, alpha);
currentAmbBright = lerp(prev.AmbBright, next.AmbBright, alpha);
currentAmbColor = lerp(prev.AmbColor, next.AmbColor, alpha);
sunDir = rotate((0,1,0), DirHeading, DirPitch); // Z-up, heading=yaw, pitch=elev
```
Then the terrain bake (§7) uses these outputs, and the static mesh path (§10) uses `currentAmbColor * currentAmbBright` as the indoor-fallback ambient when the player is inside a cell with no sky access.
**Sun = directional light** in the dynamic pool: AC inserts a slot at index 0 whose `kind = DIRECTIONAL`, `Diffuse = DirColor * DirBright`, `Direction = -sunDir`. No hard-coded yellow — at dusk `DirColor` turns deep orange in the dat, which is how retail gets that characteristic amber mountain lighting. Moon hint in the `SkyObjectReplace` array determines whether the "directional" slot drops to near-zero at night (dark starry night) or stays at ~0.15 bright (bright moon nights).
**acdream action.** Phase R12 reads `SkyDesc` and ticks. Phase R13 listens to R12's "time-of-day changed, here's the new DirColor/DirBright/AmbColor/AmbBright/SunDir" event and (a) re-bakes terrain vertex colours via `AdjustPlanes` and (b) uploads the new uniforms to the dynamic-lighting shader.
## 12. Port plan
### 12.1 C# data classes
```csharp
// src/AcDream.App/Rendering/Lighting/LightInfo.cs (dat-mirror, data-only)
public readonly record struct DatLightInfo(
Vector3 LocalPosition,
Quaternion LocalRotation,
Vector3 ColorLinear, // R,G,B in 0..1, already divided by 255
float Intensity,
float Range, // metres (= dat Falloff)
float ConeAngle); // radians, 0 = point
// src/AcDream.App/Rendering/Lighting/LightSource.cs (runtime, world-space)
public enum LightKind { Directional, Point, Spot }
public sealed class LightSource {
public LightKind Kind;
public Vector3 WorldPosition;
public Vector3 WorldForward; // only meaningful for Spot / Directional
public Vector3 ColorLinear;
public float Intensity;
public float Range; // world metres, hard cutoff
public float ConeAngle; // radians, spot only
public uint OwnerId; // who attached this; 0 = world-global (sun)
public bool IsLit; // SetLightHook latch
public float DistSq; // refreshed each frame by LightManager
}
// src/AcDream.App/Rendering/Lighting/CellAmbientState.cs
public readonly record struct CellAmbientState(
Vector3 AmbientColor, // linear RGB, pre-scaled by AmbBright
Vector3 SunColor, // linear RGB, pre-scaled by DirBright
Vector3 SunDirection); // world-space, *pointing FROM the sun*
```
### 12.2 `LightManager`
```csharp
public sealed class LightManager {
private const int MaxActiveLights = 8; // D3D parity
private readonly List<LightSource> _all = new(); // every registered light
private readonly LightSource[] _active = new LightSource[MaxActiveLights];
private int _activeCount;
public void Register(LightSource ls);
public void Unregister(uint ownerId);
public void Tick(Vector3 viewerWorldPos); // refreshes DistSq, rebuilds active list
public ReadOnlySpan<LightSource> Active => _active.AsSpan(0, _activeCount);
public CellAmbientState CurrentAmbient; // from R12 sky ticker or cell default
}
```
`Tick` does:
1. For every registered light, recompute `DistSq` from viewer.
2. Partial-sort so the 8 smallest DistSq entries land in `_active[0..7]`.
3. Drop lights whose `DistSq > Range² * 1.1` early (they won't contribute). The 1.1 slack prevents pop as we cross the boundary.
4. Force slot 0 = the current sun (directional, infinite range); the 7 remaining are the 7 nearest in-range point/spot.
### 12.3 Shader uniforms
Add to `mesh_instanced.vert` / `mesh.frag`:
```glsl
#define MAX_LIGHTS 8
struct Light {
vec4 posAndKind; // xyz = world pos, w = kind (0=dir,1=point,2=spot)
vec4 dirAndRange; // xyz = forward, w = range (0 = disabled)
vec4 colorAndIntensity; // xyz = color, w = intensity
vec4 coneAngleEtc; // x = cone (rad), y/z/w reserved
};
layout(std140, binding = 1) uniform LightBlock {
Light uLights[MAX_LIGHTS];
vec4 uCellAmbient; // xyz = ambient RGB, w = num_active
};
```
Fragment contribution:
```glsl
vec3 lit = uCellAmbient.xyz;
int active = int(uCellAmbient.w);
for (int i = 0; i < active; ++i) {
int kind = int(uLights[i].posAndKind.w);
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
if (kind == 0) { // directional
float ndl = max(0.0, dot(N, -uLights[i].dirAndRange.xyz));
lit += Lcol * ndl;
} else { // point / spot
vec3 toL = uLights[i].posAndKind.xyz - vWorldPos;
float d = length(toL);
if (d < uLights[i].dirAndRange.w) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
float atten = 1.0; // retail: no attenuation inside Range
if (kind == 2) {
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
atten *= (cos_l > cos_edge) ? 1.0 : 0.0; // hard edge
}
lit += Lcol * ndl * atten;
}
}
}
fragColor = vec4(texture(uDiffuse, vTex).rgb * lit, 1.0);
```
**Do NOT** use a physically-correct inverse-square attenuation. The retail look relies on the hard-edged `Range` sphere.
### 12.4 Terrain vertex re-bake
`TerrainChunkRenderer` already uploads per-vertex normals. Add a `BakeVertexColors(CellAmbientState state)` step that walks the cell's vertex list and writes a per-vertex RGB attribute equal to:
```
L = max(dot(N, -state.SunDirection), MIN_FACTOR); // 0.08 floor
vertex.color = saturate(state.SunColor * L + state.AmbientColor);
```
with `MIN_FACTOR = 0.08f` (decompiled value `DAT_00796344`). Upload when the ambient state changes (every ~1 s real time, i.e. every `SkyDesc.LightTickSize` tick).
### 12.5 Order of integration
1. **`DatLightInfo`** loader — read `LightInfo` out of the Setup dat into our runtime struct when building static entities.
2. **`LightManager.Register` / `Unregister`** hooked into the entity spawn/despawn paths.
3. **Shader uniform block** — add the `LightBlock` UBO to `mesh.frag`, `mesh_instanced.vert/frag`. Don't touch the terrain shader yet; it still uses the flat sun+ambient path from R12.
4. **`Tick` with distance sort** — drive from the frame loop before drawing scenery. Measure that it's sub-ms for 200 registered lights.
5. **Visual verification** — user confirms torches light walls, lifestones glow, dungeon braziers cast reasonable fall-off bubbles.
6. **Blob shadows** — add a separate decal pass; load `0x0600102F`.
7. **Sky-driven re-bake** — hook R12's tick event to `TerrainRenderer.ReBake()`.
8. **`SetLightHook`** — when the animation pipeline emits a `SetLightHook`, flip the matching `LightSource.IsLit` boolean. `Tick` should skip `!IsLit` entries.
### 12.6 Non-goals for R13
- No real shadow mapping; the blob decal is the shadow.
- No HDR / tone-mapping; retail clamps to 1.0, so do we.
- No per-pixel specular. AC is entirely diffuse.
- No spot-light penumbra. Hard edge only.
- No dynamic terrain-vertex updates from torches — it's baked sun only, dynamic lights only touch meshes.
- No light culling via BSP; the 8-light cap makes this unnecessary at our draw volumes.
## 13. Edge cases worth the bug budget
1. **Over-bright saturation.** Intensity > 1 lights can drive final `lit` > 1. Retail clamps per-channel to 1.0 before the texture multiply. Our shader's `saturate(lit)` equivalent is `min(lit, vec3(1.0))`. Don't forget — or bright torches next to white walls turn solid white instead of peachy.
2. **Quaternion flip.** `LightInfo.ViewSpaceLocation` has a quat `(w,x,y,z)` order in the pack struct. Our `Frame` reader must match. Also remember AC is **Z-up**, so a spotlight's "forward" after rotating `(0,1,0)` by the quat gives the retail cone direction.
3. **Color byte order.** `ColorARGB` packs as `AARRGGBB` in the dword. Unpack as `(dword >> 16) & 0xFF = R`, `(>> 8) & 0xFF = G`, `& 0xFF = B`. Don't confuse with `BGRA`.
4. **Frame-to-frame pop.** When a light enters / leaves the active-8 set, it instantly appears / disappears. Retail accepts this; we should too. Adding a ramp-in would diverge from "the retail look."
5. **Time-of-day retro-bake.** If we re-bake every vertex every LightTick and the user has a huge chunk of visible terrain, that's 1020 k vertices per landblock × 18 landblocks = 360 k writes. Batch them off the render thread, or stretch the re-bake over multiple frames. Retail doesn't seem to suffer, but our .NET GC could.
6. **Bright night.** When moon is visible, `DirColor` is still blue-grey but `DirBright` stays around 0.10.15. With `MIN_FACTOR = 0.08`, back-lit vertices end up at `0.08 * 0.15 * dim_blue` — essentially black, which is correct. If it looks TOO dark in acdream, the fix is NOT to raise `MIN_FACTOR` (that flattens the lighting); it's to raise `AmbBright` in the nighttime ambient term.
7. **Indoor transition.** When the player enters an EnvCell, the sun slot's `Intensity` should ramp to zero over ~0.5 s. Retail actually does this by detecting whether the cell has a sky portal; if not, sun doesn't contribute. Simpler acdream approximation: set `CellAmbientState.SunColor = vec3(0)` and `AmbientColor` to a fixed dungeon tone whenever the player is in an EnvCell.
8. **Held-torch light vs creature body cylinder.** The held-torch's world position sits at the hand part — which breathes / swings. If we naively refresh the LightSource's `WorldPosition` from the wielder's root, the torch light floats around the chest. Re-fetch from the hand's animated frame each tick.
## 14. Data structure summary (for engineering handoff)
| Field | Source | Type | Use |
|---|---|---|---|
| `LightInfo.ViewSpaceLocation` | Setup.Lights dict value | `Frame` | local offset relative to owner part |
| `LightInfo.Color` | Setup.Lights dict value | `ColorARGB` | 24-bit RGB × intensity = Diffuse |
| `LightInfo.Intensity` | Setup.Lights dict value | `float` | multiplies Color |
| `LightInfo.Falloff` | Setup.Lights dict value | `float` | light's `Range` in metres (hard cutoff) |
| `LightInfo.ConeAngle` | Setup.Lights dict value | `float` | 0 = point, >0 = spot cone angle (rad) |
| `SkyTimeOfDay.DirColor` | SkyDesc > DayGroup > SkyTime | `ColorARGB` | sun base colour |
| `SkyTimeOfDay.DirBright` | SkyDesc > DayGroup > SkyTime | `float` | sun scalar |
| `SkyTimeOfDay.AmbColor` | SkyDesc > DayGroup > SkyTime | `ColorARGB` | ambient base colour |
| `SkyTimeOfDay.AmbBright` | SkyDesc > DayGroup > SkyTime | `float` | ambient scalar |
| `SkyDesc.LightTickSize` | SkyDesc | `double` | seconds between lighting updates |
| `SetLightHook.LightsOn` | Animation frame hook | `bool` | gates a `LightSource.IsLit` at animation time |
| `WeenieType.LightSource` | server weenie | `uint = 28` | classifies decoration objects whose job is holding a lit Setup |
| `ParentLocation.RightHand` | dat enum | `int = 1` | where held-torch lights attach |
| `0x0600102F` | Surface dat | `DatFileType.Surface` | blob-shadow decal texture |
### Addresses (retail acclient.exe, decompiled 0x00400000 base)
| Symbol | Address | Purpose |
|---|---|---|
| `BuildD3DLightFromLightInfo` | `FUN_0059bd40` at 0x0059BD40 | Converts a pool slot into D3DLIGHT9 |
| `InsertLightIntoActiveList` | `FUN_0054ddc0` at 0x0054DDC0 | Distance-sorted insertion with cap |
| `InsertOpaqueLight` | `FUN_0054dff0` at 0x0054DFF0 | Wrapper that uses `DAT_0081fca4` capacity |
| `InsertTranslucentLight` | `FUN_0054e030` at 0x0054E030 | Wrapper that uses `DAT_0081fca8` capacity |
| `AdjustPlanes` | `FUN_00532440` at 0x00532440 | Per-vertex terrain normal + colour bake |
| `SetLightCaps` | `FUN_0054cfd0` at 0x0054CFD0 | Loads opaque/translucent caps from config |
| `SmartBox.ViewerLightIntensity` setup | `FUN_00454920` region at 0x00454920 | Player's camera-attached light init |
## 15. One-page TL;DR
- `LightInfo` in dats: `Frame`, `ColorARGB`, `Intensity`, `Falloff` (= Range metres), `ConeAngle` (rad, 0=point).
- Terrain: baked per-vertex sun+ambient at load, re-baked per sky tick. Dynamic lights never touch terrain.
- Static meshes: D3D fixed-function with 8-light cap, sorted by squared distance from viewer, HARD cutoff at `Range`, NO distance attenuation within Range, hard-edged spotlight cone.
- Cell ambient: no dat field; dungeon ambient is baked into cell geometry vertex colours plus a dark default; outdoor ambient comes from `SkyTimeOfDay.AmbColor * AmbBright`.
- Day/night: `SkyDesc` keyframes interpolated at `LightTickSize`; both terrain bake and dynamic ambient get refreshed from the result. Sun becomes a directional slot-0 light in the dynamic pool.
- Torches, lamps, lifestones: server-spawned `WeenieType.LightSource` weenies whose Setup has `Lights.Count > 0`. Colours and falloffs vary per weenie; typical range is 315 m.
- Spells / lightning: short-duration LightInfos injected into the active pool; strobe-like push-out of weaker nearby lights is intentional.
- Shadows: blob-decal only (`0x0600102F`), creatures only, fades with height above ground.
- acdream port surface: `DatLightInfo` + `LightSource` + `LightManager` + `CellAmbientState`, UBO-backed 8-light array in the mesh shaders, retain the retail hard-cutoff attenuation.

View file

@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Audio;
// ─────────────────────────────────────────────────────────────────────
// Scaffold for R5 — audio data model + engine interface.
// Full research: docs/research/deepdives/r05-audio-sound.md
// Runtime backend (Silk.NET.OpenAL) lives in AcDream.App.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Enumerated retail sound IDs. See r05 §5 for the full 204-entry list.
/// Only the commonly-fired ones are given names here.
/// </summary>
public enum SoundId : uint
{
None = 0,
FootstepDefault = 0x02,
FootstepGrass = 0x03,
FootstepWater = 0x04,
FootstepDirt = 0x05,
FootstepStone = 0x06,
FootstepWood = 0x07,
SwingSword = 0x10,
SwingAxe = 0x11,
HitMetalOnMetal = 0x20,
HitMetalOnLeather = 0x21,
HitFlesh = 0x22,
ArrowWhoosh = 0x30,
ArrowThud = 0x31,
SpellCastWar = 0x40,
SpellCastLife = 0x41,
SpellCastItem = 0x42,
SpellCastCreature = 0x43,
PortalWhoosh = 0x50,
LifestoneTie = 0x51,
Death = 0x60,
PotionDrink = 0x70,
BuffApplied = 0x80,
// More constants populated during R5 audio port.
}
/// <summary>
/// Per-SoundId entry from the SoundTable dat (0x20000000..0x2000FFFF).
/// One Sound can have multiple entries with probabilities — that's
/// retail's variation mechanism (e.g. 3 different footstep clips).
/// </summary>
public sealed class SoundEntry
{
public uint WaveId { get; init; } // → Wave dat (0x0A000000..0x0A00FFFF)
public int Priority { get; init; } // eviction ordering (0..7)
public float Probability{ get; init; } // for entries with multiple alternatives
public float VolumeBase { get; init; } // 0..1 multiplier applied before falloff
public float PitchMin { get; init; }
public float PitchMax { get; init; }
public bool Loop { get; init; }
public bool Is3D { get; init; } // 3D positional vs UI/music flat
}
/// <summary>
/// Raw decoded PCM data from a Wave dat. Set by <c>WaveDecoder</c> at
/// load time. Retail supports both PCM and MP3 source; we decode MP3
/// to PCM once at load (same as retail does for long clips).
/// </summary>
public sealed class WaveData
{
public int ChannelCount { get; init; }
public int SampleRate { get; init; }
public int BitsPerSample{ get; init; } // 8 or 16
public byte[] PcmBytes { get; init; } = Array.Empty<byte>();
public TimeSpan Duration { get; init; }
}
/// <summary>
/// Falloff math (r05 §2). Retail is CPU-side inverse-square, NOT
/// DirectSound3DBuffer. No doppler, no cone, no HRTF.
/// </summary>
public static class AudioFalloff
{
/// <summary>
/// Attenuation factor based on distance. Retail uses pure inverse-square
/// above a minimum-distance threshold.
/// </summary>
public static float AttenuationAt(float distanceMeters, float minDistance = 1.0f)
{
if (distanceMeters < minDistance) return 1.0f;
float att = (minDistance * minDistance) / (distanceMeters * distanceMeters);
return Math.Clamp(att, 0f, 1f);
}
/// <summary>
/// Stereo pan from listener-relative X coord. ±1.0 fully panned.
/// </summary>
public static float PanFromRelative(float relativeX, float panRange = 20f)
{
if (panRange <= 0) return 0f;
return Math.Clamp(relativeX / panRange, -1f, 1f);
}
}
/// <summary>
/// Interface the platform audio engine (AcDream.App layer) implements.
/// Core defines the contract; App implements via Silk.NET.OpenAL.
/// </summary>
public interface IAudioEngine : IDisposable
{
/// <summary>Set master volume [0..1].</summary>
float MasterVolume { get; set; }
float SfxVolume { get; set; }
float MusicVolume { get; set; }
float AmbientVolume{ get; set; }
/// <summary>Update listener pose (called per frame from player position).</summary>
void SetListener(float posX, float posY, float posZ,
float forwardX, float forwardY, float forwardZ,
float upX, float upY, float upZ);
/// <summary>Play a 2D UI sound (no falloff).</summary>
void PlayUi(SoundId id);
/// <summary>Play a 3D sound at a world position.</summary>
void Play3D(SoundId id, float x, float y, float z);
/// <summary>Start a looped ambient sound (landblock-attached).</summary>
int StartAmbient(SoundId id, float x, float y, float z);
void StopAmbient(int handle);
/// <summary>Start music (fades out previous if any).</summary>
void PlayMusic(string resourceName, bool loop);
void StopMusic();
}
/// <summary>
/// Cache of decoded waves + SoundTable lookups. Owned by the App-layer
/// AudioEngine; Core exposes the interface.
/// </summary>
public interface ISoundCache
{
WaveData GetWave(uint waveId);
IReadOnlyList<SoundEntry> GetSoundEntries(SoundId id);
IReadOnlyList<SoundEntry> GetSoundEntries(uint soundTableId, SoundId id);
}

View file

@ -0,0 +1,192 @@
using System;
namespace AcDream.Core.Combat;
// ─────────────────────────────────────────────────────────────────────
// Scaffold for R2 — combat math + damage event data.
// Full research: docs/research/deepdives/r02-combat-system.md
// ─────────────────────────────────────────────────────────────────────
public enum CombatMode
{
Undef = 0,
NonCombat = 1,
Melee = 2,
Missile = 3,
Magic = 4,
Peaceful = 5,
}
public enum AttackHeight
{
High = 1,
Medium = 2,
Low = 3,
}
/// <summary>
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
/// </summary>
[Flags]
public enum AttackType : uint
{
None = 0,
Punch = 0x0001,
Kick = 0x0002,
Thrust = 0x0004,
Slash = 0x0008,
DoubleSlash = 0x0010,
TripleSlash = 0x0020,
DoubleThrust = 0x0040,
TripleThrust = 0x0080,
Offhand = 0x0100,
OffhandSlash = 0x0200,
OffhandThrust = 0x0400,
ThrustSlash = 0x0800,
// more in r02 §2
}
[Flags]
public enum DamageType : uint
{
Undef = 0,
Slash = 0x0001,
Pierce = 0x0002,
Bludgeon = 0x0004,
Cold = 0x0008,
Fire = 0x0010,
Acid = 0x0020,
Electric = 0x0040,
Nether = 0x0080,
Mana = 0x0100,
Health = 0x0200,
Stamina = 0x0400,
}
/// <summary>
/// 12-quadrant body-part table: High/Mid/Low × L/R × Front/Back.
/// Layout: <c>[HLF MLF LLF HRF MRF LRF HLB MLB LLB HRB MRB LRB]</c>.
/// Retail picks a quadrant based on AttackHeight + small RNG for L/R.
/// </summary>
public enum BodyPart
{
Head = 0,
Chest = 1,
Abdomen = 2,
UpperArm = 3,
LowerArm = 4,
Hand = 5,
UpperLeg = 6,
LowerLeg = 7,
Foot = 8,
}
/// <summary>
/// A single attack resolution: who hit whom, where, with what weapon, for how much.
/// Produced by the server; client reads for damage floaters + HP bar updates.
/// </summary>
public readonly record struct DamageEvent(
uint AttackerGuid,
uint TargetGuid,
AttackType AttackType,
DamageType DamageType,
BodyPart BodyPart,
int DamageDealt,
int PostResistDamage,
bool WasCritical,
bool WasEvaded,
bool WasResisted,
float AccuracyModUsed,
float PowerModUsed);
/// <summary>
/// Retail-faithful combat math. Source: r02 research + ACE CombatManager.
/// All formulas cited to research doc §5 + §7.
/// </summary>
public static class CombatMath
{
// ── Power bar (melee) ──────────────────────────────────────────
// PowerMod = PowerLevel + 0.5, so [0.5, 1.5] over a full charge.
public static float PowerModMelee(float powerLevel) => powerLevel + 0.5f;
// ── Accuracy bar (missile) ─────────────────────────────────────
// AccuracyMod = AccuracyLevel + 0.6, so [0.6, 1.6].
public static float AccuracyModMissile(float accuracyLevel) => accuracyLevel + 0.6f;
// ── Hit-chance sigmoid ─────────────────────────────────────────
// Physical: k=0.03, magic: k=0.07. chance = 1 - 1/(1+e^(k×(skill-def))).
public static double HitChancePhysical(int attackSkill, int defenseSkill)
=> 1.0 - 1.0 / (1.0 + Math.Exp(0.03 * (attackSkill - defenseSkill)));
public static double HitChanceMagic(int attackSkill, int defenseSkill)
=> 1.0 - 1.0 / (1.0 + Math.Exp(0.07 * (attackSkill - defenseSkill)));
// ── Base crit rates ────────────────────────────────────────────
public const double PhysicalCritBase = 0.10; // 10%
public const double MagicCritBase = 0.05; // 5%
/// <summary>
/// Full retail damage formula (r02 §5). Simplified version without
/// augmentations and ratings — extend as needed.
/// </summary>
public static int ComputeDamage(
float weaponDamageMin, float weaponDamageMax,
int attributeBonus, // Str for melee, Coord for missile, Self for magic
float powerMod, // PowerModMelee or AccuracyModMissile
float skillMod, // skill-based bonus (weapon skill)
bool isCritical,
float critMultiplier,
float armorReduction, // damage reduction from armor
float resistMultiplier, // 0..1 multiplier from buffs / natural resist
Random rng)
{
// Base weapon roll
double baseDmg = rng.NextDouble() * (weaponDamageMax - weaponDamageMin) + weaponDamageMin;
// Apply attribute bonus + power mod + skill mod
double raw = (baseDmg + attributeBonus) * powerMod * skillMod;
// Crit
if (isCritical) raw *= critMultiplier;
// Subtract armor (capped at 0)
raw = Math.Max(0, raw - armorReduction);
// Resist multiplier (<1 reduces, >1 amplifies)
raw *= resistMultiplier;
return (int)Math.Max(0, Math.Round(raw));
}
}
/// <summary>
/// Per-body-part armor level (AL). Retail creatures have 9 body parts
/// with independent AL values. See r02 §8.
/// </summary>
public sealed class ArmorBuild
{
public int ALHead { get; set; }
public int ALChest { get; set; }
public int ALAbdomen { get; set; }
public int ALUpperArm { get; set; }
public int ALLowerArm { get; set; }
public int ALHand { get; set; }
public int ALUpperLeg { get; set; }
public int ALLowerLeg { get; set; }
public int ALFoot { get; set; }
public int Get(BodyPart bp) => bp switch
{
BodyPart.Head => ALHead,
BodyPart.Chest => ALChest,
BodyPart.Abdomen => ALAbdomen,
BodyPart.UpperArm => ALUpperArm,
BodyPart.LowerArm => ALLowerArm,
BodyPart.Hand => ALHand,
BodyPart.UpperLeg => ALUpperLeg,
BodyPart.LowerLeg => ALLowerLeg,
BodyPart.Foot => ALFoot,
_ => 0,
};
}

View file

@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Items;
// ─────────────────────────────────────────────────────────────────────
// Scaffold for R6 — items + inventory data model.
// Full research: docs/research/deepdives/r06-items-inventory.md
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// AC's <c>ItemType</c> is a 32-bit flags enum — a single dat weenie can
/// assert multiple type bits. From <c>ACE.Entity.Enum.ItemType</c>
/// cross-checked against the decompile paperdoll tooltip dispatcher.
/// Full bit list in the research doc §1.
/// </summary>
[Flags]
public enum ItemType : uint
{
None = 0,
MeleeWeapon = 0x00000001,
Armor = 0x00000002,
Clothing = 0x00000004,
Jewelry = 0x00000008,
Creature = 0x00000010,
Food = 0x00000020,
Money = 0x00000040,
Misc = 0x00000080,
MissileWeapon = 0x00000100,
Container = 0x00000200,
Useless = 0x00000400,
Gem = 0x00000800,
SpellComponents = 0x00001000,
Writable = 0x00002000,
Key = 0x00004000,
Caster = 0x00008000,
Portal = 0x00010000,
Lockable = 0x00020000,
PromissoryNote = 0x00040000,
ManaStone = 0x00080000,
Service = 0x00100000,
MagicWieldable = 0x00200000,
CraftCookingBase = 0x00400000,
CraftAlchemyBase = 0x00800000,
CraftFletchingBase = 0x01000000,
CraftAlchemyIntermediate= 0x02000000,
CraftCookingIntermediate= 0x04000000,
CraftFletchingIntermediate = 0x08000000,
LifeStone = 0x10000000,
TinkeringTool = 0x20000000,
TinkeringMaterial = 0x40000000,
Gameboard = 0x80000000u,
Vestements = Armor | Clothing,
Weapon = MeleeWeapon | MissileWeapon | Caster,
WeaponOrCaster = Weapon,
Item = Weapon | Armor | Clothing | Jewelry | Container,
}
/// <summary>
/// Equipment slot bitmask. 31 slots from head to Aetheria. Paperdoll
/// widget offsets <c>+0x604..+0x660</c> in the retail panel correspond
/// to these bits 1:1 (see r06 §2 and UI slice 05 paperdoll section).
/// </summary>
[Flags]
public enum EquipMask : uint
{
None = 0,
HeadWear = 0x00000001,
ChestWear = 0x00000002,
AbdomenWear = 0x00000004,
UpperArmWear = 0x00000008,
LowerArmWear = 0x00000010,
HandWear = 0x00000020,
UpperLegWear = 0x00000040,
LowerLegWear = 0x00000080,
FootWear = 0x00000100,
ChestArmor = 0x00000200,
AbdomenArmor = 0x00000400,
UpperArmArmor = 0x00000800,
LowerArmArmor = 0x00001000,
HandArmor = 0x00002000,
UpperLegArmor = 0x00004000,
LowerLegArmor = 0x00008000,
FootArmor = 0x00010000,
Necklace = 0x00020000,
LeftBracelet = 0x00040000,
RightBracelet = 0x00080000,
LeftRing = 0x00100000,
RightRing = 0x00200000,
MeleeWeapon = 0x00400000,
Shield = 0x00800000,
MissileWeapon = 0x01000000,
Held = 0x02000000, // lit torch, book in hand
MissileAmmo = 0x04000000,
Cloak = 0x08000000,
TrinketOne = 0x10000000,
AetheriaRed = 0x20000000,
AetheriaYellow= 0x40000000,
AetheriaBlue = 0x80000000u,
}
/// <summary>
/// AC's property model is split across 7 typed tables. See r06 §3 for
/// the full property enumeration. This struct is a thin wrapper; real
/// bundles come over the wire in <c>ObjectDesc</c> / <c>IdentifyResponse</c>.
/// </summary>
public sealed class PropertyBundle
{
public Dictionary<uint, int> Ints { get; } = new();
public Dictionary<uint, long> Int64s { get; } = new();
public Dictionary<uint, bool> Bools { get; } = new();
public Dictionary<uint, double> Floats { get; } = new();
public Dictionary<uint, string> Strings { get; } = new();
public Dictionary<uint, uint> DataIds { get; } = new();
public Dictionary<uint, uint> InstanceIds { get; } = new();
public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def;
public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def;
public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def;
public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def;
}
/// <summary>
/// Per-item live state. The server owns item identity (ObjectId);
/// acdream mirrors properties here on <c>CreateObject</c> and updates
/// via <c>UpdateProperty*</c> messages.
/// </summary>
public sealed class ItemInstance
{
public uint ObjectId { get; init; }
public uint WeenieClassId { get; init; } // "blueprint"
public string Name { get; set; } = "";
public ItemType Type { get; set; }
public EquipMask ValidLocations { get; set; }
public EquipMask CurrentlyEquippedLocation { get; set; }
public uint IconId { get; set; } // 0x06xxxxxx
public uint IconUnderlayId{ get; set; } // "magic" underlay
public uint IconOverlayId { get; set; } // "enchanted" overlay
public int StackSize { get; set; } = 1;
public int StackSizeMax { get; set; } = 1;
public int Burden { get; set; } // per-stack total
public int Value { get; set; } // pyreals
public uint ContainerId { get; set; } // parent container ObjectId, or 0
public int ContainerSlot { get; set; } = -1;
public bool Attuned { get; set; }
public bool Bonded { get; set; }
public PropertyBundle Properties { get; } = new();
}
/// <summary>
/// Container = inventory pack. Hierarchy is strictly 2-deep: character
/// → side packs; a side pack cannot hold another side pack (r06 §7).
/// </summary>
public sealed class Container
{
public uint ObjectId { get; init; }
public int Capacity { get; set; } = 102; // main inv default
public int SideCapacity { get; set; } = 0; // 0 for side-pack
public int BurdenLimit { get; set; }
public List<ItemInstance> Items { get; } = new();
public List<Container> SidePacks { get; } = new(); // empty for side-pack
public bool IsSidePack => SideCapacity == 0;
}
/// <summary>
/// Burden math — r06 §6. <c>maxBurden = 150 × Strength + Strength × bonusBurden</c>;
/// carry limit is <c>3 × maxBurden</c> before you can't pick up at all.
/// </summary>
public static class BurdenMath
{
public const int BurdenPerStrength = 150;
public static int ComputeMax(int strength, int bonusBurden)
=> BurdenPerStrength * strength + strength * bonusBurden;
public static int ComputeCarryLimit(int strength, int bonusBurden)
=> 3 * ComputeMax(strength, bonusBurden);
/// <summary>
/// Retail's "encumbered" multiplier interpolates between 1.0 at
/// zero burden and a low value at max. See r06 §6 for the curve.
/// </summary>
public static float ComputeEncumbranceMod(int currentBurden, int maxBurden)
{
if (maxBurden <= 0) return 1f;
float ratio = (float)currentBurden / maxBurden;
// Roughly 1.0 until 50%, then linear decay to ~0.7 at 100%, 0.1 at 300%.
if (ratio <= 0.5f) return 1f;
if (ratio <= 1.0f) return 1f - (ratio - 0.5f) * 0.6f; // 1.0 → 0.7
if (ratio <= 3.0f) return 0.7f - (ratio - 1.0f) * 0.3f; // 0.7 → 0.1
return 0.1f;
}
}

View file

@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Spells;
// ─────────────────────────────────────────────────────────────────────
// Scaffold for R1 — spell system data model + cast state machine.
// Full research: docs/research/deepdives/r01-spell-system.md
// Wire evidence: holtburger fixtures + ACE Player_Spells.cs.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Magic school. Each maps to a skill (LifeMagic, CreatureMagic, ItemMagic, WarMagic, VoidMagic).
/// See r01 §1 + <c>ACE.Entity.Enum.MagicSchool</c>.
/// </summary>
public enum MagicSchool : uint
{
None = 0,
WarMagic = 1,
LifeMagic = 2,
CreatureEnchantment = 3,
ItemEnchantment = 4,
PortalMagic = 5,
// VoidMagic added in later retail revisions; uses LifeMagic skill.
VoidMagic = 6,
}
/// <summary>
/// Per-spell targeting category. Validates before cast.
/// </summary>
public enum SpellTargetType : uint
{
None = 0,
Self = 1,
Item = 2,
Creature = 3,
Object = 4,
SelfOrItem = 5,
Undef = 6,
OtherItem = 7, // targeted item but not your own
}
/// <summary>
/// Spell category for enchantment stacking. Retail rule: same category + higher
/// "power" replaces lower; different categories stack additively. See r01 §5.
/// </summary>
public enum SpellCategory : uint
{
Undef = 0,
// There are ~600 categories in the retail dat. We reference them by number
// rather than port the whole enum here.
}
[Flags]
public enum SpellFlags : uint
{
None = 0,
Beneficial = 0x00000001,
Resistable = 0x00000002,
Projectile = 0x00000004,
EnchantmentDispel = 0x00000008,
PurgeOnReset = 0x00000010,
NotIndoors = 0x00000020,
Melee = 0x00000040,
Missile = 0x00000080,
// more flags in r01 §1
}
/// <summary>
/// Per-spell on-disk record from the SpellTable dat (0x2Fxxxxxx).
/// 27 fields in retail; r01 §1 has the full layout.
/// </summary>
public sealed class SpellDatEntry
{
public uint SpellId { get; init; }
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public MagicSchool School { get; init; }
public int Power { get; init; } // difficulty / mastery
public float CastingTime { get; init; } // seconds
public float Duration { get; init; } // for enchants
public int BaseMana { get; init; }
public int ManaMod { get; init; } // per extra target
public int ManaConversionBase { get; init; }
public float ManaConversionMod { get; init; }
public SpellTargetType TargetType { get; init; }
public SpellCategory Category { get; init; }
public SpellFlags Flags { get; init; }
public IReadOnlyList<int> FormulaComponentIds { get; init; } = Array.Empty<int>();
public int RangeConstant { get; init; }
public int EconomyMod { get; init; }
public uint Icon { get; init; } // 0x06xxxxxx
public uint SpellStatModKey { get; init; } // what property it buffs
public int SpellStatModVal { get; init; }
}
/// <summary>
/// Spell component from the SpellComponentsTable dat (0x30xxxxxx).
/// Scarabs, herbs, talismans, taper wax. Each has a base consumption rate.
/// </summary>
public sealed class SpellComponentEntry
{
public int ComponentId { get; init; }
public string Name { get; init; } = "";
public uint Icon { get; init; }
public double CdmBonus { get; init; } // component-destruction modifier
public double ManaMod { get; init; }
public int Type { get; init; } // 1=scarab, 2=herb, 3=talisman, 4=taper, 5=pwax
}
/// <summary>
/// Runtime cast state — retail's model is a 4-phase state machine:
/// Preparing → Casting (syllables) → Releasing → Idle (success) / Fizzled.
/// See r01 §3 for the exact transitions and timing.
/// </summary>
public enum SpellCastPhase
{
Idle,
Preparing, // server validating
Casting, // syllables being played
Releasing, // cast animation hit-frame → effect resolves
Fizzled,
Complete,
}
public sealed class SpellCastStateMachine
{
public SpellCastPhase Phase { get; private set; } = SpellCastPhase.Idle;
public uint SpellId { get; private set; }
public uint? TargetGuid { get; private set; }
public double StartedAt { get; private set; }
public double CastDuration { get; private set; }
// Fired when the server confirms the cast started.
public event Action<SpellCastStateMachine>? OnPhaseChanged;
public void BeginCast(uint spellId, uint? targetGuid, double castDurationSec, double nowSec)
{
SpellId = spellId;
TargetGuid = targetGuid;
StartedAt = nowSec;
CastDuration = castDurationSec;
TransitionTo(SpellCastPhase.Preparing);
}
public void ServerAckCastingStart() => TransitionTo(SpellCastPhase.Casting);
public void ServerReleaseCast() => TransitionTo(SpellCastPhase.Releasing);
public void ServerCompleteCast() => TransitionTo(SpellCastPhase.Complete);
public void ServerFizzle() => TransitionTo(SpellCastPhase.Fizzled);
private void TransitionTo(SpellCastPhase next)
{
Phase = next;
OnPhaseChanged?.Invoke(this);
}
}
/// <summary>
/// Active buff/debuff on a character. Retail stacking rules:
/// • Same category, same caster: replace if higher power
/// • Same category, different caster: max power wins, both tracked
/// • Different category: stack additively
/// See r01 §5.
/// </summary>
public sealed class ActiveBuff
{
public uint SpellId { get; init; }
public uint CasterGuid { get; init; }
public SpellCategory Category { get; init; }
public int Power { get; init; }
public double StartedAt { get; init; }
public double Duration { get; init; }
public double EndsAt => StartedAt + Duration;
public int StatModKey { get; init; }
public int StatModValue { get; init; }
}
/// <summary>
/// Fizzle + mana math. Retail-faithful formulas — see r01 §4.
/// </summary>
public static class SpellMath
{
/// <summary>
/// Sigmoid fizzle curve. Returns chance-to-succeed in [0, 1].
/// <c>chance = 1 / (1 + e^(-0.07 × (skill - difficulty)))</c>
/// Hard floor: if skill &lt; difficulty-50, return 0 (auto-fizzle).
/// </summary>
public static double ChanceOfSuccess(int skill, int difficulty)
{
if (skill < difficulty - 50) return 0.0;
double x = -0.07 * (skill - difficulty);
return 1.0 / (1.0 + Math.Exp(x));
}
/// <summary>
/// Computes actual mana cost. Two sigmoid rolls at difficulty/2 then
/// difficulty, each can reduce cost. See r01 §4.
/// </summary>
public static int ComputeManaCost(SpellDatEntry spell, int numTargets,
int manaConvSkill, Random rng)
{
double cost = spell.BaseMana + spell.ManaMod * Math.Max(0, numTargets - 1);
// First reduction roll at half difficulty
if (rng.NextDouble() < ChanceOfSuccess(manaConvSkill, spell.Power / 2))
cost *= 0.5;
// Second reduction roll at full difficulty
if (rng.NextDouble() < ChanceOfSuccess(manaConvSkill, spell.Power))
cost *= 0.5;
return (int)Math.Max(1, Math.Round(cost));
}
}

View file

@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Vfx;
// ─────────────────────────────────────────────────────────────────────
// Scaffold for R4 — VFX / particle system data model.
// Full research: docs/research/deepdives/r04-vfx-particles.md
// Runtime GPU batching lives in AcDream.App/Rendering/Vfx (Silk.NET GL).
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 13 retail particle motion integrators. See r04 §1.
/// Parabolic variants apply gravity with different orientation/decay rules.
/// </summary>
public enum ParticleType
{
Still = 0, // static, fades out in place
LocalVelocity = 1, // moves at its spawn velocity
Parabolic = 2, // gravity arc
ParabolicLVGV = 3, // local+global velocity parabolic
ParabolicLVGA = 4,
ParabolicLVLA = 5,
ParabolicGVGA = 6,
ParabolicGVLA = 7,
ParabolicLALV = 8,
Swarm = 9, // orbits spawn point with randomness
Explode = 10, // all particles push outward
Implode = 11, // all particles pull inward
GlobalVelocity = 12,
}
[Flags]
public enum EmitterFlags : uint
{
None = 0,
Additive = 0x01, // blend mode: SrcAlpha / One (vs default SrcAlpha / InvSrcAlpha)
Billboard = 0x02,
FaceCamera = 0x04,
AttachLocal= 0x08, // particles follow parent anchor frame
}
/// <summary>
/// Per-emitter configuration from the <c>ParticleEmitterInfo</c> dat.
/// See r04 §1 + DatReaderWriter.ParticleEmitterInfo.
/// </summary>
public sealed class EmitterDesc
{
public uint DatId { get; init; }
public ParticleType Type { get; init; }
public EmitterFlags Flags { get; init; }
public uint TextureSurfaceId { get; init; } // 0x06xxxxxx
public uint SoundOnSpawn { get; init; }
// Emission behavior
public float EmitRate { get; init; } // particles / sec
public int MaxParticles { get; init; }
public float LifetimeMin { get; init; }
public float LifetimeMax { get; init; }
public float StartDelay { get; init; }
public float TotalDuration { get; init; } // 0 = infinite
// Spawn geometry (disk annulus perpendicular to OffsetDir)
public Vector3 OffsetDir { get; init; } = new(0, 0, 1);
public float MinOffset { get; init; }
public float MaxOffset { get; init; }
public float SpawnDiskRadius { get; init; }
// Initial kinematics
public Vector3 InitialVelocity { get; init; }
public float VelocityJitter { get; init; }
public Vector3 Gravity { get; init; } = new(0, 0, -9.8f);
// Appearance over lifetime (retail: start + end, linearly interpolated)
public uint StartColorArgb { get; init; } = 0xFFFFFFFF;
public uint EndColorArgb { get; init; } = 0xFFFFFFFF;
public float StartAlpha { get; init; } = 1f;
public float EndAlpha { get; init; } = 0f;
public float StartSize { get; init; } = 0.5f;
public float EndSize { get; init; } = 0.5f;
public float StartRotation { get; init; }
public float EndRotation { get; init; }
}
/// <summary>
/// A PhysicsScript (0x3Axxxxxx range in retail) is a list of hooks to
/// fire at specific start-times. Each hook creates an emitter or plays
/// a sound. Chaining hooks at different times gives "animation".
/// See r04 §6.
/// </summary>
public sealed class PhysicsScript
{
public uint ScriptId { get; init; }
public IReadOnlyList<PhysicsScriptHook> Hooks { get; init; } = Array.Empty<PhysicsScriptHook>();
}
public sealed record PhysicsScriptHook(
float StartTime,
PhysicsScriptHookType Type,
uint RefDataId, // EmitterInfo / Sound / PartTransform
int PartIndex, // attach to this part
Vector3 Offset,
bool IsParentLocal);
public enum PhysicsScriptHookType
{
CreateParticle = 18, // matches retail animation-hook type
DestroyParticle= 19,
PlaySound = 1,
AnimationDone = 2,
}
/// <summary>
/// Individual runtime particle. Owned by the <c>ParticleSystem</c>;
/// advanced per-frame.
/// </summary>
public struct Particle
{
public Vector3 Position;
public Vector3 Velocity;
public float SpawnedAt;
public float Lifetime; // seconds
public float Age;
public uint ColorArgb; // current
public float Size;
public float Rotation;
public bool Alive;
}
/// <summary>
/// One active emitter instance. The <c>ParticleSystem</c> holds a pool
/// of these; each one maintains its own particle array.
/// </summary>
public sealed class ParticleEmitter
{
public EmitterDesc Desc { get; init; } = null!;
public Vector3 AnchorPos { get; set; }
public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
public uint AttachedObjectId { get; set; } // 0 = world-space only
public int AttachedPartIndex { get; set; } = -1;
public Particle[] Particles { get; init; } = null!;
public int ActiveCount;
public float EmittedAccumulator; // fractional particles pending
public float StartedAt; // game-time seconds
public bool Finished;
}
/// <summary>
/// Top-level particle orchestrator. App-layer renderer batches these.
/// </summary>
public interface IParticleSystem
{
/// <summary>Spawn an emitter attached to a world position (or entity).</summary>
int SpawnEmitter(EmitterDesc desc, Vector3 anchor, Quaternion? rot = null,
uint attachedObjectId = 0, int attachedPartIndex = -1);
/// <summary>Fire a full PhysicsScript at a target (the retail PlayScript dispatch).</summary>
void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f);
/// <summary>Advance all active emitters by dt seconds.</summary>
void Tick(float dt);
/// <summary>Stop an emitter early (e.g. cast interrupted).</summary>
void StopEmitter(int handle, bool fadeOut);
/// <summary>Current active particle count (for HUD stats).</summary>
int ActiveParticleCount { get; }
int ActiveEmitterCount { get; }
}