Root cause: ShadowObjectRegistry.GetNearbyObjects gated the outdoor radial sweep whenever primaryCellId is an indoor cell — this was the issue-#98 fix that stops the cottage-floor GfxObj from capping the player's head sphere from the cellar below. But the camera probe (ObjectInfoState.IsViewer, 0x004) also sweeps with an indoor primary cell, and the only geometry that encloses a Holtburg cottage in acdream's data model is the landblock-baked exterior-shell GfxObj (registered cellScope=0, outdoor). Result: the camera's spring-arm sweep found nothing and flew to full chase distance (eye ~3.4 m back, outside the player's cell 90% of frames — root cause of all three post-flap residuals: transparent outer walls, terrain-through-floor, grey stairs). Fix (Option A, retail-faithful): add isViewer parameter (default false, all existing callers keep the gate) to GetNearbyObjects. Thread oi.IsViewer from FindObjCollisions (TransitionTypes.cs ~line 2307) through to the gate. When isViewer=true the outdoor sweep runs regardless of indoor primary cell — matching retail's SmartBox::update_viewer (:92761) which calls find_obj_collisions (:308918) with no indoor-cell restriction. The #98 gate remains in force for IsPlayer and all other non-viewer sweeps. Retail anchors: - SmartBox::update_viewer @ acclient_2013_pseudo_c.txt:92761 — viewer transition finds geometry via find_obj_collisions; no indoor gate - find_obj_collisions @ :308918 — iterates shadow_object_list unconditionally - CObjCell::find_cell_list @ :308751-308769 — retail's own indoor/outdoor branch (the model that makes the #98 gate correct for the player) Also fixes a test-fixture geometry bug: the original RED test had gfxLeaf.BoundingSphere.Origin in world space (0, ExteriorWallY, 96) instead of object-local space (0, 0, 0), causing NodeIntersects to return false even when the gate was bypassed. Corrected to local space; wall polygon vertices/plane also expressed in local space relative to the GfxObj origin. Tests (3 new, 1 renamed): - SweepEye_IndoorCellExteriorGfxObjWall_StoppedByExteriorShell_AfterViewerGateExemption: was RED (_CurrentlyFails); now GREEN — camera sweep stopped by exterior GfxObj wall - GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj: #98 guard (isViewer=false keeps the gate → GfxObj NOT returned) - GetNearbyObjects_IndoorPrimaryCell_IsViewer_DoesReturnOutdoorGfxObj: viewer-exempt guard (isViewer=true bypasses gate → GfxObj IS returned) App.Tests: 154 pass / 0 fail (was 151/1). Core.Tests: 15 fail (same pre-existing static-leak flakiness, unchanged from baseline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
615 lines
27 KiB
C#
615 lines
27 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,
|
||
bool isViewer = false)
|
||
{
|
||
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.
|
||
//
|
||
// M1.5 / Phase U (2026-05-31): exempt the camera viewer (isViewer=true) from
|
||
// this gate. The camera probe (ObjectInfoState.IsViewer) sweeps up+back from the
|
||
// player pivot through the cottage exterior shell, which is a landblock-baked
|
||
// GfxObj registered cellScope=0 (outdoor shadow list). Retail's
|
||
// SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) bounds the viewer
|
||
// via the player's cell enclosure — in retail, interior EnvCells are
|
||
// self-enclosing (walls in the cell's own geometry). In acdream's data model
|
||
// the enclosure is the exterior-shell GfxObj (issue #98 established this); the
|
||
// viewer must be able to reach it. Retail's find_obj_collisions at :308918 has
|
||
// NO indoor-cell gate — the gate is acdream-specific. The #98 protection is
|
||
// correct only for the player foot/head capsule (IsPlayer), NOT for IsViewer.
|
||
// Spec: docs/superpowers/specs/2026-05-31-camera-collision-indoor-engagement-design.md
|
||
if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer)
|
||
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);
|