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); + } }