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; } }