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,
stepDownHeight: 0.04f, // retail default
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.
_body.Position = resolveResult.Position;

View file

@ -2067,6 +2067,18 @@ public sealed class GameWindow : IDisposable
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
_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
// character so UpdatePlayerAnimation can pass it to GetIdleCycle.
// 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)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
@ -2980,6 +3086,18 @@ public sealed class GameWindow : IDisposable
entity.Position = worldPos;
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
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
// 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
/// physics-description-flag bits may not be set on every CreateObject.
/// </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(
uint Guid,
ServerPosition? Position,
@ -100,7 +118,9 @@ public static class CreateObject
ushort InstanceSequence = 0,
ushort TeleportSequence = 0,
ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0);
ushort ForcePositionSequence = 0,
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -260,6 +280,12 @@ public static class CreateObject
float? objScale = null;
ServerMotionState? motionState = 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
{
@ -334,7 +360,10 @@ public static class CreateObject
if (body.Length - pos < 8) return null;
var physicsFlags = (PhysicsDescriptionFlag)BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
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)
{
@ -463,7 +492,16 @@ public static class CreateObject
if (body.Length - pos >= 4)
itemType = ReadU32(body, ref pos);
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);
}
catch { /* truncated name — partial result is still useful */ }
@ -471,13 +509,15 @@ public static class CreateObject
return new Parsed(guid, position, setupTableId, animParts,
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
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
Parsed PartialResult() => new(
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
{

View file

@ -56,7 +56,14 @@ public sealed class WorldSession : IDisposable
string? Name,
uint? ItemType,
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>
public event Action<EntitySpawn>? EntitySpawned;
@ -648,7 +655,9 @@ public sealed class WorldSession : IDisposable
parsed.Value.Name,
parsed.Value.ItemType,
parsed.Value.MotionState,
parsed.Value.MotionTableId));
parsed.Value.MotionTableId,
parsed.Value.PhysicsState,
parsed.Value.ObjectDescriptionFlags));
}
}
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 stepUpHeight, float stepDownHeight,
bool isOnGround,
PhysicsBody? body = null)
PhysicsBody? body = null,
ObjectInfoState moverFlags = ObjectInfoState.None)
{
var transition = new Transition();
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
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)
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;

View file

@ -19,11 +19,24 @@ public sealed class ShadowObjectRegistry
/// <summary>
/// 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>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
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);
@ -38,7 +51,8 @@ public sealed class ShadowObjectRegistry
int minCy = Math.Max(0, (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>();
uint lbPrefix = landblockId & 0xFFFF0000u;
@ -62,6 +76,56 @@ public sealed class ShadowObjectRegistry
_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>
public void Deregister(uint entityId)
{
@ -203,4 +267,18 @@ public readonly record struct ShadowEntry(
float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
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,
EdgeSlide = 0x200,
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>
@ -800,6 +807,17 @@ public sealed class Transition
if (distToCurr > maxReach)
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;
if (obj.CollisionType == ShadowCollisionType.BSP)

View file

@ -23,10 +23,63 @@ public sealed class CreateObjectTests
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(
uint guid,
string name,
uint itemType)
uint itemType,
uint physicsState = 0,
uint objectDescriptionFlags = 0)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
@ -38,9 +91,9 @@ public sealed class CreateObjectTests
bytes.Add(0);
bytes.Add(0);
// PhysicsData: no flags, empty physics state, then 9 sequence stamps.
WriteU32(bytes, 0);
// PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps.
WriteU32(bytes, 0);
WriteU32(bytes, physicsState);
for (int i = 0; i < 9; i++)
WriteU16(bytes, 0);
Align4(bytes);
@ -51,7 +104,7 @@ public sealed class CreateObjectTests
WritePackedDword(bytes, 0x1234); // WeenieClassId
WritePackedDword(bytes, 0); // IconId via known-type writer
WriteU32(bytes, itemType);
WriteU32(bytes, 0); // ObjectDescriptionFlags
WriteU32(bytes, objectDescriptionFlags);
Align4(bytes);
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);
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);
}
}