diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 5332fe37..03114d63 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -792,8 +792,22 @@ public sealed class PlayerMovementController
}
public void SetPosition(Vector3 pos, uint cellId)
+ // #145: tests + legacy callers run in the world==block-local frame (no
+ // streaming center), so the cell-local seed IS the world position. This
+ // makes the carried anchor (body.Position − CellPosition.Origin) == (0,0,0),
+ // identical to the legacy Zero terrain-origin fallback → behaviour unchanged.
+ => SetPosition(pos, cellId, pos);
+
+ ///
+ /// Server-snap / teleport placement. is the
+ /// LANDBLOCK-relative position (the wire's local, or world − landblock origin)
+ /// which seeds the body's cell-relative CellPosition WITHOUT any streaming
+ /// center (#145). A teleport is a large jump, so this snaps the cell frame
+ /// directly via SnapToCell rather than delta-syncing through the setter.
+ ///
+ public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
{
- _body.Position = pos;
+ _body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
UpdateCellId(cellId, "teleport");
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index f2e59d4b..4320fbb8 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -5482,6 +5482,22 @@ public sealed class GameWindow : IDisposable
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
+ // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
+ // body's cell-relative CellPosition. This is the ONE place the streaming center
+ // (_liveCenter) is allowed to touch the physics frame — at the placement seam,
+ // converting the render-frame world position into the wire's (cell, local). After
+ // seeding, physics carries (cell, local) forward without ever reading _liveCenter.
+ private System.Numerics.Vector3 CellLocalForSeed(System.Numerics.Vector3 worldPos, uint cellId)
+ {
+ int lbX = (int)((cellId >> 24) & 0xFFu);
+ int lbY = (int)((cellId >> 16) & 0xFFu);
+ var origin = new System.Numerics.Vector3(
+ (lbX - _liveCenterX) * 192f,
+ (lbY - _liveCenterY) * 192f,
+ 0f);
+ return worldPos - origin;
+ }
+
private void EnsureTeleportArrivalController()
{
if (_teleportArrival is not null) return;
@@ -5534,7 +5550,8 @@ public sealed class GameWindow : IDisposable
pe.ParentCellId = resolved.CellId;
pe.Rotation = _pendingTeleportRot;
}
- _playerController.SetPosition(snappedPos, resolved.CellId);
+ _playerController.SetPosition(snappedPos, resolved.CellId,
+ CellLocalForSeed(snappedPos, resolved.CellId));
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
@@ -12750,7 +12767,8 @@ public sealed class GameWindow : IDisposable
var initResult = _physicsEngine.Resolve(
playerEntity.Position, pinitCellId,
System.Numerics.Vector3.Zero, 100f);
- _playerController.SetPosition(initResult.Position, initResult.CellId);
+ _playerController.SetPosition(initResult.Position, initResult.CellId,
+ CellLocalForSeed(initResult.Position, initResult.CellId));
// #111 (2026-06-10): snap the ENTITY too — parity with the
// teleport-arrival path (entity.SetPosition + ParentCellId at
// GameWindow.cs:4914). Without this, the renderer keeps drawing the
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 00ea9aa6..e1288575 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -677,7 +677,8 @@ public static class CellTransit
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
- out IReadOnlyCollection cellSet)
+ out IReadOnlyCollection cellSet,
+ Vector3? carriedBlockOrigin = null)
{
var spheres = new[]
{
@@ -688,7 +689,7 @@ public static class CellTransit
},
};
- return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet);
+ return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet, carriedBlockOrigin);
}
///
@@ -701,11 +702,12 @@ public static class CellTransit
IReadOnlyList worldSpheres,
int numSpheres,
uint currentCellId,
- out IReadOnlyCollection cellSet)
+ out IReadOnlyCollection cellSet,
+ Vector3? carriedBlockOrigin = null)
{
var containing = BuildCellSetAndPickContaining(
cache, worldSpheres, numSpheres, currentCellId,
- out var candidates);
+ carriedBlockOrigin, out var candidates);
cellSet = candidates;
return containing;
}
@@ -715,6 +717,7 @@ public static class CellTransit
IReadOnlyList worldSpheres,
int numSpheres,
uint currentCellId,
+ Vector3? carriedBlockOrigin,
out CellArray candidates)
{
// Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036).
@@ -729,11 +732,21 @@ public static class CellTransit
float sphereRadius = worldSpheres[0].Radius;
uint currentLow = currentCellId & 0xFFFFu;
- // #106: the current block's world origin converts the world-frame sphere
- // coords into retail's block-local frame for the LandDefs lcoord math.
- // Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero —
- // the legacy anchor-frame assumption (world frame == block-local frame).
- cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);
+ // #145: the carried cell-relative frame supplies the TRUE landblock world
+ // origin (body.Position - body.CellPosition.Frame.Origin) — correct even
+ // for an UNSTREAMED neighbour, where TryGetTerrainOrigin returns (0,0) and
+ // the pick marches the cell id one block per tick (the far-town cascade).
+ // Falls back to the terrain registry for unseeded movers (NPCs/tests) and
+ // indoor seeds. The caller guarantees the anchor's landblock == currentCellId's
+ // (PhysicsEngine passes body.CellPosition.ObjCellId as the cell when seeded).
+ //
+ // #106: blockOrigin converts the world-frame sphere coords into retail's
+ // block-local frame for the LandDefs lcoord math.
+ Vector3 blockOrigin;
+ if (carriedBlockOrigin is { } carriedAnchor)
+ blockOrigin = carriedAnchor;
+ else
+ cache.CellGraph.TryGetTerrainOrigin(currentCellId, out blockOrigin);
// #112 rider: outdoor candidates may win the pick only when retail would
// have admitted them — outdoor seeds always; indoor seeds only when a
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 6038ab69..108b9234 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -974,6 +974,22 @@ public sealed class PhysicsEngine
transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
+ // #145: supply the carried cell-relative frame anchor to the outdoor
+ // membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE
+ // landblock world origin, correct even for an UNSTREAMED neighbour — replacing
+ // the terrain-registry origin that returns (0,0) and marches the cell id one
+ // landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED
+ // OUTDOOR body whose carried landblock matches the resolve cell (the controller
+ // passes body.CellPosition.ObjCellId for the outdoor case, so they agree);
+ // null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor.
+ transition.SpherePath.CarriedBlockOrigin =
+ body is not null
+ && (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell
+ && (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded)
+ && (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent
+ ? body.Position - body.CellPosition.Frame.Origin
+ : null;
+
if (isOnGround && body is not null
&& body.WalkablePolygonValid
&& body.WalkableVertices is { Length: >= 3 })
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 4e6d4b6e..1e39e4a4 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -335,6 +335,15 @@ public sealed class SpherePath
public uint CurCellId;
public uint CheckCellId;
+ // #145: the carried cell-relative frame anchor (body.Position -
+ // body.CellPosition.Frame.Origin) — the TRUE landblock world origin, set at
+ // resolve entry from a SEEDED OUTDOOR player body. Threaded into the outdoor
+ // membership pick (CellTransit.FindCellSet) so it no longer derives the origin
+ // from the terrain registry (which returns (0,0) for an unstreamed neighbour →
+ // the far-town cell-march cascade). Null for unseeded movers (NPCs/tests) and
+ // indoor seeds → legacy TryGetTerrainOrigin.
+ public Vector3? CarriedBlockOrigin;
+
// Per-step offset
public Vector3 GlobalOffset;
@@ -2289,7 +2298,8 @@ public sealed class Transition
sp.HitsInteriorCell = false;
uint containingCellId = CellTransit.FindCellSet(
- engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet);
+ engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet,
+ sp.CarriedBlockOrigin);
LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius);
if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u
diff --git a/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs b/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs
new file mode 100644
index 00000000..cdfe2104
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs
@@ -0,0 +1,63 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+// #145 — far-town teleport resolver runaway. A teleport to a far town places the
+// player at a landblock EDGE; the first physics tick crosses into a neighbour that
+// has NOT streamed in yet. Pre-fix, the membership pick derived the block origin from
+// the terrain registry, which returns (0,0) for an unstreamed neighbour, so
+// AdjustToOutside marched the cell id one landblock per tick (the cascade) until lbY
+// hit 0 and the outbound wire synthesized the bogus localY (17410) that ACE rejects.
+//
+// The fix (Slice 3): the resolve threads the CARRIED cell-relative frame anchor
+// (body.Position − body.CellPosition.Frame.Origin) into the pick. That anchor is the
+// TRUE landblock world origin — correct even for an unstreamed neighbour — so the pick
+// re-derives the SAME (consistent) cell and never marches.
+//
+// These tests exercise CellTransit.FindCellSet directly with an EMPTY cache (so the
+// neighbour landblock is unstreamed → the legacy path's (0,0) fallback fires) and
+// compare the carried-anchor pick against the legacy pick. The cascade is direction-
+// agnostic, so both a south and an east edge crossing are covered.
+public class TeleportFarTownRunawayTests
+{
+ private static int LbX(uint cellId) => (int)((cellId >> 24) & 0xFFu);
+ private static int LbY(uint cellId) => (int)((cellId >> 16) & 0xFFu);
+
+ [Fact]
+ public void SouthEdge_UnstreamedNeighbour_CarriedAnchor_DoesNotMarch()
+ {
+ // Streaming center is the far town 0xC95B; the player just crossed its south
+ // edge (world Y ≈ −0.088) into 0xC95A (origin (0,−192), UNSTREAMED).
+ var cache = new PhysicsDataCache();
+ var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(14.8f, -0.088f, 12f), Radius = 0.48f } };
+ const uint currentCell = 0xC95A0001u;
+ var anchor = new Vector3(0f, -192f, 0f); // body.Position − CellPosition.Origin
+
+ uint withAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor);
+ Assert.Equal(0x5A, LbY(withAnchor)); // stays in 0xC95A — no march
+
+ // Legacy path (no anchor → TryGetTerrainOrigin returns (0,0) for the
+ // unstreamed neighbour) marches the cell one landblock south.
+ uint legacy = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null);
+ Assert.True(LbY(legacy) < 0x5A, $"legacy should march south of 0x5A, got 0x{LbY(legacy):X2}");
+ }
+
+ [Fact]
+ public void EastEdge_UnstreamedNeighbour_CarriedAnchor_DoesNotMarch()
+ {
+ // Player crossed the center's east edge (world X ≈ 192.088) into 0xCA5B
+ // (origin (+192,0), UNSTREAMED). lbX 0xCA > 0xC9.
+ var cache = new PhysicsDataCache();
+ var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(192.088f, 14.8f, 12f), Radius = 0.48f } };
+ const uint currentCell = 0xCA5B0001u;
+ var anchor = new Vector3(192f, 0f, 0f);
+
+ uint withAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor);
+ Assert.Equal(0xCA, LbX(withAnchor)); // stays in 0xCA5B — no march
+
+ uint legacy = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null);
+ Assert.True(LbX(legacy) > 0xCA, $"legacy should march east of 0xCA, got 0x{LbX(legacy):X2}");
+ }
+}