feat(player): port retail augmentation stat chain
This commit is contained in:
parent
0cb60d98a0
commit
461a1fb7b4
22 changed files with 953 additions and 138 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
108
src/AcDream.Core/Player/PlayerSkillMath.cs
Normal file
108
src/AcDream.Core/Player/PlayerSkillMath.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
78
tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs
Normal file
78
tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue