acdream/docs/plans/2026-08-24-character-advancement-campaign.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

6.9 KiB

Campaign CA — character advancement retail parity (#431 + the raise/train/specialize family)

Status: ACTIVE 2026-08-24. Owner-directed scope; #431 promoted here. Milestone: M4 — Live in the world. Issue anchors: #431 (promoted), #430 tooltips (SEQUENCED AFTER, not in this campaign — needs the #409 client-wide tooltip system).

Owner's report (2026-08-24, verbatim scope)

The raise flow "works from the GUI and everything" — the guess is the refresh side "is not wired correctly." Required behavior:

  1. Raising an attribute must update everything derived from it, in real time: raising Endurance/Self/etc. must move the vitals bar maxima (health/stamina/mana); raising a vital directly (secondary attributes are XP-raisable too) must update the bar the same way.
  2. Skills must update in real time both when their underlying attributes raise and when the skill itself is raised with XP.
  3. Run speed must increase when Quickness is raised — Run is attribute-fed.
  4. Skills with no underlying attribute (e.g. Salvaging) need their own handling — no formula contribution, raise-only progression.
  5. Unknown/untested territory: raising skills with XP, and specializing skills; also the retail respec flow (quests / item turn-ins that drop learned skills so a character can re-spec / re-specialize). None of this has been exercised against ACE.

Current-state survey (2026-08-24, verified in source)

  • Outbound: complete. CharacterActions builds all four actions — RaiseVital 0x0044, RaiseAttribute 0x0045, RaiseSkill 0x0046, TrainSkill 0x0047 — and CharacterSheetProvider wires the panel's buttons to them through the Runtime command seam. This is why the GUI "works": ACE accepts and applies the raises.
  • Inbound: the hole. The ONLY private stat-update messages parsed anywhere are the vitals pair PrivateUpdateVital (0x02E7) / PrivateUpdateVitalCurrent (0x02E9). The attribute and skill update family ACE sends back after a raise is UNHANDLED — no parser, no routing, nothing reaches LocalPlayerState (whose own doc says attributes refresh "only at PlayerDescription / future PrivateUpdateAttribute"). Post-raise, the client's attribute/skill model is stale until the next full PlayerDescription (i.e. next login).
  • Consequences observed by the owner (#431): derived skills don't move when an attribute raises; run speed doesn't change with Quickness. Both follow directly from the missing inbound family — the recompute never triggers because the trigger never arrives.
  • Run-rate seam already exists: PlayerMovementController.ApplyServerRunRate (the #431 filing's own pointer) — the wire echo path updates live run rate; what's missing is driving it (and the formula-side skill totals) from stat updates.
  • Character state owner: RuntimeCharacterState (J4.3) owns the spellbook/local-player graph; new stat state routes through it, not through a parallel store.

Oracle targets (CA1 — DO FIRST, no guessing)

Per the mandatory workflow (grep named-retail → cross-reference ≥2 refs → pseudocode → port → conformance):

  1. The inbound message family. Pin exact opcodes + layouts from ACE (GameMessagePrivateUpdateAttribute, ...Attribute2ndLevel (vitals), ...Skill, ...SkillLevel, ...SkillAC as ACE names them; their sequence-number semantics) cross-checked against Chorizite.ACProtocol and holtburger. Our PrivateUpdateVital.cs already cites ACE's GameMessagePrivateUpdateAttribute2ndLevel naming — extend the same treatment to the whole family. Also pin what ACE sends for TrainSkill/specialize responses and for skill-credit changes.
  2. Retail's recompute chain. In named-retail: how the client applies an attribute update — which cached values recompute (CACQualities::InqSkill / skill formula with attribute divisors from SkillTable, max-vital formulas, run-rate refresh via the movement system). The SkillTable formula fields we already load for chargen (ChargenSkillAdvancement) are the same divisor data — verify the in-world recompute uses identical math.
  3. Attribute-less skills. SkillTable rows with no formula (Salvaging & friends): confirm retail's display/derivation for them (base = trained ranks + augmentation only).
  4. Training / specialization semantics. Credits accounting, what the 0x0047 response looks like, how specialization changes the formula multiplier (specialized = ranks count differently), and what messages carry it.
  5. Respec / untrain. Identify the retail mechanism (quest/item-driven skill refund) and what the CLIENT sees — expectation: server-driven property/skill updates using the SAME inbound family, so no bespoke client flow; verify rather than assume. Confirm ACE's implementation surface for a test path.

Slices

  • CA1 — oracle + research doc (docs/research/2026-08-24-advancement-wire-and-recompute.md): everything above, with decomp addresses and ACE file citations. Output: the pinned message table + retail recompute pseudocode.
  • CA2 — inbound stat-update family. Parsers for the attribute/skill/(vital-level) private updates; routed as ordered deltas into the J4 owners (RuntimeCharacterState / LocalPlayerState) through the existing generation-gated seam. Conformance tests from ACE byte layouts.
  • CA3 — derived recompute + real-time presentation. One recompute path (retail's formula) fed by CA2's deltas driving: character panel skill rows, vitals bar maxima (attribute- and vital-raise both), run-rate into ApplyServerRunRate's seam, attribute-less skills handled per oracle. Binding-seam tests (the #436 lesson: assert the REAL composition binds the refresh, not just that VMs recompute).
  • CA4 — train/specialize/respec verification. Live-vs-ACE for TrainSkill + specialization (panel flow + credits), and the respec path exercised as far as ACE supports; fixes as the oracle demands.
  • CA5 — connected gate script (docs/research/2026-08-24-campaign-ca-test-script.md), user-driven: raise Quickness → run speed visibly increases immediately; raise Endurance/Self → vitals maxima move; direct vital raise; skill raise; train; specialize; respec if ACE path exists. PASS = every change visible without relog.

Sequenced after this campaign: #430 skill/attribute tooltips (needs the #409 client-wide tooltip surface; the CA1 oracle should still note where retail sources its tooltip strings while it is in the neighborhood).

Ledger

Slice Status Evidence
CA1 COMPLETE 2026-08-24 docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly
CA2
CA3
CA4
CA5