From ffefc6977f3590ce9135071c86defb2e0363a156 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 13:12:56 +0200 Subject: [PATCH 1/3] 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 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/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/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..be451f95 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 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/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); + } } From 46ca3ba26bd87a4f1d9fb53ae92394cae31f00cf Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 13:16:22 +0200 Subject: [PATCH 2/3] feat(physics): live-entity collision registration (Commit B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPCs / monsters / other players now register into ShadowObjectRegistry as collision targets. The local player walks into them and stops at the body cylinder, instead of passing through. GameWindow.OnLiveEntitySpawnedLocked: after the WorldEntity is built and stored in `_entitiesByServerGuid`, call the new RegisterLiveEntityCollision helper for any non-self entity. The helper honors retail's geometry-priority order (acclient_2013_pseudo_c.txt: 276858-276987) — CylSpheres > Setup.Radius > Sphere fallback — and applies the retail phantom-Setup skip (no CylSpheres / no Spheres / zero Radius → walk-through, matching FUN_FindObjCollisions's OK_TS fallthrough at :276917). GameWindow.OnLivePositionUpdated: after the entity's render pos/rot are set to server truth, push the same coordinates into the registry via ShadowObjectRegistry.UpdatePosition (the cheap preserve-shape-and-flags path Commit A added). Mirrors retail's SetPosition → change_cell → AddShadowObject chain ( acclient_2013_pseudo_c.txt:284276 / 281200 / 282862). The local player's own server guid is filtered out at both registration and update — its PhysicsBody is the simulator (the source of truth for our collisions), not a collision target. The decoded EntityCollisionFlags + raw PhysicsState bits are stored on each ShadowEntry but NOT YET CONSULTED by the collision resolver — Commit C is where the PvP exemption block lands. Practical effect of THIS commit: every visible body, including non-PK other players, blocks the local player. Two non-PK players currently can't pass through each other; that's the rule Commit C reverts to retail. No new unit tests in this commit (Commit A's ShadowObjectRegistry + EntityCollisionFlags suite covers the new field plumbing). Verification is live: at Holtburg the +Acdream test character should stop on contact with NPCs / vendors. Phantom decorations (small plants, grass) continue to pass through (L-fix3 phantom skip extends naturally to the live path via the same Setup-shape gate). dotnet build green, dotnet test 1454 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 118 ++++++++++++++++++++++++ 1 file changed, 118 insertions(+) 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 From 7d6fe906077b77335e74e0b02fea10cc41ec21f6 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 13:21:36 +0200 Subject: [PATCH 3/3] feat(physics): retail PvP exemption + viewer/creature/missile gates (Commit C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retail-faithful exemption block at the top of CPhysicsObj::FindObjCollisions (acclient_2013_pseudo_c.txt:276782-276839,276971), ported line-for- line as a small static helper. Behaviour now matches retail: - Two non-PK players walk through each other. - Two PK players collide. - Two PKLite players collide. - Mismatched PK status (PK vs non-PK, PK vs PKLite) — exempt. - Impenetrable target ("Free" PK status) — always collides. - Player vs creature/NPC — always collides (this is what closes the user-facing complaint that walking into a Holtburg vendor was walking through them). - Mover with IGNORE_CREATURES — walks through creature targets. - Viewer (camera ray) — walks through creatures. - Target with ETHEREAL+IGNORE_COLLISIONS — universally exempt. CollisionExemption.ShouldSkip(targetState, targetFlags, moverState) - new file src/AcDream.Core/Physics/CollisionExemption.cs. - 13-test matrix covering every documented case (CollisionExemptionTests.cs). - Static + pure → cheap to call from the hot path. Wiring: - TransitionTypes.FindObjCollisions: after broadphase distance reject, call ShouldSkip on the obj and ObjectInfo.State; on true, `continue`. Static landblock entries (State=0, Flags=None) fall through cheaply — no behavior change for static collision. - PhysicsEngine.ResolveWithTransition: new optional moverFlags parameter (default None for back-compat). PlayerMovementController passes ObjectInfoState.IsPlayer; remote dead-reckoning leaves it None (matches non-player movers, no PvP exemption applies). - PK/PKLite/Impenetrable bits for the LOCAL player are not yet sourced from PlayerDescription's PlayerKillerStatus property — that's a follow-up. Default "non-PK player" matches ACE's character-creation default and the user's +Acdream test character. Cross-checked against ACE PhysicsObj.cs:381-405 (line-for-line C# port of the same retail block). Only intentional divergence: ACE adds state.HasFlag(IsImpenetrable) (mover-impenetrable) to the collide list; retail's pseudo-C only checks the target — acdream follows retail. dotnet build green, dotnet test 1467 passing (+13 new). Live test: +Acdream walking into Holtburg vendors now stops at their cylinder; walking through small plants still passes (Commit B's phantom skip). Closes the live-entity collision arc: A (plumbing) + B (registration) + C (exemption). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Input/PlayerMovementController.cs | 8 +- .../Physics/CollisionExemption.cs | 123 +++++++++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 10 +- src/AcDream.Core/Physics/TransitionTypes.cs | 11 ++ .../Physics/CollisionExemptionTests.cs | 169 ++++++++++++++++++ 5 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.Core/Physics/CollisionExemption.cs create mode 100644 tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs 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.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/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/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index be451f95..57d6e6d2 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -807,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.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)); + } +}