diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8ce6f771..ec5f0fe8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,11 +46,26 @@ Copy this block when adding a new issue: ## #106 — Outdoor player-cell membership FREEZES at landblock boundaries (whole interiors unenterable) -**Status:** OPEN +**Status:** FIX LANDED 2026-06-09 — pending live boundary-walk gate (acceptance below) **Severity:** HIGH **Filed:** 2026-06-09 **Component:** physics, membership +**Fix (2026-06-09):** retail `LandDefs` global-lcoord math ported as +`AcDream.Core.Physics.LandDefs` (`blockid_to_lcoord` / `gid_to_lcoord` / +`lcoord_to_gid` / `get_outside_lcoord` / `adjust_to_outside`, decomp-cited); +`CellTransit.AddAllOutsideCells` rewritten as the faithful sphere-variant +(pc:317499) — candidates and the `find_cell_list` pick now run in the GLOBAL +landcell grid, so landblock crossings are inherent (the pre-fix port clamped +both to the current block's 8×8 grid → zero candidates one step over the line → +frozen). World→block-local conversion via the landblock origin registered in +`CellGraph` (`TryGetTerrainOrigin`). The `b3ce505` gate was investigated FIRST +and definitively exonerated (collision-only, fires only for indoor primaries — +no membership path touches it). 31 new conformance tests incl. the capture +geometry goldens (0xA9B40031 → 0xA9B30038/0xA9B30034) and a non-anchor-frame +northbound return. Pseudocode + decomp-artifact notes: +`docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`. + **Description:** Walking outdoors across a landblock boundary does NOT update the player's outdoor cell: `playerCell` stays pinned to the last cell of the previous landblock, indefinitely. Every downstream consumer degrades: entering any building in the new landblock diff --git a/docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md b/docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md new file mode 100644 index 00000000..37608d17 --- /dev/null +++ b/docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md @@ -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 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 | diff --git a/docs/research/acclient_indoor_transitions_pseudocode.md b/docs/research/acclient_indoor_transitions_pseudocode.md index ccc20fbc..b14840c4 100644 --- a/docs/research/acclient_indoor_transitions_pseudocode.md +++ b/docs/research/acclient_indoor_transitions_pseudocode.md @@ -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. diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 4f66b5b8..4ae53df0 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -170,110 +170,112 @@ public static class CellTransit /// /// Outdoor neighbour expansion. Ported from - /// CLandCell::add_all_outside_cells (sphere variant) per the - /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)". + /// CLandCell::add_all_outside_cells (sphere variant, + /// pc:317499 @0x00533630) per + /// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md. /// /// - /// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index - /// within a landblock is computed from local X/Y mod 24. The sphere - /// adds the primary cell plus up to 3 neighbours when the radius - /// reaches a cell boundary. + /// Retail runs this in the GLOBAL landcell grid ( + /// lcoords, 0..2039 across the whole map): adjust_to_outside re-seats + /// the (cell, position) pair onto the landcell actually under the sphere — + /// crossing landblock boundaries when floor(local/24) leaves the + /// current block's 8×8 grid — and check_add_cell_boundary adds up to + /// 3 neighbour cells (strict >/< against the sphere radius), each id + /// re-derived from its own global lcoord. Issue #106: the pre-fix port + /// clamped everything to the current landblock's grid, so the candidate set + /// emptied the moment the player stepped over a boundary and membership + /// froze on the last in-block cell. /// /// /// - /// is in the landblock-local coord - /// space the rest of the engine uses (X/Y in [0, 192]; landblock - /// world origin is at the streaming center, so all landblock-local - /// positions are also world positions for the player's landblock). - /// - /// - /// - /// A6.P4 door fix (2026-05-24): pre-fix this function subtracted the - /// landblock's "absolute" world origin (lbX=0xA9*192=32448) from the - /// sphere position, which made sense only if sphere coords were the - /// absolute world position (32580). But production has used - /// landblock-local coords since Phase A.1 (streaming-center landblock - /// at world origin, so lbOffset for the center is (0,0); see - /// GameWindow.BuildInteriorEntitiesForStreaming's lbOffset - /// formula). With landblock-local sphere coords, the old subtraction - /// produced localX = 132.36 - 32448 = -32316gridX = -1346 - /// → out-of-range → early return → ZERO outdoor cells added. For - /// indoor primary cells (where issue #98 gates the GetNearbyObjects - /// outdoor radial sweep) this meant the cottage door's outdoor cell - /// 0xA9B40029 never reached portalReachableCells, the door's - /// BSP was never queried, and the player walked through unimpeded — - /// the user-reported Holtburg-door walkthrough bug. The fix: - /// treat worldSphereCenter as landblock-local directly, no - /// landblock-world-origin subtraction. This matches retail's - /// CLandCell::add_all_outside_cells which uses the per-cell - /// 6-byte position struct (landblock-relative). + /// is in the floating world frame + /// (anchor landblock at origin — the convention every physics caller uses); + /// is the current cell's landblock + /// world origin (SW corner; ), + /// which converts it to retail's block-local frame. Pass + /// when the current block IS the anchor — the + /// pre-#106 behavior, and what the A6.P4 (2026-05-24) "landblock-local + /// coords" convention actually meant. /// /// - public static void AddAllOutsideCells( + /// + /// False when adjust_to_outside rejects the position (map edge / + /// invalid cell id) — retail breaks out of the sphere loop on that. + /// + public static bool AddAllOutsideCells( Vector3 worldSphereCenter, float sphereRadius, uint currentCellId, + Vector3 currentBlockOrigin, ICollection candidates) { - const float CellSize = 24f; + // Retail's position is block-local to the current cell's landblock. + var center = worldSphereCenter - currentBlockOrigin; - uint lbPrefix = currentCellId & 0xFFFF0000u; + uint cellId = currentCellId; + if (!LandDefs.AdjustToOutside(ref cellId, ref center)) + return false; + if (!LandDefs.GidToLcoord(cellId, out int lx, out int ly)) + return false; - float localX = worldSphereCenter.X; - float localY = worldSphereCenter.Y; + AddOutsideCell(candidates, lx, ly); - float cellLocalX = localX % CellSize; - float cellLocalY = localY % CellSize; + // check_add_cell_boundary (pc:317229 @0x00533260): the point within the + // 24 m cell, from the adjust_to_outside-normalized block-local center + // (always [0, 192) post-adjust; floor-mod for safety). Strict >/< — + // a sphere exactly tangent to a boundary does NOT add the neighbour. + float pointX = center.X - MathF.Floor(center.X / LandDefs.CellLength) * LandDefs.CellLength; + float pointY = center.Y - MathF.Floor(center.Y / LandDefs.CellLength) * LandDefs.CellLength; float minRad = sphereRadius; - float maxRad = CellSize - sphereRadius; + float maxRad = LandDefs.CellLength - sphereRadius; - int gridX = (int)(localX / CellSize); - int gridY = (int)(localY / CellSize); - if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return; - - AddOutsideCell(candidates, lbPrefix, gridX, gridY); - - if (cellLocalX > maxRad) + if (pointX > maxRad) { - AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY); - if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1); - if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1); + AddOutsideCell(candidates, lx + 1, ly); + if (pointY > maxRad) AddOutsideCell(candidates, lx + 1, ly + 1); + if (pointY < minRad) AddOutsideCell(candidates, lx + 1, ly - 1); } - if (cellLocalX < minRad) + if (pointX < minRad) { - AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY); - if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1); - if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1); + AddOutsideCell(candidates, lx - 1, ly); + if (pointY > maxRad) AddOutsideCell(candidates, lx - 1, ly + 1); + if (pointY < minRad) AddOutsideCell(candidates, lx - 1, ly - 1); } - if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1); - if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1); + if (pointY > maxRad) AddOutsideCell(candidates, lx, ly + 1); + if (pointY < minRad) AddOutsideCell(candidates, lx, ly - 1); + return true; } /// /// Multi-sphere outdoor expansion. Retail's sphere variant loops every - /// path sphere and adds the outdoor landcells touched by any of them. + /// path sphere and adds the outdoor landcells touched by any of them; + /// an adjust_to_outside failure BREAKS the loop (pc:533699). /// public static void AddAllOutsideCells( IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, + Vector3 currentBlockOrigin, ICollection candidates) { int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); for (int i = 0; i < sphereCount; i++) { var sphere = worldSpheres[i]; - AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, candidates); + if (!AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, currentBlockOrigin, candidates)) + break; } } - private static void AddOutsideCell(ICollection candidates, uint lbPrefix, int gridX, int gridY) + private static void AddOutsideCell(ICollection candidates, int lx, int ly) { - if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return; - - // Cell index within landblock: row-major (X * 8 + Y) + 1. - uint low = (uint)(gridX * 8 + gridY + 1); - candidates.Add(lbPrefix | low); + // CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check, + // then lcoord_to_gid — NO same-block filter (ACE's add_cell_block + // "FIXME!" guard is an ACE divergence, not retail). The block id is + // re-derived from the global lcoord, so neighbour-landblock cells come + // out with the neighbour's prefix. + uint gid = LandDefs.LcoordToGid(lx, ly); + if (gid != 0u) candidates.Add(gid); } /// @@ -504,7 +506,12 @@ public static class CellTransit Vector3 worldSphereCenter = worldSpheres[0].Origin; float sphereRadius = worldSpheres[0].Radius; uint currentLow = currentCellId & 0xFFFFu; - uint lbPrefix = currentCellId & 0xFFFF0000u; + + // #106: the current block's world origin converts the world-frame sphere + // coords into retail's block-local frame for the LandDefs lcoord math. + // Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero — + // the legacy anchor-block assumption (world frame == block-local frame). + cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin); if (currentLow >= 0x0100u) { @@ -539,7 +546,7 @@ public static class CellTransit // pick order and interior-wins is preserved. if (exitOutside && !outdoorAdded) { - AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates); + AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates); outdoorAdded = true; } } @@ -549,7 +556,7 @@ public static class CellTransit // Outdoor seed: expand neighbour landcells (added first), then check each // for a building stab whose portals cross into an interior EnvCell. // (Stage 2 will make building entry intrinsic and remove CheckBuildingTransit.) - AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates); + AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates); var landcellSnapshot = new List(candidates.OrderedIds); foreach (uint landcellId in landcellSnapshot) @@ -571,6 +578,21 @@ public static class CellTransit // (Replaces the 5ca2f44 current-first pre-check, which approximated this for // the indoor-current case only; the ordered array now delivers it for every // seed by construction.) + // + // #106: the outdoor containing cell is the GLOBAL XY-column under the sphere + // centre (LandDefs.AdjustToOutside from the current block's frame — retail + // subtracts get_block_offset per candidate before point_in_cell, pc:308804; + // landcells are disjoint columns so identity-compare is equivalent). The + // pre-fix [0,8)-clamped, current-prefix-only computation could never match a + // neighbour-block cell, freezing membership at landblock boundaries. + uint containingOutdoorId = 0u; + { + var pickPos = worldSphereCenter - blockOrigin; + uint pickCell = currentCellId; + if (LandDefs.AdjustToOutside(ref pickCell, ref pickPos)) + containingOutdoorId = pickCell; + } + uint outdoorResult = 0u; foreach (uint candId in candidates.OrderedIds) { @@ -583,20 +605,14 @@ public static class CellTransit if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local)) return candId; // interior-wins, stop (pseudo_c:308819) } - else if (outdoorResult == 0u) + else if (outdoorResult == 0u && containingOutdoorId != 0u) { // Outdoor candidate — CLandCell::point_in_cell is the XY-column the // sphere is over (acdream landcells have no BSP point_in_cell; the // documented adaptation). Record as the running result but DO NOT // break — an interior cell later in the array can still win. - int gx = (int)(worldSphereCenter.X / 24f); - int gy = (int)(worldSphereCenter.Y / 24f); - if (gx >= 0 && gx < 8 && gy >= 0 && gy < 8) - { - uint outdoorId = lbPrefix | (uint)(gx * 8 + gy + 1); - if (candId == outdoorId) - outdoorResult = candId; - } + if (candId == containingOutdoorId) + outdoorResult = candId; } } diff --git a/src/AcDream.Core/Physics/LandDefs.cs b/src/AcDream.Core/Physics/LandDefs.cs new file mode 100644 index 00000000..a6a61040 --- /dev/null +++ b/src/AcDream.Core/Physics/LandDefs.cs @@ -0,0 +1,147 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Retail LandDefs outdoor-cell coordinate math (issue #106). All functions +/// operate on the GLOBAL landcell coordinate space (lcoord, one unit per 24 m +/// cell, range [0, 0x7F8) across the whole map) — landblock crossings are inherent +/// in the math, never special-cased. Ported per +/// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md; cross-checked +/// against ACE Physics/Common/LandDefs.cs. +/// +/// Frame note: retail positions are landblock-local ([0, 192) per axis, +/// relative to the cell id's block). acdream physics positions are in the floating +/// world frame (anchor landblock at origin) — callers convert via the current +/// block's registered world origin BEFORE calling in here. +/// +public static class LandDefs +{ + /// 24 m landcell side (retail square_length). + public const float CellLength = 24f; + + /// 192 m landblock side. + public const float BlockLength = 192f; + + /// Map-wide lcoord bound: 255 blocks × 8 cells (retail 0x7F8). + public const int LandLength = 0x7F8; + + // Retail PhysicsGlobals::EPSILON as used by adjust_to_outside's coordinate + // snap (decomp :438719 shows the literal 0.000199999995f). + private const float Epsilon = 0.000199999995f; + + /// + /// LandDefs::in_bounds (pc:68509, @0x0043d650): both lcoord axes inside + /// the map. + /// + public static bool InBounds(int lx, int ly) + => lx >= 0 && ly >= 0 && lx < LandLength && ly < LandLength; + + /// + /// LandDefs::blockid_to_lcoord (pc:68520, @0x0043d680): the lcoord of a + /// landblock's (0,0) cell. Block bytes are ZERO-extended — the decomp's + /// int8_t cast on block_y is a Binary Ninja mis-render (ACE + /// LandDefs.cs:169 confirms plain masks). + /// + public static bool BlockIdToLcoord(uint cellId, out int lx, out int ly) + { + if (cellId == 0u) { lx = 0; ly = 0; return false; } + lx = (int)((cellId >> 24) & 0xFFu) << 3; + ly = (int)((cellId >> 16) & 0xFFu) << 3; + return InBounds(lx, ly); + } + + /// + /// LandDefs::inbound_valid_cellid (pc:163438, @0x004979a0): low word in + /// a valid range (landcell 1..0x40, envcell 0x100..0xFFFD, or the 0xFFFF block + /// sentinel) and the block lcoord inside the map. Retail checks BOTH axes (ACE + /// checks only X — an ACE divergence, not copied). + /// + public static bool InboundValidCellId(uint cellId) + { + if (!CellLowInRange(cellId & 0xFFFFu)) return false; + int lx = (int)((cellId >> 24) & 0xFFu) << 3; + int ly = (int)((cellId >> 16) & 0xFFu) << 3; + return InBounds(lx, ly); + } + + /// + /// LandDefs::gid_to_lcoord (pc:163500, @0x00497a90): a full OUTDOOR cell + /// id to its global lcoord. Fails for indoor ids (low ≥ 0x100). + /// + public static bool GidToLcoord(uint cellId, out int lx, out int ly) + { + lx = 0; ly = 0; + if (!InboundValidCellId(cellId)) return false; + uint low = cellId & 0xFFFFu; + if (low >= 0x100u) return false; // outdoor only + + lx = ((int)((cellId >> 24) & 0xFFu) << 3) + (int)((low - 1u) >> 3); + ly = ((int)((cellId >> 16) & 0xFFu) << 3) + (int)((low - 1u) & 7u); + return InBounds(lx, ly); + } + + /// + /// LandDefs::lcoord_to_gid (pc:171859, @0x004a19a0): a global lcoord to + /// the full outdoor cell id — the block id is RE-DERIVED from the lcoord's + /// upper bits, so a neighbour-block lcoord yields the neighbour's cell id. + /// Returns 0 when out of map bounds (retail behavior). + /// + public static uint LcoordToGid(int lx, int ly) + { + if (!InBounds(lx, ly)) return 0u; + uint low = (uint)((ly & 7) + ((lx & 7) << 3) + 1); + uint block = (uint)(((lx >> 3) << 8) | (ly >> 3)); + return (block << 16) | low; + } + + /// + /// LandDefs::get_outside_lcoord (pc:438690, @0x005a9b00): global lcoord + /// of the landcell under , expressed relative to + /// 's block. floor(pos/24) may be negative or + /// ≥ 8 — that IS the landblock crossing; the only rejection is the map edge. + /// + public static bool GetOutsideLcoord(uint cellId, Vector3 blockLocalPos, out int lx, out int ly) + { + lx = 0; ly = 0; + if (!CellLowInRange(cellId & 0xFFFFu)) return false; + BlockIdToLcoord(cellId, out lx, out ly); + lx += (int)MathF.Floor(blockLocalPos.X / CellLength); + ly += (int)MathF.Floor(blockLocalPos.Y / CellLength); + return InBounds(lx, ly); + } + + /// + /// LandDefs::adjust_to_outside (pc:438719, @0x005a9bc0): re-seat + /// (, ) onto the outdoor + /// landcell actually under the point. On success the cell id may belong to a + /// NEIGHBOUR landblock and the position is re-based into that block's local + /// frame (pos -= floor(pos/192)·192; the decomp's 0f divisor is a + /// BN artifact — ACE LandDefs.cs:140 confirms BlockLength). On failure the cell + /// id is zeroed (retail behavior). + /// + public static bool AdjustToOutside(ref uint cellId, ref Vector3 blockLocalPos) + { + if (CellLowInRange(cellId & 0xFFFFu)) + { + if (MathF.Abs(blockLocalPos.X) < Epsilon) blockLocalPos.X = 0f; + if (MathF.Abs(blockLocalPos.Y) < Epsilon) blockLocalPos.Y = 0f; + + if (GetOutsideLcoord(cellId, blockLocalPos, out int lx, out int ly)) + { + cellId = LcoordToGid(lx, ly); + blockLocalPos.X -= MathF.Floor(blockLocalPos.X / BlockLength) * BlockLength; + blockLocalPos.Y -= MathF.Floor(blockLocalPos.Y / BlockLength) * BlockLength; + return true; + } + } + + cellId = 0u; + return false; + } + + // Retail cell_in_range: landcell, envcell, or the block sentinel. + private static bool CellLowInRange(uint low) + => low is (>= 1u and <= 0x40u) or (>= 0x100u and <= 0xFFFDu) or 0xFFFFu; +} diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index b0ddadd4..03a05957 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -416,7 +416,7 @@ public sealed class ShadowObjectRegistry /// outdoor cell ids. already adds /// outdoor cells to the candidate set when the sphere straddles an /// indoor cell's exit portal (OtherCellId=0xFFFF) via - /// . + /// . /// Pre-slice-1, the explicit /// "skip outdoor ids" filter combined with #98's indoor-primary gate /// meant doors registered at outdoor cells (default cellScope=0 diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 3f97bd51..fb6269fd 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -27,6 +27,25 @@ public sealed class CellGraph public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) => _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin); + /// + /// World origin (SW corner) of the landblock containing , + /// as registered by . Issue #106: converts the + /// floating-world-frame sphere coords into retail's block-local frame for the + /// lcoord math. False (origin zero) when the landblock + /// has no registered terrain — callers fall back to the legacy anchor-block + /// assumption (world frame == block-local frame). + /// + public bool TryGetTerrainOrigin(uint id, out Vector3 origin) + { + if (_terrain.TryGetValue(id & 0xFFFF0000u, out var t)) + { + origin = t.Origin; + return true; + } + origin = Vector3.Zero; + return false; + } + public void RemoveLandblock(uint landblockPrefix) { uint lb = landblockPrefix & 0xFFFF0000u; diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs index b310d7d6..2de4d05a 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs @@ -11,16 +11,18 @@ public class CellTransitAddAllOutsideCellsTests public void SphereWellInsideCell_AddsOneCell() { // A6.P4 (2026-05-24): coords are LANDBLOCK-LOCAL (X/Y in [0, 192]). - // Player at landblock-local (12, 12, 0) → cell (0,0) in landblock 0xA9B40000. - // Pre-fix this test passed world coords (32460, 34572) and the function - // subtracted lbXf=32448 to get local 12. Post-fix the function expects - // landblock-local directly. + // #106 (2026-06-09): the convention is now explicit — coords are + // world-frame and currentBlockOrigin converts them to block-local; + // Zero origin == "current block is the anchor", the same inputs as + // before. Player at landblock-local (12, 12, 0) → cell (0,0) in + // landblock 0xA9B40000. var candidates = new HashSet(); CellTransit.AddAllOutsideCells( worldSphereCenter: new Vector3(12f, 12f, 0f), sphereRadius: 0.5f, currentCellId: 0xA9B40001u, - candidates); + currentBlockOrigin: Vector3.Zero, + candidates: candidates); Assert.Single(candidates); Assert.Contains(0xA9B40001u, candidates); @@ -29,19 +31,79 @@ public class CellTransitAddAllOutsideCellsTests [Fact] public void SphereAtCellEastBoundary_AddsTwoCells() { - // A6.P4 (2026-05-24): landblock-local coords. Player at (23.6, 12, 0) - // — near +X edge of cell (0,0). Sphere reaches to local X = 23.6 + 0.5 - // = 24.1 → cell (1,0) added. + // Player at (23.6, 12, 0) — near +X edge of cell (0,0). Sphere + // reaches to local X = 23.6 + 0.5 = 24.1 → cell (1,0) added. var candidates = new HashSet(); CellTransit.AddAllOutsideCells( worldSphereCenter: new Vector3(23.6f, 12f, 0f), sphereRadius: 0.5f, currentCellId: 0xA9B40001u, - candidates); + currentBlockOrigin: Vector3.Zero, + candidates: candidates); Assert.Equal(2, candidates.Count); Assert.Contains(0xA9B40001u, candidates); // Cell (1,0): low-16 id = 1 * 8 + 0 + 1 = 9 → 0x0009. Assert.Contains(0xA9B40009u, candidates); } + + // ── #106: landblock crossings (retail add_all_outside_cells has no + // same-block clamp — LandDefs global lcoords) ───────────────────── + + [Fact] + public void SphereJustSouthOfBlockBoundary_AddsBothBlocks() + { + // 0.2 m south of A9B4's southern edge: adjust_to_outside re-seats to + // A9B3 row 7 (cell 0xA9B30038 under x=150), and the boundary check + // (point.Y = 23.8 > maxRad 23.5) adds the A9B4 cell back as the +Y + // neighbour. Pre-#106 this returned ZERO candidates (the freeze). + var candidates = new HashSet(); + CellTransit.AddAllOutsideCells( + worldSphereCenter: new Vector3(150f, -0.2f, 0f), + sphereRadius: 0.5f, + currentCellId: 0xA9B40031u, + currentBlockOrigin: Vector3.Zero, + candidates: candidates); + + Assert.Contains(0xA9B30038u, candidates); // containing (south block) + Assert.Contains(0xA9B40031u, candidates); // +Y neighbour (home block) + Assert.Equal(2, candidates.Count); + } + + [Fact] + public void SphereDeepInNeighbourBlock_AddsNeighbourCellOnly() + { + // The #106 capture geometry: ~109.65 m south of A9B4's origin = + // ~82 m into A9B3 → cell row 3 (floor(-109.65/24) = -5 → ly 1435). + var candidates = new HashSet(); + CellTransit.AddAllOutsideCells( + worldSphereCenter: new Vector3(150f, -109.65f, 0f), + sphereRadius: 0.5f, + currentCellId: 0xA9B40031u, + currentBlockOrigin: Vector3.Zero, + candidates: candidates); + + Assert.Contains(0xA9B30034u, candidates); + Assert.DoesNotContain(0xA9B40031u, candidates); + } + + [Fact] + public void NonAnchorBlockOrigin_ConvertsWorldFrame() + { + // Player's current cell is in A9B3 (one block south of the anchor, + // world origin (0, -192)). World y = -0.2 is block-local y = 191.8 — + // still inside A9B3 row 7, with the boundary check adding the A9B4 + // cell to the north. Exercises the origin conversion that the + // pre-#106 code silently skipped (world frame assumed block-local). + var candidates = new HashSet(); + CellTransit.AddAllOutsideCells( + worldSphereCenter: new Vector3(150f, -0.2f, 0f), + sphereRadius: 0.5f, + currentCellId: 0xA9B30038u, + currentBlockOrigin: new Vector3(0f, -192f, 0f), + candidates: candidates); + + Assert.Contains(0xA9B30038u, candidates); + Assert.Contains(0xA9B40031u, candidates); + } } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index 130a3c15..dd55654f 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -159,6 +159,75 @@ public class CellTransitFindCellSetTests Assert.Contains(0xA9B40001u, cellSet); } + // ────────────────────────────────────────────────────────────────── + // #106 — outdoor membership across landblock boundaries. + // Retail's add_all_outside_cells + the find_cell_list pick run in the + // GLOBAL landcell grid (LandDefs lcoords); crossing a landblock boundary + // is inherent. The pre-#106 port clamped both to the current block's 8×8 + // grid → zero candidates one step over the line → membership frozen + // (the 10,449-frame playerCell freeze in flap-105-capture.log). + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void OutdoorSeed_CrossesLandblockBoundary_South() + { + // The #106 acceptance golden: walking south out of A9B4, the outdoor + // cell must advance to the southern neighbour block's cell. Anchor + // frame (no registered terrain → origin Zero): world y = -0.2 is + // 0.2 m into A9B3's row 7 under x=150 → cell 0xA9B30038. + var cache = new PhysicsDataCache(); + + uint containing = CellTransit.FindCellSet( + cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f, + currentCellId: 0xA9B40031u, + out var cellSet); + + Assert.Equal(0xA9B30038u, containing); + Assert.Contains(0xA9B30038u, cellSet); + Assert.Contains(0xA9B40031u, cellSet); // +Y neighbour still in the set + } + + [Fact] + public void OutdoorSeed_NearBoundaryButInside_StaysCurrent() + { + // 0.2 m NORTH of the boundary: the candidate set includes the A9B3 + // neighbour (sphere overlaps it) but the centre column is still the + // current cell — membership must NOT flip early (single clean flip + // at the line, matching the capture's 96/96 within-block behaviour). + var cache = new PhysicsDataCache(); + + uint containing = CellTransit.FindCellSet( + cache, new Vector3(150f, 0.2f, 0f), sphereRadius: 0.5f, + currentCellId: 0xA9B40031u, + out var cellSet); + + Assert.Equal(0xA9B40031u, containing); + Assert.Contains(0xA9B30038u, cellSet); + } + + [Fact] + public void OutdoorSeed_NonAnchorBlock_UsesRegisteredTerrainOrigin() + { + // Northbound return: the player's current cell is in A9B3 (origin + // (0, -192) registered via CellGraph terrain — the production path). + // World y = +1 is 1 m back into the anchor block A9B4; the pick must + // convert through A9B3's origin (block-local y = 193) and advance to + // 0xA9B40031. Pre-#106 the world frame was silently assumed + // block-local, which is wrong for every non-anchor block. + var cache = new PhysicsDataCache(); + cache.CellGraph.RegisterTerrain( + 0xA9B30000u, + new TerrainSurface(new byte[81], new float[256]), + new Vector3(0f, -192f, 0f)); + + uint containing = CellTransit.FindCellSet( + cache, new Vector3(150f, 1f, 0f), sphereRadius: 0.5f, + currentCellId: 0xA9B30038u, + out _); + + Assert.Equal(0xA9B40031u, containing); + } + // ────────────────────────────────────────────────────────────────── // Membership hysteresis — the R1-flap root cause. // Retail CObjCell::find_cell_list adds the CURRENT cell at index 0 diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs index 0c933b50..aca8b22e 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs @@ -931,8 +931,11 @@ public class DoorBugTrajectoryReplayTests const uint currentCellId = 0xA9B40150u; var candidates = new HashSet(); + // #106 (2026-06-09): origin parameter added — Vector3.Zero is the + // anchor-block frame this capture was taken in (same semantics as + // the pre-#106 4-arg form). CellTransit.AddAllOutsideCells( - sphereWorld, sphereRadius, currentCellId, candidates); + sphereWorld, sphereRadius, currentCellId, Vector3.Zero, candidates); const uint expectedDoorCell = 0xA9B40029u; Assert.True(candidates.Contains(expectedDoorCell), diff --git a/tests/AcDream.Core.Tests/Physics/LandDefsTests.cs b/tests/AcDream.Core.Tests/Physics/LandDefsTests.cs new file mode 100644 index 00000000..2093d8f2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/LandDefsTests.cs @@ -0,0 +1,200 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Conformance tests for the retail LandDefs global-lcoord math (issue #106). +/// Goldens derive from the decomp formulas (pseudocode doc: +/// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md) using the +/// Holtburg geometry from the #106 capture: landblock 0xA9B4 with its southern +/// neighbour 0xA9B3 (block_x 0xA9 = 169, block_y 0xB4 = 180 / 0xB3 = 179). +/// +public class LandDefsTests +{ + // ── blockid_to_lcoord (pc:68520) ──────────────────────────────────── + + [Fact] + public void BlockIdToLcoord_A9B4_Is_1352_1440() + { + Assert.True(LandDefs.BlockIdToLcoord(0xA9B40031u, out int lx, out int ly)); + Assert.Equal(169 * 8, lx); + Assert.Equal(180 * 8, ly); + } + + [Fact] + public void BlockIdToLcoord_HighBlockY_NoSignExtension() + { + // BN decomp renders block_y extraction with an int8_t cast that would + // sign-extend 0xB4 → negative. ACE confirms zero-extension; this test + // pins it for every block byte ≥ 0x80. + Assert.True(LandDefs.BlockIdToLcoord(0xFEFE0001u, out int lx, out int ly)); + Assert.Equal(0xFE * 8, lx); + Assert.Equal(0xFE * 8, ly); + } + + [Fact] + public void BlockIdToLcoord_ZeroCellId_Fails() + { + Assert.False(LandDefs.BlockIdToLcoord(0u, out _, out _)); + } + + // ── gid_to_lcoord (pc:163500) ─────────────────────────────────────── + + [Fact] + public void GidToLcoord_A9B40031_Is_1358_1440() + { + // low 0x31 = 49 → cell_x = (49-1)>>3 = 6, cell_y = (49-1)&7 = 0. + Assert.True(LandDefs.GidToLcoord(0xA9B40031u, out int lx, out int ly)); + Assert.Equal(1358, lx); + Assert.Equal(1440, ly); + } + + [Fact] + public void GidToLcoord_IndoorCell_Fails() + { + // Outdoor-only (decomp gates on low < 0x100). + Assert.False(LandDefs.GidToLcoord(0xA9B40164u, out _, out _)); + } + + [Fact] + public void GidToLcoord_InvalidLowRange_Fails() + { + Assert.False(LandDefs.GidToLcoord(0xA9B40000u, out _, out _)); // low 0 + Assert.False(LandDefs.GidToLcoord(0xA9B40041u, out _, out _)); // low 0x41 (> 0x40, < 0x100) + } + + // ── lcoord_to_gid (pc:171859) ─────────────────────────────────────── + + [Fact] + public void LcoordToGid_RoundTrips_A9B40031() + { + Assert.Equal(0xA9B40031u, LandDefs.LcoordToGid(1358, 1440)); + } + + [Fact] + public void LcoordToGid_CrossesBlockSouth() + { + // One lcoord row south of A9B4's southern edge → block_y 179 (0xB3), + // cell row 7: low = (1439&7) + (1358&7)*8 + 1 = 7 + 48 + 1 = 0x38. + Assert.Equal(0xA9B30038u, LandDefs.LcoordToGid(1358, 1439)); + } + + [Fact] + public void LcoordToGid_OutOfMapBounds_ReturnsZero() + { + Assert.Equal(0u, LandDefs.LcoordToGid(-1, 5)); + Assert.Equal(0u, LandDefs.LcoordToGid(5, -1)); + Assert.Equal(0u, LandDefs.LcoordToGid(0x7F8, 5)); + Assert.Equal(0u, LandDefs.LcoordToGid(5, 0x7F8)); + } + + // ── get_outside_lcoord (pc:438690) ────────────────────────────────── + + [Fact] + public void GetOutsideLcoord_NegativeLocalY_CrossesSouth() + { + // floor(-1/24) = -1 (floor, not truncation — negative-safe). + Assert.True(LandDefs.GetOutsideLcoord( + 0xA9B40031u, new Vector3(150f, -1f, 0f), out int lx, out int ly)); + Assert.Equal(1358, lx); + Assert.Equal(1439, ly); + } + + [Fact] + public void GetOutsideLcoord_FromIndoorCellId_UsesBlockBits() + { + // adjust_to_outside accepts indoor low16 (0x100..0xFFFD) — the block + // bits drive the lcoord; the position picks the landcell. + Assert.True(LandDefs.GetOutsideLcoord( + 0xA9B40164u, new Vector3(12f, 12f, 0f), out int lx, out int ly)); + Assert.Equal(1352, lx); + Assert.Equal(1440, ly); + } + + // ── adjust_to_outside (pc:438719) ─────────────────────────────────── + + [Fact] + public void AdjustToOutside_SouthCrossing_RewritesCellAndRebasesPos() + { + uint cellId = 0xA9B40031u; + var pos = new Vector3(150f, -1f, 5f); + Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos)); + Assert.Equal(0xA9B30038u, cellId); + Assert.Equal(150f, pos.X, 4); + Assert.Equal(191f, pos.Y, 4); // -1 − floor(-1/192)·192 = 191 (new block's frame) + Assert.Equal(5f, pos.Z, 4); // Z untouched + } + + [Fact] + public void AdjustToOutside_DeepSouth_CaptureGolden() + { + // The #106 capture geometry: player ~109.65 m south of A9B4's origin + // (≈82 m into A9B3). floor(-109.65/24) = -5 → ly 1435 → row 3 of 0xB3. + uint cellId = 0xA9B40031u; + var pos = new Vector3(150f, -109.65f, 0f); + Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos)); + Assert.Equal(0xA9B30034u, cellId); + Assert.Equal(82.35f, pos.Y, 3); + } + + [Fact] + public void AdjustToOutside_NorthboundReturn() + { + // From A9B3's frame, local y = 193 is 1 m into A9B4. + uint cellId = 0xA9B30038u; + var pos = new Vector3(150f, 193f, 0f); + Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos)); + Assert.Equal(0xA9B40031u, cellId); + Assert.Equal(1f, pos.Y, 4); + } + + [Fact] + public void AdjustToOutside_WithinBlock_KeepsBlockRewritesCell() + { + uint cellId = 0xA9B40031u; // cell (6,0) + var pos = new Vector3(12f, 12f, 0f); // over cell (0,0) + Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos)); + Assert.Equal(0xA9B40001u, cellId); + Assert.Equal(12f, pos.X, 4); + Assert.Equal(12f, pos.Y, 4); + } + + [Fact] + public void AdjustToOutside_EpsilonSnap_TreatsTinyNegativeAsZero() + { + // Retail snaps |coord| < 0.0002 to 0 BEFORE the floor — a hair-negative + // y stays in the current block instead of flapping to the neighbour. + uint cellId = 0xA9B40031u; + var pos = new Vector3(150f, -0.0001f, 0f); + Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos)); + Assert.Equal(0xA9B40031u, cellId); + Assert.Equal(0f, pos.Y, 6); + } + + [Fact] + public void AdjustToOutside_InvalidLow_FailsAndZeroesCell() + { + uint cellId = 0xA9B40050u; // low 0x50: not landcell, not envcell + var pos = new Vector3(12f, 12f, 0f); + Assert.False(LandDefs.AdjustToOutside(ref cellId, ref pos)); + Assert.Equal(0u, cellId); // retail writes 0 on failure + } + + // ── inbound_valid_cellid (pc:163438) ──────────────────────────────── + + [Theory] + [InlineData(0xA9B40001u, true)] + [InlineData(0xA9B40040u, true)] + [InlineData(0xA9B40100u, true)] + [InlineData(0xA9B4FFFDu, true)] + [InlineData(0xA9B4FFFFu, true)] // block sentinel + [InlineData(0xA9B40000u, false)] + [InlineData(0xA9B40041u, false)] + [InlineData(0xA9B4FFFEu, false)] + public void InboundValidCellId_LowRanges(uint cellId, bool expected) + { + Assert.Equal(expected, LandDefs.InboundValidCellId(cellId)); + } +}