Retail sends a raise and WAITS: one request in flight, the raise controls ghost, and displayed state changes only when the authoritative quality-change record lands (gmStatManagementUI @ 0x004F03F0 family, pinned in docs/research/2026-07-10-retail-panel-behavior-pseudocode.md §5, whose own conclusion names ApplyLocalRaise as the thing to remove). The optimistic layer predates the inbound parsers — it existed so the panel showed anything at all — and with CA2 delivering server truth it became strictly harmful: against ACE, a wrong TrainSkill cost fails SILENTLY, so the optimistic promote-and-debit could show a trained skill the server refused with nothing to ever correct it. Deleted: CharacterSheetProvider.ApplyLocalRaise + both spend helpers, and LocalPlayerState's six optimistic mutators (ApplyAttributeRaise, ApplyVitalRaise, ApplySkillRaise, ApplySkillTraining, DebitIntProperty, DebitInt64Property) with their tests. Added: the one-in-flight latch in HandleRaiseRequest, CharacterSheet.AwaitingRaise ghosting all raise controls, and gate release on every authoritative quality signal (attribute/character/player-property events unconditionally; vital events only release-and-refresh while a raise is in flight, so regen ticks stay out of the sheet-rebuild path). Panel unmount resets the gate — retail's awaiting flag lives on the panel instance. AP-73 NARROWS rather than retires: retail's release on a rejection that produces NO quality change is statically unverifiable, and ACE sends chat-only (Raise*) or nothing (RaiseSkill/TrainSkill) on failure; until the CA5 live check, a silently-rejected request leaves the controls ghosted until panel reopen — recorded with its observable symptom. Also verified for CA4: the train button sends the DAT-exact TrainedCost (ACE's silent exact-match rule), and there is correctly NO panel specialize send — retail/ACE specialize only via the SkillAlterationDevice item-use + confirmation round-trip, whose client seams (SendConfirmationResponse 0x0275, the 0x028B WeenieErrorWithString chat routing) already exist. Provider tests now pin the retail contract: send-without-mutation, one-in-flight, release-on-record, release-on- unmount, and the regen-tick rebuild guard. Full hermetic suite 15,327 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7.8 KiB
7.8 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:
- 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.
- Skills must update in real time both when their underlying attributes raise and when the skill itself is raised with XP.
- Run speed must increase when Quickness is raised — Run is attribute-fed.
- Skills with no underlying attribute (e.g. Salvaging) need their own handling — no formula contribution, raise-only progression.
- 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.
CharacterActionsbuilds all four actions —RaiseVital 0x0044,RaiseAttribute 0x0045,RaiseSkill 0x0046,TrainSkill 0x0047— andCharacterSheetProviderwires 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 reachesLocalPlayerState(whose own doc says attributes refresh "only at PlayerDescription / futurePrivateUpdateAttribute"). 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):
- The inbound message family. Pin exact opcodes + layouts from ACE
(
GameMessagePrivateUpdateAttribute,...Attribute2ndLevel(vitals),...Skill,...SkillLevel,...SkillACas ACE names them; their sequence-number semantics) cross-checked against Chorizite.ACProtocol and holtburger. OurPrivateUpdateVital.csalready cites ACE'sGameMessagePrivateUpdateAttribute2ndLevelnaming — extend the same treatment to the whole family. Also pin what ACE sends for TrainSkill/specialize responses and for skill-credit changes. - 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. - Attribute-less skills. SkillTable rows with no formula (Salvaging & friends): confirm retail's display/derivation for them (base = trained ranks + augmentation only).
- 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.
- 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 | COMPLETE 2026-08-24 (65430d4c) |
0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) |
| CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests |
| CA4 | COMPLETE 2026-08-24 | Optimistic ApplyLocalRaise layer DELETED; retail one-in-flight + ghost + server-authoritative flow ported per the pinned §5 pseudocode (AP-73 NARROWED — rejection-release semantics owed to CA5 live); train cost verified DAT-exact; specialize correctly has no panel send (gem + confirmation route, seams already present); provider contract tests rewritten |
| CA5 | — |