From 6349ba49aa3ef62854fba04495318c272804a0c6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 13:51:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics=20#145):=20Slice=203=20=E2=80=94=20?= =?UTF-8?q?carried-anchor=20membership;=20closes=20the=20far-town=20cascad?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Input/PlayerMovementController.cs | 16 ++++- src/AcDream.App/Rendering/GameWindow.cs | 22 ++++++- src/AcDream.Core/Physics/CellTransit.cs | 31 ++++++--- src/AcDream.Core/Physics/PhysicsEngine.cs | 16 +++++ src/AcDream.Core/Physics/TransitionTypes.cs | 12 +++- .../Physics/TeleportFarTownRunawayTests.cs | 63 +++++++++++++++++++ 6 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs 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}"); + } +}