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>
This commit is contained in:
Erik 2026-04-29 13:12:56 +02:00
parent 90aa74a3cb
commit ffefc6977f
8 changed files with 432 additions and 14 deletions

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,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

@ -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>

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,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);
}
}