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

74 KiB
Raw Blame History

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: RaiseVitalActionDatavital_type: u32, xp_spent: u32references/holtburger/crates/holtburger-protocol/src/messages/player/actions.rs:38-65.
  • Chorizite: Train_TrainAttribute2ndType: VitalId (u32), Experience: uintreferences/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: RaiseAttributeActionDataactions.rs:8-27.
  • Chorizite: Train_TrainAttributeTrain_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: RaiseSkillActionDataskill_type: u32, xp_spent: u32actions.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_i32actions.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: u32references/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: u32references/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, currenttypes.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_PrivateUpdateAttribute2ndLevelSequence(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, currenttypes.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_PrivateUpdateIntSequence(byte), Key(u32, PropertyInt), Value(int32)references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateInt.generated.cs:14-56. Matches.

PropertyInt.AvailableSkillCredits = 24references/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 = 2references/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 | propertyreferences/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=falseCurrentValue 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. SpendAttributeXpSpendXP(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. SpendVitalXpSpendXPPrivateUpdatePropertyInt64 (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. SpendSkillXpSpendXPPrivateUpdatePropertyInt64 (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 messagereferences/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 GameMessagePrivateUpdateVitalreferences/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_i32actions.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, 0x00558f4x0x00558fcx)

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 0x0058c560CACQualities::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 UIElements — 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.csreal divergence, load-bearing:

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.