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; } }