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
158 lines
6.1 KiB
C#
158 lines
6.1 KiB
C#
using Engine.Editor.Contracts;
|
|
using Engine.Input.Contracts;
|
|
using Engine.Kernel.Diagnostics;
|
|
using Engine.Kernel.Plugins;
|
|
using Engine.Kernel.Scheduling;
|
|
using Engine.Kernel.World;
|
|
using Engine.Render.Contracts;
|
|
using Engine.Windowing.Contracts;
|
|
using ImGuiNET;
|
|
using Silk.NET.OpenGL;
|
|
using Silk.NET.OpenGL.Extensions.ImGui;
|
|
|
|
namespace Engine.Editor;
|
|
|
|
/// <summary>
|
|
/// M3's editor shell: an ImGui overlay drawn on top of the running scene.
|
|
/// Registers on Stage.Render, after engine.render's own Draw (per
|
|
/// project.json's plugin order) so it paints into the same back buffer the
|
|
/// scene just drew into — and before Stage.Present's SwapBuffers, so the
|
|
/// UI isn't delayed a frame. See Stage.Present's doc comment for why that
|
|
/// split exists at all.
|
|
///
|
|
/// Nothing here reads or writes a Component through GetComponent/Query —
|
|
/// HierarchyPanel walks IWorld.Roots/GameObject.Children directly, and the
|
|
/// not-yet-built Inspector will walk GameObject.Components the same way —
|
|
/// which is why this plugin never needs to declare Reads/Writes on its
|
|
/// system: those checks only guard the typed accessors, not plain property
|
|
/// reads. See GameObject.AddComponent's own doc comment on the same gap.
|
|
/// </summary>
|
|
public sealed class EditorPlugin : IPlugin
|
|
{
|
|
private readonly EditorState _state = new();
|
|
private readonly TranslateGizmo _gizmo = new();
|
|
private GL? _gl;
|
|
private ImGuiController? _controller;
|
|
private ITime? _time;
|
|
private PlayModeController? _playMode;
|
|
private IEngineWindow? _window;
|
|
private ICameraService? _camera;
|
|
private bool _wasPlaying;
|
|
|
|
public void Configure(IPluginContext ctx)
|
|
{
|
|
var window = ctx.Services.Require<IEngineWindow>();
|
|
var input = ctx.Services.Require<IEngineInput>();
|
|
_time = ctx.Time;
|
|
_window = window;
|
|
_camera = ctx.Services.Require<ICameraService>();
|
|
|
|
window.Native.GLContext!.MakeCurrent();
|
|
_gl = window.Native.CreateOpenGL();
|
|
_controller = new ImGuiController(_gl, window.Native, input.Native);
|
|
|
|
_playMode = new PlayModeController(ctx.World, ctx.Log);
|
|
ctx.Services.Provide<IPlayModeController>(_playMode);
|
|
|
|
ctx.Schedule.Add(Stage.Render, DrawUi);
|
|
ctx.Log.Info("editor UI ready (ImGui)");
|
|
}
|
|
|
|
public void Shutdown(IPluginContext ctx)
|
|
{
|
|
ctx.Schedule.RemoveAllFrom("engine.editor");
|
|
ctx.Services.Revoke<IPlayModeController>();
|
|
_controller?.Dispose();
|
|
_gl?.Dispose();
|
|
_controller = null;
|
|
_gl = null;
|
|
_time = null;
|
|
_playMode = null;
|
|
_window = null;
|
|
_camera = null;
|
|
}
|
|
|
|
private void DrawUi(IWorld world)
|
|
{
|
|
_controller!.Update(_time!.DeltaTime);
|
|
|
|
// Nothing selected yet and there's something to select: default to
|
|
// the first root rather than opening on an empty, useless
|
|
// Inspector. Only fires once — any real click overwrites it, and
|
|
// it never fights a deliberate deselect because there's no way to
|
|
// deselect yet.
|
|
if (_state.Selected is null && world.Roots.Count > 0)
|
|
_state.Selected = world.Roots[0];
|
|
|
|
// FirstUseEver, not every frame: a real editor session lets the
|
|
// user drag panels wherever they want, and re-forcing a position
|
|
// every frame would fight that the moment they did. This only
|
|
// picks a sane, non-overlapping default before ImGui has ever
|
|
// seen these windows (or after Reset Layout, once that exists).
|
|
ImGui.SetNextWindowPos(new(10, 10), ImGuiCond.FirstUseEver);
|
|
ImGui.SetNextWindowSize(new(220, 90), ImGuiCond.FirstUseEver);
|
|
ImGui.Begin("Lingua Editor");
|
|
ImGui.Text($"FPS: {1f / MathF.Max(_time.DeltaTime, 0.0001f):F0}");
|
|
ImGui.Text($"Frame: {_time.FrameCount}");
|
|
ImGui.Separator();
|
|
|
|
if (ImGui.Button(_playMode!.IsPlaying ? "Stop" : "Play"))
|
|
{
|
|
if (_playMode.IsPlaying)
|
|
_playMode.ExitPlay();
|
|
else
|
|
_playMode.EnterPlay();
|
|
}
|
|
|
|
ImGui.SameLine();
|
|
ImGui.Text(_playMode.IsPlaying ? "(Playing)" : "(Edit mode)");
|
|
ImGui.End();
|
|
|
|
// Found by independent review: ExitPlay's Restore destroys every
|
|
// GameObject and rebuilds fresh instances from the snapshot, but
|
|
// EditorState.Selected kept pointing at the old, now-detached one
|
|
// — Inspector and the gizmo would silently keep editing an object
|
|
// no longer in the world. Comparing against last frame's own
|
|
// IsPlaying (not just reacting to the button above) catches this
|
|
// uniformly regardless of *how* Play exited — the Stop button
|
|
// here, or the "stop" stdin command Engine.Host already processed
|
|
// before this Render-stage system ran this frame; the button
|
|
// alone would miss the second path entirely, the same
|
|
// stdin-vs-real-control gap already hit once this session.
|
|
if (_wasPlaying && !_playMode.IsPlaying)
|
|
{
|
|
var previousName = _state.Selected?.Name;
|
|
_state.Selected = previousName is null ? null : FindByName(world.Roots, previousName);
|
|
}
|
|
_wasPlaying = _playMode.IsPlaying;
|
|
|
|
HierarchyPanel.Draw(world, _state);
|
|
InspectorPanel.Draw(_state);
|
|
_gizmo.Draw(_state, _camera!, _window!);
|
|
|
|
_controller.Render();
|
|
}
|
|
|
|
// Best-effort by name, not identity — Restore rebuilds fresh
|
|
// GameObject instances, so there's no identity to match against
|
|
// anymore. Two siblings sharing a name (nothing stops it, same caveat
|
|
// HierarchyPanel's own PushID-by-hashcode already documents) means
|
|
// this picks the first match, not necessarily "the same one" — an
|
|
// acceptable approximation for reselecting after Play, not a
|
|
// guarantee.
|
|
private static GameObject? FindByName(IReadOnlyList<GameObject> roots, string name)
|
|
{
|
|
foreach (var go in roots)
|
|
{
|
|
if (go.Name == name)
|
|
return go;
|
|
|
|
var found = FindByName(go.Children, name);
|
|
if (found is not null)
|
|
return found;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|