diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md index 69b6fd6e..e4a28833 100644 --- a/docs/plans/2026-08-24-character-advancement-campaign.md +++ b/docs/plans/2026-08-24-character-advancement-campaign.md @@ -119,7 +119,7 @@ neighborhood). | Slice | Status | Evidence | |---|---|---| -| CA1 | — | | +| CA1 | COMPLETE 2026-08-24 | docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly | | CA2 | — | | | CA3 | — | | | CA4 | — | | diff --git a/docs/research/2026-08-24-advancement-wire-and-recompute.md b/docs/research/2026-08-24-advancement-wire-and-recompute.md new file mode 100644 index 00000000..e16dcfb8 --- /dev/null +++ b/docs/research/2026-08-24-advancement-wire-and-recompute.md @@ -0,0 +1,1256 @@ +# 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.