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

1049 lines
42 KiB
Markdown

# 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<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<RenderSurface> | 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 == 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 <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 | `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<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/)
```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<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/)
```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<uint> RollBurnedComponents(
SpellDatEntry s, IReadOnlyList<SpellComponentEntry> 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), <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)
```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.