From 578189597796663521eaa95c17e1e28a603b5d4f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:46:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(runtime)=20Campaign=20CA=20CA3=20#431:=20l?= =?UTF-8?q?ive=20derived-stat=20recompute=20=E2=80=94=20a=20raise=20is=20v?= =?UTF-8?q?isible=20without=20a=20relog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recompute half of #431, on the CA1 verdict that retail computes derived values LIVE at inquiry (Set* writes raw; InqSkillBaseLevel 0x00592140 -> SkillFormula::Calculate 0x00591960 re-derive per call; InqRunRate 0x00592800 runs every motion tick; UI notifications carry no value and widgets re-pull): - LocalPlayerState gains the SkillTable formula resolver — the same delegate shape (and App-side implementation, RetailSkillFormula over the loaded SkillTable) the PlayerDescription path already uses. An attribute write re-derives every skill snapshot's cached formula contribution; recomputing at the only write that changes the inputs yields values identical to retail's compute-on-read at every read. A freshly TRAINED skill unseen at login derives its contribution live instead of defaulting to zero forever. - The router pushes movement-skill totals down the SAME seam PlayerDescription uses (UpdateMovementSkillBase -> vitae/enchantment recompute -> OnSkillsUpdated -> the App stats applier) after an attribute update, and after a skill update for Run (24) / Jump (22) only. This is what turns a Quickness raise into visible run speed mid-session; the server's own movement-packet echo (HandleRunRateUpdate -> ApplyServerRunRate) remains the correcting authority. - Vitals maxima needed no new plumbing: GetMaxApprox reads attribute currents live and the vitals window binds getter lambdas re-read per frame, so CA2's attribute fan-out completes that path. The character panel already subscribes to AttributeChanged/CharacterChanged. Tests: router behavior test drives the real WorldSession events through the real router and asserts the full chain (state write, live 160/2=80 re-derivation, movement push totals, and that a non-movement skill does NOT push); the subscription-count contract now includes the two new events; Core tests cover the fresh-train resolver derivation. Full hermetic suite 15,335 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- ...26-08-24-character-advancement-campaign.md | 4 +- src/AcDream.Core/Player/LocalPlayerState.cs | 84 +++++++++++++++++- .../Session/LiveSessionEventRouter.cs | 77 +++++++++++++---- .../Player/LocalPlayerStateTests.cs | 21 +++++ .../Session/LiveSessionEventRouterTests.cs | 86 +++++++++++++++++++ 5 files changed, 251 insertions(+), 21 deletions(-) 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));