feat(phys): A6.P4 slice 1 — portal-reachable cellSet includes outdoor cells

Closes #99 (run-through doors regression from b3ce505).

The b3ce505 stopgap for #98 gates the outdoor 24m radial sweep on indoor
primary cells. Combined with ShadowObjectRegistry.GetNearbyObjects'
"skip outdoor ids" filter on the cellScope-pass loop, this meant doors
registered at outdoor cells (default cellScope=0u for server-spawned
entities at GameWindow.cs:3139) were invisible to spheres on the indoor
side of a doorway threshold — walk-through.

Pre-flight reads found that CellTransit.FindCellSet already adds
outdoor cells to its candidate set when the sphere straddles an
OtherCellId=0xFFFF exit portal (via AddAllOutsideCells triggered by
exitOutside=true inside the indoor-seed BFS). The fix is to stop
filtering those outdoor ids out before iterating, and rename the param
to portalReachableCells to reflect what the set actually contains.

- Q1: Indoor EnvCell.VisibleCellIds is indoor-only in all 16 cottage
  fixtures (low 16 bits ≥ 0x0100). OtherCellId=0xFFFF on portals
  marks "exit to outdoor world" without naming a specific cellId; the
  specific outdoor cell is computed by AddAllOutsideCells from world
  XY when the sphere straddles the exit portal.
- Q2: GameWindow.cs:3139 ShadowObjects.Register for server-spawned
  entities passes no cellScope → default 0u → outdoor 24m grid
  registration. UpdatePosition (line 145) does the same on movement.
  Doors are confirmed outdoor-registered.

Slice 1 makes a smaller change than the spec proposed (no new
parameter; just drop the existing filter), because FindCellSet's
existing exit-portal logic already exposes the needed outdoor cells.
The retail-faithful registration-side BuildShadowCellSet refactor and
the b3ce505 gate removal stay scheduled for slices 2-3.

Verification:
- 24/24 ShadowObjectRegistryTests pass (incl. two new slice 1 tests:
  IndoorPrimary_OutdoorCellInPortalSet_DoorReturned closes #99;
  IndoorPrimary_IndoorOnlyPortalSet_OutdoorRadialStillSkipped
  regression-pins #98)
- 11/11 CellarUpTrajectoryReplayTests pass (LiveCompare_FirstCap_
  FixClosesCottageFloorCap stays green)
- dotnet build AcDream.slnx: 0 errors, 0 warnings
- Pre-existing 6-8 static-state-leakage failures in serial physics
  suite verified unchanged by stash+retest baseline check

Visual verification pending: walk Holtburg cottage doorway from both
sides; door blocks both directions; cellar still climbable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-24 08:10:32 +02:00
parent 3e3cd77202
commit b49ed904c3
3 changed files with 122 additions and 30 deletions

View file

@ -244,15 +244,14 @@ public sealed class ShadowObjectRegistry
/// Within each landblock, queries only the cells the query sphere overlaps.
///
/// <para>
/// Issue #91 (2026-05-20): the optional <paramref name="indoorCellIds"/>
/// parameter is the candidate set of indoor cells the foot-sphere overlaps
/// (from <see cref="CellTransit.FindCellSet"/>). When supplied, indoor
/// shadows registered via <see cref="Register"/>'s <c>cellScope</c>
/// parameter (A1.5 fix at `4d3bf6f`) are ALSO included in the result.
/// Without this, interior statics (fireplaces, tables, chests) registered
/// against e.g. `0xA9B40121` are stored under that key but the outdoor-
/// grid lookup (cell ids like `0xA9B40029`) never queries the indoor key.
/// Net effect pre-fix: interior items don't block movement.
/// Issue #91 (2026-05-20): the optional <paramref name="portalReachableCells"/>
/// parameter is the candidate set returned by <see cref="CellTransit.FindCellSet"/>.
/// When supplied, indoor shadows registered via <see cref="Register"/>'s
/// <c>cellScope</c> parameter (A1.5 fix at `4d3bf6f`) are ALSO included in
/// the result. Without this, interior statics (fireplaces, tables, chests)
/// registered against e.g. `0xA9B40121` are stored under that key but the
/// outdoor-grid lookup (cell ids like `0xA9B40029`) never queries the
/// indoor key. Net effect pre-fix: interior items don't block movement.
/// </para>
///
/// <para>
@ -262,7 +261,8 @@ public sealed class ShadowObjectRegistry
/// <c>acclient_2013_pseudo_c.txt:308751-308769</c>: when the sphere's
/// position is in an indoor cell (id ≥ 0x0100), retail only adds THAT
/// cell + portal-visible neighbors to the cell array — never outdoor
/// cells. Combined with <c>CEnvCell::find_collisions</c> only iterating
/// cells (except via portal traversal — see #99 below). Combined with
/// <c>CEnvCell::find_collisions</c> only iterating
/// <c>this->shadow_object_list</c>, retail's indoor cells never test
/// against outdoor statics like landblock-baked buildings.
/// </para>
@ -279,24 +279,45 @@ public sealed class ShadowObjectRegistry
/// the pre-fix radial-only behavior for callers that don't know /
/// care about cell type (existing tests).
/// </para>
///
/// <para>
/// A6.P4 slice 1 / issue #99 (2026-05-24): the
/// <paramref name="portalReachableCells"/> loop no longer filters out
/// outdoor cell ids. <see cref="CellTransit.FindCellSet"/> already adds
/// outdoor cells to the candidate set when the sphere straddles an
/// indoor cell's exit portal (<c>OtherCellId=0xFFFF</c>) via
/// <see cref="CellTransit.AddAllOutsideCells(System.Numerics.Vector3, float, uint, System.Collections.Generic.HashSet{uint})"/>.
/// Pre-slice-1, the explicit
/// "skip outdoor ids" filter combined with #98's indoor-primary gate
/// meant doors registered at outdoor cells (default <c>cellScope=0</c>
/// for server-spawned doors at GameWindow.cs:3139) were invisible to
/// spheres on the indoor side of the doorway — walk-through. Iterating
/// those outdoor cells from the portal-reachable set lets the indoor
/// query reach them without re-enabling the full 24-m radial sweep
/// (which is what #98 closed).
/// </para>
/// </summary>
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
float worldOffsetX, float worldOffsetY, uint landblockId,
List<ShadowEntry> results,
System.Collections.Generic.IReadOnlyCollection<uint>? indoorCellIds = null,
System.Collections.Generic.IReadOnlyCollection<uint>? portalReachableCells = null,
uint primaryCellId = 0u)
{
results.Clear();
var seen = new HashSet<uint>();
// Indoor-scoped shadows (A1.5 cellScope). Query first so the
// outdoor-grid lookup below skips duplicates via `seen`.
if (indoorCellIds is not null)
// Cells reachable from the sphere's primary cell via the portal graph
// (output of CellTransit.FindCellSet). This set holds the primary
// cell, any indoor neighbours the sphere overlaps via portals, and —
// when the sphere straddles an exit portal (0xFFFF) — outdoor cells
// added by AddAllOutsideCells. Iterate all of them so cellScope-
// registered indoor statics AND outdoor-scope shadows reachable
// through a doorway are both visible.
if (portalReachableCells is not null)
{
foreach (uint indoorCellId in indoorCellIds)
foreach (uint cellId in portalReachableCells)
{
if ((indoorCellId & 0xFFFFu) < 0x0100u) continue; // skip outdoor ids
if (!_cells.TryGetValue(indoorCellId, out var list)) continue;
if (!_cells.TryGetValue(cellId, out var list)) continue;
foreach (var entry in list)
{
if (seen.Add(entry.EntityId))