acdream/docs/research/deepdives/r01-spell-system.md
Erik 3f913f1999 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.
2026-04-18 10:32:44 +02:00

42 KiB

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.csreferences/DatReaderWriter authority on dat shape
  • CHZ: path/to/File.csreferences/Chorizite.ACProtocol protocol XML + gen C#
  • HLT: path/to/File.rsreferences/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<uint, SpellBase>
SpellsSets: PHashTable<EquipmentSet, SpellSet>

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<uint, SpellComponentBase>.

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 <enum name="SpellCategory"> 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 == ItemEnchantmentNonComponentTargetType 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 <prev> energies permeating your blood cause this <curr> 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 <SpellName>" (wide-string at chunk_004C0000.c:5112), and appends " on <TargetName>" 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 VerifySpellSpellIsKnown 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 DoSpellWordsGameMessageHearSpeech 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/)

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<uint> 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<uint> Decrypt(
        ReadOnlySpan<uint> encrypted, string name, string desc) { ... }
}

9.2 Cast state machine (Game/Magic/)

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

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<ActiveBuff> LifeSpells;
    public IReadOnlyList<ActiveBuff> CreatureSpells;
    public ActiveBuff? Vitae;
    public IReadOnlyList<ActiveBuff> 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/)

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<uint> RollBurnedComponents(
        SpellDatEntry s, IReadOnlyList<SpellComponentEntry> resolved,
        uint skillCurrent, IRng rng);
}

9.5 Wire (Network/Magic/)

// 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), <herb>, <talisman>]

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: "<herb> <Powder><potion>" (second-word-first-letter capitalized).

10.6 Wire round-trip (matches holtburger fixtures)

// 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.