diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md
index e4a28833..cd0fe8bd 100644
--- a/docs/plans/2026-08-24-character-advancement-campaign.md
+++ b/docs/plans/2026-08-24-character-advancement-campaign.md
@@ -120,7 +120,7 @@ neighborhood).
| 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 | — | |
+| 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 | — | |
| CA5 | — | |
diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs
index d15b6366..361c2425 100644
--- a/src/AcDream.Core/Player/LocalPlayerState.cs
+++ b/src/AcDream.Core/Player/LocalPlayerState.cs
@@ -1,4 +1,6 @@
+using System;
using System.Collections.Generic;
+using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
@@ -462,11 +464,19 @@ public sealed class LocalPlayerState
/// record after an Endurance raise but NOT a Stamina one, with the
/// explicit comment that the client is expected to refresh both; this
/// local fan-out is that expectation (research doc §4.1).
+ /// Skill snapshots cache their attribute-formula contribution
+ /// (), so an attribute write
+ /// re-derives every cached bonus through
+ /// before observers re-pull —
+ /// the compute-on-write equivalent of retail's compute-on-read; the
+ /// values agree at every read because the ONLY input that changes
+ /// between reads is exactly what this method writes.
///
public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return;
_attrs[kind] = new AttributeSnapshot(ranks, start, xp);
+ RecomputeSkillFormulaBonuses();
AttributeChanged?.Invoke(kind);
switch (kind)
{
@@ -537,14 +547,82 @@ public sealed class LocalPlayerState
uint resistance,
double lastUsed)
{
- uint formulaBonus = _skills.TryGetValue(skillId, out var prev)
- ? prev.FormulaBonus
- : 0u;
+ // The wire record carries no attribute contribution (retail computes
+ // it live at inquiry): re-derive through the resolver when present —
+ // this also covers a freshly TRAINED skill unseen at login — else
+ // preserve the login-derived value.
+ uint formulaBonus = SkillFormulaBonusResolver is { } resolver
+ ? resolver(skillId, AttributeCurrentsById())
+ : _skills.TryGetValue(skillId, out var prev)
+ ? prev.FormulaBonus
+ : 0u;
_skills[skillId] = new SkillSnapshot(
skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus);
CharacterChanged?.Invoke();
}
+ ///
+ /// Campaign CA CA3 (#431): the SkillTable attribute-formula resolver —
+ /// the same delegate shape GameEventWiring uses at
+ /// PlayerDescription parse (App supplies
+ /// LiveSkillCreditResolver.Resolve over the loaded SkillTable;
+ /// headless/no-dat hosts leave it null and keep login-cached bonuses).
+ ///
+ public Func /*attrCurrents*/, uint>?
+ SkillFormulaBonusResolver { get; set; }
+
+ ///
+ /// Campaign CA CA3 (#431): re-derive every skill snapshot's cached
+ /// attribute-formula contribution from the current attributes. Retail
+ /// re-derives at every inquiry (InqSkillBaseLevel @ 0x00592140 →
+ /// SkillFormula::Calculate @ 0x00591960); recomputing at the
+ /// only write that changes the inputs yields identical values at every
+ /// read. No-op without a resolver.
+ ///
+ public void RecomputeSkillFormulaBonuses()
+ {
+ if (SkillFormulaBonusResolver is not { } resolver || _skills.Count == 0)
+ return;
+ IReadOnlyDictionary currents = AttributeCurrentsById();
+ foreach (uint skillId in _skills.Keys.ToArray())
+ {
+ SkillSnapshot snap = _skills[skillId];
+ uint bonus = resolver(skillId, currents);
+ if (bonus != snap.FormulaBonus)
+ _skills[skillId] = snap with { FormulaBonus = bonus };
+ }
+ }
+
+ ///
+ /// Current attribute values keyed by wire id (1=Strength..6=Self) — the
+ /// dictionary shape and
+ /// GameEventWiring's PlayerDescription path share.
+ ///
+ public IReadOnlyDictionary AttributeCurrentsById()
+ {
+ var currents = new Dictionary(_attrs.Count);
+ foreach ((AttributeKind kind, AttributeSnapshot snap) in _attrs)
+ currents[(uint)kind + 1u] = snap.Current;
+ return currents;
+ }
+
+ ///
+ /// Campaign CA CA3 (#431): the movement-skill totals
+ /// (formulaBonus + init + ranks, ACE Skill ordinals Run=24 /
+ /// Jump=22) in exactly the shape GameEventWiring computes at
+ /// PlayerDescription parse — so live raises push the SAME numbers down
+ /// the SAME movement seam. −1 = skill unknown (keep the previous value).
+ ///
+ public (int RunSkill, int JumpSkill) MovementSkillTotals()
+ {
+ int run = -1, jump = -1;
+ if (_skills.TryGetValue(24u, out var runSnap))
+ run = (int)(runSnap.FormulaBonus + runSnap.Init + runSnap.Ranks);
+ if (_skills.TryGetValue(22u, out var jumpSnap))
+ jump = (int)(jumpSnap.FormulaBonus + jumpSnap.Init + jumpSnap.Ranks);
+ return (run, jump);
+ }
+
///
/// Optimistically apply a successful local attribute-raise action.
/// The next server snapshot remains authoritative; this keeps UI state current
diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
index a633e69e..d4a15522 100644
--- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
@@ -460,28 +460,48 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
vital => character.Character.LocalPlayer.OnVitalCurrent(
vital.VitalId,
vital.Current));
- // Campaign CA CA2 (#431): the authoritative post-raise records.
- // Until these were routed, attributes and skills were stale
- // from login's PlayerDescription until the next login.
+ // Campaign CA CA2/CA3 (#431): the authoritative post-raise
+ // records. Until these were routed, attributes and skills were
+ // stale from login's PlayerDescription until the next login.
+ // CA3 gives the player state the same SkillTable formula
+ // resolver the PlayerDescription path uses, so an attribute
+ // write re-derives every cached formula contribution (retail
+ // computes live at inquiry — InqSkillBaseLevel @ 0x00592140);
+ // movement then re-applies through the SAME seam as PD
+ // (UpdateMovementSkillBase -> vitae/enchant recompute ->
+ // OnSkillsUpdated -> stats application), which is how a
+ // Quickness raise becomes visible run speed without a relog.
+ character.Character.LocalPlayer.SkillFormulaBonusResolver =
+ character.ResolveSkillFormulaBonus;
Subscribe(
h => session.AttributeUpdated += h,
h => session.AttributeUpdated -= h,
- attr => character.Character.LocalPlayer.OnAttributeUpdate(
- attr.AttributeId,
- attr.Ranks,
- attr.Start,
- attr.Xp));
+ attr =>
+ {
+ character.Character.LocalPlayer.OnAttributeUpdate(
+ attr.AttributeId,
+ attr.Ranks,
+ attr.Start,
+ attr.Xp);
+ PushMovementSkillTotals(character);
+ });
Subscribe(
h => session.SkillUpdated += h,
h => session.SkillUpdated -= h,
- skill => character.Character.LocalPlayer.OnSkillWireUpdate(
- skill.SkillId,
- skill.Ranks,
- skill.AdvancementClass,
- skill.Xp,
- skill.Init,
- skill.Resistance,
- skill.LastUsed));
+ skill =>
+ {
+ character.Character.LocalPlayer.OnSkillWireUpdate(
+ skill.SkillId,
+ skill.Ranks,
+ skill.AdvancementClass,
+ skill.Xp,
+ skill.Init,
+ skill.Resistance,
+ skill.LastUsed);
+ // Run=24 / Jump=22 are the only movement inputs.
+ if (skill.SkillId is 22u or 24u)
+ PushMovementSkillTotals(character);
+ });
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
@@ -556,6 +576,31 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
ConstructionCheckpoint();
}
+ ///
+ /// Campaign CA CA3 (#431): re-apply the movement-skill bases after a
+ /// live attribute/skill update, down the SAME chain the
+ /// PlayerDescription path uses (GameEventWiring's onSkillsUpdated →
+ /// UpdateMovementSkillBase → vitae/enchantment recompute →
+ /// OnSkillsUpdated/OnMovementStatsUpdated → the App stats applier).
+ /// Retail re-inquires run rate every motion tick
+ /// (CACQualities::InqRunRate @ 0x00592800 from
+ /// CMotionInterp); re-applying at the only writes that change
+ /// the inputs is our event-driven equivalent, and the server's own
+ /// re-broadcast echo (HandleRunRateUpdate →
+ /// ApplyServerRunRate) remains the correcting authority.
+ ///
+ private static void PushMovementSkillTotals(
+ LiveCharacterSessionBindings character)
+ {
+ (int runSkill, int jumpSkill) =
+ character.Character.LocalPlayer.MovementSkillTotals();
+ if (runSkill < 0 && jumpSkill < 0)
+ return;
+ character.Character.UpdateMovementSkillBase(runSkill, jumpSkill);
+ character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
+ character.OnMovementStatsUpdated?.Invoke();
+ }
+
///
/// Campaign P Slice P1 (2026-07-30): retail CACQualities::InqLoad
/// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal
diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs
index 468e41ae..fe187bb9 100644
--- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs
+++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs
@@ -764,4 +764,25 @@ public sealed class LocalPlayerStateTests
Assert.Equal(120u, snap.FormulaBonus);
Assert.Equal(131u, snap.BaseLevel); // 120 formula + 0 init + 11 ranks
}
+
+ [Fact]
+ public void FreshlyTrainedSkillDerivesItsFormulaBonusThroughTheResolver()
+ {
+ // CA3: a skill first seen via the wire (a fresh TrainSkill) was
+ // never in PlayerDescription, so its attribute contribution must
+ // come from the live resolver, not default to zero forever.
+ var s = new LocalPlayerState
+ {
+ SkillFormulaBonusResolver = (skillId, attrs) =>
+ skillId == 33u && attrs.TryGetValue(4u, out uint coordination)
+ ? coordination / 4u
+ : 0u,
+ };
+ s.OnAttributeUpdate(atType: 4u /* Coordination */, ranks: 0u, start: 80u, xp: 0u);
+
+ s.OnSkillWireUpdate(skillId: 33u, ranks: 0u, status: 2u, xp: 0u,
+ init: 0u, resistance: 0u, lastUsed: 0d);
+
+ Assert.Equal(20u, s.Skills[33u].FormulaBonus); // 80 / 4
+ }
}
diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs
index c30fd2c0..5b172849 100644
--- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs
@@ -779,6 +779,90 @@ public sealed class LiveSessionEventRouterTests
router.Dispose();
}
+ [Fact]
+ public void AttributeAndSkillUpdates_RouteToPlayerStateAndMovementSeam()
+ {
+ // Campaign CA CA2/CA3 (#431): the authoritative post-raise records
+ // must (a) land in LocalPlayerState, (b) re-derive the cached
+ // formula contributions through the SAME resolver shape the
+ // PlayerDescription path uses, and (c) re-apply movement skills
+ // down the SAME seam (UpdateMovementSkillBase -> OnSkillsUpdated ->
+ // OnMovementStatsUpdated) — that chain is what turns a Quickness
+ // raise into visible run speed without a relog.
+ using var session = NewSession();
+ var character = new RuntimeCharacterState();
+ var skillsPushed = new List<(int Run, int Jump)>();
+ int movementStatsUpdated = 0;
+
+ var router = new LiveSessionEventRouter(
+ session,
+ NoOpEntitySink(),
+ NoOpEnvironmentSink(),
+ NewInventoryBindings(),
+ new LiveCharacterSessionBindings(
+ new CombatState(),
+ character,
+ // Fake SkillTable formula: Run (24) derives from Quickness
+ // (id 3) / 2; everything else contributes nothing.
+ ResolveSkillFormulaBonus: (skillId, attrs) =>
+ skillId == 24u && attrs.TryGetValue(3u, out uint quickness)
+ ? quickness / 2u
+ : 0u,
+ OnSkillsUpdated: (run, jump) => skillsPushed.Add((run, jump)),
+ OnConfirmationRequest: null,
+ OnConfirmationDone: null,
+ ClientTime: () => 0d,
+ OnMovementStatsUpdated: () => movementStatsUpdated++),
+ NewSocialBindings());
+ router.Attach();
+
+ // Login-time skill snapshot: Run trained, formula bonus from the
+ // pre-raise Quickness current of 100 (100/2 = 50).
+ character.LocalPlayer.OnAttributeUpdate(
+ atType: 3u, ranks: 0u, start: 100u, xp: 0u);
+ character.LocalPlayer.OnSkillUpdate(
+ skillId: 24u, ranks: 10u, status: 2u, xp: 0u,
+ init: 5u, resistance: 0u, lastUsed: 0d, formulaBonus: 50u);
+ skillsPushed.Clear();
+ movementStatsUpdated = 0;
+
+ // The server's answer to a Quickness raise: current 100 -> 160.
+ EventDelegate>(
+ session, nameof(session.AttributeUpdated))
+ .Invoke(new PrivateUpdateAttribute.Parsed(
+ Sequence: 1, AttributeId: 3u, Ranks: 60u, Start: 100u, Xp: 500u));
+
+ Assert.Equal(160u,
+ character.LocalPlayer.GetAttribute(
+ LocalPlayerState.AttributeKind.Quickness)?.Current);
+ // Formula contribution re-derived live: 160/2 = 80.
+ Assert.Equal(80u, character.LocalPlayer.Skills[24u].FormulaBonus);
+ // Movement re-applied with the new total: 80 formula + 5 init + 10 ranks.
+ Assert.Equal((95, -1), Assert.Single(skillsPushed));
+ Assert.Equal(1, movementStatsUpdated);
+
+ // The server's answer to a Run skill raise: ranks 10 -> 11.
+ EventDelegate>(
+ session, nameof(session.SkillUpdated))
+ .Invoke(new PrivateUpdateSkill.Parsed(
+ Sequence: 2, SkillId: 24u, Ranks: 11u, AdjustPP: 1,
+ AdvancementClass: 2u, Xp: 1000u, Init: 5u,
+ Resistance: 0u, LastUsed: 0d));
+ Assert.Equal((96, -1), skillsPushed[^1]);
+ Assert.Equal(2, movementStatsUpdated);
+
+ // A non-movement skill update must not push movement.
+ EventDelegate>(
+ session, nameof(session.SkillUpdated))
+ .Invoke(new PrivateUpdateSkill.Parsed(
+ Sequence: 3, SkillId: 6u, Ranks: 1u, AdjustPP: 1,
+ AdvancementClass: 2u, Xp: 0u, Init: 0u,
+ Resistance: 0u, LastUsed: 0d));
+ Assert.Equal(2, movementStatsUpdated);
+
+ router.Dispose();
+ }
+
private static LiveEntitySessionSink NoOpEntitySink() => new(
Spawned: _ => { },
Deleted: _ => { },
@@ -964,6 +1048,8 @@ public sealed class LiveSessionEventRouterTests
nameof(session.TurbineChatReceived),
nameof(session.VitalUpdated),
nameof(session.VitalCurrentUpdated),
+ nameof(session.AttributeUpdated),
+ nameof(session.SkillUpdated),
];
foreach (string eventName in directEvents)
Assert.Equal(multiplier, HandlerCount(session, eventName));