diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 80a76cf8..378afe92 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -638,9 +638,23 @@ public sealed class PhysicsEngine { Console.WriteLine(System.FormattableString.Invariant( $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}")); + // #133 (2026-06-13): return the VALIDATED claim's OWN full cell id, + // NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning + // resident landblocks for one whose [0,192) local bounds contain + // the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE + // (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon + // landblock fails the localY>=0 bounds test, so the loop matches a + // neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping + // the validated claim 0x00070143 -> 0xA9B30143. The client then + // mis-resolves the player into the wrong landblock and spams ACE with + // rejected moves. The validated claim's prefix is AUTHORITATIVE; a + // position falling in a neighbouring resident landblock must not + // re-stamp it. Byte-identical for the login case (the position lies in + // the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000); + // diverges only — and correctly — in the far-teleport dungeon case. return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value), - lbPrefix | (cellId & 0xFFFFu), + cellId, IsOnGround: true); } } diff --git a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs new file mode 100644 index 00000000..e429f100 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #133 (Bug A) — the validated-claim placement branch of +/// must return the VALIDATED claim's own +/// full cell id, NOT lbPrefix | (cellId & 0xFFFF). +/// +/// +/// lbPrefix is found by scanning resident landblocks for one whose +/// [0,192) local bounds contain the candidate XY. A dungeon EnvCell's +/// local Y can be NEGATIVE relative to its own landblock (the live capture: +/// server teleport to dungeon cell 0x00070143 at local (70,-60,0.01)). +/// The dungeon landblock fails the localY >= 0 bounds test, so the loop +/// instead matches a still-resident NEIGHBOURING block (a Holtburg landblock +/// whose world bounds happen to contain the same XY) and sets +/// lbPrefix = 0xA9B30000. The old code then returned +/// 0xA9B30000 | 0x0143 = 0xA9B30143, re-stamping the validated dungeon +/// claim with the wrong landblock — the client mis-resolved the player into +/// Holtburg and spammed ACE with rejected moves +/// (movement pre-validation failed from 00070143 to A9B30143). +/// +/// +/// +/// The validated claim's prefix is authoritative; a position falling in a +/// neighbouring resident landblock must not re-stamp it. This test reproduces +/// the exact geometry of the capture (dungeon claim in landblock 0x0007, +/// candidate XY also inside resident Holtburg 0xA9B3) and asserts the +/// returned cell keeps its 0x0007 prefix. +/// +/// +public class Issue133DungeonTeleportPrefixTests +{ + private const uint DungeonLandblock = 0x00070000u; + private const uint DungeonCellId = 0x00070143u; // indoor (low 0x0143 ≥ 0x0100) + private const uint HoltburgLandblock = 0xA9B30000u; // a neighbouring resident block + + // The capture: dungeon cell 0x00070143 at dungeon-local (70, -60, 0.01). + // We place the Holtburg block at world origin so its [0,192) bounds contain + // the candidate XY, and the dungeon block at world Y-offset 130 so the SAME + // world XY lands at dungeon-local Y = 70 - 130 = -60 (the captured negative). + private static readonly Vector3 SpawnPos = new(70f, 70f, 0.01f); + + [Fact] + public void ValidatedDungeonClaim_KeepsItsLandblockPrefix_NotTheNeighbour() + { + var engine = BuildEngine(); + + // Zero delta = the snap shape (teleport arrival). cellId is the dungeon + // claim; the candidate XY also falls inside the resident Holtburg block. + var result = engine.Resolve(SpawnPos, DungeonCellId, delta: Vector3.Zero, stepUpHeight: 0.5f); + + Assert.True(result.IsOnGround); + // The validated claim's prefix is authoritative — high word stays 0x0007, + // NOT re-stamped to the neighbouring Holtburg 0xA9B3. + Assert.Equal(DungeonCellId, result.CellId); + Assert.Equal(DungeonLandblock, result.CellId & 0xFFFF0000u); + } + + // ── fixture ────────────────────────────────────────────────────────────── + + private static PhysicsEngine BuildEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // The dungeon cell: a Leaf CellBSP contains any point, so AdjustPosition + // validates the claim (returns it with found=true). Its Resolved set has + // one walkable floor polygon at z=0 under the spawn XY so the #111 + // validated-claim branch grounds onto it. + cache.RegisterCellStructForTest(DungeonCellId, MakeDungeonCell()); + + // Resident Holtburg block at world origin: its [0,192) bounds CONTAIN the + // candidate XY (70,70). This is the block the lbPrefix loop wrongly matched. + engine.AddLandblock( + landblockId: HoltburgLandblock, + terrain: FlatTerrain(), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + // The dungeon's own landblock, offset so the candidate XY produces a + // NEGATIVE dungeon-local Y (70 - 130 = -60) → it FAILS the [0,192) bounds + // test, which is exactly why the old code fell through to the Holtburg + // prefix. Registered so the scenario is faithful (a resident dungeon block + // whose local bounds don't cover the EnvCell's negative-Y position). + engine.AddLandblock( + landblockId: DungeonLandblock, + terrain: FlatTerrain(), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 130f); + + return engine; + } + + /// Flat 81-vertex stub terrain (all zero heights). + private static TerrainSurface FlatTerrain() => new(new byte[81], new float[256]); + + private static CellPhysics MakeDungeonCell() + { + // One floor polygon: a 200×200 square at z=0 centred so it covers the + // spawn XY. Normal (0,0,1) → normal.Z = 1 ≥ FloorZ (0.6642) → walkable. + // Identity transform: cell-local == world, so the plane d = 0 (z + d = 0). + var floor = new ResolvedPolygon + { + Vertices = new[] + { + new Vector3(-100f, -100f, 0f), + new Vector3( 200f, -100f, 0f), + new Vector3( 200f, 200f, 0f), + new Vector3(-100f, 200f, 0f), + }, + Plane = new Plane(new Vector3(0f, 0f, 1f), 0f), + NumPoints = 4, + SidesType = CullMode.None, + }; + + return new CellPhysics + { + BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary { [0] = floor }, + // Leaf root → point_in_cell true for any point → AdjustPosition + // validates the claim (found=true, cell unchanged). + CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(), + }; + } +}