diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 49ecb19a..1bc96735 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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, 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. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a4d7306b..f68e1b5e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 7cdfb1b2..d9b4954b 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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. --- diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index 6c9fd421..814ef6df 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -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 diff --git a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md index 1e0496c0..6d8ff3ee 100644 --- a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md +++ b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md @@ -141,10 +141,13 @@ InqRunRate(this, &rateOut): EnchantAttribute2nd(this, 4, ¤tStamina) // 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. diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 58711305..fd7ae395 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -86,6 +86,17 @@ public sealed class CharacterSheet public int ManaCurrent { get; init; } public int ManaMax { get; init; } + /// + /// Unenchanted max Health/Stamina/Mana in that order. + /// + public int[] VitalBaseMaxValues { get; init; } = Array.Empty(); + + /// + /// Isolated vitae contribution to max Health/Stamina/Mana, always + /// non-positive and ordered like . + /// + public int[] VitalVitaeModifiers { get; init; } = Array.Empty(); + // ── 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. diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 51ac37ef..69cddd55 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -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 BuildLiveCharacterSkills() + private IReadOnlyList BuildLiveCharacterSkills( + PropertyBundle properties) { var result = new List(); 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 ───────────────────────────────────────────────── /// diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index fa60c650..d8b56b46 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -110,7 +110,11 @@ public static class CharacterStatController /// Row highlight color — semi-translucent gold, matches retail /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. 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; + } /// /// 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 BuildSelectedTitleRuns( + UiText target, + CharacterStatTab tab, + Func 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 + { + 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 + { + 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; + } + /// /// Add a single attribute/vital row to as a /// 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); diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 73d57334..9057e95b 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -32,6 +32,11 @@ public sealed class UiText : UiElement, IUiDatStateful /// One display line: pre-formatted text + its colour. public readonly record struct Line(string Text, Vector4 Color); + /// + /// One inline fragment in a retail AppendTextWithFont line. + /// + public readonly record struct TextRun(string Text, Vector4 Color); + /// 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). public readonly record struct Pos(int Line, int Col); @@ -39,6 +44,13 @@ public sealed class UiText : UiElement, IUiDatStateful /// Provider of the lines to show, oldest-first. Polled each frame. public Func> LinesProvider { get; set; } = static () => Array.Empty(); + /// + /// 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. + /// + public Func>? RunsProvider { get; set; } + /// Font for the transcript; falls back to the context default. 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 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); + } + } + } + /// /// 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. diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index 51e574d6..6941bbaa 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -47,12 +47,10 @@ namespace AcDream.Core.Player; /// /// /// -/// Enchantment buffs (multiplicative + additive) and the -/// 5-min-vital clamp are not yet applied — adding those -/// requires the '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. +/// Enchantment buffs (multiplicative + additive), vitae, and the +/// retail five-point minimum are applied through the attached +/// . Base-value accessors retain +/// the unenchanted values needed by the character-panel comparison logic. /// /// public sealed class LocalPlayerState @@ -226,13 +224,37 @@ public sealed class LocalPlayerState /// skill hasn't arrived yet. /// public int? GetEffectiveSkill(uint skillId) + => GetSkillValue(skillId)?.EffectiveLevel; + + /// + /// Full retail CACQualities::InqSkill projection, including the + /// augmentation terms on both sides of EnchantSkill. Callers with a + /// fresher player-object property bundle may supply it; otherwise the + /// PlayerDescription snapshot is used. + /// + 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); } /// @@ -242,14 +264,7 @@ public sealed class LocalPlayerState /// is wired, no vitae is active, or the skill hasn't arrived yet. /// 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; /// Snapshot of the local player's current property bundle. public PropertyBundle Properties => _properties; @@ -290,11 +305,8 @@ public sealed class LocalPlayerState /// 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); } + /// + /// Unenchanted secondary-attribute maximum used by retail's + /// Attribute2ndInfoRegion::Update @ 0x004F19E0 comparison. + /// + public uint? GetBaseMaxApprox(VitalKind kind) + { + VitalSnapshot? vital = Get(kind); + if (vital is null) return null; + return vital.Value.Ranks + + vital.Value.Start + + AttributeContribution(kind); + } + + /// + /// Isolated vitae contribution to a secondary attribute, matching + /// Attribute2ndInfoRegion::GetVitaeModifier @ 0x004F1130. + /// + 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, diff --git a/src/AcDream.Core/Player/PlayerSkillMath.cs b/src/AcDream.Core/Player/PlayerSkillMath.cs new file mode 100644 index 00000000..601252fc --- /dev/null +++ b/src/AcDream.Core/Player/PlayerSkillMath.cs @@ -0,0 +1,108 @@ +using AcDream.Core.Items; +using AcDream.Core.Properties; +using AcDream.Core.Spells; + +namespace AcDream.Core.Player; + +/// +/// Retail CACQualities::InqSkill @ 0x00592660 composition. +/// Keeps the augmentation/enchantment ordering in one presentation-independent +/// place so character UI and movement consume the same effective skill. +/// +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); + + /// + /// Compose one skill exactly in retail order: + /// intrinsic + LumAugAllSkills/category bonus; EnchantSkill; then + /// Jack of All Trades and the specialized luminance bonus. + /// + 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; + } +} diff --git a/src/AcDream.Core/Spells/EnchantmentMath.cs b/src/AcDream.Core/Spells/EnchantmentMath.cs index e5f76b48..61014eb4 100644 --- a/src/AcDream.Core/Spells/EnchantmentMath.cs +++ b/src/AcDream.Core/Spells/EnchantmentMath.cs @@ -28,22 +28,15 @@ namespace AcDream.Core.Spells; /// /// Vitae (death penalty) is a singleton on /// CEnchantmentRegistry._vitae, applied multiplicatively after -/// the buff lists. We don't yet wire it through. +/// the buff lists. /// /// /// -/// Current implementation status: the aggregator iterates -/// and applies -/// family-stacking deduplication, but -/// **returns identity (1.0, 0.0) for stat modifiers** because our -/// doesn't yet carry the -/// StatMod (type/key/val) triad — that requires extending -/// ParseMagicUpdateEnchantment to read the full Enchantment -/// payload (60-64 bytes per holtburger -/// messages/magic/types.rs) 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. +/// Current implementation status: the aggregator consumes the same +/// complete StatMod (type/key/value) record shape from both the +/// PlayerDescription snapshot and live MagicUpdateEnchantment +/// (0x02C2), applies retail family stacking, then evaluates the selected +/// attribute, secondary attribute, or skill domain. /// /// /// @@ -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 enchantments, uint baseValue) + => SkillVitaeModifier(GetVitaeMultiplier(enchantments), baseValue); + + /// + /// 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. + /// + 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; } /// diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index b852ff69..7251d9cb 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -267,11 +267,9 @@ public sealed class Spellbook } /// - /// Issue #7 / #12 — accept a fully-populated record from - /// PlayerDescription'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 MagicUpdateEnchantment. + /// Accept the canonical fully-populated enchantment record shared by + /// PlayerDescription and live MagicUpdateEnchantment + /// (0x02C2), including the StatMod triad and bucket classification. /// public void OnEnchantmentAdded(ActiveEnchantmentRecord record) { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 61a76c4e..f9acaf5a 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -64,6 +64,7 @@ public sealed class RuntimeCharacterState : IDisposable /// 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); } /// /// Campaign P Slice P1 (2026-07-30): stores the pre-EnchantSkill /// 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 /// — the SAME call shape /// LiveSessionEventRouter's pre-P1 onSkillsUpdated callback /// already used (MovementSkills.Update(runSkill, jumpSkill)), now @@ -165,6 +167,22 @@ public sealed class RuntimeCharacterState : IDisposable RecomputeMovementSkills(); } + /// + /// Installs the player-quality augmentation terms consumed by retail + /// CACQualities::InqRunRate/InqJumpVelocity. The object + /// table remains authoritative for live PropertyInt updates; Runtime + /// retains only this immutable derived snapshot. + /// + public void UpdateMovementSkillAugmentations( + PlayerSkillMath.AugmentationBonuses augmentations) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_movementSkillAugmentations == augmentations) + return; + _movementSkillAugmentations = augmentations; + RecomputeMovementSkills(); + } + /// /// 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 diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index a0fafa79..704fa486 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -200,27 +200,27 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting SubscribeToRecompute( h => inventory.Objects.ObjectAdded += h, h => inventory.Objects.ObjectAdded -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectUpdated += h, h => inventory.Objects.ObjectUpdated -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectRemoved += h, h => inventory.Objects.ObjectRemoved -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectMoved += h, h => inventory.Objects.ObjectMoved -= h, - () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); + () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( 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( h => character.Character.LocalPlayer.AttributeChanged += h, h => character.Character.LocalPlayer.AttributeChanged -= h, @@ -365,7 +365,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting /// 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 /// 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(); } /// diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs index 712d516f..73d3d0c7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs @@ -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( + 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 result) { if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index c69004dc..ad99c683 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 4bab2215..212b0af4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -876,7 +876,7 @@ public class CharacterStatControllerTests var meleeTexts = rows[0].Children.OfType().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().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 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() { diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index ad170c5c..96007581 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -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); + } diff --git a/tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs b/tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs new file mode 100644 index 00000000..dfad768f --- /dev/null +++ b/tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index 3f2dd06f..eee36fca 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -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); } diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index 38090629..fc3d2fe3 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -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() {