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
|
|
@ -3833,6 +3833,48 @@ retail's viewer-distance smoothing (update_viewer region) before touching.
|
|||
|
||||
---
|
||||
|
||||
## #116 — Slide-response divergence family: near-perpendicular lateral slide lost + first-airborne-frame in-frame slide vs hard stop
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW-MEDIUM (over-blocking, never under-blocking — no
|
||||
walk-throughs; feel-level divergence at walls/doors)
|
||||
**Filed:** 2026-06-11 (BR-7 / A6.P4 ship session)
|
||||
**Component:** physics (slide response — `SlideSphere` degenerate-offset
|
||||
guard + first-contact-frame behavior)
|
||||
|
||||
**Two pinned shapes, both pre-dating BR-7 (the per-cell shadow port left
|
||||
them byte-identical):**
|
||||
|
||||
1. **Tick-22760 lateral-slide loss** (door capture, 2026-05-24): live
|
||||
blocked the southward push at the cottage door face and KEPT the tiny
|
||||
lateral component (X −0.0357, cn=(0,+1,0)); the harness hard-stops both
|
||||
components (cn=(0,0,1) from the post-stop ground refresh). The movement
|
||||
is near-perpendicular to the face, so the projected slide offset is
|
||||
tiny and the degenerate-offset guard converts it to a full stop.
|
||||
Repro: `DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals`
|
||||
(door found + BSP-only dispatched correctly — `[bsp-test]` /
|
||||
`[cyl-skip-bsp]` probes prove the cell-set layer is innocent).
|
||||
`LiveCompare_DoorBlocksFromOutside_Tick22760` now pins the blocking
|
||||
invariant only.
|
||||
|
||||
2. **D4 first-airborne-frame slide** (`BSPStepUpTests.D4_*`, skipped with
|
||||
this issue id): the L.2c pin expects the first airborne wall frame to
|
||||
hard-stop (Z stays 2.0) with the slide starting frame 2 off the cached
|
||||
sliding normal; since the P1-era `slide_sphere` work the engine slides
|
||||
in-frame (Z reaches the 1.92 target on frame 1). Retail's cached-normal
|
||||
mechanism (`CPhysicsObj::get_object_info` pc:279992, transient bit 4 →
|
||||
`init_sliding_normal`) only governs the NEXT frame — whether retail's
|
||||
first-frame response is hard-stop or in-frame slide needs a focused
|
||||
oracle read (`collide_with_environment` / `slide_sphere` first-contact
|
||||
path) before either the engine or the pin is declared wrong.
|
||||
|
||||
**Fix shape:** one oracle-driven pass over the slide response
|
||||
(`SlideSphere` + first-contact frame), with the 22760 capture and the D4
|
||||
fixture as the acceptance pair. Do NOT patch the degenerate-offset guard
|
||||
ad hoc — the DO-NOT-RETRY table's slide entries (physics digest) apply.
|
||||
|
||||
---
|
||||
|
||||
# Recently closed
|
||||
|
||||
## #113 — Phantom staircase: REOPENED 2026-06-11, folded into the HOLISTIC BUILDING-RENDER PORT
|
||||
|
|
|
|||
|
|
@ -3268,7 +3268,16 @@ public sealed class GameWindow : IDisposable
|
|||
flags: flags,
|
||||
worldOffsetX: origin.X,
|
||||
worldOffsetY: origin.Y,
|
||||
landblockId: spawn.Position.Value.LandblockId);
|
||||
landblockId: spawn.Position.Value.LandblockId,
|
||||
// BR-7 / A6.P4 (2026-06-11): the server position's full cell id
|
||||
// is the registration flood seed (retail m_position.objcell_id
|
||||
// into CObjCell::find_cell_list). A door whose spheres straddle
|
||||
// the doorway lands in BOTH the outdoor landcell and the
|
||||
// vestibule's shadow list at registration — the architectural
|
||||
// close of #99. Dynamic objects use calc_cross_cells (no
|
||||
// do_not_load prune), hence isStatic: false.
|
||||
seedCellId: spawn.Position.Value.LandblockId,
|
||||
isStatic: false);
|
||||
|
||||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||||
// Per-shape detail appears in [resolve-bldg] when collisions fire;
|
||||
|
|
@ -4487,8 +4496,11 @@ public sealed class GameWindow : IDisposable
|
|||
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
|
||||
if (update.Guid != _playerServerGuid)
|
||||
{
|
||||
// BR-7: the wire position's full cell id seeds the re-flood
|
||||
// (retail SetPosition → calc_cross_cells from m_position).
|
||||
_physicsEngine.ShadowObjects.UpdatePosition(
|
||||
entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId);
|
||||
entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId,
|
||||
seedCellId: p.LandblockId);
|
||||
}
|
||||
|
||||
// Track remote-entity motion for stop detection. Only record the
|
||||
|
|
@ -5993,7 +6005,25 @@ public sealed class GameWindow : IDisposable
|
|||
building.Frame.Origin.X, building.Frame.Origin.Y);
|
||||
uint landcellId = lbPrefix | landcellLow;
|
||||
|
||||
_physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform);
|
||||
// BR-7 / A6.P4 (2026-06-11): the shell part-0 GfxObj id
|
||||
// arms the retail building collision channel
|
||||
// (CBuildingObj::find_building_collisions, Ghidra
|
||||
// 0x006b5300 — one BSP test on part_array->parts[0]).
|
||||
// 0x01 model ids are the GfxObj; 0x02 Setups resolve to
|
||||
// their first part here, where the dat is at hand.
|
||||
uint shellPart0 = building.ModelId;
|
||||
if ((shellPart0 & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
// _dats is non-null on every streaming-drain path
|
||||
// (the GfxObj physics cache loop below dereferences
|
||||
// it unconditionally).
|
||||
var bldSetup = _dats!.Get<DatReaderWriter.DBObjs.Setup>(building.ModelId);
|
||||
shellPart0 = bldSetup is not null && bldSetup.Parts.Count > 0
|
||||
? bldSetup.Parts[0]
|
||||
: 0u;
|
||||
}
|
||||
_physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform,
|
||||
modelId: shellPart0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6143,6 +6173,16 @@ public sealed class GameWindow : IDisposable
|
|||
uint partIndex = 0;
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
// BR-7 / A6.P4 (2026-06-11): building SHELLS are not shadow
|
||||
// objects in retail — their collision is the per-LandCell
|
||||
// building channel (CSortCell::find_collisions, Ghidra
|
||||
// 0x005340a0, dispatched off the CacheBuilding entry at the
|
||||
// building's origin landcell). Registering them here is what
|
||||
// put the cottage GfxObj into the radial sweep's reach from
|
||||
// the cellar (#98). Interior statics + outdoor stab objects
|
||||
// (LandBlockInfo.Objects) remain shadow objects.
|
||||
if (entity.IsBuildingShell) break;
|
||||
|
||||
var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
|
||||
if (partCached?.BSP?.Root is null) { partIndex++; continue; }
|
||||
|
||||
|
|
@ -6182,7 +6222,7 @@ public sealed class GameWindow : IDisposable
|
|||
origin.X, origin.Y, lb.LandblockId,
|
||||
AcDream.Core.Physics.ShadowCollisionType.BSP, 0f,
|
||||
partScale,
|
||||
cellScope: entity.ParentCellId ?? 0u);
|
||||
seedCellId: entity.ParentCellId ?? 0u);
|
||||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||||
// partCached?.BSP?.Root non-null was checked above (else `continue`),
|
||||
// so hasPhys=true on this path.
|
||||
|
|
@ -6252,7 +6292,7 @@ public sealed class GameWindow : IDisposable
|
|||
entity.Rotation, cylRadius,
|
||||
origin.X, origin.Y, lb.LandblockId,
|
||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight,
|
||||
cellScope: entity.ParentCellId ?? 0u);
|
||||
seedCellId: entity.ParentCellId ?? 0u);
|
||||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||||
// state/flags literals: landblock-baked scenery; no server PhysicsState.
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
|
|
@ -6288,7 +6328,7 @@ public sealed class GameWindow : IDisposable
|
|||
entity.Rotation, sphRadius,
|
||||
origin.X, origin.Y, lb.LandblockId,
|
||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight,
|
||||
cellScope: entity.ParentCellId ?? 0u);
|
||||
seedCellId: entity.ParentCellId ?? 0u);
|
||||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||||
// state/flags literals: landblock-baked scenery; no server PhysicsState.
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
|
|
@ -6312,7 +6352,7 @@ public sealed class GameWindow : IDisposable
|
|||
entity.Position, entity.Rotation, fr,
|
||||
origin.X, origin.Y, lb.LandblockId,
|
||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh,
|
||||
cellScope: entity.ParentCellId ?? 0u);
|
||||
seedCellId: entity.ParentCellId ?? 0u);
|
||||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||||
// state/flags literals: landblock-baked scenery; no server PhysicsState.
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
|
|
@ -6512,7 +6552,7 @@ public sealed class GameWindow : IDisposable
|
|||
baseCenter, entity.Rotation, cylRadius,
|
||||
origin.X, origin.Y, lb.LandblockId,
|
||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight,
|
||||
cellScope: entity.ParentCellId ?? 0u);
|
||||
seedCellId: entity.ParentCellId ?? 0u);
|
||||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||||
// state/flags literals: landblock-baked scenery; no server PhysicsState.
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
|
|
@ -6568,6 +6608,15 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
|
||||
// BR-7 / A6.P4 (2026-06-11): this landblock's cells + buildings are
|
||||
// now hydrated — re-run the registration flood for entities whose
|
||||
// shadow cell set touches it. Covers the streaming races in both
|
||||
// directions (server spawn before the landblock hydrated → flood
|
||||
// couldn't traverse; cells hydrated after the spawn). Retail
|
||||
// equivalent: CObjCell::init_objects → recalc_cross_cells on cell
|
||||
// load (Ghidra 0x0052b420 / 0x00515a30).
|
||||
_physicsEngine.ShadowObjects.RefloodLandblock(lb.LandblockId);
|
||||
|
||||
// Register each stab as a plugin snapshot so the plugin host has
|
||||
// visibility into the streaming world state.
|
||||
foreach (var entity in lb.Entities)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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->parts[0]</c>).
|
||||
/// 0 = unknown (legacy cache entries / tests) — the channel is inert.
|
||||
/// </summary>
|
||||
public uint ModelId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,363 +10,144 @@ using Xunit;
|
|||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Phase U — RED diagnostic test for the camera-collision indoor non-engagement bug.
|
||||
/// Camera (viewer) sweep vs the BUILDING collision channel.
|
||||
///
|
||||
/// <para>
|
||||
/// Root cause (b): when the camera sphere is in an indoor cell, <see cref="ShadowObjectRegistry.GetNearbyObjects"/>
|
||||
/// returns early at line 480 (<c>if ((primaryCellId & 0xFFFF) >= 0x0100) return;</c>),
|
||||
/// skipping the outdoor radial sweep. The cottage exterior-shell GfxObj is registered
|
||||
/// with <c>cellScope=0</c> (landblock-wide, outdoor) — it lives in the outdoor per-cell
|
||||
/// shadow lists. With the indoor-primary gate active, the camera sweep (which uses
|
||||
/// <see cref="ObjectInfoState.IsViewer"/> not <see cref="ObjectInfoState.IsPlayer"/>) never
|
||||
/// finds the exterior shell while its sphere center is inside the indoor CellBSP volume.
|
||||
/// Once the sphere center exits the CellBSP boundary (<see cref="PhysicsEngine.ResolveCellId"/>
|
||||
/// falls through to outdoor), the outdoor sweep runs — but by then the sphere may have
|
||||
/// already crossed the exterior wall polygon's front face (going in the same direction).
|
||||
/// History: this file was born as the Phase U RED diagnostic for the
|
||||
/// camera-collision indoor non-engagement bug — the b3ce505 #98 indoor gate
|
||||
/// in the old radial <c>GetNearbyObjects</c> blocked the viewer sweep from
|
||||
/// reaching the exterior-shell GfxObj, and the fix was an
|
||||
/// <c>isViewer</c> exemption. BR-7 / A6.P4 (2026-06-11) deleted that whole
|
||||
/// stack: building shells are no longer shadow objects at all — they
|
||||
/// dispatch through the retail per-LandCell building channel
|
||||
/// (<c>CSortCell::find_collisions</c> → <c>find_building_collisions</c>,
|
||||
/// Ghidra 0x005340a0/0x006b5300), and the indoor camera is bounded by the
|
||||
/// interior cell's own physics BSP (the env channel — retail's actual
|
||||
/// viewer enclosure; pinned against real dat by
|
||||
/// <c>CameraCornerSealReplayTests</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Evidence from post-fix live capture (<c>u4c-fix.log</c>): <c>eyeInRoot=n</c> ~90%
|
||||
/// of frames; eye-player distance mean 3.43 m (full/zoomed chase, NOT pulled in).
|
||||
/// The <c>[flap-sweep]</c> diagnostic in <see cref="PhysicsCameraCollisionProbe.SweepEye"/>
|
||||
/// was designed to confirm this: <c>bsp=ok pulledIn≈0</c> means the cell is loaded with
|
||||
/// a valid BSP but the sweep returns full eye distance, confirming the exterior shell is
|
||||
/// not reached from the indoor context.
|
||||
/// What remains here is the end-to-end pin of the NEW channel: a viewer
|
||||
/// sweep whose primary cell is an outdoor landcell with a cached building
|
||||
/// must be stopped by the building's shell BSP.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The issue #98 fix (2026-05-24) deliberately gates the outdoor sweep when the primary
|
||||
/// cell is indoor — this is CORRECT for the player (prevents the cottage floor from
|
||||
/// capping the player's head sphere). But it is WRONG for the camera probe
|
||||
/// (<see cref="ObjectInfoState.IsViewer"/>), which needs to find the exterior building
|
||||
/// shell to implement retail's <c>SmartBox::update_viewer</c> spring-arm pull-in.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Fixture gap: the actual residual cells (0xA9B40174/0175, main-floor cottage) are not
|
||||
/// in the fixture set (the issue-98 fixtures cover 0xA9B4014X, a different cellar
|
||||
/// cottage). This test uses a fully synthetic setup to prove the mechanism identically —
|
||||
/// the issue #98 gate fires on any indoor primary cell id.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Diagnosis doc: <c>docs/research/2026-05-31-camera-collision-indoor-diagnosis.md</c>.</para>
|
||||
/// </summary>
|
||||
public class CameraCollisionIndoorTests
|
||||
{
|
||||
// ── Geometry constants ─────────────────────────────────────────────────
|
||||
// Room interior: player at world (0, 1, 94). Pivot = (0, 1, 95.5).
|
||||
// Camera sweeps backward (+Y) and slightly upward.
|
||||
// The EXTERIOR WALL GfxObj is at Y = 4.0 (just outside the room's back boundary
|
||||
// at Y = 3.5). The interior CellBSP covers Y ∈ [-2, 3.5].
|
||||
//
|
||||
// Desired eye: Y = 5.0 — past the exterior wall.
|
||||
//
|
||||
// Expected: sweep stops at the exterior wall (pulledIn ≥ MinExpectedPullIn = 0.5 m).
|
||||
// Actual: sweep reaches Y = 5.0 (pulledIn ≈ 0) because GetNearbyObjects skips the
|
||||
// outdoor sweep when primaryCellId is indoor, so the GfxObj exterior wall is not
|
||||
// tested while the sphere is inside the CellBSP volume. After the sphere crosses the
|
||||
// CellBSP boundary (Y > 3.5 + ~0.3 = 3.8), ResolveCellId returns an outdoor cell
|
||||
// and the outdoor sweep IS run — but the exterior wall is at Y = 4.0 and the sphere
|
||||
// center is approaching from Y = 3.8 toward +Y, so the exterior wall polygon (with
|
||||
// inward normal = -Y) is hit from its BACK FACE. If the wall polygon is one-sided
|
||||
// (CullMode.Clockwise from the outer face), the back-face hit is suppressed and the
|
||||
// sphere passes through. The net result is no stop.
|
||||
// Player outdoors at (0, 1, 94). Pivot = (0, 1, 95.5). Camera sweeps
|
||||
// backward (+Y) and slightly upward toward the building wall at Y=4.0.
|
||||
|
||||
private const uint IndoorCellId = 0xA9B40175u; // low 16 bits 0x0175 ≥ 0x0100 → indoor
|
||||
private const uint LandblockId = 0xA9B40000u;
|
||||
private const uint OutdoorCellId = 0xA9B40001u; // landcell (0,0)
|
||||
private const uint LandblockId = 0xA9B40000u;
|
||||
|
||||
// Player head-pivot in world space.
|
||||
private static readonly Vector3 PivotWorld = new(0f, 1f, 95.5f);
|
||||
private static readonly Vector3 PivotWorld = new(0f, 1f, 95.5f);
|
||||
|
||||
// Desired eye: backward and slightly above pivot.
|
||||
// Goes from Y=1 to Y=5, passing through the exterior wall at Y=4.0.
|
||||
private static readonly Vector3 DesiredEye = new(0f, 5f, 96.25f);
|
||||
// Desired eye: backward past the building wall at Y=4.0.
|
||||
private static readonly Vector3 DesiredEye = new(0f, 5f, 96.25f);
|
||||
|
||||
// Exterior wall GfxObj position: at Y=4.0, normal facing INTO the room (-Y).
|
||||
// When seen from outside, the front face has +Y normal (outward). When seen
|
||||
// from inside (camera going toward +Y), the facing side is the back face.
|
||||
// The wall is registered with cellScope=0 (landblock-wide, outdoor shadow list).
|
||||
private const float ExteriorWallY = 4.0f;
|
||||
private const float BuildingWallY = 4.0f;
|
||||
|
||||
// The sphere should be stopped at approximately Y = ExteriorWallY - ViewerSphereRadius.
|
||||
// Pulled-in distance ≥ MinExpectedPullIn.
|
||||
// The sphere should be stopped near Y = BuildingWallY - viewerRadius;
|
||||
// anything ≥ this margin proves the channel engaged.
|
||||
private const float MinExpectedPullIn = 0.5f;
|
||||
|
||||
// CellBSP inner boundary: sphere is considered "inside" the cell when Y ≤ 3.5.
|
||||
// Once the sphere center crosses Y = 3.5 + (radius + 0.01) ≈ 3.81, ResolveCellId
|
||||
// will classify it as outdoor.
|
||||
private const float CellBspBoundaryY = 3.5f;
|
||||
|
||||
// ── Test ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Documents the fix for the camera-collision indoor non-engagement bug (cause b).
|
||||
///
|
||||
/// <para>
|
||||
/// Setup: indoor cell <c>0xA9B40175</c> with a CellBSP boundary at Y=3.5 and no
|
||||
/// solid physics wall at that boundary (the room opens toward +Y, representing the
|
||||
/// cottage front wall / portal). A landblock-baked exterior-shell GfxObj is registered
|
||||
/// at Y=4.0 with <c>cellScope=0</c> (outdoor shadow list, NOT in the indoor cell's
|
||||
/// portal-reachable set).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The fix: <see cref="ShadowObjectRegistry.GetNearbyObjects"/> now accepts an
|
||||
/// <c>isViewer</c> parameter (default <c>false</c>). When <c>isViewer=true</c>, the
|
||||
/// issue-#98 indoor gate is bypassed so the camera probe can reach the exterior-shell
|
||||
/// GfxObj. <see cref="Transition.FindObjCollisions"/> passes <c>oi.IsViewer</c> (i.e.
|
||||
/// <c>ObjectInfo.IsViewer</c> at TransitionTypes.cs:75) at the <c>GetNearbyObjects</c>
|
||||
/// call site. The #98 gate remains active for all non-viewer (player, NPC) sweeps.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail faithfulness: <c>SmartBox::update_viewer</c> at
|
||||
/// <c>acclient_2013_pseudo_c.txt:92761</c> bounds the viewer via the player-cell
|
||||
/// enclosure; retail's interior EnvCells are self-enclosing. In acdream's data model
|
||||
/// the enclosure is the exterior-shell GfxObj (issue #98). Retail's
|
||||
/// <c>find_obj_collisions</c> at <c>:308918</c> has no indoor gate — so exempting
|
||||
/// <c>IsViewer</c> is the faithful analog.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>This test PASSES with the fix, FAILS without it.</para>
|
||||
/// BR-7 / A6.P4: the viewer sweep is stopped by the building channel —
|
||||
/// <c>Transition.FindBuildingCollisions</c> runs the shell part-0 BSP
|
||||
/// for the outdoor primary cell holding the building reference, exactly
|
||||
/// like retail <c>CLandCell::find_collisions</c> →
|
||||
/// <c>CSortCell::find_collisions</c> (Ghidra 0x00532d60/0x005340a0).
|
||||
/// The shell is NOT in the ShadowObjectRegistry (production skips
|
||||
/// <c>IsBuildingShell</c> entities); the stop can only come from the
|
||||
/// channel.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SweepEye_IndoorCellExteriorGfxObjWall_StoppedByExteriorShell_AfterViewerGateExemption()
|
||||
public void SweepEye_OutdoorCell_StoppedByBuildingChannelShell()
|
||||
{
|
||||
var (engine, _) = BuildEngineWithSyntheticRoom();
|
||||
var (engine, _) = BuildEngineWithBuilding();
|
||||
var probe = new PhysicsCameraCollisionProbe(engine);
|
||||
|
||||
var stoppedEye = probe.SweepEye(
|
||||
pivot: PivotWorld,
|
||||
desiredEye: DesiredEye,
|
||||
cellId: IndoorCellId,
|
||||
cellId: OutdoorCellId,
|
||||
selfEntityId: 0u,
|
||||
playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).Eye;
|
||||
|
||||
// The eye should be stopped before the exterior wall at Y=4.0.
|
||||
// Expected stopped eye Y ≈ 4.0 - ViewerSphereRadius = 3.7.
|
||||
// Pulled-in = |DesiredEye.Y - stoppedEye.Y| should be ≥ 0.5 m.
|
||||
float pulledIn = MathF.Abs(DesiredEye.Y - stoppedEye.Y);
|
||||
|
||||
Assert.True(
|
||||
pulledIn >= MinExpectedPullIn,
|
||||
$"Camera sweep should be stopped by the exterior-shell GfxObj wall at " +
|
||||
$"Y={ExteriorWallY:F1} (registered outdoor/landblock-wide, cellScope=0). " +
|
||||
$"Camera sweep should be stopped by the building-channel shell BSP at " +
|
||||
$"Y={BuildingWallY:F1} (cache.GetBuilding({OutdoorCellId:X8}) with ModelId set). " +
|
||||
$"Actual pulled-in: {pulledIn:F4} m (stopped eye Y={stoppedEye.Y:F4}). " +
|
||||
$"REGRESSION: ShadowObjectRegistry.GetNearbyObjects indoor gate is incorrectly " +
|
||||
$"blocking the IsViewer (camera) sweep from reaching the exterior-shell GfxObj. " +
|
||||
$"Fix: pass isViewer=true at the FindObjCollisions call site so the indoor gate " +
|
||||
$"is bypassed for camera sweeps (ShadowObjectRegistry.cs, TransitionTypes.cs:~2307).");
|
||||
}
|
||||
|
||||
// ── Issue #98 regression guard ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard: the issue-#98 indoor gate must remain active for non-viewer sweeps.
|
||||
///
|
||||
/// <para>
|
||||
/// A GfxObj registered with <c>cellScope=0</c> (outdoor shadow list) must NOT be returned
|
||||
/// by <see cref="ShadowObjectRegistry.GetNearbyObjects"/> when the primary cell is indoor
|
||||
/// and <c>isViewer=false</c> (i.e. the default player / NPC path). This is the protection
|
||||
/// that prevents the cottage-floor polygon from capping the player's head sphere while the
|
||||
/// player is in the cellar directly below.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Issue #98 fix (2026-05-24): gate fires at <c>ShadowObjectRegistry.cs:~480</c> when
|
||||
/// <c>(primaryCellId & 0xFFFF) >= 0x0100</c> AND <c>isViewer=false</c>. This test
|
||||
/// ensures the guard cannot regress.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj()
|
||||
{
|
||||
var registry = new ShadowObjectRegistry();
|
||||
|
||||
// Register a GfxObj at cellScope=0 (landblock-wide / outdoor shadow list).
|
||||
const uint EntityId = 0x00010001u;
|
||||
const uint GfxId = 0x01000001u;
|
||||
const uint LbId = 0xA9B40000u;
|
||||
var pos = new Vector3(0f, 4f, 96f);
|
||||
registry.Register(
|
||||
entityId: EntityId,
|
||||
gfxObjId: GfxId,
|
||||
worldPos: pos,
|
||||
rotation: System.Numerics.Quaternion.Identity,
|
||||
radius: 10f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: LbId,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1.0f,
|
||||
cellScope: 0u); // outdoor, landblock-wide
|
||||
|
||||
var results = new List<ShadowEntry>();
|
||||
|
||||
// Non-viewer query with indoor primary cell — gate must fire, GfxObj NOT returned.
|
||||
registry.GetNearbyObjects(
|
||||
worldPos: pos,
|
||||
queryRadius: 20f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: LbId,
|
||||
results: results,
|
||||
portalReachableCells: null,
|
||||
primaryCellId: 0xA9B40175u, // indoor cell (low 16 bits 0x0175 >= 0x0100)
|
||||
isViewer: false);
|
||||
|
||||
Assert.Empty(results); // Issue #98 gate must block the outdoor GfxObj for non-viewer sweeps.
|
||||
$"REGRESSION: Transition.FindBuildingCollisions (retail " +
|
||||
$"CBuildingObj::find_building_collisions, Ghidra 0x006b5300) is not engaging " +
|
||||
$"for the viewer's outdoor primary cell.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard: the viewer exemption allows the camera to reach outdoor GfxObjs
|
||||
/// registered at <c>cellScope=0</c> even when the primary cell is indoor.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the dual of <see cref="GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj"/>:
|
||||
/// the same GfxObj / same indoor primary cell, but <c>isViewer=true</c>.
|
||||
/// The outdoor sweep must run and return the GfxObj.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail faithfulness: <c>SmartBox::update_viewer</c> (acclient_2013_pseudo_c.txt:92761)
|
||||
/// calls <c>find_obj_collisions</c> (:308918) which has no indoor-cell gate — the viewer
|
||||
/// reaches any geometry in the player's cell enclosure. The #98 gate is an acdream-specific
|
||||
/// workaround that must not apply to the viewer.
|
||||
/// </para>
|
||||
/// Channel inertness guard: a building cached WITHOUT a model id
|
||||
/// (legacy entries, portal-transit-only callers) must not collide —
|
||||
/// the sweep reaches the desired eye.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetNearbyObjects_IndoorPrimaryCell_IsViewer_DoesReturnOutdoorGfxObj()
|
||||
public void SweepEye_BuildingWithoutModelId_ChannelInert()
|
||||
{
|
||||
var registry = new ShadowObjectRegistry();
|
||||
var (engine, cache) = BuildEngineWithBuilding();
|
||||
|
||||
// Same outdoor-scope GfxObj as the non-viewer test above.
|
||||
const uint EntityId = 0x00010002u;
|
||||
const uint GfxId = 0x01000002u;
|
||||
const uint LbId = 0xA9B40000u;
|
||||
var pos = new Vector3(0f, 4f, 96f);
|
||||
registry.Register(
|
||||
entityId: EntityId,
|
||||
gfxObjId: GfxId,
|
||||
worldPos: pos,
|
||||
rotation: System.Numerics.Quaternion.Identity,
|
||||
radius: 10f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: LbId,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1.0f,
|
||||
cellScope: 0u); // outdoor, landblock-wide
|
||||
// Replace the building entry with a model-less one.
|
||||
cache.RegisterBuildingForTest(OutdoorCellId, new BuildingPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f),
|
||||
InverseWorldTransform = InvertOrIdentity(Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f)),
|
||||
Portals = Array.Empty<BldPortalInfo>(),
|
||||
ModelId = 0u,
|
||||
});
|
||||
|
||||
var results = new List<ShadowEntry>();
|
||||
var probe = new PhysicsCameraCollisionProbe(engine);
|
||||
var stoppedEye = probe.SweepEye(
|
||||
pivot: PivotWorld,
|
||||
desiredEye: DesiredEye,
|
||||
cellId: OutdoorCellId,
|
||||
selfEntityId: 0u,
|
||||
playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).Eye;
|
||||
|
||||
// Viewer query with indoor primary cell — gate must be bypassed, GfxObj IS returned.
|
||||
registry.GetNearbyObjects(
|
||||
worldPos: pos,
|
||||
queryRadius: 20f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: LbId,
|
||||
results: results,
|
||||
portalReachableCells: null,
|
||||
primaryCellId: 0xA9B40175u, // indoor cell (low 16 bits 0x0175 >= 0x0100)
|
||||
isViewer: true);
|
||||
|
||||
Assert.NotEmpty(results); // Viewer must bypass the indoor gate and find the exterior GfxObj.
|
||||
Assert.Equal(EntityId, results[0].EntityId);
|
||||
Assert.True(MathF.Abs(DesiredEye.Y - stoppedEye.Y) < 0.05f,
|
||||
$"Model-less building entries must keep the channel inert; eye stopped at " +
|
||||
$"Y={stoppedEye.Y:F4} instead of reaching {DesiredEye.Y:F4}.");
|
||||
}
|
||||
|
||||
// ── Engine + fixture builder ──────────────────────────────────────────
|
||||
|
||||
private static Matrix4x4 InvertOrIdentity(Matrix4x4 m)
|
||||
=> Matrix4x4.Invert(m, out var inv) ? inv : Matrix4x4.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal <see cref="PhysicsEngine"/> with:
|
||||
/// Minimal engine with ONE outdoor landcell building:
|
||||
/// <list type="bullet">
|
||||
/// <item>One synthetic indoor cell (<see cref="IndoorCellId"/>), identity world transform.
|
||||
/// CellBSP boundary at Y=<see cref="CellBspBoundaryY"/>.
|
||||
/// PhysicsBSP is an empty leaf (no interior wall polygons at the target side —
|
||||
/// represents an open portal/doorway toward +Y).</item>
|
||||
/// <item>One exterior-shell GfxObj registered with <c>cellScope=0</c>
|
||||
/// (landblock-wide, outdoor shadow list). The GfxObj has a wall polygon
|
||||
/// at Y=<see cref="ExteriorWallY"/>, representing the cottage exterior shell
|
||||
/// that retail's camera spring-arm should stop on.</item>
|
||||
/// <item>A stub landblock with terrain far below (Z=-1000) to prevent outdoor
|
||||
/// terrain collision from interfering.</item>
|
||||
/// <item>Shell GfxObj: a single two-sided wall polygon at local Y=0,
|
||||
/// anchored by the building transform at world Y=<see cref="BuildingWallY"/>.</item>
|
||||
/// <item><see cref="PhysicsDataCache.RegisterBuildingForTest"/> at
|
||||
/// <see cref="OutdoorCellId"/> with <c>ModelId</c> set — the
|
||||
/// per-LandCell building reference (retail CSortCell.building,
|
||||
/// set once at the origin cell by CLandBlock::init_buildings,
|
||||
/// Ghidra 0x0052fd80).</item>
|
||||
/// <item>Stub terrain far below so terrain collision never interferes.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// This fixture directly reproduces the production gap: the issue-#98 fix
|
||||
/// (<see cref="ShadowObjectRegistry.GetNearbyObjects"/> early-return at line 480)
|
||||
/// correctly prevents indoor spheres (the PLAYER) from being capped by the landblock-baked
|
||||
/// cottage floor. But it also prevents the camera sphere (<see cref="ObjectInfoState.IsViewer"/>)
|
||||
/// from seeing the exterior shell GfxObj — the same fix that closes issue #98 is what
|
||||
/// breaks camera-collision indoors.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static (PhysicsEngine engine, PhysicsDataCache cache)
|
||||
BuildEngineWithSyntheticRoom()
|
||||
BuildEngineWithBuilding()
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
|
||||
// ── 1. Indoor cell with open-toward-+Y boundary ────────────────────
|
||||
// PhysicsBSP: empty leaf — no walls on the +Y side. This represents
|
||||
// a room that has a portal (doorway / open passage) toward +Y.
|
||||
// The exterior shell is NOT part of any indoor cell's BSP.
|
||||
var emptyLeaf = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 20f },
|
||||
};
|
||||
var emptyBsp = new PhysicsBSPTree { Root = emptyLeaf };
|
||||
|
||||
// CellBSP: splitting plane at Y = CellBspBoundaryY with normal = -Y
|
||||
// (interior is at Y ≤ CellBspBoundaryY).
|
||||
// SphereIntersectsCellBsp returns false when:
|
||||
// dist = dot(-Y, center) + CellBspBoundaryY = CellBspBoundaryY - center.Y
|
||||
// < -(radius + 0.01f)
|
||||
// i.e. center.Y > CellBspBoundaryY + radius + 0.01
|
||||
// For radius=0.3: center.Y > 3.5 + 0.31 = 3.81.
|
||||
var cellBspLeaf = new CellBSPNode { Type = BSPNodeType.Leaf };
|
||||
var cellBspRoot = new CellBSPNode
|
||||
{
|
||||
SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), CellBspBoundaryY),
|
||||
PosNode = cellBspLeaf,
|
||||
};
|
||||
|
||||
var indoorCell = new CellPhysics
|
||||
{
|
||||
BSP = emptyBsp,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(), // no interior walls toward +Y
|
||||
CellBSP = new CellBSPTree { Root = cellBspRoot },
|
||||
Portals = Array.Empty<PortalInfo>(),
|
||||
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
VisibleCellIds = new System.Collections.Generic.HashSet<uint>(),
|
||||
};
|
||||
cache.RegisterCellStructForTest(IndoorCellId, indoorCell);
|
||||
|
||||
// ── 2. Exterior shell GfxObj registered OUTDOORS (cellScope=0) ─────
|
||||
// This is the landblock-baked cottage exterior shell. The wall polygon
|
||||
// at Y=ExteriorWallY has its front face pointing INTO the room (-Y normal)
|
||||
// — so from the outside the polygon's front face faces +Y (outward).
|
||||
// When the camera sphere approaches from inside (+Y direction), it hits
|
||||
// the BACK face of this polygon.
|
||||
//
|
||||
// We register it with cellScope=0 (landblock-wide), which puts it in the
|
||||
// outdoor per-cell shadow lists — NOT in the indoor cell's portal-reachable
|
||||
// set. This mirrors how production registers landblock-baked statics:
|
||||
// GameWindow.cs:5899 uses entity.ParentCellId ?? 0u → 0 for top-level statics.
|
||||
const uint ExteriorShellEntityId = 0x00990001u;
|
||||
const uint ExteriorShellGfxId = 0x01AABB01u;
|
||||
|
||||
// Wall polygon in OBJECT-LOCAL space (GfxObj registered at world Y=ExteriorWallY).
|
||||
// In local space the wall is at Y=0 (directly at the GfxObj's origin).
|
||||
// Normal stays (-Y, facing INTO the room) — same direction as in world space.
|
||||
// X ∈ [-3, 3], Z ∈ [-3, 3] (local, centered on the GfxObj's world origin).
|
||||
var wallNormal = new Vector3(0f, -1f, 0f);
|
||||
const float wallLocalD = 0f; // wall at local Y=0 (GfxObj origin)
|
||||
// ── 1. Shell GfxObj: one two-sided wall polygon at local Y=0 ──────
|
||||
const uint ShellGfxId = 0x01AABB01u;
|
||||
const ushort WallPolyId = 1;
|
||||
|
||||
var wallPoly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = new[]
|
||||
|
|
@ -376,21 +157,15 @@ public class CameraCollisionIndoorTests
|
|||
new Vector3( 3f, 0f, 3f),
|
||||
new Vector3(-3f, 0f, 3f),
|
||||
},
|
||||
Plane = new Plane(wallNormal, wallLocalD),
|
||||
Plane = new System.Numerics.Plane(new Vector3(0f, -1f, 0f), 0f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None, // two-sided: should stop from both directions
|
||||
SidesType = CullMode.None, // two-sided: stops from both directions
|
||||
};
|
||||
|
||||
// GfxObj PhysicsBSP: single leaf containing the exterior wall.
|
||||
// BoundingSphere in OBJECT-LOCAL space: centered at origin (0,0,0), radius 10.
|
||||
var gfxLeaf = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere
|
||||
{
|
||||
Origin = Vector3.Zero, // local-space center
|
||||
Radius = 10f,
|
||||
},
|
||||
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
|
||||
};
|
||||
gfxLeaf.Polygons.Add(WallPolyId);
|
||||
|
||||
|
|
@ -402,31 +177,25 @@ public class CameraCollisionIndoorTests
|
|||
Resolved = new Dictionary<ushort, ResolvedPolygon> { [WallPolyId] = wallPoly },
|
||||
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
|
||||
};
|
||||
cache.RegisterGfxObjForTest(ExteriorShellGfxId, gfxPhysics);
|
||||
cache.RegisterGfxObjForTest(ShellGfxId, gfxPhysics);
|
||||
|
||||
// Register in the OUTDOOR shadow list (cellScope=0 → landblock-wide).
|
||||
// This mirrors production's GameWindow.cs:5893 for landblock-baked statics.
|
||||
engine.ShadowObjects.Register(
|
||||
entityId: ExteriorShellEntityId,
|
||||
gfxObjId: ExteriorShellGfxId,
|
||||
worldPos: new Vector3(0f, ExteriorWallY, 96f),
|
||||
rotation: Quaternion.Identity,
|
||||
radius: 10f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: LandblockId,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1.0f,
|
||||
cellScope: 0u); // ← landblock-wide outdoor, NOT indoor cell scope
|
||||
// ── 2. The per-LandCell building reference ─────────────────────────
|
||||
var bldTransform = Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f);
|
||||
cache.RegisterBuildingForTest(OutdoorCellId, new BuildingPhysics
|
||||
{
|
||||
WorldTransform = bldTransform,
|
||||
InverseWorldTransform = InvertOrIdentity(bldTransform),
|
||||
Portals = Array.Empty<BldPortalInfo>(),
|
||||
ModelId = ShellGfxId,
|
||||
});
|
||||
|
||||
// ── 3. Stub landblock: terrain far below ───────────────────────────
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||||
var stubTerrain = new TerrainSurface(heights, heightTable);
|
||||
engine.AddLandblock(
|
||||
landblockId: LandblockId,
|
||||
terrain: stubTerrain,
|
||||
terrain: new TerrainSurface(heights, heightTable),
|
||||
cells: Array.Empty<CellSurface>(),
|
||||
portals: Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
|
|
|
|||
|
|
@ -546,7 +546,14 @@ public class BSPStepUpTests
|
|||
/// every frame replays the same hard stop and the character hangs in falling
|
||||
/// animation until another correction breaks the loop.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Fact(Skip = "Issue #116 — slide-response divergence family (P1-era " +
|
||||
"slide_sphere work made the first airborne wall frame slide in-frame " +
|
||||
"to Z=1.92 instead of the L.2c-pinned hard stop at Z=2.0; the cached " +
|
||||
"sliding-normal mechanism retail seeds via get_object_info " +
|
||||
"(pc:279992, transient bit 4 → init_sliding_normal) only governs the " +
|
||||
"NEXT frame, so which first-frame response is retail-faithful needs " +
|
||||
"its own oracle read. NOT a cell-set problem — BR-7/A6.P4 left this " +
|
||||
"byte-identical. See docs/ISSUES.md #116.")]
|
||||
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
|
||||
{
|
||||
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
||||
|
|
|
|||
|
|
@ -1177,11 +1177,13 @@ public class CellarUpTrajectoryReplayTests : IDisposable
|
|||
landblockId: 0xA9B40000u,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1.0f,
|
||||
// Landblock-baked statics in production (GameWindow.cs:5899) use
|
||||
// `entity.ParentCellId ?? 0u` — the cottage building has no
|
||||
// ParentCellId (it's a top-level landblock static), so the
|
||||
// scope is landblock-wide (cellScope=0).
|
||||
cellScope: 0u);
|
||||
// BR-7: outdoor seed derived from the world position. (In
|
||||
// production the cottage SHELL no longer registers as a shadow
|
||||
// object at all — it dispatches via the per-LandCell building
|
||||
// channel; this fixture keeps the shadow registration to pin the
|
||||
// #98 regression shape: an outdoor-registered footprint must
|
||||
// stay invisible to fully-interior cellar queries.)
|
||||
seedCellId: 0u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -87,9 +87,24 @@ public class DoorBugTrajectoryReplayTests
|
|||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
// BR-7 / A6.P4 (2026-06-11) — FLIPPED from match-the-capture to
|
||||
// assert-the-fix. The capture IS the #99 bug (live walked through:
|
||||
// cnValid=false, position == target past the slab). The per-cell
|
||||
// shadow architecture makes the door reachable from the indoor
|
||||
// side (the straddling sphere's cell array spans both threshold
|
||||
// cells; retail "covered twice", wf1-interior-collision.md), so
|
||||
// the replay must now BLOCK: a collision fires and the sphere
|
||||
// cannot cross the slab's BSP Y-range [16.848, 17.109].
|
||||
var (engine, _) = BuildEngineWithDoorFixture(datDir);
|
||||
var captured = LoadCapturedRecord(r => r.Tick == 13558);
|
||||
AssertCallMatchesCapture(engine, captured);
|
||||
var (result, _) = ReplayCapturedCall(engine, captured);
|
||||
|
||||
Assert.True(result.CollisionNormalValid,
|
||||
$"Door must block the indoor-side off-center approach (#99 closed); " +
|
||||
$"pos=({result.Position.X:F4},{result.Position.Y:F4},{result.Position.Z:F4})");
|
||||
Assert.True(result.Position.Y <= 17.0f,
|
||||
$"Sphere must not cross the slab; pos.Y={result.Position.Y:F4} " +
|
||||
$"(capture's walkthrough reached 17.2041)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -107,9 +122,28 @@ public class DoorBugTrajectoryReplayTests
|
|||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
// BR-7 / A6.P4 (2026-06-11) — narrowed from full-capture match to
|
||||
// the blocking invariant. The capture (the WORKING outdoor side)
|
||||
// blocked Y at 18.0183 and slid the tiny lateral component
|
||||
// (X -0.0357, cn=(0,+1,0)). The harness blocks Y identically but
|
||||
// LOSES the near-perpendicular lateral slide (degenerate-offset
|
||||
// guard in the slide response converts it to a hard stop,
|
||||
// cn=(0,0,1) from the post-stop ground refresh). That residual is
|
||||
// filed as issue #116 (slide-response, NOT a cell-set problem —
|
||||
// the [bsp-test]/[cyl-skip-bsp] probes show the door found +
|
||||
// BSP-only dispatched correctly; see
|
||||
// Diagnostic_Tick22760_DumpEngineInternals). The invariant this
|
||||
// pin protects: the door BLOCKS from the outdoor side.
|
||||
var (engine, _) = BuildEngineWithDoorFixture(datDir);
|
||||
var captured = LoadCapturedRecord(r => r.Tick == 22760);
|
||||
AssertCallMatchesCapture(engine, captured);
|
||||
var (result, _) = ReplayCapturedCall(engine, captured);
|
||||
|
||||
Assert.True(result.CollisionNormalValid,
|
||||
$"Door must block the outdoor-side approach; " +
|
||||
$"pos=({result.Position.X:F4},{result.Position.Y:F4},{result.Position.Z:F4})");
|
||||
Assert.True(result.Position.Y >= 17.95f,
|
||||
$"Sphere must not penetrate southward past the slab; " +
|
||||
$"pos.Y={result.Position.Y:F4} (live blocked at 18.0183)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -119,6 +153,63 @@ public class DoorBugTrajectoryReplayTests
|
|||
/// to see the engine's internal decisions on the failing tick.
|
||||
/// Always passes (diagnostic-only).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// BR-7 diagnostic twin for tick 22760 (outdoor-side block). Live slid
|
||||
/// along the door face (cn=(0,1,0), X free); the harness has historically
|
||||
/// hard-stopped with cn=(0,0,1). Always passes; read the console dump.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Diagnostic_Tick22760_DumpEngineInternals()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
PhysicsDiagnostics.ProbeResolveEnabled = true;
|
||||
PhysicsDiagnostics.ProbeBuildingEnabled = true;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||||
PhysicsDiagnostics.ProbePushBackEnabled = true;
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||||
try
|
||||
{
|
||||
var (engine, _) = BuildEngineWithDoorFixture(datDir);
|
||||
var captured = LoadCapturedRecord(r => r.Tick == 22760);
|
||||
var body = SeedBodyFromSnapshot(captured.BodyBefore!);
|
||||
|
||||
Console.WriteLine("=== Replay tick 22760 (outdoor-side block) ===");
|
||||
var result = engine.ResolveWithTransition(
|
||||
currentPos: captured.Input.CurrentPos,
|
||||
targetPos: captured.Input.TargetPos,
|
||||
cellId: captured.Input.CellId,
|
||||
sphereRadius: captured.Input.SphereRadius,
|
||||
sphereHeight: captured.Input.SphereHeight,
|
||||
stepUpHeight: captured.Input.StepUpHeight,
|
||||
stepDownHeight: captured.Input.StepDownHeight,
|
||||
isOnGround: captured.Input.IsOnGround,
|
||||
body: body,
|
||||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||||
movingEntityId: captured.Input.MovingEntityId);
|
||||
|
||||
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||||
"=== Harness: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) cnValid={6} onGround={7} cell=0x{8:X8}",
|
||||
result.Position.X, result.Position.Y, result.Position.Z,
|
||||
result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z,
|
||||
result.CollisionNormalValid, result.IsOnGround, result.CellId));
|
||||
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||||
"=== Live: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) cnValid={6} onGround={7} cell=0x{8:X8}",
|
||||
captured.Result.Position.X, captured.Result.Position.Y, captured.Result.Position.Z,
|
||||
captured.Result.CollisionNormal.X, captured.Result.CollisionNormal.Y, captured.Result.CollisionNormal.Z,
|
||||
captured.Result.CollisionNormalValid, captured.Result.IsOnGround, captured.Result.CellId));
|
||||
}
|
||||
finally
|
||||
{
|
||||
PhysicsDiagnostics.ProbeResolveEnabled = false;
|
||||
PhysicsDiagnostics.ProbeBuildingEnabled = false;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||||
PhysicsDiagnostics.ProbePushBackEnabled = false;
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostic_Tick13558_DumpEngineInternals()
|
||||
{
|
||||
|
|
@ -412,7 +503,7 @@ public class DoorBugTrajectoryReplayTests
|
|||
landblockId: DoorLandblockId,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1.0f,
|
||||
cellScope: 0u);
|
||||
seedCellId: 0u);
|
||||
|
||||
// Replay captured tick 3254 inputs exactly.
|
||||
var currentPos = new Vector3(133.65524f, 17.58999f, 94f);
|
||||
|
|
@ -480,7 +571,7 @@ public class DoorBugTrajectoryReplayTests
|
|||
landblockId: DoorLandblockId,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1.0f,
|
||||
cellScope: 0u);
|
||||
seedCellId: 0u);
|
||||
|
||||
// 2. Load cell 0xA9B40150 BSP into cache (the alcove walls).
|
||||
const uint AlcoveCellId = 0xA9B40150u;
|
||||
|
|
@ -1102,6 +1193,34 @@ public class DoorBugTrajectoryReplayTests
|
|||
CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays one captured ResolveWithTransition call against
|
||||
/// <paramref name="engine"/>, seeded with bodyBefore, and returns the
|
||||
/// harness result for invariant-style assertions (BR-7 flips).
|
||||
/// </summary>
|
||||
private static (ResolveResult result, PhysicsBody body) ReplayCapturedCall(
|
||||
PhysicsEngine engine,
|
||||
ResolveCaptureRecord captured)
|
||||
{
|
||||
Assert.NotNull(captured.BodyBefore);
|
||||
var body = SeedBodyFromSnapshot(captured.BodyBefore!);
|
||||
|
||||
var result = engine.ResolveWithTransition(
|
||||
currentPos: captured.Input.CurrentPos,
|
||||
targetPos: captured.Input.TargetPos,
|
||||
cellId: captured.Input.CellId,
|
||||
sphereRadius: captured.Input.SphereRadius,
|
||||
sphereHeight: captured.Input.SphereHeight,
|
||||
stepUpHeight: captured.Input.StepUpHeight,
|
||||
stepDownHeight: captured.Input.StepDownHeight,
|
||||
isOnGround: captured.Input.IsOnGround,
|
||||
body: body,
|
||||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||||
movingEntityId: captured.Input.MovingEntityId);
|
||||
|
||||
return (result, body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays one captured ResolveWithTransition call against
|
||||
/// <paramref name="engine"/>, seeded with bodyBefore, and reports
|
||||
|
|
|
|||
|
|
@ -209,8 +209,13 @@ public class DoorCollisionApparatusTests
|
|||
/// sphere walks through — exactly what the user reports in the
|
||||
/// live Holtburg session 2026-05-24.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// BR-7 / A6.P4 (2026-06-11) — FLIPPED from documents-the-bug to
|
||||
/// asserts-the-fix: with the per-cell shadow architecture the door is
|
||||
/// found from every overlapped cell and this approach now blocks.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug()
|
||||
public void Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks()
|
||||
{
|
||||
if (!TryBuildScenario(out var ctx)) return;
|
||||
|
||||
|
|
@ -282,19 +287,19 @@ public class DoorCollisionApparatusTests
|
|||
_out.WriteLine($"Final pos = ({pos.X:F3}, {pos.Y:F3}, {pos.Z:F3}) after {ticks + 1} ticks; blocked={blocked}");
|
||||
_out.WriteLine($"Grounded={isOnGround}");
|
||||
|
||||
// EXPECTED FAILURE (documents-the-bug): the grounded sphere walks
|
||||
// straight through, reaching the far side at Y > 12.30. When the
|
||||
// fix lands, flip this to Assert.True(blocked) — same shape as
|
||||
// the Path-6 apparatus tests above.
|
||||
// FLIPPED (BR-7 / A6.P4, 2026-06-11): this test documented the #99
|
||||
// walk-through; the per-cell shadow architecture closes it. The
|
||||
// grounded Path-5 approach must now BLOCK before the slab
|
||||
// (Y < 12.0) — the same shape as the Path-6 apparatus tests above.
|
||||
PhysicsDiagnostics.ProbeResolveEnabled = false;
|
||||
PhysicsDiagnostics.ProbeBuildingEnabled = false;
|
||||
Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null);
|
||||
|
||||
Assert.True(pos.Y > 12.30f,
|
||||
$"This test documents the production bug. If this is failing " +
|
||||
$"because the sphere now blocks, the door fix worked — flip " +
|
||||
$"the assertion to Assert.True(blocked) and Assert.True(pos.Y < 12.0f). " +
|
||||
Assert.True(blocked,
|
||||
$"Door must block the grounded off-center approach (#99 closed). " +
|
||||
$"Current pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) blocked={blocked}");
|
||||
Assert.True(pos.Y < 12.0f,
|
||||
$"Sphere must stop before the slab; pos.Y={pos.Y:F3}");
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -128,21 +128,22 @@ public class FindEnvCollisionsMultiCellTests
|
|||
stepUpHeight: 0.04f,
|
||||
cellId: VestibuleCellId);
|
||||
|
||||
// SetCheckPos sets the candidate position FindEnvCollisions evaluates.
|
||||
t.SpherePath.SetCheckPos(to, VestibuleCellId);
|
||||
|
||||
// ── Act ───────────────────────────────────────────────────────────
|
||||
// Call FindEnvCollisions directly (now internal). Bypasses
|
||||
// FindTransitionalPosition's sub-step iteration so we can assert on
|
||||
// the single result.
|
||||
var result = t.FindEnvCollisions(engine);
|
||||
// BR-7 / A6.P4 (2026-06-11): the other-cells pass moved from
|
||||
// FindEnvCollisions into TransitionalInsert Phase 2.5 (retail
|
||||
// transitional_insert's OK_TS case, Ghidra 0x0050b756: the primary
|
||||
// insert runs env → building → objects, THEN check_other_cells).
|
||||
// Drive the public entry so the full per-attempt order runs.
|
||||
t.FindTransitionalPosition(engine);
|
||||
|
||||
// ── Assert ────────────────────────────────────────────────────────
|
||||
// Pre-A4: empty vestibule BSP returns OK, interior is never queried,
|
||||
// result == OK (sphere walks through the wall).
|
||||
// Post-A4: CheckOtherCells iterates the interior cell, BSPQuery on
|
||||
// TallWall returns Slid (the wall-slide path matching B2), and
|
||||
// FindEnvCollisions returns Slid via ApplyOtherCellResult.
|
||||
Assert.NotEqual(TransitionState.OK, result);
|
||||
// and the sphere walks through the wall to x=0.7.
|
||||
// Post-A4 (now via Phase 2.5's CheckOtherCells): the interior cell's
|
||||
// TallWall halts/slides the sphere — its center cannot pass
|
||||
// wall-X (0.8 world) minus the sphere radius (0.2) = 0.6.
|
||||
Assert.True(t.SpherePath.CurPos.X <= 0.6f + PhysicsGlobals.EPSILON * 20f,
|
||||
$"Adjacent cell's wall must block the sphere at world x≈0.6; " +
|
||||
$"CurPos.X={t.SpherePath.CurPos.X:F4} (walked through = A4 regression).");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,49 +114,48 @@ public class ShadowObjectRegistryTests
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GetNearbyObjects
|
||||
// Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS
|
||||
// the query — retail CObjCell::find_obj_collisions iterates only the
|
||||
// asked cell's shadow_object_list (Ghidra 0x0052b750). The former
|
||||
// radial GetNearbyObjects sweep is deleted; cell membership is the
|
||||
// broad phase.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetNearbyObjects_QueryCoversEntity_ReturnsIt()
|
||||
public void PerCellQuery_EntityCell_ReturnsIt()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
reg.Register(10u, 0x01000005u, new Vector3(30f, 30f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
|
||||
|
||||
var results = new List<ShadowEntry>();
|
||||
reg.GetNearbyObjects(new Vector3(30f, 30f, 50f), 5f, OffX, OffY, LbId, results);
|
||||
|
||||
// local (30,30) → landcell (1,1) → 1*8+1+1 = 10.
|
||||
var results = reg.GetObjectsInCell(LbId | 10u);
|
||||
Assert.Single(results);
|
||||
Assert.Equal(10u, results[0].EntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNearbyObjects_QueryFarFromEntity_ReturnsEmpty()
|
||||
public void PerCellQuery_FarCell_ReturnsEmpty()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
// Entity at local (12, 12) — cell (0,0).
|
||||
reg.Register(11u, 0x01000006u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
|
||||
|
||||
var results = new List<ShadowEntry>();
|
||||
// Query at local (180, 180) — cell (7,7) — far away.
|
||||
reg.GetNearbyObjects(new Vector3(180f, 180f, 50f), 5f, OffX, OffY, LbId, results);
|
||||
|
||||
Assert.Empty(results);
|
||||
// Cell (7,7) — far away. No spatial radius can reach across cells.
|
||||
Assert.Empty(reg.GetObjectsInCell(LbId | 64u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNearbyObjects_EntityInMultipleCells_ReturnedOnce()
|
||||
public void PerCellQuery_EntityInMultipleCells_OncePerCellList()
|
||||
{
|
||||
// Entity spans cells 0,0 and 1,0 (local X≈24, radius=2).
|
||||
// Entity spans cells (0,0) and (1,0) (local X≈24, radius=2) via the
|
||||
// flood's boundary-neighbor adds. Each overlapped cell's list holds
|
||||
// the entry exactly once — retail tests an object once per iterated
|
||||
// cell (no cross-cell dedup exists in find_obj_collisions).
|
||||
var reg = new ShadowObjectRegistry();
|
||||
reg.Register(20u, 0x01000007u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId);
|
||||
|
||||
var results = new List<ShadowEntry>();
|
||||
// Large query covers both cells; entity must appear exactly once.
|
||||
reg.GetNearbyObjects(new Vector3(24f, 12f, 50f), 10f, OffX, OffY, LbId, results);
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal(20u, results[0].EntityId);
|
||||
Assert.Single(reg.GetObjectsInCell(LbId | 1u), e => e.EntityId == 20u);
|
||||
Assert.Single(reg.GetObjectsInCell(LbId | 9u), e => e.EntityId == 20u);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -303,71 +302,151 @@ public class ShadowObjectRegistryTests
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// A6.P4 slice 1 — portalReachableCells set includes outdoor cells
|
||||
// when sphere straddles an exit portal (issue #99)
|
||||
// BR-7 / A6.P4 (2026-06-11) — registration-side architecture pins.
|
||||
// The former query-side compensations (the b3ce505 indoor gate, the
|
||||
// portalReachableCells expansion, the isViewer exemption) are deleted;
|
||||
// these pin the registration shapes that replaced them.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetNearbyObjects_PortalReachableSetIncludesOutdoorCell_IndoorPrimary_DoorReturned()
|
||||
public void Register_OutdoorSeed_DoorVisibleInItsOutdoorCell_NotInUnrelatedIndoorCell()
|
||||
{
|
||||
// A6.P4 slice 1 / issue #99 (2026-05-24): when the player's sphere is
|
||||
// in an indoor cell but its CellTransit.FindCellSet result includes
|
||||
// an outdoor cell (via AddAllOutsideCells triggered by the sphere
|
||||
// straddling an OtherCellId=0xFFFF exit portal), GetNearbyObjects
|
||||
// must return shadows registered in that outdoor cell. Doors are
|
||||
// server-spawned entities registered at GameWindow.cs:3139 with
|
||||
// default cellScope=0u → outdoor 24-m grid registration. Before this
|
||||
// fix, the loop filtered out outdoor cell ids ("skip outdoor ids")
|
||||
// AND #98's primaryCellId gate skipped the radial sweep — net result
|
||||
// was that doors at building thresholds were invisible to spheres
|
||||
// on the indoor side: walk-through.
|
||||
// Issue #99 architecture: a door registered at its outdoor cell is
|
||||
// found by iterating THAT cell's list — which the Transition reaches
|
||||
// from the indoor side when its sphere straddles the exit portal
|
||||
// (the cell array spans both cells at the threshold; retail
|
||||
// "covered twice" note, wf1-interior-collision.md). Without a
|
||||
// building bridge in the flood cache, the door floods outdoor-only.
|
||||
var reg = new ShadowObjectRegistry();
|
||||
|
||||
const uint doorEntityId = 0x000F4244u;
|
||||
reg.Register(doorEntityId, 0x020019FFu, new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity, 1f, OffX, OffY, LbId,
|
||||
ShadowCollisionType.Cylinder, cylHeight: 2.5f);
|
||||
ShadowCollisionType.Cylinder, cylHeight: 2.5f,
|
||||
isStatic: false);
|
||||
|
||||
var results = new List<ShadowEntry>();
|
||||
uint doorOutdoorCellId = LbId | 1u; // outdoor cell where door sits
|
||||
uint vestibuleCellId = LbId | 0x0145u; // indoor cell on the other side
|
||||
|
||||
// Sphere is in the vestibule; FindCellSet added the doorway's outdoor
|
||||
// cell via AddAllOutsideCells. Both ids land in portalReachableCells.
|
||||
reg.GetNearbyObjects(
|
||||
new Vector3(12f, 12f, 50f), 5f, OffX, OffY, LbId, results,
|
||||
portalReachableCells: new[] { vestibuleCellId, doorOutdoorCellId },
|
||||
primaryCellId: vestibuleCellId);
|
||||
|
||||
Assert.Contains(results, e => e.EntityId == doorEntityId);
|
||||
Assert.Contains(reg.GetObjectsInCell(doorOutdoorCellId), e => e.EntityId == doorEntityId);
|
||||
Assert.DoesNotContain(reg.GetObjectsInCell(vestibuleCellId), e => e.EntityId == doorEntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNearbyObjects_IndoorPrimary_IndoorOnlyPortalSet_OutdoorRadialStillSkipped()
|
||||
public void Register_OutdoorFootprint_NeverLandsInInteriorCells()
|
||||
{
|
||||
// Regression check for issue #98: when the FindCellSet result holds
|
||||
// only indoor cells (sphere not near an exit portal — e.g. deep in
|
||||
// a cellar), the radial outdoor sweep must stay skipped so the
|
||||
// landblock-baked cottage GfxObj (registered at outdoor cellScope=0)
|
||||
// is NOT returned to a sphere in the cellar EnvCell. Otherwise the
|
||||
// head sphere bumps the cottage's downward-facing floor poly from
|
||||
// below and caps the cellar ascent at world Z≈92.7.
|
||||
// The #98 shape, now closed at REGISTRATION: an outdoor-positioned
|
||||
// footprint (the old cottage GfxObj entry) floods into outdoor
|
||||
// landcells only — without a building-bridge admission its spheres
|
||||
// can never reach an interior cell's list, so a fully-interior
|
||||
// cellar query structurally cannot see it. (In production the
|
||||
// cottage SHELL additionally left the registry entirely — the
|
||||
// per-LandCell building channel owns it.)
|
||||
var reg = new ShadowObjectRegistry();
|
||||
|
||||
const uint cottageEntityId = 0xA9B47900u;
|
||||
reg.Register(cottageEntityId, 0x01000A2Bu, new Vector3(12f, 12f, 90f),
|
||||
Quaternion.Identity, 5.5f, OffX, OffY, LbId);
|
||||
|
||||
var results = new List<ShadowEntry>();
|
||||
uint cellarCellId = LbId | 0x0146u;
|
||||
Assert.Empty(reg.GetObjectsInCell(cellarCellId));
|
||||
|
||||
// Sphere is in the cellar; FindCellSet returned indoor-only set
|
||||
// (no exit portal nearby).
|
||||
reg.GetNearbyObjects(
|
||||
new Vector3(12f, 12f, 50f), 5.5f, OffX, OffY, LbId, results,
|
||||
portalReachableCells: new[] { cellarCellId },
|
||||
primaryCellId: cellarCellId);
|
||||
// And every cell it DID land in is outdoor.
|
||||
foreach (var entry in reg.AllEntriesForDebug())
|
||||
Assert.Equal(cottageEntityId, entry.EntityId);
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(results, e => e.EntityId == cottageEntityId);
|
||||
[Fact]
|
||||
public void RefloodLandblock_RerunsFloodAfterCellsHydrate()
|
||||
{
|
||||
// The streaming race (spec §7 Q3 / retail init_objects →
|
||||
// recalc_cross_cells): an entity registered while its indoor seed
|
||||
// cell was unhydrated stays pinned to {seed}; once the cell (with a
|
||||
// portal neighbor) hydrates, RefloodLandblock re-runs the flood and
|
||||
// the neighbor appears in the set.
|
||||
var reg = new ShadowObjectRegistry();
|
||||
var cache = new PhysicsDataCache();
|
||||
reg.DataCache = cache;
|
||||
|
||||
const uint seedCell = 0xA9B40100u;
|
||||
const uint neighborCell = 0xA9B40101u;
|
||||
|
||||
reg.Register(77u, 0x01000009u, new Vector3(2.0f, 0f, 2.5f),
|
||||
Quaternion.Identity, 0.5f, OffX, OffY, LbId,
|
||||
seedCellId: seedCell, isStatic: false);
|
||||
|
||||
// Unhydrated seed → registered under the claimed cell only.
|
||||
Assert.Contains(reg.GetObjectsInCell(seedCell), e => e.EntityId == 77u);
|
||||
Assert.Empty(reg.GetObjectsInCell(neighborCell));
|
||||
|
||||
// Hydrate the seed (portal to the neighbor at x=2.5) + the neighbor
|
||||
// (leaf BSP admits the straddling sphere), then re-flood.
|
||||
cache.RegisterCellStructForTest(seedCell,
|
||||
BuildShadowCellSetTests_MakeCellWithPortalAtRightWall(Matrix4x4.Identity, 0x0101));
|
||||
cache.RegisterCellStructForTest(neighborCell,
|
||||
BuildShadowCellSetTests_MakeLeafCell(Matrix4x4.CreateTranslation(5f, 0f, 0f)));
|
||||
|
||||
reg.RefloodLandblock(LbId);
|
||||
|
||||
Assert.Contains(reg.GetObjectsInCell(seedCell), e => e.EntityId == 77u);
|
||||
Assert.Contains(reg.GetObjectsInCell(neighborCell), e => e.EntityId == 77u);
|
||||
}
|
||||
|
||||
// Local copies of the BuildShadowCellSetTests fixture helpers (kept
|
||||
// private per test class by repo convention).
|
||||
private static CellPhysics BuildShadowCellSetTests_MakeCellWithPortalAtRightWall(
|
||||
Matrix4x4 worldTransform, ushort otherCellId)
|
||||
{
|
||||
var portalPoly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = new[]
|
||||
{
|
||||
new Vector3(2.5f, -2.5f, 0f),
|
||||
new Vector3(2.5f, 2.5f, 0f),
|
||||
new Vector3(2.5f, 2.5f, 5f),
|
||||
new Vector3(2.5f, -2.5f, 5f),
|
||||
},
|
||||
Plane = new System.Numerics.Plane(new Vector3(1, 0, 0), -2.5f),
|
||||
NumPoints = 4,
|
||||
SidesType = DatReaderWriter.Enums.CullMode.None,
|
||||
};
|
||||
|
||||
Matrix4x4.Invert(worldTransform, out var inv);
|
||||
return new CellPhysics
|
||||
{
|
||||
WorldTransform = worldTransform,
|
||||
InverseWorldTransform = inv,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [10] = portalPoly },
|
||||
Portals = new[]
|
||||
{
|
||||
new PortalInfo(otherCellId: otherCellId, polygonId: 10, flags: 0),
|
||||
},
|
||||
CellBSP = new DatReaderWriter.Types.CellBSPTree
|
||||
{
|
||||
Root = new DatReaderWriter.Types.CellBSPNode
|
||||
{
|
||||
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform)
|
||||
{
|
||||
Matrix4x4.Invert(worldTransform, out var inv);
|
||||
return new CellPhysics
|
||||
{
|
||||
WorldTransform = worldTransform,
|
||||
InverseWorldTransform = inv,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
CellBSP = new DatReaderWriter.Types.CellBSPTree
|
||||
{
|
||||
Root = new DatReaderWriter.Types.CellBSPNode
|
||||
{
|
||||
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue