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>
8.3 KiB
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
Positionstructs 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 |