# CA1 — advancement wire protocol + retail recompute chain (Campaign CA oracle) Assembled 2026-08-24 from two parallel research passes (wire: ACE + Chorizite + holtburger; recompute: named-retail pseudo-C + live Ghidra decompiles of the PDB-paired 2013 binary), with every load-bearing claim independently re-verified before assembly (§0). ## 0. Verification ledger (claims re-checked by hand before use) | Claim | Verified how | Verdict | |---|---|---| | `PrivateUpdateSkill = 0x02DD` (our `PropertyString.cs` comment says 0x02DD is PropertyString) | ACE `GameMessageOpcode.cs`: Skill=0x02DD, **PropertyString=0x02D5** | Agent right; OUR comment wrong (doc-only — nothing dispatches on either id today; fix the comment in CA2) | | `SkillFormula::Calculate @ 0x00591960` = `floor((x·a1+y·a2+w)/z+0.5)`, `z==0 → 0` | Ghidra decompile, read in full | EXACT — and the `z==0` early-out IS the attribute-less-skill (Salvaging) handling; no special case needed | | `CACQualities::InqSkill @ 0x00592660` computes LIVE (base formula + ranks + init + augmentation bonuses) | Ghidra decompile, read in full | CONFIRMED — includes the per-skill-group +10 augmentation adds (our #268 family) | | ACE's `HandleRunRateUpdate` wire effect | `Player.cs:975` read in full | Re-broadcasts the movement packet with new ForwardSpeed when rate changed mid-movement → lands in our existing `UpdateMotion.ForwardSpeed` echo → `ApplyServerRunRate` | | `PrivateUpdateSkillLevel (0x02DF)` producer | grep ACE Source | NO producer anywhere (opcode-name table only) — vestigial for ACE; CA2 skips the parser | | Retail formula vs our code | `src/AcDream.App/Net/RetailSkillFormula.cs` | **ALREADY PORTED EXACTLY** (cites 0x00591960; unsigned reinterpretation of DAT signed views; Trained+5/Specialized+10 from CC5-F3). ACE's simplified `AttributeFormula` is NOT in our code path | **Design verdict binding CA2/CA3:** retail computes skills, vitals maxima and run rate LIVE at inquiry time; Set* handlers are raw-storage writes; UI refresh is a value-less observer notification and widgets re-pull. Therefore acdream's port: inbound parsers write raw stats into the J4 owners, `SkillSnapshot`'s frozen `FormulaBonus` becomes a live computation against current attributes, and presentation re-reads on the existing change events. No recompute cascade, no dirty flags, no cached effective values. --- # Campaign CA / Slice CA1 — character-advancement wire research Read-only research. All ACE / Chorizite.ACProtocol / holtburger file:line citations below point at `references//...` **as checked out in the main repo** (`C:\Users\erikn\source\repos\acdream\references\...`). Those three reference repos are **not present in this worktree** (they are untracked/gitignored and worktrees don't get untracked files) — only `references/WorldBuilder` exists here. When implementing, either read the main checkout directly or re-verify paths resolve in whatever tree you're in. Our existing outbound builder: `src/AcDream.Core.Net/Messages/CharacterActions.cs` (this worktree). Our existing (partial) inbound parser: `src/AcDream.Core.Net/Messages/PrivateUpdateVital.cs` (this worktree, vitals only). --- ## 1. Summary opcode table | Dir | Opcode | Name (ACE) | Name (Chorizite) | Purpose | |---|---|---|---|---| | C→S | `0xF7B1` | `GameActionOpcode.GameAction` (envelope) | — | Wraps every GameAction: `u32 opcode=0xF7B1, u32 sequence, u32 subOpcode, ...body` | | C→S | `0x0044` | `GameActionType.RaiseVital` | `Train_TrainAttribute2nd` | Spend XP to raise a vital | | C→S | `0x0045` | `GameActionType.RaiseAttribute` | `Train_TrainAttribute` | Spend XP to raise an attribute | | C→S | `0x0046` | `GameActionType.RaiseSkill` | `Train_TrainSkill` | Spend XP to raise a skill | | C→S | `0x0047` | `GameActionType.TrainSkill` | `Train_TrainSkillAdvancementClass` | Spend skill credits to train a skill | | S→C | `0x02CD` | `PrivateUpdatePropertyInt` | `Qualities_PrivateUpdateInt` | `AvailableSkillCredits` after TrainSkill / specialize / untrain / reset | | S→C | `0x02CF` | `PrivateUpdatePropertyInt64` | `Qualities_PrivateUpdateInt64` | `AvailableExperience` after **every** successful RaiseAttribute/RaiseVital/RaiseSkill | | S→C | `0x02DD` | `PrivateUpdateSkill` | `Qualities_PrivateUpdateSkill` | Full skill record (ranks/SAC/xp/init/resistance/lastUsed) | | S→C | `0x02DF` | `PrivateUpdateSkillLevel` | (not generated under that name; opcode exists in enum) | Ranks-only skill delta — **NOT used by any of the 4 actions**; not emitted by `HandleActionRaiseSkill`/`HandleActionTrainSkill`/`SkillAlterationDevice`. holtburger still models it (`UpdateSkillLevel`) but no ACE producer for it was found for this feature surface. Flagged as an open question below. | | S→C | `0x02E3` | `PrivateUpdateAttribute` | `Qualities_PrivateUpdateAttribute` | Full attribute record (ranks/start/xp) | | S→C | `0x02E7` | `PrivateUpdateVital` | `Qualities_PrivateUpdateAttribute2nd` *(name disagreement, same bytes — see §5)* | Full vital record (ranks/start/xp/current) | | S→C | `0x02E9` | `PrivateUpdateAttribute2ndLevel` | `Qualities_PrivateUpdateAttribute2ndLevel` | Current-only vital delta (no ranks/xp) | | S→C | `0xF750` | `Sound` | — | `RaiseTrait` sound cue on rank-up | | S→C | `0xF7E0` | `ServerMessage` (→ `GameMessageSystemChat`) | — | "Your base X is now N!" text on rank-up; also failure text | | S→C (GameEvent, envelope `0xF7B0`) | GameEventType `0x028B` | `WeenieErrorWithString` | — | Specialize/untrain/reset success/failure text (routed through a confirmation dialog, not a plain GameMessage) | No dedicated opcode exists for **untrain / specialize / reset** — see §4. --- ## 2. Per-message byte layout All multi-byte integers are little-endian. Every `GameMessage` subclass in ACE auto-writes its own `u32 Opcode` first via the `GameMessage` base class ctor (`references/ACE/Source/ACE.Server/Network/GameMessages/GameMessage.cs:25-26` / `:45-46`) — that 4 bytes is **not** written again by the derived class body; I've included it in each layout below for wire-completeness. ### 2.1 Outbound — `RaiseVital` (`0x0044`) ``` u32 envelope = 0xF7B1 u32 sequence u32 subOpcode = 0x0044 u32 vitalId // PropertyAttribute2nd — MUST be the Max* id (1/3/5), see §2.9 u32 xpSpent ``` 20 bytes total. Confirmed by: - ACE parse: `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionRaiseVital.cs:11-12` (`(PropertyAttribute2nd)message.Payload.ReadUInt32()`, then `ReadUInt32()` for xp — **both u32**, not u64). - holtburger: `RaiseVitalActionData` — `vital_type: u32, xp_spent: u32` — `references/holtburger/crates/holtburger-protocol/src/messages/player/actions.rs:38-65`. - Chorizite: `Train_TrainAttribute2nd` — `Type: VitalId (u32), Experience: uint` — `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Actions/Train_TrainAttribute2nd.generated.cs:14-41`. Our builder (`CharacterActions.BuildAttrOrVital`, `src/AcDream.Core.Net/Messages/CharacterActions.cs:94-103`) matches exactly. **No change needed** for the outbound side. ### 2.2 Outbound — `RaiseAttribute` (`0x0045`) Same shape as 2.1: `u32 attrId (PropertyAttribute) + u32 xpSpent`. - ACE parse: `GameActionRaiseAttribute.cs:10-11`. - holtburger: `RaiseAttributeActionData` — `actions.rs:8-27`. - Chorizite: `Train_TrainAttribute` — `Train_TrainAttribute.generated.cs:14-41`. Matches our builder. ### 2.3 Outbound — `RaiseSkill` (`0x0046`) `u32 skillId (Skill enum) + u32 xpSpent`. - ACE parse: `GameActionRaiseSkill.cs:10-11` — `(Skill)message.Payload.ReadUInt32()`, `ReadUInt32()`. - holtburger: `RaiseSkillActionData` — `skill_type: u32, xp_spent: u32` — `actions.rs:67-94`. - Chorizite: `Train_TrainSkill` reads `Skill = (SkillId)reader.ReadInt32()` (**signed** read) — `Train_TrainSkill.generated.cs:30`. Same 4 bytes on the wire; interpretation differs (see §5). Matches our builder. ### 2.4 Outbound — `TrainSkill` (`0x0047`) `u32 skillId + i32 creditsSpent` (**signed**, unlike the other three actions' xp field). - ACE parse: `GameActionTrainSkill.cs:10-11` — `(Skill)message.Payload.ReadUInt32()`, then **`message.Payload.ReadInt32()`** (signed) for `creditsSpent`. - holtburger: `TrainSkillActionData { skill_type: u32, credits_spent: i32 }`, unpacked with `read_i32` — `actions.rs:97-116`, and its own unit test packs credits as `write_i32::` — `actions.rs:277-299`. - Chorizite: `Train_TrainSkillAdvancementClass.Credits` is declared `uint` and read via `reader.ReadUInt32()` — `Train_TrainSkillAdvancementClass.generated.cs:23,31`. **Disagreement** with ACE/holtburger's signed read — see §5. Byte layout is identical either way (credits are always small positives on the wire). Our builder (`BuildTrainSkill`, `CharacterActions.cs:51-60`) writes credits as `uint` — bytes are correct; only the C# type differs from ACE's server-side signedness, which is harmless. ### 2.5 Inbound — `PrivateUpdateAttribute` (`0x02E3`) ``` u32 opcode = 0x02E3 u8 sequence // ByteSequence, see §3 u32 attribute // PropertyAttribute (1=Strength..6=Self) u32 ranks // CreatureAttribute.Ranks (uint) u32 startingValue // CreatureAttribute.StartingValue == PropertiesAttribute.InitLevel (uint) u32 experienceSpent // CreatureAttribute.ExperienceSpent == PropertiesAttribute.CPSpent (uint) ``` 21 bytes. ACE producer + field order: `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateAttribute.cs:8-16` (`Writer.Write(seq)` line 11 [via `GetNextSequence`, 1 byte — see §3], `(uint)Attribute` line 12, `Ranks` line 13, `StartingValue` line 14, `ExperienceSpent` line 15). All four fields are `uint` in `PropertiesAttribute`: `references/ACE/Source/ACE.Entity/Models/PropertiesAttribute.cs:7-9`. Cross-check: Chorizite's `AttributeInfo` struct — `PointsRaised(u32), InnatePoints(u32), ExperienceSpent(u32)` in that exact order — `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/AttributeInfo.generated.cs:26-54` (`PointsRaised`=Ranks, `InnatePoints`=StartingValue). holtburger's `UpdateAttribute` — `sequence: u8, attribute: u32, ranks: u32, start: u32, xp: u32` — `references/holtburger/crates/holtburger-protocol/src/messages/player/types.rs:22-53`. **All three agree exactly**, field-for-field. ### 2.6 Inbound — `PrivateUpdateVital` (`0x02E7`, "full" update) ``` u32 opcode = 0x02E7 u8 sequence u32 vitalId // PropertyAttribute2nd — ALWAYS the Max* id (1/3/5), see §2.9 u32 ranks // CreatureVital.Ranks (uint) u32 startingValue // CreatureVital.StartingValue (uint) u32 experienceSpent // CreatureVital.ExperienceSpent (uint) u32 current // CreatureVital.Current == PropertiesAttribute2nd.CurrentLevel (uint) ``` 25 bytes. ACE producer: `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateVital.cs:8-18`. Field source types: `references/ACE/Source/ACE.Entity/Models/PropertiesAttribute2nd.cs:7-10` (all `uint`, including `CurrentLevel`). **IMPORTANT — this is ACE's `GameMessagePrivateUpdateVital`, but Chorizite's generated name for the SAME opcode/bytes is `Qualities_PrivateUpdateAttribute2nd`** (not `...Vital`). Confirmed identical layout: `Attribute: AttributeInfo (PointsRaised/InnatePoints/ExperienceSpent) + Current: u32` — `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateAttribute2nd.generated.cs:14-55` + `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/SecondaryAttributeInfo.generated.cs:22-50`. holtburger's `UpdateVital` (`PrivateUpdateVitalData`) matches field-for-field: `sequence, vital, ranks, start, xp, current` — `types.rs:137-184`, with a golden-fixture test at `types.rs:296-307` (`ranks:100, start:12345, xp:67890, current:100`). Our existing doc comment in `PrivateUpdateVital.cs:24-33` already has this layout right. **No correction needed** to the existing `TryParseFull`. ### 2.7 Inbound — `PrivateUpdateAttribute2ndLevel` (`0x02E9`, "current-only" delta) ``` u32 opcode = 0x02E9 u8 sequence u32 vitalId // Vital enum (ACE.Entity.Enum.Vital) — the NON-Max variant: Health=2/Stamina=4/Mana=6 u32 current ``` 13 bytes. ACE producer: `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateAttribute2ndLevel.cs:8-14`. Cross-check: Chorizite's `Qualities_PrivateUpdateAttribute2ndLevel` — `Sequence(byte), Key(u32, `CurVitalId`), Value(u32)` — `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateAttribute2ndLevel.generated.cs:14-55`. holtburger's `UpdateVitalCurrent` (`PrivateUpdateVitalCurrentData`) matches: `sequence, vital, current` — `types.rs:189-225`, fixture test at `types.rs:330-339`, and an explicit raw-bytes unit test at `types.rs:377-397` (`0x0C` seq, `0x00000002` vital=Health, `0x00000064` current=100) confirming the **current-only message's vital id is `Health`=2, not `MaxHealth`=1**. Our existing doc (`PrivateUpdateVital.cs:34-40`) already has this layout right, but its comment "ACE Vital enum (1=MaxHealth..6=Mana)" doesn't disambiguate that this specific opcode (0x02E9) **always** carries the non-Max id while 0x02E7 **always** carries the Max id — see §2.9 for the mechanism. Worth tightening that comment when the attribute/skill parsers are added alongside it. ### 2.8 Inbound — `PrivateUpdateSkill` (`0x02DD`) ``` u32 opcode = 0x02DD u8 sequence u32 skillId // Skill enum (0=None, ordinal-numbered — see §6 open question) u16 ranks // CreatureSkill.Ranks == PropertiesSkill.LevelFromPP (ushort!) u16 adjustPP // hardcoded constant = 1 on every send (see comment below) u32 advancementClass // SkillAdvancementClass (CreatureSkill.AdvancementClass == PropertiesSkill.SAC) u32 experienceSpent // CreatureSkill.ExperienceSpent == PropertiesSkill.PP (uint) u32 initLevel // CreatureSkill.InitLevel == PropertiesSkill.InitLevel (uint) u32 resistanceAtLastCheck // PropertiesSkill.ResistanceAtLastCheck (uint) f64 lastUsedTime // PropertiesSkill.LastUsedTime (double, 8 bytes) ``` 37 bytes. ACE producer, with the `adjustPP` local hardcoded to `1` and the comment "If this is not 0, it appears to trigger the initLevel to be treated as extra XP applied to the skill": `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateSkill.cs:8-24`. Field source types: `references/ACE/Source/ACE.Entity/Models/PropertiesSkill.cs:9-14` (`LevelFromPP: ushort`, `SAC: SkillAdvancementClass`, `PP: uint`, `InitLevel: uint`, `ResistanceAtLastCheck: uint`, `LastUsedTime: double`). Cross-check: Chorizite's `Skill` struct — `PointsRaised(u16), AdjustPP(u16), TrainingLevel(u32), ExperienceSpent(u32), InnatePoints(u32), ResistanceOfLastCheck(u32), LastUsedTime(f64/double)` in that exact order — `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/Skill.generated.cs:22-86`. holtburger's `UpdateSkill` matches too, reading ranks/adjustPP as `u16` then widening to its `u32` struct field: `sequence, skill, ranks(u16), adjust_pp(u16), status, xp, init, resistance, last_used(f64)` — `references/holtburger/crates/holtburger-protocol/src/messages/player/types.rs:71-131`, with a golden fixture at `types.rs:279-293` (`ranks:50, adjust_pp:1, status:3, xp:1000, init:10, resistance:0, last_used:0.0`) — confirms `adjustPP` is `1` in a real captured fixture, matching ACE's hardcoded constant. **All three agree exactly.** ### 2.9 Inbound — `PrivateUpdatePropertyInt` (`0x02CD`) ``` u32 opcode = 0x02CD u8 sequence u32 propertyId // PropertyInt i32 value ``` 13 bytes. ACE producer: `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdatePropertyInt.cs:9-15`. Cross-check: Chorizite's `Qualities_PrivateUpdateInt` — `Sequence(byte), Key(u32, PropertyInt), Value(int32)` — `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateInt.generated.cs:14-56`. Matches. `PropertyInt.AvailableSkillCredits = 24` — `references/ACE/Source/ACE.Entity/Enum/Properties/PropertyInt.cs:43`. ### 2.10 Inbound — `PrivateUpdatePropertyInt64` (`0x02CF`) ``` u32 opcode = 0x02CF u8 sequence u32 propertyId // PropertyInt64 i64 value ``` 17 bytes. ACE producer: `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdatePropertyInt64.cs:8-14`. Cross-check: Chorizite's `Qualities_PrivateUpdateInt64` matches (`Sequence(byte), Key(u32), Value(int64)`) — `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateInt64.generated.cs:14-56`. `PropertyInt64.AvailableExperience = 2` — `references/ACE/Source/ACE.Entity/Enum/Properties/PropertyInt64.cs:14`. ### 2.11 The Max-vs-current vital id split (mechanism) Two *different* enums, numerically identical, are used depending on message: - `PropertyAttribute2nd` (`references/ACE/Source/ACE.Entity/Enum/Properties/PropertyAttribute2nd.cs:8-14`): `Undef=0, MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6`. This is what `Creature.Vitals` is keyed by, and **only the Max* keys exist in that dictionary**: `Vitals[PropertyAttribute2nd.MaxHealth] = ...` etc. — `references/ACE/Source/ACE.Server/WorldObjects/Creature.cs:98-100`. So `HandleActionRaiseVital`'s inbound `vital` parameter, and the id embedded in the full `PrivateUpdateVital` (0x02E7) message (`creatureVital.Vital`), are **always one of {1, 3, 5}**. - `Vital` (`references/ACE/Source/ACE.Entity/Enum/Vital.cs`): `Undefined=0, MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6` — same numbering, used only for the current-only message. `CreatureVital.ToEnum()` maps `MaxHealth→Vital.Health(2)`, `MaxStamina→Vital.Stamina(4)`, `MaxMana→Vital.Mana(6)` — `references/ACE/Source/ACE.Server/WorldObjects/Entity/CreatureVital.cs:203-212`, and `UpdateVital()` sends `new GameMessagePrivateUpdateAttribute2ndLevel(this, vital.ToEnum(), vital.Current)` — `references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs:139`. So the 0x02E9 message's id is **always one of {2, 4, 6}**. Net: **a client-side parser must NOT assume a single id space for "vital" across both opcodes** — 0x02E7 always carries {1,3,5}, 0x02E9 always carries {2,4,6}. holtburger's raw-bytes unit test (`types.rs:377-397`) is the clean confirming fixture for the 0x02E9 case (vital=2=Health). --- ## 3. Sequence-number scheme Every "sequence" byte above is **1 byte**, produced by `ISequence.NextBytes` (returns `new byte[] { NextValue }`) — `references/ACE/Source/ACE.Server/Network/Sequence/ByteSequence.cs:30-45`. The containing `GameMessage*` constructors call `Writer.Write(byte[])`, which writes the array's raw bytes with **no length prefix**, so exactly 1 byte lands on the wire per sequence field. This matches what our existing `PrivateUpdateVital.cs` doc already asserts ("single byte... not 4 bytes despite some ACE writer signatures looking like uint"). **Per-(type, property) counters.** `SequenceManager.GetSequence(type, property)` keys its dictionary by `(uint)type << 16 | property` — `references/ACE/Source/ACE.Server/Network/Sequence/SequenceManager.cs:152-156` — so every distinct `(SequenceType, propertyId)` pair gets its **own independent** counter. Concretely: raising Strength and raising Endurance advance two different byte counters (`UpdateAttribute+1` vs `UpdateAttribute+2`); raising two different skills advance two different counters; the `AvailableExperience` counter (`UpdatePropertyInt64+2`) is independent of all attribute/skill/vital counters. **Which `SequenceType` bucket each message uses, and its allocation strategy** (`SequenceManager.GetSequence`, `:160-181`): | Wire message | `SequenceType` used | Bucket strategy | |---|---|---| | `PrivateUpdateAttribute` (0x02E3) | `UpdateAttribute` | falls to `default:` → `ByteSequence(false)` | | `PrivateUpdateVital` (0x02E7, full) | `UpdateAttribute2ndLevel` | `default:` → `ByteSequence(false)` | | `PrivateUpdateAttribute2ndLevel` (0x02E9, current) | `UpdateAttribute2ndLevel` | `default:` → `ByteSequence(false)` — **same bucket as the full message**, keyed by vital property, so 0x02E7 and 0x02E9 traffic for the *same vital* share one incrementing byte counter (see note below) | | `PrivateUpdateSkill` (0x02DD) | `UpdateSkill` | `default:` → `ByteSequence(false)` | | `PrivateUpdatePropertyInt` (0x02CD) | `UpdatePropertyInt` | `default:` → `ByteSequence(false)` | | `PrivateUpdatePropertyInt64` (0x02CF) | `UpdatePropertyInt64` | `default:` → `ByteSequence(false)` | `ByteSequence(false)` means `clientPrimed=false` → `CurrentValue` starts at `maxValue` (255); the first `NextValue` call detects `CurrentValue == maxValue`, resets to 0, and returns 0 — so **every counter's first observed value is 0**, then increments 1, 2, 3, ... wrapping back to 0 after 255 (`references/ACE/Source/ACE.Server/Network/Sequence/ByteSequence.cs:19-26,30-41`). None of the `ObjectPosition/Movement/State/Vector/.../Motion` special-cased 16-bit sequence types apply here — all six advancement-family sequence types fall through to the byte-sized default. **Shared counter caveat (0x02E7 vs 0x02E9):** `GameMessagePrivateUpdateVital`'s ctor calls `GetNextSequence(SequenceType.UpdateAttribute2ndLevel, creatureVital.Vital)` (line 11 of that file) — i.e. it uses the **same** `SequenceType.UpdateAttribute2ndLevel` bucket, keyed by the **same** `PropertyAttribute2nd` vital value, as the current-only message's `GetNextSequence(..., vital.ToEnum())` call. Because `Vital` (0x02E9's key type) and `PropertyAttribute2nd` (0x02E7's key type) share numeric values 1:1, and `SequenceManager.GetSequence` takes a raw `uint property` (`:27-30`, `:152`), a full 0x02E7 update for MaxHealth(1) and a current-only 0x02E9 tick for Health(2) hit **different dictionary keys** (`key = type<<16 | 1` vs `type<<16 | 2`) — so they do **not** actually share a counter across message types; each of the six (3 vitals × 2 opcodes) gets its own independent byte sequence. (Correcting an initial read of mine: same `SequenceType` enum value, but different `property` argument value, means different dictionary key. No cross-opcode sharing.) --- ## 4. Action → response mapping ### 4.1 `RaiseAttribute` (0x0045) → `HandleActionRaiseAttribute` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Attributes.cs:13-73`) 1. Validate: attribute exists, `amount <= AvailableExperience`. On failure: **no attribute update sent**; if `SpendAttributeXp` itself fails, sends `GameMessageSystemChat` ("Your attempt to raise {attr} has failed.", `ChatMessageType.Broadcast`) and returns. 2. `SpendAttributeXp` → `SpendXP(amount, sendNetworkUpdate=true)` → **`PrivateUpdatePropertyInt64` (0x02CF)** with `AvailableExperience` (new value) — `references/ACE/Source/ACE.Server/WorldObjects/Player_Xp.cs:352-363`. This fires on **every** successful spend, rank-up or not. 3. **`PrivateUpdateAttribute` (0x02E3)** — always sent on success, rank-up or not (`Player_Attributes.cs:35`). 4. If `prevRank != creatureAttribute.Ranks` (i.e. an actual rank-up occurred): - if max rank reached: a particle-effect broadcast (`PlayParticleEffect`, not a private GameMessage to this session) and a `" and has reached its upper limit"` suffix. - **`GameMessageSound`** (`Sound.RaiseTrait`) + **`GameMessageSystemChat`** ("Your base {attribute} is now {Base}{suffix}!", `ChatMessageType.Advancement`) — both sent together (`Player_Attributes.cs:48-51`). - if `attribute == Endurance`: an **extra `PrivateUpdateVital` (0x02E7) full update for Health** (`Player_Attributes.cs:56-58`) — comment states this "appears to trigger client to update both health and stamina" client-side, even though **only Health's full record is sent on the wire, not Stamina's**. A client parser needs to recompute both Health and Stamina derived display values from this single Health packet if it wants to match retail's stated behavior — this is a genuine, non-obvious retail/ACE quirk, not a bug to "fix". - if `attribute == Self`: an extra `PrivateUpdateVital` (0x02E7) full update for **Mana** (`Player_Attributes.cs:60-64`). - if `attribute` is `Strength` or `Quickness` and the `runrate_add_hooks` property is on: `HandleRunRateUpdate()` (affects run-speed derived state, not itself a GameMessage here). Ordered wire sequence for a successful Endurance raise that ranks up: `PrivateUpdatePropertyInt64(AvailableExperience)` → `PrivateUpdateAttribute(0x02E3)` → `GameMessageSound` + `GameMessageSystemChat` (same `EnqueueSend` call, so adjacent) → `PrivateUpdateVital(0x02E7, Health)`. ### 4.2 `RaiseVital` (0x0044) → `HandleActionRaiseVital` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs:20-65`) 1. Validate: vital exists in `Vitals` (so **must** be keyed by a Max* id — see §2.9), `amount <= AvailableExperience`. On failure (amount check): sends `GameMessageSystemChat` ("Your attempt to raise {vital} has failed.", `ChatMessageType.Broadcast`) — **note ACE's own comment**: "there is a client bug for vitals only, where the client will enable the button to raise a vital by 10 if the player only has enough AvailableExperience to raise it by 1" (`Player_Vitals.cs:30-33`) — a known retail client quirk worth preserving if we're porting client-side gating logic, not "fixing". 2. `SpendVitalXp` → `SpendXP` → **`PrivateUpdatePropertyInt64` (0x02CF)** `AvailableExperience`. 3. **`PrivateUpdateVital` (0x02E7)** full update — always sent on success (`Player_Vitals.cs:46`). 4. If rank-up: max-rank particle effect + suffix; **`GameMessageSound`(RaiseTrait)** + **`GameMessageSystemChat`** ("Your base {vital} is now {Base}{suffix}!", `ChatMessageType.Advancement`) (`:59-62`). **No cross-vital side effect** here (unlike RaiseAttribute's Endurance/Self special cases) — raising a vital directly only ever touches that one vital's full record. ### 4.3 `RaiseSkill` (0x0046) → `HandleActionRaiseSkill` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Skills.cs:21-67`) 1. Validate: creature skill exists and `AdvancementClass >= Trained` (untrained skills can't be raised by XP directly), `amount <= AvailableExperience`. Failure: silent (`log.Warn` only, **no chat message sent** — unlike RaiseAttribute/RaiseVital's failure paths, `HandleActionRaiseSkill` does not send `GameMessageSystemChat` on the "trained/specialized skill not found" or "amount > AvailableExperience" branches, `:27-35`). 2. `SpendSkillXp` → `SpendXP` → **`PrivateUpdatePropertyInt64` (0x02CF)** `AvailableExperience`. 3. **`PrivateUpdateSkill` (0x02DD)** — always sent on success (`Player_Skills.cs:42`). 4. If rank-up: max-rank particle effect + suffix; **`GameMessageSound`(RaiseTrait)** + **`GameMessageSystemChat`** ("Your base {skill} skill is now {Base}{suffix}!", `ChatMessageType.Advancement`) (`:56-59`). If `skill == Run`: `HandleRunRateUpdate()` (same run-speed hook as RaiseAttribute's Strength/Quickness case, gated by the same `runrate_add_hooks` property). ### 4.4 `TrainSkill` (0x0047) → `HandleActionTrainSkill` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Skills.cs:114-153`) 1. Validate: `creditsSpent <= AvailableSkillCredits`; skill base exists in the DAT skill table; **`creditsSpent` must exactly equal `skillBase.TrainedCost`** (server re-derives the cost from the DAT and rejects any client-sent value that doesn't match — `:129-133`). Any validation failure: silent server-side `log.Warn`, **no GameMessage sent at all** (function returns before reaching the `success`/`else` branch that sends anything). 2. Calls `TrainSkill(skill, creditsSpent)` (a different overload, `:171-197`) which sets `AdvancementClass = Trained`, resets `Ranks`/`InitLevel`/`ExperienceSpent`, and debits `AvailableSkillCredits -= creditsSpent` **in-process** (no network send inside this inner method — the caller sends the messages). 3. On success: - **`PrivateUpdateSkill` (0x02DD)** for the now-trained skill. - **`PrivateUpdatePropertyInt` (0x02CD)** `AvailableSkillCredits` (new value). - **`GameMessageSystemChat`** ("{skill} trained. You now have {N} credits available.", `ChatMessageType.Advancement`). - All three sent together in one `EnqueueSend(updateSkill, skillCredits, msg)` call (`:145-147`) — **no `GameMessageSound` here**, unlike Raise*'s rank-up path. - **Note: `TrainSkill` does NOT send a `PrivateUpdatePropertyInt64` (AvailableExperience) update** — training costs skill credits, not XP, so `AvailableExperience` is untouched. 4. On failure of the inner `TrainSkill(skill, creditsSpent)` call (only reachable if `AdvancementClass >= Trained` already, i.e. re-training an already-trained/specialized skill, or a credits race): **`GameMessageSystemChat`** ("Failed to train {skill}! You now have {N} credits available.", `ChatMessageType.Advancement`) — **no skill/credit update messages** on this path. ### 4.5 Specialize / Untrain / Unspecialize / Reset — no dedicated opcode Confirmed by exhaustive scan of `GameActionType` (`references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs`) — there is **no** `Specialize`/`Untrain`/`ResetSkill`/`ResetAttribute` entry in the 0x00xx–0x03xx GameAction range. `Player_Skills.cs` has server-side methods `SpecializeSkill`, `UntrainSkill`, `UnspecializeSkill`, `ResetSkill` (`:199-309`, `:865-921`) but **none of them carry a `[GameAction]` attribute** — they're invoked from other systems, not directly from a client opcode: - **`SkillAlterationDevice`** (Gem of Enlightenment = specialize, Gem of Forgetfulness = lower) — the item's `ActOnUse` is reached via the **generic `Use` action (`0x0036`)**, not a progression-specific opcode (`references/ACE/Source/ACE.Server/WorldObjects/SkillAlterationDevice.cs:58-103`). It then routes through a **confirmation dialog** (`ConfirmationManager.EnqueueSend(new Confirmation_AlterSkill(...))`, `:96`) — a separate ask/confirm wire round-trip (client replies via `ConfirmationResponse = 0x0275`), not part of this feature's direct action→response chain. Once confirmed, `AlterSkill` (`:169-230`) sends, per branch: - Specialize success: **`PrivateUpdateSkill`(0x02DD)** + **`PrivateUpdatePropertyInt`(0x02CD, AvailableSkillCredits)** + a **`GameEventWeenieErrorWithString`** (GameEvent sub-type `0x028B`, inside the `0xF7B0` GameEvent envelope — a different envelope from the plain `GameMessage` family used everywhere else in this doc) carrying `YouHaveSucceededSpecializing_Skill` + the skill name (`:176-185`). Layout of `GameEventWeenieErrorWithString`: `u32 errorType + String16L message` — `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventWeenieErrorWithString.cs:7-13`. - Lower (specialized→trained via `UnspecializeSkill`) or (trained→untrained via `UntrainSkill`) success: same two `PrivateUpdateSkill` + `PrivateUpdatePropertyInt` pair, with a different `WeenieErrorWithString` message id per exact transition (`:196-227`). - **`Enlightenment`** (100th-level Fianhe reset path) directly manipulates `player.Vitals[attribute]` and sends `GameMessagePrivateUpdateVital` — `references/ACE/Source/ACE.Server/Entity/Enlightenment.cs:218-220` — again reached through a non-progression trigger (an enlightenment/temple interaction, not a GameAction opcode of its own). - Generic **`ResetSkill`** (`Player_Skills.cs:865-921`) sends **`PrivateUpdateSkill`(0x02DD)** + **`PrivateUpdatePropertyInt`(0x02CD, AvailableSkillCredits)** + **`GameMessageSystemChat`** (plain chat text built in C#, not a `WeenieErrorWithString` id) — same three-message shape as TrainSkill's success path, minus the `GameMessageSound`. **Answer to "is there an UNTRAIN or specialize action opcode":** No. Retail/ACE routes all specialize/lower/reset flows through item-Use (`0x0036`) + a confirmation round-trip, or through non-player-initiated systems (Enlightenment). If CA1 needs to support these, the response-message shapes above (2× GameMessage + 1× GameEvent, all reusing opcodes already covered in §2) are sufficient — no new opcode needs to be learned, but the **GameEvent envelope (`0xF7B0`) and its sub-opcode framing** are a different code path from the plain `GameMessage` opcodes this doc otherwise covers, and weren't otherwise in scope for CA1's four core actions. --- ## 5. Disagreements between references 1. **`TrainSkill` credits field signedness.** ACE's handler reads it with `message.Payload.ReadInt32()` (signed) — `GameActionTrainSkill.cs:11` — and holtburger's `TrainSkillActionData.credits_spent` is `i32`, unpacked with `read_i32` — `actions.rs:99,109`. Chorizite's `Train_TrainSkillAdvancementClass.Credits` is declared `uint` and read via `reader.ReadUInt32()` — `Train_TrainSkillAdvancementClass.generated.cs:23,31`. **Genuine type-level disagreement** (ACE+holtburger say signed, Chorizite says unsigned). No practical wire effect since credits are always small non-negative values, but if CA1 code asserts a type, prefer **signed `int`** to match the two independent oracles (ACE server source + a working client) over the generated Chorizite stub. 2. **Message class naming for opcode `0x02E7`.** ACE calls it `GameMessagePrivateUpdateVital` (full vital record). Chorizite's generator calls the identical-opcode, identical-byte-layout type `Qualities_PrivateUpdateAttribute2nd`. Not a byte-level disagreement — purely a naming collision that could cause confusion when grepping Chorizite for "Vital" and finding nothing (as happened during this research pass — see §"open questions" for how this was discovered). If our own parser code introduces names, prefer ACE's naming (`PrivateUpdateVital`) since that's also what our existing `PrivateUpdateVital.cs` already uses. 3. **`RaiseSkill`/`TrainSkill` skill-id read signedness.** ACE reads skill ids for `RaiseSkill` as `(Skill)message.Payload.ReadUInt32()` (unsigned) but for `TrainSkill` conceptually the same field is also `(Skill)message.Payload.ReadUInt32()` — **both unsigned** on the ACE side (`GameActionRaiseSkill.cs:10`, `GameActionTrainSkill.cs:10`). Chorizite reads **both** as `(SkillId)reader.ReadInt32()` (signed) — `Train_TrainSkill.generated.cs:30`, `Train_TrainSkillAdvancementClass.generated.cs:30`. holtburger's `RaiseSkillActionData.skill_type` is `u32` (unsigned) — `actions.rs:69`, `:78`; its `TrainSkillActionData.skill_type` is also `u32` (only `credits_spent` is signed) — `actions.rs:98,107`. So Chorizite is the outlier reading skill id as signed on **both** actions; ACE+holtburger agree it's unsigned. Byte-identical either way since valid skill ids are small positive ordinals (§6 notes the exact numbering isn't pinned in this pass). 4. **No disagreement found** on any of the six inbound message byte layouts (§2.5–§2.10) — ACE, Chorizite, and holtburger's independent implementations agree field-for-field, including the unusual `ushort ranks / ushort adjustPP` pair inside `PrivateUpdateSkill` and the hardcoded `adjustPP=1` constant (confirmed present in a holtburger golden-fixture test, not just inferred from ACE source). --- ## 6. Open questions / not settled from the references 1. **`PrivateUpdateSkillLevel` (`0x02DF`) / `PublicUpdateSkillLevel` (`0x02E0`) producer not found.** The opcode exists in ACE's `GameMessageOpcode` enum and holtburger models a full `UpdateSkillLevel` struct for it (`types.rs:230-269`, with fixture tests at `:353-375`), but I did not find any ACE call site constructing a `GameMessagePrivateUpdateSkillLevel` (in fact, no such class file exists in ACE's `GameMessages/Messages/` directory at all — only `GameMessagePrivateUpdateSkill.cs`, the *full*-opcode 0x02DD one). Either (a) ACE genuinely never emits this ranks-only delta and it's vestigial/retail-only, or (b) a producer exists elsewhere I didn't search (e.g. a bulk/batch skill-sync path outside `Player_Skills.cs` and `SkillAlterationDevice.cs`). **Not required for CA1's four actions** (none of their response chains use it), but flag before assuming the opcode is dead — a targeted `grep -r PrivateUpdateSkillLevel references/ACE` beyond what this pass covered would settle it. 2. **Exact numeric `Skill` enum ordinals** were not fully enumerated — I confirmed the enum is ordinal-numbered starting at `None=0` with several `/* Retired */`/`/* Unimplemented */` gaps preserved in-place (`references/ACE/Source/ACE.Entity/Enum/Skill.cs:11-40+`), and holtburger's test comments corroborate two spot values (`6 = MeleeDefense`, `14 = ArcaneLore`, `references/holtburger/crates/holtburger-protocol/src/messages/player/actions.rs:270,283`), but I did not transcribe the full ~60-entry table. If acdream doesn't already have a `Skill` enum under `src/AcDream.Core`, the CA1 implementer should port the full ordinal list directly from `references/ACE/Source/ACE.Entity/Enum/Skill.cs` verbatim (order matters — it's explicitly documented as never-reorderable). 3. **`Sound.RaiseTrait`'s numeric id** and **`ChatMessageType.Advancement`'s numeric id** were not looked up (both are straightforward enum lookups in `references/ACE/Source/ACE.Entity/Enum/` if needed for exact-byte test fixtures). 4. **Whether raising a vital's rank ever auto-adjusts `Current`** wasn't traced end-to-end. The full `PrivateUpdateVital` message always includes `creatureVital.Current` as one of its fields (§2.6), but `HandleActionRaiseVital`/`SpendVitalXp` only touch `Ranks`/`ExperienceSpent` — I did not find code in that call chain that tops up `Current` to match a newly raised `MaxValue`. If retail visibly bumps current HP/stamina/mana immediately on a vital raise, that adjustment (if any) happens somewhere outside the files read in this pass (possibly the vital heartbeat tick picking it up on the next 5s cycle, given `VitalHeartBeat()` exists as a separate mechanism — `Player_Vitals.cs:196-205`). Confirm with a live capture before assuming instant top-up. --- # CA1 — retail attribute/skill/vitals recompute chain Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named) + `acclient.h` (verbatim retail structs) + Ghidra MCP clean decompiles (`patchmem.gpr`, same binary) for every function relied on below. Cross-checked against `references/ACE/Source/ACE.Server` and `references/ACE/Source/ACE.DatLoader`. All addresses are file offsets == RVA (x86, base 0x400000 not added — matches the pseudo-C left column). **Headline verdict (drives the whole CA1 design): the retail client computes skills, vitals maxima, and run rate LIVE, at inquiry time, from raw stored attribute/skill data. There is no cached "effective skill" field and no explicit recompute-cascade when an attribute changes. UI refresh is a separate, decoupled observer mechanism (`QualityRegistrar`) that only tells a widget "id X changed, re-pull it" — it never carries the new value.** --- ## 1. Inbound handler map All inbound private/public quality-update wire messages funnel through one C++ template, `ClientObjMaintSystem::::UpdateStat`, instantiated once per (QualityType, wire-value-shape) pair. The **public** handlers (`Handle_Qualities__Update*`, updates for an arbitrary observed weenie) and the **private** handlers (`Handle_Qualities__PrivateUpdate*`, always target the local player via `SmartBox::smartbox->player_id`) both funnel into the same template body — the private ones just supply `player_id` instead of a wire-carried target id. Confirmed handler entry points (`ClientObjMaintSystem::Handle_Qualities__*`): | Handler | Address | Wire value shape | Delegates to | |---|---|---|---| | `UpdateAttribute` | `0x00558c80` | full `Attribute` struct | `UpdateStat` | | `UpdateAttributeLevel` | `0x00558ca0` | raw `unsigned long` | `UpdateStat` | | `UpdateAttribute2nd` | `0x00558cc0` | full `SecondaryAttribute` struct | `UpdateStat` | | `UpdateAttribute2ndLevel` | `0x00558ce0` | raw `unsigned long` | `UpdateStat` | | `UpdateSkill` | `0x00558d00` | full `Skill` struct | `UpdateStat` | | `UpdateSkillLevel` | `0x00558d20` | raw `unsigned long` | `UpdateStat` | | `UpdateSkillAC` | `0x00558d40` | `SKILL_ADVANCEMENT_CLASS` enum | `UpdateStat` | | `PrivateUpdateAttribute` | `0x00558e80` | full `Attribute` struct, target = local player | same template, `player_id` forced | | `PrivateUpdateAttributeLevel` | `0x00558eb0` | raw ulong, target = local player | " | | `PrivateUpdateAttribute2nd` | `0x00558ee0` | full struct, target = local player | " | | `PrivateUpdateAttribute2ndLevel` | `0x00558f10` | raw ulong, target = local player | " | | (Skill private variants follow the identical pattern at nearby addresses in the same block, `0x00558f4x`–`0x00558fcx`) | | | | Body of every `UpdateStat` instantiation for Attribute/Attribute2nd/Skill (example, the raw-`unsigned long` Attribute overload, `0x00557d00`): ``` UpdateStat(this, qualityId, timestampByte, targetWeenieId, newValue): weenie = CObjectMaint::GetWeenieObject(this, targetWeenieId) if weenie == null: return 0 if !ACCWeenieObject::SetupStamper(weenie): return 0 // per-object dedupe/anti-replay stamp if !WTimeStamper::UpdateTS(weenie.stamper, qualityId | 0x80000, timestampByte): return 0 // stale/out-of-order update, drop qualities = weenie.pQualities // CACQualities* at weenie+0x14c if qualities != null: qualities.SetAttribute(qualityId, newValue) // <-- PURE RAW STORAGE WRITE (§below) ACCWeenieObject::OnStatUpdated(weenie, qualityId, newValue) // only on the *raw-value* overloads (see caveat) if QualityRegistrar::s_pQR != null: QualityRegistrar::s_pQR.CallChangeHandler(weenie, Attribute_StatType, qualityId) // <-- THE UI notify (§7) return 0 ``` Every `SetX` call (`CACQualities::SetAttribute` `0x00591a50`/`0x00591aa0`, `SetAttribute2nd` `0x00592530`/`0x005925f0`/`0x00592cf0`, `SetSkill` `0x00592240`, `SetSkillLevel` `0x00592340`, `SetSkillAdvancementClass` `0x00592430`) was decompiled directly and **each one is a pure raw-storage write** — no recompute, no dirty flag, no dependent-field touch: - `SetAttribute` → lazily allocates `AttributeCache` if absent, then `AttributeCache::SetAttribute(id, {init_level, level_from_cp, cp_spent})` overwrites those three fields verbatim (`0x005cc740`). - `SetSkill`/`SetSkillLevel` → hash-table upsert into `CACQualities::_skillStatsTable` (`PackableHashTable`), copying `_sac, _pp, _init_level, _level_from_pp, _resistance_of_last_check, _last_used_time` verbatim. - `SetSkillAdvancementClass` → same hash table, writes only `_sac` via `Skill::SetSkillAdvancementClass`. **`ACCWeenieObject::OnStatUpdated` caveat (OPEN, non-load-bearing):** two concrete overload bodies were found in the binary, `0x0058c680` (switches on PropertyBool ids: Stuck=1, Openable=3, Inscribable=0x16, UIHidden=0x18, CellBarrierImmune=0x19, HiddenAdmin=0x1a) and `0x0058df20` (switches on PropertyInt ids: Type=1, Priority=4, ItemsCapacity=6, …, HookItemTypes=0x98). **Neither switch has a case matching the Attribute/Attribute2nd/Skill id space** (1–6, 0x1f–0x32), so whichever of the two the compiler resolved the raw-scalar `UpdateStat` call to, it is a silent no-op for attribute/skill ids — this function does **not** participate in character-advancement recompute. I could not pin the exact overload resolved at each of the 7 call sites (BinaryNinja's rendered parameter types are ambiguous between `int32_t` and `long` at this ABI); it doesn't change the answer to Q1, so left open. **Verdict for Q1: an inbound stat update writes the raw number/struct straight into `CACQualities`'s storage and fires one generic `QualityRegistrar::CallChangeHandler(weenie, StatType, id)` notification. It never calls anything that resembles "recompute skill" or "recompute vitals max" — those are computed by the *reader*, not the writer.** --- ## 2. The skill formula — LIVE at inquiry time (not cached) ### Call chain `CACQualities::InqSkill(this, skillId, out int, includeRaw)` @ `0x00592660` (the "effective" overload; there's also a bare struct-copy `InqSkill` overload at `0x005919df` unrelated to computation) calls `InqSkillBaseLevel` @ `0x00592140` first, then layers bonuses on top. **`InqSkillBaseLevel`** (attribute contribution — the "base" the game shows before augmentations): ``` InqSkillBaseLevel(this, skillId, out value, rawAttrFlag): skillTable = DBObj::GetByEnum(4, 2, 0x10000004) // the portal.dat SkillTable singleton if skillTable == null: return 0 base = skillTable.GetSkillBase(skillId) // SkillBase record: _min_level, _formula, costs... if base == null: return 0 sac = UNTRAINED entry = this._skillStatsTable?.lookup(skillId) if entry != null: sac = entry._sac if (int)sac < (int)base._min_level: // gate: skill not usable at this SAC yet value = 0 return 1 // NOTE: still returns success=1, value forced 0 a1 = 0; a2 = 0 if base._formula._attr1 != 0: InqAttribute(this, base._formula._attr1, &a1, rawAttrFlag) if base._formula._attr2 != 0: InqAttribute(this, base._formula._attr2, &a2, rawAttrFlag) return SkillFormula::Calculate(base._formula, a1, a2, &value) ``` **`SkillFormula::Calculate`** @ `0x00591960` — the actual math, verbatim (struct `SkillFormula { uint _w, _x, _y, _z, _attr1, _attr2; }`, `acclient.h:40199`): ``` Calculate(formula, attr1Value, attr2Value, out result): if formula._z == 0: return 0 // divisor 0 => invalid formula, caller treats as failure numerator = formula._x * attr1Value + formula._y * attr2Value + formula._w result = floor((float)numerator / (float)formula._z + 0.5) // round-half-up via floor(x+0.5) return 1 ``` **`InqAttribute(this, attrId, out value, rawFlag)`** @ `0x005919d0`/`0x00591a00` — the value that feeds the formula is itself live: ``` InqAttribute(this, attrId, out value, rawFlag): if this._attribCache == null: return 0 if !AttributeCache::InqAttribute(this._attribCache, attrId, &value): return 0 if rawFlag == 0: EnchantAttribute(this, attrId, &value) // CEnchantmentRegistry::EnchantAttribute — live active-spell lookup return 1 ``` `AttributeCache::InqAttribute` (`0x005cc4e0`) is a flat per-attribute struct store (`_strength/_endurance/_quickness/_coordination/_focus/_self`, each a heap `Attribute{_init_level,_level_from_cp,_cp_spent}`); it returns `_init_level + _level_from_cp` — exactly the two fields `SetAttribute` overwrites on every wire update. **There is no third place that stores a "current effective attribute" — the value that comes back is always the freshly-stored number plus whatever `CEnchantmentRegistry` says right now.** ### Back in `InqSkill` (`0x00592660`), after the base level: ``` InqSkill(this, skillId, out value, rawFlag): if !InqSkillBaseLevel(this, skillId, &value, rawFlag): return 0 entry = this._skillStatsTable?.lookup(skillId) if entry != null: value += entry._level_from_pp + entry._init_level // trained ranks + chargen bonus // PropertyInt 0x16d = LumAugAllSkills — flat, unconditional, ALWAYS applied (even rawFlag==1) if InqInt(0x16d, &bonus) && bonus > 0: value += bonus // category-gated flat +10, keyed by skill id -> which "AugmentationSkilledX" PropertyInt to check // skill in {0x1f,0x20,0x21,0x22,0x2b} -> PropertyInt 0x12e (302, AugmentationSkilledMagic) // skill in {0x29,0x2c,0x2d,0x2e,0x31} -> PropertyInt 0x12c (300, AugmentationSkilledMelee) // skill == 0x2f -> PropertyInt 0x12d (301, AugmentationSkilledMissile) if augBonusInt > 0: value += 10 if rawFlag == 0: // "raw"==0 means "give me the effective/enchanted value" EnchantSkill(this, skillId, &value) // CEnchantmentRegistry::EnchantSkill — live spell buffs + VITAE live here // PropertyInt 0x146 (326, AugmentationJackOfAllTrades) -- flat +5, added AFTER enchant/vitae if InqInt(0x146, &b2) && b2 > 0: value += 5 // PropertyInt 0x158 (344, LumAugSkilledSpec) -- doubled, SPECIALIZED-only, added AFTER enchant/vitae InqInt(0x158, &b3) if b3 > 0 and entry?._sac == SPECIALIZED: value += b3 * 2 return 1 ``` `EnchantSkill`/`EnchantAttribute`/`EnchantAttribute2nd` (`0x0058f0b0` / `0x0058f070` / `0x0058f090`) are one-line delegates to `CEnchantmentRegistry::EnchantSkill/EnchantAttribute/EnchantAttribute2nd` — the client's live table of currently-active spell effects. Nothing about these calls is memoized; they walk the registry fresh every call. ### Verdict **LIVE, confirmed by direct trace, not inference.** An attribute raise (`SetAttribute` overwriting `AttributeCache`) requires **zero** explicit follow-up to make skills reflect it — the next `InqSkill`/`InqSkillBaseLevel` call re-reads `AttributeCache` and re-runs `SkillFormula::Calculate`. Same for a spell buff landing (`CEnchantmentRegistry` update) or an SAC change. **CA1 should port this as a pure function `Skill.Current(attributes, trainedState, activeEnchantments, augmentations, vitae) -> uint`, called on demand by every UI/gameplay reader — not as a cached field refreshed by an event handler.** --- ## 3. Vitals maxima (MaxHealth/MaxStamina/MaxMana) Same formula engine, different DBObj table. `CACQualities::InqAttribute2nd` overload with `(id, out uint, rawFlag)` @ `0x00592020`: ``` InqAttribute2nd(this, id, out value, rawFlag): base = 0 if id in {1,3,5}: // 1=MaxHealth,3=MaxStamina,5=MaxMana (odd ids; ACE PropertyAttribute2nd matches exactly) if !InqAttribute2ndBaseLevel(this, id, &base, rawFlag): return 0 if id == 1: // MaxHealth only if InqInt(0x17b /* 379 = PropertyInt.GearMaxHealth */, &gear) && gear: base += gear // flat item-granted Max Health bonus, added to "base" tier if this._attribCache != null and AttributeCache::InqAttribute2nd(cache, id, &cached) != 0: value = cached + base // "current" ids (2,4,6) hit ONLY this branch: cached holds the live current HP/SP/mana, base==0 for those ids else: if base == 0: return 0 value = base if rawFlag == 0: EnchantAttribute2nd(this, id, &value) // live spell buffs on the vital (max or current) return 1 ``` `InqAttribute2ndBaseLevel` @ `0x00591d20` mirrors `InqSkillBaseLevel` exactly but reads from a different global singleton, `DBObj::GetByEnum(1, 2, 0x10000003)` = the `Attribute2ndTable` DBObj (`struct Attribute2ndTable { Attribute2ndBase _max_health, _max_stamina, _max_mana; }`, `acclient.h:40216`, each an `Attribute2ndBase{ SkillFormula _formula; }`) — id 1→`_max_health`, 3→`_max_stamina`, 5→`_max_mana`, feeding the **same** `SkillFormula::Calculate` used for skills. Concretely: MaxHealth/MaxStamina are driven by the Endurance-weighted formula and MaxMana by the Self-weighted formula, but the exact `_attr1/_attr2/_w/_x/_y/_z` values are DAT data, not hardcoded in this function — the mechanism is confirmed, the numeric coefficients were not independently dumped (OPEN, low-value: any live `portal.dat` read or ACE's `SecondaryAttributeTable` gives them for free). Note the asymmetry: **odd ids (max) run the formula; even ids (current Health/Stamina/Mana) are pure `AttributeCache` reads** (server-pushed current value), never formula-derived. `Attribute2nd` id 2/4/6 in `AttributeCache` is exactly what `PrivateUpdateAttribute2ndLevel` (`0x00558f10`) overwrites. ### Bounds enforcement on the *current* value `CACQualities::BoundsCheck` @ `0x005920e0`, called from the `SetAttribute2nd(id, rawValue, ...)` wrapper (`0x005925f0`) whenever id is one of the *current* ids (2,4,6): ``` BoundsCheck(this, id, ref value, out maxOut): if id not in {2,4,6}: return 1 // only current HP/SP/mana are clamped if value < 0: value = 0; return 1 if !InqAttribute2nd(this, id-1, &maxOut, rawFlag=0): return 0 // id-1 = the paired max id; LIVE lookup if maxOut < value: value = maxOut return 1 ``` So the current-value SET path itself pulls a **live** Max via `InqAttribute2nd` to clamp — one more confirmation there's no cached max anywhere the client trusts. ### UI refresh trigger for the vitals bar (ties into §7) `Attribute2ndInfoRegion::Attribute2ndInfoRegion` (`0x004f1680`) — the vitals bar row widget's ctor — registers **three or more** separate `QualityRegistrar::RegisterQualityHandlerForThePlayer(Attribute_2nd_StatType=9, id, this)` subscriptions: 1. `m_CurAttribute` (e.g. Health=2) 2. `m_MaxAttribute` (`= m_CurAttribute - 1`, e.g. MaxHealth=1) 3. It then reads the `Attribute2ndTable` record for the max id (`Attribute2ndTable::InqAttribute2ndBase`) and, **if the formula's `_attr1`/`_attr2` are non-zero, ALSO registers for those ids** — under `StatType 9` again, even though the contributing attribute (e.g. Endurance) is a base `Attribute` that only ever fires `CallChangeHandler(Attribute_StatType=8, ...)`. This third registration therefore looks structurally dead/vestigial for the base-attribute case — see the note at the end of §7. It doesn't change the answer to "what triggers the bar to redraw": the confirmed, load-bearing triggers are (1) and (2), i.e. **the vitals bar redraws when the SERVER pushes an explicit `PrivateUpdateAttribute2nd`/`...Level` wire message for that specific current-or-max id — not from a local recompute cascade.** ### Verdict for Q3 Formula: **live**, same mechanism/engine as skills (`SkillFormula::Calculate` over the `Attribute2ndTable` DAT record), confirmed by direct trace. Trigger: **the vitals bar's redraw is driven by `QualityRegistrar` notifications tied 1:1 to the wire ids the server actually pushes** (own current id + own max id). The client is fully capable of computing MaxHealth locally from Endurance without any server push (§2's live chain applies here too), but the UI's *redraw* event only fires on an explicit wire update to that exact (StatType, id) — so **retail's server is the one that decides when to recompute-and-push an updated MaxHealth/MaxStamina/MaxMana whenever the underlying attribute changes; the client formula exists for the value itself, not as the UI's dirty-trigger.** CA1 must mirror this: acdream's server-authoritative path (ACE) is expected to push its own recomputed vitals-max update on an Endurance/Self change — verify ACE actually does this (see §8) rather than relying on a purely-local recompute-on-attribute- change UI hook, or the vitals bar will silently stop matching retail's redraw cadence even though the *number* would still be correct next time anything else forces a redraw. --- ## 4. Run rate — LIVE, re-derived every motion tick (not event-driven at all) `CACQualities::InqRunRate` @ `0x00592800` **inlines its own copy** of the InqSkill formula chain for skill id `0x18` (Run) rather than calling `InqSkill` — presumably to add one extra rule: ``` InqRunRate(this, out rate): if !InqLoad(this, &loadFactor): return 0 // encumbrance multiplier, 1.0 = unencumbered if this._attribCache == null: return 0 if !AttributeCache::InqAttribute2nd(cache, 4 /*Stamina, current*/, &curStamina): return 0 EnchantAttribute2nd(this, 4, &curStamina) // live buffs on current Stamina if !InqSkillBaseLevel(this, 0x18, &runSkill, rawFlag=0): return 0 entry = _skillStatsTable?.lookup(0x18) if entry: runSkill += entry._level_from_pp + entry._init_level if InqInt(0x16d) > 0: runSkill += bonus // LumAugAllSkills EnchantSkill(this, 0x18, &runSkill) // live spell buffs + vitae if InqInt(0x146) > 0: runSkill += 5 // JackOfAllTrades if InqInt(0x158) > 0 and entry?._sac == SPECIALIZED: runSkill += InqInt(0x158) * 2 // LumAugSkilledSpec if curStamina == 0: // <-- FATIGUE RULE, Run-specific runSkill = 0 // exhausted (0 current Stamina) => cannot use Run skill at all rate = MovementSystem::GetRunRate(loadFactor, runSkill, 1.0) return 1 ``` `InqMaxRunRate` @ `0x00591b20` is just `MovementSystem::GetRunRate(0.0 /* full load */, 9999 /* max skill */, 1.0)` — a theoretical ceiling, not tied to the player at all. ### Who calls it, and how often Four call sites, all in `CMotionInterp` (the per-entity motion interpolator), all on `weenie_obj->InqRunRate(...)` (virtual dispatch through `ACCWeenieObject::InqRunRate` `0x0058c560` → `CACQualities::InqRunRate`): - `CMotionInterp::apply_run_to_command` @ `0x00527be0` — scales a forward/ sidestep command's magnitude when a new motion command is applied. - `CMotionInterp::get_max_speed` @ `0x00527cb0` - `CMotionInterp::get_adjusted_max_speed` @ `0x00527d00` - `CMotionInterp::get_state_velocity` @ `0x00527d50` — computes the actual per-tick velocity vector; this one runs on the motion-interpolation cadence (every tick the interpolator advances), not just on command change. **All four fall back to `this->my_run_rate` (a plain cached float on `CMotionInterp`) if `InqRunRate` returns 0.** `InqRunRate` returns 0 whenever `this._attribCache == null` — i.e. whenever the weenie doesn't carry a live, fully-populated `CACQualities` (every weenie *other than the local player*: remote players and monsters only get a thin/partial qualities projection over the wire, not full trained-attribute data). ### Where `my_run_rate` (the fallback) is set Found exactly two write sites, both inside the inbound movement-event parser handling `MoveToObject` (case 6) and `MoveToPosition` (case 7) motion commands (`0x005245e9` / `0x00524656`, inside the function starting near `0x00524460`): ``` case MoveToObject / MoveToPosition: MovementParameters::UnPackNet(¶ms, kind, wire) // unpacks the wire MovementParameters struct speed = *(float*)wire; wire += 4 // an explicit speed/run-rate float on the wire this.motion_interpreter.my_run_rate = speed ... ``` ### Verdict for Q4 **Local player**: `InqRunRate` is called **every tick** the motion interpolator needs a velocity — there is no cache, no "recompute on Run skill change" event at all; the fatigue check (current Stamina == 0) and the whole skill-formula chain are re-evaluated on every call. **CA1 should port `InqRunRate` as a per-tick pure query** (attributes + skill state + enchantments + current Stamina → run rate), exactly mirroring how it already treats skills. **Remote weenies (and any weenie without a full qualities projection)**: run rate is **not** derived locally at all — it's a cached float taken verbatim from an explicit `speed` field on the server's `MoveToObject`/ `MoveToPosition` wire payload (`MovementParameters`), applied once per motion command, held until the next one. This directly confirms the design already noted in CLAUDE.md/`ACDREAM_RUN_SKILL` docs (`PlayerMovementController.ApplyServerRunRate`, echoing `UpdateMotion.ForwardSpeed`): **retail itself does the same local-computes/remote-trusts-the-wire split** — acdream's existing dual-path shape is retail-faithful, not an adaptation. (One nuance to verify against current acdream code: retail's wire-sourced fallback is populated from the `MovementParameters` speed field carried on `MoveToObject`/`MoveToPosition` specifically, not from every `UpdateMotion` — worth a follow-up check that acdream's actual sync point matches this exact message, not a broader one.) --- ## 5. Attribute-less skills (e.g. Salvaging) Confirmed by code inspection of `InqSkillBaseLevel` (§2): the two `InqAttribute` calls are individually gated — `if base._formula._attr1 != 0: InqAttribute(...)` and same for `_attr2`. If a `SkillBase._formula` has `_attr1 == 0` and `_attr2 == 0` (no attribute contribution authored for that skill), both `a1` and `a2` stay `0`, and `SkillFormula::Calculate` reduces to: ``` result = floor((_x*0 + _y*0 + _w) / _z + 0.5) = floor(_w/_z + 0.5) ``` i.e. a pure constant (the formula's `_w`/`_z` bias, presumably 0/1 in practice for a true attribute-less skill, giving `result = 0`). **This degrades gracefully — no null-check, no divide-by-zero risk** (the only divide-by-zero guard is `_z == 0 => return 0`, unrelated to attr1/attr2 being zero). Everything downstream (trained ranks, augmentations, enchantments) applies identically regardless of whether the attribute term contributed anything. **OPEN**: which specific skill ids have `_attr1==_attr2==0` in the shipped `portal.dat` SkillTable was not independently confirmed here (that's DAT data, not code — the pseudo-C only proves the *mechanism* handles it correctly). A live DatCollection read of `SkillTable` (0x10000004) would give the exact list; ACE's own `references/ACE/Source/ACE.DatLoader` reads the same table and could be probed against a live install if needed for CA1 test fixtures. --- ## 6. Specialization semantics (SKILL_ADVANCEMENT_CLASS) `enum SKILL_ADVANCEMENT_CLASS { UNDEF=0, UNTRAINED=1, TRAINED=2, SPECIALIZED=3, NUM=4 }` (`acclient.h:2951`). Two, and only two, places the runtime formula reads SAC (everything else — `_trained_cost`/ `_specialized_cost` on `SkillBase` — is chargen/skill-credit-spending data, never touched by `InqSkill`/`InqSkillBaseLevel`/`InqRunRate`): 1. **Usability gate** (`InqSkillBaseLevel`): `if (int)sac < (int)base._min_level: value = 0`. A skill authored with `_min_level = TRAINED` reads as 0 for an untrained character; one authored `_min_level = UNTRAINED` (i.e. always usable) is never gated. This is purely a floor-to-zero, not a formula change — the attribute/formula computation for a *usable* skill is identical between Trained and Specialized. 2. **`LumAugSkilledSpec` (PropertyInt 0x158/344) doubling**, in both `InqSkill` and the inlined copy in `InqRunRate`: the augmentation's own int value is added **only if the skill's own SAC == SPECIALIZED**, and when it applies it's doubled (`value += aug * 2`), vs. not applied at all for Trained. This is the *only* place SAC changes the numeric formula at runtime. There is **no** "+10 for specialized / +5 for trained" retail-side constant bonus baked into the client's runtime inquiry math — that folklore number (confirmed in ACE's own `CreatureSkill.InitLevel` doc comment: "A bonus from character creation: +5 for trained, +10 for specialized") is a **chargen-time one-shot** value baked directly into `_init_level` when the character is created (part of the flat number `SetSkill`/`SetSkillLevel` write into storage), not something the runtime formula re-derives from SAC on every call. `InqSkillBaseLevel`/`InqSkill` just add whatever `_init_level` already holds — they never branch on SAC to decide it. **CA1 must not port a "branch on SAC, add 5 or 10" step into the runtime formula — that number belongs entirely to chargen (already covered by `ChargenSkillAdvancement.cs`/CC's completed work), and shows up in the runtime formula only as the already-baked `_init_level` field.** Trained-cost/specialized-cost fields on `SkillBase` (`_trained_cost`,`_specialized_cost`) are the CP-cost-to-raise-SAC numbers used by the chargen/skill-credit UI, confirmed unused by any of the `Inq*`/`SkillFormula::Calculate` call chains traced above. --- ## 7. UI refresh mechanism `QualityRegistrar` (`acclient.h:33347`, `struct { vfptr; IntrusiveHashTable m_handlers; QualityHandler m_PlayerQualityHandler; QualityHandler m_GlobalQualityHandler; }`) is a genuine observer/pub-sub registry, singleton `QualityRegistrar::s_pQR`, built by `CFactory::MakeQualityRegistrar_Internal` (`0x0054af80`). Its vtable (`acclient.h:33363`): ``` RegisterQualityHandler(weenieId, StatType, qualityId, QualityChangeHandler*) RegisterQualityHandlerForThePlayer(StatType, qualityId, QualityChangeHandler*) UnRegisterQualityHandler(...) / UnRegisterQualityHandlerForThePlayer(...) CallChangeHandler(weenie, StatType, qualityId) // fired by every SetX wire handler, §1 ``` `QualityChangeHandler`'s vtable (`acclient.h:33237`) is exactly two methods: ``` OnQualityChanged(CWeenieObject* owner, StatType type, uint qualityId) OnQualityRemoved(CWeenieObject* owner, StatType type, uint qualityId) ``` **Critically, the callback carries no value** — only "this (StatType, id) just changed on this owner." Every UI widget that cares about a live number must pull it itself. Confirmed concretely for the character-panel attribute row: `AttributeInfoRegion::AttributeInfoRegion` (`0x004f1530`) ends its ctor with `RegisterQualityHandlerForThePlayer(Attribute_StatType=8, this->m_Attribute, this)`; `Attribute2ndInfoRegion::Attribute2ndInfoRegion` (`0x004f1680`) does the equivalent for the vitals bar (§3); `SkillInfoRegion`-style rows use `Skill_StatType=4` the same way (seen at `0x004f2172`: `InfoRegion::InfoRegion(this, ..., Skill_StatType, skillId, iconDID)`, base ctor only builds the label/value `UIElement`s — the subclass ctor is what registers). `StatType` enum (`acclient.h:2879`) used as the registry's namespace key: `Int=1, Float=2, Position=3, Skill=4, String=5, DataID=6, InstanceID=7, Attribute=8, Attribute_2nd=9, BodyDamageValue=0xA, BodyDamageVariance=0xB, BodyArmorValue=0xC, Bool=0xD, Int64=0xE`. ### Verdict for Q7 Event-driven, not per-frame polling and not a dirty-flag scan. Registration is per-widget, per-(StatType,id), keyed either to a specific weenie or to "whichever weenie is currently the local player" (`RegisterQualityHandlerForThePlayer`, which is what every character-panel/ vitals-bar row uses — it survives player-object swaps, e.g. on login/character-switch, without re-registering). **The push only says "something changed"; the UI re-derives the actual number via the same live `Inq*` chain described in §2/§3/§4.** This is the cleanest possible design for CA1 to port: one `IQualityChangeNotifier` (weenie, StatType, id) event, fired from the same write path identified in §1, with every panel/HUD element subscribing per-id and re-pulling its value on notification rather than caching a pushed value. **Loose end (non-blocking, flagged for awareness):** `Attribute2ndInfoRegion` also registers under `Attribute_2nd_StatType` for the *base-attribute* ids that feed the max-vital formula (e.g. Endurance's id, when the vitals row is MaxHealth). Base-attribute changes fire `CallChangeHandler` under `Attribute_StatType=8`, not `9` — so that third registration looks like it never actually fires from a base-attribute change in this build. Given §3's finding that the vitals bar's real, working trigger is the server's own explicit MaxHealth/MaxStamina/MaxMana push, this looks like either vestigial/defensive code or a targeted id-space that happens to coincide by accident; it is not load-bearing for CA1's design and I would not port it without further evidence it does anything. --- ## 8. ACE / chargen cross-check ### `src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs` Already ported the **correct** formula *shape*: `ChargenSkillFormula(AdditiveBonus, Attribute1Multiplier, Attribute2Multiplier, Divisor, Attribute1, Attribute2)` is documented as sourced from `gmCGSkillsPage::MakeSkillFormula @0x00480e10` and maps 1:1 onto `SkillFormula{_w,_x,_y,_z,_attr1,_attr2}` — verified: chargen's own compute path (`0x005c4bfb`, inside `CharGenState`-related code) calls the **same** `SkillFormula::Calculate` (`0x00591960`) used by the live runtime path in §2. So chargen and post-login runtime share one formula engine in retail, and `ChargenSkillAdvancement.cs`'s field layout is provably correct for CA1 to reuse/extend. **Gap found**: `ChargenSkillFormula` today is *data only* — the one consumer (`CharacterCreationSkillsPage.cs::ComposeFormula`) only builds a **display string** ("Str + End / 2") from it; there is no C# port yet of the actual `SkillFormula::Calculate` numeric math (`floor((x*a1 + y*a2 + w)/z + 0.5)`). CA1 needs to add this — and per the finding below, it should **not** copy ACE's simplified C# version. ### ACE cross-check: strong structural match, one formula-fidelity gap `references/ACE/Source/ACE.Server/WorldObjects/Entity/CreatureSkill.cs` (`Current` property) matches the retail decompile's *structure* almost term-for-term: - `IsUsable` gate ≈ retail's `sac < min_level` (ACE additionally special- cases `min_level==1` meaning "usable while untrained" — consistent). - `total += InitLevel + Ranks` ≈ retail's `_init_level + _level_from_pp`. - `GetAugBonus_Base` (LumAugAllSkills flat, AugmentationSkilled{Melee, Missile,Magic}*10 gated by skill category, **plus `Enlightenment`**) is added **before** the vitae/enchantment multiplier — same tier as retail's pre-`EnchantSkill` additions (0x16d, 300/301/302). - `GetAugBonus_Current` (`AugmentationJackOfAllTrades*5`, `LumAugSkilledSpec*2` gated on `SAC==Specialized`) is added **after** vitae/multiplier — same tier as retail's post-`EnchantSkill` additions (0x146, 0x158). This before/after placement matching retail exactly (down to which two bonuses are on which side of the vitae multiply) is strong independent confirmation the decompile trace above is read correctly. - **Divergence**: ACE's `AugmentationSkilledMelee/Missile/Magic` bonus is `aug_level * 10`; retail's is a **flat +10 gated on `>0`**, not scaled by the stored int's magnitude. In practice these augmentations are one-shot unlocks (0 or 1), so the two are almost certainly equivalent at runtime — but they are not the same formula, and if the augmentation is ever stackable this would diverge. Not important enough to block CA1, but do not copy ACE's `* 10` verbatim without checking the real PropertyInt's range. - **Version-era gap, not a bug**: ACE adds an `Enlightenment` bonus (`AdvancementClass >= Trained && Enlightenment != 0`) that **does not exist anywhere in the Sept-2013 client's `InqSkill`/`InqSkillBaseLevel`**. Either Enlightenment postdates this build, or it's applied exclusively server-side and baked into a field the client already stores (unconfirmed either way — flagged OPEN). **Do not port an Enlightenment skill bonus into the client-side live formula unless/until acdream's target retail era is confirmed to include it; if ported, it needs its own divergence-register row either way** since our reference build doesn't show it. `references/ACE/Source/ACE.Server/Entity/AttributeFormula.cs` — **real divergence, load-bearing**: ```csharp public static uint GetFormula(Creature creature, DatLoader.Entity.SkillFormula formula, bool current = true) { if (formula.X == 0) return 0; var total = current ? creature.Attributes[attr1].Current : creature.Attributes[attr1].Base; if (attr2 != Undef) total += current ? creature.Attributes[attr2].Current : ...Base; if (divisor != 1) total = (uint)((float)total / divisor).Round(); return total; } ``` This **ignores `_w` (the additive constant) entirely, and ignores `_x`/`_y` as per-attribute weights** — it just sums the raw attribute values unweighted (using `_x` only as an on/off gate) then divides. Retail's `SkillFormula::Calculate` is `floor((_x*a1 + _y*a2 + _w)/_z + 0.5)` — a strict superset. If the real `portal.dat` SkillTable/`Attribute2ndTable` records ever have `_w != 0` or `_x != _y` (a genuinely weighted two-attribute formula, e.g. "mostly Quickness, some Coordination"), ACE's simplified version silently produces a different number than retail. **CA1 must port the verbatim retail formula (`floor((x*a1+y*a2+w)/z + 0.5)`), not ACE's `AttributeFormula.GetFormula` — the verbatim version is safe even in the common case where `x=y=1,w=0` reduces to the same thing, so there's no downside to using the more complete formula.** This is exactly the kind of "verify shared math before substituting" trap the project's own `feedback_wb_migration_formulas.md` lesson warns about — flag as a MUST- VERIFY-AGAINST-LIVE-DAT item before shipping (a live `SkillTable`/ `Attribute2ndTable` dump via `DatCollection` would resolve it in one probe: grep for any record with non-1 `_x`/`_y` or non-zero `_w`). `CreatureVital.cs` (`GetMaxValue`) independently reaches the **same** structural conclusion the decompile trace did for `GearMaxHealth`/ `Enlightenment` placement — ACE's own code comment: *"Enlightenment and GearMaxHealth were an exception, and added in beforehand... this means [they] would get scaled by multipliers... and vitae as well."* This matches §3's finding that the client's PropertyInt-0x17b (`GearMaxHealth`) addition happens **before** the `AttributeCache`/`EnchantAttribute2nd` step — i.e. before whatever multiplier/vitae scaling lives inside `EnchantAttribute2nd`. Strong independent cross-confirmation. --- ## 9. Open items 1. **`ACCWeenieObject::OnStatUpdated` overload resolution** for the raw- scalar Attribute/Attribute2nd/Skill `UpdateStat` instantiations — which of the two concrete bodies (`0x0058c680` bool-id switch, `0x0058df20` int-id switch) actually gets called. Tried: grepped both bodies' switch cases (neither has an Attribute/Skill-id case, so it's a no-op either way); tried Ghidra `function_xrefs` on the symbolic name (no results — binary has no relocation-based xref table entry for the overloaded name). Non-blocking: confirmed harmless for character-advancement regardless of which one resolves. 2. **Exact `Attribute2ndTable`/`SkillTable` DAT coefficients** (which skills have `_attr1==_attr2==0`; whether any real record has `_w!=0` or `_x!=_y`) — code-level mechanism confirmed (§5, §8), specific numbers are DAT data and need a live `DatCollection` probe, not decomp. 3. **Whether ACE's server actually pushes a fresh `PrivateUpdateAttribute2nd` for MaxHealth/MaxStamina/MaxMana when the underlying Endurance/Self changes** (§3's load-bearing assumption about what drives the vitals bar redraw in retail). Traced ACE's `CreatureVital`/`AttributeFormula` C# math but did not trace ACE's *push/dirty* path (`Player_Vitals.cs` / wherever ACE decides to re-send `GameMessagePrivateUpdateAttribute2nd`) — out of scope for this decomp-focused pass; recommend a short follow-up read of `references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs` and `Creature_Skills.cs`'s raise-skill path before implementing CA1's server-sync expectations. 4. **Enlightenment's absence from the 2013 client formula** (§8) — need to confirm which retail era introduced client-visible Enlightenment skill bonuses, if acdream ever targets that era.