diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index 8a40f2f..8a4518a 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -244,15 +244,14 @@ public sealed class ShadowObjectRegistry
/// Within each landblock, queries only the cells the query sphere overlaps.
///
///
- /// Issue #91 (2026-05-20): the optional
- /// parameter is the candidate set of indoor cells the foot-sphere overlaps
- /// (from ). When supplied, indoor
- /// shadows registered via 's cellScope
- /// 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
+ /// parameter is the candidate set returned by .
+ /// When supplied, indoor shadows registered via 's
+ /// cellScope 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.
///
///
///
@@ -262,7 +261,8 @@ public sealed class ShadowObjectRegistry
/// acclient_2013_pseudo_c.txt:308751-308769: 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 CEnvCell::find_collisions only iterating
+ /// cells (except via portal traversal — see #99 below). Combined with
+ /// CEnvCell::find_collisions only iterating
/// this->shadow_object_list, retail's indoor cells never test
/// against outdoor statics like landblock-baked buildings.
///
@@ -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).
///
+ ///
+ ///
+ /// A6.P4 slice 1 / issue #99 (2026-05-24): the
+ /// loop no longer filters out
+ /// 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
+ /// 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).
+ ///
///
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
float worldOffsetX, float worldOffsetY, uint landblockId,
List results,
- System.Collections.Generic.IReadOnlyCollection? indoorCellIds = null,
+ System.Collections.Generic.IReadOnlyCollection? portalReachableCells = null,
uint primaryCellId = 0u)
{
results.Clear();
var seen = new HashSet();
- // 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))
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index ddfad64..9749fdf 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2160,22 +2160,24 @@ public sealed class Transition
var nearbyObjs = new List();
float queryRadius = sphereRadius + movement.Length() + 5f;
- // Issue #91 (2026-05-20): interior items (fireplaces, tables, chests)
- // are registered with `cellScope = ParentCellId` per A1.5's fix at
- // `4d3bf6f`. They're stored under the indoor cell key (e.g.
- // `0xA9B40121`), but GetNearbyObjects's outdoor-grid lookup (cells
- // like `0xA9B40029`) never queries that key. Compute the indoor
- // candidate set via CellTransit.FindCellSet — same set A4 uses for
- // multi-cell BSP iteration — and pass it through so indoor shadows
- // are also picked up. When the seed is outdoor the set typically
- // contains only outdoor land-cells which the new branch in
- // GetNearbyObjects skips via the `< 0x0100u` filter, so behavior
- // matches the prior outdoor-only path.
+ // Issue #91 (2026-05-20) + A6.P4 slice 1 issue #99 (2026-05-24):
+ // ask CellTransit for the portal-reachable cell set. Two payoffs:
+ // 1. A1.5 cellScope-registered indoor statics (fireplaces, tables,
+ // chests under e.g. 0xA9B40121) are reachable from indoor
+ // primary cells (the outdoor 24-m grid would never find them).
+ // 2. Doors registered at outdoor cells (default cellScope=0u for
+ // server-spawned entities at GameWindow.cs:3139) sit at the
+ // doorway threshold. When the sphere straddles an exit portal
+ // (OtherCellId=0xFFFF) the cellSet picks up outdoor cells via
+ // AddAllOutsideCells, so an indoor-side sphere can still see
+ // the door without re-enabling the #98 outdoor radial sweep.
+ // For outdoor seeds the set is just the local outdoor cells; the
+ // radial sweep below dedupes via the `seen` set in GetNearbyObjects.
// (engine.DataCache is non-null per the early-return at top of
// FindObjCollisions; redundant inner check would confuse nullable
// flow analysis.)
_ = CellTransit.FindCellSet(engine.DataCache, currPos, sphereRadius,
- sp.CheckCellId, out var indoorCellIds);
+ sp.CheckCellId, out var portalReachableCells);
// Issue #98 (2026-05-24): pass primary cellId so the radial outdoor
// sweep is skipped when sphere is in an indoor cell. Mirrors retail's
@@ -2187,7 +2189,7 @@ public sealed class Transition
currPos, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
nearbyObjs,
- indoorCellIds,
+ portalReachableCells,
primaryCellId: sp.CheckCellId);
foreach (var obj in nearbyObjs)
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
index f3d7b08..d366b36 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
@@ -301,4 +301,73 @@ public class ShadowObjectRegistryTests
Assert.Equal(0x00000004u, inA.State);
Assert.Equal(0x00000004u, inB.State);
}
+
+ // -----------------------------------------------------------------------
+ // A6.P4 slice 1 — portalReachableCells set includes outdoor cells
+ // when sphere straddles an exit portal (issue #99)
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void GetNearbyObjects_PortalReachableSetIncludesOutdoorCell_IndoorPrimary_DoorReturned()
+ {
+ // A6.P4 slice 1 / issue #99 (2026-05-24): when the player's sphere is
+ // in an indoor cell but its CellTransit.FindCellSet result includes
+ // an outdoor cell (via AddAllOutsideCells triggered by the sphere
+ // straddling an OtherCellId=0xFFFF exit portal), GetNearbyObjects
+ // must return shadows registered in that outdoor cell. Doors are
+ // server-spawned entities registered at GameWindow.cs:3139 with
+ // default cellScope=0u → outdoor 24-m grid registration. Before this
+ // fix, the loop filtered out outdoor cell ids ("skip outdoor ids")
+ // AND #98's primaryCellId gate skipped the radial sweep — net result
+ // was that doors at building thresholds were invisible to spheres
+ // on the indoor side: walk-through.
+ var reg = new ShadowObjectRegistry();
+
+ const uint doorEntityId = 0x000F4244u;
+ reg.Register(doorEntityId, 0x020019FFu, new Vector3(12f, 12f, 50f),
+ Quaternion.Identity, 1f, OffX, OffY, LbId,
+ ShadowCollisionType.Cylinder, cylHeight: 2.5f);
+
+ var results = new List();
+ uint doorOutdoorCellId = LbId | 1u; // outdoor cell where door sits
+ uint vestibuleCellId = LbId | 0x0145u; // indoor cell on the other side
+
+ // Sphere is in the vestibule; FindCellSet added the doorway's outdoor
+ // cell via AddAllOutsideCells. Both ids land in portalReachableCells.
+ reg.GetNearbyObjects(
+ new Vector3(12f, 12f, 50f), 5f, OffX, OffY, LbId, results,
+ portalReachableCells: new[] { vestibuleCellId, doorOutdoorCellId },
+ primaryCellId: vestibuleCellId);
+
+ Assert.Contains(results, e => e.EntityId == doorEntityId);
+ }
+
+ [Fact]
+ public void GetNearbyObjects_IndoorPrimary_IndoorOnlyPortalSet_OutdoorRadialStillSkipped()
+ {
+ // Regression check for issue #98: when the FindCellSet result holds
+ // only indoor cells (sphere not near an exit portal — e.g. deep in
+ // a cellar), the radial outdoor sweep must stay skipped so the
+ // landblock-baked cottage GfxObj (registered at outdoor cellScope=0)
+ // is NOT returned to a sphere in the cellar EnvCell. Otherwise the
+ // head sphere bumps the cottage's downward-facing floor poly from
+ // below and caps the cellar ascent at world Z≈92.7.
+ var reg = new ShadowObjectRegistry();
+
+ const uint cottageEntityId = 0xA9B47900u;
+ reg.Register(cottageEntityId, 0x01000A2Bu, new Vector3(12f, 12f, 90f),
+ Quaternion.Identity, 5.5f, OffX, OffY, LbId);
+
+ var results = new List();
+ uint cellarCellId = LbId | 0x0146u;
+
+ // Sphere is in the cellar; FindCellSet returned indoor-only set
+ // (no exit portal nearby).
+ reg.GetNearbyObjects(
+ new Vector3(12f, 12f, 50f), 5.5f, OffX, OffY, LbId, results,
+ portalReachableCells: new[] { cellarCellId },
+ primaryCellId: cellarCellId);
+
+ Assert.DoesNotContain(results, e => e.EntityId == cottageEntityId);
+ }
}