Commit Graph
2 Commits
Author SHA1 Message Date
EmilandClaude Sonnet 5 88e0afa46b Fix: exiting Play mode from the editor's own Stop button crashed
Real bug, caught by hand (not by any automated test): clicking Stop in
the Lingua Editor panel threw InvalidOperationException — "A system
structurally changed 'QuadRenderer' without declaring Writes<QuadRenderer>()".

Root cause: GameWorld.Restore rebuilds the world via GameObject.
AddComponent, which SystemAccessScope checks. That's fine when Restore is
called from unconstrained code — every existing WorldSnapshotTests test
calls it directly, outside any system, which is exactly why none of them
caught this. But PlayModeController.ExitPlay is called from inside
EditorPlugin's own Stage.Render system (the button click handler runs as
part of DrawUi), which correctly declares no Reads/Writes at all — it has
no compile-time knowledge of QuadRenderer or any other game's component
types. So the ambient SystemAccessScope was still active when Restore
tried to rebuild them.

SystemAccessScope's own doc comment already said "editor code... [is]
unconstrained by design" — true only when the call happened to originate
outside a system's scope, not actually true in general. Fixed with
SystemAccessScope.Suspend(), which GameWorld.Restore now wraps its entire
rebuild in: Restore is a bulk, whole-world reset regardless of who calls
it, the same category of operation Destroy already is (which never went
through the checked RemoveComponent path to begin with).

New regression test reproduces the exact shape: a system with no declared
access calling Restore, run through the real Schedule — not calling
SystemAccessScope directly, and not calling Restore outside a system
either, which is why this slipped through the first time. Passed
immediately after the fix; would have failed loudly before it.

Full suite: 87 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:24:14 +03:00
EmilandClaude Sonnet 5 17f66c4de5 Implement Scheduler: stage execution, conflict batching, debug enforcement
Schedule gains real execution on top of the registration bookkeeping
from the PluginHost pass: RunStage(stage, world) runs every system
registered for a stage, and Reads<>/Writes<>() declarations are
enforced live via SystemAccessScope — a system touching a component it
didn't declare throws immediately, with a message naming the
violation, from GameWorld.Query<T>() and GameObject.GetComponent<T>()/
AddComponent<T>()/RemoveComponent<T>(). Outside of a running system
(editor code, tests, scene construction) nothing is enforced.

Scope cut made deliberately, not by accident: systems are grouped into
conflict-free batches by declared access (ComputeBatches, tested
directly), but batches run sequentially rather than on real threads.
Actually parallelizing them needs GameWorld's structural changes
(Create/Destroy/AddComponent/RemoveComponent) deferred to a command
buffer first — without that, two systems with disjoint *declared*
types can still race on shared storage, since AddComponent<T>() on a
GameObject mutates that object's own component list regardless of T.
Building real concurrency on top of a known thread-safety hole would
be worse than not building it yet. Noted as a TODO on RunStage.

Two real bugs found and fixed while wiring this up, not designed in
from the start:

- ISchedule.Add took `Delegate`, and a lambda passed there doesn't
  reliably compile down to `Action<IWorld>` at runtime — the
  compiler's natural-type inference for lambdas (as opposed to method
  groups, which do work this way) can synthesize a different, private
  delegate type instead, so `is Action<IWorld>` silently failed for
  every lambda-registered system. Changed Add's parameter type to
  Action<IWorld> directly, which sidesteps the inference question
  entirely — found by ScheduleTests actually using lambdas, which
  EchoPlugin's method-group-based Tick had been masking.
- AlcUnloadTests started failing intermittently ("ALC survived unload
  cycle 3") once PluginSystemTests existed alongside it — xUnit
  parallelizes across test classes by default, and ALC-unload tests
  are sensitive to any concurrent activity in the process. Added
  [CollectionBehavior(DisableTestParallelization = true)] to the
  harness assembly; stable across 5+ repeated runs since.

EchoPlugin.Tick is no longer a stub — it increments every Ping.Count
in World, which two new integration tests in
Engine.ConformanceHarness/PluginSystemTests.cs exercise end to end: a
plugin loaded from a real collectible ALC registers a system, Schedule
actually invokes that cross-ALC delegate, and it correctly mutates a
component owned by the Default-ALC World. This only works because
PluginLoadContext resolves Sandbox.Echo.Contracts to the copy this
test project references directly, rather than loading a second,
type-incompatible one — the harness's new normal ProjectReference to
Sandbox.Echo.Contracts.csproj makes that a live assertion, not just an
implementation detail no test would notice breaking.

27 tests total now (21 in Engine.Kernel.Tests, 6 in
Engine.ConformanceHarness), all green on a clean build, harness
verified stable across repeated runs.

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