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