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:
parent
abf36e2743
commit
dbfbf8506c
15 changed files with 1109 additions and 856 deletions
|
|
@ -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->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 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.
|
||||
/// 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->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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue