diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4c771867..1142a081 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 28 rows +## 2. Adaptation (AD) — 30 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -94,6 +94,7 @@ accepted-divergence entries (#96, #49, #50). | AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius | | AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | +| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | --- diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index e1288575..a5977dd3 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -742,11 +742,32 @@ public static class CellTransit // // #106: blockOrigin converts the world-frame sphere coords into retail's // block-local frame for the LandDefs lcoord math. + // + // #145 D (2026-06-22): honor TryGetTerrainOrigin's false return for OUTDOOR + // seeds. When the current landblock has not yet been applied (priority-apply + // in flight or streaming still warm), blockOrigin=(0,0) is wrong — the pick + // treats the world-frame sphere coordinates as if they were block-local and + // marches the cell one block per tick until lbX or lbY underflows to 0x00 + // (the "lbX=0" outbound wire that ACE rejects). "frame not yet authoritative + // → preserve verbatim" mirrors the NO-LANDBLOCK contract in PhysicsEngine.Resolve. + // Indoor seeds are NOT guarded here because blockOrigin is only consumed by the + // outdoor pick path (outdoorPickAllowed=false for indoor seeds); returning early + // for indoor seeds would break all interior cell-set builds (regression). + // Adaptation (not a direct retail port — retail gets it free via cell-relative + // storage); registered as divergence-register row added in this commit. Vector3 blockOrigin; if (carriedBlockOrigin is { } carriedAnchor) + { blockOrigin = carriedAnchor; + } else - cache.CellGraph.TryGetTerrainOrigin(currentCellId, out blockOrigin); + { + bool terrainResident = cache.CellGraph.TryGetTerrainOrigin(currentCellId, out blockOrigin); + // Outdoor seed with no resident terrain: no valid block-local frame → preserve seed verbatim. + // Indoor seeds proceed regardless (blockOrigin unused for indoor picks). + if (!terrainResident && currentLow < 0x0100u) + return currentCellId; + } // #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/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs index ffe398ad..5141770e 100644 --- a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs @@ -34,6 +34,12 @@ public sealed class Issue112MembershipTests try { ConformanceDats.LoadEnvCell(dats, cache, lbPrefix | low); } catch { } } + // #145 D: register terrain for the block so TryGetTerrainOrigin succeeds for + // outdoor seeds (in production the streaming-center landblock is always resident + // before outdoor physics resolves run). Tests use block-local coordinates, so + // origin is (0,0,0). Without this, Component D's early-return for unregistered + // terrain would prevent the building-portal walk that promotes outdoor→indoor. + cache.CellGraph.RegisterTerrain(lbPrefix, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); return cache; } diff --git a/tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs b/tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs new file mode 100644 index 00000000..e4fad6ed --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs @@ -0,0 +1,85 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +// Component D — cell-march landblock preservation (#145 residual, 2026-06-22 spec §3 D). +// +// Scenario: a player arrives via teleport at outdoor cell 0xA9B40019 (lbX=0xA9, lbY=0xB4). +// The destination landblock has NOT yet been applied to the physics engine (priority-apply +// is in flight). The body's CarriedBlockOrigin is null (either the body has no seeded outdoor +// cell yet, or the incoming cell differs from the body's last-known cell). +// +// Without the fix: BuildCellSetAndPickContaining's TryGetTerrainOrigin returns false with +// blockOrigin=(0,0,0). AddAllOutsideCells treats worldSphereCenter as block-local and marches +// the cell one block in whatever direction the player is moving — lbX→lbX−1 or lbY→lbY−1 +// per tick, cascading until lbX or lbY underflows to 0x00. The outbound wire then encodes +// the marched cell and ACE rejects every move (MOVEMENT SPEED / failed transition). +// +// With the fix: when TryGetTerrainOrigin returns false (block not resident), return the +// seed currentCellId verbatim — "no block-local frame → preserve" (same contract as +// PhysicsEngine.Resolve's NO-LANDBLOCK verbatim branch). The player cell stays correct +// until the landblock applies. +// +// The entry point exercised here is CellTransit.FindCellSet — the same function that +// RunCheckOtherCellsAndAdvance (TransitionTypes.cs) calls on every per-frame physics tick. +public class CellMarchLandblockPreservationTests +{ + private static int LbX(uint cellId) => (int)((cellId >> 24) & 0xFFu); + private static int LbY(uint cellId) => (int)((cellId >> 16) & 0xFFu); + + // ── West-edge crossing: no anchor, unstreamed terrain → must NOT march lbX ────────────── + [Fact] + public void WestEdge_UnstreamedLandblock_NoAnchor_PreservesSeedCell() + { + // Empty cache — landblock 0xA9B4 has not been applied (TryGetTerrainOrigin → false). + var cache = new PhysicsDataCache(); + + // Sphere just west of 0xA9B4's origin (world X = −0.088 → block-local X = −0.088 + // relative to (0,0) origin → floor(−0.088/24) = −1 → lbX marches without fix). + var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(-0.088f, 14.8f, 12f), Radius = 0.48f } }; + const uint currentCell = 0xA9B40019u; + + // No carried anchor (the teleport-arrival first tick: body still carries an old + // cell or is null-seeded, so ResolveWithTransition passes null). + uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null); + + // The cell must be preserved unchanged — lbX must NOT drop below 0xA9. + Assert.Equal(0xA9, LbX(result)); + // The full cell id must equal the seed (no march at all). + Assert.Equal(currentCell, result); + } + + // ── South-edge crossing: no anchor, unstreamed terrain → must NOT march lbY ───────────── + [Fact] + public void SouthEdge_UnstreamedLandblock_NoAnchor_PreservesSeedCell() + { + 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 = 0xA9B40001u; // south-west landcell of 0xA9B4 + + uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null); + + // Without fix: lbY drops from 0xB4 to 0xB3 on the first tick, then cascades. + Assert.Equal(0xB4, LbY(result)); + Assert.Equal(currentCell, result); + } + + // ── Carried anchor STILL works: if the anchor is supplied, the correct neighbour ──────── + // landblock cell is returned even when terrain is unstreamed (the original #145 fix). + [Fact] + public void WithCarriedAnchor_UnstreamedNeighbour_ReturnsCorrectCell() + { + var cache = new PhysicsDataCache(); + // Player crossed the south edge into 0xA9B3 (origin (0, −192)). + var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(14.8f, -0.088f, 12f), Radius = 0.48f } }; + const uint currentCell = 0xA9B30001u; + var anchor = new Vector3(0f, -192f, 0f); // body.Position − CellPosition.Origin + + uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor); + + // The anchor gives the correct block-local frame → stays in 0xA9B3 (no march back to B4). + Assert.Equal(0xB3, LbY(result)); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index 136b28c4..cdbf5afa 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -110,6 +110,9 @@ public class CellTransitFindCellSetTests public void FindCellSet_OutdoorSeed_IncludesNeighbourLandcells() { var cache = new PhysicsDataCache(); + // #145 D: register A9B4 at origin (0,0) — in production the streaming-center + // landblock is always registered before any outdoor physics resolve runs. + cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); // A6.P4 (2026-05-24): sphere coords are LANDBLOCK-LOCAL (X/Y in // [0, 192]). Place the sphere center near the east boundary of // landcell grid(0,0) (i.e., near local X=24) so AddAllOutsideCells @@ -172,10 +175,13 @@ public class CellTransitFindCellSetTests public void OutdoorSeed_CrossesLandblockBoundary_South() { // The #106 acceptance golden: walking south out of A9B4, the outdoor - // cell must advance to the southern neighbour block's cell. Anchor - // frame (no registered terrain → origin Zero): world y = -0.2 is - // 0.2 m into A9B3's row 7 under x=150 → cell 0xA9B30038. + // cell must advance to the southern neighbour block's cell. Origin of + // A9B4 is Zero (streaming center). #145 D: register A9B4 at (0,0) so + // TryGetTerrainOrigin succeeds (in production the streaming-center + // landblock is always resident before any outdoor physics resolve). + // world y = -0.2 is 0.2 m into A9B3's row 7 under x=150 → cell 0xA9B30038. var cache = new PhysicsDataCache(); + cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); uint containing = CellTransit.FindCellSet( cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f, @@ -194,7 +200,9 @@ public class CellTransitFindCellSetTests // neighbour (sphere overlaps it) but the centre column is still the // current cell — membership must NOT flip early (single clean flip // at the line, matching the capture's 96/96 within-block behaviour). + // #145 D: register A9B4 at (0,0) — streaming-center block is always resident. var cache = new PhysicsDataCache(); + cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); uint containing = CellTransit.FindCellSet( cache, new Vector3(150f, 0.2f, 0f), sphereRadius: 0.5f, diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index f6850e6e..26664ad9 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -459,7 +459,13 @@ public class PhysicsEngineTests // FindObjCollisions early-returns when DataCache is null. An empty // cache is enough for cylinder objects; only BSP objects look up // entries inside. - engine.DataCache = new PhysicsDataCache(); + // #145 D: register terrain for 0xA9B4 at (0,0,0) so TryGetTerrainOrigin + // succeeds for the outdoor seed cell (0xA9B40039). In production the + // streaming-center landblock is always resident before outdoor resolves run; + // we replicate that invariant here by registering a flat dummy terrain. + var freshCache = new PhysicsDataCache(); + freshCache.CellGraph.RegisterTerrain(0xA9B4FFFFu, new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), Vector3.Zero); + engine.DataCache = freshCache; const uint movingEntityId = 0xDEADBEEFu; var bodyPos = new Vector3(96f, 96f, 50f); diff --git a/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs b/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs index cdfe2104..ef338054 100644 --- a/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs @@ -17,9 +17,10 @@ namespace AcDream.Core.Tests.Physics; // 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. +// neighbour landblock is unstreamed → TryGetTerrainOrigin returns false). With +// #145 Slice 3 (CarriedBlockOrigin), the explicit anchor prevents the march. With +// #145 D (2026-06-22, the TryGetTerrainOrigin-bool fix), the no-anchor path ALSO +// preserves the seed — "no frame → preserve verbatim". Both are verified here. public class TeleportFarTownRunawayTests { private static int LbX(uint cellId) => (int)((cellId >> 24) & 0xFFu); @@ -38,10 +39,10 @@ public class TeleportFarTownRunawayTests 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}"); + // #145 D (2026-06-22): no-anchor path also preserves when terrain is unregistered. + // Pre-D this marched south to 0x59; post-D returns currentCell unchanged. + uint noAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null); + Assert.Equal(currentCell, noAnchor); } [Fact] @@ -57,7 +58,8 @@ public class TeleportFarTownRunawayTests 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}"); + // #145 D: no-anchor path also preserves when terrain is unregistered. + uint noAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null); + Assert.Equal(currentCell, noAnchor); } }