From ffefc6977f3590ce9135071c86defb2e0363a156 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 13:12:56 +0200 Subject: [PATCH] feat(physics): live-entity collision plumbing (Commit A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core.Net/Messages/CreateObject.cs | 50 +++++++++-- src/AcDream.Core.Net/WorldSession.cs | 13 ++- .../Physics/EntityCollisionFlags.cs | 67 ++++++++++++++ .../Physics/ShadowObjectRegistry.cs | 84 ++++++++++++++++- src/AcDream.Core/Physics/TransitionTypes.cs | 7 ++ .../Messages/CreateObjectTests.cs | 61 ++++++++++++- .../Physics/EntityCollisionFlagsTests.cs | 89 +++++++++++++++++++ .../Physics/ShadowObjectRegistryTests.cs | 75 ++++++++++++++++ 8 files changed, 432 insertions(+), 14 deletions(-) create mode 100644 src/AcDream.Core/Physics/EntityCollisionFlags.cs create mode 100644 tests/AcDream.Core.Tests/Physics/EntityCollisionFlagsTests.cs 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); + } }