namespace Engine.Kernel.Scheduling;
///
/// Ambient, per-thread record of which component types the currently
/// running system declared via Reads<T>()/Writes<T>(). GameWorld
/// and GameObject consult this — when one is active — to enforce
/// docs/kernel-contract.md §7's rule that an undeclared access fails
/// loudly instead of silently working by accident.
///
/// No scope is active outside of Schedule.RunStage's invocation of a
/// system — editor code, tests, and initial scene construction are all
/// unconstrained by design; enforcement exists for the frame loop, not for
/// every touch of a GameObject anywhere in the process.
///
/// ThreadLocal rather than a plain static field: batches run sequentially
/// today (see the TODO on Schedule.RunStage), but this is already correct
/// for when a batch's systems run on separate threads instead.
///
internal static class SystemAccessScope
{
private static readonly ThreadLocal<(IReadOnlySet Reads, IReadOnlySet Writes)?> Current = new();
public static IDisposable Enter(IReadOnlySet reads, IReadOnlySet writes)
{
var previous = Current.Value;
Current.Value = (reads, writes);
return new Restore(previous);
}
///
/// Clears any active scope for the duration of a bulk, whole-world
/// operation — GameWorld.Restore is the one real caller. Restore
/// rebuilds arbitrary component types from a snapshot via
/// GameObject.AddComponent, and it can be invoked from inside a
/// running system (a Play/Stop button click handled during Stage.
/// Render, say) as easily as from unconstrained editor/host code. This
/// class's own doc comment already says editor code and scene
/// construction are unconstrained by design; without this, that's only
/// true when the call happens to originate outside any system's own
/// scope, not because Restore is actually exempt — a real bug, not a
/// hypothetical one: entering then exiting Play mode from the editor's
/// own Stop button throws, because ExitPlay runs inside EditorPlugin's
/// Stage.Render system, which (correctly) never declares Writes<T>()
/// for component types it has no compile-time knowledge of.
///
public static IDisposable Suspend()
{
var previous = Current.Value;
Current.Value = null;
return new Restore(previous);
}
/// Querying or fetching a component counts as a read — either
/// Reads<T>() or Writes<T>() satisfies it.
public static void CheckRead(Type componentType)
{
var scope = Current.Value;
if (scope is null)
return;
if (!scope.Value.Reads.Contains(componentType) && !scope.Value.Writes.Contains(componentType))
{
throw new InvalidOperationException(
$"A system read '{componentType.Name}' without declaring Reads<{componentType.Name}>() " +
$"or Writes<{componentType.Name}>() — see docs/kernel-contract.md §7.");
}
}
/// Structurally changing a GameObject's components — adding or
/// removing one — requires Writes<T>() specifically. Mutating a
/// component's own fields after GetComponent<T>() isn't
/// interceptable this way; see the note on GameObject.AddComponent.
public static void CheckWrite(Type componentType)
{
var scope = Current.Value;
if (scope is null)
return;
if (!scope.Value.Writes.Contains(componentType))
{
throw new InvalidOperationException(
$"A system structurally changed '{componentType.Name}' without declaring " +
$"Writes<{componentType.Name}>() — see docs/kernel-contract.md §7.");
}
}
private sealed class Restore((IReadOnlySet Reads, IReadOnlySet Writes)? previous) : IDisposable
{
public void Dispose() => Current.Value = previous;
}
}