using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
///
/// Pure-function builder that translates a into a list of
/// s suitable for registration via
/// .
///
///
/// This is a DISPATCH, not a union. In priority order: (3) when ANY Part's
/// effective GfxObj has a non-null PhysicsBSP, emit one BSP shape per such
/// Part — and nothing else; otherwise (1) every CylSphere → Cylinder shape;
/// otherwise (2) every Sphere → Sphere shape; otherwise nothing at all. Local
/// transforms come from PlacementFrames[Resting | Default | first available].
///
///
///
/// Retail anchor: CPhysicsObj::FindObjCollisions (0x0050f050)
/// dispatches EXCLUSIVELY on HAS_PHYSICS_BSP_PS (0x10000)
/// (0x0050f165 test dword [esi+0xa8],0x10000 /
/// 0x0050f16f je 0x50f1a2): it calls
/// CPartArray::FindObjCollisions (the per-part BSP walk) and returns
/// (0x0050f19d jmp 0x50f2b0, an UNCONDITIONAL jump past both primitive
/// branches — the CylSphere loop starts at 0x50f1a2 and the Sphere loop at
/// 0x50f21d), OR walks the Setup's CylSpheres and returns
/// (0x0050f1d6 jae 0x50f317), OR walks the Setup's Spheres, OR — with
/// none of the three — returns the seeded OK_TS without synthesizing
/// any shape (0x0050f22f je 0x50f31b). BSP wins.
/// CPhysicsPart::find_obj_collisions (0x0050d8d0) tests ONLY the
/// GfxObj physics BSP; CylSpheres and Spheres are Setup-level arrays
/// reached through CPartArray::GetCylsphere (0x00518090) and
/// GetSphere (0x00518070), so a part has no primitive of its own.
///
///
///
/// Cell membership dispatches on the SAME flag and in the same priority:
/// CPhysicsObj::calc_cross_cells (0x00515230) tests
/// 0x10000 at 0x00515285 and routes a BSP-bearing object to
/// CPhysicsObj::find_bbox_cell_list (0x00510fc0) at
/// 0x0051528f jne 0x515305, never reaching its cylsphere
/// (0x005152d1) or sorting-sphere (0x005152fb) branches. That
/// is why the exclusivity is enforced HERE, at emission, rather than only at
/// the query-time guard Transition.BspOnlyDispatch: the shape list is
/// also the input to ShadowObjectRegistry.BuildFloodSpheres.
///
///
///
/// AP-152 (filed and retired 2026-08-06): these three steps used to be
/// emitted ADDITIVELY — 172 of 5,935 installed Setups carry both a primitive
/// and a physics-BSP part. The collision half of that divergence was already
/// inert, because Transition.BspOnlyDispatch skips both primitive
/// branches whenever the wire PhysicsState carries 0x10000 and ACE
/// derives that bit from the same DAT flag; the live half was CELL
/// MEMBERSHIP, which had no such guard. Gating here also removes acdream's
/// undeclared dependency on the server sending the bit: the gate is derived
/// from the parts, exactly as retail's CPartArray::CacheHasPhysicsBSP
/// (0x00518110) derives it.
///
///
public static class ShadowShapeBuilder
{
///
/// Build the shape list for a Setup.
///
/// The Setup to walk.
/// The entity's overall scale factor; multiplies
/// every radius, height, and local offset.
/// Predicate: does the GfxObj with this id
/// have a physics BSP? Production derives it from the SAME resolver that
/// supplies
/// (id => _physicsBspBounds(id) is not null, over
/// PhysicsDataCache.GetFlatGfxObj(id)?.PhysicsBsp), so the dispatch
/// gate and the emitted geometry cannot disagree.
/// #175: per-part pose override for the
/// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the
/// closed pose for doors). Retail collision tests each part's LIVE
/// CPhysicsPart pose, which for an idle entity is the motion
/// table's default state, NOT the Setup's placement frame — the two
/// differ on e.g. the Facility Hub double door (Setup 0x02000C9D:
/// placement poses the panels AJAR at yaw −150°/−30°, y −0.44 m; the
/// closed pose is straight). Null / short lists fall back to the
/// placement frame per part (entities with no motion table, and the
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
/// the setup too).
/// Current part identities after
/// retail AnimPartChanged processing. Collision keeps each Setup
/// index and pose, but reads PhysicsBSP from the installed replacement.
/// Null or short lists fall back to the Setup identity.
/// The part GfxObj's physics-BSP ROOT
/// bounding sphere AND its authored vertex-array box — retail's
/// CGfxObj::physics_sphere (BSPTREE::GetSphere(physics_bsp)
/// @0x005397e0) and CGfxObj::gfx_bound_box
/// (CPhysicsPart::GetBoundingBox @0x0050d600), as ONE
/// . Supplies the emitted
/// ,
/// and /Max from one call,
/// so no part of the flood geometry can be carried while another is
/// dropped (AP-156, then #334). Null (or a null result) falls back to the
/// loose-but-safe 2 m placeholder at the part origin — a fixture-only
/// configuration; production always supplies it
/// (LiveEntityCollisionBuilder).
public static IReadOnlyList FromSetup(
Setup setup,
float entScale,
Func hasPhysicsBsp,
IReadOnlyList? partPoseOverride = null,
IReadOnlyList? effectivePartGfxObjIds = null,
Func? physicsBspBounds = null)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
var result = new List();
// 0. Retail dispatch gate. CPhysicsObj::FindObjCollisions tests
// HAS_PHYSICS_BSP_PS FIRST (0x0050f165) and leaves the BSP branch
// through an unconditional jmp past both primitive loops
// (0x0050f19d); CPhysicsObj::calc_cross_cells tests the same flag
// at 0x00515285 and routes to find_bbox_cell_list. Retail derives
// the flag from the part array itself
// (CPartArray::CacheHasPhysicsBSP 0x00518110 ORs 0x10000 on the
// first part whose gfxobj->physics_bsp is non-null), so the gate
// below reads the SAME effective part identities step 3 reads —
// never setup.Parts directly. A gate keyed on a different identity
// could suppress the primitives while step 3 emitted nothing,
// silently deleting the entity's collision.
bool anyPhysicsBspPart = false;
for (int i = 0; i < setup.Parts.Count; i++)
{
if (hasPhysicsBsp(EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i)))
{
anyPhysicsBspPart = true;
break;
}
}
// Steps 1 and 2 run ONLY for an object with no physics-BSP part.
if (!anyPhysicsBspPart)
{
// 1. CylSpheres — each becomes a Cylinder shape.
foreach (var cyl in setup.CylSpheres)
{
if (cyl.Radius <= 0f) continue;
float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f;
result.Add(ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale,
localRotation: Quaternion.Identity,
scale: entScale,
radius: cyl.Radius * entScale,
cylHeight: baseHeight * entScale));
}
// 2. Spheres — only when no CylSpheres. Retail's CylSphere loop
// returns rather than falling into the Sphere loop
// (0x0050f1d6 jae 0x50f317). Each becomes a true Sphere (no
// height clamping): CSphere::intersects_sphere @ 0x00537A80
// uses 3-D distance for the overlap check, unlike CCylSphere
// which clips to [low_pt, high_pt].
if (setup.CylSpheres.Count == 0)
{
foreach (var sph in setup.Spheres)
{
if (sph.Radius <= 0f) continue;
result.Add(ShadowShape.Sphere(
gfxObjId: 0u,
localPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
localRotation: Quaternion.Identity,
scale: entScale,
radius: sph.Radius * entScale));
}
}
}
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
// Pose priority per part: partPoseOverride (the motion-table
// default-state pose, #175) → placement frame → identity.
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
for (int i = 0; i < setup.Parts.Count; i++)
{
// Retail CPhysicsPart::SetPart installs AnimPartChanged's current
// degrade array before CPartArray::FindObjCollisions reads it.
// Keep the stable Setup part index/pose, but source collision
// identity from that effective part when one was supplied.
uint gfxId = EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i);
if (!hasPhysicsBsp(gfxId)) continue;
Frame partFrame;
if (partPoseOverride is not null && i < partPoseOverride.Count)
partFrame = partPoseOverride[i];
else if (placementFrame is not null && i < placementFrame.Frames.Count)
partFrame = placementFrame.Frames[i];
else
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
// The part's physics-BSP root bounding sphere — retail's
// CGfxObj::physics_sphere, assigned BSPTREE::GetSphere(physics_bsp)
// @0x005397e0. RADIUS AND CENTER TOGETHER: retail's per-part
// cross-cell walk (CEnvCell::find_transit_cells @0x0052cae0,
// reached from find_bbox_cell_list @0x00510fc0 through
// CPartArray::calc_cross_cells_static @0x00518160) transforms the
// sphere's CENTER through the part's own Position
// (0x0052cb4c add eax,0x30 → Position::localtolocal) BEFORE it
// reads the radius at 0x0052cb65 fadd [esi+0xc]. The center is not
// the part origin: 376 of the 973 installed physics-BSP parts sit
// further from it than half their own radius. A single resolver
// supplies both so one cannot be taken without the other.
// Absent bounds keep the loose-but-safe 2 m placeholder, centred
// on the part origin because nothing better is known.
// ShadowShape.Bsp scales radius, centre and box together.
//
// #334: the SAME resolver also supplies the authored vertex-array
// box. Retail's outdoor cell membership
// (CLandCell::add_all_outside_cells @0x00533360, reached from
// find_bbox_cell_list @0x00510fc0) divides that box — never the
// sphere — by square_length to build its cell rectangle, so a
// resolver that answered only the sphere would leave that walk
// with nothing to walk.
ShadowPartGeometry geometry =
physicsBspBounds?.Invoke(gfxId)
?? ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, 2f),
null);
result.Add(ShadowShape.Bsp(
gfxObjId: gfxId,
localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
localRotation: partFrame.Orientation,
scale: entScale,
localGeometry: geometry));
}
return result;
}
///
/// #185: build BSP shapes for a landblock-baked multi-part entity (buildings,
/// stair runs, fences, rock clusters) from its per-part s,
/// for registration via
/// under the entity's SINGLE unique id.
///
///
/// Replaces the former per-part Register(entity.Id * 256u + partIndex)
/// (GameWindow.cs) whose * 256u OVERFLOWED uint32 for class-prefixed
/// landblock ids (0x40/0x80/0xC0…): the overflow dropped
/// the prefix byte, so different-class entities sharing the low 24 bits
/// collided on one shadow part-id and Register's deregister-then-insert
/// silently overwrote one entity's collision geometry — the #185 "invisible
/// wall half-way up the stairs" (rendered steps with no collision).
///
///
///
/// Retail anchor: a multi-part object is one CPhysicsObj + CPartArray;
/// CPhysicsObj::add_shadows_to_cells (0x00514ae0) → CPartArray::AddPartsShadow
/// walks the part array under the single object — no synthetic per-part id.
///
///
///
/// Each part's local transform comes from its
/// (root-relative), decomposed to LocalPosition/LocalRotation/Scale;
/// RegisterMultiPart reconstructs the world placement identically
/// (entityWorldPos + rotate(LocalPosition, entityWorldRot)). Building
/// shells are excluded — they collide via the per-LandCell building channel
/// (CSortCell::find_collisions), not as shadow objects.
///
///
/// The entity's per-part mesh references.
/// True for LandBlockInfo.Buildings[] shells.
/// Resolves a GfxObj id to its cached physics (BSP +
/// bounding sphere). Production: id => cache.GetGfxObj(id).
public static List FromLandblockBspParts(
IReadOnlyList meshRefs,
bool isBuildingShell,
Func getGfxObj)
{
if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj));
var shapes = new List();
// Building shells collide via the building channel (retail), not shadow objects.
if (isBuildingShell || meshRefs is null) return shapes;
foreach (var meshRef in meshRefs)
{
var phys = getGfxObj(meshRef.GfxObjId);
if (phys is null) continue;
FlatPhysicsBsp? flat = phys.FlatPhysicsBsp;
bool hasFlat = flat is { RootIndex: >= 0 };
if (!hasFlat && phys.BSP?.Root is null)
continue; // graph-only fixture seam until I6 referee removal
// PartTransform is root-relative; decompose to local pos/rot/scale.
if (!Matrix4x4.Decompose(meshRef.PartTransform,
out var pScale, out var pRot, out var pPos))
{
pScale = Vector3.One;
pRot = Quaternion.Identity;
pPos = new Vector3(meshRef.PartTransform.M41,
meshRef.PartTransform.M42,
meshRef.PartTransform.M43);
}
float partScale = pScale.X > 0f ? pScale.X : 1f; // AC objects are uniformly scaled
// Root bounding sphere, CENTER AND RADIUS TOGETHER — see
// ShadowShape.BoundsCenter. Retail's per-part cross-cell walk
// (CEnvCell::find_transit_cells @0x0052cae0) transforms
// CGfxObj::physics_sphere's center through the part's Position
// before using its radius; a landblock-baked part array is the
// same CPartArray walk (CPartArray::calc_cross_cells_static
// @0x00518160), so dropping the center here mis-places the flood
// exactly as it did for live Setups.
FlatCollisionSphere localBounds =
hasFlat
? flat!.Nodes[flat.RootIndex].BoundingSphere
: new FlatCollisionSphere(
phys.BoundingSphere?.Origin ?? Vector3.Zero,
phys.BoundingSphere?.Radius ?? 1f);
// #334: the same cached record carries the authored vertex-array
// box (CGfxObj::gfx_bound_box), which retail's outdoor extent walk
// — CLandCell::add_all_outside_cells @0x00533360 — divides by
// square_length. Landblock-baked part arrays are exactly the
// population whose extent exceeds one 24 m land cell, so the
// sphere alone cannot describe their membership.
ShadowPartGeometry geometry =
ShadowPartGeometry.Create(localBounds, phys.VisualBounds);
shapes.Add(ShadowShape.Bsp(
gfxObjId: meshRef.GfxObjId,
localPosition: pPos,
localRotation: pRot,
scale: partScale,
localGeometry: geometry));
}
return shapes;
}
///
/// The collision identity of part : the installed
/// AnimPartChanged replacement when one was supplied, else the
/// Setup's own part. Shared by the step-0 dispatch gate and the step-3
/// emission so the two can never read different identities.
///
private static uint EffectivePartGfxObjId(
Setup setup,
IReadOnlyList? effectivePartGfxObjIds,
int index)
=> effectivePartGfxObjIds is not null && index < effectivePartGfxObjIds.Count
? effectivePartGfxObjIds[index]
: (uint)setup.Parts[index];
/// Resolve the placement frame in priority Resting → Default →
/// first available. Mirrors SetupMesh.Flatten's convention.
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
{
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) return resting;
if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) return def;
foreach (var kvp in setup.PlacementFrames) return kvp.Value;
return null;
}
}