acdream/docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md
Erik 7078264291 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>
2026-06-09 23:10:59 +02:00

183 lines
8.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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