Apparatus test (DoorCollisionApparatusTests) loads door GfxObj 0x010044B5 from the real dat, builds the door entity's shape list via ShadowShapeBuilder, registers via RegisterMultiPart, and sweeps a player sphere into the door from three angles. Pre-fix: all three assertions fail — the sphere walks straight through. The [cyl-test] probe fires every tick (the small Sphere shape is queried) but no [resolve-bldg] — the per-Part BSP entry is never reached. Root cause: ShadowObjectRegistry.GetNearbyObjects deduplicates on entry.EntityId via HashSet<uint>. Pre-RegisterMultiPart each entity had exactly one shadow row, so dedup-by-entityId correctly suppressed multi-cell duplication. After Task 4's RegisterMultiPart introduced multi-shape rows (1 Sphere + 1 per-Part-BSP for doors; potentially more for creatures + items), the dedup silently drops everything after the first. ShadowShapeBuilder emits Sphere shapes before Part-BSPs, so the Sphere wins and the BSP is dropped — exactly the "Task 7 produced zero [resolve-bldg] hits" finding from the 2026-05-24 evening handoff. Fix: dedup on the full ShadowEntry. record-struct equality compares all fields (EntityId, GfxObjId, Position, Rotation, Radius, CollisionType, CylHeight, Scale, State, Flags, LocalPosition, LocalRotation). Distinct shapes of the same entity are not equal and make it through; the same shape registered in multiple cells (its fields identical across calls) dedups exactly as before. Apparatus verification post-fix: all 4 tests pass. - Dead-center front approach: BLOCKED at Y=11.5 normal=(0,-1,0). - 50 cm off-center: BLOCKED at Y=11.5 normal=(0,-1,0). - Back approach from inside: BLOCKED at Y=12.8 normal=(0,+1,0). - Diagnostic dump: BSP fires at tick 5. What this fix DOES NOT do: switch live RegisterLiveEntityCollision to use ShadowShapeBuilder + RegisterMultiPart. That's Task 7 of the original plan, still reverted. With this foundation fix in place, Task 7 should now actually deliver door blocking in production. Test impact: 44/44 in the shape/registry/door scope pass. The broader Physics suite shows the pre-existing PhysicsResolveCapture static-state flakiness documented in CLAUDE.md — 6 baseline failures without my new tests, 10 with them (4 extra are my apparatus tests' IsPlayer-flag resolves getting captured by a concurrent Capture-test race). Independent of this fix; verified by isolating each test class. Findings + apparatus reasoning: docs/research/2026-05-24-door-dat-inspection-findings.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
601 lines
26 KiB
C#
601 lines
26 KiB
C#
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>
|
||
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
|
||
/// <see cref="UpdatePosition"/> to recompose part world-transforms when
|
||
/// the entity moves. Cleared by <see cref="Deregister"/>.
|
||
/// </summary>
|
||
private readonly Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes = new();
|
||
|
||
/// <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>
|
||
/// A6.P4 door fix (2026-05-24): register one logical entity composed of
|
||
/// multiple collision shapes. All emitted <see cref="ShadowEntry"/> rows
|
||
/// share <paramref name="entityId"/>, so <see cref="UpdatePhysicsState"/>
|
||
/// propagates an ETHEREAL flip to every part (the existing per-entityId
|
||
/// iteration handles this naturally). The shape list is cached in
|
||
/// <see cref="_entityShapes"/> so <see cref="UpdatePosition"/> can
|
||
/// recompose part world-transforms when the entity moves.
|
||
///
|
||
/// <para>
|
||
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> →
|
||
/// <c>CPartArray::FindObjCollisions</c> at
|
||
/// <c>acclient_2013_pseudo_c.txt:276961-286250</c>. One PhysicsObj per
|
||
/// entity, parts iterated for collision testing.
|
||
/// </para>
|
||
/// </summary>
|
||
public void RegisterMultiPart(
|
||
uint entityId,
|
||
Vector3 entityWorldPos,
|
||
Quaternion entityWorldRot,
|
||
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes,
|
||
uint state,
|
||
EntityCollisionFlags flags,
|
||
float worldOffsetX, float worldOffsetY, uint landblockId,
|
||
uint cellScope = 0u)
|
||
{
|
||
Deregister(entityId);
|
||
if (shapes.Count == 0) return;
|
||
|
||
_entityShapes[entityId] = shapes;
|
||
var allCells = new List<uint>();
|
||
var seenCells = new HashSet<uint>();
|
||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||
|
||
foreach (var shape in shapes)
|
||
{
|
||
var rotatedLocal = Vector3.Transform(shape.LocalPosition, entityWorldRot);
|
||
var partWorldPos = entityWorldPos + rotatedLocal;
|
||
var partWorldRot = entityWorldRot * shape.LocalRotation;
|
||
|
||
var entry = new ShadowEntry(
|
||
EntityId: entityId,
|
||
GfxObjId: shape.GfxObjId,
|
||
Position: partWorldPos,
|
||
Rotation: partWorldRot,
|
||
Radius: shape.Radius,
|
||
CollisionType: shape.CollisionType,
|
||
CylHeight: shape.CylHeight,
|
||
Scale: shape.Scale,
|
||
State: state,
|
||
Flags: flags,
|
||
LocalPosition: shape.LocalPosition,
|
||
LocalRotation: shape.LocalRotation);
|
||
|
||
if (cellScope != 0u)
|
||
{
|
||
AddEntryToCell(entry, cellScope);
|
||
if (seenCells.Add(cellScope)) allCells.Add(cellScope);
|
||
continue;
|
||
}
|
||
|
||
float localX = partWorldPos.X - worldOffsetX;
|
||
float localY = partWorldPos.Y - worldOffsetY;
|
||
float r = shape.Radius;
|
||
|
||
int minCx = Math.Max(0, (int)((localX - r) / 24f));
|
||
int maxCx = Math.Min(7, (int)((localX + r) / 24f));
|
||
int minCy = Math.Max(0, (int)((localY - r) / 24f));
|
||
int maxCy = Math.Min(7, (int)((localY + r) / 24f));
|
||
|
||
for (int cx = minCx; cx <= maxCx; cx++)
|
||
{
|
||
for (int cy = minCy; cy <= maxCy; cy++)
|
||
{
|
||
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
|
||
AddEntryToCell(entry, cellId);
|
||
if (seenCells.Add(cellId)) allCells.Add(cellId);
|
||
}
|
||
}
|
||
}
|
||
|
||
_entityToCells[entityId] = allCells;
|
||
}
|
||
|
||
/// <summary>Helper: append a <see cref="ShadowEntry"/> to a cell's
|
||
/// list, creating the list if needed.</summary>
|
||
private void AddEntryToCell(ShadowEntry entry, uint cellId)
|
||
{
|
||
if (!_cells.TryGetValue(cellId, out var list))
|
||
{
|
||
list = new List<ShadowEntry>();
|
||
_cells[cellId] = list;
|
||
}
|
||
list.Add(entry);
|
||
}
|
||
|
||
/// <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 5–10 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)
|
||
{
|
||
// A6.P4 door fix (2026-05-24): if the entity was registered via
|
||
// RegisterMultiPart, we have its full shape list cached. Use that
|
||
// to recompose all part transforms instead of finding one template entry.
|
||
if (_entityShapes.TryGetValue(entityId, out var shapes))
|
||
{
|
||
// Pull the entity-scoped state + flags from the first matching entry
|
||
// (they're shared across all parts of a logical entity).
|
||
uint state = 0u;
|
||
EntityCollisionFlags flags = EntityCollisionFlags.None;
|
||
if (_entityToCells.TryGetValue(entityId, out var existingCells)
|
||
&& existingCells.Count > 0
|
||
&& _cells.TryGetValue(existingCells[0], out var firstList))
|
||
{
|
||
foreach (var e in firstList)
|
||
{
|
||
if (e.EntityId == entityId)
|
||
{
|
||
state = e.State;
|
||
flags = e.Flags;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
RegisterMultiPart(entityId, worldPos, rotation, shapes,
|
||
state, flags, worldOffsetX, worldOffsetY, landblockId);
|
||
return;
|
||
}
|
||
|
||
// Single-shape path (legacy compat for tests + entities that never
|
||
// went through RegisterMultiPart).
|
||
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;
|
||
|
||
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);
|
||
_entityShapes.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();
|
||
// A6.P4 door fix (2026-05-24): dedup on the full ShadowEntry rather
|
||
// than entity id. Pre-RegisterMultiPart each entity had exactly one
|
||
// shadow, so dedup-by-entityId correctly suppressed multi-cell
|
||
// duplication. With multi-part entities (a door has 1 Sphere + 1
|
||
// per-Part-BSP = 2 entries with the same EntityId; creatures can
|
||
// have more), an entityId dedup silently dropped every shape after
|
||
// the first — the door's BSP slab never reached BSPQuery in the
|
||
// 2026-05-24 apparatus reproduction. ShadowEntry's record-struct
|
||
// equality compares all fields (incl. GfxObjId, LocalPosition,
|
||
// CollisionType) so distinct shapes of the same entity make it
|
||
// through, while a single shape registered across multiple cells
|
||
// (its position + radius equal across calls) deduplicates exactly
|
||
// as before.
|
||
var seen = new HashSet<ShadowEntry>();
|
||
|
||
// 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))
|
||
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))
|
||
results.Add(entry);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
public int TotalRegistered => _entityToCells.Count;
|
||
|
||
/// <summary>
|
||
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
|
||
/// Single-shape entities return one entry per shape; multi-part entities
|
||
/// return one entry per registered part (including duplicate shapes at the
|
||
/// same position). Each entity is enumerated exactly once per logical part:
|
||
/// we use the first cell the entity occupies to read its entries, avoiding
|
||
/// re-emitting the same part for each cell it overlaps.
|
||
/// Intended for debug rendering only.
|
||
/// </summary>
|
||
public IEnumerable<ShadowEntry> AllEntriesForDebug()
|
||
{
|
||
var seenEntities = new HashSet<uint>();
|
||
foreach (var kvp in _entityToCells)
|
||
{
|
||
uint entityId = kvp.Key;
|
||
if (!seenEntities.Add(entityId)) continue;
|
||
|
||
// Use the first cell that holds entries for this entity.
|
||
foreach (uint cellId in kvp.Value)
|
||
{
|
||
if (!_cells.TryGetValue(cellId, out var list)) continue;
|
||
bool anyFound = false;
|
||
foreach (var entry in list)
|
||
{
|
||
if (entry.EntityId == entityId)
|
||
{
|
||
yield return entry;
|
||
anyFound = true;
|
||
}
|
||
}
|
||
if (anyFound) break; // Only use the first cell — avoids duplicating multi-cell shapes.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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,
|
||
// A6.P4 door fix (2026-05-24): local-to-entity transform for multi-part
|
||
// entities. ShadowObjectRegistry.UpdatePosition uses these to rebuild
|
||
// Position/Rotation when the entity moves. Single-shape callers leave
|
||
// these at default (zero offset, identity rotation) — equivalent to
|
||
// the shape sitting at the entity's origin.
|
||
Vector3 LocalPosition = default,
|
||
Quaternion LocalRotation = default);
|