Close M1: real triangle, engine.input, screenshot-to-file capture

All three plugins M1 called for now exist over Silk.NET, and the
milestone's actual claim is proven against a live GL context, not
just argued: edit a plugin's code, rebuild just it, reload it while a
real window stays open, see the change with no app restart. Verified
directly this time — two PNGs of the same running window, before and
after a live reload (orange triangle -> green, same process, same
window, same GL context throughout) — not by analogy to the earlier
clear-color version.

engine.render, upgraded from clear-color to real geometry:
- Vertex/fragment shaders compiled and linked at Configure() time,
  with real error checking (GetShader/GetProgram *Status + InfoLog on
  failure) rather than trusting hand-typed GLSL to just work.
- VAO/VBO for a hardcoded triangle; Shutdown() deletes all three GL
  objects rather than leaking them across reloads.
- unsafe confined to Configure() (VertexAttribPointer takes a raw
  offset pointer) via a narrow, documented override of
  Directory.Build.props' default — native graphics interop, not the
  kernel data structures docs/kernel-contract.md §7 was written
  against.

engine.input: publishes IEngineInput (keyboard state) via Silk.NET.Input.
Reuses Silk.NET's own Key enum rather than inventing one, same call as
IEngineWindow.Native. Loads and constructs cleanly against a real
window; nothing reacts to it yet since there's no gameplay code to.

IScreenCapture (new, engine.render): reads the frame back via
ReadPixels and writes a PNG. No SixLabors.ImageSharp — checked its
license first and it isn't MIT/Apache (revenue-gated), which would
have been a real surprise for downstream users of an MIT engine.
PngWriter is a from-scratch encoder instead: ZLibStream (BCL, .NET 6+)
for the one genuinely hard part, a correctly zlib-wrapped DEFLATE
stream; chunk framing and CRC32 are small enough to get right and to
verify by actually decoding files this wrote (done repeatedly, by
hand, across this session).

Engine.Host: "screenshot <path>" joins "r <plugin-id>" as a live
stdin command, plus a non-interactive --screenshot/--screenshot-after-
frames pair that captures once and exits — for scripts and agents that
can't easily hold a pipe open into a long-running process.

Real bug found and fixed in PluginHost, not specific to any one
environment: loading a Contracts assembly into the Default ALC never
set up resolution for ITS OWN dependencies. engine.windowing's and
engine.render's Contracts both need Silk.NET packages and loaded fine
anyway, by accident — Engine.Host references those two Contracts
projects directly (to drive the windowed loop and screenshot capture),
so their dependencies were already sitting in Engine.Host's own output
directory. engine.input's Contracts has no such lucky coincidence and
failed with a real FileNotFoundException. Fixed by hooking
AssemblyLoadContext.Default.Resolving with an AssemblyDependencyResolver
per loaded Contracts path — mirroring what PluginLoadContext already
does for collectible ALCs, applied to the one path that never had it.
Hooked once per process via a static list/flag, not per PluginHost
instance, specifically to avoid a PluginHost instance becoming
unreclaimable through its own event subscription.

Not covered by automated tests, deliberately, same reasoning as the
windowing/render pass: opening a real window and reading back a real
framebuffer both need a real display. Verified by hand instead,
documented above and in commit history rather than asserted.

README status: M1 done, not "in progress."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
Emil
2026-09-02 04:42:54 +03:00
co-authored by Claude Sonnet 5
parent c5d3807a0f
commit f6d9ea896f
19 changed files with 587 additions and 38 deletions
+62 -2
View File
@@ -29,6 +29,16 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
PropertyNameCaseInsensitive = true,
};
// Shared across every PluginHost in the process, not per-instance:
// AssemblyLoadContext.Default.Resolving is itself process-wide, and an
// instance-bound handler on it would keep that PluginHost reachable
// forever — exactly the kind of leak this whole architecture exists to
// avoid, just aimed at a host instead of a plugin ALC. See
// EnsureDefaultResolvingHooked.
private static readonly List<AssemblyDependencyResolver> ContractResolvers = [];
private static readonly Lock ContractResolversLock = new();
private static bool _defaultResolvingHooked;
private readonly Dictionary<string, LoadedPlugin> _loaded = [];
/// <summary>
@@ -147,6 +157,26 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
return null;
}
/// <summary>
/// Loading a Contracts assembly straight into the Default ALC says
/// nothing about how ITS OWN dependencies (beyond Engine.Kernel) get
/// resolved — unlike a plugin's implementation, which always gets a
/// PluginLoadContext with a real AssemblyDependencyResolver behind it.
/// This went unnoticed for a while: engine.windowing's and
/// engine.render's Contracts both depend on Silk.NET packages, but
/// Engine.Host happens to reference those same Contracts projects
/// directly (to drive the windowed loop and screenshot capture — see
/// Program.cs), so their transitive dependencies were already sitting
/// in Engine.Host's own output directory and got found by luck via
/// normal probing. engine.input's Contracts has no such lucky
/// coincidence: Engine.Host has no reason to reference it, so its
/// Silk.NET.Input dependency wasn't anywhere the default resolution
/// order would look — a real FileNotFoundException, not a hypothetical
/// one. Hooking Default.Resolving with a resolver built against each
/// loaded Contracts path fixes it for real, rather than for whichever
/// Contracts assemblies happen to also be referenced by whatever's
/// hosting the engine this time.
/// </summary>
private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest)
{
if (manifest.Contracts is null)
@@ -158,8 +188,38 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
var alreadyLoaded = AssemblyLoadContext.Default.Assemblies
.Any(a => a.GetName().Name == name);
if (!alreadyLoaded)
AssemblyLoadContext.Default.LoadFromAssemblyPath(contractsPath);
if (alreadyLoaded)
return;
EnsureDefaultResolvingHooked();
lock (ContractResolversLock)
ContractResolvers.Add(new AssemblyDependencyResolver(contractsPath));
AssemblyLoadContext.Default.LoadFromAssemblyPath(contractsPath);
}
private static void EnsureDefaultResolvingHooked()
{
if (_defaultResolvingHooked)
return;
_defaultResolvingHooked = true;
AssemblyLoadContext.Default.Resolving += (_, name) =>
{
lock (ContractResolversLock)
{
foreach (var resolver in ContractResolvers)
{
var path = resolver.ResolveAssemblyToPath(name);
if (path is not null)
return AssemblyLoadContext.Default.LoadFromAssemblyPath(path);
}
}
return null;
};
}
private static Type FindPluginType(Assembly assembly, string pluginId)