11 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 c2bcb9b9fe M4: Stage.FixedUpdate + Time's fixed-step accumulator
Both were explicitly deferred to M4 by ITime and Stage's own doc comments
back when they were written: a FixedUpdate stage with no real accumulator
behind it would be actively misleading, and building the accumulator with
no physics system to test it against would be untested speculative
machinery. engine.physics (next) is the real consumer.

Time.ConsumeFixedSteps accumulates DeltaTime and hands back how many
FixedDeltaTime-sized (1/50s) steps it can pay for, capped at 5 per frame
so a real stall becomes visible lag instead of a catch-up burst of
physics steps. Engine.Host calls it once per frame in both the headless
and windowed loops, running Stage.FixedUpdate that many times before
Stage.Update — gated by IPlayModeController.IsPlaying the same way Update
already is, and only accumulating time while actually playing, so
entering Play doesn't open with a burst of steps for however long Edit
mode had been sitting idle.

3 new tests on the accumulator itself (below-one-step, whole-steps-plus-
remainder, the 5-step cap under a simulated stall). Full suite: 76 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:54:08 +03:00
EmilandClaude Sonnet 5 2f5a8bfde6 M3, part one: World.Snapshot()/Restore() — Play mode's actual mechanic
The TODO left on IWorld since the very first kernel scaffold commit,
closed: a deep, opaque snapshot of every GameObject and Component,
for Play/Stop to build on. Not a new clone mechanism — backed by
SceneFormat. A scene file and a Play-mode snapshot are the same
problem (capture every GameObject faithfully enough to reconstruct
it) at two different moments; reusing already-proven serialization
beats maintaining a second way to walk the same graph.

Restore() is NOT additive the way SceneFormat.Load() is by design —
it destroys every current root first. Play mode always restores onto
a world it's about to fully own; additive semantics would be the
wrong default here even though they're the right one for loading a
scene into existing content.

Verified against M3's actual "done when" (entering Play takes under
100 ms), not just round-trip correctness: 300 GameObjects, each with
a component, snapshot + restore end to end comes in well under the
100 ms bound — asserted directly with a Stopwatch, not eyeballed.
Also covers what Play mode depends on specifically: mutations,
GameObjects created or destroyed, and hierarchy changes made after
the snapshot are all discarded on Restore.

73 tests total now (49 in Engine.Kernel.Tests, 8 in
Engine.Assets.Tests, 8 in Engine.ConformanceHarness... — wait, that's
65; the two build-time contract projects add no test counts. Actual
total per the run: 49+8+8 = 65.), all green on a clean build.

Next for M3: the editor itself — hierarchy, a reflection-based
inspector, Play/Stop wired to this, gizmos. All still ahead; this
commit is only the kernel mechanic underneath Play/Stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 15:54:49 +03:00
EmilandClaude Sonnet 5 45daa6e114 M2, part one: scene format — World actually saves and loads now
The first half of M2's "done when" (a scene loads and saves) rather
than the whole milestone — the asset hot-reload half is a comparably
sized, separate chunk of work, staged on its own rather than crammed
in alongside this.

SceneFormat replaces WorldDumper rather than sitting next to it:
there was never a real reason for "what an agent reads to check a
frame" (the existing --dump) and "what a scene file actually is" to
be two different JSON shapes, and keeping them one removes the
question of which shape a save/load round trip is supposed to match.
Moved from Engine.Kernel.Diagnostics to Engine.Kernel.World to match —
this is core content loading now, not a debug tool that happens to
also serialize things.

Components are tagged "TypeFullName, AssemblyName" (partial-name
form, deliberately no version) so Type.GetType resolves them against
whatever's loaded regardless of an incidental version bump on the
plugin that defines them — full four-part AssemblyQualifiedName would
have made every saved scene brittle against that. Loading a scene
whose component type isn't loaded fails loudly, naming the missing
type, rather than silently dropping data.

New kernel API this needed: GameObject.AddComponent(Component) —
attaches an already-constructed instance, for a caller (the
deserializer) that only has a runtime Type from a file, not a
compile-time T. Deserializing straight into a real instance via
JsonSerializer.Deserialize(json, componentType) and attaching that is
simpler and more certain than constructing an empty component through
reflection and then trying to populate it after the fact.

Engine.Host: --scene now does something (was an explicit "not
implemented yet" since the very first CLI pass) — loads additively
after every plugin in --project, since a scene's component type tags
only resolve once the plugin defining them has loaded its Contracts
assembly.

Verified beyond the round-trip unit tests: two separate real CLI runs,
sandbox.echo both times. Run 1 ticks 3 frames and dumps a scene
(Ping.Count: 3). Run 2 loads that scene fresh alongside its own
newly-seeded Ping (Count: 0) and ticks 2 more frames — dump shows
Count: 2 for the fresh one and Count: 5 for the loaded one. Not just
"the file round-trips" — the loaded component's state kept being a
real, live, Scheduler-ticked object across the save/load boundary.

44 tests in Engine.Kernel.Tests now (up from 40 — 3 carried over from
WorldDumperTests plus 4 new round-trip/error-path tests), 8 in
Engine.ConformanceHarness unaffected. All green on a clean build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 05:07:14 +03:00
EmilandClaude Sonnet 5 3c60e2cd45 Close the kernel's four open questions
Real decisions with real code behind them, not just answers written
into the doc:

- Time and Log: both stay in the kernel, both on IPluginContext.
  Log was already built this way by accident; Time (DeltaTime,
  ElapsedTime, FrameCount) ships now, split into ITime (plugin-facing,
  read-only) and Time (host-facing, an internal Tick(deltaTime) only
  Engine.Host calls) — the same split Schedule/ISchedule already
  established. The fixed-step accumulator from the original kernel
  scope is explicitly NOT included: nothing exists to test it against
  yet (no physics), so building it now would be exactly the kind of
  untested speculative machinery this project has avoided everywhere
  else. It arrives with M4, alongside the Stage.FixedUpdate it would
  drive — a "FixedUpdate" stage with no real fixed-timestep semantics
  behind it would be actively misleading, not just incomplete.

- Event Bus: real Publish/Subscribe/RemoveAllFrom, not events-as-
  World-entities (a worse fit for GameObject's persistent identity
  than for a disposable-entity pure ECS). Given a real consumer
  immediately rather than shipped as an unused API: PluginHost now
  publishes PluginLoaded/PluginUnloaded, and sandbox.echo subscribes
  to PluginLoaded for real, cleaning up in Shutdown() via
  Events.RemoveAllFrom — mirroring exactly how Schedule.RemoveAllFrom
  was proven. The 200-cycle leak test in AlcUnloadTests now exercises
  this cleanup path too, not just Schedule's; still green, stable
  across repeated runs. GameWorld does NOT auto-publish on every
  GameObject/Component change — a publish on every structural change
  would tax the hot path for listeners that usually don't exist.

- Frame stages: fixed, kernel-defined, not plugin-extensible — a
  stage is part of the shared vocabulary the host's loop and every
  plugin rely on. Set stays {Update, Render} until FixedUpdate earns
  its place alongside the accumulator in M4.

- Data-oriented fast path: left open on purpose, but with a trigger
  condition instead of a deadline — revisit when a concrete system
  (particles, the standing example) needs tens of thousands of
  GameObjects updated per frame AND profiling, not intuition, shows
  GameObject/Component overhead is the actual bottleneck.

PluginHost's constructor changed shape: takes EventBus (concrete, not
IEventBus — it needs RegisterPlugin, same reason it already took
Schedule instead of ISchedule) and ITime. NullEventBus is gone;
every call site now constructs a real EventBus. Engine.Host advances
Time each frame — real wall-clock delta in --windowed (via the
window's own Native.Time), a fixed nominal 1/60s in --headless, which
has no wall clock to measure and needs to stay deterministic anyway.

48 tests total now (40 in Engine.Kernel.Tests, 8 in
Engine.ConformanceHarness), all green on a clean build. Verified by
hand too: headless run against sandbox.echo now logs "[sandbox.echo]
observed load of 'sandbox.echo'" — the EventBus subscription actually
firing, not just compiling.

docs/kernel-contract.md's open-questions footer rewritten to record
each decision and why, plus the kernel scope table (§2) and the
IPluginContext listing (§3) updated to match what's actually built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 04:57:38 +03:00
EmilandClaude Sonnet 5 c5d3807a0f M1 (in progress): engine.windowing + engine.render, hot-reload proven live
The first plugins with a real external dependency (Silk.NET) and the
first that need a display. Both built, both verified by hand against
a real window and a live GL context — not just "compiles."

engine.windowing:
- IEngineWindow, not IWindow — Silk.NET's own windowing type already
  owns that name; same lesson as the kernel's World -> GameWorld
  rename, applied proactively this time instead of hitting the build
  error first. docs/kernel-contract.md's §3 illustrative example
  updated to match.
- Exposes the real Silk.NET IWindow directly (IEngineWindow.Native)
  rather than re-wrapping it — GL context creation and event pumping
  both need it, and hiding it buys nothing yet.

engine.render:
- Minimal: glClear + SwapBuffers against a hardcoded color, no mesh.
  Enough to prove M1's actual claim, which has nothing to do with
  triangles specifically: edit a plugin, rebuild just it, reload it
  while a real window stays open, see the change with no app restart.
- No Contracts assembly — PluginManifest.Contracts is now nullable
  rather than forcing an empty assembly into existence just to satisfy
  the schema; PluginHost.Load skips the Default-ALC step when absent.

Engine.Host:
- --windowed alongside --headless: pumps window events plus
  Stage.Update/Stage.Render each frame instead of a bounded --frames
  loop. References Engine.Windowing.Contracts directly (never the
  implementation) to know how to drive that loop — same "Contracts
  are safe to share" pattern already proven for Sandbox.Echo.Contracts.
- New: typing "r <plugin-id>" + Enter reloads that plugin live. Not a
  file watcher (still not built), but real Unload+Load through the
  same PluginHost path, against a running window — this is what
  actually exercised the hot-reload claim below.
- IServiceRegistry gained TryRequire<T> so Engine.Host can ask "is a
  window available" without treating its absence as an error; a
  plugin's own Configure()/Shutdown() should keep using Require().

Verified end to end by hand: opened the window, watched it render its
hardcoded color, edited RenderPlugin.cs's ClearColor, rebuilt only
that project, typed "r engine.render" into the running process, and
watched the color change with the same window and GL context still
alive. Also found and documented a real platform gotcha along the
way: on Wayland (unlike X11), a window with no committed buffer isn't
shown at all, not even as a black rectangle — engine.windowing alone
produces an invisible window; engine.render's first Clear+SwapBuffers
is what actually makes it appear. Noted directly on RenderPlugin.

Not covered by automated tests, deliberately: opening a real window
needs a real display, which isn't safe to assume of every environment
this runs in. ServiceRegistry.TryRequire<T> and the null-Contracts
path in PluginHost are unit-tested; the window/render/reload flow is
described here and was checked by hand instead.

README status updated: M1 in progress, not done — engine.input and an
actual drawn triangle (vs. a clear color) are still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 04:19:32 +03:00
EmilandClaude Sonnet 5 246b969744 Close M0: headless CLI, JSON world dump, project-level plugin loading
The last piece of the agent loop from docs/kernel-contract.md §7:

- WorldDumper serializes a World to JSON — every GameObject, its
  transform, its components (arbitrary plugin-defined classes, so this
  leans on System.Text.Json's own reflection with IncludeFields=true,
  since components are public fields, not properties). Components are
  a list of {type, data}, not a dictionary keyed by type name:
  AddComponent<T>() doesn't enforce uniqueness, so a GameObject can
  carry two components of the same type, and a dictionary would throw
  on exactly that case — tested directly.
- PluginHost.LoadProject reads a project.json and resolves each
  referenced plugin id against engine + project-local search paths,
  finally putting ProjectManifest/PluginReference to use — they'd sat
  unused since the very first scaffold commit.
- Engine.Host is a real CLI now: `engine run --headless --plugins <dir>
  --project <project.json> --frames <n> [--dump <path>]`. --scene and
  --assert are explicitly rejected with a message pointing at why
  (no scene format yet — that's M2; no query DSL was ever actually
  specified for --assert, and jq over a plain JSON dump already covers
  that need), rather than silently ignored or generically rejected.
  Same for `engine diag why-pinned`: nothing has ever failed to unload
  in testing, so there's nothing to build that against yet.
- EchoPlugin now seeds one Ping-bearing GameObject in Configure() —
  sandbox.echo is documented as a test fixture, not a real subsystem,
  and there's no scene format yet to seed content any other way.

Verified by hand, not just by unit tests, since Program.cs itself
isn't covered by any: assembled a real flat plugin directory
(plugin.json + both built DLLs) and actually ran the CLI against
sandbox.echo end to end — 3 frames in, Ping.Count came back 3 in the
dump. Also checked every "not implemented" path (--scene, `diag`,
missing required args) prints its intended message rather than a
generic error.

32 tests total now (26 in Engine.Kernel.Tests, 6 in
Engine.ConformanceHarness), all green on a clean build.

README's Status section updated — M0 was the whole reason this
project exists (fast iteration without Unity's domain reload), and
that claim is no longer just architecture on paper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 01:07:36 +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
EmilandClaude Sonnet 5 978f34727f Implement PluginHost: two-ALC plugin loading, unloading verified live
The centerpiece of the whole "no domain reload" claim, now proven
empirically rather than argued on paper: 200 load/unload cycles against
a real plugin (sandbox.echo), each one checked with a WeakReference
that the collectible ALC actually collected — docs/kernel-contract.md
§4's leak test, previously Skip-marked since the very first scaffold,
now runs and passes (stable across repeated runs).

New pieces, minimal by design:

- PluginLoadContext: the collectible ALC a plugin's implementation
  loads into. Load() defers to whatever's already in the Default ALC
  (Engine.Kernel, the plugin's own Contracts assembly) before
  consulting AssemblyDependencyResolver for genuinely private
  dependencies — the standard .NET plugin pattern, needed so component
  types stay identical across the plugin boundary instead of loading
  as two distinct, incompatible copies.
- PluginHost: reads plugin.json, loads Contracts into the Default ALC
  (once — verified directly, not just inferred from the leak test),
  loads the implementation into a fresh PluginLoadContext, finds the
  IPlugin type via reflection, calls Configure(). Unload() calls
  Shutdown() first, then .Unload()s the ALC and hands back a
  WeakReference for the caller to check.
- Schedule, ServiceRegistry, NullEventBus, ConsoleLogger: minimal real
  implementations of the remaining IPluginContext pieces — no stage
  execution or parallelism in Schedule yet, that's separate Scheduler
  work. Schedule.RemoveAllFrom is the one piece that has to be
  correct now, not later: it's what lets a plugin's Shutdown() actually
  drop the delegate reference into its own collectible ALC, which is
  exactly what the leak test is checking end to end.

Explicitly out of scope for this pass: resolving a project's or
plugin's dependsOn graph to order loading across multiple plugins.
Nothing to test that against yet — sandbox.echo is deliberately the
only, dependency-free fixture. Noted as a TODO on PluginHost rather
than built speculatively.

Sandbox.Echo.csproj gets <EnableDynamicLoading>true</EnableDynamicLoading>
(future plugins with real dependencies will need the deps.json this
generates). Engine.ConformanceHarness.csproj now copies plugin.json and
both built DLLs into one flat directory under its own output, matching
the layout PluginHost.Load(directory) expects — via $(Configuration)/
$(TargetFramework)-aware CopyToOutputDirectory items, correct in
Release too, not just Debug.

17 tests total now (13 in Engine.Kernel.Tests, 4 in
Engine.ConformanceHarness), all passing on a clean build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 00:47:23 +03:00
EmilandClaude Sonnet 5 f040f71045 Implement World: GameObject hierarchy, components, type-indexed queries
First real piece of M0 rather than scaffolding. GameWorld : IWorld owns
GameObject creation/destruction, a type-indexed Dictionary<Type,
HashSet<GameObject>> backing Query<T>(), and hierarchy bookkeeping
(roots list, parent/children, cycle rejection on SetParent).

Two corrections to the design doc found while implementing:

- WorldMatrix can't be cached on Transform as described — Transform is
  a plain struct with no reference to the hierarchy it would need to
  compose against. Moved to GameObject.WorldMatrix, computed from the
  parent chain on read; docs/kernel-contract.md and the §3 example
  updated (go.Transform.WorldMatrix -> go.WorldMatrix).
- The concrete World class collided with its own containing namespace
  (Engine.Kernel.World.World), which makes the bare name ambiguous for
  every consumer. Renamed to GameWorld; IWorld and the World folder/
  namespace are unaffected, and the doc never named the concrete class
  either way, so nothing there needed to change.

Also fixed a real correctness bug caught while writing World.Destroy:
a GameObject can carry more than one component of the same type, so
removing one from the type index has to check whether any others of
that type remain before dropping the GameObject from the index set —
tested directly (Query_Still_Finds_A_GameObject_With_A_Duplicate_
Component_After_One_Removal).

12 tests in Engine.Kernel.Tests cover creation, hierarchy (including
cycle rejection), component add/remove/query, destroy cascading
through a subtree, and WorldMatrix composition (verified against a
worked-through parent+child translation, not just asserted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 00:33:18 +03:00
EmilandClaude Sonnet 5 1459657408 Scaffold the .sln and M0 project structure
Buildable skeleton matching docs/kernel-contract.md:

- src/Engine.Kernel — the frozen kernel. Interfaces and data types only
  (World, Component, GameObject, Transform, ISchedule, IServiceRegistry,
  IEventBus, ILogger, IPlugin/IPluginContext, plugin.json and project.json
  manifest models). No PluginHost/Scheduler/World implementation yet —
  that's M0's actual work, not scaffolding.
- src/Engine.Host — the CLI runtime entry point, placeholder for now.
- plugins/sandbox.echo — a minimal two-assembly plugin (Contracts in
  Default ALC, implementation in collectible ALC) that exists only to
  exercise the reload loop end to end once PluginHost exists.
- tests/Engine.Kernel.Tests, tests/Engine.ConformanceHarness — wired up
  with one Skip-marked placeholder test each, naming what M0 needs to
  make them real (including the 200-cycle ALC leak test from §4). The
  harness references the sandbox plugin with
  ReferenceOutputAssembly="false" so it loads it dynamically by path
  instead of linking its types into its own Default ALC.
- samples/EmptyProject — a starter project.json.
- Directory.Build.props centralizes shared settings across every
  project, including AllowUnsafeBlocks=false to enforce the §7 rule at
  the build level rather than by convention.

Solution builds clean and `dotnet test` runs both placeholders as
Skipped (not failing) — confirms the wiring, not the engine.

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