acdream/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs
Erik 96ae274081 fix #182: port CSphere collision family — retail-faithful crowd wiggle (retires TS-45)
Humanoid creatures/players collide as body Spheres (ShadowShapeBuilder emits
Sphere-type shadows for a Setup with Spheres + no CylSpheres), so player-vs-monster
crowd contact ran through Transition.SphereCollision — a hand-rolled 3-D wall-slide
(register TS-45), NOT a port of retail CSphere::intersects_sphere. It shaved no eps,
force-pushed each contact RADIALLY to a fixed combinedR+1cm shell, ignored the head
sphere, and always returned Slid. In a crowd the opposing radial de-penetration
pushes from neighbours fight each other -> the player wedges and can't wiggle free
(the user's live report).

Port the full CSphere family verbatim — dispatcher 0x00537A80 + step_sphere_up /
slide_sphere / land_on_sphere / collide_with_point / step_sphere_down — the direct
analog of the 2026-07-05 CCylSphere port (#172). The grounded slide now routes
through the shared crease SlideSphere (0x00537440, #116-Ghidra-confirmed) ->
tangential shuffle along the contact toward gaps, retail-faithful. isCreature
(target creature/missile) gates OFF the stand-on/land-on branches (2 & 5). ACE
Sphere.cs = readable oracle; pseudocode doc 2026-07-07-csphere-collision-family.

Retail-faithfulness verified: CTransition::validate_transition (0x0050aa70:272593)
reverts curr_pos on any non-clean-OK step, so a deep-mutual-overlap start wedges in
retail too — the realistic crowd-edge graze slides free (SphereCollisionFamilyTests
slide-around trajectory: player grazes a creature's SW, curves around its west side,
continues N).

TS-45 retired, AP-84 added (PerfectClip TOI dead in M1.5). Core 2603/0, App 741/0.
Pending user visual gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:25:56 +02:00

122 lines
5.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Guards the DAT-only shape-source rule (Slice 1 of the unified
/// collision-inclusion phase, 2026-06-24).
///
/// <para>
/// Retail oracle: <c>CPartArray::InitParts@0x00517F40</c> (parts come from
/// the Setup list only), <c>CGfxObj::Serialize@0x00534970</c> (physics_bsp
/// gated on serialized-flags bit-0), <c>CPhysicsPart::find_obj_collisions@0x0050D8D0</c>
/// (returns OK / passable when <c>physics_bsp == null</c>). Render-mesh bounds
/// never enter collision.
/// </para>
/// </summary>
public class ShadowShapeBuilderShapeSourceTests
{
// Retail: an object with no DAT physics shape (no CylSphere, no Sphere,
// no part with a PhysicsBSP) emits NO collision shape and is passable.
// CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK when physics_bsp==null.
[Fact]
public void Setup_WithNoCylSpheres_NoSpheres_NoPhysicsBspParts_YieldsEmptyShapeList()
{
var setup = new Setup
{
CylSpheres = new List<CylSphere>(),
Spheres = new List<Sphere>(),
Parts = { 0x01000ABCu },
PlacementFrames = new Dictionary<Placement, AnimationFrame>(),
};
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false);
Assert.Empty(shapes);
}
// D4+W1 (2026-06-24) — BSP-only furniture weenie guard.
//
// A Setup with no CylSpheres, no Spheres, no Radius, but whose Part has a
// PhysicsBSP (e.g. a candle holder or floor candelabra) MUST produce a BSP
// ShadowShape. The premature `if (!hasCyl && !hasSphere && !hasRadius) return`
// gate in GameWindow.RegisterLiveEntityCollision was dropped because it fired
// BEFORE ShadowShapeBuilder ran and discarded these entities entirely.
//
// Retail oracle: CPhysicsPart::find_obj_collisions@0x0050D8D0 -- when
// physics_bsp is non-null the part IS tested; the outer loop in
// CPartArray::FindObjCollisions iterates all parts regardless of
// CylSpheres/Spheres. ShadowShapeBuilder.FromSetup mirrors this by emitting
// one BSP shape per part that `hasPhysicsBsp` returns true for.
[Fact]
public void Setup_WithBspPart_NoCylSpheres_EmitsBspShape()
{
// hasPhysicsBsp predicate returns true for 0x0100AAAAu, simulating a
// GfxObj that has a PhysicsBSP but no CylSpheres/Spheres.
const uint BspGfxObjId = 0x0100AAAAu;
var setup = new Setup
{
CylSpheres = new List<CylSphere>(),
Spheres = new List<Sphere>(),
Parts = { BspGfxObjId },
PlacementFrames = new Dictionary<Placement, AnimationFrame>(),
};
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f,
hasPhysicsBsp: id => id == BspGfxObjId);
Assert.Contains(shapes, s => s.CollisionType == ShadowCollisionType.BSP);
}
// Complementary regression guard: a Setup with no CylSpheres, no Spheres,
// and a Part that has NO PhysicsBSP must still produce an empty shape list.
// The premature gate removal does not accidentally register shapeless entities.
[Fact]
public void Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList()
{
const uint NoBspGfxObjId = 0x0100BBBBu;
var setup = new Setup
{
CylSpheres = new List<CylSphere>(),
Spheres = new List<Sphere>(),
Parts = { NoBspGfxObjId },
PlacementFrames = new Dictionary<Placement, AnimationFrame>(),
};
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f,
hasPhysicsBsp: _ => false);
Assert.Empty(shapes);
}
// 2026-07-07 — the PREMISE of the player-vs-monster crowd-collision fix (the
// CSphere family port): a humanoid Setup carries body Spheres and NO
// CylSpheres, so FromSetup emits Sphere-type shadows → collision runs through
// Transition.SphereCollision (the retail CSphere::intersects_sphere family),
// NOT the cylinder path. Mirrors the human Setup 0x02000001 (two body spheres
// at 0.475 / 1.350, r=0.48 — TS-46 / the physics digest). If this ever flips
// to CylSphere-first, the crowd fix would be aimed at the wrong dispatcher.
[Fact]
public void Setup_WithBodySpheres_NoCylSpheres_EmitsSphereShapes()
{
var setup = new Setup
{
CylSpheres = new List<CylSphere>(),
Spheres = new List<Sphere>
{
new Sphere { Origin = new Vector3(0f, 0f, 0.475f), Radius = 0.48f },
new Sphere { Origin = new Vector3(0f, 0f, 1.350f), Radius = 0.48f },
},
Parts = new List<QualifiedDataId<GfxObj>>(),
PlacementFrames = new Dictionary<Placement, AnimationFrame>(),
};
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false);
Assert.Equal(2, shapes.Count);
Assert.All(shapes, s => Assert.Equal(ShadowCollisionType.Sphere, s.CollisionType));
}
}