acdream/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
Erik b49ed904c3 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>
2026-05-24 08:10:32 +02:00

434 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Cell-based spatial index for object collision. Each entity registers
/// into the outdoor terrain cells (24m × 24m) it overlaps. The Transition
/// system queries this to find nearby objects during collision detection.
///
/// Retail AC uses the same cell-based approach (no k-d tree / octree).
/// Outdoor cells are 24×24m (8 cells per 192m landblock, 64 cells per lb).
/// Cell ID = landblock high 16 bits | (cellX * 8 + cellY + 1) in low 16.
/// </summary>
public sealed class ShadowObjectRegistry
{
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
/// <summary>
/// Register an entity into the cells it overlaps based on world position + radius.
///
/// <para>
/// The optional <paramref name="state"/> + <paramref name="flags"/>
/// parameters carry retail <c>PhysicsState</c> bits and decoded
/// <see cref="EntityCollisionFlags"/> respectively, so the
/// <c>FindObjCollisions</c> retail-faithful exemption block (PvP rule,
/// ETHEREAL skip, viewer-vs-creature) can short-circuit without an
/// extra lookup. Default <c>state=0</c> + <c>flags=None</c> preserves
/// the original "static decoration" behavior — the existing 5
/// landblock-entity registration sites pass nothing.
/// </para>
/// </summary>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
float cylHeight = 0f, float scale = 1.0f,
uint state = 0u,
EntityCollisionFlags flags = EntityCollisionFlags.None,
uint cellScope = 0u)
{
Deregister(entityId);
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius,
collisionType, cylHeight, scale, state, flags);
// ISSUES #83 / Phase A1.5 (2026-05-21): if the caller passed a
// cellScope (typically the entity's ParentCellId for an interior
// EnvCell static), scope the shadow to ONLY that cell instead of
// computing outdoor-landcell occupancy from XY. Without this,
// interior statics (a fireplace inside cell 0xA9B40121) get
// registered into the outdoor landcell whose XY they overlap
// (e.g. 0xA9B40029) and fire collisions when the player is OUTSIDE
// the building — the user-reported "thin air" collision outdoors.
if (cellScope != 0u)
{
if (!_cells.TryGetValue(cellScope, out var scopedList))
{
scopedList = new List<ShadowEntry>();
_cells[cellScope] = scopedList;
}
scopedList.Add(entry);
_entityToCells[entityId] = new List<uint> { cellScope };
return;
}
// The radius parameter should already be the WORLD-SPACE bounding
// radius (i.e., already multiplied by scale) so the broad-phase cell
// occupancy is correct. Callers are responsible for that.
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
int minCx = Math.Max(0, (int)((localX - radius) / 24f));
int maxCx = Math.Min(7, (int)((localX + radius) / 24f));
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
var cellIds = new List<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u;
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
cellIds.Add(cellId);
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List<ShadowEntry>();
_cells[cellId] = list;
}
list.Add(entry);
}
}
_entityToCells[entityId] = cellIds;
}
/// <summary>
/// Update an already-registered entity's world position + rotation,
/// preserving its <see cref="ShadowEntry.State"/>,
/// <see cref="ShadowEntry.Flags"/>, and shape parameters.
///
/// <para>
/// Cheaper than <see cref="Deregister"/> + <see cref="Register"/> for
/// the 510 Hz <c>UpdatePosition (0xF748)</c> stream the server emits
/// per visible entity: this is the path retail's
/// <c>CPhysicsObj::SetPosition</c> takes (cited at
/// <c>acclient_2013_pseudo_c.txt:284276</c>) — same shape, new cell
/// membership. If the entity isn't already registered, this is a
/// no-op so callers don't have to gate.
/// </para>
/// </summary>
public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation,
float worldOffsetX, float worldOffsetY, uint landblockId)
{
// Find the existing entry (any cell holds a copy with the same
// entity-scoped state + flags + shape).
if (!_entityToCells.TryGetValue(entityId, out var oldCells) || oldCells.Count == 0)
return;
ShadowEntry? template = null;
foreach (var oldCellId in oldCells)
{
if (_cells.TryGetValue(oldCellId, out var list))
{
foreach (var e in list)
{
if (e.EntityId == entityId)
{
template = e;
break;
}
}
}
if (template is not null) break;
}
if (template is null)
return;
// Preserve everything except position + rotation.
var t = template.Value;
Register(entityId, t.GfxObjId, worldPos, rotation, t.Radius,
worldOffsetX, worldOffsetY, landblockId,
t.CollisionType, t.CylHeight, t.Scale,
t.State, t.Flags);
}
/// <summary>
/// Update the cached <see cref="ShadowEntry.State"/> bits for an
/// already-registered entity. Called by the inbound
/// <c>SetState (0xF74B)</c> dispatcher when the server broadcasts a
/// post-spawn <c>PhysicsState</c> change — chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use, so the
/// <see cref="CollisionExemption.ShouldSkip"/> short-circuit can honor
/// the new state on the next resolve.
///
/// <para>
/// Retail equivalent: <c>CPhysicsObj::set_state</c> at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:283044</c>
/// — direct write `this->state = arg2`. Retail also fires side-effect
/// handlers for the 0x800 (lighting), 0x20 (nodraw), 0x4000 (hidden)
/// changed bits; ETHEREAL (0x4) doesn't trigger any of them, so slice 1
/// scopes to the bare state-write.
/// </para>
///
/// <para>
/// Implementation: <see cref="ShadowEntry"/> is a value-type record
/// copied into per-cell lists, so we rewrite the copy in each cell the
/// entity occupies. Unregistered entities are a no-op (callers don't
/// have to gate).
/// </para>
/// </summary>
public void UpdatePhysicsState(uint entityId, uint newState)
{
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
return; // not registered — no-op
foreach (var cellId in cellIds)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
for (int i = 0; i < list.Count; i++)
{
if (list[i].EntityId == entityId)
list[i] = list[i] with { State = newState };
}
}
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
{
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
return;
foreach (var cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(e => e.EntityId == entityId);
}
_entityToCells.Remove(entityId);
}
/// <summary>Remove all entities belonging to a landblock.</summary>
public void RemoveLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toRemove = new List<uint>();
foreach (var kvp in _cells)
{
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
toRemove.Add(kvp.Key);
}
foreach (var cellId in toRemove)
_cells.Remove(cellId);
// Clean up entity-to-cell map
var entitiesToRemove = new List<uint>();
foreach (var kvp in _entityToCells)
{
kvp.Value.RemoveAll(c => (c & 0xFFFF0000u) == lbPrefix);
if (kvp.Value.Count == 0)
entitiesToRemove.Add(kvp.Key);
}
foreach (var eid in entitiesToRemove)
_entityToCells.Remove(eid);
}
/// <summary>Get all objects registered in a specific cell.</summary>
public IReadOnlyList<ShadowEntry> GetObjectsInCell(uint cellId)
{
if (_cells.TryGetValue(cellId, out var list))
return list;
return Array.Empty<ShadowEntry>();
}
/// <summary>
/// Get all objects near a world position. Searches the given landblock plus
/// all 8 adjacent landblocks to handle objects near cell/landblock boundaries.
/// Within each landblock, queries only the cells the query sphere overlaps.
///
/// <para>
/// 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>
/// Issue #98 (2026-05-24): the optional <paramref name="primaryCellId"/>
/// parameter gates the outdoor radial sweep on the SPHERE's primary cell
/// type. Mirrors retail's <c>CObjCell::find_cell_list</c> at
/// <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 (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>
///
/// <para>
/// Pre-fix bug shape (issue #98): the cellar EnvCell's player sphere
/// queried the outdoor 24-m grid via the radial sweep and picked up the
/// landblock-wide cottage GfxObj (registered with <c>cellScope=0</c>);
/// the head sphere bumped the cottage's downward-facing floor poly from
/// below at world Z=94 and capped the ascent. With this gate, the
/// outdoor sweep is skipped when the primary cell is indoor, so the
/// cottage is only seen from outdoor primary cells (the building's
/// own outdoor footprint). Default <c>primaryCellId=0</c> preserves
/// 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>? portalReachableCells = null,
uint primaryCellId = 0u)
{
results.Clear();
var seen = new HashSet<uint>();
// 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 cellId in portalReachableCells)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
foreach (var entry in list)
{
if (seen.Add(entry.EntityId))
results.Add(entry);
}
}
}
// Issue #98 (2026-05-24): when the sphere is in an INDOOR cell, skip
// the outdoor radial sweep entirely — retail's CEnvCell::find_collisions
// only iterates this->shadow_object_list, never outdoor cells'. Indoor
// statics are reached via indoorCellIds above. This closes the
// cottage-cellar Z-cap (head sphere bumping cottage floor from below
// because the landblock-wide cottage GfxObj was returned by the
// unconditional radial sweep). Callers that don't pass primaryCellId
// (or pass 0) keep the pre-fix radial-only behavior.
if ((primaryCellId & 0xFFFFu) >= 0x0100u)
return;
// Extract landblock X/Y from the ID.
int lbX = (int)((landblockId >> 24) & 0xFF);
int lbY = (int)((landblockId >> 16) & 0xFF);
// Search the player's landblock and all 8 neighbors.
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
int nx = lbX + dx;
int ny = lbY + dy;
if (nx < 0 || nx > 255 || ny < 0 || ny > 255) continue;
uint neighborLb = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
uint nbPrefix = neighborLb & 0xFFFF0000u;
// Compute local position relative to this neighbor landblock.
float nbOffX = worldOffsetX + dx * 192f;
float nbOffY = worldOffsetY + dy * 192f;
float localX = worldPos.X - nbOffX;
float localY = worldPos.Y - nbOffY;
int minCx = Math.Max(0, (int)((localX - queryRadius) / 24f));
int maxCx = Math.Min(7, (int)((localX + queryRadius) / 24f));
int minCy = Math.Max(0, (int)((localY - queryRadius) / 24f));
int maxCy = Math.Min(7, (int)((localY + queryRadius) / 24f));
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = nbPrefix | (uint)(cx * 8 + cy + 1);
if (!_cells.TryGetValue(cellId, out var list)) continue;
foreach (var entry in list)
{
if (seen.Add(entry.EntityId))
results.Add(entry);
}
}
}
}
}
}
public int TotalRegistered => _entityToCells.Count;
/// <summary>
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
/// For each entity, returns the first entry found in any cell it occupies.
/// Intended for debug rendering only.
/// </summary>
public IEnumerable<ShadowEntry> AllEntriesForDebug()
{
var seen = new HashSet<uint>();
foreach (var kvp in _cells)
{
foreach (var entry in kvp.Value)
{
if (seen.Add(entry.EntityId))
yield return entry;
}
}
}
}
/// <summary>
/// Collision type for a shadow entry. BSP uses full polygon collision.
/// Cylinder uses a simple cylinder-sphere intersection test.
/// </summary>
public enum ShadowCollisionType : byte { BSP, Cylinder }
public readonly record struct ShadowEntry(
uint EntityId,
uint GfxObjId,
Vector3 Position,
Quaternion Rotation,
float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
float CylHeight = 0f,
float Scale = 1.0f,
/// <summary>
/// Retail <c>PhysicsState</c> bits (<c>acclient.h:2815</c>). Used
/// by <c>FindObjCollisions</c> to honor <c>ETHEREAL_PS=0x4</c> +
/// <c>IGNORE_COLLISIONS_PS=0x10</c> short-circuits. Zero for static
/// landblock entities (default behavior matches pre-Commit-A).
/// </summary>
uint State = 0u,
/// <summary>
/// Decoded player / PK / PKLite / Impenetrable flags driving the
/// retail PvP exemption block in <c>FindObjCollisions</c>. Built
/// from <c>PWD._bitfield</c> at <c>CreateObject</c> time via
/// <see cref="EntityCollisionFlagsExt.FromPwdBitfield(uint)"/>.
/// </summary>
EntityCollisionFlags Flags = EntityCollisionFlags.None);