# R01 — Retail Spell-Casting System: Complete Deep-Dive **Scope:** the complete spell-casting system as it exists in the decompiled retail `acclient.exe` (22,225 functions / 688K lines), cross-referenced against the DatReaderWriter dat layout, ACE server implementation, Chorizite protocol definitions, and holtburger TUI client. This doc is the primary reference for any acdream work on magic. **Legend for citations:** - `chunk_NNNNNNNN.c:line` — decompiled retail `acclient.exe` - `ACE: path/to/File.cs:line` — the `references/ACE` authority on server rules - `DRW: path/to/File.cs` — `references/DatReaderWriter` authority on dat shape - `CHZ: path/to/File.cs` — `references/Chorizite.ACProtocol` protocol XML + gen C# - `HLT: path/to/File.rs` — `references/holtburger` client-side wire reference Everything below has been cross-checked against **at least two** of these references; a single-source claim is flagged explicitly. --- ## 1. Spell Data Model ### 1.1 SpellTable (portal.dat 0x0E00000E) **On-disk record header** (DRW: `DatReaderWriter/Generated/DBObjs/SpellTable.generated.cs`): ``` DBObjType = SpellTable (0x0E00000E) HeaderFlags = HasId Min/Max File ID = 0x0E00000E ``` **Body**: ``` Spells : PackableHashTable SpellsSets: PHashTable ``` The `Spells` map is keyed by `SpellId` (uint, 1-based) with entries of type `SpellBase`. There are ~3000+ live entries in end-of-retail data; void/quest spells populate the 5000-7000 range. ### 1.2 SpellBase (per-spell record) Ordering and types cross-checked against DRW: `DatReaderWriter/Types/SpellBase.cs:204` (Unpack) and ACE: `ACE.DatLoader/Entity/SpellBase.cs:58`. The retail client builds the identical record in `chunk_00590000.c:6594-6620` — note the `uVar5 % 0xbeadcf45 + uVar4 % 0x12107680` line at `chunk_00590000.c:6614`, which is the exact decryption-key formula for the packed component IDs. | Offset (rel.) | Type | Field | Notes | |---------------|------|-------|-------| | — | ObfuscatedPString | **Name** | Hashed key input (nameHash) | | — | ObfuscatedPString | **Description** | Hashed key input (descHash) | | +0 | int32 | **School** | `MagicSchool` enum | | +4 | uint32 | **Icon** | Portal dat render-surface DID | | +8 | uint32 | **Category** | `SpellCategory` enum (1-800+) | | +12 | int32 | **Bitfield** | `SpellIndex` / `SpellFlags` bitfield | | +16 | uint32 | **BaseMana** | Base mana cost before ManaConversion | | +20 | float | **BaseRangeConstant** | Minimum range (meters) | | +24 | float | **BaseRangeMod** | Range per point of magic skill | | +28 | uint32 | **Power** | Difficulty (also used for spell Level calc) | | +32 | float | **SpellEconomyMod** | Legacy, unused by retail on end-of-retail | | +36 | uint32 | **FormulaVersion** | Seen as 0, 1, 2 | | +40 | float | **ComponentLoss** | Base burn rate (0.0-1.0) | | +44 | uint32 | **MetaSpellType** | `SpellType` enum (Enchantment/Projectile/etc) | | +48 | uint32 | **MetaSpellId** | Almost always re-copies spell id | | +52 | double | **Duration** | If Enchantment or FellowEnchantment | | +60 | float | **DegradeModifier** | Enchantment degrade | | +64 | float | **DegradeLimit** | Enchantment degrade | | +52 | double | **PortalLifetime** | Overlays Duration when MetaSpellType = PortalSummon | | — | uint32[8] | **Components (encrypted)** | XOR-subtracted by hash key | | — | uint32 | **CasterEffect** | `PlayScript` — caster VFX | | — | uint32 | **TargetEffect** | `PlayScript` — target VFX | | — | uint32 | **FizzleEffect** | Always 0; retail uses fixed fizzle playscript | | — | double | **RecoveryInterval** | Always 0 in retail data | | — | float | **RecoveryAmount** | Always 0 in retail data | | — | uint32 | **DisplayOrder** | Spellbook UI ordering | | — | uint32 | **NonComponentTargetType** | `ItemType` enum — target kind for item spells | | — | uint32 | **ManaMod** | Extra mana per target (fellowship / bane spells) | **Component decryption** (DRW: `SpellBase.cs:154`, matches `chunk_00590000.c:6614`): ``` key = (StringHash(Name) % 0x12107680) + (StringHash(Description) % 0xBEADCF45) comps[i] = (encrypted[i] - key); if (comps[i] > 198) comps[i] &= 0xFF ``` `StringHash()` is the Pascal-style 4-bit rotating hash of the CP1252-encoded string. The "> 198 → mask" correction exists because the highest valid component ID is 198 ("Essence of Kemeroi"), and extended characters perturb the hash. ### 1.3 SpellComponentTable (portal.dat 0x0E00000F) DRW: `Generated/DBObjs/SpellComponentTable.generated.cs` + `Generated/Types/SpellComponentBase.generated.cs`. ACE: `ACE.DatLoader/FileTypes/SpellComponentsTable.cs:27`. Body is `PackableHashTable`. **SpellComponentBase:** | Field | Type | Notes | |-------|------|-------| | Name | ObfuscatedPString | "Lead Scarab", "Ashes of Frost", ... | | Category | uint32 | Loose grouping | | Icon | QualifiedDataId | Icon DID | | Type | uint32 | `ComponentType` enum (see 1.4) | | Gesture | uint32 | `MotionCommand` for windup/cast gesture | | Time | float | Cast-time contribution | | Text | ObfuscatedPString | Spoken syllable for this component | | CDM | float | Component Destruction Modifier — multiplier for burn | ### 1.4 ComponentType (DRW `Generated/Enums/ComponentType.generated.cs`) ``` Undef=0, Scarab=1, Herb=2, Powder=3, Potion=4, Talisman=5, Taper=6, PotionPea=7, TalismanPea=5, TaperPea=7 ``` ### 1.5 MagicSchool (DRW `Generated/Enums/MagicSchool.generated.cs`) ``` None=0, WarMagic=1, LifeMagic=2, ItemEnchantment=3, CreatureEnchantment=4, VoidMagic=5 ``` ### 1.6 SpellType / MetaSpellType (DRW `Generated/Enums/SpellType.generated.cs`) ``` Undef=0, Enchantment=1, Projectile=2, Boost=3, Transfer=4, PortalLink=5, PortalRecall=6, PortalSummon=7, PortalSending=8, Dispel=9, LifeProjectile=10, FellowBoost=11, FellowEnchantment=12, FellowPortalSending=13, FellowDispel=14, EnchantmentProjectile=15 ``` ### 1.7 SpellFlags / SpellIndex (DRW `Generated/Enums/SpellIndex.generated.cs`, ACE `ACE.Entity/Enum/SpellFlags.cs`) ``` Resistable = 0x00001 PKSensitive = 0x00002 Beneficial = 0x00004 SelfTargeted = 0x00008 Reversed = 0x00010 // animation plays reversed (buffs) NotIndoor = 0x00020 NotOutdoor = 0x00040 NotResearchable = 0x00080 Projectile = 0x00100 CreatureSpell = 0x00200 // monster-cast only ExcludedFromItemDescriptions = 0x00400 IgnoresManaConversion = 0x00800 NonTrackingProjectile = 0x01000 FellowshipSpell = 0x02000 FastCast = 0x04000 // no windup motions IndoorLongRange = 0x08000 DamageOverTime = 0x10000 UNKNOWN = 0x20000 // seen set on a handful of spells ``` ### 1.8 SpellCategory 249+ values (CHZ: `protocol.xml` `` lines 1-800+). Values are tightly grouped by purpose: | Range | Meaning | |-------|---------| | 1-40 | Attribute + weapon-skill raising/lowering | | 41-68 | Magic-skill raising/lowering (defense, schools) | | 69-100 | Utility skills (healing, run, loyalty, leadership, vitae) | | 101-114 | Elemental protection / vulnerability | | 115-151 | Projectile families (Missile, Seeker, Burst, Blast, Scatter by 7 damage types) | | 152-199 | Damage / attack / defense modifier enchantments | | 200-221 | Portal / crafting skills / vitae | | 222-250+ | Ring / Wall / Strike / Streak / Volley projectile families | Categories are used as a **stacking rule key**: two enchantments sharing a category will not both be active — the higher-power one wins (`ACE.Server/Managers/EnchantmentManager`). ### 1.9 SpellTargetType — not a standalone enum Retail doesn't have an explicit `SpellTargetType` enum; target validity is derived from two fields: - `Flags.SelfTargeted` — must target caster - `Flags.FellowshipSpell` (or MetaSpellType in `FellowBoost..FellowDispel`) — targets all fellows - `School == ItemEnchantment` → `NonComponentTargetType` gates which item classes it can be cast on - Otherwise (LifeMagic / CreatureEnchantment / WarMagic / VoidMagic) → target must be a `Creature` (see ACE `IsInvalidTarget` below) ### 1.10 SpellSet (portal.dat, inside SpellTable) DRW: `Generated/Types/SpellSet.generated.cs`, `Generated/Types/SpellSetTiers.generated.cs`. Armor and weapon "sets" (Tusker, Nariyid, etc.) define a list of spells that activate as more set pieces get equipped. Keyed by `EquipmentSet` enum. --- ## 2. Cast Validation — The Decision Tree Full flow, starting from when the server receives `Magic_CastTargetedSpell` (opcode 0x004A) or `Magic_CastUntargetedSpell` (0x0048): ``` [incoming wire] │ ▼ HandleActionCastTargetedSpell (ACE: Player_Magic.cs:80) HandleActionMagicCastUnTargetedSpell (Player_Magic.cs:271) │ ▼ (checks in order) 1. CombatMode != Magic → SendUseDone; bail 2. Physics stance != Magic (FastTick only) → SendUseDone(YoureTooBusy); bail 3. IsJumping → SendUseDone(YouCantDoThatWhileInTheAir) 4. PKLogout → SendUseDone(YouHaveBeenInPKBattleTooRecently) 5. IsBusy && MagicState.CanQueue → queue cast; return (no error) 6. VerifyBusy() (IsBusy||Teleporting||dead) → SendUseDone(YoureTooBusy); bail 7. VerifySpell(spellId, casterItem) - casterItem != null → item.SpellDID == spellId? (0x03FC MagicInvalidSpellType) - else → SpellIsKnown(spellId)? 8. (targeted) GetTargetCategory → target, teleporting? (0x0403 TargetNotAcquired) 9. MagicState.OnCastStart() + snapshot StartPos 10. TurnTo_Magic(target) — rotate in place to face 11. (after rotate/turn) DoWindup → CreatePlayerSpell │ ▼ ValidateSpell(spellId, isWeaponSpell) (Player_Magic.cs:386) a. new Spell(spellId) — load SpellBase from dat + server DB row b. spell.NotFound? → YouDontKnowThatSpell or MagicInvalidSpellType c. !isWeaponSpell && !HasComponentsForSpell(spell) → YouDontHaveAllTheComponents (0x0400) 12. VerifySpellTarget → IsInvalidTarget(spell, target) (see 2.1) 13. VerifySpellRange → range + NotIndoor/NotOutdoor (see 2.2) 14. GetCastingPreCheckStatus → skill roll (see 2.3) 15. CalculateManaUsage → skill-modulated cost + currentMana check if (manaUsed > mana) → YouDontHaveEnoughManaToCast (0x0401) 16. Play windup gestures (ACE: DoWindupGestures), spell words (GameMessageHearSpeech, channel = Spellcasting = 0x11) 17. Cast gesture → DoCastSpell_Inner a. Consume mana (UpdateVitalDelta) b. TryBurnComponents(spell) (see 2.4) c. windup move > 6.0m && PK? → SendSystemChat + fizzle d. CheckPKStatusVsTarget → maybe InvalidPKStatus e. switch(castingPreCheckStatus): Success → CreatePlayerSpell(target, spell, ...) (per-school effect) InvalidPKStatus → if Projectile, launch anyway (for harmless VFX) default (Failed) → GameMessageScript(Guid, PlayScript.Fizzle, 0.5f) + SendWeenieError(YourSpellFizzled) (0x0402) 18. FinishCast() — recovery motion + optional queue-to-next ``` ### 2.1 IsInvalidTarget (ACE `Player_Magic.cs:427`) Returns true (invalid) if any of: - `!target.IsEnchantable` - `spell.SelfTargeted && target != caster` - `spell.School != ItemEnchantment && targetCreature == null` (War/Life/Creature need living target) - `targetCreature != null && !(target is Player) && spell.IsBeneficial` (can't buff non-player creatures) - Spell is negative + target is an item wielded by a player the caster can't PK - Spell is ItemEnchantment and fails `VerifyNonComponentTargetType` - Target == caster and spell is negative-redirectable (brittlemail / lure) - Target is a creature caster "can't damage" (shared-group PKs, etc.) ### 2.2 Range + indoor/outdoor (ACE `Player_Magic.cs:481`) ``` maxRange = min(spell.BaseRangeConstant + magicSkill * spell.BaseRangeMod, MaxRadarRange_Outdoors) if (distanceTo > maxRange) → MissileOutOfRange if (spell.NotIndoor && (caster.indoors || target.indoors)) → YourSpellCannotBeCastInside (0x0408) if (spell.NotOutdoor && (caster.outdoors || target.outdoors)) → YourSpellCannotBeCastOutside (0x0407) ``` When the caller has no item caster, `magicSkill` for range is **init-level + ranks** (not the full buffed+augmented skill), matching retail `DetermineSpellRange` (noted in comment at `Player_Magic.cs:494`). ### 2.3 Skill / fizzle formula (ACE `Player_Magic.cs:528`, `SkillCheck.cs:19`) ``` difficulty = spell.Power // not spell.PowerMod — that's mana-only // gating: below difficulty - 50, no roll happens at all if (magicSkill == 0 || magicSkill < difficulty - 50) → CastFailed // the roll: chance = 1 / (1 + exp(-0.07 * (magicSkill - difficulty))) // sigmoid, factor=0.07 rng = random(0.0, 1.0) status = (chance > rng) ? Success : CastFailed ``` Weapon-built-in spells override to `Success` — they cannot fizzle (`Player_Magic.cs:543`). **War↔Void "blood interference"** (`Player_Magic.cs:547`): if caster casts War within 3-5 s of a successful Void (or vice-versa), the cast is forced to `CastFailed` with the chat line: `"The energies permeating your blood cause this magic to fail."` ### 2.4 Component consumption (ACE `Spell.cs:157`) ``` baseRate = spell.ComponentLoss magicSkill = spell.GetMagicSkill() → player.GetCreatureSkill(...) skillMod = min(1.0, spell.Power / playerSkill.Current) for each component in spell.Formula.CurrentFormula: burnRate = baseRate * component.CDM * skillMod if random(0..1) < burnRate: consumed.Add(component) // per-component loop: TryConsumeFromInventoryWithNetworking(item, 1) // final: GameMessageSystemChat("The spell consumed the following components: ...", Magic) ``` Note that components are burned **both on success and on fizzle** — ACE's `DoCastSpell_Inner` calls `TryBurnComponents` unconditionally before the switch-on-status. (`Player_Magic.cs:867-868`.) ### 2.5 Mana calculation (ACE `Creature_Magic.cs:14, GetManaCost:55`) ``` baseCost = spell.BaseMana (or castItem.ItemManaCost if casting item's built-in) // per-target modifier if (ItemEnchantment + Enchantment + Category in ArmorValueRaising..AcidicResistanceLowering && target is Player): baseCost += spell.ManaMod * numEnchantableEquipped else if (spell.IsFellowshipSpell): baseCost += spell.ManaMod * numFellows // ManaConversion skill reduction — 2 rolls, additive if (!IgnoresManaConversion && ManaConversion >= Trained): difficulty = spell.PowerMod (modified power, not raw) manaConv = round(ManaConversion.Current * GetWeaponManaConversionModifier) successChance = SkillCheck.GetSkillChance(manaConv, difficulty/2) roll = random(0..1); luck = random(0..1) if (roll < successChance): cost *= (1.0 - (successChance - roll*luck)) // second roll at full difficulty if cost still > 1 if (cost > 1): successChance = SkillCheck.GetSkillChance(manaConv, difficulty) if (random(0..1) < successChance): cost *= (1.0 - (successChance - roll*luck)) return max(cost, 1) ``` On `CastFailed` (fizzle) the cost is **hard-coded to 5 mana** (`Player_Magic.cs:572`, commented "todo: verify with retail"). This is the one place we should double-check a retail pcap. --- ## 3. Cast Sequence — From Click to Resolution ### 3.1 Client side (decompiled) The spell UI click handler is `FUN_004c7620` at `chunk_004C0000.c:5018-5238`. Field offsets on the spellbook panel (`param_1`) are: | Offset | Purpose | |--------|---------| | +0x604 | GUI widget host pointer | | +0x61C | "has selected item" flag byte | | +0x620 | Selected item-based-spell object handle | | +0x624 | Selected item-based spell id | | +0x634 + tabIdx*0x1C | Tab N: currently-highlighted spell id | | +0x638 | Life tab list head | | +0x654 | Creature tab list head | | +0x670 | Item tab list head | | +0x68C | War tab list head | | +0x6A8 | Portal tab list head | | +0x6C4 | Void tab list head | | +0x6E0 | Favorites tab list head | | +0x6FC | Combined/search tab list head | Each tab is 0x1C bytes. The pattern +0x10 on the tab-head stores the current spell count. The flow on click: 1. `FUN_00567c00()` / `FUN_00567eb0()` — look up the `SpellBase` from the currently-selected tab slot. 2. If the selection succeeded, the client formats a chat echo: `"CAST "` (wide-string at `chunk_004C0000.c:5112`), and appends `" on "` if a non-self target is also selected (`chunk_004C0000.c:5117`). 3. Sends the `Magic_CastTargetedSpell` (0x004A) or `Magic_CastUntargetedSpell` (0x0048) action via the game-action queue (which layer goes through the 0xF7B1 ordered game-action framing). 4. If nothing is selected, the client writes the UI-bar hint `"Select a spell to cast"` (`chunk_004C0000.c:5146`) or `"You have no spells ready to cast"` (`chunk_004C0000.c:5143`). ### 3.2 Server side (ACE mapped to retail FUN_ addresses) | Step | ACE method | Retail client role | |------|------------|---------------------| | Parse 0x004A payload | `GameActionMagicCastTargetedSpell.Handle` | — (server-only) | | Validate combat mode + stance | `Player_Magic.HandleActionCastTargetedSpell` | Client sets CombatMode via separate `Combat_ChangeCombatMode` (0x0053) before casting | | Verify spell known | `VerifySpell` → `SpellIsKnown` | Client checked a subset before sending, but server is authoritative | | Target lookup | `GetTargetCategory` | Client only sends `targetGuid`; server resolves | | Begin windup | `MagicState.OnCastStart` + `DoWindup` | Client plays `CasterEffect` playscript when it receives a `GameMessageScript` from server | | Emit syllables | `DoSpellWords` → `GameMessageHearSpeech` ch=0x11 | Client `FUN_00564d30` area handles ch-filtering to the Spellcasting chat window | | Cast gesture | `DoCastGesture` enqueues `Motion(MotionStance.Magic, CastGesture)` | Client animates via motion-interpreter; retail uses `FUN_005649f0` result channels | | Consume mana + comps | `DoCastSpell_Inner` | — (server persists inventory, then sends `Inventory_*` updates and a chat burn-message) | | Apply effect | `CreatePlayerSpell` → per-school branch | See §5 | | Report resolution | `SendWeenieError(YourSpellFizzled)` / no msg on success | Retail dispatch table at `chunk_00570000.c:1700-1770` decodes each WeenieError code | | Recovery motion | `FinishCast` | Client plays recovery via same MotionTable lookup | ### 3.3 WeenieError codes relevant to casting All observed in the retail dispatch table at `chunk_00570000.c:1705-1768`: | Code | Message | |------|---------| | 0x3FA (1018) | "You've attempted an impossible spell path!" | | 0x3FB | MagicIncompleteAnimList (internal) | | 0x3FC | MagicInvalidSpellType | | 0x3FD | MagicInqPositionAndVelocityFailure | | 0x3FE | "You don't know that spell!" | | 0x3FF | "Incorrect target type" | | 0x400 | "You don't have all the components for this spell." | | 0x401 | "You don't have enough Mana to cast this spell." | | 0x402 | **"Your spell fizzled."** | | 0x403 | "Your spell's target is missing!" | | 0x404 | "Your projectile spell mislaunched!" | | 0x407 | "Your spell cannot be cast outside" | | 0x408 | "Your spell cannot be cast inside" | | 0x4FB | "YouAreInvalidTargetForSpellOf_" (WeenieErrorWithString) | | 0x50 | "You fail to affect %s because beneficial spells do not affect %s!" | --- ## 4. Fizzle Math & Component Loss ### 4.1 Fizzle chance (exact) From `ACE.Server/WorldObjects/SkillCheck.cs:19`: ``` chance = 1 / (1 + exp(-0.07 * (effective_skill - difficulty))) where effective_skill = playerSkill.Current (buffed + augmented + item mods) difficulty = spell.Power (NOT spell.PowerMod) ``` Gating rule: if `effective_skill < difficulty - 50` or `effective_skill == 0`, the status is forced to `CastFailed` before the roll even happens. This gives a hard floor ~50 under the spell's power where you always fizzle. **Worked examples** (double-check against retail log-sniffs): | skill | difficulty | chance | |-------|------------|--------| | 50 | 100 | 3.06% (but floor triggers → 0%) | | 100 | 100 | 50.00% | | 100 | 150 | 3.06% | | 120 | 150 | 11.92% | | 150 | 150 | 50.00% | | 175 | 150 | 85.81% | | 200 | 150 | 96.93% | | 300 | 200 | 99.91% | | 400 | 400 | 50.00% | (R's sigmoid with λ=0.07; pure math, no other modifiers.) ### 4.2 Component loss on fizzle Components are **not** special-cased by fizzle. The same `TryBurnComponents` rolls each component independently with: ``` burnRate = spell.ComponentLoss * component.CDM * min(1.0, spell.Power / skill) ``` Since `min(1.0, power/skill)` is **larger** when the player is under-skilled (approaches 1.0), under-skilled casters burn a higher fraction of components on every attempt — success or failure. A capped caster (skill > power) burns at the spell's raw `ComponentLoss * component.CDM` rate. --- ## 5. Spell Effects Taxonomy Dispatch lives per-school in `WorldObject_Magic.HandleCastSpell` → one of the following branches (reached via `Player_Magic.CreatePlayerSpell(...)` after the windup completes): | School + MetaSpellType | Effect path | |------------------------|-------------| | Life + Boost / Transfer / LifeProjectile | Heal/Harm/drain: `LifeMagic` resolves vital delta, then networked via `GameMessageUpdateAttribute2ndLevel`. Harm = subtracts, Heal = adds. | | Life / Creature / Item + Enchantment | Creates a `PropertiesEnchantmentRegistry` entry on the target: `spellId, layer, startTime, duration, degradeMod, degradeLimit, statMod{Type,Key,Value}, casterId`. Target receives `Magic_UpdateEnchantment` (0x02C2). | | + FellowEnchantment | Same as Enchantment but iterated over `GetFellowshipTargets()`. Each individual update is a separate `Magic_UpdateEnchantment`. | | War / Void + Projectile / LifeProjectile / EnchantmentProjectile | Spawns one or more `SpellProjectile` world objects (`NumProjectiles`, driven by Category). Client-side visual: `PlayScript.TargetEffect` on spawn point, projectile mesh per category, velocity from `ProjectileSpeed` on server. On hit → damage calc + enchantment for EnchantmentProjectile variants. | | PortalLink / PortalRecall / PortalSummon / PortalSending | Uses the Teleport pipeline: cooldown, destination validation, fade playscript, `GameMessageSetPosition`. Retail client catches the resulting `PlayerTeleport` message and re-runs EnterWorld-lite. | | Dispel / FellowDispel | Iterates target's `EnchantmentRegistry`, rolls per-slot dispel chance, sends `Magic_DispelEnchantment` (0x02C7) / `Magic_DispelMultipleEnchantments` (0x02C8) / `Magic_PurgeBadEnchantments` (0x0312). | **Stacking rule**: two enchantments with the same `SpellCategory` do **not** coexist. On apply, `EnchantmentManager.Add` checks existing registry and keeps the higher-power spell (uses `spell.Power`, not effect value). Lower-power spell apply is silently dropped with a "Spell not added (stronger active)" message. **Duration rules**: - `Enchantment.Duration` field is the base seconds. - A caster item with `ItemCurMana > 0` and matching `SpellDID` produces a permanent (-1) enchantment — `Duration = -1` signals "while equipped". - `Vitae` uses a fixed Category = 204 and its `HasEquipmentSet=false`, `StartTime/Duration` are tracked separately per player. **Projectile-spell speed/tracking**: `Spell.IsTracking ≡ !Flags.HasFlag(NonTrackingProjectile)`. War missiles (bolts, arcs) track; streaks/volleys don't. Projectile velocity is on the server-side `Spell` record as `ProjectileSpeed` (not in the dat). --- ## 6. Spell Icons + School Colors + Levels ### 6.1 Icon `SpellBase.Icon` is a `uint` DID into the portal.dat RenderSurface section. Each spell has its own unique icon. The client's spellbook UI textures them directly; there's no per-school tint. ### 6.2 School color scheme From retail UI sprite atlases (not in dat — inferred from the chunk_004C0000 spellbook setup + ACViewer's UI screenshots): | School | UI accent color | Tab icon | |--------|-----------------|----------| | Life Magic | Red | Heart / teardrop | | Creature Enchantment | Yellow | Beast silhouette | | Item Enchantment | Blue | Gear / cog | | War Magic | Orange-red | Crossed bolts | | Portal Magic | Purple | Swirl / portal | | Void Magic | Dark purple / black | Skull | (This needs a pcap/screenshot verification pass before the acdream UI uses it as authoritative.) ### 6.3 Spell level (I-VIII) Two formulas coexist: - **Server formula** (`Spell.Level`, ACE `Spell.cs:92`): walks the `SpellFormula.MinPower` table and returns the highest level whose `MinPower` is ≤ `spell.Power`: ``` {1:1, 2:50, 3:100, 4:150, 5:200, 6:250, 7:300, 8:400} ``` - **Client formula** (`Formula.Level`, ACE `SpellFormula.cs:177`): looks at the **first component** of the decrypted formula, which is always a scarab, and maps: ``` Lead→1, Iron→2, Copper→3, Silver→4, Gold→5, Pyreal→6, Diamond→6, Platinum→7, Dark→7, Mana→8 ``` The two disagree for a handful of hybrid spells (`LevelMatch == false`), e.g. Vitae-level creature buffs. For spellbook filtering and UI display, use the **client formula** — that's what retail renders. --- ## 7. Spellbook UI Layout (Paper-Doll Region) From the decompiled field offsets at `chunk_004C0000.c:5053-5060`, the spellbook panel has **8 tab lists** whose heads are at offsets `0x638, 0x654, 0x670, 0x68C, 0x6A8, 0x6C4, 0x6E0, 0x6FC` (stride = 0x1C bytes). Semantically these map to: 1. **Life Magic** (0x638) 2. **Creature Enchantment** (0x654) 3. **Item Enchantment** (0x670) 4. **War Magic** (0x68C) 5. **Portal Magic** (0x6A8) — the subset of Life with MetaSpellType in Portal* 6. **Void Magic** (0x6C4) 7. **Favorites** (0x6E0) — user-marked spells (`Character_AddSpellFavorite`, 0x01E3) 8. **Combined / search** (0x6FC) — flat list filtered by `Character_SpellbookFilterEvent` (0x0286) Each tab is a doubly-linked list of spell entries; tab-list+0x10 tracks the count (seen written as `*(iVar + 0x10) = *(iVar + 0x10) + 1` at `chunk_004C0000.c:4910`). The "spell bar" (hotbar for quick-cast) is separate — it's stored per-char in `CharacterPropertiesSpellBar` on the server (ACE `ACE.Database/Models/Shard/CharacterPropertiesSpellBar.cs`), keyed by `(SpellBarNumber, PositionInBar, SpellId)`. There are 8 bars (keyboard hotkeys F1-F8), each with up to 16 slots. Actions are `Character_AddShortCut` (0x019C) and `Character_RemoveShortCut` (0x019D) — these are generic shortcuts but retail uses them for spell-bar slots too. Double-click-to-cast vs click-and-hold: the client UI text at `chunk_004C0000.c:4917` is `"%hs\nDouble-click to cast this spell"` — the tip shown on hover over a spell icon in the book. --- ## 8. Wire Format — Every Opcode in a Single Cast ### 8.1 Client → Server (GameAction ordered 0xF7B1) **`Magic_CastUntargetedSpell` (opcode 0x0048)** — 4 bytes: ``` [uint32 spellId] // LayeredSpellId with layer=0 == same bytes as raw uint32 ``` **`Magic_CastTargetedSpell` (opcode 0x004A)** — 8 bytes: ``` [uint32 targetGuid][uint32 spellId] ``` Test fixtures from HLT (`magic/actions.rs:57-74`): ``` target=0x50000001, spellId=1234 → 01 00 00 50 D2 04 00 00 spellId=1234 → D2 04 00 00 ``` Related cast-chain C→S: | Opcode | Name | Payload | |--------|------|---------| | 0x0053 | Combat_ChangeCombatMode | `u32 newMode` (Magic=3) — client sends before casting | | 0x019C | Character_AddShortCut | shortcut-bar slot mapping | | 0x01A8 | Magic_RemoveSpell | `LayeredSpellId` — 4 bytes | | 0x01E3 | Character_AddSpellFavorite | `uint32 spellId`, `uint32 tabIdx` | | 0x01E4 | Character_RemoveSpellFavorite | `uint32 spellId`, `uint32 tabIdx` | | 0x0224 | Character_SetDesiredComponentLevel | `uint32 componentWcid`, `uint32 amount` | | 0x0286 | Character_SpellbookFilterEvent | `uint32 filterBitfield` | ### 8.2 Server → Client (GameEvent ordered 0xF7B0) | Opcode | Name | Payload | |--------|------|---------| | 0x01A8 | Magic_RemoveSpell | `LayeredSpellId` (ushort Id, ushort Layer) | | 0x02C1 | Magic_UpdateSpell | `LayeredSpellId` (**spellbook add**, not cast!) | | 0x02C2 | Magic_UpdateEnchantment | `Enchantment{LayeredSpellId, ushort hasSet, SpellCategory, u32 Power, f64 Start, f64 Dur, u32 CasterId, f32 DegradeMod, f32 DegradeLim, f64 LastDeg, StatMod{u32 Type, u32 Key, f32 Value}, [u32 EquipmentSet if hasSet]}` | | 0x02C3 | Magic_RemoveEnchantment | `LayeredSpellId` | | 0x02C4 | Magic_UpdateMultipleEnchantments | packable list of `Enchantment` | | 0x02C5 | Magic_RemoveMultipleEnchantments | packable list of `LayeredSpellId` | | 0x02C6 | Magic_PurgeEnchantments | (empty — purge all) | | 0x02C7 | Magic_DispelEnchantment | `Enchantment` (the dispelled one) | | 0x02C8 | Magic_DispelMultipleEnchantments | list of `Enchantment` | | 0x0312 | Magic_PurgeBadEnchantments | (empty — purge all bad) | ### 8.3 Supporting opcodes fired during cast | Opcode | Name | Context | |--------|------|---------| | 0x02BB | HearSpeech | **Spoken syllables**. Payload: `str16L messageText, str16L senderName, u32 senderId, u32 chatType=0x11 (Spellcasting)`. Broadcast to players within `LocalBroadcastRange` (~96m, ~recall radius). | | 0x01D6 | Script (PlayScript) | Caster VFX (CasterEffect), target VFX (TargetEffect), fizzle VFX, enchantment-apply VFX. | | 0x01C7 | UseDone (WeenieError) | Cast-failed codes: 0x3FC, 0x400, 0x401, 0x402, 0x403, 0x404, 0x407, 0x408, etc. | | 0x028A | UpdateAttribute2ndLevel | Post-cast: mana/health/stam update after consume-and-apply. | ### 8.4 LayeredSpellId exact shape CHZ `Types/LayeredSpellId.generated.cs`: ``` public class LayeredSpellId { public ushort Id; // spell id public ushort Layer; // 0 for fresh cast; nonzero disambiguates multi-instance enchantments } // Read/Write order: Id then Layer, little-endian ``` **Important wire note**: ACE's server-side parser of 0x004A / 0x0048 reads the spell id as a `uint32` (`GameActionMagicCastTargetedSpell.Handle:9`). This is byte-equivalent to `LayeredSpellId{Id, Layer=0}` because the layer field follows the id in memory and is zero on outgoing cast requests. Upstream parsers (holtburger, Chorizite) treat the field differently but the bytes are identical. --- ## 9. Port Plan — C# Classes for acdream ### 9.1 Dat-layer records (Data/Spells/) ```csharp public sealed record SpellDatEntry( uint Id, string Name, string Description, MagicSchool School, uint IconId, SpellCategory Category, SpellFlags Flags, uint BaseMana, float BaseRangeConstant, float BaseRangeMod, uint Power, float ComponentLoss, SpellType MetaSpellType, uint MetaSpellId, double Duration, // 0 for non-enchantment float DegradeModifier, float DegradeLimit, double PortalLifetime, // 0 unless PortalSummon ImmutableArray Components, // decrypted uint CasterEffect, uint TargetEffect, uint FizzleEffect, uint DisplayOrder, uint NonComponentTargetType, uint ManaMod ); public sealed record SpellComponentEntry( uint Id, string Name, uint CategoryMask, uint IconId, ComponentType Type, uint Gesture, // MotionCommand float Time, string SyllableText, float DestructionModifier // CDM ); public static class SpellComponentDecrypt { // ports DRW:SpellBase.DecryptComponents + ACE:SpellTable.ComputeHash public static uint StringHash(string s) { ... } // 4-bit rotating hash public static uint DeriveKey(string name, string desc) => (StringHash(name) % 0x12107680u) + (StringHash(desc) % 0xBEADCF45u); public static ImmutableArray Decrypt( ReadOnlySpan encrypted, string name, string desc) { ... } } ``` ### 9.2 Cast state machine (Game/Magic/) ```csharp public enum CastingPreCheckStatus { Success, CastFailed, InvalidPKStatus } public sealed class SpellCastRequest { public uint SpellId; public uint? TargetGuid; // null = untargeted public uint? CasterItemGuid; // null = normal cast } public sealed class SpellCastParams { public SpellDatEntry Spell; public IWorldObject? Target; public IWorldObject? CasterItem; public uint MagicSkill; public uint ManaUsed; public CastingPreCheckStatus Status; } public enum CastPhase { Idle, Validating, // verify spell/target/comp/stance Windup, // pre-gesture + spell words CastGesture, // final cast motion, roll happens at release Applying, // effect dispatched to target(s) Recovery, // recoil animation } public sealed class SpellCastStateMachine { public CastPhase Phase { get; private set; } public SpellCastParams? Current { get; private set; } public SpellCastRequest? Queued { get; private set; } // spellcast_recoil_queue public void OnCastStart(SpellCastRequest req); public void OnWindupComplete(); public void OnCastGestureComplete(); // → roll fizzle, spend mana+comps, dispatch effect public void OnRecoveryComplete(); public void OnInterrupt(WeenieError reason); } ``` ### 9.3 Active enchantment / buff table ```csharp public sealed class ActiveBuff { public ushort SpellId; public ushort Layer; public SpellCategory Category; public uint Power; public DateTime StartTimeUtc; public double DurationSeconds; // -1 = while-equipped public uint CasterGuid; public float DegradeModifier; public float DegradeLimit; public DateTime LastDegradeUtc; public EnchantmentStatMod StatMod; public EquipmentSet? EquipmentSet; } public sealed record EnchantmentStatMod( EnchantmentTypeFlags Type, uint Key, float Value); public sealed class EnchantmentRegistry { public IReadOnlyList LifeSpells; public IReadOnlyList CreatureSpells; public ActiveBuff? Vitae; public IReadOnlyList Cooldowns; public void ApplyOrReplace(ActiveBuff buff); // stacking by Category public bool Remove(ushort spellId, ushort layer); public void PurgeAll(bool onlyBad); } ``` ### 9.4 Validation + rolls (Game/Magic/) ```csharp public static class MagicFormulas { public const float MagicSkillSigmoidFactor = 0.07f; public static double GetSkillChance(int skill, int difficulty, float factor = 0.03f) => Math.Clamp(1.0 - (1.0 / (1.0 + Math.Exp(factor * (skill - difficulty)))), 0.0, 1.0); public static double GetMagicSkillChance(int skill, int difficulty) => GetSkillChance(skill, difficulty, MagicSkillSigmoidFactor); public static CastingPreCheckStatus RollPreCheck( uint magicSkill, uint difficulty, IRng rng, bool isWeaponSpell) { if (isWeaponSpell) return CastingPreCheckStatus.Success; if (magicSkill == 0 || magicSkill < (int)difficulty - 50) return CastingPreCheckStatus.CastFailed; var chance = GetMagicSkillChance((int)magicSkill, (int)difficulty); return chance > rng.NextSingle() ? CastingPreCheckStatus.Success : CastingPreCheckStatus.CastFailed; } public static uint CalcManaCost(SpellDatEntry s, uint baseManaOverride, /* ... */); public static List RollBurnedComponents( SpellDatEntry s, IReadOnlyList resolved, uint skillCurrent, IRng rng); } ``` ### 9.5 Wire (Network/Magic/) ```csharp // Client outgoing public sealed class MagicCastUntargetedSpell : ClientMessage { public const uint Opcode = 0x0048; public ushort SpellId; public ushort Layer; // 0 on cast } public sealed class MagicCastTargetedSpell : ClientMessage { public const uint Opcode = 0x004A; public uint TargetGuid; public ushort SpellId; public ushort Layer; // 0 on cast } // Server incoming (subset relevant to cast flow) public sealed class MagicUpdateSpell : ServerEvent { /* 0x02C1 */ } public sealed class MagicUpdateEnchantment : ServerEvent { /* 0x02C2 */ } public sealed class MagicRemoveEnchantment : ServerEvent { /* 0x02C3 */ } public sealed class MagicUpdateMultipleEnchantments : ServerEvent { /* 0x02C4 */ } public sealed class MagicRemoveSpell : ServerEvent { /* 0x01A8 */ } public sealed class HearSpeechSpellcasting : ServerEvent { /* 0x02BB w/ chatType=0x11 */ } public sealed class WeenieError : ServerEvent { /* 0x01C7 */ } ``` ### 9.6 Integration points - `IGameState.ActiveBuffs { get; }` — exposed to plugins (per plugin rule). - `IGameState.Spellbook { get; }` — readonly list of known `SpellDatEntry`. - `IEvents.SpellCastStarted`, `IEvents.SpellCastSucceeded`, `IEvents.SpellCastFizzled`, `IEvents.EnchantmentApplied`, `IEvents.EnchantmentRemoved` — plugin-visible. --- ## 10. Conformance Tests (Golden Values) These are the first targets to port into `acdream.Tests/SpellSystemTests.cs` once the dat loader + formula code is written. ### 10.1 Component decryption round-trip For any SpellBase loaded from live dat: ``` Decrypt(Encrypt(comps, name, desc), name, desc) == comps ``` and ``` every nonzero component in the decrypted list ∈ SpellComponentTable.Components ``` This is the test DRW itself relies on (`DatReaderWriter.Tests/Types/SpellBaseTests.cs`). ### 10.2 Sigmoid fizzle chance at known inputs ``` MagicFormulas.GetMagicSkillChance(100, 100) == 0.5 ± 1e-9 MagicFormulas.GetMagicSkillChance(150, 100) ≈ 0.9706877692 MagicFormulas.GetMagicSkillChance( 50, 100) ≈ 0.0293122308 MagicFormulas.GetMagicSkillChance(200, 150) ≈ 0.9706877692 // symmetry +50 ``` ### 10.3 Floor / gating ``` RollPreCheck(skill= 49, diff=100, rng=…, isWeapon=false) → CastFailed RollPreCheck(skill= 50, diff=100, rng=…, isWeapon=false) → rolls normally RollPreCheck(skill= 0, diff= 1, rng=…, isWeapon=false) → CastFailed RollPreCheck(skill= 1, diff=999, rng=…, isWeapon=true ) → Success // weapon override ``` ### 10.4 Spell data — `SpellId 6` (Heal Self I) From end-of-retail portal.dat (verify during bring-up; these are the expected values cross-checked from ACE seed): ``` Id = 6 School = LifeMagic MetaSpellType= Boost Category = HealingRaising (67) // not sure; verify Power = 1 BaseMana = 5 BaseRangeConstant = 0.0 BaseRangeMod = 0.0 // self-range Flags.SelfTargeted = true Flags.Beneficial = true Components (decrypted) = [Scarab.Lead(1), , ] ``` Test: `SpellTable.Spells[6].BaseMana == 5` (exact). ### 10.5 Spell words formation For `Heal Self I`, the `GetSpellWords(Formula)` should return the concatenation of the Herb-component's `Text` + capitalized Powder-component's `Text` + lowercased Potion-component's `Text`, joined with a single space: Expected format: `" "` (second-word-first-letter capitalized). ### 10.6 Wire round-trip (matches holtburger fixtures) ```csharp // TargetedCast Assert.Equal( new byte[] { 0x01, 0x00, 0x00, 0x50, 0xD2, 0x04, 0x00, 0x00 }, PackMagicCastTargetedSpell(targetGuid: 0x50000001, spellId: 1234, layer: 0)); // UntargetedCast Assert.Equal( new byte[] { 0xD2, 0x04, 0x00, 0x00 }, PackMagicCastUntargetedSpell(spellId: 1234, layer: 0)); ``` ### 10.7 Stacking rule Apply `StrengthSelf3 (power=100)` then `StrengthSelf4 (power=150)` on same caster → only the latter appears in `EnchantmentRegistry.CreatureSpells`; apply `StrengthSelf3` again → still only the higher-power one stays. --- ## 11. Open Questions / Flagged Uncertainties 1. **Fizzle mana cost = 5**: ACE says "todo: verify with retail" (`Player_Magic.cs:572`). Need a retail pcap showing mana delta on a fizzle. 2. **School color scheme**: §6.2 is inferred from ACViewer UI, not confirmed in the decompiled client's UI atlas code. Ideal to pcap a live retail server once or sniff texture DIDs. 3. **spellcast_max_angle**: ACE exposes this as a property, default unclear for retail. Watch the `chunk_004C0000.c` angle check in `IsWithinAngle` for a numeric literal. 4. **War↔Void interference timer**: ACE uses `random(3.0, 5.0)` seconds; the actual retail formula may be deterministic. 5. **ItemEnchantment target-set mana bonus** (§2.5): ACE applies `ManaMod * numEnchantableEquipped` only for the `ArmorValueRaising..AcidicResistanceLowering` category range. Verify this range in retail. 6. **DisplayOrder usage**: How does the retail spellbook sort entries? Alpha by Name, by DisplayOrder, by Id, or per-school alpha? Watch the sort compare fn near `chunk_004C0000.c:5053`. 7. **Shortcut bar dwell-cast animation**: when casting from a hotbar (F1-F8), does the client skip the spell-book double-click and send a different opcode, or just re-use 0x004A/0x0048? --- ## 12. Summary of Function Map Additions Add to `docs/research/acclient_function_map.md`: ``` FUN_004c7620 → CSpellBookPanel::OnCastButton (chunk_004C0000.c:5018) Dispatches selected tab spell; formats "CAST %s [on %s]" chat echo; sends 0x004A/0x0048 game-action. FUN_00567eb0 → CSpellTable::GetSpellById (likely; called from both the click handler and the syllable-playback path) FUN_00597d40 → Heap-pointer fetch helper used extensively by spell UI (returns *ptr + 0x14 for WString member access). FUN_00567c00 → CSpellTable::Instance getter (returns 0 pre-login) FUN_00564d30 → CGameEventManager::DispatchWeenieError, switch on param_1 code starting at chunk_00570000.c:1705 chunk_00590000.c:6580-6630 → CSpellBase::Decrypt — reads Name+Desc, computes key = (hash(name) % 0x12107680) + (hash(desc) % 0xbeadcf45), XOR-subtracts each component by key. ``` --- **End of R01 spell-system deep-dive.** Next R-slices that depend on this: - R04 (combat system) needs §2 validation and §5 projectile taxonomy. - R07 (UI + paper-doll) needs §7 spellbook offsets. - R11 (enchantment lifetime + degrade) needs §5 stacking + §8.2 wire.