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.
1049 lines
42 KiB
Markdown
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.
|