fix(phys): #106 — outdoor membership crosses landblock boundaries (LandDefs global-lcoord port)

The player's outdoor cell froze at the last in-block cell the moment they
walked over a landblock boundary (10,449-frame playerCell freeze in the
2026-06-09 capture; whole neighbouring-block interiors unenterable, plus
the running-distortion from the stale render anchor). Root cause: the
add_all_outside_cells port clamped BOTH the candidate proposal and the
find_cell_list containing-cell pick to the current landblock's 8x8 grid,
in a frame that silently assumed the current block sits at world origin.
One step over the line -> zero candidates -> FindCellSet returns
currentCellId forever.

Retail has no such clamp. Its cell math runs in a GLOBAL landcell grid
(lcoord 0..2039 spanning the map): get_outside_lcoord = blockid_to_lcoord
+ floor(blockLocalPos/24) with no bounds besides the map edge, and
lcoord_to_gid re-derives the landblock id from the lcoord's upper bits —
crossings are inherent, never special-cased.

The fix, decomp-cited throughout:
- New AcDream.Core.Physics.LandDefs: in_bounds (pc:68509),
  blockid_to_lcoord (pc:68520), inbound_valid_cellid (pc:163438),
  gid_to_lcoord (pc:163500), lcoord_to_gid (pc:171859),
  get_outside_lcoord (pc:438690), adjust_to_outside (pc:438719).
  Cross-checked against ACE LandDefs.cs; three artifacts documented and
  avoided: BN's int8_t mis-render of block_y, BN's dropped 192f
  BlockLength constant, and ACE add_cell_block's "FIXME!" same-block
  guard (an ACE divergence, not retail).
- CellTransit.AddAllOutsideCells rewritten as the faithful sphere
  variant (pc:317499 @0x00533630): adjust_to_outside re-seats the
  (cell, position) pair cross-block, check_add_cell_boundary (pc:317229)
  adds up to 3 neighbours by global lcoord, add_outside_cell (pc:317056)
  has no same-block filter. adjust_to_outside failure breaks the sphere
  loop (pc:533699 verbatim).
- BuildCellSetAndPickContaining: the outdoor containing-cell pick is now
  the global XY-column under the sphere centre (AdjustToOutside), not
  the [0,8)-clamped current-prefix reconstruction. Interior-wins order
  and current-cell-first hysteresis unchanged.
- World->block-local frame conversion via the landblock origin already
  registered in CellGraph (new TryGetTerrainOrigin); Zero fallback
  preserves the legacy anchor-block assumption for unregistered terrain.
- Cross-landblock building entry comes free: the candidate snapshot now
  contains neighbour-block landcells, so GetBuilding/CheckBuildingTransit
  fire for cottages across the line (the capture's one failing entry).

Investigated FIRST per the pickup brief: the b3ce505 #98 stopgap gate is
definitively exonerated — it is a collision-object query gate that fires
only for indoor primary cells; no membership path touches
ShadowObjectRegistry.

Tests: 31 new (25 LandDefs conformance incl. capture-geometry goldens
0xA9B40031 -> 0xA9B30038/0xA9B30034 and the northbound return; 4
AddAllOutsideCells cross-block; 3 FindCellSet membership goldens incl.
the non-anchor-frame origin conversion). Full suite: 294+218+420 green;
Core 1369 green + the 4 pre-existing door/#99-era failures + 1 skip
(unchanged from baseline).

Pseudocode + artifact notes:
docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md.
Remaining acceptance: live boundary walk with ACDREAM_PROBE_CELL=1
(ISSUES.md #106).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-09 23:10:59 +02:00
parent 12fb408972
commit 7078264291
11 changed files with 813 additions and 88 deletions

View file

@ -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

View file

@ -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 3124 = `block_x` (eastwest), bits 2316 = `block_y` (northsouth).
- 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 + ((low1) >> 3)
ly = block_y*8 + ((low1) & 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=24radius)
```
### 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, ly1)
if point.x < minRad:
add_outside_cell(lx1, ly)
if point.y > maxRad: add_outside_cell(lx1, ly+1)
if point.y < minRad: add_outside_cell(lx1, ly1)
if point.y > maxRad: add_outside_cell(lx, ly+1)
if point.y < minRad: add_outside_cell(lx, ly1)
```
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 |

View file

@ -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.

View file

@ -170,110 +170,112 @@ public static class CellTransit
/// <summary>
/// Outdoor neighbour expansion. Ported from
/// <c>CLandCell::add_all_outside_cells</c> (sphere variant) per the
/// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
/// <c>CLandCell::add_all_outside_cells</c> (sphere variant,
/// pc:317499 @0x00533630) per
/// <c>docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md</c>.
///
/// <para>
/// 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 (<see cref="LandDefs"/>
/// lcoords, 0..2039 across the whole map): <c>adjust_to_outside</c> re-seats
/// the (cell, position) pair onto the landcell actually under the sphere —
/// crossing landblock boundaries when <c>floor(local/24)</c> leaves the
/// current block's 8×8 grid — and <c>check_add_cell_boundary</c> adds up to
/// 3 neighbour cells (strict &gt;/&lt; 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.
/// </para>
///
/// <para>
/// <see cref="worldSphereCenter"/> 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).
/// </para>
///
/// <para>
/// 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
/// <c>GameWindow.BuildInteriorEntitiesForStreaming</c>'s lbOffset
/// formula). With landblock-local sphere coords, the old subtraction
/// produced <c>localX = 132.36 - 32448 = -32316</c> → <c>gridX = -1346</c>
/// → 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 <c>portalReachableCells</c>, the door's
/// BSP was never queried, and the player walked through unimpeded —
/// the user-reported Holtburg-door walkthrough bug. The fix:
/// treat <c>worldSphereCenter</c> as landblock-local directly, no
/// landblock-world-origin subtraction. This matches retail's
/// <c>CLandCell::add_all_outside_cells</c> which uses the per-cell
/// 6-byte position struct (landblock-relative).
/// <paramref name="worldSphereCenter"/> is in the floating world frame
/// (anchor landblock at origin — the convention every physics caller uses);
/// <paramref name="currentBlockOrigin"/> is the current cell's landblock
/// world origin (SW corner; <see cref="World.Cells.CellGraph.TryGetTerrainOrigin"/>),
/// which converts it to retail's block-local frame. Pass
/// <see cref="Vector3.Zero"/> 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.
/// </para>
/// </summary>
public static void AddAllOutsideCells(
/// <returns>
/// False when <c>adjust_to_outside</c> rejects the position (map edge /
/// invalid cell id) — retail breaks out of the sphere loop on that.
/// </returns>
public static bool AddAllOutsideCells(
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> 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;
}
/// <summary>
/// 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 <c>adjust_to_outside</c> failure BREAKS the loop (pc:533699).
/// </summary>
public static void AddAllOutsideCells(
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> 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<uint> candidates, uint lbPrefix, int gridX, int gridY)
private static void AddOutsideCell(ICollection<uint> 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);
}
/// <summary>
@ -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<uint>(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,22 +605,16 @@ 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)
if (candId == containingOutdoorId)
outdoorResult = candId;
}
}
}
// No interior cell contained the centre. Return the outdoor XY-column cell if
// it was a candidate, else stay on the current cell (retail leaves *result

View file

@ -0,0 +1,147 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>LandDefs</c> outdoor-cell coordinate math (issue #106). All functions
/// operate on the GLOBAL landcell coordinate space (<c>lcoord</c>, 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
/// <c>docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md</c>; cross-checked
/// against ACE <c>Physics/Common/LandDefs.cs</c>.
///
/// <para>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.</para>
/// </summary>
public static class LandDefs
{
/// <summary>24 m landcell side (retail <c>square_length</c>).</summary>
public const float CellLength = 24f;
/// <summary>192 m landblock side.</summary>
public const float BlockLength = 192f;
/// <summary>Map-wide lcoord bound: 255 blocks × 8 cells (retail <c>0x7F8</c>).</summary>
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;
/// <summary>
/// <c>LandDefs::in_bounds</c> (pc:68509, @0x0043d650): both lcoord axes inside
/// the map.
/// </summary>
public static bool InBounds(int lx, int ly)
=> lx >= 0 && ly >= 0 && lx < LandLength && ly < LandLength;
/// <summary>
/// <c>LandDefs::blockid_to_lcoord</c> (pc:68520, @0x0043d680): the lcoord of a
/// landblock's (0,0) cell. Block bytes are ZERO-extended — the decomp's
/// <c>int8_t</c> cast on block_y is a Binary Ninja mis-render (ACE
/// LandDefs.cs:169 confirms plain masks).
/// </summary>
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);
}
/// <summary>
/// <c>LandDefs::inbound_valid_cellid</c> (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).
/// </summary>
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);
}
/// <summary>
/// <c>LandDefs::gid_to_lcoord</c> (pc:163500, @0x00497a90): a full OUTDOOR cell
/// id to its global lcoord. Fails for indoor ids (low ≥ 0x100).
/// </summary>
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);
}
/// <summary>
/// <c>LandDefs::lcoord_to_gid</c> (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).
/// </summary>
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;
}
/// <summary>
/// <c>LandDefs::get_outside_lcoord</c> (pc:438690, @0x005a9b00): global lcoord
/// of the landcell under <paramref name="blockLocalPos"/>, expressed relative to
/// <paramref name="cellId"/>'s block. <c>floor(pos/24)</c> may be negative or
/// ≥ 8 — that IS the landblock crossing; the only rejection is the map edge.
/// </summary>
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);
}
/// <summary>
/// <c>LandDefs::adjust_to_outside</c> (pc:438719, @0x005a9bc0): re-seat
/// (<paramref name="cellId"/>, <paramref name="blockLocalPos"/>) 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 (<c>pos -= floor(pos/192)·192</c>; the decomp's <c>0f</c> divisor is a
/// BN artifact — ACE LandDefs.cs:140 confirms BlockLength). On failure the cell
/// id is zeroed (retail behavior).
/// </summary>
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;
}

View file

@ -416,7 +416,7 @@ public sealed class ShadowObjectRegistry
/// outdoor cell ids. <see cref="CellTransit.FindCellSet"/> already adds
/// outdoor cells to the candidate set when the sphere straddles an
/// indoor cell's exit portal (<c>OtherCellId=0xFFFF</c>) via
/// <see cref="CellTransit.AddAllOutsideCells(System.Numerics.Vector3, float, uint, System.Collections.Generic.HashSet{uint})"/>.
/// <see cref="CellTransit.AddAllOutsideCells(System.Numerics.Vector3, float, uint, System.Numerics.Vector3, System.Collections.Generic.ICollection{uint})"/>.
/// Pre-slice-1, the explicit
/// "skip outdoor ids" filter combined with #98's indoor-primary gate
/// meant doors registered at outdoor cells (default <c>cellScope=0</c>

View file

@ -27,6 +27,25 @@ public sealed class CellGraph
public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
=> _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin);
/// <summary>
/// World origin (SW corner) of the landblock containing <paramref name="id"/>,
/// as registered by <see cref="RegisterTerrain"/>. Issue #106: converts the
/// floating-world-frame sphere coords into retail's block-local frame for the
/// <see cref="LandDefs"/> 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).
/// </summary>
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;

View file

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

View file

@ -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

View file

@ -931,8 +931,11 @@ public class DoorBugTrajectoryReplayTests
const uint currentCellId = 0xA9B40150u;
var candidates = new HashSet<uint>();
// #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),

View file

@ -0,0 +1,200 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// 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).
/// </summary>
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));
}
}