Files
EmilandClaude Sonnet 5 cd8f221ddd Fix real bugs from independent review (docs/review-handoff.md)
A second AI session read the whole codebase in parallel (read-only, no
code changes) and left a handoff doc. Addressed the correctness findings:

- PhysicsWorld.Sync's early return compared _bodies.Count to live.Count,
  not their contents — same size, different membership (destroy one
  tracked GameObject, gain one untracked-because-no-collider one; or any
  Restore where the scene has both a Rigidbody+collider object and a
  Rigidbody-without-one) skipped cleanup entirely, leaking the native
  Box3D body forever. Fixed by checking the actual stale set. Two new
  regression tests reproduce the review's own two scenarios via a new
  Lingua_GetBodyCount native export, asserting on the native table's own
  count rather than PhysicsWorld's C#-side bookkeeping.
- TryCreateBody stored handle -1 (native shim refused: invalid world, or
  its 8192-slot body table full) as if it were real — every later
  GetBodyTransform on it silently teleported the GameObject to the
  origin with a degenerate rotation, no error anywhere. Now checked and
  warned once, same as the missing-collider case.
- PluginHost.Load didn't roll back anything when Configure threw partway
  through: Schedule.Add/Events.Subscribe registrations it already made
  stayed forever, and its ALC was never unloaded — neither loaded (no
  _loaded entry) nor cleanly unloadable. Fixed with try/catch: best-effort
  Shutdown (the only thing that knows which services this plugin
  provided), RemoveAllFrom on both Schedule and EventBus, best-effort ALC
  unload, rethrow. New fixture plugin (sandbox.failing-configure, mirrors
  sandbox.echo's own real-load pattern) registers a system against a
  shared Ping component then throws, so FailedConfigureRollbackTests can
  assert the dangling system actually stops firing — an earlier version
  tried to prove this via AssemblyLoadContext.All instead, which passed
  even against the deliberately-reverted buggy code (the ALC turned out
  to get collected either way once its only references went out of
  scope); watching the dangling system is what actually distinguishes
  rolled-back from not, confirmed by deliberately reverting the fix and
  watching this specific test fail before restoring it.
- Engine.Host's "r <id>" left a plugin unloaded on a failed reload with
  no honest indication of that, and retrying threw "not loaded" instead
  of ever reaching Load again. Added PluginHost.IsLoaded so the handler
  only calls Unload when there's something to unload, and the failure
  message now says the plugin is unloaded, not just "failed."
- AssetService.ReloadWithRetry's `when (attempt < 4)` guard meant the
  5th and final IOException fell out of the loop and propagated from a
  discarded fire-and-forget Task — no log, no event, nothing. Now logged.
- EditorState.Selected kept pointing at a GameObject Restore had already
  destroyed after ExitPlay, so Inspector/gizmo would silently keep
  editing something no longer in the world. EditorPlugin.DrawUi now
  compares IsPlaying against its own previous frame (not just reacting to
  the Stop button) so this is caught whether Play was exited via the
  button or the "stop" stdin command — the same stdin-vs-real-control gap
  already hit once earlier this session — and re-resolves the selection
  by name.
- README.md and kernel-contract.md's Event Bus/Time rows had fallen a
  milestone behind (still said "no asset system yet" and "no fixed-step
  accumulator yet" after both shipped).

Full suite: 98 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 20:57:08 +03:00

220 lines
7.4 KiB
C#

using System.Numerics;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.World;
using Engine.Physics;
using Engine.Physics.Contracts;
namespace Engine.Physics.Tests;
file sealed class RecordingLogger : ILogger
{
public List<string> Warnings { get; } = [];
public void Info(string message) { }
public void Warn(string message) => Warnings.Add(message);
public void Error(string message) { }
}
public class PhysicsWorldTests
{
private static GameObject CreateGround(GameWorld world)
{
var go = world.CreateGameObject("Ground");
go.Transform = Transform.Identity;
go.AddComponent<Rigidbody>().Type = BodyType.Static;
go.AddComponent<BoxCollider>().HalfExtents = new Vector3(10f, 0.5f, 10f);
return go;
}
[Fact]
public void DynamicBox_FallsAndSettlesOnStaticGround()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
CreateGround(world);
var box = world.CreateGameObject("FallingBox");
box.Transform = Transform.Identity;
box.Transform.LocalPosition = new Vector3(0, 5, 0);
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
box.AddComponent<BoxCollider>().HalfExtents = new Vector3(0.5f, 0.5f, 0.5f);
for (var i = 0; i < 150; i++)
{
physics.Sync(world);
physics.Step(1f / 50f);
}
// Ground half-height 0.5 + box half-height 0.5 = rests at y=1.
Assert.Equal(1f, box.Transform.LocalPosition.Y, 1);
Assert.Empty(log.Warnings);
}
[Fact]
public void DynamicSphere_FallsAndSettlesOnStaticGround()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
CreateGround(world);
var sphere = world.CreateGameObject("FallingSphere");
sphere.Transform = Transform.Identity;
sphere.Transform.LocalPosition = new Vector3(0, 5, 0);
sphere.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
sphere.AddComponent<SphereCollider>().Radius = 0.5f;
for (var i = 0; i < 150; i++)
{
physics.Sync(world);
physics.Step(1f / 50f);
}
Assert.Equal(1f, sphere.Transform.LocalPosition.Y, 1);
}
[Fact]
public void RigidbodyWithoutCollider_WarnsOnceAndDoesNotThrow()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
var go = world.CreateGameObject("NoShape");
go.Transform = Transform.Identity;
go.AddComponent<Rigidbody>();
for (var i = 0; i < 5; i++)
{
physics.Sync(world);
physics.Step(1f / 50f);
}
Assert.Single(log.Warnings);
}
[Fact]
public void DestroyedGameObject_BodyCleanedUp_SubsequentStepsStillWork()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
CreateGround(world);
var box = world.CreateGameObject("Temp");
box.Transform = Transform.Identity;
box.Transform.LocalPosition = new Vector3(0, 5, 0);
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
box.AddComponent<BoxCollider>();
physics.Sync(world);
physics.Step(1f / 50f);
world.Destroy(box);
// Should not throw even though the body backing a now-destroyed
// GameObject still existed in the tracking table until this Sync.
for (var i = 0; i < 10; i++)
{
physics.Sync(world);
physics.Step(1f / 50f);
}
}
[Fact]
public void ApplyLinearImpulse_ChangesVelocity()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(Vector3.Zero, log); // no gravity, isolate the impulse
var box = world.CreateGameObject("Box");
box.Transform = Transform.Identity;
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
box.AddComponent<BoxCollider>();
physics.Sync(world); // create the body before applying an impulse to it
physics.ApplyLinearImpulse(box, new Vector3(5, 0, 0), wake: true);
var velocity = physics.GetLinearVelocity(box);
Assert.True(velocity.X > 0f, $"expected positive X velocity after impulse, got {velocity.X}");
}
// Sync's old early-return compared _bodies.Count to live.Count, not
// their contents — a real leak found by independent review, not by
// any of the tests above (all of them either never remove a
// GameObject, or do so in a way that changes the count). Both tests
// below reproduce the review's own two scenarios and assert on
// Native.Lingua_GetBodyCount() directly: the native table's own count
// is what actually leaks, and PhysicsWorld's C#-side _bodies
// dictionary alone can't prove it didn't.
[Fact]
public void Sync_DestroysStaleBody_EvenWhenLiveCountStaysTheSame()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
var a = world.CreateGameObject("A");
a.Transform = Transform.Identity;
a.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
a.AddComponent<BoxCollider>();
// B has a Rigidbody but no collider — it counts toward "live"
// every Sync (any Rigidbody does) but never gets a native body.
var b = world.CreateGameObject("B");
b.Transform = Transform.Identity;
b.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
physics.Sync(world);
Assert.Equal(1, Native.Lingua_GetBodyCount());
world.Destroy(a);
// live = {B} (1), stale _bodies = {A} (1) — same count as before
// destroying A, which is exactly what let the old bug's early
// return skip cleanup.
physics.Sync(world);
Assert.Equal(0, Native.Lingua_GetBodyCount());
}
[Fact]
public void Sync_DestroysStaleBody_AcrossARestore()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
var a = world.CreateGameObject("A");
a.Transform = Transform.Identity;
a.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
a.AddComponent<BoxCollider>();
var b = world.CreateGameObject("B"); // Rigidbody, no collider
b.Transform = Transform.Identity;
b.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
physics.Sync(world);
Assert.Equal(1, Native.Lingua_GetBodyCount());
// Restore destroys A and B and rebuilds fresh instances A'/B' from
// the snapshot. The next Sync creates A' (a new body) while old
// A's body is now orphaned — _bodies briefly holds {A, A'} (2)
// and live holds {A', B'} (2), the same count, which is exactly
// what the old bug's early return let slip through as "nothing
// stale to clean up."
var snapshot = world.Snapshot();
world.Restore(snapshot);
physics.Sync(world);
// Exactly one live native body (A'), not two (leaked old A + A').
Assert.Equal(1, Native.Lingua_GetBodyCount());
}
}