A full Release App run failed once at Issue181WallPressEquilibriumTests
.Diagnostic_WallPressedCamera_EyeWanderAndViewerCellStability. It passed in
isolation and did not recur across five further whole-suite runs, and the diff
under test touched only the world texture-creation stack -- nothing in camera,
visibility or physics. A cross-class parallelism race was the only plausible
mechanism, not a regression.
Ten App test classes share three process-global mutable statics, and xUnit runs
distinct test classes in parallel by default:
- CameraDiagnostics: AlignToSlope, CollideCamera, TranslationStiffness,
RotationStiffness, UseRetailChaseCamera. These are not merely written, they
are written AWAY from their defaults -- RetailChaseCameraTests sets
AlignToSlope and CollideCamera to false, and three classes set
UseRetailChaseCamera to false -- while RetailChaseCamera.Update,
CameraController.Active, CameraFrameController, WorldRenderFrameBuilder and
MouseLookController read them.
- RenderingDiagnostics.ProbeFlapEnabled, written by CornerFloodReplayTests
and Issue181WallPressEquilibriumTests.
- System.Console.Out, redirected by those same two classes to capture probe
output.
Every one of these classes already saved and restored in try/finally. That is
correct within a class and remains necessary, but it was never sufficient. A
finally bounds a mutation in TIME along its own thread; it cannot stop another
class from reading the static inside that window. Worse, two overlapping
save/restore pairs can interleave so the second restore writes back the FIRST
one's temporary value, leaving the global permanently wrong for the rest of the
run. The Console.Out case is the sharpest instance: an interleaved restore can
install a DISPOSED StringWriter as the process-wide Console.Out, which then
throws in unrelated tests. Serializing the sharers is what makes each class's
existing finally sufficient.
The fix is a marker CollectionDefinition applied to the ten sharing classes,
following the WorldEnvironmentControllerCollection precedent. No collection
fixture: several members are [Theory] cases that need different knob values per
case, so a fixture cannot own the save/restore without rewriting every member's
internals, and it would not help the read side at all. Because every member
references the same compile-time const for the collection name, the grouping
cannot silently drift via a typo.
Membership is deliberately narrow. It covers the eight writers plus two classes
that drive production code which READS a knob another member moves off its
default (HouseExitWalkReplayTests and CameraFrameControllerTests both run
RetailChaseCamera.Update and assert on the resulting eye). Classes that merely
construct a CameraController without a retail chase camera are NOT members --
their reads fall through the null branch and are insensitive.
No production code changed; no assertion was weakened, and no retry, sleep or
tolerance was added.
Verification. Base commit f6275f45 measured empirically at 3,763 passed / 3
skipped. Post-fix: 136 whole-suite Release runs. Every failure observed was in
the pre-existing zero-allocation family tracked as #250 (an Expected 0 / Actual
N bytes assertion), and none was in any collection member. A matched 55-run
baseline at f6275f45 reproduced that same family, confirming it predates this
change. Serialization cost is inside run-to-run noise: the suite is ~3 s of a
~4.5 s wall-clock dotnet test, and the ten serialized classes are a small
fraction of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
232 lines
7.7 KiB
C#
232 lines
7.7 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Combat;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Update;
|
|
using AcDream.Core.Physics;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
[Collection(CameraDiagnosticsCollection.Name)]
|
|
public sealed class CameraFrameControllerTests
|
|
{
|
|
[Fact]
|
|
public void FlyMode_UsesOneSemanticHeldInputSnapshot()
|
|
{
|
|
CameraController camera = CreateCamera();
|
|
camera.ToggleFly();
|
|
Vector3 start = camera.Fly.Position;
|
|
var input = new InputSource
|
|
{
|
|
Fly = new FlyCameraInput(
|
|
Forward: true,
|
|
Left: false,
|
|
Backward: false,
|
|
Right: false,
|
|
Up: true,
|
|
Down: false,
|
|
Boost: false),
|
|
};
|
|
CameraFrameController frame = CreateFrame(camera, input: input);
|
|
|
|
frame.Tick(new UpdateFrameTiming(0.5, 0.5f, 0.5));
|
|
|
|
Assert.Equal(1, input.FlyCaptures);
|
|
Assert.InRange(camera.Fly.Position.Y - start.Y, 5.999f, 6.001f);
|
|
Assert.InRange(camera.Fly.Position.Z - start.Z, 5.999f, 6.001f);
|
|
}
|
|
|
|
[Fact]
|
|
public void DevToolsKeyboardCapture_PausesFlyCamera()
|
|
{
|
|
CameraController camera = CreateCamera();
|
|
camera.ToggleFly();
|
|
Vector3 start = camera.Fly.Position;
|
|
var input = new InputSource
|
|
{
|
|
Fly = new FlyCameraInput(true, false, false, false, false, false, false),
|
|
};
|
|
CameraFrameController frame = CreateFrame(
|
|
camera,
|
|
capture: new CaptureSource { DevToolsKeyboard = true },
|
|
input: input);
|
|
|
|
frame.Tick(new UpdateFrameTiming(1.0, 1f, 1.0));
|
|
|
|
Assert.Equal(start, camera.Fly.Position);
|
|
Assert.Equal(0, input.FlyCaptures);
|
|
}
|
|
|
|
[Fact]
|
|
public void InboundCreatedPlayer_ProjectsThenReconcilesBeforeCameraPublication()
|
|
{
|
|
PlayerMovementController controller = CreatePlayer();
|
|
var calls = new List<string>();
|
|
var runtime = new PlayerRuntime(controller, calls);
|
|
var localFrame = new RetailLocalPlayerFrameController(
|
|
runtime,
|
|
new StillMovementInput());
|
|
CameraController camera = CreateCamera();
|
|
var legacy = new ChaseCamera();
|
|
var retail = new RetailChaseCamera();
|
|
camera.EnterChaseMode(legacy, retail);
|
|
var chase = new ChaseSource(legacy, retail);
|
|
var reconciler = new Reconciler(calls);
|
|
var frame = new CameraFrameController(
|
|
camera,
|
|
new CaptureSource(),
|
|
new InputSource(),
|
|
runtime,
|
|
chase,
|
|
localFrame,
|
|
reconciler,
|
|
new CombatTargetSource());
|
|
|
|
frame.Tick(new UpdateFrameTiming(1.0 / 60.0, 1f / 60f, 1.0));
|
|
|
|
Assert.Equal(["project", "reconcile"], calls);
|
|
Assert.NotEqual(Vector3.Zero, legacy.Position);
|
|
Assert.NotEqual(Vector3.Zero, retail.Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void PreNetworkAdvancedPlayer_DoesNotRunTheInboundCreationReconcile()
|
|
{
|
|
PlayerMovementController controller = CreatePlayer();
|
|
var calls = new List<string>();
|
|
var runtime = new PlayerRuntime(controller, calls);
|
|
var localFrame = new RetailLocalPlayerFrameController(
|
|
runtime,
|
|
new StillMovementInput());
|
|
localFrame.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
|
|
calls.Clear();
|
|
CameraController camera = CreateCamera();
|
|
var legacy = new ChaseCamera();
|
|
var retail = new RetailChaseCamera();
|
|
camera.EnterChaseMode(legacy, retail);
|
|
var frame = new CameraFrameController(
|
|
camera,
|
|
new CaptureSource(),
|
|
new InputSource(),
|
|
runtime,
|
|
new ChaseSource(legacy, retail),
|
|
localFrame,
|
|
new Reconciler(calls),
|
|
new CombatTargetSource());
|
|
|
|
frame.Tick(new UpdateFrameTiming(1.0 / 60.0, 1f / 60f, 1.0));
|
|
|
|
Assert.Empty(calls);
|
|
}
|
|
|
|
private static CameraFrameController CreateFrame(
|
|
CameraController camera,
|
|
CaptureSource? capture = null,
|
|
InputSource? input = null)
|
|
{
|
|
var runtime = new PlayerRuntime(null, []);
|
|
var localFrame = new RetailLocalPlayerFrameController(
|
|
runtime,
|
|
new StillMovementInput());
|
|
return new CameraFrameController(
|
|
camera,
|
|
capture ?? new CaptureSource(),
|
|
input ?? new InputSource(),
|
|
runtime,
|
|
new ChaseSource(null, null),
|
|
localFrame,
|
|
new Reconciler([]),
|
|
new CombatTargetSource());
|
|
}
|
|
|
|
private static CameraController CreateCamera() =>
|
|
new(new OrbitCamera(), new FlyCamera());
|
|
|
|
private static PlayerMovementController CreatePlayer()
|
|
{
|
|
var engine = new PhysicsEngine();
|
|
var heights = new byte[81];
|
|
Array.Fill(heights, (byte)50);
|
|
var heightTable = new float[256];
|
|
for (int i = 0; i < heightTable.Length; i++)
|
|
heightTable[i] = i;
|
|
engine.AddLandblock(
|
|
0xA9B4FFFFu,
|
|
new TerrainSurface(heights, heightTable),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
0f,
|
|
0f);
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
|
|
return controller;
|
|
}
|
|
|
|
private sealed class CaptureSource : IInputCaptureSource
|
|
{
|
|
public bool DevToolsKeyboard { get; set; }
|
|
public bool WantCaptureMouse => false;
|
|
public bool WantCaptureKeyboard => DevToolsKeyboard;
|
|
public bool DevToolsWantCaptureKeyboard => DevToolsKeyboard;
|
|
}
|
|
|
|
private sealed class InputSource : ICameraFrameInputSource
|
|
{
|
|
public bool IsAvailable { get; set; } = true;
|
|
public FlyCameraInput Fly { get; set; }
|
|
public ChaseCameraAdjustmentInput Chase { get; set; }
|
|
public int FlyCaptures { get; private set; }
|
|
public FlyCameraInput CaptureFly()
|
|
{
|
|
FlyCaptures++;
|
|
return Fly;
|
|
}
|
|
public ChaseCameraAdjustmentInput CaptureChaseAdjustment() => Chase;
|
|
}
|
|
|
|
private sealed class ChaseSource(
|
|
ChaseCamera? legacy,
|
|
RetailChaseCamera? retail) : IChaseCameraSource
|
|
{
|
|
public ChaseCamera? Legacy => legacy;
|
|
public RetailChaseCamera? Retail => retail;
|
|
}
|
|
|
|
private sealed class StillMovementInput : IMovementInputSource
|
|
{
|
|
public MovementInput Capture() => default;
|
|
}
|
|
|
|
private sealed class PlayerRuntime(
|
|
PlayerMovementController? controller,
|
|
List<string> calls) : ILocalPlayerFrameRuntime
|
|
{
|
|
public bool CanPresentPlayer { get; set; } = controller is not null;
|
|
public PlayerMovementController? Controller => controller;
|
|
public uint ResolveLocalEntityId() => 7u;
|
|
public void HandleTargeting() { }
|
|
public bool IsHidden => false;
|
|
public RetailObjectClockDisposition ObjectClockDisposition =>
|
|
RetailObjectClockDisposition.Advance;
|
|
public void Project(
|
|
PlayerMovementController owner,
|
|
MovementResult movement,
|
|
bool hidden) => calls.Add("project");
|
|
public void SendPreNetwork(
|
|
PlayerMovementController owner,
|
|
MovementResult movement,
|
|
bool hidden) { }
|
|
public void SendPostNetwork(PlayerMovementController owner, bool hidden) { }
|
|
}
|
|
|
|
private sealed class Reconciler(List<string> calls)
|
|
: ILiveSpatialReconcilePhase
|
|
{
|
|
public void Reconcile() => calls.Add("reconcile");
|
|
}
|
|
|
|
private sealed class CombatTargetSource : ICombatCameraTargetSource
|
|
{
|
|
public Vector3? GetTrackedTargetPoint() => null;
|
|
}
|
|
}
|