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.
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 retailacclient.exeACE: path/to/File.cs:line— thereferences/ACEauthority on server rulesDRW: path/to/File.cs—references/DatReaderWriterauthority on dat shapeCHZ: path/to/File.cs—references/Chorizite.ACProtocolprotocol XML + gen C#HLT: path/to/File.rs—references/holtburgerclient-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 casterFlags.FellowshipSpell(or MetaSpellType inFellowBoost..FellowDispel) — targets all fellowsSchool == ItemEnchantment→NonComponentTargetTypegates which item classes it can be cast on- Otherwise (LifeMagic / CreatureEnchantment / WarMagic / VoidMagic) → target
must be a
Creature(see ACEIsInvalidTargetbelow)
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.IsEnchantablespell.SelfTargeted && target != casterspell.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:
FUN_00567c00()/FUN_00567eb0()— look up theSpellBasefrom the currently-selected tab slot.- If the selection succeeded, the client formats a chat echo:
"CAST <SpellName>"(wide-string atchunk_004C0000.c:5112), and appends" on <TargetName>"if a non-self target is also selected (chunk_004C0000.c:5117). - Sends the
Magic_CastTargetedSpell(0x004A) orMagic_CastUntargetedSpell(0x0048) action via the game-action queue (which layer goes through the 0xF7B1 ordered game-action framing). - 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.Durationfield is the base seconds.- A caster item with
ItemCurMana > 0and matchingSpellDIDproduces a permanent (-1) enchantment —Duration = -1signals "while equipped". Vitaeuses a fixed Category = 204 and itsHasEquipmentSet=false,StartTime/Durationare 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, ACESpell.cs:92): walks theSpellFormula.MinPowertable and returns the highest level whoseMinPoweris ≤spell.Power:{1:1, 2:50, 3:100, 4:150, 5:200, 6:250, 7:300, 8:400} -
Client formula (
Formula.Level, ACESpellFormula.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:
- Life Magic (0x638)
- Creature Enchantment (0x654)
- Item Enchantment (0x670)
- War Magic (0x68C)
- Portal Magic (0x6A8) — the subset of Life with MetaSpellType in Portal*
- Void Magic (0x6C4)
- Favorites (0x6E0) — user-marked spells (
Character_AddSpellFavorite, 0x01E3) - 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 knownSpellDatEntry.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
- Fizzle mana cost = 5: ACE says "todo: verify with retail"
(
Player_Magic.cs:572). Need a retail pcap showing mana delta on a fizzle. - 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.
- spellcast_max_angle: ACE exposes this as a property, default unclear
for retail. Watch the
chunk_004C0000.cangle check inIsWithinAnglefor a numeric literal. - War↔Void interference timer: ACE uses
random(3.0, 5.0)seconds; the actual retail formula may be deterministic. - ItemEnchantment target-set mana bonus (§2.5): ACE applies
ManaMod * numEnchantableEquippedonly for theArmorValueRaising..AcidicResistanceLoweringcategory range. Verify this range in retail. - 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. - 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.