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>
This commit is contained in:
parent
0cbe1102d9
commit
96ae274081
6 changed files with 908 additions and 85 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
|
|
@ -90,4 +91,32 @@ public class ShadowShapeBuilderShapeSourceTests
|
|||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
251
tests/AcDream.Core.Tests/Physics/SphereCollisionFamilyTests.cs
Normal file
251
tests/AcDream.Core.Tests/Physics/SphereCollisionFamilyTests.cs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for the retail <c>CSphere::intersects_sphere</c> collision
|
||||
/// family port (2026-07-07) — dispatcher <c>0x00537A80</c> + <c>step_sphere_up</c>
|
||||
/// <c>0x00537900</c> + <c>slide_sphere</c> <c>0x00537440</c> +
|
||||
/// <c>land_on_sphere</c> <c>0x005379a0</c> + <c>collide_with_point</c>
|
||||
/// <c>0x00537230</c> + <c>step_sphere_down</c> <c>0x00536d20</c>. Pseudocode:
|
||||
/// docs/research/2026-07-07-csphere-collision-family-pseudocode.md.
|
||||
///
|
||||
/// <para>
|
||||
/// The driving repro: the user's live report that a player packed inside a crowd
|
||||
/// of monsters gets wedged and can't wiggle free, where retail leaves room to
|
||||
/// shuffle out. Humanoid creatures/players collide as body <b>Spheres</b>
|
||||
/// (Setup.Spheres → <see cref="ShadowShapeBuilder.FromSetup"/> emits
|
||||
/// <see cref="ShadowCollisionType.Sphere"/>), so the crowd contact runs through
|
||||
/// <c>Transition.SphereCollision</c>. Before this port that method was a
|
||||
/// hand-rolled 3-D wall-slide with a forced fixed de-penetration (register
|
||||
/// <b>TS-45</b>) — opposing radial pushes from neighbours cancelled and the
|
||||
/// player wedged. The faithful family routes the slide through the shared crease
|
||||
/// projection, restoring retail's tangential shuffle. Synthetic spheres only — no
|
||||
/// dat dependency.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class SphereCollisionFamilyTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public SphereCollisionFamilyTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint TestLandblockId = 0xA9B40000u;
|
||||
private const uint TestCellId = TestLandblockId | 0x0001u; // landcell (0,0)
|
||||
|
||||
private const float SphereRadius = 0.48f; // retail player capsule radius
|
||||
private const float SphereHeight = 1.20f;
|
||||
private const float StepUpHeight = 0.60f;
|
||||
private const float StepDownHeight = 0.04f;
|
||||
|
||||
private const float CreatureRadius = 0.48f; // a humanoid body sphere
|
||||
|
||||
/// <summary>
|
||||
/// THE headline behavior: the crease slide lets a grounded player SHUFFLE
|
||||
/// AROUND a body-sphere creature that partly blocks the path, instead of
|
||||
/// sticking to it. The creature sits slightly EAST of the player's northward
|
||||
/// line; the player walks N, grazes the creature's south-west, and must slide
|
||||
/// along it (crease = collisionNormal × ground-up) and continue past.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the retail-faithful tangential slide the port restores. The
|
||||
/// pre-2026-07-07 hand-rolled SphereCollision force-pushed the contact
|
||||
/// RADIALLY to a fixed <c>combinedR + 1 cm</c> shell (shoving the player
|
||||
/// south-west, back along its approach) — TS-45. In a crowd those radial
|
||||
/// shoves from several neighbours fight each other and wedge the player; the
|
||||
/// tangential crease slide is what leaves "room to shuffle out". The player
|
||||
/// starts CLEAR (not penetrating) — a start deep inside overlapping spheres
|
||||
/// reverts in retail too (validate_transition restores curr_pos on a
|
||||
/// non-clean step, 0x0050aa70:272593), so that is not a valid repro.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GroundedDiagonalApproach_SlidesPastOffsetCreature()
|
||||
{
|
||||
var engine = BuildEngine(out _);
|
||||
RegisterCreatureSphere(engine, 0xC0C0u, 10.35f, 12f); // slightly EAST of the N path
|
||||
|
||||
var body = MakeGroundedBody(new Vector3(10f, 10f, 0f));
|
||||
Vector3 pos = body.Position;
|
||||
uint cellId = TestCellId;
|
||||
bool grounded = true;
|
||||
var perTick = new Vector3(0f, 0.08f, 0f); // push NORTH
|
||||
|
||||
for (int tick = 0; tick < 60; tick++)
|
||||
{
|
||||
var result = engine.ResolveWithTransition(
|
||||
pos, pos + perTick, cellId,
|
||||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||
grounded, body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0);
|
||||
|
||||
body.Position = result.Position;
|
||||
pos = result.Position;
|
||||
cellId = result.CellId;
|
||||
grounded = result.IsOnGround;
|
||||
_out.WriteLine($"tick {tick,2}: ({pos.X:F3},{pos.Y:F3})");
|
||||
}
|
||||
|
||||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
|
||||
|
||||
// The creature centre is at Y=12. If the player slid around it, it ends
|
||||
// NORTH of the creature; if it stuck, it wedges near Y≈11 (surface at
|
||||
// Y = 12 − sqrt(0.96² − 0.35²) ≈ 11.11).
|
||||
Assert.True(pos.Y > 12.5f,
|
||||
$"Player must slide around the offset creature and continue north, not stick at " +
|
||||
$"its surface; got Y={pos.Y:F3}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single body-sphere creature dead ahead: the player pushing straight into
|
||||
/// it must stop AT the surface (a head-on push has no crease tangent → the
|
||||
/// slide degenerates → Collided) and must NOT penetrate or pass through.
|
||||
/// Surface contact is at Y = 11.5 − (0.48+0.48) = 10.54.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GroundedSingleCreature_HeadOnPush_BlocksWithoutPenetration()
|
||||
{
|
||||
var engine = BuildEngine(out _);
|
||||
RegisterCreatureSphere(engine, 0xC0B0u, 12f, 11.5f); // due north
|
||||
|
||||
var body = MakeGroundedBody(new Vector3(12f, 10f, 0f));
|
||||
Vector3 pos = body.Position;
|
||||
uint cellId = TestCellId;
|
||||
bool grounded = true;
|
||||
var perTick = new Vector3(0f, 0.08f, 0f); // straight in
|
||||
|
||||
for (int tick = 0; tick < 30; tick++)
|
||||
{
|
||||
var result = engine.ResolveWithTransition(
|
||||
pos, pos + perTick, cellId,
|
||||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||
grounded, body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0);
|
||||
|
||||
body.Position = result.Position;
|
||||
pos = result.Position;
|
||||
cellId = result.CellId;
|
||||
grounded = result.IsOnGround;
|
||||
}
|
||||
|
||||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
|
||||
|
||||
Assert.True(pos.Y < 10.7f,
|
||||
$"Player must be blocked at the sphere surface (Y≈10.54), not penetrate; got Y={pos.Y:F3}");
|
||||
Assert.True(pos.Y > 10.3f,
|
||||
$"Player must actually reach the creature (not stop early); got Y={pos.Y:F3}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An ETHEREAL sphere (state 0x4, non-static) is fully passable — the branch-1
|
||||
/// early-OK consume plus the caller's Layer-2 override. Guards the door/ghost
|
||||
/// passability against the family rewrite.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GroundedEtherealSphere_IsFullyPassable()
|
||||
{
|
||||
var engine = BuildEngine(out _);
|
||||
RegisterCreatureSphere(engine, 0xC0E0u, 12f, 11.5f,
|
||||
state: 0x4u, isCreatureFlag: false); // ETHEREAL_PS, non-static
|
||||
|
||||
var body = MakeGroundedBody(new Vector3(12f, 10f, 0f));
|
||||
Vector3 pos = body.Position;
|
||||
uint cellId = TestCellId;
|
||||
bool grounded = true;
|
||||
var perTick = new Vector3(0f, 0.08f, 0f);
|
||||
|
||||
for (int tick = 0; tick < 45; tick++)
|
||||
{
|
||||
var result = engine.ResolveWithTransition(
|
||||
pos, pos + perTick, cellId,
|
||||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||
grounded, body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0);
|
||||
|
||||
body.Position = result.Position;
|
||||
pos = result.Position;
|
||||
cellId = result.CellId;
|
||||
grounded = result.IsOnGround;
|
||||
}
|
||||
|
||||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
|
||||
|
||||
Assert.True(pos.Y > 12.5f,
|
||||
$"Ethereal sphere must not block (walked from 10 past the axis); got Y={pos.Y:F3}");
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Harness (mirrors CylSphereFamilyTests)
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static PhysicsEngine BuildEngine(out PhysicsDataCache cache)
|
||||
{
|
||||
cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256]; // all zero → terrain Z = 0
|
||||
engine.AddLandblock(
|
||||
landblockId: TestLandblockId,
|
||||
terrain: new TerrainSurface(heights, heightTable),
|
||||
cells: Array.Empty<CellSurface>(),
|
||||
portals: Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a humanoid body sphere at foot height (Z = SphereRadius) so the
|
||||
/// player's foot sphere overlaps it horizontally. Flagged as a creature by
|
||||
/// default (the crowd case), mimicking a live monster's body-sphere shadow.
|
||||
/// </summary>
|
||||
private static void RegisterCreatureSphere(PhysicsEngine engine, uint entityId,
|
||||
float x, float y, float radius = CreatureRadius, uint state = 0u,
|
||||
bool isCreatureFlag = true)
|
||||
{
|
||||
engine.ShadowObjects.Register(
|
||||
entityId, gfxObjId: 0u,
|
||||
new Vector3(x, y, SphereRadius), Quaternion.Identity, radius,
|
||||
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
|
||||
collisionType: ShadowCollisionType.Sphere,
|
||||
cylHeight: 0f, scale: 1f,
|
||||
state: state,
|
||||
flags: isCreatureFlag ? EntityCollisionFlags.IsCreature : EntityCollisionFlags.None,
|
||||
isStatic: false);
|
||||
}
|
||||
|
||||
private static PhysicsBody MakeGroundedBody(Vector3 position)
|
||||
{
|
||||
var floorPlane = new Plane(Vector3.UnitZ, 0f);
|
||||
var floorVerts = new[]
|
||||
{
|
||||
new Vector3(-100f, -100f, 0f),
|
||||
new Vector3( 100f, -100f, 0f),
|
||||
new Vector3( 100f, 100f, 0f),
|
||||
new Vector3(-100f, 100f, 0f),
|
||||
};
|
||||
|
||||
return new PhysicsBody
|
||||
{
|
||||
Position = position,
|
||||
Orientation = Quaternion.Identity,
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = floorPlane,
|
||||
ContactPlaneCellId = TestCellId,
|
||||
WalkablePolygonValid = true,
|
||||
WalkablePlane = floorPlane,
|
||||
WalkableVertices = floorVerts,
|
||||
WalkableUp = Vector3.UnitZ,
|
||||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue