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

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