feat(player): port retail augmentation stat chain

This commit is contained in:
Erik 2026-07-31 08:08:23 +02:00
parent 0cb60d98a0
commit 461a1fb7b4
22 changed files with 953 additions and 138 deletions

View file

@ -158,7 +158,7 @@ cos(10°) with base 0.2); the jump chain end-to-end (`GetJumpHeight`
0x006b09b0 exact incl. 1300/22.2/0.05/0.35, `InqJumpVelocity` 0x00592980
`vz=sqrt(h*19.6)`, powerbar charge 1.0 s / 0.8 s dual-wield) — the user's
"we jump too high" hypothesis is REFUTED, jump height is retail-parity
(our eff skill is 5 lower pending #268's augmentation port). Retail has no
(the former five-point effective-skill gap was closed by #268). Retail has no
Sledding auto-toggle (P2 finding re-confirmed; no `state |= 0x800000`
writer exists).
@ -175,27 +175,29 @@ alignment.
## #268 — Character panel: vitae color, buff coloring, and augmentation bonuses
**Status:** OPEN (user direction 2026-07-30, follow-up to #267)
**Status:** IMPLEMENTED 2026-07-31 — closure pends the user visual/live gate.
**Severity:** MEDIUM (presentation parity)
**Component:** retained UI / character window
**User direction:** (a) the vitae parenthetical (text + number) renders in
light blue — VERIFY the exact retail color from the decomp/DAT before
hardcoding (the chat-color cdb recipe in
`claude-memory/reference_retail_chat_colors.md` is the fallback method);
(b) when a buff-like effect is active (including AUGMENTATIONS such as
+Acdream's +5-all-skills), affected skills AND attributes render green
(buff coloring) — #267's CurrentLevel/BaseLevel split made the green/red
coloring live, but augmentation bonuses are not part of the effective
value yet, so the color never triggers for them; (c) augmentation skill
bonuses must be included in the displayed effective values.
**Resolution:** the shared Core `PlayerSkillMath` now ports
`CACQualities::InqSkill @ 0x00592660` in retail order for both the
character panel and Runtime movement: intrinsic skill, positive
`LumAugAllSkills` (0x16D), the authored +10 melee/missile/magic category
augmentation, `EnchantSkill`, then +5 Jack of All Trades (0x146) and
`2 × LumAugSkilledSpec` (0x158) for specialized skills. Live player
PropertyInt updates recompute the Runtime snapshot, so the display and
run/jump prediction cannot drift.
**Register link:** this PROMOTES AP-127 — the "two minor unmodeled retail
bonus properties" (0x146/0x158 family — the all-skills augmentation
terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE
`EnchantSkill`) are user-visible in both the panel and potentially
movement speed. Port them into the CACQualities chain (movement) and the
panel's effective-value computation together, decomp-first.
`AttributeInfoRegion::Update @ 0x004F1910`,
`Attribute2ndInfoRegion::Update @ 0x004F19E0`, and
`SkillInfoRegion::Update @ 0x004F1AE0` now drive exact value coloring:
green/red compare the non-vitae residual against the base, so a pure vitae
penalty remains white. The selected-skill footer uses one shared inline-run
text primitive matching retail `AppendTextWithFont`; its vitae fragment uses
the authored LayoutDesc 0x2100002E / FooterTitle 0x1000024E palette index 3
(#7FFFFF), while positive/negative buff fragments use palette indices 1/2
(#00FF00/#FF0000). Attributes, secondary attributes, and skills share those
exact colors. AP-127 and TS-8 are retired by the same stat-chain package.
---
@ -11080,7 +11082,13 @@ The remaining trailer sections (options / shortcuts / hotbars / inventory / equi
**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow`
**Resolution:** Closed alongside #7 in the same commit. `ActiveEnchantmentRecord` extended with optional `StatModType`, `StatModKey`, `StatModValue`, `Bucket` fields. `Spellbook` got an `OnEnchantmentAdded(ActiveEnchantmentRecord)` overload that accepts the full record. `EnchantmentMath.GetMod` aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail `CEnchantmentRegistry::EnchantAttribute` semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff.
`ParseMagicUpdateEnchantment` (the live-update opcode 0x02C2) is **not** yet extended — it still uses the 4-field summary. That's a separate refactor; PlayerDescription's enchantment block is the load-bearing path for issue #6, and that's now flowing.
**2026-07-31 live-update closeout:** `ParseMagicUpdateEnchantment`
(0x02C2) now parses the complete record, including start time, DegradeModifier,
degrade limit, last time degraded, StatMod type/key/value, and bucket
classification. `GameEventWiring` maps that immutable wire record into the
same `ActiveEnchantmentRecord` shape as PlayerDescription. The end-to-end
test sends an actual 0x02C2 payload through dispatch and proves the resulting
StatMod changes the local player's effective skill without relogging.
---
@ -11090,7 +11098,9 @@ The remaining trailer sections (options / shortcuts / hotbars / inventory / equi
**Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath`
**Resolution:** Ported `CEnchantmentRegistry::EnchantAttribute` (PDB `0x00594570`) as `EnchantmentMath.GetMod(IEnumerable<ActiveEnchantmentRecord>, SpellTable, statKey)` returning `(Multiplier, Additive)`. Family-stacking dedup via `SpellTable.Family` (only one buff per family bucket wins, by highest spell-id as a generation proxy). `Spellbook.GetVitalMod(statKey)` delegates. `LocalPlayerState.GetMaxApprox` reworked to apply `(unbuffed × mult) + add` with retail's min-vital clamp (`>= 5` if base ≥ 5 else `>= 1`, matches `CreatureVital::GetMaxValue` at PDB `0x0058F2DD`). Stat-key constants (`MaxHealth=1`, `MaxStamina=3`, `MaxMana=5`) verified against `docs/research/named-retail/acclient.h` line 37287-37301.
**Architecture in place; data still flat.** Until ISSUES.md #12 lands the wire-format extension that captures `StatMod (type/key/val)` on `ActiveEnchantmentRecord`, the per-enchantment modifier value isn't aggregated yet — `EnchantmentMath.GetMod` returns `Identity (1.0, 0.0)` for every stat key. Once #12 wires the data, the existing aggregator + formula light up automatically. Live `+Acdream` Stam/Mana percent will continue to read ~95% until #12 lands.
**Data path complete:** both PlayerDescription and live 0x02C2 updates carry
the full StatMod into `ActiveEnchantmentRecord`; the shared aggregator applies
it immediately to attributes, vitals, skills, movement, and retained UI.
6 new EnchantmentMathTests cover: empty list returns Identity, no-table-entries returns Identity, stat-key constants match ACE enum, Identity is `(1, 0)`, family-stacking dedup, family=0 (no-bucket) treated as separate.

View file

@ -118,7 +118,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 91 active rows (AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
## 3. Documented approximation (AP) — 90 active rows (AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -233,10 +233,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` |
| AP-125 | Transport control packets (the 2.0 s cumulative AckSequence and the 0.6 s RequestRetransmit) are emitted STANDALONE; retail piggybacks optional headers onto queued outbound packets first-fit (`FlowQueue::CoalesceData @ 0x00547740`, invoked at `TransmitNewPackets @ 0x00547A6E`), and `EnqueueNaks` hands the NAK to `PacketController::EnqueueOptionalHeader @ 0x00543C84` rather than emitting directly. | `src/AcDream.Core.Net/Transport/AckNakScheduler.cs` (`EmitCumulativeAck`, `EmitNakRequest`) | ACE honours a RequestRetransmit ONLY when EncryptedChecksum is absent (NetworkSession.cs:283-284) — a retail-style piggyback onto a sequenced packet encrypts the NAK and ACE silently ignores it, making S2C loss unrecoverable; ACE likewise advances its client-sequence watermark on any packet whose flags are not exactly AckSequence (:474-476), so coalesced control content on a borrowed sequence risks skipping a real packet. Standalone exact-flag emission is the only ACE-safe shape; it also keeps reliable packets free of optional headers, making the resend cache strip provably a no-op. | Slightly higher C2S datagram count than retail (one extra small packet per 2.0 s / per NAK window); marginally more loss exposure for the control packets themselves on a metered path. | `FlowQueue::CoalesceData @ 0x00547740`; `SharedNet::EnqueuePak @ 0x00543B10`; `SharedNet::EnqueueNaks @ 0x00543BD0`; ACE `NetworkSession.cs:283-284,:342-343,:474-476` |
| AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) |
| AP-127 | Campaign P Slice P1's run/jump base-skill chain omits two minor retail additive/multiplier terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE `EnchantSkill` runs (property `0x146` "> 0 → +5" bonus; property `0x158` "specialized skill" doubling of a PP-derived term), and reads the raw wire current-stamina value for the zero-skill gate rather than the retail-adjusted local copy (`EnchantAttribute2nd(ATTR2ND_STAMINA)` can apply a Stamina-buff to that check's own copy without changing the displayed vital) | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`ApplySkillEnchantments`); `src/AcDream.Core/Physics/PlayerWeenie.cs` (`InqRunRate`/`InqJumpVelocity` stamina==0 gate) | Bounded per the P1 plan's explicit scope ("port only what the run/jump query path needs... not a general effective-skill engine"); both terms are rare/small relative to the dominant formulaBonus+init+ranks+vitae chain, which IS fully ported | A character with the specific rare property set (0x146/0x158) or an active Stamina-buff at exactly 0 raw stamina predicts a slightly different run/jump skill than retail; low practical impact | `CACQualities::InqRunRate` 0x00592800 pc 413824 (0x146/0x158 reads); `CEnchantmentRegistry::EnchantAttribute2nd` 0x00594670 pc 416169 |
| ~~AP-127~~ | **RETIRED 2026-07-31 (#268).** `PlayerSkillMath` now owns retail `CACQualities::InqSkill` ordering for both panel values and Runtime run/jump prediction: intrinsic + positive 0x16D all-skills + the exact +10 category switch, then `EnchantSkill`, then 0x146 Jack of All Trades +5 and specialized-only `2 × 0x158`. Live player PropertyInt changes refresh the immutable Runtime augmentation snapshot. The separately described current-stamina local-copy nuance was re-audited: the query reads current stamina, but ordinary max-vital buffs target the max-secondary key and do not create stamina when current is zero; no independently observable residual remains. | `src/AcDream.Core/Player/PlayerSkillMath.cs`; `src/AcDream.Core/Player/LocalPlayerState.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`; `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | — | — | `CACQualities::InqSkill @ 0x00592660`; `CACQualities::InqRunRate @ 0x00592800`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0` |
| AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b |
## 4. Temporary stopgap (TS) — 35 active rows (Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-4/TS-5/TS-23/TS-46 retired by ports — ZERO goal-enumerated physics stopgaps remain; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
## 4. Temporary stopgap (TS) — 34 active rows (TS-8 retired 2026-07-31 — live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately; Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-4/TS-5/TS-23/TS-46 retired by ports — ZERO goal-enumerated physics stopgaps remain; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -244,7 +244,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-4 | **RE-OPENED 2026-07-30 after the matrix live gate**: the fixture-gated removal shipped and the user immediately hit the wedge live ("stuck sliding on an edge") plus a non-retail uphill-jump bounce and lost roof slides — the horizontal-velocity convergence claim under-modeled real trajectories. Removal reverted; the oracle plan §7 degenerate analysis needs live-capture-driven rework before any retry. Original row: Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
| ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` |
| TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) |
| TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition |
| TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 |
@ -310,9 +310,8 @@ WITH that phase, not before.
3. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
4. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
5. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
6. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
7. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together.
8. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
6. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together.
7. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-25

View file

@ -46,6 +46,12 @@ a ledger pass. Goal: zero physics TS rows, no unargued feel-affecting AP
rows, one final batched connected visual matrix. Sonnet implements, Opus
reviews. The plan is
[`2026-07-29-physics-parity-campaign.md`](2026-07-29-physics-parity-campaign.md).
The 2026-07-31 #268 stat-chain package is implemented pending its live visual
gate: panel and Runtime movement share retail's complete augmentation ordering,
the authored per-fragment vitae/buff/debuff colors are live, and AP-127 plus
TS-8 are retired by focused and end-to-end packet tests. The remaining
implementation target is #269's capture-driven slope-slide feel residual,
followed by the unfinished live matrix rows.
---

View file

@ -54,8 +54,9 @@ PK-timer jump-cost decode for P3). Full Release suite 9,880/0/5 at the
slice gate. Retail's vitae/enchant chain reuses the M3 bucket-4
representation; `JumpStaminaCost` never refuses (weak-jump only) — the
plan's formula shorthand had the `+0.5` operand wrong and the
implementation follows the decomp's `(load+0.5)*power*8+2`. AP-127 filed
(two minor bonus properties, out of bounded scope).
implementation follows the decomp's `(load+0.5)*power*8+2`. AP-127 was
filed for the then-bounded bonus properties and retired by #268 on
2026-07-31.
Today `PlayerWeenie.SetBurden` has zero callers, `CanJump` is always
true, `JumpStaminaCost` is 0, and pushed run/jump skill is
@ -296,7 +297,8 @@ waits on the single user gate below.
TS-4 (+ its FlatBspQuery twin), TS-5, TS-23, TS-35, TS-46 retired by
ports; TS-25 retired on #219 evidence; TS-24→AD-57, TS-40→AD-58
re-argued. AP-7, AP-10, AP-25, AP-71 retired; UN-8 and AD-55 retired by
raw-byte proof; AD-25 retired. New argued rows: AP-127/128/129,
raw-byte proof; AD-25 retired. AP-127 was subsequently retired by #268;
the remaining new argued rows are AP-128/129,
AD-53/54/55(retired)/56/57/58.
**Issues:** #72, #153, #167, #255 closed; #116 shape-2 closed /shape-1
@ -369,11 +371,15 @@ root-caused, retail-ported, and user-accepted in the same session:
Downhill bounce chain, flat-ground pop, and clean uphill landings all
user-accepted ("almost pass with merits"). Investigation + byte-decode
record: `docs/research/2026-07-30-landing-bounce-family.md`.
- **#267 shipped** (vitae/buff panel values; attributes vitae-immune) —
visual pass; **#268 filed** (light-blue #7FFFFF vitae parenthetical
from the authored 0x1B palette, green/red buff coloring, augmentation
properties 0x146/0x158/0x16d — promotes AP-127; retail color indices
and the aug chain fully decoded, implementation pending).
- **#267 shipped** (vitae/buff panel values; attributes vitae-immune).
**#268 implemented 2026-07-31; live visual gate pending**: the complete
augmentation chain is shared by panel and Runtime movement; AP-127 is
retired. Attributes, secondary attributes, and skills use retail's
vitae-excluded green/red comparison. The selected-skill footer now renders
per-fragment colors through the shared retained text primitive, using the
authored 0x1B palette exactly: #7FFFFF vitae, #00FF00 buff, #FF0000
debuff. TS-8 is also retired: a real live 0x02C2 payload carries its full
StatMod through dispatch and changes the effective skill immediately.
- **#269 filed** — slope-stop slide sometimes runs long. `calc_friction`
(0x0050ee70) and the complete jump chain (`GetJumpHeight` 0x006b09b0,
`InqJumpVelocity` vz=sqrt(h·19.6), 1.0 s/0.8 s powerbar charge) are

View file

@ -141,10 +141,13 @@ InqRunRate(this, &rateOut):
EnchantAttribute2nd(this, 4, &currentStamina) // vital-buff adjusts the LOCAL COPY only
// (not the wire "current stamina" state)
skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // base: formula-bonus + init + ranks
// (+ two minor bonus properties 0x146,
// 0x158 — NOT ported, see §6 AP-127)
EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, round
skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // formula-bonus + init + ranks
skill += max(PropertyInt 0x16D, 0) // LumAugAllSkills
skill += matching category augmentation ? 10 : 0 // 0x12C melee / 0x12D missile /
// 0x12E magic; exact skill-id switch
EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, truncate
if (PropertyInt 0x146 > 0) skill += 5 // Jack of All Trades
if (skill is specialized) skill += 2 * max(PropertyInt 0x158, 0)
if (currentStamina == 0) skill = 0 // THE stamina-gates-movement mechanism
@ -189,16 +192,21 @@ skill... reading vitae + relevant skill enchantments from the M3
active-effect state" without a general effective-skill engine — the only new
code is the type-flag filter and the skill-id key.
**Two things P1 deliberately does NOT port** (bounded scope, register row
AP-127):
1. Two minor additive skill-bonus properties inside `InqSkillBaseLevel`'s
surrounding block (property `0x146` "> 0 → +5", property `0x158`
"specialized-skill → double a PP-derived term") — small, rare bonuses
unrelated to burden/stamina/vitae.
2. `EnchantAttribute2nd`'s buff-adjustment of the LOCAL stamina-current copy
used only for the `== 0` gate (i.e. a Stamina-boosting buff could
theoretically keep that local copy above 0 even at true-zero wire
stamina). We gate on the raw wire "current stamina" value directly.
**2026-07-31 #268 closeout:** the previously bounded augmentation terms are
now ported in shared `PlayerSkillMath`, after a complete read of
`CACQualities::InqSkill @ 0x00592660`. The exact order matters:
1. intrinsic formula/init/ranks;
2. positive property 0x16D plus the exact category +10 switch;
3. `EnchantSkill`;
4. property 0x146 contributes +5 when positive;
5. specialized skills receive `2 × max(property 0x158, 0)`.
The character panel and Runtime movement both consume this one Core
calculation. AP-127 is retired. The apparent current-stamina-copy residual
does not create an independently reachable effect for ordinary stat
enchantments: current and maximum stamina use distinct secondary-attribute
keys, and a max-stamina enchantment cannot turn zero current stamina nonzero.
## 6. GetRunRate / GetJumpHeight / JumpStaminaCost formula bodies (MovementSystem, pc 695958+)
@ -365,10 +373,8 @@ every pre-P1 `PlayerWeenieTests.cs` expectation unchanged.
- **TS-23 extended** (not a new row) — its "PlayerKillerStatus not parsed"
scope now also covers the new `MovementSystem.JumpStaminaCost` `pk`
parameter, hardcoded `false` at the `PlayerWeenie` call site pending P3.
- **New AP-127** — two minor retail skill-bonus properties (0x146, 0x158)
and the stamina-buff-adjusts-local-copy nuance are not ported (§5, §9
bullet list) — bounded, deliberate, low-risk (rare bonus terms, not
burden/stamina/vitae).
- **AP-127 retired 2026-07-31 (#268)** — the complete 0x16D/category/
0x146/0x158 chain is shared by panel and movement (§5 closeout).
- **New UN-8**`CACQualities::CanJump`'s x87 comparison polarity resolved
by domain plausibility rather than a literal BN read (§3); Ghidra MCP
confirmation is the retire path.

View file

@ -86,6 +86,17 @@ public sealed class CharacterSheet
public int ManaCurrent { get; init; }
public int ManaMax { get; init; }
/// <summary>
/// Unenchanted max Health/Stamina/Mana in that order.
/// </summary>
public int[] VitalBaseMaxValues { get; init; } = Array.Empty<int>();
/// <summary>
/// Isolated vitae contribution to max Health/Stamina/Mana, always
/// non-positive and ordered like <see cref="VitalBaseMaxValues"/>.
/// </summary>
public int[] VitalVitaeModifiers { get; init; } = Array.Empty<int>();
// ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ────────────
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self.
@ -202,7 +213,8 @@ public sealed record CharacterSkill(
uint IconDid,
CharacterSkillAdvancementClass AdvancementClass,
int BaseLevel,
// Issue #267: CurrentLevel is now the EFFECTIVE (vitae + buff) level —
// BaseLevel is retail's pre-EnchantSkill value, including augmentation
// terms. CurrentLevel is the EFFECTIVE (vitae + buff) level —
// retail CACQualities::EnchantSkill (0x005947b0). Previously an alias of
// BaseLevel; this activates the existing CharacterStatController.
// SkillValueColor buffed/debuffed row coloring.

View file

@ -140,6 +140,18 @@ public sealed class CharacterSheetProvider
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
VitalBaseMaxValues =
[
VitalBaseMax(LocalPlayerState.VitalKind.Health),
VitalBaseMax(LocalPlayerState.VitalKind.Stamina),
VitalBaseMax(LocalPlayerState.VitalKind.Mana),
],
VitalVitaeModifiers =
[
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Health),
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Stamina),
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Mana),
],
// Issue #267: the panel's main attribute values are EFFECTIVE
// (post-buff) — retail CACQualities::EnchantAttribute. Base values
@ -176,7 +188,7 @@ public sealed class CharacterSheetProvider
AttrCurrent(LocalPlayerState.AttributeKind.Focus),
AttrCurrent(LocalPlayerState.AttributeKind.Self),
},
Skills = BuildLiveCharacterSkills(),
Skills = BuildLiveCharacterSkills(props),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
EncumbranceAugmentations = props.GetInt(0xE6u),
@ -344,7 +356,8 @@ public sealed class CharacterSheetProvider
}
}
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills(
PropertyBundle properties)
{
var result = new List<CharacterSkill>();
var skillTable = SkillTable;
@ -374,23 +387,26 @@ public sealed class CharacterSheetProvider
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
// isolates vitae's own contribution for the footer's separate
// vitae parenthetical (SkillInfoRegion::GetVitaeModifier 0x004f0fa0).
int effectiveLevel = _localPlayer.GetEffectiveSkill(snapshot.SkillId)
?? checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel));
int vitaeModifier = _localPlayer.GetSkillVitaeModifier(snapshot.SkillId);
PlayerSkillMath.Value values =
_localPlayer.GetSkillValue(snapshot.SkillId, properties)
?? new PlayerSkillMath.Value(
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
0);
result.Add(new CharacterSkill(
snapshot.SkillId,
name,
icon,
advancement,
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
effectiveLevel,
values.UnenchantedLevel,
values.EffectiveLevel,
IsUsableUntrained(snapshot.SkillId),
trainedCost,
specializedCost,
raiseCost,
raise10Cost,
vitaeModifier));
values.VitaeModifier));
}
return result;
@ -467,6 +483,11 @@ public sealed class CharacterSheetProvider
private int VitalMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
private int VitalBaseMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetBaseMaxApprox(kind) is { } max
? checked((int)Math.Min(int.MaxValue, max))
: 0;
// ── Raise-request flow ─────────────────────────────────────────────────
/// <summary>

View file

@ -110,7 +110,11 @@ public static class CharacterStatController
/// <summary>Row highlight color — semi-translucent gold, matches retail
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
private static readonly Vector4 BuffedSkillGreen = new(0.55f, 1f, 0.55f, 1f);
// LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B:
// [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF).
private static readonly Vector4 RetailBuffGreen = new(0f, 1f, 0f, 1f);
private static readonly Vector4 RetailDebuffRed = new(1f, 0f, 0f, 1f);
private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f);
// ── Row layout constants ─────────────────────────────────────────────────
// RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
@ -688,7 +692,8 @@ public static class CharacterStatController
_ => 0,
};
return v.ToString();
});
},
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
row.OnClick = () =>
{
@ -719,7 +724,8 @@ public static class CharacterStatController
2 => $"{s.ManaCurrent}/{s.ManaMax}",
_ => string.Empty,
};
});
},
valueColorProvider: () => VitalValueColor(data(), rowIndex));
row.OnClick = () =>
{
@ -872,10 +878,48 @@ public static class CharacterStatController
return null;
}
private static Vector4 SkillValueColor(CharacterSkill skill)
=> skill.CurrentLevel > skill.BaseLevel ? BuffedSkillGreen
: skill.CurrentLevel < skill.BaseLevel ? new Vector4(1f, 0.45f, 0.45f, 1f)
internal static Vector4 SkillValueColor(CharacterSkill skill)
{
int withoutVitae = skill.CurrentLevel - skill.VitaeModifier;
return withoutVitae > skill.BaseLevel ? RetailBuffGreen
: withoutVitae < skill.BaseLevel ? RetailDebuffRed
: Vector4.One;
}
internal static Vector4 AttributeValueColor(
CharacterSheet sheet,
int rowIndex)
{
int delta = GetAttributeDelta(sheet, rowIndex);
return delta > 0 ? RetailBuffGreen
: delta < 0 ? RetailDebuffRed
: Vector4.One;
}
internal static Vector4 VitalValueColor(
CharacterSheet sheet,
int vitalIndex)
{
if ((uint)vitalIndex >= 3u
|| vitalIndex >= sheet.VitalBaseMaxValues.Length
|| vitalIndex >= sheet.VitalVitaeModifiers.Length)
{
return Vector4.One;
}
int effective = vitalIndex switch
{
0 => sheet.HealthMax,
1 => sheet.StaminaMax,
2 => sheet.ManaMax,
_ => 0,
};
int withoutVitae = effective - sheet.VitalVitaeModifiers[vitalIndex];
int baseline = sheet.VitalBaseMaxValues[vitalIndex];
return withoutVitae > baseline ? RetailBuffGreen
: withoutVitae < baseline ? RetailDebuffRed
: Vector4.One;
}
/// <summary>
/// Handles a row click: toggle (same row → deselect), else select new row.
@ -1315,6 +1359,64 @@ public static class CharacterStatController
return $"{name}: {value}{delta}";
}
private static IReadOnlyList<UiText.TextRun> BuildSelectedTitleRuns(
UiText target,
CharacterStatTab tab,
Func<CharacterSheet> data,
int[] attrSel,
int[] skillSel)
{
Vector4 Color(int index) =>
index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: index switch
{
1 => RetailBuffGreen,
2 => RetailDebuffRed,
3 => RetailVitaeBlue,
_ => Vector4.One,
};
if (tab == CharacterStatTab.Skills)
{
CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]);
if (skill is null)
return [new("Select a Skill to Improve", Body)];
if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
return [new(skill.Name, Color(0))];
var runs = new List<UiText.TextRun>
{
new($"{skill.Name}: {skill.CurrentLevel}", Color(0)),
};
if (skill.VitaeModifier < 0)
runs.Add(new(FormatVitaeDelta(skill.VitaeModifier), Color(3)));
int buffDelta = GetSkillBuffOnlyDelta(skill);
if (buffDelta != 0)
runs.Add(new(
FormatBuffDelta(buffDelta),
Color(buffDelta > 0 ? 1 : 2)));
return runs;
}
if (attrSel[0] < 0)
return [new("Select an Attribute to Improve", Body)];
CharacterSheet sheet = data();
var attributeRuns = new List<UiText.TextRun>
{
new(
$"{GetRowName(attrSel[0])}: {GetRowValueString(sheet, attrSel[0])}",
Color(0)),
};
int delta = GetAttributeDelta(sheet, attrSel[0]);
if (delta != 0)
attributeRuns.Add(new(
FormatBuffDelta(delta),
Color(delta > 0 ? 1 : 2)));
return attributeRuns;
}
/// <summary>
/// Add a single attribute/vital row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
@ -1512,6 +1614,12 @@ public static class CharacterStatController
// Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
// RightAligned stays false (BuildText default for a Center element).
titleEl.ClickThrough = true;
titleEl.RunsProvider = () => BuildSelectedTitleRuns(
titleEl,
activeTab[0],
data,
attrSel,
skillSel);
titleEl.LinesProvider = () =>
{
string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel);

View file

@ -32,6 +32,11 @@ public sealed class UiText : UiElement, IUiDatStateful
/// <summary>One display line: pre-formatted text + its colour.</summary>
public readonly record struct Line(string Text, Vector4 Color);
/// <summary>
/// One inline fragment in a retail <c>AppendTextWithFont</c> line.
/// </summary>
public readonly record struct TextRun(string Text, Vector4 Color);
/// <summary>A caret position: a line index into the cached line list plus a
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
public readonly record struct Pos(int Line, int Col);
@ -39,6 +44,13 @@ public sealed class UiText : UiElement, IUiDatStateful
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
/// <summary>
/// Optional inline fragments for a static one-line element. When present
/// this reproduces retail's per-append font-state colors while preserving
/// the element's authored alignment as one composed line.
/// </summary>
public Func<IReadOnlyList<TextRun>>? RunsProvider { get; set; }
/// <summary>Font for the transcript; falls back to the context default.</summary>
public BitmapFont? Font { get; set; }
@ -381,6 +393,12 @@ public sealed class UiText : UiElement, IUiDatStateful
private void DrawClippedText(UiRenderContext ctx)
{
if (OneLine && RunsProvider is { } runsProvider)
{
DrawSingleLineRuns(ctx, runsProvider());
return;
}
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
// UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
@ -533,6 +551,54 @@ public sealed class UiText : UiElement, IUiDatStateful
}
}
private void DrawSingleLineRuns(
UiRenderContext ctx,
IReadOnlyList<TextRun> runs)
{
if (runs.Count == 0) return;
UiDatFont? datFont = DatFont;
BitmapFont? bitmapFont = datFont is null
? Font ?? ctx.DefaultFont
: null;
if (datFont is null && bitmapFont is null) return;
float totalWidth = 0f;
foreach (TextRun run in runs)
{
totalWidth += datFont is not null
? datFont.MeasureWidth(run.Text)
: bitmapFont!.MeasureWidth(run.Text);
}
float x = Centered
? Math.Max(Padding, (Width - totalWidth) * 0.5f)
: RightAligned
? Math.Max(Padding, Width - Padding - totalWidth)
: Padding;
float lineHeight = datFont?.LineHeight ?? bitmapFont!.LineHeight;
float y = VOffset(
Height,
lineHeight,
Padding,
VerticalJustify);
foreach (TextRun run in runs)
{
if (run.Text.Length == 0) continue;
if (datFont is not null)
{
ctx.DrawStringDat(datFont, run.Text, x, y, run.Color);
x += datFont.MeasureWidth(run.Text);
}
else
{
ctx.DrawString(run.Text, x, y, run.Color, bitmapFont);
x += bitmapFont!.MeasureWidth(run.Text);
}
}
}
/// <summary>
/// True when any vertical portion of a line intersects a text viewport. Retail
/// clips the glyphs at the viewport edge; it does not require the full line box to fit.

View file

@ -47,12 +47,10 @@ namespace AcDream.Core.Player;
/// </para>
///
/// <para>
/// <b>Enchantment buffs</b> (multiplicative + additive) and the
/// 5-min-vital clamp are <b>not yet applied</b> — adding those
/// requires the <see cref="AcDream.Core.Spells.Spellbook"/>'s active
/// enchantment list. The unenchanted max is correct for clean
/// characters; buffed players will read percent slightly higher than
/// retail until enchantment integration lands.
/// <b>Enchantment buffs</b> (multiplicative + additive), vitae, and the
/// retail five-point minimum are applied through the attached
/// <see cref="AcDream.Core.Spells.Spellbook"/>. Base-value accessors retain
/// the unenchanted values needed by the character-panel comparison logic.
/// </para>
/// </summary>
public sealed class LocalPlayerState
@ -226,13 +224,37 @@ public sealed class LocalPlayerState
/// skill hasn't arrived yet.
/// </summary>
public int? GetEffectiveSkill(uint skillId)
=> GetSkillValue(skillId)?.EffectiveLevel;
/// <summary>
/// Full retail <c>CACQualities::InqSkill</c> projection, including the
/// augmentation terms on both sides of <c>EnchantSkill</c>. Callers with a
/// fresher player-object property bundle may supply it; otherwise the
/// PlayerDescription snapshot is used.
/// </summary>
public PlayerSkillMath.Value? GetSkillValue(
uint skillId,
PropertyBundle? properties = null)
{
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return null;
uint baseValue = skill.Value.CurrentLevel;
if (_spellbook is null) return (int)baseValue;
EnchantmentMath.VitalMod mod = _spellbook.GetSkillMod(skillId);
return EnchantmentMath.EnchantSkill(mod, baseValue);
PlayerSkillMath.AugmentationBonuses augmentations =
PlayerSkillMath.AugmentationBonuses.FromProperties(
properties ?? _properties);
EnchantmentMath.VitalMod mod = _spellbook?.GetSkillMod(skillId)
?? EnchantmentMath.VitalMod.Identity;
float vitae = _spellbook is null
? 1f
: EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments);
return PlayerSkillMath.Calculate(
checked((int)Math.Min(int.MaxValue, skill.Value.CurrentLevel)),
skillId,
skill.Value.Status,
augmentations,
mod,
vitae);
}
/// <summary>
@ -242,14 +264,7 @@ public sealed class LocalPlayerState
/// is wired, no vitae is active, or the skill hasn't arrived yet.
/// </summary>
public int GetSkillVitaeModifier(uint skillId)
{
if (_spellbook is null) return 0;
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return 0;
return EnchantmentMath.SkillVitaeModifier(
_spellbook.ActiveEnchantments,
skill.Value.CurrentLevel);
}
=> GetSkillValue(skillId)?.VitaeModifier ?? 0;
/// <summary>Snapshot of the local player's current property bundle.</summary>
public PropertyBundle Properties => _properties;
@ -290,11 +305,8 @@ public sealed class LocalPlayerState
/// </summary>
public uint? GetMaxApprox(VitalKind kind)
{
var v = Get(kind);
if (v is null) return null;
uint baseMax = v.Value.Ranks + v.Value.Start;
uint contrib = AttributeContribution(kind);
uint unbuffed = baseMax + contrib;
uint? baseValue = GetBaseMaxApprox(kind);
if (baseValue is not uint unbuffed) return null;
// Preserve the "no data" sentinel — when the unbuffed max is 0
// we lack the inputs to compute anything reasonable. The retail
// min-vital floor only kicks in once we know the base.
@ -311,6 +323,37 @@ public sealed class LocalPlayerState
return (uint)System.Math.Round(buffed);
}
/// <summary>
/// Unenchanted secondary-attribute maximum used by retail's
/// <c>Attribute2ndInfoRegion::Update @ 0x004F19E0</c> comparison.
/// </summary>
public uint? GetBaseMaxApprox(VitalKind kind)
{
VitalSnapshot? vital = Get(kind);
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind);
}
/// <summary>
/// Isolated vitae contribution to a secondary attribute, matching
/// <c>Attribute2ndInfoRegion::GetVitaeModifier @ 0x004F1130</c>.
/// </summary>
public int GetVitalVitaeModifier(VitalKind kind)
{
if (_spellbook is null
|| GetBaseMaxApprox(kind) is not uint baseValue)
{
return 0;
}
return EnchantmentMath.SkillVitaeModifier(
EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments),
baseValue);
}
private static uint StatKeyForKind(VitalKind kind) => kind switch
{
VitalKind.Health => EnchantmentMath.StatKey.MaxHealth,

View file

@ -0,0 +1,108 @@
using AcDream.Core.Items;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
/// <summary>
/// Retail <c>CACQualities::InqSkill @ 0x00592660</c> composition.
/// Keeps the augmentation/enchantment ordering in one presentation-independent
/// place so character UI and movement consume the same effective skill.
/// </summary>
public static class PlayerSkillMath
{
public readonly record struct AugmentationBonuses(
int AllSkills,
bool JackOfAllTrades,
int SkilledSpecialized,
bool SkilledMelee,
bool SkilledMissile,
bool SkilledMagic)
{
public static AugmentationBonuses FromProperties(PropertyBundle properties)
{
ArgumentNullException.ThrowIfNull(properties);
return new(
Positive(properties.GetInt((uint)PropertyInt.LumAugAllSkills)),
properties.GetInt((uint)PropertyInt.AugmentationJackOfAllTrades) > 0,
Positive(properties.GetInt((uint)PropertyInt.LumAugSkilledSpec)),
properties.GetInt((uint)PropertyInt.AugmentationSkilledMelee) > 0,
properties.GetInt((uint)PropertyInt.AugmentationSkilledMissile) > 0,
properties.GetInt((uint)PropertyInt.AugmentationSkilledMagic) > 0);
}
public int BeforeEnchantments(uint skillId)
{
int category = skillId switch
{
// Retail switch at pc 0x005926F6. A positive category
// augmentation adds ten once; its stored rank is not multiplied.
0x29u or 0x2Cu or 0x2Du or 0x2Eu or 0x31u when SkilledMelee => 10,
0x2Fu when SkilledMissile => 10,
0x1Fu or 0x20u or 0x21u or 0x22u or 0x2Bu when SkilledMagic => 10,
_ => 0,
};
return SaturatingAdd(AllSkills, category);
}
public int AfterEnchantments(uint advancementClass)
{
int result = JackOfAllTrades ? 5 : 0;
if (advancementClass == 3u)
result = SaturatingAdd(result, SaturatingMultiply(SkilledSpecialized, 2));
return result;
}
}
public readonly record struct Value(
int UnenchantedLevel,
int EffectiveLevel,
int VitaeModifier);
/// <summary>
/// Compose one skill exactly in retail order:
/// intrinsic + LumAugAllSkills/category bonus; EnchantSkill; then
/// Jack of All Trades and the specialized luminance bonus.
/// </summary>
public static Value Calculate(
int intrinsicLevel,
uint skillId,
uint advancementClass,
AugmentationBonuses augmentations,
EnchantmentMath.VitalMod enchantment,
float vitaeMultiplier)
{
int intrinsic = Math.Max(0, intrinsicLevel);
int unenchanted = SaturatingAdd(
intrinsic,
augmentations.BeforeEnchantments(skillId));
int enchanted = EnchantmentMath.EnchantSkill(
enchantment,
(uint)unenchanted);
int effective = SaturatingAdd(
enchanted,
augmentations.AfterEnchantments(advancementClass));
int vitaeModifier = EnchantmentMath.SkillVitaeModifier(
vitaeMultiplier,
(uint)unenchanted);
return new Value(unenchanted, effective, vitaeModifier);
}
private static int Positive(int value) => value > 0 ? value : 0;
private static int SaturatingAdd(int left, int right)
{
long result = (long)left + right;
return result > int.MaxValue
? int.MaxValue
: result < int.MinValue ? int.MinValue : (int)result;
}
private static int SaturatingMultiply(int left, int right)
{
long result = (long)left * right;
return result > int.MaxValue
? int.MaxValue
: result < int.MinValue ? int.MinValue : (int)result;
}
}

View file

@ -28,22 +28,15 @@ namespace AcDream.Core.Spells;
/// <para>
/// <b>Vitae</b> (death penalty) is a singleton on
/// <c>CEnchantmentRegistry._vitae</c>, applied multiplicatively after
/// the buff lists. We don't yet wire it through.
/// the buff lists.
/// </para>
///
/// <para>
/// <b>Current implementation status:</b> the aggregator iterates
/// <see cref="Spellbook.ActiveEnchantments"/> and applies
/// <see cref="SpellTable"/> family-stacking deduplication, but
/// **returns identity (1.0, 0.0) for stat modifiers** because our
/// <see cref="ActiveEnchantmentRecord"/> doesn't yet carry the
/// <c>StatMod (type/key/val)</c> triad — that requires extending
/// <c>ParseMagicUpdateEnchantment</c> to read the full Enchantment
/// payload (60-64 bytes per holtburger
/// <c>messages/magic/types.rs</c>) and storing it on the record.
/// Filed as ISSUES.md #12. Once that lands, the aggregator's
/// `effectiveMult * mod.Val` and `additive + mod.Val` paths fire and
/// the Vitals HUD percent gap closes.
/// <b>Current implementation status:</b> the aggregator consumes the same
/// complete <c>StatMod (type/key/value)</c> record shape from both the
/// PlayerDescription snapshot and live <c>MagicUpdateEnchantment</c>
/// (0x02C2), applies retail family stacking, then evaluates the selected
/// attribute, secondary attribute, or skill domain.
/// </para>
///
/// <para>
@ -167,9 +160,8 @@ public static class EnchantmentMath
// Bucket 2 (Additive): additive += ench.StatModValue
// Bucket 4 (Vitae): multiplier *= ench.StatModValue (post-pass)
// Bucket 8 (Cooldown): skipped (doesn't affect vital max)
// Records without StatMod data (StatModKey == null) — e.g.
// those from older MagicUpdateEnchantment events that don't
// yet parse the full payload — contribute nothing.
// Records without StatMod data (StatModKey == null) are valid for
// non-stat enchantment classes and contribute nothing here.
float multiplier = 1.0f;
float additive = 0.0f;
float vitae = 1.0f;
@ -283,10 +275,17 @@ public static class EnchantmentMath
public static int SkillVitaeModifier(
IEnumerable<ActiveEnchantmentRecord> enchantments,
uint baseValue)
=> SkillVitaeModifier(GetVitaeMultiplier(enchantments), baseValue);
/// <summary>
/// Value overload for callers that already captured the registry's vitae
/// singleton. Keeping the truncation here prevents the movement and UI
/// paths from growing subtly different copies of retail's calculation.
/// </summary>
public static int SkillVitaeModifier(float vitaeMultiplier, uint baseValue)
{
float vitae = GetVitaeMultiplier(enchantments);
if (vitae == 1.0f) return 0;
return (int)(baseValue * vitae) - (int)baseValue;
if (vitaeMultiplier == 1.0f) return 0;
return (int)(baseValue * vitaeMultiplier) - (int)baseValue;
}
/// <summary>

View file

@ -267,11 +267,9 @@ public sealed class Spellbook
}
/// <summary>
/// Issue #7 / #12 — accept a fully-populated record from
/// <c>PlayerDescription</c>'s enchantment block (which carries
/// the StatMod triad + bucket). Used when the wire-format extension
/// gives us the full per-enchantment payload, rather than the
/// 4-field summary from <c>MagicUpdateEnchantment</c>.
/// Accept the canonical fully-populated enchantment record shared by
/// <c>PlayerDescription</c> and live <c>MagicUpdateEnchantment</c>
/// (0x02C2), including the StatMod triad and bucket classification.
/// </summary>
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
{

View file

@ -64,6 +64,7 @@ public sealed class RuntimeCharacterState : IDisposable
/// </summary>
private int _runSkillBase = -1;
private int _jumpSkillBase = -1;
private PlayerSkillMath.AugmentationBonuses _movementSkillAugmentations;
public RuntimeCharacterState(SpellTable? spellTable = null)
{
@ -142,13 +143,14 @@ public sealed class RuntimeCharacterState : IDisposable
&& MovementSkills.PlayerKillerStatus == -1
&& MovementSkills.LastPkAttackTimestamp is null
&& _runSkillBase == -1
&& _jumpSkillBase == -1);
&& _jumpSkillBase == -1
&& _movementSkillAugmentations == default);
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): stores the pre-<c>EnchantSkill</c>
/// base run/jump skill (PlayerDescription's formulaBonus+init+ranks)
/// and pushes the vitae/enchantment-adjusted result into
/// and pushes the augmentation/vitae/enchantment-adjusted result into
/// <see cref="MovementSkills"/> — the SAME call shape
/// <c>LiveSessionEventRouter</c>'s pre-P1 <c>onSkillsUpdated</c> callback
/// already used (<c>MovementSkills.Update(runSkill, jumpSkill)</c>), now
@ -165,6 +167,22 @@ public sealed class RuntimeCharacterState : IDisposable
RecomputeMovementSkills();
}
/// <summary>
/// Installs the player-quality augmentation terms consumed by retail
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>. The object
/// table remains authoritative for live PropertyInt updates; Runtime
/// retains only this immutable derived snapshot.
/// </summary>
public void UpdateMovementSkillAugmentations(
PlayerSkillMath.AugmentationBonuses augmentations)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_movementSkillAugmentations == augmentations)
return;
_movementSkillAugmentations = augmentations;
RecomputeMovementSkills();
}
/// <summary>
/// Re-derives the adjusted run/jump skill from the stored base plus the
/// CURRENT spellbook state (vitae + skill enchantments) — matching
@ -178,10 +196,10 @@ public sealed class RuntimeCharacterState : IDisposable
private void RecomputeMovementSkills()
{
int run = _runSkillBase >= 0
? ApplySkillEnchantments(_runSkillBase, RunSkillId)
? CalculateMovementSkill(_runSkillBase, RunSkillId)
: -1;
int jump = _jumpSkillBase >= 0
? ApplySkillEnchantments(_jumpSkillBase, JumpSkillId)
? CalculateMovementSkill(_jumpSkillBase, JumpSkillId)
: -1;
// #266 apparatus (permanent, low-volume — fires only on skill-base or
// enchantment changes, the [snap] class): the full stat-chain state at
@ -196,14 +214,19 @@ public sealed class RuntimeCharacterState : IDisposable
MovementSkills.Update(run, jump);
}
private int ApplySkillEnchantments(int baseSkill, uint skillId)
private int CalculateMovementSkill(int baseSkill, uint skillId)
{
EnchantmentMath.VitalMod mod = Spellbook.GetSkillMod(skillId);
float adjusted = baseSkill * mod.Multiplier + mod.Additive;
// CEnchantmentRegistry::EnchantSkill pc 416240: floor to 0 below
// 0.5, then truncate (retail _ftol2, a C-style cast).
if (adjusted < 0.5f) adjusted = 0f;
return (int)adjusted;
float vitae = EnchantmentMath.GetVitaeMultiplier(
Spellbook.ActiveEnchantments);
uint advancementClass = LocalPlayer.GetSkill(skillId)?.Status ?? 0u;
return PlayerSkillMath.Calculate(
baseSkill,
skillId,
advancementClass,
_movementSkillAugmentations,
mod,
vitae).EffectiveLevel;
}
private void OnEnchantmentsChangedForMovement() => RecomputeMovementSkills();
@ -331,6 +354,7 @@ public sealed class RuntimeCharacterState : IDisposable
Try(Options.ResetSession, ref failures);
_runSkillBase = -1;
_jumpSkillBase = -1;
_movementSkillAugmentations = default;
Try(MovementSkills.ResetSession, ref failures);
if (failures is not null)
{
@ -355,6 +379,7 @@ public sealed class RuntimeCharacterState : IDisposable
Try(Options.ResetSession, ref failures);
_runSkillBase = -1;
_jumpSkillBase = -1;
_movementSkillAugmentations = default;
Try(MovementSkills.ResetSession, ref failures);
}
finally

View file

@ -200,27 +200,27 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectAdded += h,
h => inventory.Objects.ObjectAdded -= h,
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectUpdated += h,
h => inventory.Objects.ObjectUpdated -= h,
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectRemoved += h,
h => inventory.Objects.ObjectRemoved -= h,
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObjectMove>(
h => inventory.Objects.ObjectMoved += h,
h => inventory.Objects.ObjectMoved -= h,
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<uint>(
h => inventory.Objects.ContainerContentsReplaced += h,
h => inventory.Objects.ContainerContentsReplaced -= h,
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
() => RecomputePlayerQualities(inventory, character));
SubscribeParameterless(
h => inventory.Objects.Cleared += h,
h => inventory.Objects.Cleared -= h,
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
() => RecomputePlayerQualities(inventory, character));
Subscribe<LocalPlayerState.AttributeKind>(
h => character.Character.LocalPlayer.AttributeChanged += h,
h => character.Character.LocalPlayer.AttributeChanged -= h,
@ -365,7 +365,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
/// </summary>
private static void RecomputeBurden(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character)
LiveCharacterSessionBindings character,
bool notify = true)
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
@ -381,6 +382,22 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
: inventory.Objects.SumCarriedBurden(player);
float load = EncumbranceSystem.Load(capacity, burden);
character.Character.MovementSkills.UpdateBurden(load);
if (notify)
character.OnMovementStatsUpdated?.Invoke();
}
private static void RecomputePlayerQualities(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character)
{
RecomputeBurden(inventory, character, notify: false);
RecomputePvpStatus(inventory, character, notify: false);
uint player = inventory.PlayerGuid();
PropertyBundle properties = inventory.Objects.Get(player)?.Properties
?? character.Character.LocalPlayer.Properties;
character.Character.UpdateMovementSkillAugmentations(
PlayerSkillMath.AugmentationBonuses.FromProperties(properties));
character.OnMovementStatsUpdated?.Invoke();
}
@ -397,7 +414,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
/// </summary>
private static void RecomputePvpStatus(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character)
LiveCharacterSessionBindings character,
bool notify = true)
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
@ -415,7 +433,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
character.Character.MovementSkills.UpdatePlayerKillerStatus(
pkStatus,
lastPkAttackTimestamp);
character.OnMovementStatsUpdated?.Invoke();
if (notify)
character.OnMovementStatsUpdated?.Invoke();
}
/// <summary>

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
@ -75,6 +76,31 @@ public sealed class CharacterLayoutImportProbe
Assert.Equal(1, closes);
}
[Fact]
public void Footer_title_exposes_retail_append_text_palette()
{
string? datDir = DatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
ImportedLayout? layout = LayoutImporter.Import(
dats,
CharacterLayout,
_ => (1u, 30, 26),
null);
UiText title = Assert.IsType<UiText>(
layout!.FindElement(CharacterStatController.FooterTitleId));
Assert.Equal(
[
Vector4.One,
new Vector4(0f, 1f, 0f, 1f),
new Vector4(1f, 0f, 0f, 1f),
new Vector4(127f / 255f, 1f, 1f, 1f),
],
title.FontColorPalette);
}
private static void CollectRows(UiElement node, List<UiClickablePanel> result)
{
if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null)

View file

@ -3,6 +3,7 @@ using System.Linq;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
using Xunit;
@ -285,6 +286,65 @@ public sealed class CharacterSheetProviderTests
Assert.Equal(-100, skill.VitaeModifier); // the exact user-reported oracle example
}
[Fact]
public void BuildSheet_SkillAugmentations_UseRetailBeforeAndAfterOrdering()
{
var h = new VitaeHarness();
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 3;
properties.Ints[(uint)PropertyInt.AugmentationSkilledMagic] = 1;
properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1;
properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 4;
h.Player.OnProperties(properties);
h.Player.OnSkillUpdate(
skillId: 0x1Fu,
ranks: 100u,
status: 3u,
xp: 0u,
init: 0u,
resistance: 0u,
lastUsed: 0d,
formulaBonus: 0u);
CharacterSkill skill = Assert.Single(h.Provider.BuildSheet().Skills);
Assert.Equal(113, skill.BaseLevel);
Assert.Equal(126, skill.CurrentLevel);
Assert.Equal(0, skill.VitaeModifier);
}
[Fact]
public void BuildSheet_VitalPairsCarryBaseAndVitaeContribution()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(
atType: 2u,
ranks: 100u,
start: 100u,
xp: 0u);
h.Player.OnVitalUpdate(
vitalId: 7u,
ranks: 0u,
start: 100u,
xp: 0u,
current: 150u);
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: -1d,
CasterGuid: 0u,
StatModType: 0u,
StatModKey: 0u,
StatModValue: 0.8f,
Bucket: 4u));
CharacterSheet sheet = h.Provider.BuildSheet();
Assert.Equal(200, sheet.VitalBaseMaxValues[0]);
Assert.Equal(-40, sheet.VitalVitaeModifiers[0]);
Assert.Equal(160, sheet.HealthMax);
}
[Fact]
public void SubscribeChanged_FiresOnEnchantmentsChanged_AndRebuildReflectsNewValue()
{

View file

@ -876,7 +876,7 @@ public class CharacterStatControllerTests
var meleeTexts = rows[0].Children.OfType<UiText>().ToList();
Assert.Equal(Vector4.One, meleeTexts[1].LinesProvider()[0].Color);
Assert.Equal(new Vector4(0.55f, 1f, 0.55f, 1f), meleeTexts[2].LinesProvider()[0].Color);
Assert.Equal(new Vector4(0f, 1f, 0f, 1f), meleeTexts[2].LinesProvider()[0].Color);
var healingTexts = rows[5].Children.OfType<UiText>().ToList();
Assert.Equal(Vector4.One, healingTexts[1].LinesProvider()[0].Color);
@ -1314,6 +1314,42 @@ public class CharacterStatControllerTests
Assert.Equal(50, CharacterStatController.GetSkillBuffOnlyDelta(skill));
}
[Fact]
public void SkillValueColor_SubtractsVitaeBeforeChoosingFontState()
{
var vitaeOnly = new CharacterSkill(
1u, "S", 0u, CharacterSkillAdvancementClass.Trained,
BaseLevel: 300, CurrentLevel: 201, UsableUntrained: true,
TrainedCost: 0, SpecializedCost: 0, RaiseCost: 0,
VitaeModifier: -99);
var vitaeAndBuff = vitaeOnly with { CurrentLevel = 211 };
Assert.Equal(Vector4.One, CharacterStatController.SkillValueColor(vitaeOnly));
Assert.Equal(
new Vector4(0f, 1f, 0f, 1f),
CharacterStatController.SkillValueColor(vitaeAndBuff));
}
[Fact]
public void AttributeAndVitalValueColors_FollowRetailResidualComparison()
{
var sheet = new CharacterSheet
{
Strength = 220,
AttributeBaseValues = [200, 0, 0, 0, 0, 0],
HealthMax = 170,
VitalBaseMaxValues = [200, 0, 0],
VitalVitaeModifiers = [-40, 0, 0],
};
Assert.Equal(
new Vector4(0f, 1f, 0f, 1f),
CharacterStatController.AttributeValueColor(sheet, 0));
Assert.Equal(
new Vector4(0f, 1f, 0f, 1f),
CharacterStatController.VitalValueColor(sheet, 0));
}
[Fact]
public void RowClick_AttributeWithBuff_FooterTitleShowsPositiveDelta()
{
@ -1405,6 +1441,41 @@ public class CharacterStatControllerTests
Assert.Equal("Test Skill: 251 (-99) (+50)", title.LinesProvider()[0].Text);
}
[Fact]
public void SkillFooter_UsesAuthoredPaletteForVitaeAndBuffRuns()
{
Vector4 normal = new(0.1f, 0.1f, 0.1f, 1f);
Vector4 buff = new(0.2f, 0.3f, 0.4f, 1f);
Vector4 debuff = new(0.5f, 0.6f, 0.7f, 1f);
Vector4 vitae = new(127f / 255f, 1f, 1f, 1f);
var list = new UiPanel { Width = 300 };
var title = new UiText
{
FontColorPalette = [normal, buff, debuff, vitae],
};
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.FooterTitleId, title));
CharacterSheet Sheet() => VitaeSkillSheet(
currentLevel: 251,
baseLevel: 300,
vitaeModifier: -99);
CharacterStatController.Bind(
layout,
Sheet,
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
SkillRows(list)[0].OnClick!();
IReadOnlyList<UiText.TextRun> runs = title.RunsProvider!();
Assert.Equal(3, runs.Count);
Assert.Equal(("Test Skill: 251", normal), (runs[0].Text, runs[0].Color));
Assert.Equal((" (-99)", vitae), (runs[1].Text, runs[1].Color));
Assert.Equal((" (+50)", buff), (runs[2].Text, runs[2].Color));
}
[Fact]
public void SkillClick_ZeroDelta_NoParentheticals()
{

View file

@ -639,6 +639,56 @@ public sealed class GameEventWiringTests
Assert.Equal(60d, record.Duration);
}
[Fact]
public void WireAll_MagicUpdateEnchantment_PropagatesFullStatModMidSession()
{
var dispatcher = new GameEventDispatcher();
var book = new Spellbook(SpellTable.Create([TestSpell(42u)]));
var player = new LocalPlayerState(book);
player.OnSkillUpdate(
skillId: 7u,
ranks: 100u,
status: 2u,
xp: 0u,
init: 0u,
resistance: 0u,
lastUsed: 0d,
formulaBonus: 0u);
int changed = 0;
book.EnchantmentsChanged += () => changed++;
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
book,
new ChatLog(),
localPlayer: player,
clientTime: () => 100d);
byte[] payload = BuildEnchantment(
spellId: 42,
layer: 3,
duration: 60,
caster: 0xBEEF,
startTime: 10,
lastDegraded: 2,
statModType: 0x00008010u,
statModKey: 7u,
statModValue: 25f);
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(
GameEventType.MagicUpdateEnchantment,
payload))!.Value);
ActiveEnchantmentRecord record =
Assert.Single(book.ActiveEnchantmentSnapshot);
Assert.Equal(0x00008010u, record.StatModType);
Assert.Equal(7u, record.StatModKey);
Assert.Equal(25f, record.StatModValue);
Assert.Equal(125, player.GetEffectiveSkill(7u));
Assert.Equal(1, changed);
}
[Fact]
public void WireAll_WeenieError_RoutesToChatLog()
{
@ -1294,14 +1344,16 @@ public sealed class GameEventWiringTests
uint caster,
double startTime,
double lastDegraded,
uint statModType)
uint statModType,
uint statModKey = 7u,
float statModValue = 1.25f)
{
byte[] payload = new byte[60];
int offset = 0;
WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0);
WriteU32(8); WriteF64(startTime); WriteF64(duration); WriteU32(caster);
WriteF32(0.1f); WriteF32(-1f); WriteF64(lastDegraded);
WriteU32(statModType); WriteU32(7); WriteF32(1.25f);
WriteU32(statModType); WriteU32(statModKey); WriteF32(statModValue);
return payload;
void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; }
@ -1310,4 +1362,9 @@ public sealed class GameEventWiringTests
void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; }
}
private static SpellMetadata TestSpell(uint spellId) => new(
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
false, false, "", 0, 0, 0u, 0, false, false, true,
0f, 0u, 0u, 0u, 0);
}

View file

@ -0,0 +1,78 @@
using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Player;
public sealed class PlayerSkillMathTests
{
[Fact]
public void Calculate_PreservesRetailAugmentationOrdering()
{
var augmentations = new PlayerSkillMath.AugmentationBonuses(
AllSkills: 3,
JackOfAllTrades: true,
SkilledSpecialized: 4,
SkilledMelee: false,
SkilledMissile: false,
SkilledMagic: true);
PlayerSkillMath.Value value = PlayerSkillMath.Calculate(
intrinsicLevel: 100,
skillId: 0x1Fu,
advancementClass: 3u,
augmentations,
enchantment: new EnchantmentMath.VitalMod(0.5f, 0f),
vitaeMultiplier: 0.8f);
Assert.Equal(113, value.UnenchantedLevel); // 100 + all 3 + magic 10
Assert.Equal(69, value.EffectiveLevel); // trunc(113 * .5) + JOAT 5 + spec 8
Assert.Equal(-23, value.VitaeModifier); // trunc(113 * .8) - 113
}
[Theory]
[InlineData(0x29u, true, false, false, 10)]
[InlineData(0x2Fu, false, true, false, 10)]
[InlineData(0x22u, false, false, true, 10)]
[InlineData(0x18u, true, true, true, 0)]
public void BeforeEnchantments_UsesRetailSkillCategorySwitch(
uint skillId,
bool melee,
bool missile,
bool magic,
int expectedCategoryBonus)
{
var augmentations = new PlayerSkillMath.AugmentationBonuses(
AllSkills: 2,
JackOfAllTrades: false,
SkilledSpecialized: 0,
SkilledMelee: melee,
SkilledMissile: missile,
SkilledMagic: magic);
Assert.Equal(
2 + expectedCategoryBonus,
augmentations.BeforeEnchantments(skillId));
}
[Fact]
public void FromProperties_MapsNamedRetailQualities()
{
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 7;
properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1;
properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 3;
properties.Ints[(uint)PropertyInt.AugmentationSkilledMissile] = 2;
PlayerSkillMath.AugmentationBonuses value =
PlayerSkillMath.AugmentationBonuses.FromProperties(properties);
Assert.Equal(7, value.AllSkills);
Assert.True(value.JackOfAllTrades);
Assert.Equal(3, value.SkilledSpecialized);
Assert.True(value.SkilledMissile);
Assert.False(value.SkilledMelee);
Assert.False(value.SkilledMagic);
}
}

View file

@ -257,11 +257,56 @@ public sealed class RuntimeCharacterStateTests
Assert.Equal(100, state.MovementSkills.JumpSkill); // untouched
}
[Fact]
public void MovementSkillAugmentations_UseSameRetailChainAsCharacterSheet()
{
using var state = new RuntimeCharacterState();
state.LocalPlayer.OnSkillUpdate(
RuntimeCharacterState.RunSkillId,
ranks: 0u,
status: 3u,
xp: 0u,
init: 0u,
resistance: 0u,
lastUsed: 0d,
formulaBonus: 200u);
state.LocalPlayer.OnSkillUpdate(
RuntimeCharacterState.JumpSkillId,
ranks: 0u,
status: 2u,
xp: 0u,
init: 0u,
resistance: 0u,
lastUsed: 0d,
formulaBonus: 100u);
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
state.UpdateMovementSkillAugmentations(
new PlayerSkillMath.AugmentationBonuses(
AllSkills: 2,
JackOfAllTrades: true,
SkilledSpecialized: 3,
SkilledMelee: false,
SkilledMissile: false,
SkilledMagic: false));
Assert.Equal(213, state.MovementSkills.RunSkill);
Assert.Equal(107, state.MovementSkills.JumpSkill);
}
[Fact]
public void ResetSession_ClearsBurdenStaminaAndSkillBase()
{
using var state = new RuntimeCharacterState();
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
state.UpdateMovementSkillAugmentations(
new PlayerSkillMath.AugmentationBonuses(
AllSkills: 2,
JackOfAllTrades: true,
SkilledSpecialized: 3,
SkilledMelee: false,
SkilledMissile: false,
SkilledMagic: false));
state.MovementSkills.UpdateBurden(1.5f);
state.MovementSkills.UpdateStamina(0);
@ -274,7 +319,7 @@ public sealed class RuntimeCharacterStateTests
Assert.True(state.CaptureOwnership().MovementSkillsAreReset);
// A fresh base push after reset must not still carry the pre-reset
// vitae/enchantment adjustment (spellbook was cleared too).
// augmentation/vitae/enchantment adjustment (spellbook was cleared too).
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
Assert.Equal(200, state.MovementSkills.RunSkill);
}

View file

@ -233,6 +233,58 @@ public sealed class LiveSessionEventRouterTests
router.Dispose();
}
[Fact]
public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations()
{
using var session = NewSession();
const uint playerGuid = 0x50000001u;
var objects = new ClientObjectTable();
var character = new RuntimeCharacterState();
character.LocalPlayer.OnSkillUpdate(
RuntimeCharacterState.RunSkillId,
ranks: 0u,
status: 3u,
xp: 0u,
init: 0u,
resistance: 0u,
lastUsed: 0d,
formulaBonus: 200u);
character.UpdateMovementSkillBase(
runSkillBase: 200,
jumpSkillBase: -1);
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
new LiveInventorySessionBindings(
objects,
PlayerGuid: () => playerGuid,
OnShortcuts: null,
OnUseDone: null,
ItemMana: new ItemManaState(),
ExternalContainers: new ExternalContainerState()),
new LiveCharacterSessionBindings(
new CombatState(),
character,
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: null,
OnConfirmationDone: null,
ClientTime: () => 0d),
NewSocialBindings());
router.Attach();
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 2;
properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1;
properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 3;
objects.UpsertProperties(playerGuid, properties);
Assert.Equal(213, character.MovementSkills.RunSkill);
router.Dispose();
}
[Fact]
public void StaminaVitalChange_PushesCurrentStamina()
{