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

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