diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 39b30cd..d574887 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 885ec63..030573b 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/EntityCollisionFlags.cs b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
new file mode 100644
index 0000000..5ee86a8
--- /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/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index e7c2950..6b4ea11 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 62a9c8b..be451f9 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,
}
///
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
index a7dea33..71bf72d 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/EntityCollisionFlagsTests.cs b/tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs
new file mode 100644
index 0000000..9c8cff0
--- /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 2f2b463..73143d8 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);
+ }
}