feat(phys): A6.P4 slice 1 — portal-reachable cellSet includes outdoor cells
Closes #99 (run-through doors regression fromb3ce505). Theb3ce505stopgap 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 theb3ce505gate 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:
parent
3e3cd77202
commit
b49ed904c3
3 changed files with 122 additions and 30 deletions
|
|
@ -244,15 +244,14 @@ public sealed class ShadowObjectRegistry
|
||||||
/// Within each landblock, queries only the cells the query sphere overlaps.
|
/// Within each landblock, queries only the cells the query sphere overlaps.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Issue #91 (2026-05-20): the optional <paramref name="indoorCellIds"/>
|
/// Issue #91 (2026-05-20): the optional <paramref name="portalReachableCells"/>
|
||||||
/// parameter is the candidate set of indoor cells the foot-sphere overlaps
|
/// parameter is the candidate set returned by <see cref="CellTransit.FindCellSet"/>.
|
||||||
/// (from <see cref="CellTransit.FindCellSet"/>). When supplied, indoor
|
/// When supplied, indoor shadows registered via <see cref="Register"/>'s
|
||||||
/// shadows registered via <see cref="Register"/>'s <c>cellScope</c>
|
/// <c>cellScope</c> parameter (A1.5 fix at `4d3bf6f`) are ALSO included in
|
||||||
/// parameter (A1.5 fix at `4d3bf6f`) are ALSO included in the result.
|
/// the result. Without this, interior statics (fireplaces, tables, chests)
|
||||||
/// Without this, interior statics (fireplaces, tables, chests) registered
|
/// registered against e.g. `0xA9B40121` are stored under that key but the
|
||||||
/// against e.g. `0xA9B40121` are stored under that key but the outdoor-
|
/// outdoor-grid lookup (cell ids like `0xA9B40029`) never queries the
|
||||||
/// grid lookup (cell ids like `0xA9B40029`) never queries the indoor key.
|
/// indoor key. Net effect pre-fix: interior items don't block movement.
|
||||||
/// Net effect pre-fix: interior items don't block movement.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -262,7 +261,8 @@ public sealed class ShadowObjectRegistry
|
||||||
/// <c>acclient_2013_pseudo_c.txt:308751-308769</c>: when the sphere's
|
/// <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
|
/// position is in an indoor cell (id ≥ 0x0100), retail only adds THAT
|
||||||
/// cell + portal-visible neighbors to the cell array — never outdoor
|
/// 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
|
/// <c>this->shadow_object_list</c>, retail's indoor cells never test
|
||||||
/// against outdoor statics like landblock-baked buildings.
|
/// against outdoor statics like landblock-baked buildings.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|
@ -279,24 +279,45 @@ public sealed class ShadowObjectRegistry
|
||||||
/// the pre-fix radial-only behavior for callers that don't know /
|
/// the pre-fix radial-only behavior for callers that don't know /
|
||||||
/// care about cell type (existing tests).
|
/// care about cell type (existing tests).
|
||||||
/// </para>
|
/// </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>
|
/// </summary>
|
||||||
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
|
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
|
||||||
float worldOffsetX, float worldOffsetY, uint landblockId,
|
float worldOffsetX, float worldOffsetY, uint landblockId,
|
||||||
List<ShadowEntry> results,
|
List<ShadowEntry> results,
|
||||||
System.Collections.Generic.IReadOnlyCollection<uint>? indoorCellIds = null,
|
System.Collections.Generic.IReadOnlyCollection<uint>? portalReachableCells = null,
|
||||||
uint primaryCellId = 0u)
|
uint primaryCellId = 0u)
|
||||||
{
|
{
|
||||||
results.Clear();
|
results.Clear();
|
||||||
var seen = new HashSet<uint>();
|
var seen = new HashSet<uint>();
|
||||||
|
|
||||||
// Indoor-scoped shadows (A1.5 cellScope). Query first so the
|
// Cells reachable from the sphere's primary cell via the portal graph
|
||||||
// outdoor-grid lookup below skips duplicates via `seen`.
|
// (output of CellTransit.FindCellSet). This set holds the primary
|
||||||
if (indoorCellIds is not null)
|
// 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(cellId, out var list)) continue;
|
||||||
if (!_cells.TryGetValue(indoorCellId, out var list)) continue;
|
|
||||||
foreach (var entry in list)
|
foreach (var entry in list)
|
||||||
{
|
{
|
||||||
if (seen.Add(entry.EntityId))
|
if (seen.Add(entry.EntityId))
|
||||||
|
|
|
||||||
|
|
@ -2160,22 +2160,24 @@ public sealed class Transition
|
||||||
var nearbyObjs = new List<ShadowEntry>();
|
var nearbyObjs = new List<ShadowEntry>();
|
||||||
float queryRadius = sphereRadius + movement.Length() + 5f;
|
float queryRadius = sphereRadius + movement.Length() + 5f;
|
||||||
|
|
||||||
// Issue #91 (2026-05-20): interior items (fireplaces, tables, chests)
|
// Issue #91 (2026-05-20) + A6.P4 slice 1 issue #99 (2026-05-24):
|
||||||
// are registered with `cellScope = ParentCellId` per A1.5's fix at
|
// ask CellTransit for the portal-reachable cell set. Two payoffs:
|
||||||
// `4d3bf6f`. They're stored under the indoor cell key (e.g.
|
// 1. A1.5 cellScope-registered indoor statics (fireplaces, tables,
|
||||||
// `0xA9B40121`), but GetNearbyObjects's outdoor-grid lookup (cells
|
// chests under e.g. 0xA9B40121) are reachable from indoor
|
||||||
// like `0xA9B40029`) never queries that key. Compute the indoor
|
// primary cells (the outdoor 24-m grid would never find them).
|
||||||
// candidate set via CellTransit.FindCellSet — same set A4 uses for
|
// 2. Doors registered at outdoor cells (default cellScope=0u for
|
||||||
// multi-cell BSP iteration — and pass it through so indoor shadows
|
// server-spawned entities at GameWindow.cs:3139) sit at the
|
||||||
// are also picked up. When the seed is outdoor the set typically
|
// doorway threshold. When the sphere straddles an exit portal
|
||||||
// contains only outdoor land-cells which the new branch in
|
// (OtherCellId=0xFFFF) the cellSet picks up outdoor cells via
|
||||||
// GetNearbyObjects skips via the `< 0x0100u` filter, so behavior
|
// AddAllOutsideCells, so an indoor-side sphere can still see
|
||||||
// matches the prior outdoor-only path.
|
// 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
|
// (engine.DataCache is non-null per the early-return at top of
|
||||||
// FindObjCollisions; redundant inner check would confuse nullable
|
// FindObjCollisions; redundant inner check would confuse nullable
|
||||||
// flow analysis.)
|
// flow analysis.)
|
||||||
_ = CellTransit.FindCellSet(engine.DataCache, currPos, sphereRadius,
|
_ = 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
|
// 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
|
// sweep is skipped when sphere is in an indoor cell. Mirrors retail's
|
||||||
|
|
@ -2187,7 +2189,7 @@ public sealed class Transition
|
||||||
currPos, queryRadius,
|
currPos, queryRadius,
|
||||||
worldOffsetX, worldOffsetY, landblockId,
|
worldOffsetX, worldOffsetY, landblockId,
|
||||||
nearbyObjs,
|
nearbyObjs,
|
||||||
indoorCellIds,
|
portalReachableCells,
|
||||||
primaryCellId: sp.CheckCellId);
|
primaryCellId: sp.CheckCellId);
|
||||||
|
|
||||||
foreach (var obj in nearbyObjs)
|
foreach (var obj in nearbyObjs)
|
||||||
|
|
|
||||||
|
|
@ -301,4 +301,73 @@ public class ShadowObjectRegistryTests
|
||||||
Assert.Equal(0x00000004u, inA.State);
|
Assert.Equal(0x00000004u, inA.State);
|
||||||
Assert.Equal(0x00000004u, inB.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<ShadowEntry>();
|
||||||
|
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<ShadowEntry>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue