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.
45 KiB
R2 — Retail AC Combat System (Deep Dive)
Scope: The complete physical combat system as shipped in retail Asheron's Call: attack modes, attack types, attack heights, the power/accuracy bar, the full damage formula, crit mechanics, evasion, body-part targeting, resistances, wire format, and PvP differences. This is the authoritative reference for porting combat into acdream.
Sources used:
acclient.exedecompilation, primarilychunk_00560000.c(combat HUD + notification handlers) — FUN_0056AC80 family and FUN_0056D370 family have the "You hit / You evaded / Critical!" text formatting and the AttackConditions bit-decode. Client-side combat is mostly presentation; the math lives on the server, but the wire format and the constants the client bakes in are ground truth.- ACE server-side port:
DamageEvent.cs,Player_Combat.cs,Player_Melee.cs,Creature_Combat.cs,Creature_BodyPart.cs,BodyPartTable.cs,WorldObject_Weapon.cs,SkillCheck.cs,SkillFormula.cs,Armor.cs,AttackQueue.cs. ACE's formulas are the most complete open-source reconstruction; they match retail pcaps and data-mined formulas. - Chorizite.ACProtocol generated types for the wire definitions of
C2S
Combat_TargetedMeleeAttack/TargetedMissileAttackand S2CCombat_HandleAttackerNotificationEvent/HandleDefenderNotificationEventplus evasion + death. docs/research/acclient_function_map.mdfor correlating decompiled FUNs.
A note on retail vs ACE. The retail client does not calculate damage — it sends intent (
target_guid,attack_height,power_level) and receives the resulting damage number + flags. The formulas below are the server's job; for acdream (a client), we implement them so we can (a) predict outcomes for the power bar preview, (b) verify what we receive against what we'd expect, and (c) so the plugin API can surface "why did my attack do 47?" telemetry. The actual authority is whatever the ACE server sends back.
1. Attack modes (CombatMode enum)
Retail has five values, bit-packed. Source:
ACE.Entity.Enum.CombatMode:
| Bit | Name | Purpose |
|---|---|---|
| 0x00 | Undef |
Uninitialized / server-side only |
| 0x01 | NonCombat |
"Peace mode" — weapon sheathed, cannot attack |
| 0x02 | Melee |
Weapon drawn, melee swing animations available |
| 0x04 | Missile |
Bow/crossbow/atlatl drawn, arrow nocked |
| 0x08 | Magic |
Wand/orb ready, casting stance |
Helpers: ValidCombat = NonCombat|Melee|Missile|Magic,
CombatCombat = Melee|Missile|Magic (anything that actually swings).
Transitions. The client sends Combat_ChangeCombatMode (GameAction
0x0053) carrying a single uint32 Mode. The server plays the stance-swap
animations and responds with PrivateUpdatePropertyInt(CombatMode, newMode).
The swap takes real time — you cannot attack during it. ACE's
SetCombatMode calls MotionTable.GetAnimationLength(MotionTableId, Stance, MotionCommand.Ready, targetMotion) and queues the attack
window to fire after that animLength. For weapon swap (e.g. bow →
sword), the client does currentStance → NonCombat → HandCombat → NonCombat → newStance, with each hop incurring its own animLength; ACE's
SwitchCombatStyles() computes the sum.
MotionStance is the fine-grained counterpart tied to held-item
geometry. Values: NonCombat, HandCombat (unarmed), SwordCombat,
SwordShieldCombat, TwoHandedSwordCombat, BowCombat, CrossbowCombat,
AtlatlCombat, SlingCombat, ThrownWeaponCombat, ThrownShieldCombat,
DualWieldCombat, Magic. The translation from weapon to stance lives
in Creature_Combat.GetWeaponStance(WorldObject weapon):
CombatStyle.OneHanded → SwordCombat
CombatStyle.OneHandedAndShield→ SwordShieldCombat
CombatStyle.TwoHanded → TwoHandedSwordCombat
CombatStyle.Bow → BowCombat
CombatStyle.Crossbow → CrossbowCombat
CombatStyle.Atlatl → AtlatlCombat
CombatStyle.Sling → SlingCombat
CombatStyle.ThrownWeapon → ThrownWeaponCombat
CombatStyle.ThrownShield → ThrownShieldCombat
CombatStyle.DualWield → DualWieldCombat
CombatStyle.Unarmed → HandCombat
CombatStyle.Magic → Magic
If a shield is also equipped and the stance is SwordCombat or
ThrownWeaponCombat, it is promoted to the …ShieldCombat variant.
2. Attack types (AttackType enum + weapon styles)
Retail stores attack type as a bitflag because a single weapon can offer
multiple animations (thrust OR slash, pickable with the power bar). From
AttackType.cs:
| Bit | Flag | Notes |
|---|---|---|
| 0x0001 | Punch |
Unarmed jab |
| 0x0002 | Thrust |
Single-weapon thrust |
| 0x0004 | Slash |
Single-weapon slash |
| 0x0008 | Kick |
High-power unarmed |
| 0x0010 | OffhandPunch |
Dual-wield left-hand punch |
| 0x0020 | DoubleSlash |
Rapier / scimitar style |
| 0x0040 | TripleSlash |
Quickness / fast swords |
| 0x0080 | DoubleThrust |
Stiletto style |
| 0x0100 | TripleThrust |
Spears / jambiya |
| 0x0200 | OffhandThrust |
|
| 0x0400 | OffhandSlash |
|
| 0x0800 | OffhandDoubleSlash |
|
| 0x1000 | OffhandTripleSlash |
|
| 0x2000 | OffhandDoubleThrust |
|
| 0x4000 | OffhandTripleThrust |
Composites: Unarmed = Punch|Kick|OffhandPunch;
DoubleStrike, TripleStrike, MultiStrike = Double|Triple;
Offhand = <all offhand bits>;
Thrusts, Slashes, Punches (the single-technique groupings).
How a weapon picks its animation. WorldObject.GetAttackType(stance, powerLevel, offhand) (ACE WorldObject_Weapon.cs:1050):
- If
offhandis true, return the offhand variant. - Start from the weapon's
W_AttackTypebitfield. - Apply stance-specific overrides. The key rule: many weapons have both
Thrust and Slash. The ThrustThreshold (=
0.33f, sourced from the Dark Majesty Strategy Guide p.150) gates which one is used:
Some weapons usepowerLevel < 0.33 → use Thrust powerLevel >= 0.33 → use SlashDoubleThrust|DoubleSlashorTripleThrust|TripleSlashpairs; the threshold still applies. - Shield stance forces thrust on multi-strike weapons; sword stance (no shield) forces slash.
Multi-strike to single-strike reduction. AttackType.ReduceMultiStrike
collapses Double/Triple{Thrust,Slash} → {Thrust,Slash} for the
damage-type decision, because multi-strike only affects the animation
loop and swing count, not what body-parts it hits or what damage type
it applies.
GetNumStrikes. Returns 1, 2, or 3 based on the flag. The player's
Attack() function divides the anim length by numStrikes and fires a
DamageTarget() call per sub-strike (each one rolling its own damage,
its own crit, its own evasion). One power-bar commit = N damage rolls.
From the decompiled client side (chunk_00560000.c:8186), the
formatted hit message is literally:
FUN_00406500(&puStack_58, "You %s %s for %d point%s of %sdamage!",
verb, targetName, damage, pluralS, damageTypeStr);
Where verb is produced from local_50 + 5 — a table keyed off attack
type (e.g. "slash", "thrust", "pierce"). So the retail HUD verbifies the
attack type.
3. Attack heights (AttackHeight enum)
From AttackHeight.cs:
High = 1
Medium = 2
Low = 3
The client sends this as a uint32 in both melee and missile attack
packets. Its effect on damage flows through body-part selection,
not a direct damage multiplier. The flow:
- Retail builds a Quadrant by OR-ing the height with the attacker's
relative direction:
quadrant = attackHeight.ToQuadrant() | attacker.GetRelativeDir(defender); - The defender's
BodyPartTablehas 12 buckets, one per quadrant:[HLF MLF LLF | HRF MRF LRF | HLB MLB LLB | HRB MRB LRB](where H/M/L = High/Medium/Low, L/R = Left/Right, F/B = Front/Back). - Each creature weenie's
PropertiesBodyParthas per-quadrant probabilities;BodyPartTable.RollBodyPart(quadrant)weighted-random selects one. - The selected
CombatBodyParthas its own armor and its own crit multiplier.
For player defenders (the retail client fills this in differently)
the flow is simpler because player body-parts are not in a full weenie
table; BodyParts.GetBodyPart(AttackHeight) picks uniformly from:
High → Head, Chest, UpperArm
Medium → Chest, Abdomen, UpperArm, LowerArm, Hand, UpperLeg
Low → Foot, LowerLeg
The selected body part then maps to a CoverageMask which selects which
armor/clothing pieces resist the hit. Medium attacks hit the meatiest
torso zones and therefore usually connect with chest armor, which is
typically the best-AL piece — this is why players who want to mitigate
incoming damage should buff chest pieces. Low attacks hit feet/legs
which often have lower AL — low attacks are typically more damaging
on well-geared players.
On monsters, height × quadrant distributes across 27 possible
CombatBodyPart values (Head, Chest, Abdomen, UpperArm, LowerArm, Hand,
UpperLeg, LowerLeg, Foot, Horn, FrontLeg, FrontFoot, RearLeg, RearFoot,
Torso, Tail, Arm, Leg, Claw, Wings, Breath, Tentacle, UpperTentacle,
LowerTentacle, Cloak — enum CombatBodyPart). Different monsters have
different coverage; a drudge has limbs, an olthoi has mandibles + abdomen
- legs. The weenie table encodes this.
4. Power bar / accuracy bar
The power bar is a user-charged attack intensity meter, 0.0 → 1.0.
The client holds the attack key to fill it; releasing fires an attack
packet carrying the current value in the Power or Accuracy float
field. Player_Melee.cs:
public float PowerLevel { get; set; } // 0.0..1.0
public float AccuracyLevel{ get; set; } // 0.0..1.0 (bows)
public float GetPowerAccuracyBar() =>
GetCombatType() == CombatType.Missile ? AccuracyLevel : PowerLevel;
public PowerAccuracy GetPowerRange() =>
PowerLevel < 0.33f ? Low :
PowerLevel < 0.66f ? Medium :
High;
How long does it take to fill? ACE's refill model:
NextRefillTime = now + PowerLevel * refillMod after an attack lands,
where refillMod = 0.8f for dual-wield (20% faster) else 1.0. Retail
uses approximately 1 second for full power from a hard release; charging
to 50% bar = ~0.5s, full = ~1.0s. The exact tick rate is handled by the
client's UI animation; the server only sees the final PowerLevel
float the client sends and trusts it (this is why power-bar-hacks
historically existed — no server-side verification).
Effect of power on damage math
Player_Combat.cs:
public override float GetPowerMod(WorldObject weapon)
{
if (weapon == null || !weapon.IsRanged)
return PowerLevel + 0.5f; // → [0.5, 1.5] range for melee/thrown
else
return 1.0f; // melee power-bar inert for bows
}
public override float GetAccuracyMod(WorldObject weapon)
{
if (weapon != null && weapon.IsRanged)
return AccuracyLevel + 0.6f;// → [0.6, 1.6] range for bows
else
return 1.0f;
}
So the effective multiplier ranges:
- Melee / thrown:
PowerMod ∈ [0.5, 1.5], applied as a damage multiplier. Zero-bar does half damage, full-bar does 1.5×. - Bows / crossbows / atlatls:
AccuracyMod ∈ [0.6, 1.6], applied as an attack-skill multiplier (makes you more likely to hit, not hit harder). Bow damage is power-bar-inert; bows have a separateDamageModattribute on the launcher (e.g. Yumi = 2.13).
Stamina cost. Player.GetAttackStamina(PowerAccuracy) from
Player_Combat.cs:621 interpolates a cost from a 3×3 table keyed by
bucket (Low/Med/High) and held-item burden, then scales down by an
Endurance bonus (caps at 50% reduction at ~290 Endurance). Low-bar
thrust costs 1 stam per 700 burden; high-bar full swing costs 2 per 700,
4 per 1200, 6 per 1600. Running out of stamina drops defense to 0 AND
halves your weapon skill.
5. Damage formula — the full retail expression
Canonical form, exactly as implemented in DamageEvent.DoCalculateDamage:
// PHASE 1 — hit?
evasionChance = 1 - SkillCheck.GetSkillChance(
effectiveAttackSkill,
effectiveDefenseSkill,
0.03f);
if (rng() < evasionChance) → EVADE, damage = 0, exit.
// PHASE 2 — base damage roll
baseDamage = rng(weapon.MinDamage, weapon.MaxDamage) // uniform
// PHASE 3 — pre-mitigation multipliers
damageBefore =
baseDamage
* attributeMod // [1.0 … 5.5] based on Str/Coord
* powerMod // [0.5 … 1.5] melee, 1.0 missile
* slayerMod // 1.0 base, ×bonus vs slayer type
* damageRatingMod // additive combine (see §6)
// PHASE 4 — CRITICAL override (rolled after non-crit compute)
if (rng() < criticalChance) {
damageBefore = weapon.MaxDamage
* attributeMod
* powerMod
* slayerMod
* damageRatingMod // recomputed, no Recklessness in crits
* criticalDamageMod; // 1 + wepMultiplier
}
// PHASE 5 — mitigation
damage = damageBefore
* armorMod // AL → 200/3 / (AL + 200/3)
* shieldMod // shield SL absorption
* resistanceMod // natural + buff resists
* damageResistanceRatingMod; // DRR from augmentations
return round(damage);
Every factor is a multiplicative float, applied in order. AdditiveCombine
is used within the damageRatingMod slot — that's where DR, recklessness,
sneak attack, and heritage stack additively (see §6). Everything else is
pure multiplication.
Component details
baseDamage — BaseDamageMod on the weapon wraps a BaseDamage(max, variance) plus enchantments. Actual min = max × (1 − variance × VarianceMod).
ThreadSafeRandom.Next(min, max) uniformly samples. Blood Drinker
enchantments add a flat DamageBonus. Missile launchers (bows) add
ElementalBonus (additive int) when the ammo damage type matches the bow's
imbued element.
attributeMod — from SkillFormula.GetAttributeMod:
public const float DefaultMod = 0.011f; // melee, finesse, thrown, atlatl
public const float BowMod = 0.008f; // bows, crossbows
attributeMod = max(1.0f, 1.0f + (currentSkill - 55) * factor)
Note: despite the name this is not a direct attribute stat — it's the
current weapon skill being damage-scaled. The name comes from the fact
that in pre-MoA AC it WAS tied to the primary attribute. Post-MoA, it's
the combat skill. A 400 Heavy Weapons skill gives
1 + (400-55)*0.011 = 1 + 3.795 = 4.795. A 200 bow skill gives
1 + 145*0.008 = 2.16.
powerMod — see §4. PowerLevel + 0.5 melee, 1.0 missile.
slayerMod — WorldObject.GetWeaponCreatureSlayerModifier. If the
weapon has a SlayerCreatureType matching the target's CreatureType,
returns the stored SlayerDamageBonus (values like 1.5–3.0 in data). Else
1.0.
damageRatingMod — Creature.AdditiveCombine(damageRatingBaseMod, recklessnessMod, sneakAttackMod, heritageMod [, pkDamageMod]). Each
input is something like 1.xx, and AdditiveCombine does:
(x1 − 1) + (x2 − 1) + … + 1
i.e. additive-in-delta. So 1.10 × 1.20 ≠ AdditiveCombine(1.10, 1.20) = 1.30.
armorMod — see §9.
shieldMod — GetShieldMod. If the attacker is within the shield's
effective arc (default 180° front cone), compute
effectiveSL = baseSL + impenMods,
effectiveRL = baseRL + baneMods (clamped [-2, +2]),
effectiveLevel = effectiveSL × effectiveRL, capped at Shield skill
(skill.Current if specialized, else skill.Current / 2). Final
shieldMod = SkillFormula.CalcArmorMod(effectiveLevel).
resistanceMod — §10.
damageResistanceRatingMod — rating-based mitigation from
augmentations/buffs/imbues, converted via GetNegativeRatingMod.
Worked examples
Example A — low-level Chorizon, swords, full bar, hits a drudge:
- Heavy Weapons skill 125, Strength 70, no enchantments
- Weapon: Iron Sword (max 12, variance 0.5 → min 6)
- Target: Drudge Skulker — BaseArmor 24 on chest, natural 0 resists
- PowerLevel 1.0, no crit, no aug DR, front-left medium quadrant hit
Steps:
baseDamage = rng(6, 12) = 9(sample)attributeMod = 1 + (125 − 55)×0.011 = 1 + 0.77 = 1.77powerMod = 1.0 + 0.5 = 1.5slayerMod = 1.0,damageRatingMod = 1.0damageBefore = 9 × 1.77 × 1.5 × 1.0 × 1.0 = 23.895- Drudge chest:
effectiveAL = 24 × 1.0 = 24,armorMod = (200/3) / (24 + 200/3) = 66.67 / 90.67 = 0.735 resistanceMod = 1.0(drudge: slash neutral)damage = 23.895 × 0.735 × 1.0 × 1.0 × 1.0 = 17.56- Final:
18 damage (slash)
Example B — mid-level with crit:
- Light Weapons skill 300, Coordination 120, Biting Strike rapier
- Weapon: Bone Rapier (max 32, variance 0.4, CritFrequency 0.15)
- Target: Banderling — BaseArmor 50 on chest, 1.3× pierce vulnerability
- PowerLevel 0.3 → thrust, crit rolls TRUE
Steps:
attributeMod = 1 + (300 − 55)×0.011 = 1 + 2.695 = 3.695powerMod = 0.3 + 0.5 = 0.8- On crit,
baseDamage= weapon max = 32 (not rolled) critDmgMod = 1.0 + 1.0 = 2.0(default weapon multiplier)damageBefore = 32 × 3.695 × 0.8 × 1.0 × 1.0 × 2.0 = 189.1- Banderling chest:
effectiveAL = 50,armorMod = 66.67 / 116.67 = 0.571 resistanceMod = 1.3(pierce vuln)damage = 189.1 × 0.571 × 1.3 × 1.0 = 140.4- Final:
140 damage (pierce)— "Critical hit!"
Example C — bow vs naked PvP target:
- Missile Weapons skill 350, Coord 160
- Weapon: Yumi (max 45, var 0.3, DamageMod 2.13 → "max" = 45 × 2.13 = 96)
- Ammo: Cold-imbued arrows (+20 elemental bonus)
- Target: player, 0 natural Cold resist, Prot Cold 6 (res 0.56)
- AccuracyLevel 1.0, no crit, no sneak, NPK so no PK scale
Steps:
maxDamage = (45 + 0 + 20) × 2.13 = 138.45,minDamage = 138.45 × 0.7 = 96.9baseDamage = rng(96.9, 138.45) = 118sampledattributeMod = 1 + (350 − 55)×0.008 = 1 + 2.36 = 3.36powerMod = 1.0(bows ignore power bar for damage)damageBefore = 118 × 3.36 × 1.0 × 1.0 × 1.0 = 396.5attackSkill = skill × accuracyMod × offenseMod = 350 × 1.6 × 1.0 = 560(accuracy boosts hit chance, not damage)- Target chest clothes only:
effectiveAL = 12 + 0 = 12,armorMod = 66.67 / 78.67 = 0.847 resistanceMod = 0.56(Prot Cold 6)damage = 396.5 × 0.847 × 0.56 = 188.1- Final:
188 damage (cold)
6. Critical hits
Base crit chance:
- Physical: 10% (
defaultPhysicalCritFrequency = 0.1finWorldObject_Weapon.cs:291). - Magic: 5% (
defaultMagicCritFrequency = 0.05f, post-Iron Coast release; was 2% pre-Atonement).
Per-weapon override via PropertyFloat.CriticalFrequency (e.g.
Biting Strike quest weapons 15%).
Critical Strike imbue: Math.Max(critRate, GetCriticalStrikeMod(skill))
— a scaling function that rewards high attack skill. At low skill it's
10%, at 400+ skill it can push physical critical chance past 30%.
Crit Rating: critRate += wielder.GetCritRating() * 0.01f. Each
point of Crit Rating = +1% flat.
Crit Resist Rating: mitigates incoming crits as a multiplicative
reduction via GetNegativeRatingMod — rating 20 → critRate ×= 0.833.
Critical damage multiplier: CriticalDamageMod = 1.0 + weapon.CriticalMultiplier where CriticalMultiplier defaults to
1.0f (so default crit is 2× max damage). Crippling Blow imbue
replaces this when higher via Math.Max.
Crit damage bookkeeping:
- On crit,
DamageBeforeMitigation = weapon.MaxDamage × attributeMod × powerMod × slayerMod × damageRatingMod × critDamageMod. Note baseDamage is replaced by max damage, not re-rolled. - Recklessness is set to 1.0 on crits (doesn't stack with crit
multiplier — retail design decision explicit in the code comment
// recklessness excluded from crits). CriticalDamageRatingMod(from Crit Damage Rating augmentation) replaces Recklessness in the rating stack for the crit calculation.
Critical Defense augmentation: if the defender has the augmentation and a crit is rolled, there's a secondary roll:
criticalDefenseChance = augRank * 0.05f // vs player attacker
criticalDefenseChance = augRank * 0.25f // vs monster attacker
If that succeeds, CriticalDefended = true; the attacker still hits
but with normal (non-crit) damage, and the AttackConditions bit 0x01 is
set. The hit message adds: "Your target's Critical Protection augmentation allows them to avoid your critical hit!" (the literal
string lives at 0x00560000.c:8191).
Logoff auto-crit: if the defender is logging out OR in a PK logout
freeze window (2 min post-PvP), criticalChance = 1.0f. From the dev
notes (ACE comments cite 2002/08 Atonement): "any time a character is
logging off, PK or not, all physical attacks against them become
automatically critical. (Note that spells do not share this behavior.)"
7. Defense formula — attack skill vs defense skill
The core mechanic uses a logistic curve on skill difference.
SkillCheck.GetSkillChance:
public static double GetSkillChance(int skill, int difficulty,
float factor = 0.03f)
{
var chance = 1.0 - (1.0 / (1.0 + Math.Exp(factor * (skill - difficulty))));
return Math.Clamp(chance, 0.0, 1.0);
}
- Physical combat:
factor = 0.03f. Equal skill = 50% hit. +50 skill advantage → ~81.8% hit. +100 → ~95.3%. +200 → ~99.75%. −50 skill → ~18%. −100 → ~4.7%. - Magic:
factor = 0.07f— the same curve but steeper. Equal skill is still 50% resist, but +50 skill advantage is already 97%. This is why magic defense drops off fast when underskilled.
EvadeChance = 1 − hitChance. If rng() < EvadeChance, the attack
is evaded (no damage, no stamina cost to attacker, 1 stamina cost to
defender in combat mode).
Effective skills
Attacker (player) effective attack skill:
effAttack = round(weaponSkill * accuracyMod * offenseMod)
weaponSkill= current skill (post-enchantments) of the equipped weapon's skill category (LightWeapons / HeavyWeapons / FinesseWeapons / MissileWeapons / TwoHandedCombat / DualWield).accuracyMod=AccuracyLevel + 0.6for bows, else 1.0.offenseMod= weapon enchantment like Heart Seeker's attack-skill mod (e.g. 1.12 for HS VII).
Off-hand attacks: if DualWield.Current < weapon.Current, the DualWield
skill replaces the weapon skill. (That's why specialized dual-wield
builds spec DW — you don't want your main-hand skill to leak down into
off-hand hits.)
Attacker (monster) effective attack skill:
effAttack = round(weaponSkill * offenseMod) // no accuracy mod
Defender effective defense skill:
effDefense = round(defenseSkill * defenseMod * burdenMod * stanceMod
+ defenseImbues)
defenseSkill= MeleeDefense vs melee/missile-close-range, MissileDefense vs missile, MagicDefense vs spells.defenseMod= Defender weapon enchantment (e.g. Defender V = 1.25).burdenMod= scales with encumbrance-vs-capacity.stanceModfromPlayer.GetDefenseStanceMod():IsJumping→ 0.5IsLoggingOut→ 0.8- In combat mode (not NonCombat) → 1.0
- NonCombat + Crouch → 0.4
- NonCombat + Sitting → 0.3
- NonCombat + Sleeping → 0.2
defenseImbues= count of equipped items with the matching MeleeDefense/MissileDefense imbued effect (flat additive).
Exhausted (Stamina ≤ 0): effDefense = 0 → always hit. Also attacker
weapon skill is halved internally. Stamina management is a combat
mechanic.
Overpower. Some elite monsters (olthoi queens, Virindi lords) have
an Overpower property. On attack, an Overpower roll happens before
the evasion roll: if successful, the hit is auto-landed regardless of
defense skill. Two formulas exist (OverpowerMethod toggle);
Formula B (default in ACE) is:
overpowerChance = attacker.Overpower ?? 0 (percent, 0-100)
resistChance = defender.OverpowerResist ?? 0
final = rng() < overpowerChance*0.01
AND rng() >= resistChance*0.01
If overpowered, AttackConditions.Overpower flag is set and the
attacker notification reads "Overpower!".
8. Body-part targeting + per-part AL
Player body parts (9 parts)
From BodyPart.cs:
| CombatBodyPart | BodyPart flag | DamageLocation (wire) | Height pool |
|---|---|---|---|
| Head | 0x001 | 0x0 | High |
| Chest | 0x002 | 0x1 | High, Mid |
| Abdomen | 0x004 | 0x2 | Mid |
| UpperArm | 0x008 | 0x3 | High, Mid |
| LowerArm | 0x010 | 0x4 | Mid |
| Hand | 0x020 | 0x5 | Mid |
| UpperLeg | 0x040 | 0x6 | Mid |
| LowerLeg | 0x080 | 0x7 | Low |
| Foot | 0x100 | 0x8 | Low |
The High pool is 3 parts, Mid is 6 parts, Low is 2 parts. This is a meaningful targeting choice: High has 1/3 chance of head (typically best helmet AL), Low has 50% chance of foot (commonly one of the weakest pieces on an ungeared player).
Monster body parts (27 parts, per-weenie table)
CombatBodyPart enum values 0..26 include generic humanoid parts
(Head/Chest/Abdomen/UpperArm/LowerArm/Hand/UpperLeg/LowerLeg/Foot) plus
monster-specific (Horn, FrontLeg, FrontFoot, RearLeg, RearFoot, Torso,
Tail, Arm, Leg, Claw, Wings, Breath, Tentacle, UpperTentacle,
LowerTentacle, Cloak). Not every creature has every part — the weenie's
PropertiesBodyPart dictionary holds only the parts it has, each with:
BaseArmor(int) — AL for that partHLF, MLF, LLF, HRF, MRF, LRF, HLB, MLB, LLB, HRB, MRB, LRB(float) — per-quadrant hit probability (most are 0.0, a few non-zero per part)ArmorVsSlash, ArmorVsPierce, ..., ArmorVsNether(int) — the per-damage-type resist scaling (multiplied into BaseArmor)
A drudge skulker's torso dominates the MLF/MRF/MLB/MRB cells; its head appears only in HLF/HRF/HLB/HRB. Creature armor is per-part, not global.
Per-part AL + resistance scaling
Creature_BodyPart.GetEffectiveArmorVsType:
var armorVsType = biota.BaseArmor * (float)Creature.GetArmorVsType(damageType);
var enchantMod = ignoreMagicResist ? 0 : EnchantmentManager.GetBodyArmorMod();
var effectiveAL = armorVsType + enchantMod;
foreach (var armorLayer in armorLayers)
effectiveAL += GetArmorMod(armorLayer, damageType, ignoreMagicArmor);
if (effectiveAL > 0)
effectiveAL *= armorRendingMod; // 1.0 unless ArmorRending imbue
return effectiveAL;
For players, the armorLayers are the actual equipped armor/clothing
pieces whose ClothingPriority covers the hit body part — so a chest
piece + a surcoat + an undershirt all stack. Clothing.GetArmorMod()
per layer:
effectiveAL_piece = piece.BaseArmorLevel + impenAdditive
effectiveRL_piece = piece.ResistanceVsType + baneAdditive
effectiveRL_piece = clamp(effectiveRL_piece, -2.0, +2.0)
layerAL = effectiveAL_piece * effectiveRL_piece
Sum across layers → final effective AL → SkillFormula.CalcArmorMod.
9. Damage types + resistances
7 damage types + 4 special
DamageType.cs:
| Flag | Value | Notes |
|---|---|---|
| Slash | 0x001 | Physical |
| Pierce | 0x002 | Physical |
| Bludgeon | 0x004 | Physical |
| Cold | 0x008 | Elemental |
| Fire | 0x010 | Elemental |
| Acid | 0x020 | Elemental |
| Electric | 0x040 | Elemental |
| Health | 0x080 | Drain (harm) |
| Stamina | 0x100 | Drain |
| Mana | 0x200 | Drain |
| Nether | 0x400 | Void magic |
| Base | 0x10000000 | Prismatic arrow sentinel |
Helpers: Physical = Slash|Pierce|Bludgeon, Elemental = Cold|Fire|Acid|Electric.
Multi-damage weapons. Many weapons have Slash|Pierce or similar
bitfields. The selection process in Player.GetDamageType:
- If the weapon has a single damage type → use it.
- If
DamageType.Slash|Pierce:- Unarmed: low power → Pierce, high power → Slash
- Thrust attack → Pierce
- Otherwise → Slash
- Other multi-bit →
SelectDamageType(powerLevel):- If
powerLevel < 0.33, bias to Physical; else bias to Elemental. - Randomly pick from the resulting subset.
- If
This is how pyreal (ivory-blade) weapons and Aerfalle-style elemental weapons work — the power bar biases whether you do physical or elemental on a given swing.
Resistances
Each creature has per-damage-type ResistXxxMod (float, 1.0 default):
ResistSlash, ResistPierce, ResistBludgeon,
ResistFire, ResistCold, ResistAcid, ResistElectric,
ResistNether,
ResistHealthBoost, ResistStaminaDrain, ResistManaDrain
A value of 0.5 = 50% reduction (Prot 5), 2.0 = 100% bonus damage
(Vuln 6). The clamp is [-2, +2].
Natural resistances (players only)
Retail April 2002 Betrayal patch: player Str+End combination grants
a passive resistance to the 7 damage types, capping at 50% reduction
equivalent to Life Prot V. From Player_Combat.cs:GetNaturalResistance:
var strAndEnd = Strength.Base + Endurance.Base;
if (strAndEnd <= 200)
return 1.0f;
var natRes = 1.0f - (float)(strAndEnd - 200) / 300 * 0.5f;
return Math.Max(natRes, 0.5f);
Tiers (for the UI description):
- ≤200: None
- 201–260: Poor (1–10% reduction)
- 261–320: Mediocre
- 321–380: Hardy
- 381–440: Resilient
- 441+: Indomitable (cap, 50% reduction)
Crucial detail: natural resists do NOT stack with Life Protection spells. A higher-rank Prot spell overwrites natural resists. However, vulns subtract against the prot; if the net is worse than natural, you still don't go below natural. This is why some low-end characters are tougher than you'd expect.
Nether exception: All creatures "under Asheron's protection" take
50% damage from Nether damage by default (fandom: Anniversary-patch
announcement). GetNaturalResistance(DamageType.Nether) hardcodes 0.5.
Final resistance math
ResistanceMod = playerDefender.GetResistanceMod(damageType, attacker, weapon, weaponResistanceMod). Composed of:
naturalRes = GetNaturalResistance(damageType) // 0.5..1.0
prot/vuln = EnchantmentManager.GetResistanceMod(damageType)
× weaponResistanceMod // weapon rend / anti-mod
final = min(naturalRes, prot) AND × vuln effects
The exact min/max logic is subtle; the simplified version is: if the
life-prot is stronger than natural, prot wins; else natural wins (this
is what the April 2002 announcement said). Vulns cancel prots in full
before falling back to natural.
10. Wire format
Client → Server (C2S GameActions)
All these wrap in the GameAction (0xF7B1) envelope over the
ordered/reliable message stream. Payload layout:
Targeted Melee Attack — GameAction 0x0008:
uint32 ObjectId // target guid
uint32 Height // AttackHeight enum: 1=H, 2=M, 3=L
float32 Power // [0.0, 1.0], clamped server-side
Total payload: 12 bytes (after the GameAction header).
Targeted Missile Attack — GameAction 0x000A:
uint32 ObjectId
uint32 Height
float32 Accuracy // [0.0, 1.0]
Change Combat Mode — GameAction 0x0053:
uint32 Mode // CombatMode enum
Cancel Attack — GameAction 0x01B7:
(empty body)
Server → Client (S2C GameEvents wrapped in 0xF7B0 Ordered GameEvent)
AttackerNotification (0x01B3) — "You hit X for N damage!":
string16L DefenderName // 2-byte length, UTF-16LE bytes, dword-aligned
uint32 Type // DamageType
float64 DamagePercent // fraction of defender MaxHealth, 0..1
uint32 Damage // actual damage applied
uint32 Critical // 0 or 1 (really a bool, written as uint32)
uint32 AttackConditions // AttackConditionsMask bits
(align to 4 bytes)
Max observed length ~76 bytes.
DefenderNotification (0x01B5) — "The monster hits you!":
string16L AttackerName
uint32 Type // DamageType
float64 DamagePercent
uint32 Damage
uint32 Location // DamageLocation (0..8, player-only)
uint32 Critical
uint32 AttackConditions
(align to 4)
Max ~80 bytes.
EvasionAttackerNotification (0x01B8):
string16L DefenderName // "X evaded your attack."
EvasionDefenderNotification (0x01B6):
string16L AttackerName // "You evaded X's attack."
AttackDone (0x01B4):
uint32 Number // appears unused by client; WeenieError in ACE
CommenceAttack (0x01B7):
(empty body, just hourglass start for repeat attacks)
PlayerDeathEvent (S2C direct 0x019E, not a GameEvent):
string16L Message // Death message ("X killed you in battle!")
uint32 KilledId
uint32 KillerId
VictimNotificationSelf / Other (GameEvent):
string16L Message // death message for victim (self) or bystander (other)
AttackConditions bit layout
From the retail client decompilation (chunk_00560000.c:8173-8201)
confirming bit meanings:
if (param_6 != 0) "Critical hit! " // the `critical` param, separate
if ((uVar2 & 8) != 0) "Overpower! "
if ((uVar2 & 4) != 0) "Sneak Attack! "
if ((uVar2 & 2) != 0) "Recklessness! "
if ((uVar2 & 1) != 0) " Your target's Critical Protection augmentation allows them to avoid your critical hit!"
So the bits are exactly:
| Bit | Meaning |
|---|---|
| 0x01 | CriticalProtectionAugmentation (critical defended) |
| 0x02 | Recklessness active |
| 0x04 | Sneak Attack triggered |
| 0x08 | Overpower triggered |
(ACE's AttackConditions.cs and Chorizite's AttackConditionsMask both
match this, modulo Chorizite missing the Overpower bit in its generated
enum — an oversight to correct in acdream.)
11. PvP vs PvE differences
PK status flags
PlayerKillerStatus:
NPK— non-PK, cannot attack or be attacked by playersPK— full player killer, can attack other PKsPKLite— PK-Lite, non-lethal PvP (no corpse drop on death to other PKL)Free— special (event/gladiator), can attack anyoneNPK_Protected— post-revive grace period
Attack gating
Player.CheckPKStatusVsTarget(target, spell):
- Either side
Free→ allowed. NPKattacker with harmful spell on player → reject (WeenieError.YouFailToAffect_YouAreNotPK).NPKdefender → reject (WeenieError._FailsToAffectYou_TheyAreNotPK).- Different PK types (PK vs PKL, etc.) → reject with
NotSamePKTypeunless it's a beneficial spell on NPK. - Housing permission check:
CheckHouseRestrictions— attacks across restricted-house cell boundaries denied.
Mismatched PK type on a monster target (e.g. a PK quest-monster vs NPK attacker) also rejects.
PK damage adjustments
In PvP:
PkDamageMod = Creature.GetPositiveRatingMod(attacker.GetPKDamageRating())added to the damage rating stack.PkDamageResistanceMod = Creature.GetNegativeRatingMod(defender. GetPKDamageResistRating())added to damage resistance stack.- PvP elemental damage bonus is halved:
if (modifier > 1.0f && target is Player) modifier = 1.0f + (modifier - 1.0f) * 0.5f;
PK timers
LastPkAttackTimestamp— updated on successful PvP hit (both sides).pk_timerproperty (typically 30s) — while active, cannot logout normally, spawns are gated.PKLogoffTimer = 2 minutes— logout freeze window. During this window all physical hits auto-crit against you.
Lifestone protection
5 min invulnerability window after lifestone tie; UnderLifestoneProtection.
Any PvP attack dispels it on the attacker (not the defender). Server sends
"The Lifestone's magic protects X from the attack!" (FUN_00570000:2431
in the decompile, verbatim). Damage = 0, LifestoneProtection bit set.
12. Port plan for acdream
acdream is the client, so our combat layer splits into:
- Client-authoritative UI/prediction — power bar widget, attack staging, send attack GameActions, play swing animation while waiting.
- Wire decoder — parse inbound
AttackerNotification,DefenderNotification,EvasionNotification,AttackDone,PlayerDeathEventand surface them to the plugin API + chat/HUD. - Shadow damage calculator — our own implementation of the ACE formulas, used to (a) feed the power-bar damage preview, (b) sanity check server damage (for anti-cheat debug output, never used as authority), (c) give plugins access to "if I attacked with X, how much would I do?" queries.
Suggested namespace layout
acdream.Combat/
CombatMode.cs // enum: Undef|NonCombat|Melee|Missile|Magic
AttackType.cs // enum Flags, same bits as ACE
AttackHeight.cs // enum: High|Medium|Low + ToQuadrant()
AttackConditions.cs // enum Flags, 0x01/0x02/0x04/0x08
DamageType.cs // enum Flags, same bits as ACE
CombatBodyPart.cs // enum, 0..26
DamageLocation.cs // enum, 0..8 (player wire)
Quadrant.cs // enum Flags, High|Mid|Low|Left|Right|Front|Back
AttackRequest.cs // POCO: TargetGuid, Height, Power, Kind (Melee|Missile)
AttackResult.cs // POCO: Hit|Evade|Lifestone; DamageType; Damage;
// Critical; BodyPart; AttackConditions
DamageEvent.cs // POCO mirroring ACE's DamageEvent for shadow calc
CombatMath.cs // static helpers: GetSkillChance, CalcArmorMod,
// GetAttributeMod, AdditiveCombine,
// GetPositiveRatingMod, GetNegativeRatingMod,
// ThrustThreshold const 0.33f,
// defaultPhysicalCritFrequency 0.1f,
// defaultMagicCritFrequency 0.05f,
// DefaultMod 0.011f, BowMod 0.008f,
// ArmorMod 200f/3f
DefenseBuild.cs // struct: attack/defense skills, mods,
// weapon, stance, burden — for shadow calc
ArmorLayer.cs // struct: BaseAL, ResVsType map, Impen, Bane
BodyPartTable.cs // 12-quadrant probability table loader (from weenies)
Wire/
CombatC2S.cs // TargetedMeleeAttack / MissileAttack /
// ChangeCombatMode / CancelAttack senders
CombatS2C.cs // AttackerNotification / DefenderNotification /
// Evasion* / AttackDone / PlayerDeath decoders
CombatClient.cs // high-level: HandleAttackKey(), charge tick,
// release, process incoming notification,
// emit events to plugin API
Conformance tests
Port these from ACE's test suite plus capture direct pcaps from retail:
SkillCheckTests.cs— verify the logistic curve at ±50/100/200 skill delta, factor 0.03 and 0.07.SkillFormulaTests.cs— already exists in ACE (references/ACE/Source/ACE.Server.Tests/SkillFormulaTests.cs), port the cases verbatim.GetAttributeModat 55/100/200/400 skill, bow and non-bow factors.CalcArmorModat AL 0, 50, 100, 200, 500, and negative values.DamageEventTests.cs— table-driven worked examples A, B, C from §5 here, with fixed RNG seeds to ensure exact reproducibility.BodyPartTableTests.cs— given a weenie's PropertiesBodyPart dict, verify all 12 quadrants sum probabilities correctly and thatRollBodyPartis deterministic with a fixed seed.AttackWireTests.cs— byte-level round-trip tests against canned hex payloads. Start fromAttackerNotificationwith a known DamageType/Damage/Critical/AttackConditions combination, ensure our encoder produces the exact bytes the retail client expects, and our decoder extracts the same values.
Phase ordering (proposed)
| Phase label | What ships |
|---|---|
| R2.A Wire | Enums + C2S senders + S2C decoders, 1:1 with Chorizite types. Integration into WorldSession. HUD shows "You hit X for N" chat messages. No shadow math yet. |
| R2.B Power | Power bar widget, charging, release → Combat_TargetedMeleeAttack. GetPowerRange mapping, ThrustThreshold logic. Attack key binding. |
| R2.C ShadowMath | CombatMath statics ported from SkillCheck/SkillFormula. Shadow calculator that consumes DefenseBuild + weapon + target state → predicted AttackResult. Conformance tests. |
| R2.D BodyParts | BodyPartTable loader from weenies, Quadrant/AttackHeight wiring, damage-location surfacing in HUD. |
| R2.E Plugin | IAttackEvents on the plugin API: OnAttackerNotification, OnDefenderNotification, OnEvasion. Plugin can query shadow math for "preview" computations. |
Non-goals for acdream (server territory)
These are explicitly server-side and we do NOT implement them:
- Authoritative damage calculation (we predict, server decides).
- Enchantment arbitration (prots vs vulns vs items).
- Stamina, health, mana updates (server sends
PrivateUpdateVital). - Evasion RNG (we just receive evade/hit from server).
- Proc triggering (Blood Drinker etc.).
- NPC AI — attack target selection, aggro lists, faction matrices.
Our job: predict correctly, send correctly, display correctly.
Appendix: Key constants cheat-sheet
| Constant | Value | Source |
|---|---|---|
ThrustThreshold |
0.33f | DM Guide p.150, WorldObject_Weapon.cs:1033 |
KickThreshold |
0.75f | Player_Melee.cs:432 |
SkillFormula.DefaultMod |
0.011f | melee/finesse/thrown/atlatl attribute scale |
SkillFormula.BowMod |
0.008f | bow/crossbow attribute scale |
SkillFormula.ArmorMod |
200f/3f ≈ 66.667 | armor half-life constant |
SkillCheck physical factor |
0.03f | logistic steepness for melee/missile |
SkillCheck magic factor |
0.07f | magic resist steepness |
defaultPhysicalCritFrequency |
0.10f | 10% base melee/missile crit |
defaultMagicCritFrequency |
0.05f | 5% base magic crit |
defaultCritDamageMultiplier |
1.0f | crit multiplier added to 1.0 = 2.0× max |
ElementalDamageBonusPvPReduction |
0.5f | PvP elemental halved |
MeleeDistance |
0.6f | direct-attack range |
StickyDistance |
4.0f | sticky melee range |
RepeatDistance |
16.0f | auto-repeat cutoff |
MinAttackSpeed |
0.5 | anim speed clamp (lower bound) |
MaxAttackSpeed |
2.0 | anim speed clamp (upper bound) |
| Natural Res cap | 0.5f (= 50%) | at strAndEnd 440+ |
| PKLogoffTimer | 2 minutes | logout freeze |
| Lifestone protection window | 5 minutes | after LS tie |
| Stamina mod cap | 0.5f (50% less) | at Endurance ~290 |
| NoStaminaUse evasion cap | 0.75f (75%) | at Endurance ~290 |
Remember: every one of these is tunable by ACE's PropertyManager but
the defaults above match what retail clients observed across live pcaps.
Use these as the acdream defaults and expose them via the plugin API for
debugging / emulator compatibility.