diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 8ce6f771..ec5f0fe8 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,11 +46,26 @@ Copy this block when adding a new issue:
## #106 — Outdoor player-cell membership FREEZES at landblock boundaries (whole interiors unenterable)
-**Status:** OPEN
+**Status:** FIX LANDED 2026-06-09 — pending live boundary-walk gate (acceptance below)
**Severity:** HIGH
**Filed:** 2026-06-09
**Component:** physics, membership
+**Fix (2026-06-09):** retail `LandDefs` global-lcoord math ported as
+`AcDream.Core.Physics.LandDefs` (`blockid_to_lcoord` / `gid_to_lcoord` /
+`lcoord_to_gid` / `get_outside_lcoord` / `adjust_to_outside`, decomp-cited);
+`CellTransit.AddAllOutsideCells` rewritten as the faithful sphere-variant
+(pc:317499) — candidates and the `find_cell_list` pick now run in the GLOBAL
+landcell grid, so landblock crossings are inherent (the pre-fix port clamped
+both to the current block's 8×8 grid → zero candidates one step over the line →
+frozen). World→block-local conversion via the landblock origin registered in
+`CellGraph` (`TryGetTerrainOrigin`). The `b3ce505` gate was investigated FIRST
+and definitively exonerated (collision-only, fires only for indoor primaries —
+no membership path touches it). 31 new conformance tests incl. the capture
+geometry goldens (0xA9B40031 → 0xA9B30038/0xA9B30034) and a non-anchor-frame
+northbound return. Pseudocode + decomp-artifact notes:
+`docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`.
+
**Description:** Walking outdoors across a landblock boundary does NOT update the player's
outdoor cell: `playerCell` stays pinned to the last cell of the previous landblock,
indefinitely. Every downstream consumer degrades: entering any building in the new landblock
diff --git a/docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md b/docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md
new file mode 100644
index 00000000..37608d17
--- /dev/null
+++ b/docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md
@@ -0,0 +1,183 @@
+# LandDefs + add_all_outside_cells — retail pseudocode (issue #106)
+
+**Date:** 2026-06-09. **Purpose:** ground the #106 fix (outdoor membership freezes at
+landblock boundaries) in the retail decomp. The pre-fix acdream port of
+`AddAllOutsideCells` clamped candidates to the current landblock's 8×8 grid; retail
+has NO such clamp — its cell math runs in a **global** landcell coordinate space
+(`lcoord`, 0..2039) spanning the entire map, so landblock crossings are inherent.
+
+**Sources:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (primary);
+`references/ACE/Source/ACE.Server/Physics/Common/LandDefs.cs` + `LandCell.cs`
+(cross-check, in the MAIN repo checkout — this worktree only vendors WorldBuilder).
+
+---
+
+## 1. The coordinate model
+
+- A **landblock id** packs the map grid into the high word of a cell id:
+ bits 31–24 = `block_x` (east–west), bits 23–16 = `block_y` (north–south).
+- An **outdoor cell** low word is `1 + cell_x*8 + cell_y` (`cell_x`,`cell_y` ∈ 0..7,
+ 24 m cells, row-major with X outer).
+- A **global lcoord** is `(block_x*8 + cell_x, block_y*8 + cell_y)` — a flat map-wide
+ cell grid, valid range `[0, 0x7F8)` = `[0, 2040)` (255 blocks × 8).
+- Positions in retail `Position` structs are **block-local** (`[0,192)` per axis).
+
+acdream divergence to bridge: physics positions are in a **floating world frame**
+anchored at the login/teleport landblock (origin = that block's SW corner;
+`GameWindow.ApplyLoadedTerrainLocked` registers each block's origin as
+`((lbX − centerX)·192, (lbY − centerY)·192)`). Convert world → current-block-local by
+subtracting the current block's registered origin (`CellGraph._terrain` stores it),
+then retail's math applies verbatim.
+
+## 2. LandDefs functions (verbatim-equivalent pseudocode)
+
+### in_bounds (pc:68509, @0x0043d650)
+```
+in_bounds(x, y) := x >= 0 && y >= 0 && x < 0x7F8 && y < 0x7F8
+```
+
+### blockid_to_lcoord (pc:68520, @0x0043d680)
+```
+blockid_to_lcoord(cellId, out lx, out ly):
+ if cellId == 0: return false
+ lx = ((cellId >> 24) & 0xFF) * 8 // decomp form: (cellId >> 21) & 0x7F8
+ ly = ((cellId >> 16) & 0xFF) * 8 // decomp form: (cellId >> 13) & 0x7F8
+ return in_bounds(lx, ly)
+```
+⚠️ **BN artifact:** the decomp renders the Y extraction as `(int8_t)(cellId >> 16) << 3`
+— a signed cast that would explode for block_y ≥ 0x80 (e.g. 0xB4). ACE's
+`blockid_to_lcoord` (LandDefs.cs:169) confirms plain zero-extended masks. Use unsigned.
+
+### inbound_valid_cellid (pc:163438, @0x004979a0)
+```
+inbound_valid_cellid(cellId):
+ low = cellId & 0xFFFF
+ if !(low in [1,0x40] || low in [0x100,0xFFFD] || low == 0xFFFF): return false
+ lx = (cellId >> 21) & 0x7F8; ly = (cellId >> 13) & 0x7F8
+ return in_bounds(lx, ly)
+```
+⚠️ **ACE divergence:** ACE's `inbound_valid_cellid` (LandDefs.cs:241) checks only
+`block_x`. Retail checks both axes. Port retail.
+
+### gid_to_lcoord (pc:163500, @0x00497a90) — outdoor cells only
+```
+gid_to_lcoord(cellId, out lx, out ly):
+ if !inbound_valid_cellid(cellId): return false
+ if (cellId & 0xFFFF) >= 0x100: return false // outdoor only
+ lx = block_x*8 + ((low−1) >> 3)
+ ly = block_y*8 + ((low−1) & 7)
+ return in_bounds(lx, ly)
+```
+
+### lcoord_to_gid (pc:171859, @0x004a19a0)
+```
+lcoord_to_gid(lx, ly):
+ if !in_bounds(lx, ly): return 0
+ low = (ly & 7) + (lx & 7)*8 + 1
+ block = ((lx >> 3) << 8) | (ly >> 3) // decomp: ((lx & ~7) << 5) | (ly >> 3)
+ return (block << 16) | low
+```
+Cross-block is transparent: `lx = 1439` → `block_y = 179 (0xB3)` even when the input
+block was 0xB4.
+
+### get_outside_lcoord (pc:438690, @0x005a9b00)
+```
+get_outside_lcoord(cellId, blockLocalPos, out lx, out ly):
+ if cellId low16 not in {1..0x40, 0x100..0xFFFD, 0xFFFF}: return false
+ blockid_to_lcoord(cellId, out lx, out ly)
+ lx += floor(pos.x / 24); ly += floor(pos.y / 24) // floor() — negative-safe
+ return in_bounds(lx, ly)
+```
+`floor(pos/24)` may be negative or ≥ 8 — **this is the landblock crossing**. No clamp.
+
+### adjust_to_outside (pc:438719, @0x005a9bc0)
+```
+adjust_to_outside(ref cellId, ref blockLocalPos):
+ low = cellId & 0xFFFF
+ if low in valid ranges (as above):
+ if |pos.x| < 0.000199999995: pos.x = 0 // retail EPSILON snap
+ if |pos.y| < 0.000199999995: pos.y = 0
+ if get_outside_lcoord(cellId, pos, out lx, out ly):
+ cellId = lcoord_to_gid(lx, ly)
+ pos.x -= floor(pos.x / 192) * 192 // re-normalize to NEW block
+ pos.y -= floor(pos.y / 192) * 192
+ return true
+ cellId = 0
+ return false
+```
+⚠️ **BN artifact:** the decomp shows `floor(pos.x / 0f) * 0f` — the constant was
+dropped. ACE (LandDefs.cs:140-141) confirms `BlockLength = 192`.
+
+## 3. CLandCell::add_all_outside_cells — sphere variant (pc:317499, @0x00533630)
+
+```
+add_all_outside_cells(position, numSphere, spheres[], cellArray):
+ if cellArray.added_outside: return
+ cellArray.added_outside = 1
+ if numSphere == 0:
+ cellId = position.objcell_id; pos = position.frame.origin
+ if adjust_to_outside(ref cellId, ref pos) && gid_to_lcoord(cellId, out lx, ly):
+ add_outside_cell(cellArray, lx, ly)
+ return
+ for each sphere (center copied):
+ cellId = position.objcell_id
+ if !adjust_to_outside(ref cellId, ref center): BREAK // break, not continue
+ if gid_to_lcoord(cellId, out lx, out ly):
+ add_outside_cell(cellArray, lx, ly)
+ point.x = center.x mod 24; point.y = center.y mod 24 // center is [0,192) post-adjust
+ check_add_cell_boundary(cellArray, point, lx, ly, minRad=radius, maxRad=24−radius)
+```
+
+### add_outside_cell (pc:317056, @0x00532ec0)
+```
+add_outside_cell(cellArray, lx, ly):
+ if in_bounds(lx, ly):
+ cellArray.add_cell(lcoord_to_gid(lx, ly), LScape::get_landcell(...))
+```
+**NO same-block filter.** ⚠️ ACE's `add_cell_block` (LandCell.cs:198) inserts
+`if (id >> 16 != cellID >> 16) continue;` annotated `// FIXME!` — an ACE divergence
+that drops cross-landblock cells. Do NOT copy it.
+
+### check_add_cell_boundary (pc:317229, @0x00533260)
+```
+check_add_cell_boundary(cellArray, point, lx, ly, minRad, maxRad): // strict > / <
+ if point.x > maxRad:
+ add_outside_cell(lx+1, ly)
+ if point.y > maxRad: add_outside_cell(lx+1, ly+1)
+ if point.y < minRad: add_outside_cell(lx+1, ly−1)
+ if point.x < minRad:
+ add_outside_cell(lx−1, ly)
+ if point.y > maxRad: add_outside_cell(lx−1, ly+1)
+ if point.y < minRad: add_outside_cell(lx−1, ly−1)
+ if point.y > maxRad: add_outside_cell(lx, ly+1)
+ if point.y < minRad: add_outside_cell(lx, ly−1)
+```
+A sphere exactly tangent to a 24 m boundary does NOT add the neighbour (strict
+comparisons — matches ACE).
+
+## 4. The containing-cell pick (CObjCell::find_cell_list, pc:308742-308869, @0x0052b4e0)
+
+After the transit set is built, retail iterates candidates IN ORDER; for each it
+subtracts `LandDefs::get_block_offset(position.block, candidate.block)` from the
+sphere center (cross-block-aware) and calls the cell's `point_in_cell` (vtable
++0x84). Any containing cell sets the running result; an INTERIOR containing cell
+breaks immediately (interior-wins); outdoor cells are disjoint columns so at most one
+contains the point. acdream adaptation (landcells have no BSP `point_in_cell`): the
+containing outdoor cell is `adjust_to_outside(currentCellId, blockLocal(center))` —
+the global XY-column id — compared by identity against candidates. Pre-#106 this was
+computed with a `[0,8)` grid clamp under the current block's prefix, which is what
+froze membership at boundaries.
+
+## 5. Worked goldens (from the #106 capture geometry)
+
+Anchor A9B4 at world origin; A9B3 is one block SOUTH (origin (0, −192)).
+
+| Input | Result |
+|---|---|
+| `blockid_to_lcoord(0xA9B40031)` | (1352, 1440) |
+| `gid_to_lcoord(0xA9B40031)` (low 0x31 = 49 → cell 6,0) | (1358, 1440) |
+| `lcoord_to_gid(1358, 1440)` | 0xA9B40031 (roundtrip) |
+| `lcoord_to_gid(1358, 1439)` | **0xA9B30038** (block_y 179, cell 6,7) |
+| `adjust_to_outside(0xA9B40031, (150, −1))` | cellId **0xA9B30038**, pos (150, **191**) |
+| `adjust_to_outside(0xA9B40031, (150, −109.65))` | cellId **0xA9B30034** (floor(−109.65/24) = −5 → ly 1435), pos (150, 82.35) |
+| `adjust_to_outside(0xA9B30038, (150, 193))` from A9B3 frame | cellId **0xA9B40031**, pos (150, 1) — northbound return |
diff --git a/docs/research/acclient_indoor_transitions_pseudocode.md b/docs/research/acclient_indoor_transitions_pseudocode.md
index ccc20fbc..b14840c4 100644
--- a/docs/research/acclient_indoor_transitions_pseudocode.md
+++ b/docs/research/acclient_indoor_transitions_pseudocode.md
@@ -241,6 +241,17 @@ EnvCell.check_building_transit(portalId, pos, numSphere, spheres[], cellArray, p
## LandCell.add_all_outside_cells (sphere variant)
+> ⚠️ **Correction (2026-06-09, issue #106):** this section understates the math.
+> Retail's lcoords are **GLOBAL** map-wide cell coordinates (0..2039), not
+> block-relative — `AdjustToOutside` doesn't just "normalise to correct block",
+> it can re-seat the cell id into a NEIGHBOUR landblock, and `add_outside_cell`
+> has no same-block filter. The original acdream port read this section as
+> single-block math and clamped to the current landblock's 8×8 grid, which froze
+> outdoor membership at landblock boundaries (#106). The full corrected
+> pseudocode (with the BN decomp artifacts and the ACE `add_cell_block` FIXME
+> divergence) is in
+> `docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`.
+
Determines which outdoor landblock cells an entity's spheres overlap. Each outdoor
cell is a 24×24m square. The function adds the primary cell plus up to 3 neighbors
when the sphere radius reaches a boundary.
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 4f66b5b8..4ae53df0 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -170,110 +170,112 @@ public static class CellTransit
///
/// Outdoor neighbour expansion. Ported from
- /// CLandCell::add_all_outside_cells (sphere variant) per the
- /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
+ /// CLandCell::add_all_outside_cells (sphere variant,
+ /// pc:317499 @0x00533630) per
+ /// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md.
///
///
- /// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index
- /// within a landblock is computed from local X/Y mod 24. The sphere
- /// adds the primary cell plus up to 3 neighbours when the radius
- /// reaches a cell boundary.
+ /// Retail runs this in the GLOBAL landcell grid (
+ /// lcoords, 0..2039 across the whole map): adjust_to_outside re-seats
+ /// the (cell, position) pair onto the landcell actually under the sphere —
+ /// crossing landblock boundaries when floor(local/24) leaves the
+ /// current block's 8×8 grid — and check_add_cell_boundary adds up to
+ /// 3 neighbour cells (strict >/< against the sphere radius), each id
+ /// re-derived from its own global lcoord. Issue #106: the pre-fix port
+ /// clamped everything to the current landblock's grid, so the candidate set
+ /// emptied the moment the player stepped over a boundary and membership
+ /// froze on the last in-block cell.
///
///
///
- /// is in the landblock-local coord
- /// space the rest of the engine uses (X/Y in [0, 192]; landblock
- /// world origin is at the streaming center, so all landblock-local
- /// positions are also world positions for the player's landblock).
- ///
- ///
- ///
- /// A6.P4 door fix (2026-05-24): pre-fix this function subtracted the
- /// landblock's "absolute" world origin (lbX=0xA9*192=32448) from the
- /// sphere position, which made sense only if sphere coords were the
- /// absolute world position (32580). But production has used
- /// landblock-local coords since Phase A.1 (streaming-center landblock
- /// at world origin, so lbOffset for the center is (0,0); see
- /// GameWindow.BuildInteriorEntitiesForStreaming's lbOffset
- /// formula). With landblock-local sphere coords, the old subtraction
- /// produced localX = 132.36 - 32448 = -32316 → gridX = -1346
- /// → out-of-range → early return → ZERO outdoor cells added. For
- /// indoor primary cells (where issue #98 gates the GetNearbyObjects
- /// outdoor radial sweep) this meant the cottage door's outdoor cell
- /// 0xA9B40029 never reached portalReachableCells, the door's
- /// BSP was never queried, and the player walked through unimpeded —
- /// the user-reported Holtburg-door walkthrough bug. The fix:
- /// treat worldSphereCenter as landblock-local directly, no
- /// landblock-world-origin subtraction. This matches retail's
- /// CLandCell::add_all_outside_cells which uses the per-cell
- /// 6-byte position struct (landblock-relative).
+ /// is in the floating world frame
+ /// (anchor landblock at origin — the convention every physics caller uses);
+ /// is the current cell's landblock
+ /// world origin (SW corner; ),
+ /// which converts it to retail's block-local frame. Pass
+ /// when the current block IS the anchor — the
+ /// pre-#106 behavior, and what the A6.P4 (2026-05-24) "landblock-local
+ /// coords" convention actually meant.
///
///
- public static void AddAllOutsideCells(
+ ///
+ /// False when adjust_to_outside rejects the position (map edge /
+ /// invalid cell id) — retail breaks out of the sphere loop on that.
+ ///
+ public static bool AddAllOutsideCells(
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
+ Vector3 currentBlockOrigin,
ICollection candidates)
{
- const float CellSize = 24f;
+ // Retail's position is block-local to the current cell's landblock.
+ var center = worldSphereCenter - currentBlockOrigin;
- uint lbPrefix = currentCellId & 0xFFFF0000u;
+ uint cellId = currentCellId;
+ if (!LandDefs.AdjustToOutside(ref cellId, ref center))
+ return false;
+ if (!LandDefs.GidToLcoord(cellId, out int lx, out int ly))
+ return false;
- float localX = worldSphereCenter.X;
- float localY = worldSphereCenter.Y;
+ AddOutsideCell(candidates, lx, ly);
- float cellLocalX = localX % CellSize;
- float cellLocalY = localY % CellSize;
+ // check_add_cell_boundary (pc:317229 @0x00533260): the point within the
+ // 24 m cell, from the adjust_to_outside-normalized block-local center
+ // (always [0, 192) post-adjust; floor-mod for safety). Strict >/< —
+ // a sphere exactly tangent to a boundary does NOT add the neighbour.
+ float pointX = center.X - MathF.Floor(center.X / LandDefs.CellLength) * LandDefs.CellLength;
+ float pointY = center.Y - MathF.Floor(center.Y / LandDefs.CellLength) * LandDefs.CellLength;
float minRad = sphereRadius;
- float maxRad = CellSize - sphereRadius;
+ float maxRad = LandDefs.CellLength - sphereRadius;
- int gridX = (int)(localX / CellSize);
- int gridY = (int)(localY / CellSize);
- if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
-
- AddOutsideCell(candidates, lbPrefix, gridX, gridY);
-
- if (cellLocalX > maxRad)
+ if (pointX > maxRad)
{
- AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY);
- if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1);
- if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1);
+ AddOutsideCell(candidates, lx + 1, ly);
+ if (pointY > maxRad) AddOutsideCell(candidates, lx + 1, ly + 1);
+ if (pointY < minRad) AddOutsideCell(candidates, lx + 1, ly - 1);
}
- if (cellLocalX < minRad)
+ if (pointX < minRad)
{
- AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY);
- if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1);
- if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1);
+ AddOutsideCell(candidates, lx - 1, ly);
+ if (pointY > maxRad) AddOutsideCell(candidates, lx - 1, ly + 1);
+ if (pointY < minRad) AddOutsideCell(candidates, lx - 1, ly - 1);
}
- if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1);
- if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1);
+ if (pointY > maxRad) AddOutsideCell(candidates, lx, ly + 1);
+ if (pointY < minRad) AddOutsideCell(candidates, lx, ly - 1);
+ return true;
}
///
/// Multi-sphere outdoor expansion. Retail's sphere variant loops every
- /// path sphere and adds the outdoor landcells touched by any of them.
+ /// path sphere and adds the outdoor landcells touched by any of them;
+ /// an adjust_to_outside failure BREAKS the loop (pc:533699).
///
public static void AddAllOutsideCells(
IReadOnlyList worldSpheres,
int numSpheres,
uint currentCellId,
+ Vector3 currentBlockOrigin,
ICollection candidates)
{
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
- AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, candidates);
+ if (!AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, currentBlockOrigin, candidates))
+ break;
}
}
- private static void AddOutsideCell(ICollection candidates, uint lbPrefix, int gridX, int gridY)
+ private static void AddOutsideCell(ICollection candidates, int lx, int ly)
{
- if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
-
- // Cell index within landblock: row-major (X * 8 + Y) + 1.
- uint low = (uint)(gridX * 8 + gridY + 1);
- candidates.Add(lbPrefix | low);
+ // CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
+ // then lcoord_to_gid — NO same-block filter (ACE's add_cell_block
+ // "FIXME!" guard is an ACE divergence, not retail). The block id is
+ // re-derived from the global lcoord, so neighbour-landblock cells come
+ // out with the neighbour's prefix.
+ uint gid = LandDefs.LcoordToGid(lx, ly);
+ if (gid != 0u) candidates.Add(gid);
}
///
@@ -504,7 +506,12 @@ public static class CellTransit
Vector3 worldSphereCenter = worldSpheres[0].Origin;
float sphereRadius = worldSpheres[0].Radius;
uint currentLow = currentCellId & 0xFFFFu;
- uint lbPrefix = currentCellId & 0xFFFF0000u;
+
+ // #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-block assumption (world frame == block-local frame).
+ cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);
if (currentLow >= 0x0100u)
{
@@ -539,7 +546,7 @@ public static class CellTransit
// pick order and interior-wins is preserved.
if (exitOutside && !outdoorAdded)
{
- AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates);
+ AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
outdoorAdded = true;
}
}
@@ -549,7 +556,7 @@ public static class CellTransit
// Outdoor seed: expand neighbour landcells (added first), then check each
// for a building stab whose portals cross into an interior EnvCell.
// (Stage 2 will make building entry intrinsic and remove CheckBuildingTransit.)
- AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates);
+ AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
var landcellSnapshot = new List(candidates.OrderedIds);
foreach (uint landcellId in landcellSnapshot)
@@ -571,6 +578,21 @@ public static class CellTransit
// (Replaces the 5ca2f44 current-first pre-check, which approximated this for
// the indoor-current case only; the ordered array now delivers it for every
// seed by construction.)
+ //
+ // #106: the outdoor containing cell is the GLOBAL XY-column under the sphere
+ // centre (LandDefs.AdjustToOutside from the current block's frame — retail
+ // subtracts get_block_offset per candidate before point_in_cell, pc:308804;
+ // landcells are disjoint columns so identity-compare is equivalent). The
+ // pre-fix [0,8)-clamped, current-prefix-only computation could never match a
+ // neighbour-block cell, freezing membership at landblock boundaries.
+ uint containingOutdoorId = 0u;
+ {
+ var pickPos = worldSphereCenter - blockOrigin;
+ uint pickCell = currentCellId;
+ if (LandDefs.AdjustToOutside(ref pickCell, ref pickPos))
+ containingOutdoorId = pickCell;
+ }
+
uint outdoorResult = 0u;
foreach (uint candId in candidates.OrderedIds)
{
@@ -583,20 +605,14 @@ public static class CellTransit
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
return candId; // interior-wins, stop (pseudo_c:308819)
}
- else if (outdoorResult == 0u)
+ else if (outdoorResult == 0u && containingOutdoorId != 0u)
{
// Outdoor candidate — CLandCell::point_in_cell is the XY-column the
// sphere is over (acdream landcells have no BSP point_in_cell; the
// documented adaptation). Record as the running result but DO NOT
// break — an interior cell later in the array can still win.
- int gx = (int)(worldSphereCenter.X / 24f);
- int gy = (int)(worldSphereCenter.Y / 24f);
- if (gx >= 0 && gx < 8 && gy >= 0 && gy < 8)
- {
- uint outdoorId = lbPrefix | (uint)(gx * 8 + gy + 1);
- if (candId == outdoorId)
- outdoorResult = candId;
- }
+ if (candId == containingOutdoorId)
+ outdoorResult = candId;
}
}
diff --git a/src/AcDream.Core/Physics/LandDefs.cs b/src/AcDream.Core/Physics/LandDefs.cs
new file mode 100644
index 00000000..a6a61040
--- /dev/null
+++ b/src/AcDream.Core/Physics/LandDefs.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Retail LandDefs outdoor-cell coordinate math (issue #106). All functions
+/// operate on the GLOBAL landcell coordinate space (lcoord, one unit per 24 m
+/// cell, range [0, 0x7F8) across the whole map) — landblock crossings are inherent
+/// in the math, never special-cased. Ported per
+/// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md; cross-checked
+/// against ACE Physics/Common/LandDefs.cs.
+///
+/// Frame note: retail positions are landblock-local ([0, 192) per axis,
+/// relative to the cell id's block). acdream physics positions are in the floating
+/// world frame (anchor landblock at origin) — callers convert via the current
+/// block's registered world origin BEFORE calling in here.
+///
+public static class LandDefs
+{
+ /// 24 m landcell side (retail square_length).
+ public const float CellLength = 24f;
+
+ /// 192 m landblock side.
+ public const float BlockLength = 192f;
+
+ /// Map-wide lcoord bound: 255 blocks × 8 cells (retail 0x7F8).
+ public const int LandLength = 0x7F8;
+
+ // Retail PhysicsGlobals::EPSILON as used by adjust_to_outside's coordinate
+ // snap (decomp :438719 shows the literal 0.000199999995f).
+ private const float Epsilon = 0.000199999995f;
+
+ ///
+ /// LandDefs::in_bounds (pc:68509, @0x0043d650): both lcoord axes inside
+ /// the map.
+ ///
+ public static bool InBounds(int lx, int ly)
+ => lx >= 0 && ly >= 0 && lx < LandLength && ly < LandLength;
+
+ ///
+ /// LandDefs::blockid_to_lcoord (pc:68520, @0x0043d680): the lcoord of a
+ /// landblock's (0,0) cell. Block bytes are ZERO-extended — the decomp's
+ /// int8_t cast on block_y is a Binary Ninja mis-render (ACE
+ /// LandDefs.cs:169 confirms plain masks).
+ ///
+ public static bool BlockIdToLcoord(uint cellId, out int lx, out int ly)
+ {
+ if (cellId == 0u) { lx = 0; ly = 0; return false; }
+ lx = (int)((cellId >> 24) & 0xFFu) << 3;
+ ly = (int)((cellId >> 16) & 0xFFu) << 3;
+ return InBounds(lx, ly);
+ }
+
+ ///
+ /// LandDefs::inbound_valid_cellid (pc:163438, @0x004979a0): low word in
+ /// a valid range (landcell 1..0x40, envcell 0x100..0xFFFD, or the 0xFFFF block
+ /// sentinel) and the block lcoord inside the map. Retail checks BOTH axes (ACE
+ /// checks only X — an ACE divergence, not copied).
+ ///
+ public static bool InboundValidCellId(uint cellId)
+ {
+ if (!CellLowInRange(cellId & 0xFFFFu)) return false;
+ int lx = (int)((cellId >> 24) & 0xFFu) << 3;
+ int ly = (int)((cellId >> 16) & 0xFFu) << 3;
+ return InBounds(lx, ly);
+ }
+
+ ///
+ /// LandDefs::gid_to_lcoord (pc:163500, @0x00497a90): a full OUTDOOR cell
+ /// id to its global lcoord. Fails for indoor ids (low ≥ 0x100).
+ ///
+ public static bool GidToLcoord(uint cellId, out int lx, out int ly)
+ {
+ lx = 0; ly = 0;
+ if (!InboundValidCellId(cellId)) return false;
+ uint low = cellId & 0xFFFFu;
+ if (low >= 0x100u) return false; // outdoor only
+
+ lx = ((int)((cellId >> 24) & 0xFFu) << 3) + (int)((low - 1u) >> 3);
+ ly = ((int)((cellId >> 16) & 0xFFu) << 3) + (int)((low - 1u) & 7u);
+ return InBounds(lx, ly);
+ }
+
+ ///
+ /// LandDefs::lcoord_to_gid (pc:171859, @0x004a19a0): a global lcoord to
+ /// the full outdoor cell id — the block id is RE-DERIVED from the lcoord's
+ /// upper bits, so a neighbour-block lcoord yields the neighbour's cell id.
+ /// Returns 0 when out of map bounds (retail behavior).
+ ///
+ public static uint LcoordToGid(int lx, int ly)
+ {
+ if (!InBounds(lx, ly)) return 0u;
+ uint low = (uint)((ly & 7) + ((lx & 7) << 3) + 1);
+ uint block = (uint)(((lx >> 3) << 8) | (ly >> 3));
+ return (block << 16) | low;
+ }
+
+ ///
+ /// LandDefs::get_outside_lcoord (pc:438690, @0x005a9b00): global lcoord
+ /// of the landcell under , expressed relative to
+ /// 's block. floor(pos/24) may be negative or
+ /// ≥ 8 — that IS the landblock crossing; the only rejection is the map edge.
+ ///
+ public static bool GetOutsideLcoord(uint cellId, Vector3 blockLocalPos, out int lx, out int ly)
+ {
+ lx = 0; ly = 0;
+ if (!CellLowInRange(cellId & 0xFFFFu)) return false;
+ BlockIdToLcoord(cellId, out lx, out ly);
+ lx += (int)MathF.Floor(blockLocalPos.X / CellLength);
+ ly += (int)MathF.Floor(blockLocalPos.Y / CellLength);
+ return InBounds(lx, ly);
+ }
+
+ ///
+ /// LandDefs::adjust_to_outside (pc:438719, @0x005a9bc0): re-seat
+ /// (, ) onto the outdoor
+ /// landcell actually under the point. On success the cell id may belong to a
+ /// NEIGHBOUR landblock and the position is re-based into that block's local
+ /// frame (pos -= floor(pos/192)·192; the decomp's 0f divisor is a
+ /// BN artifact — ACE LandDefs.cs:140 confirms BlockLength). On failure the cell
+ /// id is zeroed (retail behavior).
+ ///
+ public static bool AdjustToOutside(ref uint cellId, ref Vector3 blockLocalPos)
+ {
+ if (CellLowInRange(cellId & 0xFFFFu))
+ {
+ if (MathF.Abs(blockLocalPos.X) < Epsilon) blockLocalPos.X = 0f;
+ if (MathF.Abs(blockLocalPos.Y) < Epsilon) blockLocalPos.Y = 0f;
+
+ if (GetOutsideLcoord(cellId, blockLocalPos, out int lx, out int ly))
+ {
+ cellId = LcoordToGid(lx, ly);
+ blockLocalPos.X -= MathF.Floor(blockLocalPos.X / BlockLength) * BlockLength;
+ blockLocalPos.Y -= MathF.Floor(blockLocalPos.Y / BlockLength) * BlockLength;
+ return true;
+ }
+ }
+
+ cellId = 0u;
+ return false;
+ }
+
+ // Retail cell_in_range: landcell, envcell, or the block sentinel.
+ private static bool CellLowInRange(uint low)
+ => low is (>= 1u and <= 0x40u) or (>= 0x100u and <= 0xFFFDu) or 0xFFFFu;
+}
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index b0ddadd4..03a05957 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -416,7 +416,7 @@ public sealed class ShadowObjectRegistry
/// outdoor cell ids. already adds
/// outdoor cells to the candidate set when the sphere straddles an
/// indoor cell's exit portal (OtherCellId=0xFFFF) via
- /// .
+ /// .
/// Pre-slice-1, the explicit
/// "skip outdoor ids" filter combined with #98's indoor-primary gate
/// meant doors registered at outdoor cells (default cellScope=0
diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs
index 3f97bd51..fb6269fd 100644
--- a/src/AcDream.Core/World/Cells/CellGraph.cs
+++ b/src/AcDream.Core/World/Cells/CellGraph.cs
@@ -27,6 +27,25 @@ public sealed class CellGraph
public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
=> _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin);
+ ///
+ /// World origin (SW corner) of the landblock containing ,
+ /// as registered by . Issue #106: converts the
+ /// floating-world-frame sphere coords into retail's block-local frame for the
+ /// lcoord math. False (origin zero) when the landblock
+ /// has no registered terrain — callers fall back to the legacy anchor-block
+ /// assumption (world frame == block-local frame).
+ ///
+ public bool TryGetTerrainOrigin(uint id, out Vector3 origin)
+ {
+ if (_terrain.TryGetValue(id & 0xFFFF0000u, out var t))
+ {
+ origin = t.Origin;
+ return true;
+ }
+ origin = Vector3.Zero;
+ return false;
+ }
+
public void RemoveLandblock(uint landblockPrefix)
{
uint lb = landblockPrefix & 0xFFFF0000u;
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs
index b310d7d6..2de4d05a 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs
@@ -11,16 +11,18 @@ public class CellTransitAddAllOutsideCellsTests
public void SphereWellInsideCell_AddsOneCell()
{
// A6.P4 (2026-05-24): coords are LANDBLOCK-LOCAL (X/Y in [0, 192]).
- // Player at landblock-local (12, 12, 0) → cell (0,0) in landblock 0xA9B40000.
- // Pre-fix this test passed world coords (32460, 34572) and the function
- // subtracted lbXf=32448 to get local 12. Post-fix the function expects
- // landblock-local directly.
+ // #106 (2026-06-09): the convention is now explicit — coords are
+ // world-frame and currentBlockOrigin converts them to block-local;
+ // Zero origin == "current block is the anchor", the same inputs as
+ // before. Player at landblock-local (12, 12, 0) → cell (0,0) in
+ // landblock 0xA9B40000.
var candidates = new HashSet();
CellTransit.AddAllOutsideCells(
worldSphereCenter: new Vector3(12f, 12f, 0f),
sphereRadius: 0.5f,
currentCellId: 0xA9B40001u,
- candidates);
+ currentBlockOrigin: Vector3.Zero,
+ candidates: candidates);
Assert.Single(candidates);
Assert.Contains(0xA9B40001u, candidates);
@@ -29,19 +31,79 @@ public class CellTransitAddAllOutsideCellsTests
[Fact]
public void SphereAtCellEastBoundary_AddsTwoCells()
{
- // A6.P4 (2026-05-24): landblock-local coords. Player at (23.6, 12, 0)
- // — near +X edge of cell (0,0). Sphere reaches to local X = 23.6 + 0.5
- // = 24.1 → cell (1,0) added.
+ // Player at (23.6, 12, 0) — near +X edge of cell (0,0). Sphere
+ // reaches to local X = 23.6 + 0.5 = 24.1 → cell (1,0) added.
var candidates = new HashSet();
CellTransit.AddAllOutsideCells(
worldSphereCenter: new Vector3(23.6f, 12f, 0f),
sphereRadius: 0.5f,
currentCellId: 0xA9B40001u,
- candidates);
+ currentBlockOrigin: Vector3.Zero,
+ candidates: candidates);
Assert.Equal(2, candidates.Count);
Assert.Contains(0xA9B40001u, candidates);
// Cell (1,0): low-16 id = 1 * 8 + 0 + 1 = 9 → 0x0009.
Assert.Contains(0xA9B40009u, candidates);
}
+
+ // ── #106: landblock crossings (retail add_all_outside_cells has no
+ // same-block clamp — LandDefs global lcoords) ─────────────────────
+
+ [Fact]
+ public void SphereJustSouthOfBlockBoundary_AddsBothBlocks()
+ {
+ // 0.2 m south of A9B4's southern edge: adjust_to_outside re-seats to
+ // A9B3 row 7 (cell 0xA9B30038 under x=150), and the boundary check
+ // (point.Y = 23.8 > maxRad 23.5) adds the A9B4 cell back as the +Y
+ // neighbour. Pre-#106 this returned ZERO candidates (the freeze).
+ var candidates = new HashSet();
+ CellTransit.AddAllOutsideCells(
+ worldSphereCenter: new Vector3(150f, -0.2f, 0f),
+ sphereRadius: 0.5f,
+ currentCellId: 0xA9B40031u,
+ currentBlockOrigin: Vector3.Zero,
+ candidates: candidates);
+
+ Assert.Contains(0xA9B30038u, candidates); // containing (south block)
+ Assert.Contains(0xA9B40031u, candidates); // +Y neighbour (home block)
+ Assert.Equal(2, candidates.Count);
+ }
+
+ [Fact]
+ public void SphereDeepInNeighbourBlock_AddsNeighbourCellOnly()
+ {
+ // The #106 capture geometry: ~109.65 m south of A9B4's origin =
+ // ~82 m into A9B3 → cell row 3 (floor(-109.65/24) = -5 → ly 1435).
+ var candidates = new HashSet();
+ CellTransit.AddAllOutsideCells(
+ worldSphereCenter: new Vector3(150f, -109.65f, 0f),
+ sphereRadius: 0.5f,
+ currentCellId: 0xA9B40031u,
+ currentBlockOrigin: Vector3.Zero,
+ candidates: candidates);
+
+ Assert.Contains(0xA9B30034u, candidates);
+ Assert.DoesNotContain(0xA9B40031u, candidates);
+ }
+
+ [Fact]
+ public void NonAnchorBlockOrigin_ConvertsWorldFrame()
+ {
+ // Player's current cell is in A9B3 (one block south of the anchor,
+ // world origin (0, -192)). World y = -0.2 is block-local y = 191.8 —
+ // still inside A9B3 row 7, with the boundary check adding the A9B4
+ // cell to the north. Exercises the origin conversion that the
+ // pre-#106 code silently skipped (world frame assumed block-local).
+ var candidates = new HashSet();
+ CellTransit.AddAllOutsideCells(
+ worldSphereCenter: new Vector3(150f, -0.2f, 0f),
+ sphereRadius: 0.5f,
+ currentCellId: 0xA9B30038u,
+ currentBlockOrigin: new Vector3(0f, -192f, 0f),
+ candidates: candidates);
+
+ Assert.Contains(0xA9B30038u, candidates);
+ Assert.Contains(0xA9B40031u, candidates);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
index 130a3c15..dd55654f 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
@@ -159,6 +159,75 @@ public class CellTransitFindCellSetTests
Assert.Contains(0xA9B40001u, cellSet);
}
+ // ──────────────────────────────────────────────────────────────────
+ // #106 — outdoor membership across landblock boundaries.
+ // Retail's add_all_outside_cells + the find_cell_list pick run in the
+ // GLOBAL landcell grid (LandDefs lcoords); crossing a landblock boundary
+ // is inherent. The pre-#106 port clamped both to the current block's 8×8
+ // grid → zero candidates one step over the line → membership frozen
+ // (the 10,449-frame playerCell freeze in flap-105-capture.log).
+ // ──────────────────────────────────────────────────────────────────
+
+ [Fact]
+ 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.
+ var cache = new PhysicsDataCache();
+
+ uint containing = CellTransit.FindCellSet(
+ cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f,
+ currentCellId: 0xA9B40031u,
+ out var cellSet);
+
+ Assert.Equal(0xA9B30038u, containing);
+ Assert.Contains(0xA9B30038u, cellSet);
+ Assert.Contains(0xA9B40031u, cellSet); // +Y neighbour still in the set
+ }
+
+ [Fact]
+ public void OutdoorSeed_NearBoundaryButInside_StaysCurrent()
+ {
+ // 0.2 m NORTH of the boundary: the candidate set includes the A9B3
+ // 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).
+ var cache = new PhysicsDataCache();
+
+ uint containing = CellTransit.FindCellSet(
+ cache, new Vector3(150f, 0.2f, 0f), sphereRadius: 0.5f,
+ currentCellId: 0xA9B40031u,
+ out var cellSet);
+
+ Assert.Equal(0xA9B40031u, containing);
+ Assert.Contains(0xA9B30038u, cellSet);
+ }
+
+ [Fact]
+ public void OutdoorSeed_NonAnchorBlock_UsesRegisteredTerrainOrigin()
+ {
+ // Northbound return: the player's current cell is in A9B3 (origin
+ // (0, -192) registered via CellGraph terrain — the production path).
+ // World y = +1 is 1 m back into the anchor block A9B4; the pick must
+ // convert through A9B3's origin (block-local y = 193) and advance to
+ // 0xA9B40031. Pre-#106 the world frame was silently assumed
+ // block-local, which is wrong for every non-anchor block.
+ var cache = new PhysicsDataCache();
+ cache.CellGraph.RegisterTerrain(
+ 0xA9B30000u,
+ new TerrainSurface(new byte[81], new float[256]),
+ new Vector3(0f, -192f, 0f));
+
+ uint containing = CellTransit.FindCellSet(
+ cache, new Vector3(150f, 1f, 0f), sphereRadius: 0.5f,
+ currentCellId: 0xA9B30038u,
+ out _);
+
+ Assert.Equal(0xA9B40031u, containing);
+ }
+
// ──────────────────────────────────────────────────────────────────
// Membership hysteresis — the R1-flap root cause.
// Retail CObjCell::find_cell_list adds the CURRENT cell at index 0
diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
index 0c933b50..aca8b22e 100644
--- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
@@ -931,8 +931,11 @@ public class DoorBugTrajectoryReplayTests
const uint currentCellId = 0xA9B40150u;
var candidates = new HashSet();
+ // #106 (2026-06-09): origin parameter added — Vector3.Zero is the
+ // anchor-block frame this capture was taken in (same semantics as
+ // the pre-#106 4-arg form).
CellTransit.AddAllOutsideCells(
- sphereWorld, sphereRadius, currentCellId, candidates);
+ sphereWorld, sphereRadius, currentCellId, Vector3.Zero, candidates);
const uint expectedDoorCell = 0xA9B40029u;
Assert.True(candidates.Contains(expectedDoorCell),
diff --git a/tests/AcDream.Core.Tests/Physics/LandDefsTests.cs b/tests/AcDream.Core.Tests/Physics/LandDefsTests.cs
new file mode 100644
index 00000000..2093d8f2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/LandDefsTests.cs
@@ -0,0 +1,200 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Conformance tests for the retail LandDefs global-lcoord math (issue #106).
+/// Goldens derive from the decomp formulas (pseudocode doc:
+/// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md) using the
+/// Holtburg geometry from the #106 capture: landblock 0xA9B4 with its southern
+/// neighbour 0xA9B3 (block_x 0xA9 = 169, block_y 0xB4 = 180 / 0xB3 = 179).
+///
+public class LandDefsTests
+{
+ // ── blockid_to_lcoord (pc:68520) ────────────────────────────────────
+
+ [Fact]
+ public void BlockIdToLcoord_A9B4_Is_1352_1440()
+ {
+ Assert.True(LandDefs.BlockIdToLcoord(0xA9B40031u, out int lx, out int ly));
+ Assert.Equal(169 * 8, lx);
+ Assert.Equal(180 * 8, ly);
+ }
+
+ [Fact]
+ public void BlockIdToLcoord_HighBlockY_NoSignExtension()
+ {
+ // BN decomp renders block_y extraction with an int8_t cast that would
+ // sign-extend 0xB4 → negative. ACE confirms zero-extension; this test
+ // pins it for every block byte ≥ 0x80.
+ Assert.True(LandDefs.BlockIdToLcoord(0xFEFE0001u, out int lx, out int ly));
+ Assert.Equal(0xFE * 8, lx);
+ Assert.Equal(0xFE * 8, ly);
+ }
+
+ [Fact]
+ public void BlockIdToLcoord_ZeroCellId_Fails()
+ {
+ Assert.False(LandDefs.BlockIdToLcoord(0u, out _, out _));
+ }
+
+ // ── gid_to_lcoord (pc:163500) ───────────────────────────────────────
+
+ [Fact]
+ public void GidToLcoord_A9B40031_Is_1358_1440()
+ {
+ // low 0x31 = 49 → cell_x = (49-1)>>3 = 6, cell_y = (49-1)&7 = 0.
+ Assert.True(LandDefs.GidToLcoord(0xA9B40031u, out int lx, out int ly));
+ Assert.Equal(1358, lx);
+ Assert.Equal(1440, ly);
+ }
+
+ [Fact]
+ public void GidToLcoord_IndoorCell_Fails()
+ {
+ // Outdoor-only (decomp gates on low < 0x100).
+ Assert.False(LandDefs.GidToLcoord(0xA9B40164u, out _, out _));
+ }
+
+ [Fact]
+ public void GidToLcoord_InvalidLowRange_Fails()
+ {
+ Assert.False(LandDefs.GidToLcoord(0xA9B40000u, out _, out _)); // low 0
+ Assert.False(LandDefs.GidToLcoord(0xA9B40041u, out _, out _)); // low 0x41 (> 0x40, < 0x100)
+ }
+
+ // ── lcoord_to_gid (pc:171859) ───────────────────────────────────────
+
+ [Fact]
+ public void LcoordToGid_RoundTrips_A9B40031()
+ {
+ Assert.Equal(0xA9B40031u, LandDefs.LcoordToGid(1358, 1440));
+ }
+
+ [Fact]
+ public void LcoordToGid_CrossesBlockSouth()
+ {
+ // One lcoord row south of A9B4's southern edge → block_y 179 (0xB3),
+ // cell row 7: low = (1439&7) + (1358&7)*8 + 1 = 7 + 48 + 1 = 0x38.
+ Assert.Equal(0xA9B30038u, LandDefs.LcoordToGid(1358, 1439));
+ }
+
+ [Fact]
+ public void LcoordToGid_OutOfMapBounds_ReturnsZero()
+ {
+ Assert.Equal(0u, LandDefs.LcoordToGid(-1, 5));
+ Assert.Equal(0u, LandDefs.LcoordToGid(5, -1));
+ Assert.Equal(0u, LandDefs.LcoordToGid(0x7F8, 5));
+ Assert.Equal(0u, LandDefs.LcoordToGid(5, 0x7F8));
+ }
+
+ // ── get_outside_lcoord (pc:438690) ──────────────────────────────────
+
+ [Fact]
+ public void GetOutsideLcoord_NegativeLocalY_CrossesSouth()
+ {
+ // floor(-1/24) = -1 (floor, not truncation — negative-safe).
+ Assert.True(LandDefs.GetOutsideLcoord(
+ 0xA9B40031u, new Vector3(150f, -1f, 0f), out int lx, out int ly));
+ Assert.Equal(1358, lx);
+ Assert.Equal(1439, ly);
+ }
+
+ [Fact]
+ public void GetOutsideLcoord_FromIndoorCellId_UsesBlockBits()
+ {
+ // adjust_to_outside accepts indoor low16 (0x100..0xFFFD) — the block
+ // bits drive the lcoord; the position picks the landcell.
+ Assert.True(LandDefs.GetOutsideLcoord(
+ 0xA9B40164u, new Vector3(12f, 12f, 0f), out int lx, out int ly));
+ Assert.Equal(1352, lx);
+ Assert.Equal(1440, ly);
+ }
+
+ // ── adjust_to_outside (pc:438719) ───────────────────────────────────
+
+ [Fact]
+ public void AdjustToOutside_SouthCrossing_RewritesCellAndRebasesPos()
+ {
+ uint cellId = 0xA9B40031u;
+ var pos = new Vector3(150f, -1f, 5f);
+ Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
+ Assert.Equal(0xA9B30038u, cellId);
+ Assert.Equal(150f, pos.X, 4);
+ Assert.Equal(191f, pos.Y, 4); // -1 − floor(-1/192)·192 = 191 (new block's frame)
+ Assert.Equal(5f, pos.Z, 4); // Z untouched
+ }
+
+ [Fact]
+ public void AdjustToOutside_DeepSouth_CaptureGolden()
+ {
+ // The #106 capture geometry: player ~109.65 m south of A9B4's origin
+ // (≈82 m into A9B3). floor(-109.65/24) = -5 → ly 1435 → row 3 of 0xB3.
+ uint cellId = 0xA9B40031u;
+ var pos = new Vector3(150f, -109.65f, 0f);
+ Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
+ Assert.Equal(0xA9B30034u, cellId);
+ Assert.Equal(82.35f, pos.Y, 3);
+ }
+
+ [Fact]
+ public void AdjustToOutside_NorthboundReturn()
+ {
+ // From A9B3's frame, local y = 193 is 1 m into A9B4.
+ uint cellId = 0xA9B30038u;
+ var pos = new Vector3(150f, 193f, 0f);
+ Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
+ Assert.Equal(0xA9B40031u, cellId);
+ Assert.Equal(1f, pos.Y, 4);
+ }
+
+ [Fact]
+ public void AdjustToOutside_WithinBlock_KeepsBlockRewritesCell()
+ {
+ uint cellId = 0xA9B40031u; // cell (6,0)
+ var pos = new Vector3(12f, 12f, 0f); // over cell (0,0)
+ Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
+ Assert.Equal(0xA9B40001u, cellId);
+ Assert.Equal(12f, pos.X, 4);
+ Assert.Equal(12f, pos.Y, 4);
+ }
+
+ [Fact]
+ public void AdjustToOutside_EpsilonSnap_TreatsTinyNegativeAsZero()
+ {
+ // Retail snaps |coord| < 0.0002 to 0 BEFORE the floor — a hair-negative
+ // y stays in the current block instead of flapping to the neighbour.
+ uint cellId = 0xA9B40031u;
+ var pos = new Vector3(150f, -0.0001f, 0f);
+ Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
+ Assert.Equal(0xA9B40031u, cellId);
+ Assert.Equal(0f, pos.Y, 6);
+ }
+
+ [Fact]
+ public void AdjustToOutside_InvalidLow_FailsAndZeroesCell()
+ {
+ uint cellId = 0xA9B40050u; // low 0x50: not landcell, not envcell
+ var pos = new Vector3(12f, 12f, 0f);
+ Assert.False(LandDefs.AdjustToOutside(ref cellId, ref pos));
+ Assert.Equal(0u, cellId); // retail writes 0 on failure
+ }
+
+ // ── inbound_valid_cellid (pc:163438) ────────────────────────────────
+
+ [Theory]
+ [InlineData(0xA9B40001u, true)]
+ [InlineData(0xA9B40040u, true)]
+ [InlineData(0xA9B40100u, true)]
+ [InlineData(0xA9B4FFFDu, true)]
+ [InlineData(0xA9B4FFFFu, true)] // block sentinel
+ [InlineData(0xA9B40000u, false)]
+ [InlineData(0xA9B40041u, false)]
+ [InlineData(0xA9B4FFFEu, false)]
+ public void InboundValidCellId_LowRanges(uint cellId, bool expected)
+ {
+ Assert.Equal(expected, LandDefs.InboundValidCellId(cellId));
+ }
+}