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