T6 (BR-7) C3: per-cell shadow architecture - flood registration, building channel, per-cell query; b3ce505 stopgap DELETED (closes #99)

The A6.P4 port, fused into one installment per the BR-2 half-port lesson
(registration and query are co-dependent: flood-registering shells under
the old radial query would re-open #98 through the vestibule).

REGISTRATION (ShadowObjectRegistry rewritten):
- Register/RegisterMultiPart/UpdatePosition compute the cell set via
  CellTransit.BuildShadowCellSet (the C2 find_cell_list flood) seeded by
  the entity's m_position cell id; the private 24m XY-grid rectangle and
  its single-landblock clamp are deleted. Flood spheres follow retail's
  CylSphere rule (base point + cyl radius, cap 10; BSP bounding-sphere
  fallback - Ghidra 0x0052b9f0). Statics flood with the do_not_load
  prune; dynamics (server spawns, isStatic:false) without.
- Keep-when-empty (SetPositionInternal num_cells gate, pc:283540): a
  failed flood leaves the previous registration in place.
- RefloodLandblock: streaming-race hook re-runs the flood when a
  landblock's cells hydrate (retail init_objects -> recalc_cross_cells,
  Ghidra 0x0052b420/0x00515a30); wired at GameWindow's hydration tail.
- GameWindow sites pass the server position's full cell id as the seed
  (spawn + UpdatePosition); the five static sites pass ParentCellId.

BUILDING CHANNEL (CSortCell.building shape):
- Building SHELLS are not shadow objects in retail (only caller of
  find_building_collisions is CSortCell::find_collisions 0x005340aa;
  one building per origin landcell, init_buildings 0x0052fd80 verified
  verbatim + ACE cross-ref). IsBuildingShell entities skip the registry;
  Transition.FindBuildingCollisions runs the shell part-0 BSP off
  cache.GetBuilding(cellId) with bldg_check set around it
  (find_building_collisions 0x006b5300), CollidedWithEnvironment on
  non-Contact non-OK. BuildingPhysics.ModelId = pre-resolved part-0
  GfxObj (0x02 Setups resolved at the CacheBuilding site).
- Placement/ethereal weakening: BSPQuery Path 1 passes center_solid=0
  when BldgCheck && HitsInteriorCell (BSPTREE::find_collisions 0x0053a82e
  + placement_insert 0x005399d8) so doorway crossings don't hard-fail
  against shell solids. SpherePath gains both retail fields;
  HitsInteriorCell is rebuilt at every cell-array build
  (build_cell_array reset 0x00509ef2 + find_cell_list/check_building_
  transit set sites).

QUERY (retail per-cell order, transitional_insert 0x0050b6f0):
- TransitionalInsert per attempt: env -> building (LandCell only) ->
  objects on the PRIMARY cell, then on OK the check_other_cells pass
  (env -> building -> objects per OTHER overlapped cell) + the
  carried-cell advance - the advance now happens AFTER all per-cell
  object passes (the WF1 ordering divergence), with Adjusted/Slid
  feeding the retry exactly like retail's OK_TS case.
- FindObjCollisionsInCell = CObjCell::find_obj_collisions (0x0052b750):
  iterate ONLY the asked cell's list. DELETED: the radial 9-landblock
  sweep, the +5m query pad, the b3ce505 indoor-primary gate, and the
  isViewer exemption (the camera is bounded by interior cell-BSP env
  collision - retail's own channel; CameraCornerSealReplayTests pins it
  against real dat, and the new building-channel camera test pins the
  outdoor stop).

TESTS: Core 1416/0/2 (was 1398 + 4 pre-existing #99-era fails + 1 skip),
App 225, UI 420, Net 294 - all green.
- 3 of the 4 #99-era reds flipped green as designed: the door apparatus
  (Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks) and tick-13558
  (indoor walkthrough) now assert the door BLOCKS; tick-22760 pins the
  outdoor blocking invariant.
- The 4th (BSPStepUp D4) + 22760's lateral-slide delta are NOT cell-set
  problems (probes prove the door is found + BSP-only dispatched;
  BR-7 left both byte-identical) - filed as issue #116 (slide-response
  family), D4 skipped with the issue reference.
- FindEnvCollisionsMultiCellTests migrated to the public entry (the A4
  multi-cell halt now lives at the retail call site).
- New registry pins: per-cell query surface, outdoor-footprint-never-
  indoor (#98 architectural), door-outdoor-cell membership, reflood.
- CameraCollisionIndoorTests rewritten against the building channel
  (the isViewer-exemption pins died with the exemption).

Closes #99 (doors block both ways via registration-time cell membership
+ the straddle-spanning player cell array). #97 likely closed (the +5m
radial pad that produced phantom-collision candidates is gone) - verify
at T5. #98 stays closed ARCHITECTURALLY (outdoor footprints structurally
cannot reach interior cells; the cellar harness stays green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-11 14:37:50 +02:00
parent abf36e2743
commit dbfbf8506c
15 changed files with 1109 additions and 856 deletions

View file

@ -1716,7 +1716,14 @@ public static class BSPQuery
// ----------------------------------------------------------------
if (path.InsertType == InsertType.Placement || obj.Ethereal)
{
const bool clearCell = true;
// BR-7 / A6.P4 (2026-06-11): retail weakens the solid test
// against BUILDING shells while the path engages interior cells
// — center_solid (ebp_4/edi) flips 1→0 when
// `bldg_check && hits_interior_cell` (BSPTREE::find_collisions
// 0x0053a82e + placement_insert 0x005399d8), so doorway
// crossings don't hard-fail placement against the shell's solid
// regions. Everything else keeps the full test.
bool clearCell = !(path.BldgCheck && path.HitsInteriorCell);
// A6.P3 slice 5 (2026-05-22) — reset the placement-fail side-channel
// before each SphereIntersectsSolidInternal call so a leftover

View file

@ -15,6 +15,17 @@ public sealed class BuildingPhysics
public required Matrix4x4 WorldTransform { get; init; }
public required Matrix4x4 InverseWorldTransform { get; init; }
public required IReadOnlyList<BldPortalInfo> Portals { get; init; }
/// <summary>
/// BR-7 / A6.P4 (2026-06-11): the building's shell part-0 GfxObj id.
/// 0x01 BuildingInfo.ModelId values are stored verbatim; 0x02 Setup
/// models are resolved to their FIRST part at cache time (the
/// CacheBuilding call site reads the dat). Drives the retail building
/// collision channel (<c>CBuildingObj::find_building_collisions</c>,
/// Ghidra 0x006b5300: one BSP test on <c>part_array-&gt;parts[0]</c>).
/// 0 = unknown (legacy cache entries / tests) — the channel is inert.
/// </summary>
public uint ModelId { get; init; }
}
/// <summary>

View file

@ -430,7 +430,8 @@ public sealed class PhysicsDataCache
/// for an outdoor landcell that contains a building stab. Used by
/// <see cref="CellTransit.CheckBuildingTransit"/>.
/// </summary>
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform)
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform,
uint modelId = 0u)
{
if (_buildings.ContainsKey(landcellId)) return;
Matrix4x4.Invert(worldTransform, out var inverse);
@ -439,6 +440,10 @@ public sealed class PhysicsDataCache
WorldTransform = worldTransform,
InverseWorldTransform = inverse,
Portals = portals,
// BR-7: first-wins per cell mirrors retail CSortCell::add_building
// (0x00534030) — and one building per origin landcell mirrors
// CLandBlock::init_buildings (0x0052fd80).
ModelId = modelId,
};
}

View file

@ -40,9 +40,17 @@ public sealed class PhysicsEngine
/// <summary>
/// Physics BSP cache shared with the streaming loader. Set once by the
/// host (GameWindow) immediately after construction. The Transition system
/// reads this during FindObjCollisions to perform narrow-phase BSP tests.
/// reads this during FindObjCollisionsInCell to perform narrow-phase BSP
/// tests. BR-7: propagated into <see cref="ShadowObjects"/> so the
/// registration-side flood (<see cref="CellTransit.BuildShadowCellSet"/>)
/// can traverse cells + buildings.
/// </summary>
public PhysicsDataCache? DataCache { get; set; }
public PhysicsDataCache? DataCache
{
get => _dataCache;
set { _dataCache = value; ShadowObjects.DataCache = value; }
}
private PhysicsDataCache? _dataCache;
private sealed record LandblockPhysics(
TerrainSurface Terrain,

View file

@ -4,13 +4,25 @@ 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.
/// Per-cell shadow-object index — the collision-query side of retail's
/// <c>CObjCell.shadow_object_list</c> (acclient.h:30916-30936). Each entity
/// registers into the EXACT cells its collision footprint overlaps, computed
/// at registration time by the sphere-overlap portal flood
/// (<see cref="CellTransit.BuildShadowCellSet"/> = retail
/// <c>CObjCell::find_cell_list</c>, Ghidra 0x0052b4e0, as invoked by
/// <c>calc_cross_cells(_static)</c> 0x00515230/0x00515160). The Transition
/// system queries strictly per cell (<see cref="GetObjectsInCell"/> = retail
/// <c>CObjCell::find_obj_collisions</c> iterating only
/// <c>this-&gt;shadow_object_list</c>, Ghidra 0x0052b750).
///
/// 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.
/// <para>
/// BR-7 / A6.P4 (2026-06-11): this replaces the previous outdoor 24-m XY
/// grid-rectangle placement + 9-landblock radial query sweep. There is no
/// spatial radius anywhere in retail's query path; cell membership IS the
/// broad phase. The b3ce505 indoor-primary gate, the isViewer exemption,
/// and the +5 m query pad all existed to compensate the grid approximation
/// and are deleted with it.
/// </para>
/// </summary>
public sealed class ShadowObjectRegistry
{
@ -25,17 +37,56 @@ public sealed class ShadowObjectRegistry
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.
/// BR-7: per-entity registration arguments, kept so a registration can be
/// RE-RUN when more cells hydrate. Retail's equivalent is
/// <c>CObjCell::init_objects → recalc_cross_cells</c> on cell load
/// (Ghidra 0x0052b420 / 0x00515a30): the flood can only traverse loaded
/// cells, so an object registered before its neighbourhood streams in
/// gets its cell set recomputed afterwards. <see cref="RefloodLandblock"/>
/// is the streaming-side trigger.
/// </summary>
private readonly Dictionary<uint, RegistrationRecord> _entityReg = new();
private sealed record RegistrationRecord(
uint SeedCellId,
Vector3 EntityWorldPos,
Quaternion EntityWorldRot,
uint State,
EntityCollisionFlags Flags,
bool IsStatic,
bool IsMultiPart,
// Single-shape fields (IsMultiPart == false):
uint GfxObjId,
float Radius,
ShadowCollisionType CollisionType,
float CylHeight,
float Scale);
/// <summary>
/// The flood's data source (cells, buildings, terrain origins). Wired by
/// <see cref="PhysicsEngine"/> when its own <c>DataCache</c> is set.
/// A bare registry (unit tests) floods against an empty cache: outdoor
/// seeds still produce the overlapped landcells (pure LandDefs math);
/// indoor seeds resolve to just the seed cell.
/// </summary>
public PhysicsDataCache? DataCache { get; set; }
private PhysicsDataCache _fallbackCache => _fallback ??= new PhysicsDataCache();
private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
/// <summary>
/// Register a single-shape entity. <paramref name="seedCellId"/> is the
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to
/// derive the outdoor landcell under <paramref name="worldPos"/>
/// (landblock-baked statics whose position is implicitly outdoor).
///
/// <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.
/// For <see cref="ShadowCollisionType.Cylinder"/> shapes the flood
/// sphere is the cylinder BASE point with the cylinder radius — retail
/// globalizes CylSphere <c>low_pt</c> (overload Ghidra 0x0052b9f0).
/// For BSP shapes it is the part bounding sphere (retail's
/// sorting-sphere fallback).
/// </para>
/// </summary>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
@ -44,81 +95,58 @@ public sealed class ShadowObjectRegistry
float cylHeight = 0f, float scale = 1.0f,
uint state = 0u,
EntityCollisionFlags flags = EntityCollisionFlags.None,
uint cellScope = 0u)
uint seedCellId = 0u,
bool isStatic = true)
{
// Flood FIRST: retail keeps the previous shadows when the new cell
// array would be empty (SetPositionInternal num_cells gate,
// pc:283540) — so the old registration must survive a failed flood.
uint seed = seedCellId != 0u
? seedCellId
: DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
var spheres = new[]
{
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = radius },
};
var cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, spheres, spheres.Length, isStatic);
if (cellSet.Count == 0) return;
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)
var cellIds = new List<uint>(cellSet.Count);
foreach (uint cellId in cellSet)
{
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);
}
AddEntryToCell(entry, cellId);
cellIds.Add(cellId);
}
_entityToCells[entityId] = cellIds;
_entityReg[entityId] = new RegistrationRecord(
seed, worldPos, rotation, state, flags, isStatic,
IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale);
}
/// <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.
/// Register one logical entity composed of multiple collision shapes
/// (A6.P4 door fix, 2026-05-24). All emitted <see cref="ShadowEntry"/>
/// rows share <paramref name="entityId"/>; the shape list is cached so
/// <see cref="UpdatePosition"/> can recompose part transforms.
///
/// <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.
/// BR-7: the cell set is ONE flood for the whole entity (retail floods
/// per OBJECT with its full sphere set, not per part). Flood spheres
/// follow retail's rule (Ghidra 0x0052b9f0): when the object has
/// CylSpheres, they alone drive the flood (base point + cyl radius,
/// capped at 10); otherwise the BSP parts' bounding spheres stand in
/// for the sorting sphere. Every shape row is then written into every
/// flooded cell, mirroring add_shadows_to_cells (0x00514ae0) +
/// CPartArray::AddPartsShadow.
/// </para>
/// </summary>
public void RegisterMultiPart(
@ -129,15 +157,25 @@ public sealed class ShadowObjectRegistry
uint state,
EntityCollisionFlags flags,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint cellScope = 0u)
uint seedCellId = 0u,
bool isStatic = false)
{
Deregister(entityId);
if (shapes.Count == 0) return;
if (shapes.Count == 0) { Deregister(entityId); return; }
// Flood FIRST — keep-when-empty, see Register.
uint seed = seedCellId != 0u
? seedCellId
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
var cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
if (cellSet.Count == 0) return;
Deregister(entityId);
_entityShapes[entityId] = shapes;
var allCells = new List<uint>();
var seenCells = new HashSet<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u;
var allCells = new List<uint>(cellSet.Count);
foreach (var shape in shapes)
{
@ -159,34 +197,77 @@ public sealed class ShadowObjectRegistry
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);
}
}
foreach (uint cellId in cellSet)
AddEntryToCell(entry, cellId);
}
foreach (uint cellId in cellSet)
allCells.Add(cellId);
_entityToCells[entityId] = allCells;
_entityReg[entityId] = new RegistrationRecord(
seed, entityWorldPos, entityWorldRot, state, flags, isStatic,
IsMultiPart: true, GfxObjId: 0u, Radius: 0f,
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
}
/// <summary>
/// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0):
/// when the object has cylinder shapes, each contributes one sphere at
/// its world BASE point (low_pt) with the cylinder radius, capped at 10;
/// otherwise the BSP parts' bounding spheres are the footprint (the
/// sorting-sphere fallback, calc_cross_cells 0x00515230 tail).
/// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
{
const int RetailSphereCap = 10;
var spheres = new List<DatReaderWriter.Types.Sphere>();
bool anyCyl = false;
foreach (var s in shapes)
{
if (s.CollisionType == ShadowCollisionType.Cylinder) { anyCyl = true; break; }
}
foreach (var s in shapes)
{
if (anyCyl && s.CollisionType != ShadowCollisionType.Cylinder)
continue;
if (spheres.Count >= RetailSphereCap)
break;
var world = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
spheres.Add(new DatReaderWriter.Types.Sphere
{
Origin = world,
Radius = s.Radius,
});
}
return spheres;
}
/// <summary>
/// Derive the outdoor landcell id under a world position — the implicit
/// seed for landblock-baked statics registered without a cell id
/// (retail: their m_position resolves outdoor via adjust_to_outside).
/// </summary>
private static uint DeriveOutdoorSeed(
Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId)
{
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
int cx = (int)System.Math.Clamp(localX / 24f, 0f, 7f);
int cy = (int)System.Math.Clamp(localY / 24f, 0f, 7f);
uint lbPrefix = landblockId & 0xFFFF0000u;
if (lbPrefix == 0u) return 0u;
// The clamp only anchors the SEED id; AddAllOutsideCells re-seats the
// actual flood cells from the sphere centers via LandDefs.AdjustToOutside
// (block-crossing), so an out-of-block position still floods correctly.
return lbPrefix | (uint)(cx * 8 + cy + 1);
}
/// <summary>Helper: append a <see cref="ShadowEntry"/> to a cell's
@ -207,74 +288,96 @@ public sealed class ShadowObjectRegistry
/// <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.
/// Retail re-registers every moved object per successful transition step
/// (SetPositionInternal tail, Ghidra 0x00515330: remove_shadows_from_cells
/// + add_shadows_to_cells with the transition's cell array) and on
/// server-driven SetPosition via calc_cross_cells. Remote entities have
/// no local transition, so this runs the same flood from their reported
/// cell (<paramref name="seedCellId"/> = the wire position's full cell
/// id). Retail keeps the previous shadows when the new array would be
/// EMPTY (the <c>num_cells != 0</c> gate at pc:283540) — mirrored here
/// by skipping the re-registration when no seed resolves.
/// </para>
/// </summary>
public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation,
float worldOffsetX, float worldOffsetY, uint landblockId)
float worldOffsetX, float worldOffsetY, uint landblockId,
uint seedCellId = 0u)
{
// 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))
if (!_entityReg.TryGetValue(entityId, out var reg))
return; // not registered — no-op (callers don't have to gate)
// Keep-when-empty (retail pc:283540): no resolvable seed → leave the
// previous registration in place.
if (seedCellId == 0u
&& DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId) == 0u)
return;
if (reg.IsMultiPart && _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);
reg.State, reg.Flags, worldOffsetX, worldOffsetY, landblockId,
seedCellId, reg.IsStatic);
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;
Register(entityId, reg.GfxObjId, worldPos, rotation, reg.Radius,
worldOffsetX, worldOffsetY, landblockId,
reg.CollisionType, reg.CylHeight, reg.Scale,
reg.State, reg.Flags, seedCellId, reg.IsStatic);
}
ShadowEntry? template = null;
foreach (var oldCellId in oldCells)
/// <summary>
/// BR-7 streaming hook — re-run the flood for every entity whose seed
/// cell or current cell set touches <paramref name="landblockId"/>'s
/// prefix. Retail's equivalent runs per loaded cell
/// (<c>CObjCell::init_objects → recalc_cross_cells</c>, Ghidra
/// 0x0052b420/0x00515a30); per-landblock granularity matches our
/// streaming unit. Covers both race directions: entity registered
/// before its neighbourhood hydrated (flood couldn't traverse), and
/// cells hydrated after a server spawn landed.
/// </summary>
public void RefloodLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toReflood = new List<uint>();
foreach (var kvp in _entityReg)
{
if (_cells.TryGetValue(oldCellId, out var list))
if ((kvp.Value.SeedCellId & 0xFFFF0000u) == lbPrefix)
{
foreach (var e in list)
toReflood.Add(kvp.Key);
continue;
}
if (_entityToCells.TryGetValue(kvp.Key, out var cells))
{
foreach (uint c in cells)
{
if (e.EntityId == entityId)
if ((c & 0xFFFF0000u) == lbPrefix)
{
template = e;
toReflood.Add(kvp.Key);
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);
foreach (uint entityId in toReflood)
{
var reg = _entityReg[entityId];
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
{
RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes,
reg.State, reg.Flags, 0f, 0f, lbPrefix,
reg.SeedCellId, reg.IsStatic);
}
else
{
Register(entityId, reg.GfxObjId, reg.EntityWorldPos, reg.EntityWorldRot,
reg.Radius, 0f, 0f, lbPrefix,
reg.CollisionType, reg.CylHeight, reg.Scale,
reg.State, reg.Flags, reg.SeedCellId, reg.IsStatic);
}
}
}
/// <summary>
@ -316,24 +419,35 @@ public sealed class ShadowObjectRegistry
list[i] = list[i] with { State = newState };
}
}
if (_entityReg.TryGetValue(entityId, out var reg))
_entityReg[entityId] = reg 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 (_entityToCells.TryGetValue(entityId, out var cellIds))
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(e => e.EntityId == entityId);
foreach (var cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(e => e.EntityId == entityId);
}
_entityToCells.Remove(entityId);
}
_entityToCells.Remove(entityId);
_entityShapes.Remove(entityId);
_entityReg.Remove(entityId);
}
/// <summary>Remove all entities belonging to a landblock.</summary>
/// <summary>
/// Remove all entities belonging to a landblock. With flood-driven
/// registration an entity's cells can span landblock prefixes; entries
/// under OTHER prefixes survive, and the entity is fully dropped only
/// when no cells remain (its owner despawns it via
/// <see cref="Deregister"/> in the normal path — retail's per-object
/// remove_shadows_from_cells, Ghidra 0x00511230).
/// </summary>
public void RemoveLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
@ -357,186 +471,25 @@ public sealed class ShadowObjectRegistry
entitiesToRemove.Add(kvp.Key);
}
foreach (var eid in entitiesToRemove)
{
_entityToCells.Remove(eid);
_entityShapes.Remove(eid);
_entityReg.Remove(eid);
}
}
/// <summary>Get all objects registered in a specific cell.</summary>
/// <summary>
/// All objects registered in a specific cell — retail
/// <c>CObjCell::find_obj_collisions</c> iterating only
/// <c>this-&gt;shadow_object_list</c> (Ghidra 0x0052b750). THE query
/// surface: the Transition system calls this per cell in its transit
/// cell array (primary via the insert, others via check_other_cells).
/// </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.Numerics.Vector3, System.Collections.Generic.ICollection{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);
}
}
}
}
}
return System.Array.Empty<ShadowEntry>();
}
public int TotalRegistered => _entityToCells.Count;

View file

@ -373,6 +373,28 @@ public sealed class SpherePath
public bool CheckWalkable;
public InsertType InsertType = InsertType.Transition;
/// <summary>
/// BR-7 / A6.P4 (2026-06-11). Retail <c>SPHEREPATH.bldg_check</c>: set
/// around the building-shell part test by
/// <c>CBuildingObj::find_building_collisions</c> (Ghidra 0x006b5300,
/// set at 006b5311 / cleared at 006b5328). Together with
/// <see cref="HitsInteriorCell"/> it weakens the placement/ethereal
/// solid test against building shells (center_solid=0) in
/// <c>BSPTREE::find_collisions</c> (0x0053a82e) +
/// <c>placement_insert</c> (0x005399d8).
/// </summary>
public bool BldgCheck;
/// <summary>
/// Retail <c>SPHEREPATH.hits_interior_cell</c>: reset at
/// <c>insert_into_cell</c> / <c>check_other_cells</c> entry
/// (0x00509ef2 / 0x0050ae7a), set during cell-array building when the
/// seed is indoor (find_cell_list 0052b551), when the containing-cell
/// pick lands an interior cell (0052b64a), or when
/// <c>check_building_transit</c> admits an interior cell (0052c650).
/// </summary>
public bool HitsInteriorCell;
public void SetCheckPos(Vector3 pos, uint cellId)
{
CheckPos = pos;
@ -853,10 +875,12 @@ public sealed class Transition
/// </para>
///
/// <para>
/// This is simplified from ACE: we don't have CellArray/CheckOtherCells
/// iteration because our FindObjCollisions (via ShadowObjectRegistry) is
/// already a flat per-landblock query. That's the equivalent of iterating
/// objects across all relevant cells.
/// BR-7 / A6.P4 (2026-06-11): per-attempt order is retail's
/// transitional_insert (Ghidra 0x0050b6f0): primary cell's
/// find_collisions (env → building → objects, the insert_into_cell
/// composition) then, on OK, check_other_cells (env → building →
/// objects per OTHER overlapped cell) + the carried-cell advance.
/// The former flat per-landblock object query is gone.
/// </para>
/// </summary>
private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine)
@ -873,6 +897,8 @@ public sealed class Transition
for (int attempt = 0; attempt < numAttempts; attempt++)
{
// ── Phase 1: environment collision (terrain + indoor BSP) ───
// Primary cell only — retail CEnvCell/CLandCell::find_collisions
// step 1 (find_env_collisions). Other cells run in Phase 2.5.
transitState = FindEnvCollisions(engine);
if (transitState == TransitionState.Collided)
@ -895,9 +921,36 @@ public sealed class Transition
continue;
}
// ── Phase 2: object (static BSP + cylinder) collision ───────
// Env was OK — now test objects.
var objState = FindObjCollisions(engine);
// ── Phase 1b: the building channel (BR-7 / A6.P4) ───────────
// CLandCell::find_collisions (Ghidra 0x00532d60) interposes
// CSortCell::find_collisions (0x005340a0 — the per-LandCell
// building shell BSP) between env and objects. Indoor primary
// cells have no building leg (CEnvCell::find_collisions,
// 0x0052c100). No-op when the cell has no building.
var bldgState = FindBuildingCollisions(engine, sp.CheckCellId);
if (bldgState == TransitionState.Collided)
return TransitionState.Collided;
if (bldgState == TransitionState.Slid)
{
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
continue;
}
if (bldgState == TransitionState.Adjusted)
{
sp.NegPolyHit = false;
continue;
}
// ── Phase 2: object collision — PRIMARY cell's shadow list ──
// Retail CObjCell::find_obj_collisions(this) (0x0052b750), the
// tail of the primary cell's find_collisions. Other cells'
// lists run per cell in Phase 2.5 (check_other_cells).
var objState = FindObjCollisionsInCell(engine, sp.CheckCellId);
// L.4-diag: log Phase outcomes per attempt so we can see whether
// we're escaping to the step-down branch or churning in retries.
DumpPhase2(attempt, transitState, objState);
@ -924,6 +977,28 @@ public sealed class Transition
continue;
}
// ── Phase 2.5: other cells + carried-cell advance ────────────
// Retail transitional_insert OK_TS case (0x0050b756): on a clean
// primary insert, check_other_cells runs env AND building AND
// shadow objects per OTHER overlapped cell, then the carried
// cell advances to the ordered pick. Its non-OK results clear
// neg_poly_hit and feed the retry (COLLIDED returns; the
// internal SLID already cleared the contact-plane fields, retail
// pc:272752-272760). This is the collide-then-pick order — the
// advance happens AFTER all per-cell object passes, never
// before (the pre-A6.P4 ordering advanced before objects).
var otherState = RunCheckOtherCellsAndAdvance(
engine, sp.GlobalSphere[0].Origin, sp.GlobalSphere[0].Radius);
if (otherState != TransitionState.OK)
sp.NegPolyHit = false;
if (otherState == TransitionState.Collided)
return TransitionState.Collided;
if (otherState != TransitionState.OK)
continue; // ADJUSTED / SLID → retry the attempt
// ── Phase 3: both env and objects returned OK ──────────────
// Handle Collide flag (BSP path 6 set it on a non-contact hit).
// ACE: Transition.TransitionalInsert Collide branch (Transition.cs:891-930).
@ -1681,6 +1756,21 @@ public sealed class Transition
if (ApplyOtherCellResult(terrainState, out var terrainHalted))
return terrainHalted;
// BR-7 / A6.P4 (2026-06-11): retail's per-other-cell
// find_collisions on a LandCell is env → building → objects
// (CLandCell::find_collisions 0x00532d60 →
// CSortCell::find_collisions 0x005340a0 →
// CObjCell::find_obj_collisions 0x0052b750). This is how an
// indoor-primary sphere straddling an exit portal tests the
// building shell AND outdoor-registered objects (doors).
var bldgOtherState = FindBuildingCollisions(engine, cellId);
if (ApplyOtherCellResult(bldgOtherState, out var bldgHalted))
return bldgHalted;
var objOtherState = FindObjCollisionsInCell(engine, cellId);
if (ApplyOtherCellResult(objOtherState, out var objHalted))
return objHalted;
continue;
}
@ -1763,6 +1853,15 @@ public sealed class Transition
if (ApplyOtherCellResult(result, out var halted))
return halted;
// BR-7 / A6.P4 (2026-06-11): retail's per-other-cell
// find_collisions on an EnvCell is env → objects
// (CEnvCell::find_collisions 0x0052c100 →
// CObjCell::find_obj_collisions 0x0052b750) — the other cell's
// OWN shadow list, which the registration-side flood populated.
var objIndoorState = FindObjCollisionsInCell(engine, cellId);
if (ApplyOtherCellResult(objIndoorState, out var objIndoorHalted))
return objIndoorHalted;
}
return TransitionState.OK;
@ -2085,16 +2184,17 @@ public sealed class Transition
return cellState;
}
// ── check_other_cells (retail collide-then-pick) ───────────
// The primary indoor BSP collision above ran against the carried
// cell (sp.CheckCellId). NOW pick the new containing cell, collide
// every OTHER cell the sphere overlaps, and advance the carried
// cell. Retail CTransition::check_other_cells (pseudo_c:272717).
// (Indoor walkable: retail CEnvCell::find_env_collisions returns OK
// after the BSP find_collisions — no set_contact_plane synthesis;
// ContactPlane comes from a prior Path-6 land or the per-transition
// LKCP restore in ValidateTransition.)
return RunCheckOtherCellsAndAdvance(engine, footCenter, sphereRadius);
// BR-7 / A6.P4 (2026-06-11): the check_other_cells pass +
// carried-cell advance moved OUT of the env phase — retail
// runs it after the WHOLE primary insert (env → building →
// objects), from transitional_insert's OK_TS case
// (0x0050b756). See TransitionalInsert Phase 2.5.
// (Indoor walkable: retail CEnvCell::find_env_collisions
// returns OK after the BSP find_collisions — no
// set_contact_plane synthesis; ContactPlane comes from a
// prior Path-6 land or the per-transition LKCP restore in
// ValidateTransition.)
return TransitionState.OK;
}
}
@ -2135,15 +2235,14 @@ public sealed class Transition
if (terrainState != TransitionState.OK)
return terrainState;
}
// else: no terrain loaded here — allow pass-through, but STILL run the post-collision
// pick so an outdoor-seeded sphere re-entering a building is promoted to the interior.
// else: no terrain loaded here — allow pass-through.
// ── check_other_cells (retail collide-then-pick) ──
// Pick the new containing cell + collide every OTHER cell the sphere overlaps + advance
// the carried cell. For an outdoor seed this is the outdoor→indoor re-entry path (the
// ordered pick promotes to the interior cell via CheckBuildingTransit and collides its
// walls on the entry frame). Retail CTransition::check_other_cells (pseudo_c:272717).
return RunCheckOtherCellsAndAdvance(engine, footCenter, sphereRadius);
// BR-7 / A6.P4 (2026-06-11): the check_other_cells pass + advance
// moved to TransitionalInsert Phase 2.5 (retail order: after the
// whole primary insert incl. building + objects). The outdoor→indoor
// re-entry promotion (ordered pick via CheckBuildingTransit) still
// happens there, on every attempt that reaches a clean primary.
return TransitionState.OK;
}
/// <summary>
@ -2178,10 +2277,34 @@ public sealed class Transition
// tangent) position, where the floor no longer overlaps. (2026-06-05)
footCenter = sp.GlobalSphere[0].Origin;
// BR-7 / A6.P4 (2026-06-11): retail recomputes hits_interior_cell at
// every cell-array (re)build — reset in build_cell_array /
// check_other_cells entry (Ghidra 0x00509ef2 / 0x0050ae7a), set by
// find_cell_list's indoor seed (0052b551), the interior
// containing-cell pick (0052b64a), and check_building_transit
// admissions (0052c650). For an outdoor seed, interior ids enter the
// array ONLY via check_building_transit, so "array contains an
// interior id" is exactly the (b)(c) condition. Feeds the building
// shell center-solid weakening (BSPQuery Path 1).
sp.HitsInteriorCell = false;
uint containingCellId = CellTransit.FindCellSet(
engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet);
LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius);
if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u
|| (containingCellId & 0xFFFFu) >= 0x0100u)
{
sp.HitsInteriorCell = true;
}
else
{
foreach (uint id in cellSet)
{
if ((id & 0xFFFFu) >= 0x0100u) { sp.HitsInteriorCell = true; break; }
}
}
var otherCellsState = CheckOtherCells(engine, footCenter, sphereRadius, cellSet);
if (otherCellsState != TransitionState.OK)
return otherCellsState;
@ -2291,35 +2414,44 @@ public sealed class Transition
// -----------------------------------------------------------------------
/// <summary>
/// Query the ShadowObjectRegistry for nearby static objects and run
/// collision against each using the retail BSPTree.find_collisions 6-path
/// dispatcher.
/// BR-7 / A6.P4 (2026-06-11). Per-cell object collision — retail
/// <c>CObjCell::find_obj_collisions</c> (Ghidra 0x0052b750, pc:308916):
/// iterate ONLY <paramref name="cellId"/>'s shadow_object_list, skipping
/// self, running the per-object collision test; the first non-OK result
/// halts. There is NO spatial radius anywhere in retail's query path —
/// cell membership (established at registration by the
/// <see cref="CellTransit.BuildShadowCellSet"/> flood) IS the broad
/// phase. Replaces the radial 9-landblock sweep, the +5 m query pad,
/// the b3ce505 indoor-primary gate, and the isViewer exemption — all of
/// which compensated the old XY-grid registration (the camera probe is
/// bounded by interior cell-BSP env collision, retail's own channel).
///
/// ACE: ObjCell.FindObjCollisions iterates objects, calling
/// PhysicsObj.FindObjCollisions on each. For BSP objects, this transforms
/// to object-local space and calls BSPTree.find_collisions (the 6-path
/// dispatcher that handles step-up, slide, collide-with-point, etc.).
/// <para>
/// Called per cell at retail's two sites: the primary cell inside the
/// insert (CEnvCell/CLandCell::find_collisions, Ghidra
/// 0x0052c100/0x00532d60 — env [→ building] → objects) and every other
/// overlapped cell inside <see cref="CheckOtherCells"/> (0x0050ae50).
/// </para>
///
/// The retail approach processes objects sequentially — the first non-OK
/// result modifies SpherePath and is returned. This differs from the
/// previous "find earliest t" approach.
/// <para>
/// The per-object distance pre-check below is the analog of the part
/// sorting-sphere early-outs inside retail's
/// <c>CPhysicsObj::FindObjCollisions</c> — response-neutral, pure perf.
/// </para>
/// </summary>
private TransitionState FindObjCollisions(PhysicsEngine engine)
private TransitionState FindObjCollisionsInCell(PhysicsEngine engine, uint cellId)
{
if (engine.DataCache is null) return TransitionState.OK;
var objsInCell = engine.ShadowObjects.GetObjectsInCell(cellId);
if (objsInCell.Count == 0) return TransitionState.OK;
var sp = SpherePath;
var oi = ObjectInfo;
var ci = CollisionInfo;
// #42 diagnostic (2026-05-05): identify which static object causes
// the airborne first-frame ~1m push. Capture sphere check pos at
// entry; on a non-OK return, we'll log the (object, delta) pair
// gated on ACDREAM_AIRBORNE_DIAG=1 + airborne. The first evidence
// run ruled out H1 (slope-driven AdjustOffset projection); cpN was
// (0,0,1) flat for every drift event, so the horizontal push must
// come from CylinderCollision or BSPQuery.FindCollisions inside
// this function. Logging the object identity tells us which one.
// the airborne first-frame ~1m push.
bool airborneDiag = !oi.Contact
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1";
Vector3 sphereCheckBefore = sp.CheckPos;
@ -2329,57 +2461,14 @@ public sealed class Transition
float sphereRadius = sp.GlobalSphere[0].Radius;
Vector3 movement = checkPos - currPos;
if (!engine.TryGetLandblockContext(checkPos.X, checkPos.Y,
out uint landblockId, out float worldOffsetX, out float worldOffsetY))
return TransitionState.OK;
// Landblock offsets feed the [resolve-bldg] probe only.
engine.TryGetLandblockContext(checkPos.X, checkPos.Y,
out _, out float worldOffsetX, out float worldOffsetY);
// Use a local list: DoStepUp calls TransitionalInsert → FindObjCollisions
// recursively, so reusing a single field list would corrupt the outer
// iteration. Allocate per call (cheap — typically 0-5 entries).
var nearbyObjs = new List<ShadowEntry>();
float queryRadius = sphereRadius + movement.Length() + 5f;
// 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 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
// CObjCell::find_cell_list indoor/outdoor branch
// (acclient_2013_pseudo_c.txt:308751-308769) — indoor cells only
// iterate their own shadow lists + portal-visible neighbors, never
// outdoor cells' shadow lists. Closes the cottage cellar-up cap.
//
// M1.5 / Phase U (2026-05-31): pass isViewer so the camera probe
// (ObjectInfoState.IsViewer) bypasses the indoor gate and reaches the
// landblock-baked cottage exterior-shell GfxObj (registered cellScope=0).
// Retail's SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761)
// bounds the viewer by the cell enclosure; in acdream's data model that
// enclosure is the exterior shell GfxObj. The #98 gate stays in force for
// all non-viewer (IsPlayer, NPC, etc.) sweeps. ObjectInfo.IsViewer is
// TransitionTypes.cs:75, derived from ObjectInfoState.IsViewer (0x004).
engine.ShadowObjects.GetNearbyObjects(
currPos, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
nearbyObjs,
portalReachableCells,
primaryCellId: sp.CheckCellId,
isViewer: oi.IsViewer);
// Snapshot the LIVE per-cell list: a nested step-up
// (DoStepUp → TransitionalInsert → this) must not observe
// registry mutations through the same reference mid-iteration.
var nearbyObjs = new List<ShadowEntry>(objsInCell);
foreach (var obj in nearbyObjs)
{
@ -2678,6 +2767,113 @@ public sealed class Transition
return TransitionState.OK;
}
/// <summary>
/// BR-7 / A6.P4 (2026-06-11). The retail BUILDING collision channel —
/// <c>CSortCell::find_collisions</c> (Ghidra 0x005340a0): an outdoor
/// LandCell holds at most ONE building reference (set at its origin
/// cell by <c>CLandBlock::init_buildings</c> 0x0052fd80 →
/// <c>CBuildingObj::add_to_cell</c> 0x006b5550); when present,
/// <c>CBuildingObj::find_building_collisions</c> (0x006b5300) runs ONE
/// BSP test against the shell model's part 0 with
/// <c>sphere_path.bldg_check = 1</c> around it, and sets
/// <c>collided_with_environment</c> on a non-OK result for non-Contact
/// movers. Building shells are NOT shadow objects in retail (the only
/// find_building_collisions caller is 0x005340aa) — indoor cells
/// structurally cannot collide with a shell, which is what makes the
/// b3ce505 #98 gate removable rather than relocated.
///
/// <para>
/// The <see cref="SpherePath.BldgCheck"/> +
/// <see cref="SpherePath.HitsInteriorCell"/> pair weakens the
/// placement/ethereal solid test (center_solid=0) in BSPQuery Path 1 —
/// retail mutes shell-solid containment for spheres engaged with
/// interior cells (BSPTREE::find_collisions 0x0053a82e /
/// placement_insert 0x005399d8) so doorway crossings don't hard-fail
/// against the shell.
/// </para>
/// </summary>
private TransitionState FindBuildingCollisions(PhysicsEngine engine, uint cellId)
{
// The building channel hangs off CLandCell only (CEnvCell::
// find_collisions, 0x0052c100, has no building leg).
if ((cellId & 0xFFFFu) >= 0x0100u) return TransitionState.OK;
if (engine.DataCache is null) return TransitionState.OK;
var building = engine.DataCache.GetBuilding(cellId);
if (building is null || building.ModelId == 0u) return TransitionState.OK;
// ModelId is the PRE-RESOLVED shell part-0 GfxObj id (the
// CacheBuilding site resolves 0x02 Setup models to their first part
// — retail tests part_array->parts[0] only, 0x006b5320).
var physics = engine.DataCache.GetGfxObj(building.ModelId);
if (physics?.BSP?.Root is null) return TransitionState.OK;
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
if (!Matrix4x4.Decompose(building.WorldTransform, out _,
out Quaternion bldRotation, out Vector3 bldOrigin))
{
bldRotation = Quaternion.Identity;
bldOrigin = building.WorldTransform.Translation;
}
var invRot = Quaternion.Inverse(bldRotation);
var localSphere0 = new DatReaderWriter.Types.Sphere
{
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - bldOrigin, invRot),
Radius = sp.GlobalSphere[0].Radius,
};
DatReaderWriter.Types.Sphere? localSphere1 = null;
if (sp.NumSphere > 1)
{
localSphere1 = new DatReaderWriter.Types.Sphere
{
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - bldOrigin, invRot),
Radius = sp.GlobalSphere[1].Radius,
};
}
var localCurrCenter = Vector3.Transform(
sp.GlobalCurrCenter[0].Origin - bldOrigin, invRot);
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
// bldg_check set/cleared around the part test (0x006b5311/0x006b5328).
sp.BldgCheck = true;
TransitionState result;
try
{
result = BSPQuery.FindCollisions(
physics.BSP.Root,
physics.Resolved,
this,
localSphere0,
localSphere1,
localCurrCenter,
localSpaceZ,
1.0f, // buildings are unscaled
bldRotation,
engine,
worldOrigin: bldOrigin);
}
finally
{
sp.BldgCheck = false;
}
if (PhysicsDiagnostics.ProbeBuildingEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[bldg-channel] cell=0x{cellId:X8} model=0x{building.ModelId:X8} wpos=({sp.GlobalSphere[0].Origin.X:F3},{sp.GlobalSphere[0].Origin.Y:F3},{sp.GlobalSphere[0].Origin.Z:F3}) hitsInterior={sp.HitsInteriorCell} result={result}"));
}
// 0x006b5338: non-OK + non-Contact mover → environment attribution.
if (result != TransitionState.OK && !oi.Contact)
ci.CollidedWithEnvironment = true;
return result;
}
/// <summary>
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs,
/// door foot-colliders). For Contact-grounded movers, attempts to step over short