diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index d3c25c46..8084231a 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -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;
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 2b9bfb0e..ba3c9781 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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
}
}
+ ///
+ /// Commit B 2026-04-29 — register a live (server-spawned) entity into
+ /// the as a single collision body.
+ /// One entry per entity (in contrast to static scenery's per-CylSphere
+ /// registration) so RemoveLiveEntityByServerGuid's single
+ /// Deregister(entity.Id) cleans it up without leaks.
+ ///
+ ///
+ /// Geometry-priority order matches retail
+ /// (acclient_2013_pseudo_c.txt:276858-276987): CylSpheres >
+ /// Sphere fallback > Setup.Radius. Phantom Setups (no shape) are
+ /// rejected — retail's FindObjCollisions falls through to
+ /// OK_TS in that case.
+ ///
+ ///
+ ///
+ /// Carries derived from the PWD
+ /// bitfield (acclient.h:6431-6463) plus IsCreature
+ /// derived from the inbound ItemType. Commit C consumes these in
+ /// the PvP exemption block.
+ ///
+ ///
+ 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
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 39b30cde..d574887b 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -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.
///
+ ///
+ ///
+ /// (acclient.h:2815) carries flag
+ /// bits like ETHEREAL_PS=0x4, IGNORE_COLLISIONS_PS=0x10,
+ /// HAS_PHYSICS_BSP_PS=0x10000 — the bits retail's
+ /// FindObjCollisions 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.
+ ///
+ ///
+ /// is the PWD._bitfield
+ /// trailer (acclient.h:6431-6463) — bits include BF_PLAYER (0x8),
+ /// BF_PLAYER_KILLER (0x20), BF_FREE_PKSTATUS (0x200000),
+ /// BF_PKLITE_PKSTATUS (0x2000000). Decoded into
+ /// EntityCollisionFlags at registration time for the PvP
+ /// exemption gate.
+ ///
+ ///
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);
///
/// The relevant subset of the server-sent MovementData /
@@ -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
{
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 885ec634..030573b8 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -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);
/// Fires when the session finishes parsing a CreateObject.
public event Action? 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)
diff --git a/src/AcDream.Core/Physics/CollisionExemption.cs b/src/AcDream.Core/Physics/CollisionExemption.cs
new file mode 100644
index 00000000..89b368d6
--- /dev/null
+++ b/src/AcDream.Core/Physics/CollisionExemption.cs
@@ -0,0 +1,123 @@
+namespace AcDream.Core.Physics;
+
+///
+/// The retail-faithful exemption gate at the top of
+/// CPhysicsObj::FindObjCollisions. Decides — based on the moving
+/// object's bits and the target's raw
+/// PhysicsState + decoded —
+/// whether collision against the target should be skipped entirely
+/// (return OK_TS) or proceed to broad-phase / shape dispatch.
+///
+///
+/// Ported from the named retail decompilation:
+///
+///
+/// - acclient_2013_pseudo_c.txt:276782 — target
+/// ETHEREAL_PS=0x4 & IGNORE_COLLISIONS_PS=0x10: walk through.
+/// - acclient_2013_pseudo_c.txt:276787 — viewer mover vs
+/// creature target: walk through (camera ray ignores creatures).
+/// - acclient_2013_pseudo_c.txt:276971 — mover with
+/// IGNORE_CREATURES (state & 0x400) vs creature target:
+/// walk through.
+/// - acclient_2013_pseudo_c.txt:276807-276839 — PvP rule:
+///
+/// If both are players: skip unless 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.
+///
+///
+///
+///
+///
+/// Cross-checked against ACE
+/// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:381-405
+/// (line-for-line C# port of the same logic). Note: ACE adds
+/// state.HasFlag(IsImpenetrable) (mover-impenetrable) to the
+/// collide list; retail's pseudo-C only checks the target's
+/// IsImpenetrable(). acdream follows retail.
+///
+///
+public static class CollisionExemption
+{
+ private const uint ETHEREAL_PS = 0x4u; // acclient.h:2819
+ private const uint IGNORE_COLLISIONS_PS = 0x10u; // acclient.h:2821
+
+ ///
+ /// Should the moving object skip collision testing against this
+ /// target entirely? Returns true if exempt (no further
+ /// shape dispatch).
+ ///
+ /// Raw retail PhysicsState bits
+ /// captured at CreateObject time (ETHEREAL/IGNORE/etc.).
+ /// Decoded
+ /// from the target's PWD bitfield
+ /// plus its ItemType-derived IsCreature bit.
+ /// The moving object's
+ /// — typically the local player's
+ /// IsPlayer + (PK/PKLite/Impenetrable bits if known) flags.
+ 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
+ }
+}
diff --git a/src/AcDream.Core/Physics/EntityCollisionFlags.cs b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
new file mode 100644
index 00000000..5ee86a85
--- /dev/null
+++ b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
@@ -0,0 +1,67 @@
+namespace AcDream.Core.Physics;
+
+///
+/// Per-entity flags driving the retail-faithful PvP / Player /
+/// Impenetrable exemption logic in FindObjCollisions. Decoded
+/// from PublicWeenieDesc._bitfield at CreateObject time.
+///
+///
+/// Bit positions (verified against
+/// docs/research/named-retail/acclient_2013_pseudo_c.txt:406898-406918
+/// where ACCWeenieObject::IsPK/IsPKLite/IsImpenetrable read directly
+/// from this->pwd._bitfield):
+///
+///
+/// - BF_PLAYER = 0x8 (bit 3) →
+/// - BF_PLAYER_KILLER = 0x20 (bit 5) →
+/// - BF_FREE_PKSTATUS = 0x200000 (bit 21) →
+/// - BF_PKLITE_PKSTATUS = 0x2000000 (bit 25) →
+///
+///
+///
+/// is NOT a PWD bit — retail derives it from
+/// PublicWeenieDesc._type matching ITEM_TYPE_CREATURE
+/// (acclient.h ITEM_TYPE enum). Set at registration time by callers that
+/// already know the item type.
+///
+///
+[Flags]
+public enum EntityCollisionFlags : byte
+{
+ None = 0x00,
+ /// Set when BF_PLAYER (0x8) is set in pwd._bitfield.
+ IsPlayer = 0x01,
+ /// Derived from ItemType.Creature on the spawn payload.
+ IsCreature = 0x02,
+ /// Set when BF_PLAYER_KILLER (0x20) is set.
+ IsPK = 0x04,
+ /// Set when BF_PKLITE_PKSTATUS (0x2000000) is set.
+ IsPKLite = 0x08,
+ /// Set when BF_FREE_PKSTATUS (0x200000) is set (a.k.a. "Free" PK status — cannot be PKed).
+ IsImpenetrable = 0x10,
+}
+
+/// Helpers to convert raw retail bitfields into .
+public static class EntityCollisionFlagsExt
+{
+ ///
+ /// Decode the player/PK/PKLite/Impenetrable bits from a
+ /// PublicWeenieDesc._bitfield value (the WeenieHeader trailer
+ /// field acdream's parser surfaces as ObjectDescriptionFlags).
+ ///
+ /// Bit positions per
+ /// docs/research/named-retail/acclient.h:6431-6463
+ /// (PublicWeenieDesc::BitfieldIndex) and
+ /// acclient_2013_pseudo_c.txt:441868-441890
+ /// (PublicWeenieDesc::SetPlayerKillerStatus).
+ ///
+ 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;
+ }
+}
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index bb26c54a..4a934031 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -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;
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index e7c29507..6b4ea114 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -19,11 +19,24 @@ public sealed class ShadowObjectRegistry
///
/// Register an entity into the cells it overlaps based on world position + radius.
+ ///
+ ///
+ /// The optional +
+ /// parameters carry retail PhysicsState bits and decoded
+ /// respectively, so the
+ /// FindObjCollisions retail-faithful exemption block (PvP rule,
+ /// ETHEREAL skip, viewer-vs-creature) can short-circuit without an
+ /// extra lookup. Default state=0 + flags=None preserves
+ /// the original "static decoration" behavior — the existing 5
+ /// landblock-entity registration sites pass nothing.
+ ///
///
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 lbPrefix = landblockId & 0xFFFF0000u;
@@ -62,6 +76,56 @@ public sealed class ShadowObjectRegistry
_entityToCells[entityId] = cellIds;
}
+ ///
+ /// Update an already-registered entity's world position + rotation,
+ /// preserving its ,
+ /// , and shape parameters.
+ ///
+ ///
+ /// Cheaper than + for
+ /// the 5–10 Hz UpdatePosition (0xF748) stream the server emits
+ /// per visible entity: this is the path retail's
+ /// CPhysicsObj::SetPosition takes (cited at
+ /// acclient_2013_pseudo_c.txt:284276) — same shape, new cell
+ /// membership. If the entity isn't already registered, this is a
+ /// no-op so callers don't have to gate.
+ ///
+ ///
+ 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);
+ }
+
/// Remove an entity from all cells it was registered in.
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,
+ ///
+ /// Retail PhysicsState bits (acclient.h:2815). Used
+ /// by FindObjCollisions to honor ETHEREAL_PS=0x4 +
+ /// IGNORE_COLLISIONS_PS=0x10 short-circuits. Zero for static
+ /// landblock entities (default behavior matches pre-Commit-A).
+ ///
+ uint State = 0u,
+ ///
+ /// Decoded player / PK / PKLite / Impenetrable flags driving the
+ /// retail PvP exemption block in FindObjCollisions. Built
+ /// from PWD._bitfield at CreateObject time via
+ /// .
+ ///
+ EntityCollisionFlags Flags = EntityCollisionFlags.None);
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 62a9c8bf..57d6e6d2 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -34,6 +34,13 @@ public enum ObjectInfoState : uint
IsPlayer = 0x100,
EdgeSlide = 0x200,
IgnoreCreatures = 0x400,
+ /// Bits added 2026-04-29 (Commit A live-entity collision):
+ /// the moving object's PK status drives the player-vs-player
+ /// exemption block in FindObjCollisions per
+ /// acclient_2013_pseudo_c.txt:276807-276839. Values match
+ /// retail's OBJECTINFO::state bits (acclient.h:6190-6194).
+ IsPK = 0x800,
+ IsPKLite = 0x1000,
}
///
@@ -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)
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
index a7dea334..71bf72db 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
@@ -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();
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();
diff --git a/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs b/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs
new file mode 100644
index 00000000..b8431658
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs
@@ -0,0 +1,169 @@
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Unit tests for — Commit C of the
+/// 2026-04-29 live-entity collision port. Covers retail's
+/// CPhysicsObj::FindObjCollisions exemption block, ported
+/// line-for-line from
+/// docs/research/named-retail/acclient_2013_pseudo_c.txt:276782-276839,276971.
+///
+///
+/// Behaviour matrix (target / mover columns):
+///
+///
+/// MoverTargetSkip?
+/// - anyETHEREAL+IGNORE_COLLISIONSYES (early-out)
+/// - IsViewerIsCreatureYES (camera ray-test passes through)
+/// - IGNORE_CREATURESIsCreatureYES (mover walks through creatures)
+/// - IsPlayerIsPlayer (no PK)YES (non-PK pair walks through)
+/// - IsPlayer + IsPKIsPlayer + IsPKNO (PK pair collides)
+/// - IsPlayer + IsPKLiteIsPlayer + IsPKLiteNO (PKLite pair collides)
+/// - IsPlayerIsPlayer + IsImpenetrableNO (Impenetrable target always collides)
+/// - IsPlayer + IsPKIsPlayer (no PK)YES (mismatched PK skip)
+/// - IsPlayerIsCreature (NPC)NO (player vs NPC always collides)
+///
+///
+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));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs b/tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs
new file mode 100644
index 00000000..9c8cff0f
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs
@@ -0,0 +1,89 @@
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Unit tests for — 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 FindObjCollisions consumes for the PvP exemption.
+///
+/// Bit positions confirmed against:
+///
+/// - docs/research/named-retail/acclient_2013_pseudo_c.txt:406898-406918
+/// (ACCWeenieObject::IsPKLite/IsPK/IsImpenetrable) — the
+/// retail client itself reads bits 25 / 5 / 21 of pwd._bitfield.
+/// - docs/research/named-retail/acclient.h:6431-6463
+/// (PublicWeenieDesc::BitfieldIndex) — names the bits:
+/// BF_PLAYER=0x8, BF_PLAYER_KILLER=0x20,
+/// BF_FREE_PKSTATUS=0x200000, BF_PKLITE_PKSTATUS=0x2000000.
+/// - docs/research/named-retail/acclient_2013_pseudo_c.txt:441868-441890
+/// (PublicWeenieDesc::SetPlayerKillerStatus) — confirms
+/// the writer maps PKStatusEnum values onto these exact bits.
+///
+///
+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);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
index 2f2b4636..73143d8c 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
@@ -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);
+ }
}