Compare commits

...

3 commits

Author SHA1 Message Date
Erik
7d6fe90607 feat(physics): retail PvP exemption + viewer/creature/missile gates (Commit C)
The retail-faithful exemption block at the top of
CPhysicsObj::FindObjCollisions
(acclient_2013_pseudo_c.txt:276782-276839,276971), ported line-for-
line as a small static helper.

Behaviour now matches retail:
  - Two non-PK players walk through each other.
  - Two PK players collide.
  - Two PKLite players collide.
  - Mismatched PK status (PK vs non-PK, PK vs PKLite) — exempt.
  - Impenetrable target ("Free" PK status) — always collides.
  - Player vs creature/NPC — always collides (this is what closes the
    user-facing complaint that walking into a Holtburg vendor was
    walking through them).
  - Mover with IGNORE_CREATURES — walks through creature targets.
  - Viewer (camera ray) — walks through creatures.
  - Target with ETHEREAL+IGNORE_COLLISIONS — universally exempt.

CollisionExemption.ShouldSkip(targetState, targetFlags, moverState)
  - new file src/AcDream.Core/Physics/CollisionExemption.cs.
  - 13-test matrix covering every documented case
    (CollisionExemptionTests.cs).
  - Static + pure → cheap to call from the hot path.

Wiring:
  - TransitionTypes.FindObjCollisions: after broadphase distance
    reject, call ShouldSkip on the obj and ObjectInfo.State; on true,
    `continue`. Static landblock entries (State=0, Flags=None) fall
    through cheaply — no behavior change for static collision.
  - PhysicsEngine.ResolveWithTransition: new optional moverFlags
    parameter (default None for back-compat). PlayerMovementController
    passes ObjectInfoState.IsPlayer; remote dead-reckoning leaves it
    None (matches non-player movers, no PvP exemption applies).
  - PK/PKLite/Impenetrable bits for the LOCAL player are not yet
    sourced from PlayerDescription's PlayerKillerStatus property —
    that's a follow-up. Default "non-PK player" matches ACE's
    character-creation default and the user's +Acdream test
    character.

Cross-checked against ACE PhysicsObj.cs:381-405 (line-for-line C# port
of the same retail block). Only intentional divergence: ACE adds
state.HasFlag(IsImpenetrable) (mover-impenetrable) to the collide list;
retail's pseudo-C only checks the target — acdream follows retail.

dotnet build green, dotnet test 1467 passing (+13 new). Live test:
+Acdream walking into Holtburg vendors now stops at their cylinder;
walking through small plants still passes (Commit B's phantom skip).

Closes the live-entity collision arc: A (plumbing) + B (registration)
+ C (exemption).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:21:36 +02:00
Erik
46ca3ba26b feat(physics): live-entity collision registration (Commit B)
NPCs / monsters / other players now register into ShadowObjectRegistry
as collision targets. The local player walks into them and stops at
the body cylinder, instead of passing through.

GameWindow.OnLiveEntitySpawnedLocked: after the WorldEntity is built
and stored in `_entitiesByServerGuid`, call the new
RegisterLiveEntityCollision helper for any non-self entity. The helper
honors retail's geometry-priority order (acclient_2013_pseudo_c.txt:
276858-276987) — CylSpheres > Setup.Radius > Sphere fallback — and
applies the retail phantom-Setup skip (no CylSpheres / no Spheres /
zero Radius → walk-through, matching FUN_FindObjCollisions's OK_TS
fallthrough at :276917).

GameWindow.OnLivePositionUpdated: after the entity's render pos/rot
are set to server truth, push the same coordinates into the registry
via ShadowObjectRegistry.UpdatePosition (the cheap
preserve-shape-and-flags path Commit A added). Mirrors retail's
SetPosition → change_cell → AddShadowObject chain (
acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).

The local player's own server guid is filtered out at both
registration and update — its PhysicsBody is the simulator (the
source of truth for our collisions), not a collision target.

The decoded EntityCollisionFlags + raw PhysicsState bits are stored
on each ShadowEntry but NOT YET CONSULTED by the collision resolver
— Commit C is where the PvP exemption block lands. Practical effect
of THIS commit: every visible body, including non-PK other players,
blocks the local player. Two non-PK players currently can't pass
through each other; that's the rule Commit C reverts to retail.

No new unit tests in this commit (Commit A's ShadowObjectRegistry +
EntityCollisionFlags suite covers the new field plumbing).
Verification is live: at Holtburg the +Acdream test character should
stop on contact with NPCs / vendors. Phantom decorations (small
plants, grass) continue to pass through (L-fix3 phantom skip
extends naturally to the live path via the same Setup-shape gate).

dotnet build green, dotnet test 1454 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:16:22 +02:00
Erik
ffefc6977f feat(physics): live-entity collision plumbing (Commit A)
Plumbing-only foundation for the upcoming live-entity (NPC / monster
/ player) collision port. No behavior change — the new fields default
to zero/None so the 5 existing static-entity Register call sites in
GameWindow.cs are untouched.

Wire layer:
- CreateObject parser now surfaces PhysicsState (acclient.h:2815 —
  ETHEREAL_PS=0x4, IGNORE_COLLISIONS_PS=0x10, HAS_PHYSICS_BSP_PS=0x10000,
  ...) which the parser previously dropped at line ~337 with a bare
  `pos += 4`.
- CreateObject parser now surfaces ObjectDescriptionFlags (the retail
  PWD._bitfield trailer per acclient.h:6431-6463), where
  acclient_2013_pseudo_c.txt:406898-406918 ACCWeenieObject::IsPK /
  IsPKLite / IsImpenetrable read bits 5 / 25 / 21 directly. Previously
  read-and-discarded.
- WorldSession.EntitySpawn carries both new fields through to subscribers.

Physics layer:
- New `EntityCollisionFlags` enum (IsPlayer / IsCreature / IsPK /
  IsPKLite / IsImpenetrable) + `FromPwdBitfield` helper. Bit
  positions verified against retail's SetPlayerKillerStatus (
  acclient_2013_pseudo_c.txt:441868-441890) which maps
  PKStatusEnum→bitfield exactly: PK=0x4→bit5, PKLite=0x40→bit25,
  Free=0x20→bit21.
- `ShadowEntry` extended with `State` (raw PhysicsState bits) +
  `Flags` (decoded EntityCollisionFlags). Backward-compatible — all
  five existing landblock-entity Register call sites omit them.
- `ShadowObjectRegistry.UpdatePosition(entityId, pos, rot, ...)` —
  fast-path for the 5–10 Hz UpdatePosition (0xF748) stream the server
  emits per visible entity. Reuses the entry's existing shape +
  state + flags. Mirrors retail's CPhysicsObj::SetPosition
  (acclient_2013_pseudo_c.txt:284276) which keeps the same shape and
  re-registers cell membership.
- `ObjectInfoState` adds `IsPK = 0x800` and `IsPKLite = 0x1000`
  matching retail's OBJECTINFO::state bits (acclient.h:6190-6194).
  Used by Commit C's PvP exemption gate.

Tests:
- `EntityCollisionFlagsTests` — 7 tests covering empty / each bit
  alone / PK+player combo / unrelated-bit ignore.
- `ShadowObjectRegistryTests` — 5 new tests: UpdatePosition moves
  entry to new cell, preserves State/Flags, unregistered no-op,
  Register stores State/Flags, defaults are zero/None.
- `CreateObjectTests` — 3 new tests verifying PhysicsState + PWD
  bitfield (with PK / PKLite bit cases) parse and surface.

1454 → 1454 + 15 = covered by suite. dotnet build + dotnet test
green.

Foundation for Commit B (live-entity registration) and Commit C
(PvP exemption block in FindObjCollisions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:12:56 +02:00
13 changed files with 869 additions and 16 deletions

View file

@ -413,7 +413,13 @@ public sealed class PlayerMovementController
stepUpHeight: StepUpHeight, stepUpHeight: StepUpHeight,
stepDownHeight: 0.04f, // retail default stepDownHeight: 0.04f, // retail default
isOnGround: _body.OnWalkable, isOnGround: _body.OnWalkable,
body: _body); // persist ContactPlane across frames for slope tracking body: _body, // persist ContactPlane across frames for slope tracking
// 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).
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer);
// Apply resolved position. // Apply resolved position.
_body.Position = resolveResult.Position; _body.Position = resolveResult.Position;

View file

@ -2067,6 +2067,18 @@ public sealed class GameWindow : IDisposable
// UpdateMotion / UpdatePosition events can reseat this entity by guid. // UpdateMotion / UpdatePosition events can reseat this entity by guid.
_entitiesByServerGuid[spawn.Guid] = entity; _entitiesByServerGuid[spawn.Guid] = entity;
// Commit B 2026-04-29 — live-entity collision registration. The
// local player is the simulator (its PhysicsBody is the source of
// truth for our own movement); only remotes register as targets.
// Phantom-Setup entities (no CylSpheres / no Spheres / no Radius)
// are deliberately skipped — retail FUN's `FindObjCollisions`
// falls through to OK_TS for any object with no collision
// geometry (acclient_2013_pseudo_c.txt:276917,276987).
if (spawn.Guid != _playerServerGuid)
{
RegisterLiveEntityCollision(entity, setup, spawn, origin);
}
// Phase B.2: capture the server-sent MotionTableId for our own // Phase B.2: capture the server-sent MotionTableId for our own
// character so UpdatePlayerAnimation can pass it to GetIdleCycle. // character so UpdatePlayerAnimation can pass it to GetIdleCycle.
// The Setup's DefaultMotionTable is often 0 for human characters; // The Setup's DefaultMotionTable is often 0 for human characters;
@ -2284,6 +2296,100 @@ public sealed class GameWindow : IDisposable
} }
} }
/// <summary>
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
/// the <see cref="ShadowObjectRegistry"/> as a single collision body.
/// One entry per entity (in contrast to static scenery's per-CylSphere
/// registration) so <c>RemoveLiveEntityByServerGuid</c>'s single
/// <c>Deregister(entity.Id)</c> cleans it up without leaks.
///
/// <para>
/// Geometry-priority order matches retail
/// (<c>acclient_2013_pseudo_c.txt:276858-276987</c>): CylSpheres &gt;
/// Sphere fallback &gt; Setup.Radius. Phantom Setups (no shape) are
/// rejected — retail's <c>FindObjCollisions</c> falls through to
/// OK_TS in that case.
/// </para>
///
/// <para>
/// Carries <see cref="EntityCollisionFlags"/> derived from the PWD
/// bitfield (<c>acclient.h:6431-6463</c>) plus <c>IsCreature</c>
/// derived from the inbound ItemType. Commit C consumes these in
/// the PvP exemption block.
/// </para>
/// </summary>
private void RegisterLiveEntityCollision(
AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup,
AcDream.Core.Net.WorldSession.EntitySpawn spawn,
System.Numerics.Vector3 origin)
{
if (spawn.Position is null) return;
bool hasCyl = setup.CylSpheres.Count > 0;
bool hasSphere = setup.Spheres.Count > 0;
bool hasRadius = setup.Radius > 0.0001f;
// Retail-faithful phantom skip (acclient_2013_pseudo_c.txt:276917).
if (!hasCyl && !hasSphere && !hasRadius)
return;
float entScale = spawn.ObjScale ?? 1.0f;
float radius;
float height;
if (hasCyl)
{
// Pick the largest CylSphere as the body cylinder. Retail
// tests every CylSphere in turn (276891) but for collision
// BLOCKING the largest is sufficient — the player will stop
// at the body's outer radius.
var sph = setup.CylSpheres[0];
for (int i = 1; i < setup.CylSpheres.Count; i++)
{
if (setup.CylSpheres[i].Radius > sph.Radius) sph = setup.CylSpheres[i];
}
radius = sph.Radius * entScale;
height = (sph.Height > 0 ? sph.Height : sph.Radius * 4f) * entScale;
}
else if (hasRadius)
{
radius = setup.Radius * entScale;
height = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale;
}
else
{
// Sphere-only: largest sphere as a Cylinder approximation.
var sph = setup.Spheres[0];
for (int i = 1; i < setup.Spheres.Count; i++)
{
if (setup.Spheres[i].Radius > sph.Radius) sph = setup.Spheres[i];
}
radius = sph.Radius * entScale;
height = sph.Radius * 2f * entScale;
}
if (radius <= 0f) return;
// Decode PvP / Player / Impenetrable from PWD._bitfield.
// IsCreature comes from the spawn's ItemType (server-known type).
var flags = AcDream.Core.Physics.EntityCollisionFlags.None;
if (spawn.ObjectDescriptionFlags is { } odf)
flags = AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf);
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
uint state = spawn.PhysicsState ?? 0u;
_physicsEngine.ShadowObjects.Register(
entity.Id, entity.SourceGfxObjOrSetupId,
entity.Position, entity.Rotation, radius,
origin.X, origin.Y, spawn.Position.Value.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder,
cylHeight: height, scale: 1.0f,
state: state, flags: flags);
}
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete) private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
{ {
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
@ -2980,6 +3086,18 @@ public sealed class GameWindow : IDisposable
entity.Position = worldPos; entity.Position = worldPos;
entity.Rotation = rot; entity.Rotation = rot;
// Commit B 2026-04-29 — keep the shadow registry in sync with
// server-authoritative position so the player's collision broadphase
// tests against the up-to-date target body. Skip the local player
// (its body is the simulator, not a target). Retail does the
// equivalent via SetPosition → change_cell → AddShadowObject
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
if (update.Guid != _playerServerGuid)
{
_physicsEngine.ShadowObjects.UpdatePosition(
entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId);
}
// Track remote-entity motion for stop detection. Only record the // Track remote-entity motion for stop detection. Only record the
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates // timestamp when position moved MEANINGFULLY (> 0.05m). Updates
// that report the same position keep the old Time, so the // that report the same position keep the old Time, so the

View file

@ -84,6 +84,24 @@ public static class CreateObject
/// SetupTableId are nullable because their corresponding /// SetupTableId are nullable because their corresponding
/// physics-description-flag bits may not be set on every CreateObject. /// physics-description-flag bits may not be set on every CreateObject.
/// </summary> /// </summary>
/// <remarks>
/// <para>
/// <see cref="PhysicsState"/> (<c>acclient.h:2815</c>) carries flag
/// bits like <c>ETHEREAL_PS=0x4</c>, <c>IGNORE_COLLISIONS_PS=0x10</c>,
/// <c>HAS_PHYSICS_BSP_PS=0x10000</c> — the bits retail's
/// <c>FindObjCollisions</c> reads to short-circuit ethereal /
/// no-collision entities. Pre-2026-04-29 (Commit A of the live-entity
/// collision port) the parser silently dropped this field.
/// </para>
/// <para>
/// <see cref="ObjectDescriptionFlags"/> is the <c>PWD._bitfield</c>
/// trailer (<c>acclient.h:6431-6463</c>) — bits include <c>BF_PLAYER (0x8)</c>,
/// <c>BF_PLAYER_KILLER (0x20)</c>, <c>BF_FREE_PKSTATUS (0x200000)</c>,
/// <c>BF_PKLITE_PKSTATUS (0x2000000)</c>. Decoded into
/// <c>EntityCollisionFlags</c> at registration time for the PvP
/// exemption gate.
/// </para>
/// </remarks>
public readonly record struct Parsed( public readonly record struct Parsed(
uint Guid, uint Guid,
ServerPosition? Position, ServerPosition? Position,
@ -100,7 +118,9 @@ public static class CreateObject
ushort InstanceSequence = 0, ushort InstanceSequence = 0,
ushort TeleportSequence = 0, ushort TeleportSequence = 0,
ushort ServerControlSequence = 0, ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0); ushort ForcePositionSequence = 0,
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null);
/// <summary> /// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> / /// The relevant subset of the server-sent <c>MovementData</c> /
@ -260,6 +280,12 @@ public static class CreateObject
float? objScale = null; float? objScale = null;
ServerMotionState? motionState = null; ServerMotionState? motionState = null;
uint? motionTableId = null; uint? motionTableId = null;
// Commit A 2026-04-29 — live-entity collision plumbing. PhysicsState
// (acclient.h:2815) was previously skipped at line ~337; the PWD
// _bitfield (acclient.h:6431-6463) was previously discarded as
// "ObjectDescriptionFlags" at the WeenieHeader trailer.
uint? physicsState = null;
uint? objectDescriptionFlags = null;
try try
{ {
@ -334,7 +360,10 @@ public static class CreateObject
if (body.Length - pos < 8) return null; if (body.Length - pos < 8) return null;
var physicsFlags = (PhysicsDescriptionFlag)BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); var physicsFlags = (PhysicsDescriptionFlag)BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4; pos += 4;
pos += 4; // PhysicsState (skip) // PhysicsState (acclient.h:2815). Previously skipped, now
// surfaced for live-entity collision (Commit A 2026-04-29).
physicsState = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Movement) != 0) if ((physicsFlags & PhysicsDescriptionFlag.Movement) != 0)
{ {
@ -463,7 +492,16 @@ public static class CreateObject
if (body.Length - pos >= 4) if (body.Length - pos >= 4)
itemType = ReadU32(body, ref pos); itemType = ReadU32(body, ref pos);
if (body.Length - pos >= 4) if (body.Length - pos >= 4)
_ = ReadU32(body, ref pos); // ObjectDescriptionFlags {
// ObjectDescriptionFlags = retail PWD._bitfield
// (acclient.h:6431-6463). Carries BF_PLAYER (0x8),
// BF_PLAYER_KILLER (0x20), BF_FREE_PKSTATUS (0x200000),
// BF_PKLITE_PKSTATUS (0x2000000) — the bits that
// acclient_2013_pseudo_c.txt:406898-406918 read for
// IsPK() / IsPKLite() / IsImpenetrable(). Previously
// discarded; now surfaced for the PvP collision rule.
objectDescriptionFlags = ReadU32(body, ref pos);
}
AlignTo4(ref pos); AlignTo4(ref pos);
} }
catch { /* truncated name — partial result is still useful */ } catch { /* truncated name — partial result is still useful */ }
@ -471,13 +509,15 @@ public static class CreateObject
return new Parsed(guid, position, setupTableId, animParts, return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq); instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
physicsState, objectDescriptionFlags);
// Local helper: if we ran out of fields past PhysicsData, still // Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
Parsed PartialResult() => new( Parsed PartialResult() => new(
guid, position, setupTableId, animParts, guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId); textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId,
PhysicsState: physicsState, ObjectDescriptionFlags: objectDescriptionFlags);
} }
catch catch
{ {

View file

@ -56,7 +56,14 @@ public sealed class WorldSession : IDisposable
string? Name, string? Name,
uint? ItemType, uint? ItemType,
CreateObject.ServerMotionState? MotionState, CreateObject.ServerMotionState? MotionState,
uint? MotionTableId); uint? MotionTableId,
// Commit A 2026-04-29 — live-entity collision plumbing.
// PhysicsState: retail acclient.h:2815 (ETHEREAL_PS=0x4,
// IGNORE_COLLISIONS_PS=0x10, HAS_PHYSICS_BSP_PS=0x10000, ...).
// ObjectDescriptionFlags: retail PWD._bitfield (acclient.h:6431-6463)
// — drives IsPlayer/IsPK/IsPKLite/IsImpenetrable for PvP gating.
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary> /// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned; public event Action<EntitySpawn>? EntitySpawned;
@ -648,7 +655,9 @@ public sealed class WorldSession : IDisposable
parsed.Value.Name, parsed.Value.Name,
parsed.Value.ItemType, parsed.Value.ItemType,
parsed.Value.MotionState, parsed.Value.MotionState,
parsed.Value.MotionTableId)); parsed.Value.MotionTableId,
parsed.Value.PhysicsState,
parsed.Value.ObjectDescriptionFlags));
} }
} }
else if (op == DeleteObject.Opcode) else if (op == DeleteObject.Opcode)

View file

@ -0,0 +1,123 @@
namespace AcDream.Core.Physics;
/// <summary>
/// The retail-faithful exemption gate at the top of
/// <c>CPhysicsObj::FindObjCollisions</c>. Decides — based on the moving
/// object's <see cref="ObjectInfoState"/> bits and the target's raw
/// <c>PhysicsState</c> + decoded <see cref="EntityCollisionFlags"/> —
/// whether collision against the target should be skipped entirely
/// (return <c>OK_TS</c>) or proceed to broad-phase / shape dispatch.
///
/// <para>
/// Ported from the named retail decompilation:
/// </para>
/// <list type="bullet">
/// <item><c>acclient_2013_pseudo_c.txt:276782</c> — target
/// <c>ETHEREAL_PS=0x4 &amp; IGNORE_COLLISIONS_PS=0x10</c>: walk through.</item>
/// <item><c>acclient_2013_pseudo_c.txt:276787</c> — viewer mover vs
/// creature target: walk through (camera ray ignores creatures).</item>
/// <item><c>acclient_2013_pseudo_c.txt:276971</c> — mover with
/// <c>IGNORE_CREATURES (state &amp; 0x400)</c> vs creature target:
/// walk through.</item>
/// <item><c>acclient_2013_pseudo_c.txt:276807-276839</c> — PvP rule:
/// <para>
/// If both are players: skip <em>unless</em> target is Impenetrable,
/// or both are PK, or both are PKLite. Mismatched PK status (PK vs
/// non-PK, PK vs PKLite) is exempted — players in different pools
/// pass through each other, matching retail muscle memory.
/// </para>
/// </item>
/// </list>
///
/// <para>
/// Cross-checked against ACE
/// <c>references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:381-405</c>
/// (line-for-line C# port of the same logic). Note: ACE adds
/// <c>state.HasFlag(IsImpenetrable)</c> (mover-impenetrable) to the
/// collide list; retail's pseudo-C only checks the target's
/// <c>IsImpenetrable()</c>. acdream follows retail.
/// </para>
/// </summary>
public static class CollisionExemption
{
private const uint ETHEREAL_PS = 0x4u; // acclient.h:2819
private const uint IGNORE_COLLISIONS_PS = 0x10u; // acclient.h:2821
/// <summary>
/// Should the moving object skip collision testing against this
/// target entirely? Returns <c>true</c> if exempt (no further
/// shape dispatch).
/// </summary>
/// <param name="targetState">Raw retail <c>PhysicsState</c> bits
/// captured at <c>CreateObject</c> time (ETHEREAL/IGNORE/etc.).</param>
/// <param name="targetFlags">Decoded
/// <see cref="EntityCollisionFlags"/> from the target's PWD bitfield
/// plus its <c>ItemType</c>-derived <c>IsCreature</c> bit.</param>
/// <param name="moverState">The moving object's
/// <see cref="ObjectInfoState"/> — typically the local player's
/// IsPlayer + (PK/PKLite/Impenetrable bits if known) flags.</param>
public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags,
ObjectInfoState moverState)
{
// 1. Target ETHEREAL + IGNORE_COLLISIONS → walk through.
// acclient_2013_pseudo_c.txt:276782 — wraps the entire body of
// FindObjCollisions; we hoist it as the first early-out.
if ((targetState & ETHEREAL_PS) != 0
&& (targetState & IGNORE_COLLISIONS_PS) != 0)
{
return true;
}
// 2. Viewer mover + creature target → walk through.
// acclient_2013_pseudo_c.txt:276787-276790.
bool moverIsViewer = (moverState & ObjectInfoState.IsViewer) != 0;
bool targetIsCreature = (targetFlags & EntityCollisionFlags.IsCreature) != 0;
if (moverIsViewer && targetIsCreature)
return true;
// 3. IGNORE_CREATURES mover + creature target → walk through.
// acclient_2013_pseudo_c.txt:276971.
bool moverIgnoresCreatures = (moverState & ObjectInfoState.IgnoreCreatures) != 0;
if (moverIgnoresCreatures && targetIsCreature)
return true;
// 4. PvP exemption block.
// acclient_2013_pseudo_c.txt:276807-276839.
bool moverIsPlayer = (moverState & ObjectInfoState.IsPlayer) != 0;
bool targetIsPlayer = (targetFlags & EntityCollisionFlags.IsPlayer) != 0;
if (moverIsPlayer && targetIsPlayer)
{
// Tentatively exempt (retail `ebp_1 = 1`). Then disqualify
// if any of the COLLIDE conditions hold.
bool collide = false;
// 4a. Impenetrable target → collide.
// acclient_2013_pseudo_c.txt:276826.
if ((targetFlags & EntityCollisionFlags.IsImpenetrable) != 0)
collide = true;
// 4b. Both PK → collide.
// acclient_2013_pseudo_c.txt:276832-276836.
if (!collide
&& (moverState & ObjectInfoState.IsPK) != 0
&& (targetFlags & EntityCollisionFlags.IsPK) != 0)
{
collide = true;
}
// 4c. Both PKLite → collide.
// acclient_2013_pseudo_c.txt:276837.
if (!collide
&& (moverState & ObjectInfoState.IsPKLite) != 0
&& (targetFlags & EntityCollisionFlags.IsPKLite) != 0)
{
collide = true;
}
if (!collide)
return true; // exempt — non-PK pair walks through
}
return false; // proceed to broad-phase + shape dispatch
}
}

View file

@ -0,0 +1,67 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Per-entity flags driving the retail-faithful PvP / Player /
/// Impenetrable exemption logic in <c>FindObjCollisions</c>. Decoded
/// from <c>PublicWeenieDesc._bitfield</c> at <c>CreateObject</c> time.
///
/// <para>
/// Bit positions (verified against
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:406898-406918</c>
/// where <c>ACCWeenieObject::IsPK/IsPKLite/IsImpenetrable</c> read directly
/// from <c>this-&gt;pwd._bitfield</c>):
/// </para>
/// <list type="bullet">
/// <item><c>BF_PLAYER = 0x8</c> (bit 3) → <see cref="IsPlayer"/></item>
/// <item><c>BF_PLAYER_KILLER = 0x20</c> (bit 5) → <see cref="IsPK"/></item>
/// <item><c>BF_FREE_PKSTATUS = 0x200000</c> (bit 21) → <see cref="IsImpenetrable"/></item>
/// <item><c>BF_PKLITE_PKSTATUS = 0x2000000</c> (bit 25) → <see cref="IsPKLite"/></item>
/// </list>
///
/// <para>
/// <see cref="IsCreature"/> is NOT a PWD bit — retail derives it from
/// <c>PublicWeenieDesc._type</c> matching <c>ITEM_TYPE_CREATURE</c>
/// (acclient.h ITEM_TYPE enum). Set at registration time by callers that
/// already know the item type.
/// </para>
/// </summary>
[Flags]
public enum EntityCollisionFlags : byte
{
None = 0x00,
/// <summary>Set when <c>BF_PLAYER (0x8)</c> is set in <c>pwd._bitfield</c>.</summary>
IsPlayer = 0x01,
/// <summary>Derived from <c>ItemType.Creature</c> on the spawn payload.</summary>
IsCreature = 0x02,
/// <summary>Set when <c>BF_PLAYER_KILLER (0x20)</c> is set.</summary>
IsPK = 0x04,
/// <summary>Set when <c>BF_PKLITE_PKSTATUS (0x2000000)</c> is set.</summary>
IsPKLite = 0x08,
/// <summary>Set when <c>BF_FREE_PKSTATUS (0x200000)</c> is set (a.k.a. "Free" PK status — cannot be PKed).</summary>
IsImpenetrable = 0x10,
}
/// <summary>Helpers to convert raw retail bitfields into <see cref="EntityCollisionFlags"/>.</summary>
public static class EntityCollisionFlagsExt
{
/// <summary>
/// Decode the player/PK/PKLite/Impenetrable bits from a
/// <c>PublicWeenieDesc._bitfield</c> value (the WeenieHeader trailer
/// field acdream's parser surfaces as <c>ObjectDescriptionFlags</c>).
///
/// <para>Bit positions per
/// <c>docs/research/named-retail/acclient.h:6431-6463</c>
/// (<c>PublicWeenieDesc::BitfieldIndex</c>) and
/// <c>acclient_2013_pseudo_c.txt:441868-441890</c>
/// (<c>PublicWeenieDesc::SetPlayerKillerStatus</c>).</para>
/// </summary>
public static EntityCollisionFlags FromPwdBitfield(uint bitfield)
{
var flags = EntityCollisionFlags.None;
if ((bitfield & 0x8u) != 0) flags |= EntityCollisionFlags.IsPlayer;
if ((bitfield & 0x20u) != 0) flags |= EntityCollisionFlags.IsPK;
if ((bitfield & 0x200000u) != 0) flags |= EntityCollisionFlags.IsImpenetrable;
if ((bitfield & 0x2000000u) != 0) flags |= EntityCollisionFlags.IsPKLite;
return flags;
}
}

View file

@ -388,13 +388,21 @@ public sealed class PhysicsEngine
float sphereRadius, float sphereHeight, float sphereRadius, float sphereHeight,
float stepUpHeight, float stepDownHeight, float stepUpHeight, float stepDownHeight,
bool isOnGround, bool isOnGround,
PhysicsBody? body = null) PhysicsBody? body = null,
ObjectInfoState moverFlags = ObjectInfoState.None)
{ {
var transition = new Transition(); var transition = new Transition();
transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true; transition.ObjectInfo.StepDown = true;
// Commit C 2026-04-29 — caller-supplied mover flags drive the
// retail PvP exemption block in FindObjCollisions. The local
// player passes IsPlayer (and PK/PKLite/Impenetrable when known
// from PlayerDescription); remote dead-reckoning passes None
// (matches non-player movement, all targets collide).
transition.ObjectInfo.State |= moverFlags;
if (isOnGround) if (isOnGround)
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;

View file

@ -19,11 +19,24 @@ public sealed class ShadowObjectRegistry
/// <summary> /// <summary>
/// Register an entity into the cells it overlaps based on world position + radius. /// Register an entity into the cells it overlaps based on world position + radius.
///
/// <para>
/// The optional <paramref name="state"/> + <paramref name="flags"/>
/// parameters carry retail <c>PhysicsState</c> bits and decoded
/// <see cref="EntityCollisionFlags"/> respectively, so the
/// <c>FindObjCollisions</c> retail-faithful exemption block (PvP rule,
/// ETHEREAL skip, viewer-vs-creature) can short-circuit without an
/// extra lookup. Default <c>state=0</c> + <c>flags=None</c> preserves
/// the original "static decoration" behavior — the existing 5
/// landblock-entity registration sites pass nothing.
/// </para>
/// </summary> /// </summary>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation, public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId, float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
ShadowCollisionType collisionType = ShadowCollisionType.BSP, ShadowCollisionType collisionType = ShadowCollisionType.BSP,
float cylHeight = 0f, float scale = 1.0f) float cylHeight = 0f, float scale = 1.0f,
uint state = 0u,
EntityCollisionFlags flags = EntityCollisionFlags.None)
{ {
Deregister(entityId); Deregister(entityId);
@ -38,7 +51,8 @@ public sealed class ShadowObjectRegistry
int minCy = Math.Max(0, (int)((localY - radius) / 24f)); int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f)); int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight, scale); var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius,
collisionType, cylHeight, scale, state, flags);
var cellIds = new List<uint>(); var cellIds = new List<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u; uint lbPrefix = landblockId & 0xFFFF0000u;
@ -62,6 +76,56 @@ public sealed class ShadowObjectRegistry
_entityToCells[entityId] = cellIds; _entityToCells[entityId] = cellIds;
} }
/// <summary>
/// Update an already-registered entity's world position + rotation,
/// preserving its <see cref="ShadowEntry.State"/>,
/// <see cref="ShadowEntry.Flags"/>, and shape parameters.
///
/// <para>
/// Cheaper than <see cref="Deregister"/> + <see cref="Register"/> for
/// the 510 Hz <c>UpdatePosition (0xF748)</c> stream the server emits
/// per visible entity: this is the path retail's
/// <c>CPhysicsObj::SetPosition</c> takes (cited at
/// <c>acclient_2013_pseudo_c.txt:284276</c>) — same shape, new cell
/// membership. If the entity isn't already registered, this is a
/// no-op so callers don't have to gate.
/// </para>
/// </summary>
public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation,
float worldOffsetX, float worldOffsetY, uint landblockId)
{
// Find the existing entry (any cell holds a copy with the same
// entity-scoped state + flags + shape).
if (!_entityToCells.TryGetValue(entityId, out var oldCells) || oldCells.Count == 0)
return;
ShadowEntry? template = null;
foreach (var oldCellId in oldCells)
{
if (_cells.TryGetValue(oldCellId, out var list))
{
foreach (var e in list)
{
if (e.EntityId == entityId)
{
template = e;
break;
}
}
}
if (template is not null) break;
}
if (template is null)
return;
// Preserve everything except position + rotation.
var t = template.Value;
Register(entityId, t.GfxObjId, worldPos, rotation, t.Radius,
worldOffsetX, worldOffsetY, landblockId,
t.CollisionType, t.CylHeight, t.Scale,
t.State, t.Flags);
}
/// <summary>Remove an entity from all cells it was registered in.</summary> /// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId) public void Deregister(uint entityId)
{ {
@ -203,4 +267,18 @@ public readonly record struct ShadowEntry(
float Radius, float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP, ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
float CylHeight = 0f, float CylHeight = 0f,
float Scale = 1.0f); float Scale = 1.0f,
/// <summary>
/// Retail <c>PhysicsState</c> bits (<c>acclient.h:2815</c>). Used
/// by <c>FindObjCollisions</c> to honor <c>ETHEREAL_PS=0x4</c> +
/// <c>IGNORE_COLLISIONS_PS=0x10</c> short-circuits. Zero for static
/// landblock entities (default behavior matches pre-Commit-A).
/// </summary>
uint State = 0u,
/// <summary>
/// Decoded player / PK / PKLite / Impenetrable flags driving the
/// retail PvP exemption block in <c>FindObjCollisions</c>. Built
/// from <c>PWD._bitfield</c> at <c>CreateObject</c> time via
/// <see cref="EntityCollisionFlagsExt.FromPwdBitfield(uint)"/>.
/// </summary>
EntityCollisionFlags Flags = EntityCollisionFlags.None);

View file

@ -34,6 +34,13 @@ public enum ObjectInfoState : uint
IsPlayer = 0x100, IsPlayer = 0x100,
EdgeSlide = 0x200, EdgeSlide = 0x200,
IgnoreCreatures = 0x400, IgnoreCreatures = 0x400,
/// <summary>Bits added 2026-04-29 (Commit A live-entity collision):
/// the moving object's PK status drives the player-vs-player
/// exemption block in <c>FindObjCollisions</c> per
/// <c>acclient_2013_pseudo_c.txt:276807-276839</c>. Values match
/// retail's <c>OBJECTINFO::state</c> bits (acclient.h:6190-6194).</summary>
IsPK = 0x800,
IsPKLite = 0x1000,
} }
/// <summary> /// <summary>
@ -800,6 +807,17 @@ public sealed class Transition
if (distToCurr > maxReach) if (distToCurr > maxReach)
continue; continue;
// Commit C 2026-04-29 — retail exemption block at the top of
// CPhysicsObj::FindObjCollisions
// (acclient_2013_pseudo_c.txt:276782-276839,276971). Skips
// ethereal+ignore-collisions, viewer-vs-creature,
// IGNORE_CREATURES movers, and the player-vs-player PvP
// pass-through (non-PK pair / mismatched PK). Static
// landblock entries register with State=0 and Flags=None,
// so this is a cheap fall-through for them.
if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State))
continue;
TransitionState result; TransitionState result;
if (obj.CollisionType == ShadowCollisionType.BSP) if (obj.CollisionType == ShadowCollisionType.BSP)

View file

@ -23,10 +23,63 @@ public sealed class CreateObjectTests
Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType); Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType);
} }
// -----------------------------------------------------------------------
// Commit A of 2026-04-29 live-entity collision port:
// PhysicsState (post-flags u32) + ObjectDescriptionFlags (PWD bitfield)
// must be surfaced for downstream collision registration.
// -----------------------------------------------------------------------
[Fact]
public void TryParse_PhysicsState_Parsed()
{
// ETHEREAL_PS = 0x4 + IGNORE_COLLISIONS_PS = 0x10 → 0x14
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000003u, name: "GhostNpc",
itemType: (uint)ItemType.Creature,
physicsState: 0x14u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x14u, parsed!.Value.PhysicsState);
}
[Fact]
public void TryParse_ObjectDescriptionFlags_PlayerKillerBitsSurface()
{
// BF_PLAYER (0x8) | BF_PLAYER_KILLER (0x20) → a PK player.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000004u, name: "+PkPlayer",
itemType: (uint)ItemType.Creature,
objectDescriptionFlags: 0x8u | 0x20u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x28u, parsed!.Value.ObjectDescriptionFlags);
}
[Fact]
public void TryParse_ObjectDescriptionFlags_PkLiteBit()
{
// BF_PLAYER (0x8) | BF_PKLITE_PKSTATUS (0x2000000) → a PK-lite player.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000005u, name: "+PklPlayer",
itemType: (uint)ItemType.Creature,
objectDescriptionFlags: 0x8u | 0x2000000u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x2000008u, parsed!.Value.ObjectDescriptionFlags);
}
private static byte[] BuildMinimalCreateObjectWithWeenieHeader( private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid, uint guid,
string name, string name,
uint itemType) uint itemType,
uint physicsState = 0,
uint objectDescriptionFlags = 0)
{ {
var bytes = new List<byte>(); var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode); WriteU32(bytes, CreateObject.Opcode);
@ -38,9 +91,9 @@ public sealed class CreateObjectTests
bytes.Add(0); bytes.Add(0);
bytes.Add(0); bytes.Add(0);
// PhysicsData: no flags, empty physics state, then 9 sequence stamps. // PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps.
WriteU32(bytes, 0);
WriteU32(bytes, 0); WriteU32(bytes, 0);
WriteU32(bytes, physicsState);
for (int i = 0; i < 9; i++) for (int i = 0; i < 9; i++)
WriteU16(bytes, 0); WriteU16(bytes, 0);
Align4(bytes); Align4(bytes);
@ -51,7 +104,7 @@ public sealed class CreateObjectTests
WritePackedDword(bytes, 0x1234); // WeenieClassId WritePackedDword(bytes, 0x1234); // WeenieClassId
WritePackedDword(bytes, 0); // IconId via known-type writer WritePackedDword(bytes, 0); // IconId via known-type writer
WriteU32(bytes, itemType); WriteU32(bytes, itemType);
WriteU32(bytes, 0); // ObjectDescriptionFlags WriteU32(bytes, objectDescriptionFlags);
Align4(bytes); Align4(bytes);
return bytes.ToArray(); return bytes.ToArray();

View file

@ -0,0 +1,169 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Unit tests for <see cref="CollisionExemption"/> — Commit C of the
/// 2026-04-29 live-entity collision port. Covers retail's
/// <c>CPhysicsObj::FindObjCollisions</c> exemption block, ported
/// line-for-line from
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:276782-276839,276971</c>.
///
/// <para>
/// Behaviour matrix (target / mover columns):
/// </para>
/// <list type="table">
/// <listheader><term>Mover</term><term>Target</term><description>Skip?</description></listheader>
/// <item><term>any</term><term>ETHEREAL+IGNORE_COLLISIONS</term><description>YES (early-out)</description></item>
/// <item><term>IsViewer</term><term>IsCreature</term><description>YES (camera ray-test passes through)</description></item>
/// <item><term>IGNORE_CREATURES</term><term>IsCreature</term><description>YES (mover walks through creatures)</description></item>
/// <item><term>IsPlayer</term><term>IsPlayer (no PK)</term><description>YES (non-PK pair walks through)</description></item>
/// <item><term>IsPlayer + IsPK</term><term>IsPlayer + IsPK</term><description>NO (PK pair collides)</description></item>
/// <item><term>IsPlayer + IsPKLite</term><term>IsPlayer + IsPKLite</term><description>NO (PKLite pair collides)</description></item>
/// <item><term>IsPlayer</term><term>IsPlayer + IsImpenetrable</term><description>NO (Impenetrable target always collides)</description></item>
/// <item><term>IsPlayer + IsPK</term><term>IsPlayer (no PK)</term><description>YES (mismatched PK skip)</description></item>
/// <item><term>IsPlayer</term><term>IsCreature (NPC)</term><description>NO (player vs NPC always collides)</description></item>
/// </list>
/// </summary>
public class CollisionExemptionTests
{
private const uint ETHEREAL_PS = 0x4u;
private const uint IGNORE_COLLISIONS_PS = 0x10u;
[Fact]
public void EtherealAndIgnoreCollisions_AlwaysSkipped()
{
// Target with both bits set is exempted from any mover.
Assert.True(CollisionExemption.ShouldSkip(
targetState: ETHEREAL_PS | IGNORE_COLLISIONS_PS,
targetFlags: EntityCollisionFlags.None,
moverState: ObjectInfoState.IsPlayer));
}
[Fact]
public void EtherealOnly_NotSkipped()
{
// Target with ETHEREAL but NOT IGNORE_COLLISIONS does not bail
// out at the first gate — collision proceeds. (Step-down marks
// obstruction_ethereal, but does not exempt.)
Assert.False(CollisionExemption.ShouldSkip(
targetState: ETHEREAL_PS,
targetFlags: EntityCollisionFlags.None,
moverState: ObjectInfoState.IsPlayer));
}
[Fact]
public void Viewer_VsCreature_Skipped()
{
// Camera-ray viewer transitions through creatures.
Assert.True(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsCreature,
moverState: ObjectInfoState.IsViewer));
}
[Fact]
public void Viewer_VsNonCreature_NotSkipped()
{
Assert.False(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.None,
moverState: ObjectInfoState.IsViewer));
}
[Fact]
public void IgnoreCreatures_VsCreature_Skipped()
{
// Per acclient_2013_pseudo_c.txt:276971 — an arrow with
// IGNORE_CREATURES doesn't get blocked by the very monster it's
// tracking towards (until missile_ignore filters its target).
Assert.True(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsCreature,
moverState: ObjectInfoState.IgnoreCreatures));
}
[Fact]
public void NonPkPlayer_VsNonPkPlayer_Skipped()
{
// The user-visible payoff: two ordinary players walk through each
// other instead of blocking.
Assert.True(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer,
moverState: ObjectInfoState.IsPlayer));
}
[Fact]
public void Pk_VsPk_NotSkipped()
{
// Two PK players collide.
Assert.False(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPK,
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
}
[Fact]
public void PkLite_VsPkLite_NotSkipped()
{
Assert.False(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPKLite,
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPKLite));
}
[Fact]
public void Pk_VsNonPk_Skipped()
{
// Mismatched PK status: still exempt — only matching pair collides.
Assert.True(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer,
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
}
[Fact]
public void Pk_VsPkLite_Skipped()
{
// PK and PKLite are different pools — pair doesn't match.
Assert.True(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPKLite,
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
}
[Fact]
public void ImpenetrableTarget_VsAnyPlayer_NotSkipped()
{
// Impenetrable target ("Free" PK status) always collides with
// any player mover — regardless of mover's PK state.
Assert.False(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsImpenetrable,
moverState: ObjectInfoState.IsPlayer));
}
[Fact]
public void Player_VsCreature_NotSkipped()
{
// PvP exemption only applies player-on-player. Player vs creature
// (NPC, monster) is the normal blocking case.
Assert.False(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsCreature,
moverState: ObjectInfoState.IsPlayer));
}
[Fact]
public void NonPlayerMover_VsPlayer_NotSkipped()
{
// PvP rule requires BOTH to be players. Mover is not a player
// (e.g., dead-reckoned remote NPC) → no exemption applies.
Assert.False(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer,
moverState: ObjectInfoState.None));
}
}

View file

@ -0,0 +1,89 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Unit tests for <see cref="EntityCollisionFlags"/> — Commit A of the
/// 2026-04-29 live-entity collision port. Verifies retail-faithful
/// conversion from PWD bitfield bits to the boolean collision-decision
/// flags that <c>FindObjCollisions</c> consumes for the PvP exemption.
///
/// <para>Bit positions confirmed against:</para>
/// <list type="bullet">
/// <item><c>docs/research/named-retail/acclient_2013_pseudo_c.txt:406898-406918</c>
/// (<c>ACCWeenieObject::IsPKLite/IsPK/IsImpenetrable</c>) — the
/// retail client itself reads bits 25 / 5 / 21 of <c>pwd._bitfield</c>.</item>
/// <item><c>docs/research/named-retail/acclient.h:6431-6463</c>
/// (<c>PublicWeenieDesc::BitfieldIndex</c>) — names the bits:
/// <c>BF_PLAYER=0x8</c>, <c>BF_PLAYER_KILLER=0x20</c>,
/// <c>BF_FREE_PKSTATUS=0x200000</c>, <c>BF_PKLITE_PKSTATUS=0x2000000</c>.</item>
/// <item><c>docs/research/named-retail/acclient_2013_pseudo_c.txt:441868-441890</c>
/// (<c>PublicWeenieDesc::SetPlayerKillerStatus</c>) — confirms
/// the writer maps PKStatusEnum values onto these exact bits.</item>
/// </list>
/// </summary>
public class EntityCollisionFlagsTests
{
[Fact]
public void FromPwdBitfield_AllZeros_NoFlags()
{
Assert.Equal(EntityCollisionFlags.None, EntityCollisionFlagsExt.FromPwdBitfield(0u));
}
[Fact]
public void FromPwdBitfield_PlayerBit_SetsIsPlayer()
{
// BF_PLAYER = 0x8 (bit 3)
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x8u);
Assert.True(flags.HasFlag(EntityCollisionFlags.IsPlayer));
Assert.False(flags.HasFlag(EntityCollisionFlags.IsPK));
}
[Fact]
public void FromPwdBitfield_PlayerKillerBit_SetsIsPK()
{
// BF_PLAYER_KILLER = 0x20 (bit 5)
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x20u);
Assert.True(flags.HasFlag(EntityCollisionFlags.IsPK));
Assert.False(flags.HasFlag(EntityCollisionFlags.IsPKLite));
}
[Fact]
public void FromPwdBitfield_PkLiteBit_SetsIsPKLite()
{
// BF_PKLITE_PKSTATUS = 0x2000000 (bit 25)
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x2000000u);
Assert.True(flags.HasFlag(EntityCollisionFlags.IsPKLite));
Assert.False(flags.HasFlag(EntityCollisionFlags.IsPK));
}
[Fact]
public void FromPwdBitfield_FreePkStatusBit_SetsIsImpenetrable()
{
// BF_FREE_PKSTATUS = 0x200000 (bit 21)
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x200000u);
Assert.True(flags.HasFlag(EntityCollisionFlags.IsImpenetrable));
}
[Fact]
public void FromPwdBitfield_PlayerAndPK_SetsBoth()
{
// A PK player: BF_PLAYER (0x8) | BF_PLAYER_KILLER (0x20) = 0x28
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x28u);
Assert.True(flags.HasFlag(EntityCollisionFlags.IsPlayer));
Assert.True(flags.HasFlag(EntityCollisionFlags.IsPK));
Assert.False(flags.HasFlag(EntityCollisionFlags.IsPKLite));
Assert.False(flags.HasFlag(EntityCollisionFlags.IsImpenetrable));
}
[Fact]
public void FromPwdBitfield_UnrelatedBits_Ignored()
{
// Set BF_OPENABLE (0x1), BF_INSCRIBABLE (0x2), BF_STUCK (0x4) — none
// map to collision flags. A creature spawn might have BF_ATTACKABLE
// (0x10) set; that's ItemType-derived IsCreature, not a PvP flag.
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x1u | 0x2u | 0x4u | 0x10u);
Assert.Equal(EntityCollisionFlags.None, flags);
}
}

View file

@ -177,4 +177,79 @@ public class ShadowObjectRegistryTests
var objs = reg.GetObjectsInCell(cellId); var objs = reg.GetObjectsInCell(cellId);
Assert.Contains(objs, e => e.EntityId == 99u); Assert.Contains(objs, e => e.EntityId == 99u);
} }
// -----------------------------------------------------------------------
// UpdatePosition — Commit A of 2026-04-29 live-entity collision port
// -----------------------------------------------------------------------
[Fact]
public void UpdatePosition_MovedEntity_NewCellOccupied()
{
// Entity starts at local (12, 12) — cell (0,0).
var reg = new ShadowObjectRegistry();
reg.Register(42u, 0x01000010u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 0.5f, OffX, OffY, LbId);
// Move to local (60, 12) — cell (2, 0). Same landblock.
reg.UpdatePosition(42u, new Vector3(60f, 12f, 50f), Quaternion.Identity, OffX, OffY, LbId);
// Old cell empty, new cell holds the entity.
Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); // cell (0,0)
var newCell = reg.GetObjectsInCell(LbId | 17u); // cell (2,0) → 2*8+0+1
Assert.Single(newCell);
Assert.Equal(42u, newCell[0].EntityId);
Assert.Equal(1, reg.TotalRegistered); // not duplicated
}
[Fact]
public void UpdatePosition_PreservesFlags()
{
// Register with PK flags + PhysicsState; UpdatePosition must keep them.
var reg = new ShadowObjectRegistry();
reg.Register(50u, 0x01000011u, new Vector3(12f, 12f, 50f), Quaternion.Identity,
0.5f, OffX, OffY, LbId,
state: 0x4u, // ETHEREAL_PS
flags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPK);
reg.UpdatePosition(50u, new Vector3(60f, 12f, 50f), Quaternion.Identity, OffX, OffY, LbId);
var newCell = reg.GetObjectsInCell(LbId | 17u);
Assert.Single(newCell);
Assert.Equal(0x4u, newCell[0].State);
Assert.Equal(EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPK, newCell[0].Flags);
}
[Fact]
public void UpdatePosition_UnregisteredEntity_NoOp()
{
var reg = new ShadowObjectRegistry();
// Should not throw, should not register a new entity.
reg.UpdatePosition(99u, new Vector3(12f, 12f, 50f), Quaternion.Identity, OffX, OffY, LbId);
Assert.Equal(0, reg.TotalRegistered);
}
[Fact]
public void Register_WithStateAndFlags_StoredOnEntry()
{
var reg = new ShadowObjectRegistry();
reg.Register(60u, 0x01000012u, new Vector3(12f, 12f, 50f), Quaternion.Identity,
0.5f, OffX, OffY, LbId,
state: 0x10u, // IGNORE_COLLISIONS_PS
flags: EntityCollisionFlags.IsImpenetrable);
var entry = reg.GetObjectsInCell(LbId | 1u)[0];
Assert.Equal(0x10u, entry.State);
Assert.Equal(EntityCollisionFlags.IsImpenetrable, entry.Flags);
}
[Fact]
public void Register_DefaultStateAndFlags_AreZeroAndNone()
{
var reg = new ShadowObjectRegistry();
reg.Register(70u, 0x01000013u, new Vector3(12f, 12f, 50f), Quaternion.Identity,
0.5f, OffX, OffY, LbId);
var entry = reg.GetObjectsInCell(LbId | 1u)[0];
Assert.Equal(0u, entry.State);
Assert.Equal(EntityCollisionFlags.None, entry.Flags);
}
} }