Campaign P Slice P3 item 1. Retail CPhysicsObj::transition (0x00512dc0) seeds the collision sweep from CPartArray::GetSphere (the Setup's own <=2-sphere list, each origin+radius scaled by m_scale) via SPHEREPATH::init_sphere (0x0050c670) -- not from a symmetric two-scalar (radius, height) capsule reconstruction. The human Setup 0x02000001's authored spheres are (0,0,0.475) r=.48 and (0,0,1.350) r=.48; the old reconstruction from (0.48, 1.835) produced (0,0,0.48) + (0,0,1.355), a 5 mm head-center offset the TS-46 register row documented as a residual. Port: - SpherePath.InitPath gains a sphere-list overload (ImmutableArray< FlatCollisionSphere>, scale) sharing a new InitPathCore with the existing (radius, height) overload, which is now the degenerate 2-scalar case of the same code -- byte-for-byte unchanged, so every captured-fixture replay (CellarUpTrajectoryReplayTests, DoorBugTrajectoryReplayTests, CellarLipWedgeTests) keeps passing unmodified. - PhysicsEngine.ResolveWithTransition gains optional sphereList/ sphereScale parameters; empty/default preserves the legacy scalar path for every pre-existing caller. - LiveEntityMotionRuntimeController.GetSetupMoverShape is a new sibling of GetSetupCylinder (left untouched) that resolves the Setup's own sphere list plus Setup-derived step-up/step-down (CPartArray::GetStepUpHeight/GetStepDownHeight, 0x005180d0/0x005180f0, x ObjScale, 0.4 m fallback matching the pre-existing literal). - Threaded through PlayerMovementController (both resolve call sites, new SphereList property set by PlayerModeController.ApplyStepHeights and the Headless world projection), RuntimeRemotePhysicsUpdater (Tick + TickHidden), and RuntimeOrdinaryPhysicsUpdater.TryBegin. Remote/ordinary step heights are now Setup-derived instead of a hardcoded 0.4f literal. Projectile and camera-probe sweeps are untouched (already single-sphere-exact). - PlayerModeController.ApplyStepHeights also now applies the x ObjScale multiply to the player's own step heights (previously only the remote/ordinary paths did), closing an adjacent gap the P3 research flagged. Ts46SphereListConformanceTests proves the sphere-list overload sees the exact dat spheres (not the reconstruction), that the scalar overload is unchanged, and that ResolveWithTransition's sphereList parameter actually drives the sweep (a decoy-scalar control pair using a head-height obstacle sphere). Register: TS-46 retired (both residuals it named are closed); header count corrected to 40 active TS rows. dotnet build + dotnet test (Core.Tests 3991/2 skip, Runtime.Tests 425/0, App.Tests 3968/3 skip, complete solution build) all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
9 KiB
C#
264 lines
9 KiB
C#
using AcDream.App.Composition;
|
|
using AcDream.App.Physics;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Physics.Motion;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Tests.Composition;
|
|
|
|
public sealed class LivePresentationCompositionTests
|
|
{
|
|
[Fact]
|
|
public void RuntimeBindingsReleaseInReverseAndRetryOnlyFailedEdges()
|
|
{
|
|
var calls = new List<string>();
|
|
var bindings = new LivePresentationRuntimeBindings();
|
|
var first = new RetryBinding("first", calls, failures: 0);
|
|
var second = new RetryBinding("second", calls, failures: 1);
|
|
bindings.Adopt("first", first);
|
|
bindings.Adopt("second", second);
|
|
|
|
Assert.Throws<AggregateException>(bindings.Dispose);
|
|
Assert.Equal(["second", "first"], calls);
|
|
|
|
bindings.Dispose();
|
|
bindings.Dispose();
|
|
|
|
Assert.Equal(["second", "first", "second"], calls);
|
|
Assert.Throws<ObjectDisposedException>(() =>
|
|
bindings.Adopt("late", new RetryBinding("late", calls, 0)));
|
|
}
|
|
|
|
[Fact]
|
|
public void TransferableAdoptionRollsBackExactlyAndRetriesFailure()
|
|
{
|
|
var calls = new List<string>();
|
|
var bindings = new LivePresentationRuntimeBindings();
|
|
bindings.Adopt("stable", new RetryBinding("stable", calls, 0));
|
|
IDisposable adoption = bindings.AdoptOwned(
|
|
"candidate",
|
|
new RetryBinding("candidate", calls, 1));
|
|
|
|
Assert.Throws<InvalidOperationException>(adoption.Dispose);
|
|
adoption.Dispose();
|
|
adoption.Dispose();
|
|
bindings.Dispose();
|
|
|
|
Assert.Equal(["candidate", "candidate", "stable"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanonicalRuntimeSlotUsesExactOwnerBinding()
|
|
{
|
|
var slot = new LiveEntityRuntimeSlot();
|
|
LiveEntityRuntime first = Runtime();
|
|
LiveEntityRuntime second = Runtime();
|
|
|
|
IDisposable stale = slot.BindOwned(first);
|
|
Assert.Same(first, slot.Current);
|
|
Assert.Throws<InvalidOperationException>(() => slot.BindOwned(second));
|
|
|
|
stale.Dispose();
|
|
IDisposable current = slot.BindOwned(second);
|
|
stale.Dispose();
|
|
Assert.Same(second, slot.Current);
|
|
current.Dispose();
|
|
Assert.Null(slot.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void MotionBindingCannotBeClearedByAStaleOwner()
|
|
{
|
|
var source = new DeferredLiveEntityMotionRuntimeBindings();
|
|
var first = new MotionRuntime(1f);
|
|
var second = new MotionRuntime(2f);
|
|
var entity = new WorldEntity
|
|
{
|
|
Id = 1u,
|
|
SourceGfxObjOrSetupId = 1u,
|
|
Position = System.Numerics.Vector3.Zero,
|
|
Rotation = System.Numerics.Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
};
|
|
|
|
IDisposable stale = source.BindOwned(first);
|
|
Assert.Equal(1f, source.GetSetupCylinder(1u, entity).Radius);
|
|
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
|
|
|
|
stale.Dispose();
|
|
IDisposable current = source.BindOwned(second);
|
|
stale.Dispose();
|
|
Assert.Equal(2f, source.GetSetupCylinder(1u, entity).Radius);
|
|
current.Dispose();
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
source.GetSetupCylinder(1u, entity));
|
|
}
|
|
|
|
[Fact]
|
|
public void LandblockLoadedBridgeIsInertUntilBoundAndDeactivationIsTerminal()
|
|
{
|
|
var source = new DeferredLiveEntityLandblockLoadedSink();
|
|
var first = new LandblockSink();
|
|
var second = new LandblockSink();
|
|
|
|
source.OnLandblockLoaded(1u);
|
|
IDisposable stale = source.Bind(first);
|
|
source.OnLandblockLoaded(2u);
|
|
Assert.Equal([2u], first.Landblocks);
|
|
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
|
|
|
|
stale.Dispose();
|
|
IDisposable current = source.Bind(second);
|
|
stale.Dispose();
|
|
source.OnLandblockLoaded(3u);
|
|
Assert.Equal([3u], second.Landblocks);
|
|
|
|
source.Deactivate();
|
|
current.Dispose();
|
|
source.OnLandblockLoaded(4u);
|
|
Assert.Equal([3u], second.Landblocks);
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
|
}
|
|
|
|
[Fact]
|
|
public void GameWindowUsesLivePhaseAndContainsNoPhaseSixConstructionBody()
|
|
{
|
|
string root = FindRepoRoot();
|
|
string window = File.ReadAllText(Path.Combine(
|
|
root,
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs"));
|
|
string phase = File.ReadAllText(Path.Combine(
|
|
root,
|
|
"src",
|
|
"AcDream.App",
|
|
"Composition",
|
|
"LivePresentationComposition.cs"));
|
|
|
|
Assert.Contains("new LivePresentationCompositionPhase(", window);
|
|
Assert.DoesNotContain("new AcDream.App.World.LiveEntityRuntime(", window);
|
|
Assert.DoesNotContain("new AcDream.App.Rendering.Wb.WbDrawDispatcher(", window);
|
|
Assert.DoesNotContain("new AcDream.App.Streaming.LandblockRenderPublisher(", window);
|
|
Assert.DoesNotContain("_portalTunnelFallback.AcquirePrepared(", window);
|
|
Assert.Contains("new LiveEntityRuntime(", phase);
|
|
Assert.Contains("new WbDrawDispatcher(", phase);
|
|
Assert.Contains("new LandblockRenderPublisher(", phase);
|
|
Assert.Contains("d.PortalTunnelFallback.AcquirePrepared(", phase);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6m: portal space is composed on BOTH arms. It was the
|
|
/// last renderer this phase built only when a GL context existed, and the
|
|
/// Vulkan arm's portal-less teleport presentation went with the condition —
|
|
/// so both the gate and the stand-in are asserted absent here.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PortalSpaceIsComposedOnBothBackendArms()
|
|
{
|
|
string root = FindRepoRoot();
|
|
string phase = File.ReadAllText(Path.Combine(
|
|
root,
|
|
"src",
|
|
"AcDream.App",
|
|
"Composition",
|
|
"LivePresentationComposition.cs"));
|
|
string session = File.ReadAllText(Path.Combine(
|
|
root,
|
|
"src",
|
|
"AcDream.App",
|
|
"Composition",
|
|
"SessionPlayerComposition.cs"));
|
|
|
|
Assert.Contains(
|
|
"if (dispatcherLease.Resource is { } portalDispatcher)",
|
|
phase);
|
|
Assert.DoesNotContain(
|
|
"if (gl is not null && dispatcherLease.Resource is { } portalDispatcher)",
|
|
phase);
|
|
Assert.DoesNotContain("NullLocalPlayerTeleportPresentation", session);
|
|
Assert.DoesNotContain(
|
|
"NullLocalPlayerTeleportPresentation",
|
|
File.ReadAllText(Path.Combine(
|
|
root,
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"Gpu",
|
|
"Vk",
|
|
"VulkanCompositionFramePhases.cs")));
|
|
}
|
|
|
|
private static LiveEntityRuntime Runtime() => LiveEntityRuntimeFixture.Create(
|
|
new GpuWorldState(),
|
|
new DelegateLiveEntityResourceLifecycle(static _ => { }, static _ => { }));
|
|
|
|
private sealed class MotionRuntime(float radius)
|
|
: ILiveEntityMotionRuntimeBindings
|
|
{
|
|
public (float Radius, float Height) GetSetupCylinder(
|
|
uint serverGuid,
|
|
WorldEntity entity) => (radius, 1f);
|
|
|
|
public (System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)
|
|
GetSetupMoverShape(uint serverGuid, WorldEntity entity) =>
|
|
(System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f);
|
|
|
|
public bool RouteServerMoveTo(
|
|
MovementManager movement,
|
|
uint cellId,
|
|
WorldSession.EntityMotionUpdate update) => false;
|
|
|
|
public void StickToObjectFromWire(
|
|
IPhysicsObjHost? host,
|
|
uint targetGuid)
|
|
{
|
|
}
|
|
|
|
public void ClearTargetForHiddenEntity(uint serverGuid)
|
|
{
|
|
}
|
|
|
|
public IPhysicsObjHost? ResolvePhysicsHost(
|
|
uint serverGuid) => null;
|
|
}
|
|
|
|
private sealed class LandblockSink : ILiveEntityLandblockLoadedSink
|
|
{
|
|
public List<uint> Landblocks { get; } = [];
|
|
public void OnLandblockLoaded(uint landblockId) => Landblocks.Add(landblockId);
|
|
}
|
|
|
|
private sealed class RetryBinding(
|
|
string name,
|
|
List<string> calls,
|
|
int failures) : IDisposable
|
|
{
|
|
private int _failures = failures;
|
|
|
|
public void Dispose()
|
|
{
|
|
calls.Add(name);
|
|
if (_failures > 0)
|
|
{
|
|
_failures--;
|
|
throw new InvalidOperationException("retry");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
return directory.FullName;
|
|
directory = directory.Parent;
|
|
}
|
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
|
}
|
|
}
|