Campaign P Slice P3 item 3. The wire parse (CreateObject's PublicWeenieDesc._bitfield), the decode (EntityCollisionFlagsExt. FromPwdBitfield), the per-GUID storage (ClientObjectTable. PublicWeenieBitfield), and the exemption logic (CollisionExemption. ShouldSkip) all already existed and were already correct -- every mover-flags call site just fed a GUID-prefix IsPlayer heuristic instead of the real per-entity PK/PKLite/Impenetrable state (retail OBJECTINFO::init 0x0050cf30 state |= 0x80/0x800/0x1000). Port: - EntityCollisionFlagsExt.ToMoverState translates the decoded PWD bit-space into the ObjectInfoState bit-space FindObjCollisions actually reads -- two different numberings that must not be confused. Deliberately does not translate IsPlayer (every call site already derives that correctly from its own GUID heuristic per #184 Slice 2b). - EntityCollisionFlagsExt.ResolveMoverPvpState is the one shared ClientObjectTable-backed lookup (guid -> ObjectInfoState), replacing what would otherwise have been three separate inline copies across GameWindow/LivePresentationComposition/RemoteTeleportController. - Threaded as a new optional moverPvpState parameter through RuntimeRemotePhysicsUpdater.Tick/TickHidden and RuntimeOrdinaryPhysicsUpdater.TryBegin (default None preserves every pre-P3 caller unchanged), and as PlayerMovementController.OwnPvpFlags for the local player's own two resolve call sites. - TS-23 section 12b: PlayerWeenie.JumpStaminaCost's pk parameter now reads the real PlayerKillerStatus(0x86)/LastPkAttackTimestamp(0x91) pair against retail's 20-second recency window (pkStatus in {4, 0x40} && (timestamp + 20.0) >= now), replacing the P1 hardcoded false. RuntimeMovementSkillState/Snapshot and LiveSessionEventRouter.RecomputePvpStatus push both the PWD bitfield and the PlayerKillerStatus pair reactively, riding the SAME ClientObject event triggers RecomputeBurden already uses. - A conformance test caught a genuine precision bug in the first PK-timer clock choice: DateTimeOffset.UtcNow's Unix-epoch seconds (~1.7 billion) loses ~128 seconds of precision in a 32-bit float, silently swallowing the entire 20-second window. Switched to Environment.TickCount64 (small, monotonic magnitude) -- also the more retail-plausible basis, since LastPkAttackTimestamp is itself a wire PropertyFloat and retail's Timer::cur_time is almost certainly a process/session-relative counter for the same precision reason, not an absolute epoch. Non-PK invariant (the acceptance criterion): an entity with no ClientObjectTable row, or a row whose PublicWeenieBitfield is null or 0, resolves to ObjectInfoState.None -- a no-op OR into moverFlags, bit-identical to every pre-P3 caller's hardcoded value. A dedicated test drives two real ClientObjectTable rows through CollisionExemption.ShouldSkip and confirms PK-vs-PK collides while PK-vs-non-PK and non-PK-vs-non-PK both stay exempt (walk through). Register: TS-23 retired (both the collision-flags and PK-timer halves); the stale "M2 combat must land TS-23" phase-gate note removed. dotnet build + dotnet test (Core.Tests 4008/2 skip, Runtime.Tests 425/0, App.Tests 3968/3 skip, complete solution build) all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
10 KiB
C#
290 lines
10 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Physics.Motion;
|
|
using AcDream.Runtime.Entities;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.Runtime.Physics;
|
|
|
|
internal readonly record struct RuntimePhysicsFrameSnapshot(
|
|
Vector3 Position,
|
|
Quaternion Orientation,
|
|
uint FullCellId);
|
|
|
|
internal sealed class RuntimeOrdinaryPhysicsCommit
|
|
{
|
|
internal required RuntimeOrdinaryPhysicsUpdater Owner { get; init; }
|
|
internal required RuntimeEntityRecord Record { get; init; }
|
|
internal required PhysicsBody Body { get; init; }
|
|
internal required ulong ObjectClockEpoch { get; init; }
|
|
internal required bool FrameChanged { get; init; }
|
|
internal required Func<bool>? ExternalOwnerValid { get; init; }
|
|
internal bool Completed { get; set; }
|
|
internal RuntimePhysicsFrameSnapshot Snapshot { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Presentation-free, body-backed branch of retail
|
|
/// <c>CPhysicsObj::UpdateObjectInternal</c> (`0x005156B0`). App supplies the
|
|
/// completed animation root frame and acknowledges the resulting projection;
|
|
/// Runtime owns integration, transition, canonical-body state, and shadows.
|
|
/// </summary>
|
|
internal sealed class RuntimeOrdinaryPhysicsUpdater
|
|
{
|
|
private readonly RuntimePhysicsState _physics;
|
|
|
|
internal RuntimeOrdinaryPhysicsUpdater(RuntimePhysicsState physics)
|
|
{
|
|
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
|
}
|
|
|
|
internal bool TryBegin(
|
|
RuntimeEntityRecord record,
|
|
Frame rootFrame,
|
|
float objectScale,
|
|
float quantum,
|
|
float radius,
|
|
float height,
|
|
ulong objectClockEpoch,
|
|
AnimationSequencer? sequencer,
|
|
Action<uint, AnimationSequencer> captureAnimationHooks,
|
|
Func<bool>? externalOwnerValid,
|
|
out RuntimeOrdinaryPhysicsCommit commit,
|
|
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list + Setup-derived
|
|
// step heights (LiveEntityMotionRuntimeController.GetSetupMoverShape).
|
|
// Default/empty preserves the pre-TS-46 0.4 m literal fallback below.
|
|
System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>
|
|
sphereList = default,
|
|
float sphereScale = 1f,
|
|
float stepUpHeight = 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);
|
|
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
|
|
if (record.PhysicsBody is not { } body
|
|
|| !IsCurrent(
|
|
record,
|
|
body,
|
|
objectClockEpoch,
|
|
externalOwnerValid))
|
|
{
|
|
commit = null!;
|
|
return false;
|
|
}
|
|
|
|
body.State = record.FinalPhysicsState;
|
|
Vector3 priorPosition = body.Position;
|
|
Quaternion priorOrientation = body.Orientation;
|
|
bool previousContact = body.InContact;
|
|
bool previousOnWalkable = body.OnWalkable;
|
|
|
|
// UpdatePositionInternal 0x00512CA1: scale root translation only while
|
|
// OnWalkable. Complete root orientation composes regardless.
|
|
Vector3 candidatePosition = priorPosition;
|
|
if (body.OnWalkable && rootFrame.Origin != Vector3.Zero)
|
|
{
|
|
candidatePosition += Vector3.Transform(
|
|
rootFrame.Origin * objectScale,
|
|
priorOrientation);
|
|
}
|
|
|
|
Quaternion candidateOrientation = priorOrientation;
|
|
if (!rootFrame.Orientation.IsIdentity)
|
|
{
|
|
candidateOrientation = FrameOps.SetRotate(
|
|
candidatePosition,
|
|
priorOrientation,
|
|
priorOrientation * rootFrame.Orientation);
|
|
}
|
|
|
|
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
|
|
body.calc_acceleration();
|
|
body.UpdatePhysicsInternal(quantum);
|
|
body.SetFrameInCurrentCell(body.Position, body.Orientation);
|
|
|
|
if (sequencer is not null)
|
|
{
|
|
uint localId = record.LocalEntityId
|
|
?? throw new InvalidOperationException(
|
|
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity.");
|
|
captureAnimationHooks(localId, sequencer);
|
|
}
|
|
if (!IsCurrent(
|
|
record,
|
|
body,
|
|
objectClockEpoch,
|
|
externalOwnerValid))
|
|
{
|
|
commit = null!;
|
|
return false;
|
|
}
|
|
|
|
Vector3 integratedPosition = body.Position;
|
|
uint sourceCellId = record.FullCellId;
|
|
uint resolvedCellId = sourceCellId;
|
|
bool frameChanged = integratedPosition != priorPosition
|
|
|| body.Orientation != priorOrientation;
|
|
uint movingEntityId = record.LocalEntityId ?? 0u;
|
|
|
|
if (integratedPosition != priorPosition
|
|
&& sourceCellId != 0
|
|
&& radius >= 0.05f
|
|
&& _physics.Engine.LandblockCount > 0)
|
|
{
|
|
ResolveResult resolved = _physics.Engine.ResolveWithTransition(
|
|
priorPosition,
|
|
integratedPosition,
|
|
sourceCellId,
|
|
radius,
|
|
height,
|
|
stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal
|
|
stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal
|
|
isOnGround: previousOnWalkable,
|
|
body: body,
|
|
// 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)
|
|
| moverPvpState,
|
|
movingEntityId: movingEntityId,
|
|
// TS-46: the Setup's own sphere list, scaled by ObjScale.
|
|
// Empty falls back to the radius/height reconstruction above.
|
|
sphereList: sphereList,
|
|
sphereScale: sphereScale);
|
|
|
|
if (resolved.Ok)
|
|
{
|
|
resolvedCellId = resolved.CellId != 0
|
|
? resolved.CellId
|
|
: sourceCellId;
|
|
body.CommitTransitionPosition(
|
|
resolvedCellId,
|
|
resolved.Position);
|
|
PhysicsObjUpdate.CommitSetPositionTransition(
|
|
body,
|
|
resolved.InContact,
|
|
resolved.OnWalkable,
|
|
resolved.CollisionNormalValid,
|
|
resolved.CollisionNormal,
|
|
previousContact,
|
|
previousOnWalkable);
|
|
body.CachedVelocity = quantum > 0f
|
|
? (body.Position - priorPosition) / quantum
|
|
: Vector3.Zero;
|
|
}
|
|
else
|
|
{
|
|
body.CachedVelocity = Vector3.Zero;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
body.CachedVelocity = Vector3.Zero;
|
|
}
|
|
|
|
if (!IsCurrent(
|
|
record,
|
|
body,
|
|
objectClockEpoch,
|
|
externalOwnerValid))
|
|
{
|
|
commit = null!;
|
|
return false;
|
|
}
|
|
|
|
commit = new RuntimeOrdinaryPhysicsCommit
|
|
{
|
|
Owner = this,
|
|
Record = record,
|
|
Body = body,
|
|
ObjectClockEpoch = objectClockEpoch,
|
|
FrameChanged = frameChanged,
|
|
ExternalOwnerValid = externalOwnerValid,
|
|
Snapshot = new RuntimePhysicsFrameSnapshot(
|
|
body.Position,
|
|
body.Orientation,
|
|
resolvedCellId),
|
|
};
|
|
return true;
|
|
}
|
|
|
|
internal bool Complete(
|
|
RuntimeOrdinaryPhysicsCommit commit,
|
|
int liveCenterX,
|
|
int liveCenterY,
|
|
Func<RuntimePhysicsFrameSnapshot, bool> acknowledgeProjection)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(commit);
|
|
ArgumentNullException.ThrowIfNull(acknowledgeProjection);
|
|
if (!ReferenceEquals(commit.Owner, this))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"An ordinary-physics commit belongs to another Runtime owner.");
|
|
}
|
|
if (commit.Completed)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"An ordinary-physics commit has already completed.");
|
|
}
|
|
commit.Completed = true;
|
|
|
|
if (!IsCurrent(
|
|
commit.Record,
|
|
commit.Body,
|
|
commit.ObjectClockEpoch,
|
|
commit.ExternalOwnerValid)
|
|
|| !_physics.CommitOrdinaryCell(
|
|
commit.Record,
|
|
commit.Body,
|
|
commit.ObjectClockEpoch,
|
|
commit.Snapshot.FullCellId,
|
|
commit.ExternalOwnerValid)
|
|
|| !acknowledgeProjection(commit.Snapshot)
|
|
|| !IsCurrent(
|
|
commit.Record,
|
|
commit.Body,
|
|
commit.ObjectClockEpoch,
|
|
commit.ExternalOwnerValid))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (commit.FrameChanged
|
|
&& commit.Record.FullCellId != 0)
|
|
{
|
|
ShadowPositionSynchronizer.Sync(
|
|
_physics.Engine.ShadowObjects,
|
|
commit.Record.LocalEntityId ?? 0u,
|
|
commit.Body.Position,
|
|
commit.Body.Orientation,
|
|
commit.Record.FullCellId,
|
|
liveCenterX,
|
|
liveCenterY);
|
|
}
|
|
|
|
return IsCurrent(
|
|
commit.Record,
|
|
commit.Body,
|
|
commit.ObjectClockEpoch,
|
|
commit.ExternalOwnerValid);
|
|
}
|
|
|
|
private bool IsCurrent(
|
|
RuntimeEntityRecord record,
|
|
PhysicsBody body,
|
|
ulong objectClockEpoch,
|
|
Func<bool>? externalOwnerValid) =>
|
|
_physics.IsSpatialRoot(record)
|
|
&& record.ObjectClockEpoch == objectClockEpoch
|
|
&& ReferenceEquals(record.PhysicsBody, body)
|
|
&& record.RemoteMotion is null
|
|
&& (externalOwnerValid?.Invoke() ?? true);
|
|
|
|
private static bool IsPlayerGuid(uint guid) =>
|
|
(guid & 0xFF000000u) == 0x50000000u;
|
|
}
|