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>
74 KiB
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(), thenReadUInt32()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_TrainSkillreadsSkill = (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(), thenmessage.Payload.ReadInt32()(signed) forcreditsSpent. - holtburger:
TrainSkillActionData { skill_type: u32, credits_spent: i32 }, unpacked withread_i32—actions.rs:97-116, and its own unit test packs credits aswrite_i32::<LittleEndian>—actions.rs:277-299. - Chorizite:
Train_TrainSkillAdvancementClass.Creditsis declareduintand read viareader.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 asuint— 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'sUpdateVital<false>(PrivateUpdateVitalData) matches field-for-field:sequence, vital, ranks, start, xp, current—types.rs:137-184, with a golden-fixture test attypes.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 whatCreature.Vitalsis 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. SoHandleActionRaiseVital's inboundvitalparameter, and the id embedded in the fullPrivateUpdateVital(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()mapsMaxHealth→Vital.Health(2),MaxStamina→Vital.Stamina(4),MaxMana→Vital.Mana(6)—references/ACE/Source/ACE.Server/WorldObjects/Entity/CreatureVital.cs:203-212, andUpdateVital()sendsnew 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)
- Validate: attribute exists,
amount <= AvailableExperience. On failure: no attribute update sent; ifSpendAttributeXpitself fails, sendsGameMessageSystemChat("Your attempt to raise {attr} has failed.",ChatMessageType.Broadcast) and returns. SpendAttributeXp→SpendXP(amount, sendNetworkUpdate=true)→PrivateUpdatePropertyInt64(0x02CF) withAvailableExperience(new value) —references/ACE/Source/ACE.Server/WorldObjects/Player_Xp.cs:352-363. This fires on every successful spend, rank-up or not.PrivateUpdateAttribute(0x02E3) — always sent on success, rank-up or not (Player_Attributes.cs:35).- 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 extraPrivateUpdateVital(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 extraPrivateUpdateVital(0x02E7) full update for Mana (Player_Attributes.cs:60-64). - if
attributeisStrengthorQuicknessand therunrate_add_hooksproperty is on:HandleRunRateUpdate()(affects run-speed derived state, not itself a GameMessage here).
- if max rank reached: a particle-effect broadcast (
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)
- Validate: vital exists in
Vitals(so must be keyed by a Max* id — see §2.9),amount <= AvailableExperience. On failure (amount check): sendsGameMessageSystemChat("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". SpendVitalXp→SpendXP→PrivateUpdatePropertyInt64(0x02CF)AvailableExperience.PrivateUpdateVital(0x02E7) full update — always sent on success (Player_Vitals.cs:46).- 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)
- Validate: creature skill exists and
AdvancementClass >= Trained(untrained skills can't be raised by XP directly),amount <= AvailableExperience. Failure: silent (log.Warnonly, no chat message sent — unlike RaiseAttribute/RaiseVital's failure paths,HandleActionRaiseSkilldoes not sendGameMessageSystemChaton the "trained/specialized skill not found" or "amount > AvailableExperience" branches,:27-35). SpendSkillXp→SpendXP→PrivateUpdatePropertyInt64(0x02CF)AvailableExperience.PrivateUpdateSkill(0x02DD) — always sent on success (Player_Skills.cs:42).- If rank-up: max-rank particle effect + suffix;
GameMessageSound(RaiseTrait) +GameMessageSystemChat("Your base {skill} skill is now {Base}{suffix}!",ChatMessageType.Advancement) (:56-59). Ifskill == Run:HandleRunRateUpdate()(same run-speed hook as RaiseAttribute's Strength/Quickness case, gated by the samerunrate_add_hooksproperty).
4.4 TrainSkill (0x0047) → HandleActionTrainSkill
(references/ACE/Source/ACE.Server/WorldObjects/Player_Skills.cs:114-153)
- Validate:
creditsSpent <= AvailableSkillCredits; skill base exists in the DAT skill table;creditsSpentmust exactly equalskillBase.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-sidelog.Warn, no GameMessage sent at all (function returns before reaching thesuccess/elsebranch that sends anything). - Calls
TrainSkill(skill, creditsSpent)(a different overload,:171-197) which setsAdvancementClass = Trained, resetsRanks/InitLevel/ExperienceSpent, and debitsAvailableSkillCredits -= creditsSpentin-process (no network send inside this inner method — the caller sends the messages). - 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) — noGameMessageSoundhere, unlike Raise*'s rank-up path. - Note:
TrainSkilldoes NOT send aPrivateUpdatePropertyInt64(AvailableExperience) update — training costs skill credits, not XP, soAvailableExperienceis untouched.
- On failure of the inner
TrainSkill(skill, creditsSpent)call (only reachable ifAdvancementClass >= Trainedalready, 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'sActOnUseis reached via the genericUseaction (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 viaConfirmationResponse = 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) + aGameEventWeenieErrorWithString(GameEvent sub-type0x028B, inside the0xF7B0GameEvent envelope — a different envelope from the plainGameMessagefamily used everywhere else in this doc) carryingYouHaveSucceededSpecializing_Skill+ the skill name (:176-185). Layout ofGameEventWeenieErrorWithString:u32 errorType + String16L message—references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventWeenieErrorWithString.cs:7-13. - Lower (specialized→trained via
UnspecializeSkill) or (trained→untrained viaUntrainSkill) success: same twoPrivateUpdateSkill+PrivateUpdatePropertyIntpair, with a differentWeenieErrorWithStringmessage id per exact transition (:196-227).
- Specialize success:
Enlightenment(100th-level Fianhe reset path) directly manipulatesplayer.Vitals[attribute]and sendsGameMessagePrivateUpdateVital—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) sendsPrivateUpdateSkill(0x02DD) +PrivateUpdatePropertyInt(0x02CD, AvailableSkillCredits) +GameMessageSystemChat(plain chat text built in C#, not aWeenieErrorWithStringid) — same three-message shape as TrainSkill's success path, minus theGameMessageSound.
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
TrainSkillcredits field signedness. ACE's handler reads it withmessage.Payload.ReadInt32()(signed) —GameActionTrainSkill.cs:11— and holtburger'sTrainSkillActionData.credits_spentisi32, unpacked withread_i32—actions.rs:99,109. Chorizite'sTrain_TrainSkillAdvancementClass.Creditsis declareduintand read viareader.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 signedintto match the two independent oracles (ACE server source + a working client) over the generated Chorizite stub.- Message class naming for opcode
0x02E7. ACE calls itGameMessagePrivateUpdateVital(full vital record). Chorizite's generator calls the identical-opcode, identical-byte-layout typeQualities_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 existingPrivateUpdateVital.csalready uses. RaiseSkill/TrainSkillskill-id read signedness. ACE reads skill ids forRaiseSkillas(Skill)message.Payload.ReadUInt32()(unsigned) but forTrainSkillconceptually 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'sRaiseSkillActionData.skill_typeisu32(unsigned) —actions.rs:69,:78; itsTrainSkillActionData.skill_typeis alsou32(onlycredits_spentis 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).- 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 adjustPPpair insidePrivateUpdateSkilland the hardcodedadjustPP=1constant (confirmed present in a holtburger golden-fixture test, not just inferred from ACE source).
6. Open questions / not settled from the references
PrivateUpdateSkillLevel(0x02DF) /PublicUpdateSkillLevel(0x02E0) producer not found. The opcode exists in ACE'sGameMessageOpcodeenum and holtburger models a fullUpdateSkillLevelstruct for it (types.rs:230-269, with fixture tests at:353-375), but I did not find any ACE call site constructing aGameMessagePrivateUpdateSkillLevel(in fact, no such class file exists in ACE'sGameMessages/Messages/directory at all — onlyGameMessagePrivateUpdateSkill.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 outsidePlayer_Skills.csandSkillAlterationDevice.cs). Not required for CA1's four actions (none of their response chains use it), but flag before assuming the opcode is dead — a targetedgrep -r PrivateUpdateSkillLevel references/ACEbeyond what this pass covered would settle it.- Exact numeric
Skillenum ordinals were not fully enumerated — I confirmed the enum is ordinal-numbered starting atNone=0with 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 aSkillenum undersrc/AcDream.Core, the CA1 implementer should port the full ordinal list directly fromreferences/ACE/Source/ACE.Entity/Enum/Skill.csverbatim (order matters — it's explicitly documented as never-reorderable). Sound.RaiseTrait's numeric id andChatMessageType.Advancement's numeric id were not looked up (both are straightforward enum lookups inreferences/ACE/Source/ACE.Entity/Enum/if needed for exact-byte test fixtures).- Whether raising a vital's rank ever auto-adjusts
Currentwasn't traced end-to-end. The fullPrivateUpdateVitalmessage always includescreatureVital.Currentas one of its fields (§2.6), butHandleActionRaiseVital/SpendVitalXponly touchRanks/ExperienceSpent— I did not find code in that call chain that tops upCurrentto match a newly raisedMaxValue. 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, givenVitalHeartBeat()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 allocatesAttributeCacheif absent, thenAttributeCache::SetAttribute(id, {init_level, level_from_cp, cp_spent})overwrites those three fields verbatim (0x005cc740).SetSkill/SetSkillLevel→ hash-table upsert intoCACQualities::_skillStatsTable(PackableHashTable<ulong,Skill>), copying_sac, _pp, _init_level, _level_from_pp, _resistance_of_last_check, _last_used_timeverbatim.SetSkillAdvancementClass→ same hash table, writes only_sacviaSkill::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:
m_CurAttribute(e.g. Health=2)m_MaxAttribute(= m_CurAttribute - 1, e.g. MaxHealth=1)- It then reads the
Attribute2ndTablerecord for the max id (Attribute2ndTable::InqAttribute2ndBase) and, if the formula's_attr1/_attr2are non-zero, ALSO registers for those ids — underStatType 9again, even though the contributing attribute (e.g. Endurance) is a baseAttributethat only ever firesCallChangeHandler(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 explicitPrivateUpdateAttribute2nd/...Levelwire 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@0x00527cb0CMotionInterp::get_adjusted_max_speed@0x00527d00CMotionInterp::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):
- Usability gate (
InqSkillBaseLevel):if (int)sac < (int)base._min_level: value = 0. A skill authored with_min_level = TRAINEDreads 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. LumAugSkilledSpec(PropertyInt 0x158/344) doubling, in bothInqSkilland the inlined copy inInqRunRate: 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:
IsUsablegate ≈ retail'ssac < min_level(ACE additionally special- casesmin_level==1meaning "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, plusEnlightenment) is added before the vitae/enchantment multiplier — same tier as retail's pre-EnchantSkilladditions (0x16d, 300/301/302).GetAugBonus_Current(AugmentationJackOfAllTrades*5,LumAugSkilledSpec*2gated onSAC==Specialized) is added after vitae/multiplier — same tier as retail's post-EnchantSkilladditions (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/Magicbonus isaug_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* 10verbatim without checking the real PropertyInt's range. - Version-era gap, not a bug: ACE adds an
Enlightenmentbonus (AdvancementClass >= Trained && Enlightenment != 0) that does not exist anywhere in the Sept-2013 client'sInqSkill/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:
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
ACCWeenieObject::OnStatUpdatedoverload resolution for the raw- scalar Attribute/Attribute2nd/SkillUpdateStatinstantiations — which of the two concrete bodies (0x0058c680bool-id switch,0x0058df20int-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 Ghidrafunction_xrefson 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.- Exact
Attribute2ndTable/SkillTableDAT coefficients (which skills have_attr1==_attr2==0; whether any real record has_w!=0or_x!=_y) — code-level mechanism confirmed (§5, §8), specific numbers are DAT data and need a liveDatCollectionprobe, not decomp. - Whether ACE's server actually pushes a fresh
PrivateUpdateAttribute2ndfor 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'sCreatureVital/AttributeFormulaC# math but did not trace ACE's push/dirty path (Player_Vitals.cs/ wherever ACE decides to re-sendGameMessagePrivateUpdateAttribute2nd) — out of scope for this decomp-focused pass; recommend a short follow-up read ofreferences/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.csandCreature_Skills.cs's raise-skill path before implementing CA1's server-sync expectations. - 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.