acdream/docs/research/deepdives/r02-combat-system.md
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
2026-04-18 10:32:44 +02:00

45 KiB
Raw Permalink Blame History

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.exe decompilation, primarily chunk_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/TargetedMissileAttack and S2C Combat_HandleAttackerNotificationEvent/HandleDefenderNotificationEvent plus evasion + death.
  • docs/research/acclient_function_map.md for 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):

  1. If offhand is true, return the offhand variant.
  2. Start from the weapon's W_AttackType bitfield.
  3. 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:
    powerLevel <  0.33 → use Thrust
    powerLevel >= 0.33 → use Slash
    
    Some weapons use DoubleThrust|DoubleSlash or TripleThrust|TripleSlash pairs; the threshold still applies.
  4. 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:

  1. Retail builds a Quadrant by OR-ing the height with the attacker's relative direction:
    quadrant = attackHeight.ToQuadrant() | attacker.GetRelativeDir(defender);
    
  2. The defender's BodyPartTable has 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).
  3. Each creature weenie's PropertiesBodyPart has per-quadrant probabilities; BodyPartTable.RollBodyPart(quadrant) weighted-random selects one.
  4. The selected CombatBodyPart has 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 separate DamageMod attribute 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

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

slayerModWorldObject.GetWeaponCreatureSlayerModifier. If the weapon has a SlayerCreatureType matching the target's CreatureType, returns the stored SlayerDamageBonus (values like 1.53.0 in data). Else 1.0.

damageRatingModCreature.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.20AdditiveCombine(1.10, 1.20) = 1.30.

armorMod — see §9.

shieldModGetShieldMod. 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:

  1. baseDamage = rng(6, 12) = 9 (sample)
  2. attributeMod = 1 + (125 55)×0.011 = 1 + 0.77 = 1.77
  3. powerMod = 1.0 + 0.5 = 1.5
  4. slayerMod = 1.0, damageRatingMod = 1.0
  5. damageBefore = 9 × 1.77 × 1.5 × 1.0 × 1.0 = 23.895
  6. Drudge chest: effectiveAL = 24 × 1.0 = 24, armorMod = (200/3) / (24 + 200/3) = 66.67 / 90.67 = 0.735
  7. resistanceMod = 1.0 (drudge: slash neutral)
  8. damage = 23.895 × 0.735 × 1.0 × 1.0 × 1.0 = 17.56
  9. 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:

  1. attributeMod = 1 + (300 55)×0.011 = 1 + 2.695 = 3.695
  2. powerMod = 0.3 + 0.5 = 0.8
  3. On crit, baseDamage = weapon max = 32 (not rolled)
  4. critDmgMod = 1.0 + 1.0 = 2.0 (default weapon multiplier)
  5. damageBefore = 32 × 3.695 × 0.8 × 1.0 × 1.0 × 2.0 = 189.1
  6. Banderling chest: effectiveAL = 50, armorMod = 66.67 / 116.67 = 0.571
  7. resistanceMod = 1.3 (pierce vuln)
  8. damage = 189.1 × 0.571 × 1.3 × 1.0 = 140.4
  9. 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:

  1. maxDamage = (45 + 0 + 20) × 2.13 = 138.45, minDamage = 138.45 × 0.7 = 96.9
  2. baseDamage = rng(96.9, 138.45) = 118 sampled
  3. attributeMod = 1 + (350 55)×0.008 = 1 + 2.36 = 3.36
  4. powerMod = 1.0 (bows ignore power bar for damage)
  5. damageBefore = 118 × 3.36 × 1.0 × 1.0 × 1.0 = 396.5
  6. attackSkill = skill × accuracyMod × offenseMod = 350 × 1.6 × 1.0 = 560 (accuracy boosts hit chance, not damage)
  7. Target chest clothes only: effectiveAL = 12 + 0 = 12, armorMod = 66.67 / 78.67 = 0.847
  8. resistanceMod = 0.56 (Prot Cold 6)
  9. damage = 396.5 × 0.847 × 0.56 = 188.1
  10. Final: 188 damage (cold)

6. Critical hits

Base crit chance:

  • Physical: 10% (defaultPhysicalCritFrequency = 0.1f in WorldObject_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:

  1. On crit, DamageBeforeMitigation = weapon.MaxDamage × attributeMod × powerMod × slayerMod × damageRatingMod × critDamageMod. Note baseDamage is replaced by max damage, not re-rolled.
  2. 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).
  3. 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.6 for 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.
  • stanceMod from Player.GetDefenseStanceMod():
    • IsJumping → 0.5
    • IsLoggingOut → 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 part
  • HLF, 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:

  1. If the weapon has a single damage type → use it.
  2. If DamageType.Slash|Pierce:
    • Unarmed: low power → Pierce, high power → Slash
    • Thrust attack → Pierce
    • Otherwise → Slash
  3. Other multi-bit → SelectDamageType(powerLevel):
    • If powerLevel < 0.33, bias to Physical; else bias to Elemental.
    • Randomly pick from the resulting subset.

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
  • 201260: Poor (110% reduction)
  • 261320: Mediocre
  • 321380: Hardy
  • 381440: 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 players
  • PK — full player killer, can attack other PKs
  • PKLite — PK-Lite, non-lethal PvP (no corpse drop on death to other PKL)
  • Free — special (event/gladiator), can attack anyone
  • NPK_Protected — post-revive grace period

Attack gating

Player.CheckPKStatusVsTarget(target, spell):

  1. Either side Free → allowed.
  2. NPK attacker with harmful spell on player → reject (WeenieError.YouFailToAffect_YouAreNotPK).
  3. NPK defender → reject (WeenieError._FailsToAffectYou_TheyAreNotPK).
  4. Different PK types (PK vs PKL, etc.) → reject with NotSamePKType unless it's a beneficial spell on NPK.
  5. 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_timer property (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:

  1. Client-authoritative UI/prediction — power bar widget, attack staging, send attack GameActions, play swing animation while waiting.
  2. Wire decoder — parse inbound AttackerNotification, DefenderNotification, EvasionNotification, AttackDone, PlayerDeathEvent and surface them to the plugin API + chat/HUD.
  3. 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. GetAttributeMod at 55/100/200/400 skill, bow and non-bow factors. CalcArmorMod at 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 that RollBodyPart is deterministic with a fixed seed.
  • AttackWireTests.cs — byte-level round-trip tests against canned hex payloads. Start from AttackerNotification with 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.