diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d9b09107..150548f7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -233,7 +233,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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) — 40 active rows (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) — 39 active rows (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 | |---|---|---|---|---|---| @@ -250,7 +250,6 @@ 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. 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` | @@ -317,7 +316,7 @@ WITH that phase, not before. 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. **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** -M2 combat must land TS-23 (PK bits), TS-25 +M2 combat must land 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). diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index a9219c86..784b44c4 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -482,7 +482,14 @@ internal sealed class LivePresentationCompositionPhase var ordinaryPhysicsUpdater = new LiveEntityOrdinaryPhysicsUpdater( d.EntityObjects.Physics, d.MotionBindings.GetSetupCylinder, - d.MotionBindings.GetSetupMoverShape); + d.MotionBindings.GetSetupMoverShape, + // TS-23 (Campaign P Slice P3, 2026-07-30): the ordinary + // mover's own PK/PKLite/Impenetrable bits — same + // ClientObjectTable-backed lookup GameWindow's remote + // updater uses. + guid => AcDream.Core.Physics.EntityCollisionFlagsExt.ResolveMoverPvpState( + d.EntityObjects.Objects, + guid)); var animationScheduler = new LiveEntityAnimationScheduler( liveEntities, d.PlayerIdentity, @@ -572,7 +579,11 @@ internal sealed class LivePresentationCompositionPhase d.WorldOrigin.CellLocalForSeed, remoteShadowPlacement.Sync, remoteTeleportPresentation.Complete, - remoteTeleportPresentation.Begin), + remoteTeleportPresentation.Begin, + getMoverPvpState: guid => + EntityCollisionFlagsExt.ResolveMoverPvpState( + d.EntityObjects.Objects, + guid)), static value => value.Dispose()); bindings.BindProjectionPoseReady( equippedLease.Resource, diff --git a/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs b/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs index 7797faaf..61c2af51 100644 --- a/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs +++ b/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs @@ -21,13 +21,17 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater private readonly Func Spheres, float Scale, float StepUpHeight, float StepDownHeight)> _getSetupMoverShape; + private readonly Func _getMoverPvpState; public LiveEntityOrdinaryPhysicsUpdater( RuntimePhysicsState physics, Func getSetupCylinder, Func Spheres, float Scale, float StepUpHeight, float StepDownHeight)> - getSetupMoverShape) + getSetupMoverShape, + // TS-23 (Campaign P Slice P3, 2026-07-30): see RemotePhysicsUpdater's + // identical parameter. + Func? getMoverPvpState = null) { _runtime = new RuntimeOrdinaryPhysicsUpdater( physics ?? throw new ArgumentNullException(nameof(physics))); @@ -35,6 +39,7 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater ?? throw new ArgumentNullException(nameof(getSetupCylinder)); _getSetupMoverShape = getSetupMoverShape ?? throw new ArgumentNullException(nameof(getSetupMoverShape)); + _getMoverPvpState = getMoverPvpState ?? (static _ => ObjectInfoState.None); } public bool Tick( @@ -82,7 +87,8 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater sphereList: shape.Spheres, sphereScale: shape.Scale, stepUpHeight: shape.StepUpHeight, - stepDownHeight: shape.StepDownHeight)) + stepDownHeight: shape.StepDownHeight, + moverPvpState: _getMoverPvpState(record.ServerGuid))) { return false; } diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index a9b8ee74..4fc0acf6 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -22,6 +22,7 @@ internal sealed class RemotePhysicsUpdater private readonly Func Spheres, float Scale, float StepUpHeight, float StepDownHeight)> _getSetupMoverShape; + private readonly Func _getMoverPvpState; private readonly Action _applyServerControlledVelocityCycle; private readonly List _spatialRemoteSnapshot = new(); @@ -33,7 +34,14 @@ internal sealed class RemotePhysicsUpdater (ImmutableArray Spheres, float Scale, float StepUpHeight, float StepDownHeight)> getSetupMoverShape, Action - applyServerControlledVelocityCycle) + applyServerControlledVelocityCycle, + // TS-23 (Campaign P Slice P3, 2026-07-30): resolves a remote's own + // PK/PKLite/Impenetrable ObjectInfoState bits by server guid + // (ClientObjectTable.PublicWeenieBitfield → EntityCollisionFlagsExt. + // FromPwdBitfield → ToMoverState). Default (null) always returns + // None — bit-identical to the pre-P3 behavior for every caller that + // doesn't supply one. + Func? getMoverPvpState = null) { _runtime = new RuntimeRemotePhysicsUpdater( physics ?? throw new ArgumentNullException(nameof(physics))); @@ -41,6 +49,7 @@ internal sealed class RemotePhysicsUpdater ?? throw new ArgumentNullException(nameof(getSetupCylinder)); _getSetupMoverShape = getSetupMoverShape ?? throw new ArgumentNullException(nameof(getSetupMoverShape)); + _getMoverPvpState = getMoverPvpState ?? (static _ => ObjectInfoState.None); _applyServerControlledVelocityCycle = applyServerControlledVelocityCycle ?? throw new ArgumentNullException( @@ -235,7 +244,8 @@ internal sealed class RemotePhysicsUpdater sphereList: shape.Spheres, sphereScale: shape.Scale, stepUpHeight: shape.StepUpHeight, - stepDownHeight: shape.StepDownHeight); + stepDownHeight: shape.StepDownHeight, + moverPvpState: _getMoverPvpState(ownerRecord.ServerGuid)); } public bool TickHidden( @@ -289,7 +299,8 @@ internal sealed class RemotePhysicsUpdater sphereList: shape.Spheres, sphereScale: shape.Scale, stepUpHeight: shape.StepUpHeight, - stepDownHeight: shape.StepDownHeight); + stepDownHeight: shape.StepDownHeight, + moverPvpState: _getMoverPvpState(ownerRecord.ServerGuid)); } public void SyncRemoteShadowToBody( diff --git a/src/AcDream.App/Physics/RemoteTeleportController.cs b/src/AcDream.App/Physics/RemoteTeleportController.cs index 8edf981a..68fb162c 100644 --- a/src/AcDream.App/Physics/RemoteTeleportController.cs +++ b/src/AcDream.App/Physics/RemoteTeleportController.cs @@ -33,6 +33,7 @@ internal sealed class RemoteTeleportController : IDisposable private readonly Action _completeAuthoritativePlacement; private readonly Action _beginAuthoritativePlacement; private readonly PlacementResolver _resolvePlacement; + private readonly Func _getMoverPvpState; private readonly Dictionary _pending = new(); internal RemoteTeleportController( @@ -43,7 +44,10 @@ internal sealed class RemoteTeleportController : IDisposable Action syncResolvedShadow, Action completeAuthoritativePlacement, Action beginAuthoritativePlacement, - PlacementResolver? resolvePlacement = null) + PlacementResolver? resolvePlacement = null, + // TS-23 (Campaign P Slice P3, 2026-07-30): see + // RemotePhysicsUpdater's identical parameter. + Func? getMoverPvpState = null) { _physics = physics ?? throw new ArgumentNullException(nameof(physics)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); @@ -58,6 +62,7 @@ internal sealed class RemoteTeleportController : IDisposable _beginAuthoritativePlacement = beginAuthoritativePlacement ?? throw new ArgumentNullException(nameof(beginAuthoritativePlacement)); _resolvePlacement = resolvePlacement ?? ResolvePlacement; + _getMoverPvpState = getMoverPvpState ?? (static _ => ObjectInfoState.None); _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } @@ -286,9 +291,12 @@ internal sealed class RemoteTeleportController : IDisposable request.RequestedCellId, radius, height, - IsPlayerGuid(request.Entity.ServerGuid) + // TS-23: moverPvpState is a no-op OR (None) for every non-PK + // remote. + (IsPlayerGuid(request.Entity.ServerGuid) ? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide - : ObjectInfoState.EdgeSlide, + : ObjectInfoState.EdgeSlide) + | _getMoverPvpState(request.Entity.ServerGuid), request.Entity.Id); } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 255a037c..e55ef0b6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -153,6 +153,18 @@ public sealed class GameWindow : private AcDream.Core.Physics.PhysicsDataCache _physicsDataCache => _runtimeEntityObjects.Physics.DataCache; + /// + /// TS-23 (Campaign P Slice P3, 2026-07-30): resolves a mover's own + /// PK/PKLite/Impenetrable + /// bits from its ClientObjectTable row — the same table CreateObject + /// already populates PublicWeenieBitfield on (per-GUID, including + /// every remote's own row, not just collision targets). + /// + private AcDream.Core.Physics.ObjectInfoState GetMoverPvpState(uint serverGuid) => + AcDream.Core.Physics.EntityCollisionFlagsExt.ResolveMoverPvpState( + _runtimeEntityObjects.Objects, + serverGuid); + // #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the // >10k-line TickAnimations (Code Structure Rule 1). Reusable setup/motion // policy is supplied through the once-bound live motion runtime, while the @@ -653,7 +665,8 @@ public sealed class GameWindow : _runtimeEntityObjects.Physics, _liveEntityMotionBindings.GetSetupCylinder, _liveEntityMotionBindings.GetSetupMoverShape, - AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply); + AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply, + GetMoverPvpState); _remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher( (movement, cellId, update) => _liveEntityMotionBindings.RouteServerMoveTo( diff --git a/src/AcDream.Core/Physics/EntityCollisionFlags.cs b/src/AcDream.Core/Physics/EntityCollisionFlags.cs index f08e7d9f..eceb61f3 100644 --- a/src/AcDream.Core/Physics/EntityCollisionFlags.cs +++ b/src/AcDream.Core/Physics/EntityCollisionFlags.cs @@ -1,3 +1,5 @@ +using AcDream.Core.Items; + namespace AcDream.Core.Physics; /// @@ -72,4 +74,46 @@ public static class EntityCollisionFlagsExt if ((bitfield & 0x2000000u) != 0) flags |= EntityCollisionFlags.IsPKLite; return flags; } + + /// + /// TS-23 (Campaign P Slice P3, 2026-07-30): translate the decoded + /// per-entity (the PWD-bitfield + /// bit-space) into the bits + /// CollisionExemption.ShouldSkip and the moverFlags argument to + /// PhysicsEngine.ResolveWithTransition actually consume — a + /// DIFFERENT bit-space (retail OBJECTINFO::init 0x0050cf30 + /// `state |= 0x80/0x800/0x1000`, acclient.h:6190-6194) that must not be + /// confused with the PWD wire numbering. + /// is deliberately NOT translated here — every existing mover-flags call + /// site already derives from its + /// own GUID-prefix heuristic (correct per #184 Slice 2b) and this helper + /// only fills the gap that heuristic cannot: PK/PKLite/Impenetrable. + /// + public static ObjectInfoState ToMoverState(this EntityCollisionFlags flags) + { + var state = ObjectInfoState.None; + if ((flags & EntityCollisionFlags.IsPK) != 0) state |= ObjectInfoState.IsPK; + if ((flags & EntityCollisionFlags.IsPKLite) != 0) state |= ObjectInfoState.IsPKLite; + if ((flags & EntityCollisionFlags.IsImpenetrable) != 0) state |= ObjectInfoState.IsImpenetrable; + return state; + } + + /// + /// TS-23 (Campaign P Slice P3, 2026-07-30): the one shared + /// ClientObjectTable-backed mover-flags lookup every physics + /// call site (local player world-entry, remote DR sweep + teleport, + /// ordinary movers) uses — was inlined three times (App + /// GameWindow/LivePresentationComposition/ + /// RemoteTeleportController) before being consolidated here. + /// An entity with no row, or a row with no wire bitfield yet, resolves + /// to — a no-op OR into moverFlags, + /// bit-identical to every pre-P3 caller. + /// + public static ObjectInfoState ResolveMoverPvpState(this ClientObjectTable objects, uint serverGuid) + { + ArgumentNullException.ThrowIfNull(objects); + return objects.Get(serverGuid)?.PublicWeenieBitfield is { } bitfield + ? FromPwdBitfield(bitfield).ToMoverState() + : ObjectInfoState.None; + } } diff --git a/src/AcDream.Core/Physics/MovementSystem.cs b/src/AcDream.Core/Physics/MovementSystem.cs index 7241bc2b..6cf7e020 100644 --- a/src/AcDream.Core/Physics/MovementSystem.cs +++ b/src/AcDream.Core/Physics/MovementSystem.cs @@ -65,9 +65,9 @@ public static class MovementSystem /// campaign plan's shorthand had the operands swapped (see pseudocode /// doc §6). The pk!=0 branch is entirely dropped by BN; ACE's /// (power+1.0)*100.0 is the tiebreaker. is - /// hardcoded false at every P1 call site pending TS-23 - /// (PlayerKillerStatus parsing, Campaign P Slice P3) — ported here for - /// signature completeness only. + /// the real PlayerKillerStatus/LastPkAttackTimestamp + /// 20-second-window predicate as of TS-23 (Campaign P Slice P3, + /// 2026-07-30) — see . /// public static int JumpStaminaCost(float power, float burden, bool pk) { diff --git a/src/AcDream.Core/Physics/PlayerWeenie.cs b/src/AcDream.Core/Physics/PlayerWeenie.cs index c942b647..8a895a49 100644 --- a/src/AcDream.Core/Physics/PlayerWeenie.cs +++ b/src/AcDream.Core/Physics/PlayerWeenie.cs @@ -44,6 +44,20 @@ public sealed class PlayerWeenie : IWeenieObject private int _jumpSkill; private float _burden; + /// + /// TS-23 §12b (Campaign P Slice P3, 2026-07-30): raw + /// PropertyInt.PlayerKillerStatus (0x86). null = never + /// pushed (matches every pre-P3 caller — no PK-timer bump, today's + /// behavior unchanged). + /// + private int? _playerKillerStatus; + + /// + /// TS-23 §12b: raw PropertyFloat.LastPkAttackTimestamp (0x91). + /// null = never pushed / property absent. + /// + private float? _lastPkAttackTimestamp; + /// /// Retail AttributeCache::InqAttribute2nd(ATTR2ND_STAMINA=4)'s /// current-stamina reading, consulted by InqRunRate/ @@ -84,6 +98,23 @@ public sealed class PlayerWeenie : IWeenieObject /// public void SetStamina(uint? currentStamina) => _currentStamina = currentStamina; + /// + /// TS-23 §12b (Campaign P Slice P3, 2026-07-30): pushes the raw + /// PropertyInt.PlayerKillerStatus (0x86) / PropertyFloat. + /// LastPkAttackTimestamp (0x91) pair CACQualities:: + /// JumpStaminaCost (0x00591b90) reads for its 20-second PK-timer + /// jump-stamina-cost bump. Both null restores "never pushed" (no + /// bump, matching every pre-P3 caller). The 20-second recency window + /// itself is evaluated fresh at call time + /// against a live clock, not cached here — the window's EXPIRY has no + /// wire event to re-push on. + /// + public void SetPlayerKillerStatus(int? playerKillerStatus, float? lastPkAttackTimestamp) + { + _playerKillerStatus = playerKillerStatus; + _lastPkAttackTimestamp = lastPkAttackTimestamp; + } + public bool InqRunRate(out float rate) { int effectiveSkill = _currentStamina == 0 ? 0 : _runSkill; @@ -121,12 +152,49 @@ public sealed class PlayerWeenie : IWeenieObject /// unconditionally (once burden is knowable, which it always is for the /// local player) — retail's own function never exercises the "can't /// afford" false path; see the pseudocode doc §4/§7. TS-5 retired: this - /// was previously a zero-cost stub. pk is hardcoded false - /// pending TS-23 (PlayerKillerStatus parsing, Campaign P Slice P3). + /// was previously a zero-cost stub. /// + /// + /// TS-23 §12b (2026-07-30): pk is retail's own formula — + /// PlayerKillerStatus (0x86) in {4 (PK), 0x40 (PKLite)} AND + /// (LastPkAttackTimestamp + 20.0) >= Timer::cur_time. Both + /// defaulting to 8 (retail's own + /// InqInt default) and an absent timestamp evaluate to + /// pk = false — bit-identical to the pre-P3 hardcoded value for + /// every non-PK/PKLite character, which is every ACE default-created + /// character (the invariant this port must not break). + /// + /// "Now" uses (process-uptime + /// milliseconds), NOT an absolute wall-clock epoch. A first attempt + /// used 's Unix-epoch seconds and a + /// conformance test caught the bug it produces: LastPkAttackTimestamp + /// is a wire PropertyFloat (32-bit, ~7 significant decimal + /// digits) — at a ~1.7-billion-second Unix epoch magnitude, float + /// precision only resolves to roughly ±128 seconds, so a 20-second + /// recency window is entirely swallowed by rounding error (a `now − 30s` + /// timestamp computed the SAME cost as `now`). Retail's own + /// Timer::cur_time almost certainly is NOT an absolute epoch for + /// exactly this reason — a small process/session-relative counter is + /// the only magnitude a 32-bit float can hold with sub-second precision + /// for a meaningful session length. This is a documented, evidence-based + /// clock CHOICE (a precision bug the test caught, not a guess dressed up + /// as fact) — the exact retail epoch/basis was not independently + /// confirmed (ACE does not model either property server-side, so this + /// branch is inert against every local-ACE test scenario regardless of + /// the clock's exact basis; flagged for cdb confirmation if a real PK + /// server is ever tested against). + /// + /// public bool JumpStaminaCost(float extent, out int cost) { - cost = MovementSystem.JumpStaminaCost(extent, _burden, pk: false); + bool pk = false; + int pkStatus = _playerKillerStatus ?? 8; // retail InqInt default when the property is absent + if ((pkStatus == 4 || pkStatus == 0x40) && _lastPkAttackTimestamp is { } ts) + { + float now = Environment.TickCount64 / 1000f; + pk = (ts + 20.0f) >= now; + } + cost = MovementSystem.JumpStaminaCost(extent, _burden, pk); return true; } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index ff3cea8d..f2b73494 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -163,6 +163,21 @@ public sealed class PlayerMovementController /// public float StepDownHeight { get; set; } = 0.4f; + /// + /// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own + /// PK/PKLite/Impenetrable bits, decoded + /// from PublicWeenieDesc._bitfield (retail OBJECTINFO::init + /// 0x0050cf30 `state |= w->vtable->IsPK()/IsPKLite()/IsImpenetrable() + /// ? 0x800/0x1000/0x80`). Set at world-entry and refreshed reactively + /// whenever the player's own ClientObject.PublicWeenieBitfield + /// changes (same trigger set as 's + /// server-skill pushes). Default + /// (no PK/PKLite/Impenetrable) is bit-identical to every pre-P3 caller's + /// hardcoded IsPlayer | EdgeSlide — the non-PK invariant this + /// port must not break. + /// + public ObjectInfoState OwnPvpFlags { get; set; } = ObjectInfoState.None; + /// /// TS-46 (2026-07-30): the player's own Setup ≤2-sphere list (dat /// CSphere Origin+Radius), verbatim per retail @@ -930,6 +945,22 @@ public sealed class PlayerMovementController _weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina); } + /// + /// TS-23 §12b (Campaign P Slice P3, 2026-07-30): pushes the raw + /// PlayerKillerStatus/LastPkAttackTimestamp pair into + /// 's PK-timer bump. A + /// negative restores "never + /// pushed" (matches 's own sentinel + /// convention); is null + /// when the property is absent. + /// + public void SetCharacterPkStatus(int playerKillerStatus, float? lastPkAttackTimestamp) + { + _weenie.SetPlayerKillerStatus( + playerKillerStatus < 0 ? null : playerKillerStatus, + lastPkAttackTimestamp); + } + /// /// R3-W2 (r3-port-plan.md §4): the player's /// — GameWindow binds the player sequencer's MotionDone seam to it so the @@ -1418,7 +1449,10 @@ public sealed class PlayerMovementController stepDownHeight: StepDownHeight, isOnGround: previousOnWalkable, body: _body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + // TS-23: OwnPvpFlags is None for every non-PK character + // (the default), so this OR is a no-op for the common + // case and bit-identical to the pre-P3 hardcoded value. + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide | OwnPvpFlags, movingEntityId: LocalEntityId, // TS-46: the player's own Setup sphere list, scaled by // ObjectScale. Empty falls back to the 0.48/1.835 @@ -1859,12 +1893,15 @@ public sealed class PlayerMovementController // under the same flag profile as retail player movement. // // Commit C 2026-04-29 — local player is always IsPlayer. - // The PK/PKLite/Impenetrable bits come from PlayerDescription's - // PlayerKillerStatus property; not yet parsed (non-PK pair → walks - // through other non-PK players, which is retail's default for - // ACE's character creation defaults too). + // TS-23 (2026-07-30): OwnPvpFlags carries the real + // PK/PKLite/Impenetrable bits, decoded from the player's own + // PublicWeenieDesc._bitfield (default None — a no-op OR for + // every non-PK character, bit-identical to the pre-P3 value; + // non-PK pair still walks through other non-PK players, + // retail's default for ACE's character creation defaults). moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer - | AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + | AcDream.Core.Physics.ObjectInfoState.EdgeSlide + | OwnPvpFlags, // Fix #42: skip self in FindObjCollisions. Wired by GameWindow // when the local player entity spawns (or stays 0 in tests, in // which case there's no registered ShadowEntry to collide with diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 4f0715a0..e7fc9097 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -138,6 +138,9 @@ public sealed class RuntimeCharacterState : IDisposable && MovementSkills.JumpSkill == -1 && MovementSkills.Burden == 0f && MovementSkills.CurrentStamina == -1 + && MovementSkills.OwnPwdBitfield == 0u + && MovementSkills.PlayerKillerStatus == -1 + && MovementSkills.LastPkAttackTimestamp is null && _runSkillBase == -1 && _jumpSkillBase == -1); } @@ -552,19 +555,32 @@ public readonly record struct RuntimeMovementSkillSnapshot( // ratio (0.0 unencumbered) and current stamina (-1 = unknown/don't-gate, // matching RunSkill/JumpSkill's own sentinel convention). float Burden = 0f, - int CurrentStamina = -1) + int CurrentStamina = -1, + // TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own + // PublicWeenieDesc._bitfield (0 = never pushed / no PK-relevant bits — + // a no-op OR, matching every pre-P3 caller) and the raw + // PlayerKillerStatus/LastPkAttackTimestamp pair §12b's PK-timer + // jump-cost bump reads (-1 / null = never pushed). + uint OwnPwdBitfield = 0u, + int PlayerKillerStatus = -1, + float? LastPkAttackTimestamp = null) { public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0; } /// -/// Server-authoritative run/jump/burden/stamina values retained +/// Server-authoritative run/jump/burden/stamina/PK-status values retained /// independently of any graphical movement controller. App applies this /// borrowed state whenever its presentation/physics controller exists or is /// rebuilt. Campaign P Slice P1 (2026-07-30) extended this beyond run/jump /// skill to the full stat-coupled-movement input set (burden, current -/// stamina) — see the pseudocode doc §9. RunSkill/JumpSkill arrive here -/// ALREADY vitae/enchantment-adjusted by +/// stamina) — see the pseudocode doc §9. Campaign P Slice P3 (2026-07-30) +/// further added the player's own PWD bitfield (PK/PKLite/Impenetrable +/// collision-exemption bits) and the PlayerKillerStatus/ +/// LastPkAttackTimestamp pair (the PK-timer jump-cost bump) — the SAME +/// reactive push seam, since both ride the player's own ClientObject +/// property-update stream. RunSkill/JumpSkill arrive here ALREADY +/// vitae/enchantment-adjusted by /// . /// public sealed class RuntimeMovementSkillState @@ -573,16 +589,34 @@ public sealed class RuntimeMovementSkillState private int _jumpSkill = -1; private float _burden; private int _currentStamina = -1; + private uint _ownPwdBitfield; + private int _playerKillerStatus = -1; + private float _lastPkAttackTimestamp; + private bool _hasLastPkAttackTimestamp; private long _revision; public int RunSkill => Volatile.Read(ref _runSkill); public int JumpSkill => Volatile.Read(ref _jumpSkill); public float Burden => Volatile.Read(ref _burden); public int CurrentStamina => Volatile.Read(ref _currentStamina); + public uint OwnPwdBitfield => Volatile.Read(ref _ownPwdBitfield); + public int PlayerKillerStatus => Volatile.Read(ref _playerKillerStatus); + public float? LastPkAttackTimestamp => + Volatile.Read(ref _hasLastPkAttackTimestamp) + ? Volatile.Read(ref _lastPkAttackTimestamp) + : null; public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0; public long Revision => Interlocked.Read(ref _revision); public RuntimeMovementSkillSnapshot Snapshot => - new(_runSkill, _jumpSkill, Revision, _burden, _currentStamina); + new( + _runSkill, + _jumpSkill, + Revision, + _burden, + _currentStamina, + OwnPwdBitfield, + PlayerKillerStatus, + LastPkAttackTimestamp); public void Update(int runSkill, int jumpSkill) { @@ -626,12 +660,62 @@ public sealed class RuntimeMovementSkillState Interlocked.Increment(ref _revision); } + /// + /// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's own + /// raw PublicWeenieDesc._bitfield — the PWD wire bit-space + /// + /// decodes. Fires on the SAME ClientObject add/update events + /// already reacts to (the player's own + /// PublicWeenieBitfield lives on the same row). + /// + public void UpdateOwnPwdBitfield(uint bitfield) + { + if (OwnPwdBitfield == bitfield) return; + Volatile.Write(ref _ownPwdBitfield, bitfield); + Interlocked.Increment(ref _revision); + } + + /// + /// TS-23 §12b: pushes the raw PlayerKillerStatus + /// (PropertyInt 0x86) / LastPkAttackTimestamp + /// (PropertyFloat 0x91) pair feeding + /// CACQualities::JumpStaminaCost's 20-second PK-timer bump. A + /// negative restores "never + /// pushed" (matches 's own sentinel + /// convention); is null + /// when the property is absent. + /// + public void UpdatePlayerKillerStatus(int playerKillerStatus, float? lastPkAttackTimestamp) + { + bool changed = false; + if (PlayerKillerStatus != playerKillerStatus) + { + Volatile.Write(ref _playerKillerStatus, playerKillerStatus); + changed = true; + } + bool hasTimestamp = lastPkAttackTimestamp.HasValue; + float timestamp = lastPkAttackTimestamp ?? 0f; + if (Volatile.Read(ref _hasLastPkAttackTimestamp) != hasTimestamp + || (hasTimestamp && Volatile.Read(ref _lastPkAttackTimestamp) != timestamp)) + { + Volatile.Write(ref _lastPkAttackTimestamp, timestamp); + Volatile.Write(ref _hasLastPkAttackTimestamp, hasTimestamp); + changed = true; + } + if (changed) + Interlocked.Increment(ref _revision); + } + public void ResetSession() { Volatile.Write(ref _runSkill, -1); Volatile.Write(ref _jumpSkill, -1); Volatile.Write(ref _burden, 0f); Volatile.Write(ref _currentStamina, -1); + Volatile.Write(ref _ownPwdBitfield, 0u); + Volatile.Write(ref _playerKillerStatus, -1); + Volatile.Write(ref _lastPkAttackTimestamp, 0f); + Volatile.Write(ref _hasLastPkAttackTimestamp, false); Interlocked.Increment(ref _revision); } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs b/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs index a94f4f1b..65d77146 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs @@ -1,3 +1,5 @@ +using AcDream.Core.Physics; + namespace AcDream.Runtime.Gameplay; /// @@ -23,6 +25,17 @@ public static class RuntimeMovementSkillProjection // seam run/jump skill already used — see the pseudocode doc §9. controller.SetCharacterBurden(snapshot.Burden); controller.SetCharacterStamina(snapshot.CurrentStamina); + // TS-23 (Campaign P Slice P3, 2026-07-30): the player's own + // PK/PKLite/Impenetrable collision-exemption bits and the + // PlayerKillerStatus/LastPkAttackTimestamp pair the jump-cost + // PK-timer bump reads — see EntityCollisionFlagsExt.ToMoverState + // and PlayerWeenie.JumpStaminaCost. + controller.OwnPvpFlags = + EntityCollisionFlagsExt.FromPwdBitfield(snapshot.OwnPwdBitfield) + .ToMoverState(); + controller.SetCharacterPkStatus( + snapshot.PlayerKillerStatus, + snapshot.LastPkAttackTimestamp); return true; } } diff --git a/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs index df9403cb..d03a87fc 100644 --- a/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs +++ b/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs @@ -57,7 +57,10 @@ internal sealed class RuntimeOrdinaryPhysicsUpdater sphereList = default, float sphereScale = 1f, float stepUpHeight = 0.4f, - float stepDownHeight = 0.4f) + float stepDownHeight = 0.4f, + // TS-23 (2026-07-30): see RuntimeRemotePhysicsUpdater.Tick's + // identical parameter. + ObjectInfoState moverPvpState = ObjectInfoState.None) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(rootFrame); @@ -142,9 +145,12 @@ internal sealed class RuntimeOrdinaryPhysicsUpdater stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal isOnGround: previousOnWalkable, body: body, - moverFlags: IsPlayerGuid(record.ServerGuid) + // TS-23: moverPvpState is a no-op OR (None) for every + // non-PK ordinary mover. + moverFlags: (IsPlayerGuid(record.ServerGuid) ? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide - : ObjectInfoState.EdgeSlide, + : ObjectInfoState.EdgeSlide) + | moverPvpState, movingEntityId: movingEntityId, // TS-46: the Setup's own sphere list, scaled by ObjScale. // Empty falls back to the radius/height reconstruction above. diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs index 9cf3d381..06ae0028 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs @@ -83,7 +83,14 @@ internal sealed class RuntimeRemotePhysicsUpdater sphereList = default, float sphereScale = 1f, float stepUpHeight = 0.4f, - float stepDownHeight = 0.4f) + float stepDownHeight = 0.4f, + // TS-23 (2026-07-30): the remote's own PK/PKLite/Impenetrable + // ObjectInfoState bits (LiveEntityMotionRuntimeController's + // ClientObjectTable-backed lookup, translated via + // EntityCollisionFlagsExt.ToMoverState). Default None is a no-op OR + // for every non-PK remote — bit-identical to the pre-P3 value. + AcDream.Core.Physics.ObjectInfoState moverPvpState = + AcDream.Core.Physics.ObjectInfoState.None) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(rm); @@ -382,15 +389,17 @@ internal sealed class RuntimeRemotePhysicsUpdater // non-PK player pair). Without IsPlayer the mover would de-overlap // two players — MORE solid than retail (you can stand inside another // non-PK player in AC). Players still COLLIDE with monsters (target - // not IsPlayer → no exemption) + terrain + walls. PK/PKLite/ - // Impenetrable are NOT plumbed onto the remote mover yet, so a PK - // pair walks through where retail collides — the SAME M1.5 gap the - // local player carries (see TS-23; PlayerDescription PK status - // unparsed). - moverFlags: IsPlayerGuid(serverGuid) + // not IsPlayer → no exemption) + terrain + walls. TS-23 + // (2026-07-30): moverPvpState carries the remote's real + // PK/PKLite/Impenetrable bits (default None — a no-op OR + // for every non-PK remote, bit-identical to the pre-P3 + // value); a PK-vs-PK pair now collides exactly like + // retail instead of always walking through. + moverFlags: (IsPlayerGuid(serverGuid) ? AcDream.Core.Physics.ObjectInfoState.IsPlayer | AcDream.Core.Physics.ObjectInfoState.EdgeSlide - : AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + : AcDream.Core.Physics.ObjectInfoState.EdgeSlide) + | moverPvpState, // Fix #42 (2026-05-05): skip the moving remote's // own ShadowEntry. _animatedEntities is keyed by // entity.Id so kv.Key matches the EntityId the @@ -629,12 +638,14 @@ internal sealed class RuntimeRemotePhysicsUpdater System.Func? acknowledgeProjection = null, System.Func? externalOwnerValid = null, - // TS-46 (2026-07-30): see the visible Tick's identical parameters. + // TS-46/TS-23 (2026-07-30): see the visible Tick's identical parameters. System.Collections.Immutable.ImmutableArray sphereList = default, float sphereScale = 1f, float stepUpHeight = 0.4f, - float stepDownHeight = 0.4f) + float stepDownHeight = 0.4f, + AcDream.Core.Physics.ObjectInfoState moverPvpState = + AcDream.Core.Physics.ObjectInfoState.None) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(rm); @@ -709,10 +720,13 @@ internal sealed class RuntimeRemotePhysicsUpdater stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal isOnGround: previousOnWalkable, body: rm.Body, - moverFlags: IsPlayerGuid(record.ServerGuid) + // TS-23: moverPvpState is a no-op OR (None) for every + // non-PK remote. + moverFlags: (IsPlayerGuid(record.ServerGuid) ? AcDream.Core.Physics.ObjectInfoState.IsPlayer | AcDream.Core.Physics.ObjectInfoState.EdgeSlide - : AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + : AcDream.Core.Physics.ObjectInfoState.EdgeSlide) + | moverPvpState, movingEntityId: localEntityId, // TS-46: the Setup's own sphere list, scaled by ObjScale. // Empty falls back to the radius/height reconstruction above. diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 1d3f025f..a0fafa79 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -193,31 +193,34 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting // the SAME event set IndicatorBarController.UpdateBurden already // reacts to (Strength + augmentation property 0xE6 + // EncumbranceVal property 5, falling back to SumCarriedBurden). - // See the pseudocode doc §9. + // See the pseudocode doc §9. Campaign P Slice P3 (2026-07-30) + // rides the SAME triggers for the player's own PWD bitfield + // (PK/PKLite/Impenetrable) and PlayerKillerStatus/ + // LastPkAttackTimestamp — all live on the SAME ClientObject row. SubscribeToRecompute( h => inventory.Objects.ObjectAdded += h, h => inventory.Objects.ObjectAdded -= h, - () => RecomputeBurden(inventory, character)); + () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); SubscribeToRecompute( h => inventory.Objects.ObjectUpdated += h, h => inventory.Objects.ObjectUpdated -= h, - () => RecomputeBurden(inventory, character)); + () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); SubscribeToRecompute( h => inventory.Objects.ObjectRemoved += h, h => inventory.Objects.ObjectRemoved -= h, - () => RecomputeBurden(inventory, character)); + () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); SubscribeToRecompute( h => inventory.Objects.ObjectMoved += h, h => inventory.Objects.ObjectMoved -= h, - () => RecomputeBurden(inventory, character)); + () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); SubscribeToRecompute( h => inventory.Objects.ContainerContentsReplaced += h, h => inventory.Objects.ContainerContentsReplaced -= h, - () => RecomputeBurden(inventory, character)); + () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); SubscribeParameterless( h => inventory.Objects.Cleared += h, h => inventory.Objects.Cleared -= h, - () => RecomputeBurden(inventory, character)); + () => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); }); Subscribe( h => character.Character.LocalPlayer.AttributeChanged += h, h => character.Character.LocalPlayer.AttributeChanged -= h, @@ -381,6 +384,40 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting character.OnMovementStatsUpdated?.Invoke(); } + /// + /// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's + /// own PublicWeenieDesc._bitfield (PK/PKLite/Impenetrable + /// collision-exemption bits, already parsed at CreateObject time — see + /// CreateObject.cs's objectDescriptionFlags read) and the + /// raw PlayerKillerStatus(0x86)/LastPkAttackTimestamp(0x91) + /// pair (retail CACQualities::JumpStaminaCost's PK-timer bump) + /// into . Rides the SAME + /// ClientObject add/update/move/clear events + /// already reacts to — both live on the player's own row. + /// + private static void RecomputePvpStatus( + LiveInventorySessionBindings inventory, + LiveCharacterSessionBindings character) + { + uint player = inventory.PlayerGuid(); + ClientObject? playerObject = inventory.Objects.Get(player); + uint bitfield = playerObject?.PublicWeenieBitfield ?? 0u; + int pkStatus = playerObject?.Properties.Ints.TryGetValue( + (uint)PropertyInt.PlayerKillerStatus, out int wirePkStatus) == true + ? wirePkStatus + : -1; + float? lastPkAttackTimestamp = + playerObject?.Properties.Floats.TryGetValue( + (uint)PropertyFloat.LastPkAttackTimestamp, out double wireTimestamp) == true + ? (float)wireTimestamp + : null; + character.Character.MovementSkills.UpdateOwnPwdBitfield(bitfield); + character.Character.MovementSkills.UpdatePlayerKillerStatus( + pkStatus, + lastPkAttackTimestamp); + character.OnMovementStatsUpdated?.Invoke(); + } + /// /// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital /// changes into — feeds diff --git a/tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs b/tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs index 9c8cff0f..d0c83e08 100644 --- a/tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs +++ b/tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs @@ -1,3 +1,4 @@ +using AcDream.Core.Items; using AcDream.Core.Physics; using Xunit; @@ -86,4 +87,215 @@ public class EntityCollisionFlagsTests var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x1u | 0x2u | 0x4u | 0x10u); Assert.Equal(EntityCollisionFlags.None, flags); } + + // ── ToMoverState (TS-23, Campaign P Slice P3, 2026-07-30) ──────────────── + + [Fact] + public void ToMoverState_None_ProducesNoneNotIsPlayer() + { + // ToMoverState deliberately does NOT translate IsPlayer — every + // mover-flags call site derives ObjectInfoState.IsPlayer from its + // own GUID-prefix heuristic. Confirms the OR is a true no-op for + // the non-PK invariant. + Assert.Equal(ObjectInfoState.None, EntityCollisionFlags.None.ToMoverState()); + Assert.Equal( + ObjectInfoState.None, + EntityCollisionFlags.IsPlayer.ToMoverState()); + } + + [Fact] + public void ToMoverState_IsPK_TranslatesToObjectInfoStateIsPK() + { + Assert.Equal( + ObjectInfoState.IsPK, + EntityCollisionFlags.IsPK.ToMoverState()); + } + + [Fact] + public void ToMoverState_IsPKLite_TranslatesToObjectInfoStateIsPKLite() + { + Assert.Equal( + ObjectInfoState.IsPKLite, + EntityCollisionFlags.IsPKLite.ToMoverState()); + } + + [Fact] + public void ToMoverState_IsImpenetrable_TranslatesToObjectInfoStateIsImpenetrable() + { + Assert.Equal( + ObjectInfoState.IsImpenetrable, + EntityCollisionFlags.IsImpenetrable.ToMoverState()); + } + + [Fact] + public void ToMoverState_AllThreeBits_TranslateIndependently() + { + var flags = EntityCollisionFlags.IsPlayer + | EntityCollisionFlags.IsPK + | EntityCollisionFlags.IsPKLite + | EntityCollisionFlags.IsImpenetrable + | EntityCollisionFlags.IsCreature + | EntityCollisionFlags.HasWeenie; + + ObjectInfoState state = flags.ToMoverState(); + + Assert.Equal( + ObjectInfoState.IsPK | ObjectInfoState.IsPKLite | ObjectInfoState.IsImpenetrable, + state); + } + + /// + /// End-to-end proof of the FULL PWD-bitfield-to-moverFlags pipeline + /// (TS-23): a wire bitfield decodes through + /// then + /// to the exact + /// bits every mover-flags call site now + /// ORs into PhysicsEngine.ResolveWithTransition's moverFlags + /// argument. + /// + [Fact] + public void FromPwdBitfield_ThenToMoverState_PkPlayer_ProducesIsPKOnly() + { + // BF_PLAYER (0x8) | BF_PLAYER_KILLER (0x20). + uint bitfield = 0x8u | 0x20u; + ObjectInfoState moverState = + EntityCollisionFlagsExt.FromPwdBitfield(bitfield).ToMoverState(); + + Assert.Equal(ObjectInfoState.IsPK, moverState); + Assert.False(moverState.HasFlag(ObjectInfoState.IsPlayer)); + } + + // ── ResolveMoverPvpState (TS-23) — the ClientObjectTable-backed lookup + // every mover-flags call site (local player, remote sweep + teleport, + // ordinary movers) now uses ──────────────────────────────────────────── + + private const uint PkGuid = 0x50000101u; + private const uint NonPkGuid = 0x50000102u; + + /// + /// THE non-PK invariant the plan requires: an entity with no row at + /// all, and a row whose PublicWeenieBitfield is explicitly 0 (received + /// but carries none of the tracked bits), BOTH resolve to + /// — a no-op OR into moverFlags, + /// bit-identical to the pre-P3 hardcoded value for every ACE + /// default-created character. + /// + [Fact] + public void ResolveMoverPvpState_NoRowOrZeroBitfield_IsNoneNotJustAbsentIsPlayer() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = NonPkGuid, + PublicWeenieBitfield = 0u, + }); + + // No row registered at all for this guid. + Assert.Equal( + ObjectInfoState.None, + EntityCollisionFlagsExt.ResolveMoverPvpState(objects, 0x50000999u)); + // A row exists, but PublicWeenieBitfield is 0 — no PK-relevant bits. + Assert.Equal( + ObjectInfoState.None, + EntityCollisionFlagsExt.ResolveMoverPvpState(objects, NonPkGuid)); + } + + /// + /// A row whose is + /// null (parsed CreateObject never carried the trailer field at + /// all) is the SAME safe default — matches + /// ClientObject.PublicWeenieBitfield's own "not yet known" + /// sentinel. + /// + [Fact] + public void ResolveMoverPvpState_NullBitfield_IsNone() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = NonPkGuid, + PublicWeenieBitfield = null, + }); + + Assert.Equal( + ObjectInfoState.None, + EntityCollisionFlagsExt.ResolveMoverPvpState(objects, NonPkGuid)); + } + + /// + /// The actual acceptance criterion (not just the non-regression guard): + /// a PK/PKLite/Impenetrable row resolves to the exact matching + /// ObjectInfoState bits. + /// + [Fact] + public void ResolveMoverPvpState_PkPlayerRow_ResolvesToIsPK() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = PkGuid, + // BF_PLAYER (0x8) | BF_PLAYER_KILLER (0x20). + PublicWeenieBitfield = 0x8u | 0x20u, + }); + + Assert.Equal( + ObjectInfoState.IsPK, + EntityCollisionFlagsExt.ResolveMoverPvpState(objects, PkGuid)); + } + + /// + /// End-to-end: two PK players' resolved moverFlags/targetFlags make + /// CollisionExemption.ShouldSkip COLLIDE (retail: both-PK pairs + /// collide), while a PK-vs-non-PK pair stays EXEMPT (walks through) — + /// the exact acceptance criterion from the plan, now driven through the + /// real ClientObjectTable-backed lookup instead of directly-constructed + /// enum values. + /// + [Fact] + public void PkVsPk_Collides_PkVsNonPk_staysExempt_ThroughRealTableLookup() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = PkGuid, + PublicWeenieBitfield = 0x8u | 0x20u, // BF_PLAYER | BF_PLAYER_KILLER + }); + objects.AddOrUpdate(new ClientObject + { + ObjectId = NonPkGuid, + PublicWeenieBitfield = 0x8u, // BF_PLAYER only — no PK status + }); + + ObjectInfoState pkMoverState = + ObjectInfoState.IsPlayer + | EntityCollisionFlagsExt.ResolveMoverPvpState(objects, PkGuid); + EntityCollisionFlags nonPkTargetFlags = + EntityCollisionFlagsExt.FromPwdBitfield( + objects.Get(NonPkGuid)!.PublicWeenieBitfield!.Value); + EntityCollisionFlags pkTargetFlags = + EntityCollisionFlagsExt.FromPwdBitfield( + objects.Get(PkGuid)!.PublicWeenieBitfield!.Value); + + // PK mover vs a non-PK player target: retail exempts (walk through). + Assert.True(CollisionExemption.ShouldSkip( + targetState: 0u, + targetFlags: nonPkTargetFlags, + moverState: pkMoverState)); + + // PK mover vs a PK player target: retail collides. + Assert.False(CollisionExemption.ShouldSkip( + targetState: 0u, + targetFlags: pkTargetFlags, + moverState: pkMoverState)); + + // Non-PK mover vs non-PK target (the invariant): retail exempts, + // matching the pre-P3 hardcoded IsPlayer-only behavior exactly. + ObjectInfoState nonPkMoverState = + ObjectInfoState.IsPlayer + | EntityCollisionFlagsExt.ResolveMoverPvpState(objects, NonPkGuid); + Assert.True(CollisionExemption.ShouldSkip( + targetState: 0u, + targetFlags: nonPkTargetFlags, + moverState: nonPkMoverState)); + } } diff --git a/tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs b/tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs index a4821d7f..1b3b320f 100644 --- a/tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs @@ -141,6 +141,92 @@ public class PlayerWeenieTests Assert.Equal(14, cost); } + // ── TS-23 §12b (Campaign P Slice P3, 2026-07-30): PlayerKillerStatus/ ── + // ── LastPkAttackTimestamp PK-timer jump-cost bump ─────────────────────── + + // Matches PlayerWeenie.JumpStaminaCost's own clock exactly (see its + // doc remarks for why Environment.TickCount64, not an absolute epoch). + private static float NowSeconds() => Environment.TickCount64 / 1000f; + + [Fact] + public void JumpStaminaCost_NeverPushed_UsesNonPkFormula_TheInvariant() + { + // THE non-PK invariant: a character whose PlayerKillerStatus was + // never pushed at all (every ACE default-created character, and + // every pre-P3 caller) must produce the exact SAME cost as before + // this port. + var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100); + Assert.True(pw.JumpStaminaCost(1.0f, out int cost)); + Assert.Equal(6, cost); // ceil((0+0.5)*1*8+2) = 6, unchanged from P1 + } + + [Fact] + public void JumpStaminaCost_PlayerKillerStatusRetailDefault_UsesNonPkFormula() + { + // Retail InqInt's own default (8, "not a killer") with a fresh + // timestamp still does not qualify — pkStatus must be 4 or 0x40. + var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100); + pw.SetPlayerKillerStatus(8, NowSeconds()); + Assert.True(pw.JumpStaminaCost(1.0f, out int cost)); + Assert.Equal(6, cost); + } + + [Fact] + public void JumpStaminaCost_PkStatusNoTimestamp_UsesNonPkFormula() + { + // pkStatus qualifies (PK), but LastPkAttackTimestamp was never + // pushed (retail's InqFloat "fails") — the pk flag stays false. + var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100); + pw.SetPlayerKillerStatus(4, null); + Assert.True(pw.JumpStaminaCost(1.0f, out int cost)); + Assert.Equal(6, cost); + } + + [Fact] + public void JumpStaminaCost_PkStatusActiveWithinWindow_UsesPkFormula() + { + var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100); + pw.SetPlayerKillerStatus(4, NowSeconds()); // just attacked + Assert.True(pw.JumpStaminaCost(1.0f, out int cost)); + // pk branch: (int)((power + 1.0) * 100.0) = (1.0+1.0)*100 = 200 + Assert.Equal(200, cost); + } + + [Fact] + public void JumpStaminaCost_PkLiteStatusActiveWithinWindow_UsesPkFormula() + { + // BF_PKLITE_PKSTATUS decodes to the retail PKLite status code 0x40 + // (per CACQualities::JumpStaminaCost's own comparison, §12b) — both + // PK(4) and PKLite(0x40) qualify identically. + var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100); + pw.SetPlayerKillerStatus(0x40, NowSeconds()); + Assert.True(pw.JumpStaminaCost(1.0f, out int cost)); + Assert.Equal(200, cost); + } + + [Fact] + public void JumpStaminaCost_PkTimerExpired_UsesNonPkFormula() + { + // 30 seconds ago — past the 20-second recency window. + var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100); + pw.SetPlayerKillerStatus(4, NowSeconds() - 30f); + Assert.True(pw.JumpStaminaCost(1.0f, out int cost)); + Assert.Equal(6, cost); + } + + [Fact] + public void JumpStaminaCost_PkTimerRestoredToNeverPushed_ReturnsToNonPkFormula() + { + var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100); + pw.SetPlayerKillerStatus(4, NowSeconds()); + Assert.True(pw.JumpStaminaCost(1.0f, out int cost)); + Assert.Equal(200, cost); + + pw.SetPlayerKillerStatus(null, null); + Assert.True(pw.JumpStaminaCost(1.0f, out int cost2)); + Assert.Equal(6, cost2); + } + [Fact] public void InqRunRate_ZeroStamina_ZeroesEffectiveSkill() {