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:
parent
865aae8876
commit
6349ba49aa
6 changed files with 147 additions and 13 deletions
|
|
@ -792,8 +792,22 @@ public sealed class PlayerMovementController
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetPosition(Vector3 pos, uint cellId)
|
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;
|
_prevPhysicsPos = pos;
|
||||||
_currPhysicsPos = pos;
|
_currPhysicsPos = pos;
|
||||||
UpdateCellId(cellId, "teleport");
|
UpdateCellId(cellId, "teleport");
|
||||||
|
|
|
||||||
|
|
@ -5482,6 +5482,22 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
|
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
|
||||||
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
|
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()
|
private void EnsureTeleportArrivalController()
|
||||||
{
|
{
|
||||||
if (_teleportArrival is not null) return;
|
if (_teleportArrival is not null) return;
|
||||||
|
|
@ -5534,7 +5550,8 @@ public sealed class GameWindow : IDisposable
|
||||||
pe.ParentCellId = resolved.CellId;
|
pe.ParentCellId = resolved.CellId;
|
||||||
pe.Rotation = _pendingTeleportRot;
|
pe.Rotation = _pendingTeleportRot;
|
||||||
}
|
}
|
||||||
_playerController.SetPosition(snappedPos, resolved.CellId);
|
_playerController.SetPosition(snappedPos, resolved.CellId,
|
||||||
|
CellLocalForSeed(snappedPos, resolved.CellId));
|
||||||
|
|
||||||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||||||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||||||
|
|
@ -12750,7 +12767,8 @@ public sealed class GameWindow : IDisposable
|
||||||
var initResult = _physicsEngine.Resolve(
|
var initResult = _physicsEngine.Resolve(
|
||||||
playerEntity.Position, pinitCellId,
|
playerEntity.Position, pinitCellId,
|
||||||
System.Numerics.Vector3.Zero, 100f);
|
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
|
// #111 (2026-06-10): snap the ENTITY too — parity with the
|
||||||
// teleport-arrival path (entity.SetPosition + ParentCellId at
|
// teleport-arrival path (entity.SetPosition + ParentCellId at
|
||||||
// GameWindow.cs:4914). Without this, the renderer keeps drawing the
|
// GameWindow.cs:4914). Without this, the renderer keeps drawing the
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,8 @@ public static class CellTransit
|
||||||
Vector3 worldSphereCenter,
|
Vector3 worldSphereCenter,
|
||||||
float sphereRadius,
|
float sphereRadius,
|
||||||
uint currentCellId,
|
uint currentCellId,
|
||||||
out IReadOnlyCollection<uint> cellSet)
|
out IReadOnlyCollection<uint> cellSet,
|
||||||
|
Vector3? carriedBlockOrigin = null)
|
||||||
{
|
{
|
||||||
var spheres = new[]
|
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>
|
/// <summary>
|
||||||
|
|
@ -701,11 +702,12 @@ public static class CellTransit
|
||||||
IReadOnlyList<Sphere> worldSpheres,
|
IReadOnlyList<Sphere> worldSpheres,
|
||||||
int numSpheres,
|
int numSpheres,
|
||||||
uint currentCellId,
|
uint currentCellId,
|
||||||
out IReadOnlyCollection<uint> cellSet)
|
out IReadOnlyCollection<uint> cellSet,
|
||||||
|
Vector3? carriedBlockOrigin = null)
|
||||||
{
|
{
|
||||||
var containing = BuildCellSetAndPickContaining(
|
var containing = BuildCellSetAndPickContaining(
|
||||||
cache, worldSpheres, numSpheres, currentCellId,
|
cache, worldSpheres, numSpheres, currentCellId,
|
||||||
out var candidates);
|
carriedBlockOrigin, out var candidates);
|
||||||
cellSet = candidates;
|
cellSet = candidates;
|
||||||
return containing;
|
return containing;
|
||||||
}
|
}
|
||||||
|
|
@ -715,6 +717,7 @@ public static class CellTransit
|
||||||
IReadOnlyList<Sphere> worldSpheres,
|
IReadOnlyList<Sphere> worldSpheres,
|
||||||
int numSpheres,
|
int numSpheres,
|
||||||
uint currentCellId,
|
uint currentCellId,
|
||||||
|
Vector3? carriedBlockOrigin,
|
||||||
out CellArray candidates)
|
out CellArray candidates)
|
||||||
{
|
{
|
||||||
// Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036).
|
// Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036).
|
||||||
|
|
@ -729,11 +732,21 @@ public static class CellTransit
|
||||||
float sphereRadius = worldSpheres[0].Radius;
|
float sphereRadius = worldSpheres[0].Radius;
|
||||||
uint currentLow = currentCellId & 0xFFFFu;
|
uint currentLow = currentCellId & 0xFFFFu;
|
||||||
|
|
||||||
// #106: the current block's world origin converts the world-frame sphere
|
// #145: the carried cell-relative frame supplies the TRUE landblock world
|
||||||
// coords into retail's block-local frame for the LandDefs lcoord math.
|
// origin (body.Position - body.CellPosition.Frame.Origin) — correct even
|
||||||
// Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero —
|
// for an UNSTREAMED neighbour, where TryGetTerrainOrigin returns (0,0) and
|
||||||
// the legacy anchor-frame assumption (world frame == block-local frame).
|
// the pick marches the cell id one block per tick (the far-town cascade).
|
||||||
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);
|
// 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
|
// #112 rider: outdoor candidates may win the pick only when retail would
|
||||||
// have admitted them — outdoor seeds always; indoor seeds only when a
|
// have admitted them — outdoor seeds always; indoor seeds only when a
|
||||||
|
|
|
||||||
|
|
@ -974,6 +974,22 @@ public sealed class PhysicsEngine
|
||||||
|
|
||||||
transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
|
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
|
if (isOnGround && body is not null
|
||||||
&& body.WalkablePolygonValid
|
&& body.WalkablePolygonValid
|
||||||
&& body.WalkableVertices is { Length: >= 3 })
|
&& body.WalkableVertices is { Length: >= 3 })
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,15 @@ public sealed class SpherePath
|
||||||
public uint CurCellId;
|
public uint CurCellId;
|
||||||
public uint CheckCellId;
|
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
|
// Per-step offset
|
||||||
public Vector3 GlobalOffset;
|
public Vector3 GlobalOffset;
|
||||||
|
|
||||||
|
|
@ -2289,7 +2298,8 @@ public sealed class Transition
|
||||||
sp.HitsInteriorCell = false;
|
sp.HitsInteriorCell = false;
|
||||||
|
|
||||||
uint containingCellId = CellTransit.FindCellSet(
|
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);
|
LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius);
|
||||||
|
|
||||||
if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u
|
if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue