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>
1256 lines
74 KiB
Markdown
1256 lines
74 KiB
Markdown
# 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 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::<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** (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<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.
|