acdream/docs/research/2026-08-24-advancement-wire-and-recompute.md
Erik 1fc64984c9 research: Campaign CA slice CA1 — advancement wire + retail recompute oracle
Two parallel research passes assembled with a hand-verification ledger on
every load-bearing claim.

Wire (ACE + Chorizite + holtburger, field-for-field agreement on all six
inbound layouts): PrivateUpdateAttribute 0x02E3, PrivateUpdateVital 0x02E7
(always Max-family vital ids 1/3/5), PrivateUpdateAttribute2ndLevel 0x02E9
(always current-family ids 2/4/6 — a parser must NOT treat the two as one
id space), PrivateUpdateSkill 0x02DD (ushort ranks + the hardcoded
adjustPP=1 pair, f64 lastUsedTime), PrivateUpdatePropertyInt 0x02CD
(AvailableSkillCredits=24) and Int64 0x02CF (AvailableExperience=2).
Ordered action->response chains for all four raise/train actions,
including the retail quirk that an Endurance raise pushes only a HEALTH
full-vital record and the client is expected to refresh stamina from it
too. Specialize/untrain/reset have NO dedicated opcode — item-Use plus a
confirmation round-trip reusing the same update messages. 0x02DF has no
ACE producer (verified); CA2 skips it.

Recompute (named-retail + live Ghidra): retail computes skills, vitals
maxima and run rate LIVE at inquiry time — Set* are raw-storage writes,
InqSkill re-derives from the attribute formula every call (verified in
the decompile, including the z==0 early-out that IS the attribute-less
Salvaging handling and the +10 augmentation adds), InqRunRate runs every
motion tick, and UI refresh is a value-less observer notification.

Two corrections to our own tree surfaced: PropertyString.cs's comment
claims 0x02DD (it is 0x02D5 — doc-only, nothing dispatches on it), and
SkillSnapshot.FormulaBonus is frozen at PlayerDescription parse — the
stale-cache half of #431 that CA3 replaces with the live computation.
RetailSkillFormula.TryCalculate already ports 0x00591960 exactly, so CA3
reuses it rather than porting anew.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:30:33 +02:00

1256 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<repo>/...` **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::<LittleEndian>``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<false>`
`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<false>` (`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<false>` (`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<false>` 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 0x00xx0x03xx
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::<QualityType,ValueType>::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<Attribute_QualityType, Attribute&>` |
| `UpdateAttributeLevel` | `0x00558ca0` | raw `unsigned long` | `UpdateStat<Attribute_QualityType, ulong>` |
| `UpdateAttribute2nd` | `0x00558cc0` | full `SecondaryAttribute` struct | `UpdateStat<Attribute_2nd_QualityType, SecondaryAttribute&>` |
| `UpdateAttribute2ndLevel` | `0x00558ce0` | raw `unsigned long` | `UpdateStat<Attribute_2nd_QualityType, ulong>` |
| `UpdateSkill` | `0x00558d00` | full `Skill` struct | `UpdateStat<Skill_QualityType, Skill&>` |
| `UpdateSkillLevel` | `0x00558d20` | raw `unsigned long` | `UpdateStat<Skill_QualityType, ulong>` |
| `UpdateSkillAC` | `0x00558d40` | `SKILL_ADVANCEMENT_CLASS` enum | `UpdateStat<Skill_QualityType, SKILL_ADVANCEMENT_CLASS>` |
| `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<ulong,Skill>`),
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** (16, 0x1f0x32), 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(&params, 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<ulong,QualityHandler*,1> 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.