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

8.3 KiB
Raw Blame History

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 = 1439block_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