fix(physics #145): Slice 3 — carried-anchor membership; closes the far-town cascade

The outdoor membership pick derived the landblock origin from the terrain
registry, which returns (0,0) for an UNSTREAMED neighbour — so a fresh far-town
teleport at a landblock edge marched the cell id one block per physics tick
(the cascade; the 17410 ACE rejects is its wire artifact).

Fix: thread the CARRIED cell-relative frame anchor (body.Position -
body.CellPosition.Frame.Origin) into the pick via SpherePath.CarriedBlockOrigin.
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.
- CellTransit.FindCellSet/BuildCellSetAndPickContaining: Vector3? carriedBlockOrigin
  (null default = legacy TryGetTerrainOrigin → every existing caller/test untouched).
- PhysicsEngine.ResolveWithTransition: set the anchor from a SEEDED OUTDOOR body
  whose carried landblock matches the resolve cell (else null → legacy).
- PlayerMovementController.SetPosition: 3-arg overload seeds CellPosition from the
  wire's (cell, local) via SnapToCell; 2-arg delegates with cellLocal=pos (anchor
  (0,0,0) == legacy → zero test churn).
- GameWindow.CellLocalForSeed: the placement seam (_liveCenter used ONCE here to
  derive the cell-local; physics carries it forward without _liveCenter).
Regression: TeleportFarTownRunawayTests (south + east edge, unstreamed neighbour).
Core 1529 / App 480, zero regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-21 13:51:04 +02:00
parent 865aae8876
commit 6349ba49aa
6 changed files with 147 additions and 13 deletions

View file

@ -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);
/// <summary>
/// Server-snap / teleport placement. <paramref name="cellLocal"/> is the
/// LANDBLOCK-relative position (the wire's local, or world landblock origin)
/// which seeds the body's cell-relative <c>CellPosition</c> WITHOUT any streaming
/// center (#145). A teleport is a large jump, so this snaps the cell frame
/// directly via <c>SnapToCell</c> rather than delta-syncing through the setter.
/// </summary>
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
{
_body.Position = pos;
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
UpdateCellId(cellId, "teleport");

View file

@ -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

View file

@ -677,7 +677,8 @@ public static class CellTransit
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
out IReadOnlyCollection<uint> cellSet)
out IReadOnlyCollection<uint> 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);
}
/// <summary>
@ -701,11 +702,12 @@ public static class CellTransit
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
out IReadOnlyCollection<uint> cellSet)
out IReadOnlyCollection<uint> 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<Sphere> 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

View file

@ -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 })

View file

@ -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