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>
183 lines
8.3 KiB
Markdown
183 lines
8.3 KiB
Markdown
# 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 |
|