feat(physics): Campaign P P1 - stat-coupled movement (burden/stamina/vitae)
Ports the retail CACQualities/EncumbranceSystem/MovementSystem chain (named-retail decomp pc 256393/412901-414050/416169-416320/695958+) so PlayerWeenie's run rate, jump height, jump permission, and jump stamina cost are real functions of burden, current stamina, and vitae/skill enchantments instead of stubs. Core: - New EncumbranceSystem.cs (delegates to the already-verified BurdenMath formulas — one source of truth for the burden HUD and movement physics) and MovementSystem.cs (GetRunRate/GetJumpHeight/ JumpStaminaCost/GetJumpPower, decomp-cited; ACE cross-referenced where BN dropped the general-case arithmetic entirely). - PlayerWeenie rewritten as the CACQualities-shaped composition: CanJump gates on burden (<2.0 load, UN-8 — x87 polarity resolved by plausibility, Ghidra MCP unavailable this slice), JumpStaminaCost returns the real ceil((load+0.5)*power*8+2) cost and always affords it (matches decomp — retail's own function never refuses; "weak" jump comes entirely from the stamina==0 skill-zeroing gate inside InqRunRate/InqJumpVelocity, not a hard refusal), SetStamina wires a null="unknown, don't gate" sentinel preserving every pre-P1 test. - EnchantmentMath.GetMod gained an optional StatModType flag filter (GetSkillMod convenience wrapper) so the SAME vitae/family-stacking machinery already used for vital-max buffs now also answers "what's the vitae+skill-enchantment-adjusted Run/Jump skill" — reusing the M3 active-enchantment state, not a new engine. Runtime: - RuntimeCharacterState now stores the pre-EnchantSkill base run/jump skill and recomputes the adjusted value (vitae first, then matching Skill-flagged buffs, floor 0.5, truncate) on every base push AND on every Spellbook.EnchantmentsChanged notification — a vitae change alone moves the produced rate without a fresh PlayerDescription. - RuntimeMovementSkillState extended with Burden/CurrentStamina (RuntimeMovementSkillProjection.ApplyTo pushes both through the existing seam); LiveSessionEventRouter recomputes burden from the same Strength+aug-property+EncumbranceVal inputs the burden HUD already assembles (reacting to the same ClientObjectTable events) and pushes current stamina from LocalPlayerState vital updates. - Wires the previously dead-lettered ReportExhaustion() R3-W4 seam: LiveSessionRuntimeFactory's OnMovementStatsUpdated callback re- applies the current snapshot to the live controller and forces an immediate movement re-evaluation on any skill/burden/stamina change. Register: retires TS-5 (CanJump/JumpStaminaCost stubs) and AP-25 (no vitae in pushed skill). Adds AP-127 (two minor unmodeled retail bonus properties + the stamina-buff-adjusts-local-copy nuance, deliberately out of the bounded "run/jump query path only" scope) and UN-8 (the CanJump x87 polarity call, flagged for a future Ghidra MCP confirmation pass). Extends TS-23 (PlayerKillerStatus not parsed) to cover JumpStaminaCost's new pk parameter, hardcoded false pending P3. Full pseudocode + retail citations + the vitae/skill-level finding in docs/research/2026-07-30-stat-coupled-movement-pseudocode.md. Release suite: Core.Tests 3977/2 skips, Runtime.Tests 425/0 skips, App.Tests 3968/3 skips — all green. (One pre-existing, unrelated Debug- only flake in LandblockBuildOriginTests reproduces on the pre-P1 baseline and passes in Release; not touched here.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3a4782048e
commit
9355ddcec6
20 changed files with 1714 additions and 74 deletions
|
|
@ -1,4 +1,4 @@
|
|||
# Retail Divergence Register — current through 2026-07-27
|
||||
# Retail Divergence Register — current through 2026-07-30
|
||||
|
||||
**What this is.** The single auditable register of every known place acdream's
|
||||
runtime behavior can deviate from the retail client (Sept 2013 EoR build,
|
||||
|
|
@ -114,7 +114,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 93 active rows
|
||||
## 3. Documented approximation (AP) — 93 active rows (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)
|
||||
|
||||
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
|
||||
|
|
@ -142,7 +142,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
|
||||
| AP-23 | Invented per-type pickup-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating plus the speculative local TurnToObject/MoveToObject install through the player's MoveToManager. **R5-V3 narrowed it:** the install threads the target's real Setup radius/height (`GetSetupCylinder`, same as wire mt-6) and the player's real radius; only the radius buckets remain invented. **Use retired from this seam 2026-07-25** and now sends immediately. | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | The retained pickup presentation reserves a destination slot before the authoritative transfer; its close branch still needs an arrival boundary | A target whose real UseRadius differs from the bucket misjudges the pickup gate — pickup waits forever or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
|
||||
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
|
||||
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
|
||||
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
|
||||
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
|
||||
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
|
||||
|
|
@ -230,14 +229,14 @@ 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 |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 43 active rows (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) — 42 active rows (TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; 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 |
|
||||
|---|---|---|---|---|---|
|
||||
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
|
||||
| TS-4 | 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-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
|
||||
| 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 |
|
||||
|
|
@ -249,7 +248,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
|
||||
| ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16** — `CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons ∪ portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` |
|
||||
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
|
||||
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+) | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`) |
|
||||
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover. Campaign P Slice P1 (2026-07-30) added a THIRD consumer of this same gap: `MovementSystem.JumpStaminaCost`'s `pk` parameter (retail `CACQualities::JumpStaminaCost` reads PlayerKillerStatus property 0x86 + LastPkAttackTimestamp property 0x91) is hardcoded `false` at the `PlayerWeenie` call site pending this row's fix | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch); `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost`, P1) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail). P1's jump-stamina-cost `pk:false` is harmless pre-PK (the PK-timer-active cost bump never applies to a non-PK character anyway) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+); a PK-active character's jump stamina cost also undercounts vs retail's `(power+1.0)*100.0` PK-timer formula until this row is fixed | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`); `CACQualities::JumpStaminaCost` 0x00591b90 pc 412949 |
|
||||
| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
|
||||
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
|
||||
| TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` |
|
||||
|
|
@ -285,7 +284,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
|
||||
---
|
||||
|
||||
## 5. Unclear (UN) — 4 rows
|
||||
## 5. Unclear (UN) — 5 rows (UN-8 filed 2026-07-30 at Campaign P Slice P1)
|
||||
|
||||
These rows have a missing, contradictory, or never-argued justification.
|
||||
They are the highest-priority audits: each needs either a recorded
|
||||
|
|
@ -297,6 +296,7 @@ equivalence argument (promote to AD/AP) or a fix.
|
|||
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
|
||||
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
|
||||
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
|
||||
| UN-8 | `CACQualities::CanJump`'s (0x00591b50) x87-mush comparison against the 2.0-load threshold was resolved by DOMAIN PLAUSIBILITY, not a literal BN read — Campaign P Slice P1 (2026-07-30) ported `load < 2.0` (can jump under 200% burden), the polarity a normal AC player's experience requires and that coincides with `LoadMod`'s own floor, over BN's literal (backwards) reading. ACE gives no tiebreaker (its `WeenieObject.CanJump` is an unconditional `true` stub); Ghidra MCP was unavailable this slice | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`CanJump`, `CanJumpLoadThreshold`) | Plausibility argument recorded in the pseudocode doc §3, not a verified decompile; the exact x87 flag-synthesis for the FOLLOWING `test ah,mask` interpretation is the documented BN "bitfield mush" artifact class | If the polarity is actually backwards, CanJump refuses jumps under 200% load (breaking ordinary play) instead of only refusing severe overload — would surface immediately in the P1 visual matrix scenario 1 (barely moves/jumps near 200%) | `CACQualities::CanJump` 0x00591b50 pc 412907; `docs/research/2026-07-30-stat-coupled-movement-pseudocode.md` §3 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -318,7 +318,7 @@ WITH that phase, not before.
|
|||
9. **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-5 (CanJump gating), TS-23 (PK bits), TS-25
|
||||
M2 combat must land TS-23 (PK bits), TS-25
|
||||
(stance in MoveToState), TS-17 (AttackConditions),
|
||||
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
|
||||
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
|
||||
|
|
|
|||
387
docs/research/2026-07-30-stat-coupled-movement-pseudocode.md
Normal file
387
docs/research/2026-07-30-stat-coupled-movement-pseudocode.md
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
# Campaign P — P1 stat-coupled movement: pseudocode + retail chain
|
||||
|
||||
Filed 2026-07-30 ahead of the P1 implementation (burden/stamina/vitae feeding
|
||||
run rate, jump height, jump permission, jump stamina cost). All addresses are
|
||||
from `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
|
||||
build) unless marked ACE-cross-reference. Ghidra MCP was unavailable for this
|
||||
slice (operator note); ACE (`references/ACE/Source/ACE.Server/Physics/`) is
|
||||
the tiebreaker wherever BN's x87 mush drops a branch, called out explicitly
|
||||
below.
|
||||
|
||||
## 1. The call chain (top to bottom)
|
||||
|
||||
```
|
||||
CMotionInterp (our MotionInterpreter.cs, unchanged this slice)
|
||||
jump_is_allowed / ChargeJump / JumpChargeIsAllowed
|
||||
-> WeenieObj.CanJump(extent) [IWeenieObject +0x3C]
|
||||
-> WeenieObj.JumpStaminaCost(extent, out cost) [IWeenieObject +0x44]
|
||||
GetJumpVZ -> WeenieObj.InqJumpVelocity(extent, out vz) [+0x30]
|
||||
apply_run_to_command -> WeenieObj.InqRunRate(out rate) [+0x34]
|
||||
|
||||
ACCWeenieObject (thin delegation, pc 406512+)
|
||||
CanJump/JumpStaminaCost/InqRunRate/InqJumpVelocity/InqMaxRunRate all
|
||||
gate on IsThePlayer() first (0058c400/40/520/560/5a0) — NPCs/monsters/
|
||||
remote players never reach m_pQualities for these queries. Confirms P1
|
||||
is scoped correctly to PlayerWeenie only; RemoteWeenie is untouched.
|
||||
|
||||
CACQualities (the "qualities DB" == our PlayerWeenie, pc 412901-414050)
|
||||
InqLoad 0x0058f130 (pc 409756) — burden/load ratio
|
||||
CanJump 0x00591b50 — burden hard-gate
|
||||
JumpStaminaCost 0x00591b90 — stamina cost + PK flag
|
||||
InqRunRate 0x00592800 — full skill+vitae chain
|
||||
InqJumpVelocity 0x00592980 — mirrors InqRunRate for Jump
|
||||
|
||||
MovementSystem (pure formulas, pc 695958+)
|
||||
GetRunRate 0x006b0950
|
||||
GetJumpHeight 0x006b09b0
|
||||
JumpStaminaCost 0x006b0a40
|
||||
|
||||
EncumbranceSystem (pure formulas, pc 256393+)
|
||||
EncumbranceCapacity 0x004fcc00
|
||||
Load 0x004fcc40
|
||||
LoadMod 0x004fcc70
|
||||
```
|
||||
|
||||
## 2. InqLoad (0x0058f130, pc 409756) — FULLY READABLE
|
||||
|
||||
```c
|
||||
InqLoad(this, &loadOut):
|
||||
strength = InqAttribute(this, ATTRIBUTE_STRENGTH=1) // default 0xa if absent
|
||||
aug = InqInt(this, PROPERTY_INT_AUGMENTATION_INCREASED_CARRYING_CAPACITY=0xE6 /*230*/)
|
||||
capacity = EncumbranceSystem::EncumbranceCapacity(strength, aug)
|
||||
burden = InqInt(this, PROPERTY_INT_ENCUMBRANCE_VAL=5) // default 0 if absent
|
||||
*loadOut = EncumbranceSystem::Load(capacity, burden)
|
||||
return 1 // always succeeds for CACQualities (has vtable)
|
||||
```
|
||||
|
||||
This EXACTLY matches acdream's existing `IndicatorBarController.UpdateBurden()`
|
||||
/ `InventoryController.RefreshBurden()` pattern (Strength attribute + prop
|
||||
0xE6 aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the
|
||||
wire value is absent) — already ported, already correct, already tested via
|
||||
the UI. **`AcDream.Core.Items.BurdenMath`
|
||||
(`EncumbranceCapacity`/`LoadRatio`/`LoadModifier`) is the SAME formulas at
|
||||
the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for
|
||||
citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather
|
||||
than re-deriving — one source of truth, no drift between the burden HUD and
|
||||
movement physics.
|
||||
|
||||
## 3. CanJump (0x00591b50, pc 412907) — X87 MUSH, POLARITY RESOLVED BY PLAUSIBILITY
|
||||
|
||||
```c
|
||||
CanJump(this, extent):
|
||||
load = 0
|
||||
if (InqLoad(this, &load) != 0):
|
||||
p = <fcompp load, 2.0f; fnstsw; test ah,0x05> // "load < 2.0" per BN's own
|
||||
// asserted C0 subexpression
|
||||
if (!p) return 1
|
||||
return 0
|
||||
```
|
||||
|
||||
Literal BN reading: `if (!p) return 1` = "if load is NOT < 2.0 (i.e. >= 2.0),
|
||||
return CAN-jump; otherwise CANNOT". That is backwards from every other
|
||||
retail-movement fact we have (LoadMod's own floor sits at 2.0; the campaign's
|
||||
connected-matrix acceptance is "≥200% barely moves/jumps", not "can only jump
|
||||
when overloaded"). This is the documented BN "bitfield mush" artifact class
|
||||
(`feedback_bn_decomp_field_names.md`) — the flag-synthesis is unreliable for
|
||||
the FOLLOWING `test ah,mask` interpretation even when the preceding
|
||||
subexpression is trustworthy.
|
||||
|
||||
**Resolution (register row UN-8, see §6):** `CanJump` returns `load < 2.0`
|
||||
(can jump under 200% burden; refused at/above it) — the polarity a normal
|
||||
AC player's lived experience requires, and the one that makes CanJump's own
|
||||
threshold coincide with `LoadMod`'s floor. ACE gives no tiebreaker
|
||||
(`WeenieObject.CanJump` is an unconditional `return true` stub — never
|
||||
ported burden gating at all). Ghidra MCP was down for this slice; flagged
|
||||
for a future confirmation pass, not blocking this port.
|
||||
|
||||
## 4. JumpStaminaCost (0x00591b90, pc 412949) — FULLY READABLE
|
||||
|
||||
```c
|
||||
CACQualities::JumpStaminaCost(this, extent, &costOut):
|
||||
load = 0
|
||||
if (InqLoad(this, &load) == 0) return 0
|
||||
pk = 0
|
||||
pkStatus = InqInt(this, PROPERTY_INT_PLAYER_KILLER_STATUS=0x86, default=8)
|
||||
if (pkStatus == 4 || pkStatus == 0x40): // PK / PKLite
|
||||
pkTimestamp = InqFloat(this, PROPERTY_FLOAT_LAST_PK_ATTACK_TIMESTAMP=0x91)
|
||||
if (pkTimestamp is present && !(pkTimestamp + 20.0 < Timer::cur_time)):
|
||||
pk = 1 // PK timer active (<20s since last PK act)
|
||||
*costOut = MovementSystem::JumpStaminaCost(extent, load, pk)
|
||||
return 1 // ALWAYS true once InqLoad succeeds — no affordability
|
||||
// check lives in this function.
|
||||
```
|
||||
|
||||
**Key finding:** retail's `CanQualities::JumpStaminaCost` NEVER returns false
|
||||
(except when `InqLoad` itself fails, which doesn't happen for a real player).
|
||||
`jump_is_allowed`'s `if (!WeenieObj.JumpStaminaCost(...)) return 0x47` branch
|
||||
(the "refusal" path our own `MotionInterpreter.cs` already ports verbatim,
|
||||
W0-pins.md A2) is real retail *machinery*, but `CACQualities` never actually
|
||||
exercises the refusing side of it. **"Refused jump" does not happen via this
|
||||
mechanism in retail — only "weak jump" (see §5).** P1 ports
|
||||
`JumpStaminaCost` to always return `true` with the REAL computed cost
|
||||
(retiring the TS-5 zero-cost stub), matching this decomp exactly.
|
||||
|
||||
The `pk` flag is `PlayerKillerStatus`/`LastPkAttackTimestamp` — TS-23's
|
||||
exact scope (P3, not P1). P1 hardcodes `pk: false` at the one new call site
|
||||
(`PlayerWeenie.JumpStaminaCost`) and documents the dependency against TS-23
|
||||
rather than re-implementing PK parsing here.
|
||||
|
||||
## 5. InqRunRate (0x00592800, pc 413824) / InqJumpVelocity (0x00592980, pc 413902) — FULLY READABLE
|
||||
|
||||
Both functions share one shape (Run uses skill id 0x18=24, Jump uses 0x16=22):
|
||||
|
||||
```c
|
||||
InqRunRate(this, &rateOut):
|
||||
load = 1.0
|
||||
if (InqLoad(this, &load) == 0) return 0
|
||||
|
||||
currentStamina = 0
|
||||
if (AttributeCache::InqAttribute2nd(attribCache, ATTR2ND_STAMINA=4, ¤tStamina) == 0)
|
||||
return 0
|
||||
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
|
||||
|
||||
if (currentStamina == 0) skill = 0 // THE stamina-gates-movement mechanism
|
||||
|
||||
*rateOut = MovementSystem::GetRunRate(load, skill, 1.0)
|
||||
return 1
|
||||
```
|
||||
|
||||
`InqJumpVelocity` is identical but for skill id 0x16=22, and finishes with
|
||||
`sqrt(MovementSystem::GetJumpHeight(load, skill, extent, 1.0) * 19.6)` (pc
|
||||
413975, matching `GetJumpVZ`'s existing sqrt call already in
|
||||
`MotionInterpreter.cs`/`PlayerWeenie.cs` — unchanged).
|
||||
|
||||
**Answering the plan's question — "which skill level does retail feed?"**
|
||||
Neither raw base nor a separately-cached value: retail re-derives, on every
|
||||
query, `EnchantSkill(baseSkill)` where `EnchantSkill` (`CEnchantmentRegistry::
|
||||
EnchantSkill` 0x005947b0, pc 416240, FULLY READABLE) is:
|
||||
|
||||
```c
|
||||
EnchantSkill(registry, skillId, &valueInOut):
|
||||
value = *valueInOut // base skill (formulaBonus+init+ranks)
|
||||
if (registry._vitae != null):
|
||||
value = Enchant(registry._vitae, value) // vitae multiplier FIRST
|
||||
matching = CullEnchantmentsFromList(mult_list, category=SKILL=0x10, skillId)
|
||||
++ CullEnchantmentsFromList(add_list, category=SKILL=0x10, skillId)
|
||||
for each e in matching: value = Enchant(e, value) // per-record mult OR add
|
||||
if (value < 0.5) value = 0 // floor
|
||||
*valueInOut = (int)value // truncate (ftol2)
|
||||
return ...
|
||||
```
|
||||
|
||||
`CEnchantmentRegistry::EnchantAttribute2nd` (0x00594670, pc 416169, the
|
||||
vitals path our `EnchantmentMath.GetMod` already ports for
|
||||
`LocalPlayerState.GetMaxApprox`) applies `_vitae` in the **identical**
|
||||
position (first, before the mult/add lists) — confirming our existing vitae
|
||||
representation (`ActiveEnchantmentRecord.Bucket == 4`, a StatModType `Vitae`
|
||||
flag `0x00800000` classified in `GameEventWiring.ClassifyLiveEnchantmentBucket`)
|
||||
is the right vehicle: **P1 reuses it unmodified**, adding a sibling
|
||||
`EnchantmentMath.GetSkillMod` (filtered by `StatModType & Skill(0x10) != 0`
|
||||
instead of the vitals' implicit attribute2nd filter) rather than inventing a
|
||||
new vitae channel. This satisfies "vitae/enchant-adjusted effective run/jump
|
||||
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.
|
||||
|
||||
## 6. GetRunRate / GetJumpHeight / JumpStaminaCost formula bodies (MovementSystem, pc 695958+)
|
||||
|
||||
`GetRunRate` (0x006b0950) and the `arg3!=0` (PK) branch of `JumpStaminaCost`
|
||||
(0x006b0a40) have their GENERAL-CASE arithmetic entirely dropped by BN (only
|
||||
the `EncumbranceSystem::LoadMod`/`800`-skill-cap calls and the `arg3==0`
|
||||
ceil expression survive uncollapsed — the same information-loss class as the
|
||||
x87 mush, just total rather than partial). **ACE is the cross-reference
|
||||
tiebreaker for those two spots** (`references/ACE/Source/ACE.Server/Physics/
|
||||
Animation/MovementSystem.cs`), matching this exact acdream port's ORIGINAL
|
||||
citation style (`PlayerWeenie.cs`'s pre-P1 doc comments already said
|
||||
"decompiled + ACE MovementSystem" for these two formulas — nothing new here,
|
||||
just now with a named-decomp address alongside):
|
||||
|
||||
- `GetRunRate(load, skill, scaling) = skill>=800 ? 18/4 : ((LoadMod(load) * (skill/(skill+200)*11) + 4) / scaling) / 4` —
|
||||
matches acdream's pre-existing `PlayerWeenie.GetRunRate` exactly (which
|
||||
hardcoded `scaling=1`); the true retail signature carries a 3rd
|
||||
`scaling` arg (confirmed by the decomp's own function signature), and
|
||||
every known call site (`InqMaxRunRate`, `InqRunRate`) passes `1f` — so
|
||||
porting the full signature is free (no behavior change), just closer to
|
||||
the retail surface for future callers.
|
||||
- `GetJumpHeight(load, skill, extent, scaling)` — BN's extent-clamp
|
||||
micro-branch (pc 006b09b0-006b09ca) is the SAME x87-mush pattern as §3;
|
||||
ACE's `Math.Clamp(extent, 0, 1)` is the tiebreaker (matches the EXISTING
|
||||
acdream code, which already does this — unchanged).
|
||||
`= LoadMod(load) * (skill/(skill+1300)*22.2 + 0.05) * clampedExtent / scaling`,
|
||||
floored at 0.35 — matches acdream's pre-existing formula exactly.
|
||||
- `JumpStaminaCost(power, load, pk)`:
|
||||
- `pk==0`: `ceil((load + 0.5) * power * 8 + 2)` — **the campaign plan's
|
||||
own shorthand ("ceil((power+0.5)*load*8+2)") has the `+0.5` term on the
|
||||
wrong operand; the verbatim decomp (fully readable, no mush) is
|
||||
`(load + 0.5) * power`, confirmed against ACE's identical
|
||||
`(burden + 0.5f) * power`.**
|
||||
- `pk!=0`: BN drops the body entirely (bare `_ftol2()` tailcall, no
|
||||
operands survive); ACE's `(int)((power + 1.0f) * 100.0f)` is the
|
||||
tiebreaker. Unused by P1 (`pk` is hardcoded `false` — see §4), ported
|
||||
anyway for signature completeness/citation.
|
||||
- `EncumbranceSystem::{EncumbranceCapacity, Load, LoadMod}` (0x004fcc00/40/70,
|
||||
pc 256393+) — already verbatim-ported as `AcDream.Core.Items.BurdenMath`;
|
||||
P1's `EncumbranceSystem` delegates (see §2).
|
||||
|
||||
## 7. What "weak jump" actually is (no hard refusal exists)
|
||||
|
||||
Given §4 (JumpStaminaCost never refuses) and §5 (stamina==0 zeroes the
|
||||
EFFECTIVE skill, not the extent), the retail zero-stamina jump is:
|
||||
`GetJumpHeight(load, skill=0, extent, 1) = LoadMod(load) * 0.05 * extent`,
|
||||
floored to the 0.35 m minimum by the function's own clamp — i.e. **every
|
||||
jump attempt, however exhausted, still produces at least the 0.35 m floor
|
||||
hop.** There is no code path in `CACQualities` that makes `jump_is_allowed`
|
||||
return `GeneralMovementFailure` due to low stamina. The campaign plan's
|
||||
"weak/refused jump" acceptance phrasing is satisfied by "weak" (the floor
|
||||
hop); "refused" does not occur via burden/stamina in this chain and P1 does
|
||||
not invent it.
|
||||
|
||||
## 8. ReportExhaustion — wiring the dead R3-W4 seam
|
||||
|
||||
`ReportExhaustion()` (`MotionInterpreter.cs:1619`, already a full verbatim
|
||||
port of `CMotionInterp::ReportExhaustion` 0x005288d0) has ZERO callers
|
||||
anywhere in the codebase today. Retail's caller chain is
|
||||
`CPhysicsObj::report_exhaustion` (0x0050fdd0) →
|
||||
`MovementManager::ReportExhaustion` (0x00524360), both outside
|
||||
`CMotionInterp`'s scope and not yet located precisely in the decomp
|
||||
(out of P1's bounded scope to hunt down the exact upstream trigger site).
|
||||
What we DO know precisely: its effect is "re-apply current movement through
|
||||
the SAME dual-dispatch predicate as `apply_current_movement`" — i.e. force a
|
||||
fresh `WeenieObj.InqRunRate`/`InqJumpVelocity` query against the CURRENT
|
||||
physics/interpreted state, with no new input event.
|
||||
|
||||
That is exactly the primitive needed to make a live burden/stamina/vitae
|
||||
change visible immediately (mid-run, mid-charge) instead of waiting for the
|
||||
next keypress. **P1 wires `ReportExhaustion()` as the "re-evaluate movement
|
||||
now" trampoline any time Runtime pushes a fresh burden, stamina, or
|
||||
vitae-adjusted-skill value into the active `PlayerMovementController`** —
|
||||
plausible given `ReportExhaustion`'s documented purpose, and the least
|
||||
speculative real consumer available for a seam that otherwise never fires.
|
||||
|
||||
## 9. Design: where each input is computed and pushed
|
||||
|
||||
```
|
||||
Runtime (AcDream.Runtime, presentation-free):
|
||||
RuntimeCharacterState
|
||||
- Spellbook (existing) -- vitae + skill enchantments live here
|
||||
- MovementSkills: RuntimeMovementSkillState (existing, EXTENDED)
|
||||
RunSkill / JumpSkill -- now the ADJUSTED (EnchantSkill'd) values
|
||||
Burden (float, new) -- InqLoad's load ratio
|
||||
CurrentStamina (int, new, -1 sentinel = unknown/don't-gate)
|
||||
- _runSkillBase / _jumpSkillBase (new, private) -- pre-EnchantSkill values
|
||||
- UpdateMovementSkillBase(runBase, jumpBase) -- stores base, recomputes+pushes adjusted
|
||||
- RecomputeMovementSkills() -- base * EnchantmentMath.GetSkillMod(skillId), floor/round
|
||||
- wired: Spellbook.EnchantmentsChanged -> RecomputeMovementSkills (vitae/buff changes
|
||||
recompute WITHOUT a fresh PD skill push)
|
||||
|
||||
LiveSessionEventRouter.Attach() (cross-owner wiring hub; already the home
|
||||
of the existing onSkillsUpdated -> MovementSkills.Update plumbing)
|
||||
- onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...)
|
||||
- NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved,
|
||||
ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength)
|
||||
-> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal,
|
||||
SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden)
|
||||
-> character.Character.MovementSkills.UpdateBurden(ratio)
|
||||
- NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina)
|
||||
-> character.Character.MovementSkills.UpdateStamina(current)
|
||||
- all three trigger points additionally invoke the new
|
||||
LiveCharacterSessionBindings.OnMovementStatsUpdated callback
|
||||
|
||||
RuntimeMovementSkillProjection.ApplyTo(skills, controller) (existing seam,
|
||||
called at construction AND reactively from OnSkillsUpdated/OnMovementStatsUpdated)
|
||||
- SetCharacterSkills(run, jump) (existing)
|
||||
- NEW: SetCharacterBurden(burden), SetCharacterStamina(stamina)
|
||||
|
||||
App (LiveSessionRuntimeFactory) / Headless (HeadlessSessionHost):
|
||||
- OnMovementStatsUpdated: App wires ApplyTo(...) + controller.Motion.ReportExhaustion()
|
||||
(mirrors the existing OnSkillsUpdated body, which P1 ALSO extends with
|
||||
the ReportExhaustion() call for consistency); Headless passes null,
|
||||
matching its existing OnSkillsUpdated: null (headless bots don't need
|
||||
live mid-session re-apply to a controller that may not exist yet).
|
||||
|
||||
Core (AcDream.Core.Physics, presentation-free, pure):
|
||||
EncumbranceSystem -- EncumbranceCapacity/Load/LoadMod, delegates to BurdenMath
|
||||
MovementSystem -- GetRunRate/GetJumpHeight/JumpStaminaCost/GetJumpPower
|
||||
PlayerWeenie (CACQualities-shaped)
|
||||
_burden (float), _currentStamina (int?, null=unknown) -- pushed via
|
||||
SetBurden/SetStamina (SetBurden already existed, wires the dead setter)
|
||||
_runSkill/_jumpSkill (int) -- pushed via SetSkills, ALREADY vitae/enchant-
|
||||
adjusted by Runtime before it arrives here (PlayerWeenie itself stays
|
||||
a pure formula consumer -- no Spellbook/enchantment dependency, keeping
|
||||
it trivially testable)
|
||||
CanJump(extent) -> _burden < 2.0 (UN-8 polarity, §3)
|
||||
JumpStaminaCost(extent, out cost)
|
||||
-> cost = MovementSystem.JumpStaminaCost(extent, _burden, pk:false);
|
||||
return true; (§4 -- always true, TS-23 owns pk)
|
||||
InqRunRate(out rate) -> effSkill = _currentStamina == 0 ? 0 : _runSkill;
|
||||
rate = MovementSystem.GetRunRate(_burden, effSkill, 1f);
|
||||
InqJumpVelocity(extent, out vz)
|
||||
-> effSkill = _currentStamina == 0 ? 0 : _jumpSkill;
|
||||
vz = sqrt(MovementSystem.GetJumpHeight(_burden, effSkill, extent, 1f) * 19.6f);
|
||||
```
|
||||
|
||||
`_currentStamina == null` (never set — matches every existing test /
|
||||
call site that doesn't call `SetStamina`) never zeroes the skill, preserving
|
||||
every pre-P1 `PlayerWeenieTests.cs` expectation unchanged.
|
||||
|
||||
## 10. Register bookkeeping (same commit as the port)
|
||||
|
||||
- **Delete TS-5** (`CanJump` always true / `JumpStaminaCost` zero-cost stub) —
|
||||
retired: both now real, decomp-cited.
|
||||
- **Delete AP-25** (run/jump skill = attributeBonus+init+ranks only, no
|
||||
vitae) — retired: vitae now flows through `EnchantmentMath.GetSkillMod`.
|
||||
- **TS-21 untouched** — still valid (pre-PD fallback defaults 200/300 are a
|
||||
separate divergence, not addressed by P1).
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
## 11. Test plan
|
||||
|
||||
- `MovementSystemTests` (new, Core): golden tables for `GetRunRate` (0/200/
|
||||
800 skill, load knees), `GetJumpHeight` (extent 0/0.5/1, 0.35 floor,
|
||||
load knees), `JumpStaminaCost` (ceil rounding, load/power sweep),
|
||||
`GetJumpPower` (inverse sanity, not consumed by P1 but ported for
|
||||
signature completeness / future charge-meter work).
|
||||
- `EncumbranceSystemTests` (new, Core): capacity at 100%/200% aug clamp,
|
||||
Load ratio, LoadMod knees — cross-checked 1:1 against the EXISTING
|
||||
`BurdenMath` tests (same formulas, must agree bit-for-bit).
|
||||
- `PlayerWeenieTests` (extend): CanJump refusal at load>=2.0 / allowed
|
||||
below; JumpStaminaCost real nonzero cost; InqRunRate/InqJumpVelocity
|
||||
zero at stamina==0 (skill forced to 0, still floors at 0.35 m for jump);
|
||||
ALL pre-existing tests must stay green unmodified (no SetStamina call ->
|
||||
null sentinel -> no gating, exactly today's behavior).
|
||||
- `EnchantmentMathTests` (extend): `GetSkillMod` type-flag filtering
|
||||
(Skill-flagged records match; vital-only records with a colliding
|
||||
numeric key do NOT), vitae-first-then-mult-then-add ordering.
|
||||
- `RuntimeCharacterStateTests` / `RuntimeMovementSkillStateTests` (Runtime):
|
||||
burden/stamina push + revision bump; `RecomputeMovementSkills` fires on
|
||||
`Spellbook.EnchantmentsChanged` without a fresh base push; ResetSession
|
||||
convergence includes Burden==0/CurrentStamina==-1.
|
||||
- `LiveSessionEventRouterTests` (Runtime, if a harness exists) or a focused
|
||||
new test: ObjectTable burden-trigger events recompute and push burden;
|
||||
Stamina vital change pushes CurrentStamina.
|
||||
Loading…
Add table
Add a link
Reference in a new issue