using System.Numerics; using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.App.Physics; /// /// Immutable input to the canonical shadow registry for one live object. /// Building is DAT/physics-only and therefore testable without a window or GL /// context; registration remains one explicit commit. /// internal sealed record LiveEntityCollisionRegistration( uint EntityId, uint SourceId, Vector3 EntityWorldPosition, Quaternion EntityWorldRotation, IReadOnlyList Shapes, uint State, EntityCollisionFlags Flags, float WorldOffsetX, float WorldOffsetY, uint LandblockId, uint SeedCellId); /// /// Ports the live-object collision-shape policy used by /// CPartArray::FindObjCollisions: CylSpheres before Spheres, every /// physics-BSP part, and the established Setup-radius fallback for the /// remaining ACE prop data. /// internal sealed class LiveEntityCollisionBuilder { private readonly Func _hasPhysicsBsp; private readonly Func _physicsBspRadius; private readonly LiveEntityDefaultPoseResolver _defaultPose; public LiveEntityCollisionBuilder( PhysicsDataCache physicsData, LiveEntityDefaultPoseResolver defaultPose) : this( id => physicsData.GetGfxObj(id)?.BSP?.Root is not null, id => physicsData.GetGfxObj(id)?.BoundingSphere?.Radius, defaultPose) { ArgumentNullException.ThrowIfNull(physicsData); } internal LiveEntityCollisionBuilder( Func hasPhysicsBsp, Func physicsBspRadius, LiveEntityDefaultPoseResolver defaultPose) { _hasPhysicsBsp = hasPhysicsBsp ?? throw new ArgumentNullException(nameof(hasPhysicsBsp)); _physicsBspRadius = physicsBspRadius ?? throw new ArgumentNullException(nameof(physicsBspRadius)); _defaultPose = defaultPose ?? throw new ArgumentNullException(nameof(defaultPose)); } /// /// Resolves collision slot zero for every post-AnimPartChanged identity. /// This intentionally has no visual-LOD option or Setup-type input. /// public static uint[] ResolveEffectivePartIdentities( IReadOnlyList postAnimPartGfxObjIds, Func resolveSlotZero) { ArgumentNullException.ThrowIfNull(postAnimPartGfxObjIds); ArgumentNullException.ThrowIfNull(resolveSlotZero); var result = new uint[postAnimPartGfxObjIds.Count]; for (int i = 0; i < result.Length; i++) result[i] = resolveSlotZero(postAnimPartGfxObjIds[i]); return result; } public LiveEntityCollisionRegistration? Build( WorldEntity entity, Setup setup, IReadOnlyList effectivePartGfxObjIds, WorldSession.EntitySpawn spawn, LiveEntityRecord exactRecord, Vector3 worldOrigin, bool retainEmptyPayload = false) { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(setup); ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds); ArgumentNullException.ThrowIfNull(exactRecord); if (spawn.Position is not { } position) return null; if (spawn.Guid != exactRecord.ServerGuid || spawn.InstanceSequence != exactRecord.Generation || !ReferenceEquals(exactRecord.WorldEntity, entity)) { throw new InvalidOperationException( "Live collision construction requires the exact materialized record."); } float scale = spawn.ObjScale ?? 1f; IReadOnlyList? defaultPose = _defaultPose.Resolve( spawn.MotionTableId ?? 0u, setup.Parts.Count); IReadOnlyList raw = ShadowShapeBuilder.FromSetup( setup, scale, _hasPhysicsBsp, partPoseOverride: defaultPose, effectivePartGfxObjIds: effectivePartGfxObjIds); var shapes = new List(raw.Count); foreach (ShadowShape shape in raw) { if (shape.CollisionType == ShadowCollisionType.BSP) { float radius = (_physicsBspRadius(shape.GfxObjId) ?? 2f) * scale; shapes.Add(shape with { Radius = radius }); } else { shapes.Add(shape); } } if (shapes.Count == 0 && setup.Radius > 0.0001f) { shapes.Add(new ShadowShape( GfxObjId: 0u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity, Scale: scale, CollisionType: ShadowCollisionType.Cylinder, Radius: setup.Radius * scale, CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale)); } if (shapes.Count == 0 && !retainEmptyPayload) return null; EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie; if (spawn.ObjectDescriptionFlags is { } descriptionFlags) flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags); if (spawn.ItemType == (uint)ItemType.Creature) flags |= EntityCollisionFlags.IsCreature; return new LiveEntityCollisionRegistration( entity.Id, entity.SourceGfxObjOrSetupId, entity.Position, entity.Rotation, shapes, (uint)exactRecord.FinalPhysicsState, flags, worldOrigin.X, worldOrigin.Y, position.LandblockId, position.LandblockId); } public static void Register( ShadowObjectRegistry registry, LiveEntityCollisionRegistration registration) { ArgumentNullException.ThrowIfNull(registry); ArgumentNullException.ThrowIfNull(registration); registry.RegisterMultiPart( registration.EntityId, registration.EntityWorldPosition, registration.EntityWorldRotation, registration.Shapes, registration.State, registration.Flags, registration.WorldOffsetX, registration.WorldOffsetY, registration.LandblockId, registration.SeedCellId, isStatic: false); if (!PhysicsDiagnostics.ProbeBuildingEnabled) return; int cylinders = 0; int bsps = 0; foreach (ShadowShape shape in registration.Shapes) { if (shape.CollisionType == ShadowCollisionType.Cylinder) cylinders++; else bsps++; } Console.WriteLine(FormattableString.Invariant( $"[entity-source] id=0x{registration.EntityId:X8} entityId=0x{registration.EntityId:X8} src=0x{registration.SourceId:X8} gfxObj=0x{registration.SourceId:X8} lb=0x{registration.LandblockId:X8} shapes=cyl{cylinders}+bsp{bsps} note=server-spawn-root state=0x{registration.State:X8} flags={registration.Flags}")); } /// /// Commits a retail ObjDesc part-array replacement. A shapeless result /// removes the prior payload; a shaped result replaces it without making /// a Hidden, parented, or cell-less object collide in world cells. /// public static void ReconcileAppearance( ShadowObjectRegistry registry, uint entityId, LiveEntityCollisionRegistration? registration, bool suspendIfNew) { ArgumentNullException.ThrowIfNull(registry); if (registration is null) return; if (registration.EntityId != entityId) throw new InvalidOperationException("Collision replacement belongs to a different live entity."); registry.ReplaceMultiPartPayload( registration.EntityId, registration.EntityWorldPosition, registration.EntityWorldRotation, registration.Shapes, registration.State, registration.Flags, registration.WorldOffsetX, registration.WorldOffsetY, registration.LandblockId, registration.SeedCellId, isStatic: false, suspendIfNew); } }