diff --git a/CheatEngine.Client.slnx b/CheatEngine.Client.slnx
index c680960..33b6f04 100644
--- a/CheatEngine.Client.slnx
+++ b/CheatEngine.Client.slnx
@@ -38,6 +38,12 @@
Path="tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj"/>
+
+
+
+
+
+
diff --git a/docs/adr/0002-plugin-activation-lifecycle.md b/docs/adr/0002-plugin-activation-lifecycle.md
index f12e95a..08d3fa0 100644
--- a/docs/adr/0002-plugin-activation-lifecycle.md
+++ b/docs/adr/0002-plugin-activation-lifecycle.md
@@ -36,10 +36,15 @@ diagnostics. Module callbacks and Client-owned CE resource draining are never ru
provider construction; reloadable runtime configuration would violate the activation boundary.
- A Client scope, cancellation token, SDK handle, Lua reference, or CE-owned resource never crosses an enable/disable
epoch. `ICheatEngineClient.Epoch` and `Stopping` identify the active lifetime.
+- Activation-local is the fresh-provider boundary, not a blanket `ServiceLifetime.Scoped` rule. A second scope from
+ one provider has fresh scoped application/module services but reuses provider singletons, options, codecs, and the
+ Client graph; it is not another activation and is not supported as a persistent-root hosting model.
- Public Client operations are synchronous. They do not retain Lua state across an `await`, and main-thread work enters
the SDK dispatcher as a bounded operation.
- The DI container owns disposal of services it created. Hosting owns the `ConfigurationManager` instance it created
- and releases it once after the scope and provider; it never disposes resolved services individually.
+ and releases it once after the scope and provider; it never disposes resolved services individually. A disposable
+ application service has one owning registration; aliases must not make one disposable instance container-owned by
+ multiple descriptors.
## Consequences and project value
diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md
index fc9af98..b836bbb 100644
--- a/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md
@@ -63,3 +63,21 @@ services.AddCheatEngineClient(configuration)
For an SDK-loaded plugin, use `CheatEngine.Client.Hosting` instead of manually building this collection. The hosting
package creates one validating provider for each enable epoch and resolves `IOptions`
immediately, so generated and semantic validation run before Client work starts.
+
+## Provider and scope contract
+
+`AddCheatEngineClient` configures one provider; it does not define a persistent application root. The supported plugin
+path builds a **fresh provider per enable epoch**, then opens one activation scope. The Core Client graph, options, and
+deterministic codecs are intentionally provider-local singleton registrations: that is safe because the provider itself
+is discarded at disable. Modules are scoped so they can consume scoped application services, but their scoped lifetime
+does not make a second scope in the same provider a fresh Client activation.
+
+Two scopes made from one external provider are ordinary sibling DI scopes. Their scoped services and modules differ,
+while provider singletons, options, and Client services remain shared until the provider is disposed. Do not reuse such
+a provider across Cheat Engine enable epochs; Client does not offer a persistent-root hosting mode or an activation
+factory for it. Any future external-provider model must specify and test its activation-bound registrations separately.
+
+The container disposes services it creates at their scope/provider boundary. Do not dispose services resolved from DI
+in a module or plugin callback, and do not register one disposable object through multiple forwarding aliases. Give the
+disposable one owning descriptor; expose an additional non-disposable facade when an application needs an alias. The
+host itself owns only its `ConfigurationManager`, which it releases after the scope and provider.
diff --git a/libs/CheatEngine.Client.Hosting/README.md b/libs/CheatEngine.Client.Hosting/README.md
index af78db4..9497bb8 100644
--- a/libs/CheatEngine.Client.Hosting/README.md
+++ b/libs/CheatEngine.Client.Hosting/README.md
@@ -31,6 +31,28 @@ On disable—or if activation fails—the host invokes `OnClientDisabling` and m
Client-owned Cheat Engine resources while the SDK context remains valid, disposes the scope and provider, and finally
disposes activation configuration. Cleanup failures are aggregated after all cleanup opportunities have run.
+## Composition lifetime and DI ownership
+
+`CheatEngineClientPlugin` is the supported composition root: every `OnEnable` creates a new
+`CheatEnginePluginBuilder`, builds a new provider, and creates one activation scope from that provider. The Core Client
+graph intentionally uses provider-local singleton registrations, so **activation-local** means “owned by this new
+provider,” not “registered with Microsoft DI's `Scoped` lifetime.” A disable/re-enable cycle therefore constructs a
+new Client graph, options cache, deterministic codecs, and module state without mechanically changing their DI
+lifetimes.
+
+Creating a second `IServiceScope` from the same provider does not create another Client activation. That second scope
+has its own scoped application services and modules, but shares the provider's singleton Client graph, options, and
+codecs; scopes are siblings, not nested activation roots. Hosting opens exactly one such scope for an enable epoch.
+An integrator that needs an external persistent root must first introduce and qualify an explicit activation-factory
+design—repeated `CreateScope()` calls are not a supported substitute.
+
+The DI container owns the objects that it creates. Hosting never disposes resolved modules or services individually:
+after lifecycle callbacks and Client resource drain, it disposes the activation scope, then the provider, and finally
+its own `ConfigurationManager`. Register an application `IDisposable` under one owning descriptor. If application code
+needs another service view of that object, use a non-disposable facade/projection or make ownership explicit; do not
+forward the same disposable instance through multiple DI descriptors and expect Hosting to de-duplicate its disposal.
+Instances supplied directly by the application remain application-owned.
+
Construction failure has a narrower rollback: only resources acquired before publication are released, once each, in
reverse construction order (`scope → provider → configuration`). Every stage is attempted even if an earlier disposal
fails. The original configuration, validation, or service-resolution failure remains primary; cleanup failures are
diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md
index 018e502..48c46bc 100644
--- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md
+++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md
@@ -13,7 +13,9 @@ this project inside the template instead of maintaining a separate `samples/` co
The project provides a minimal but production-shaped plugin boundary:
- `[CheatEnginePlugin]` is the SDK entry-point annotation recognized by the generated bootstrap.
-- `CheatEngineClientPlugin` creates a fresh DI container and Client activation for every enable cycle.
+- `CheatEngineClientPlugin` creates a fresh DI container and Client activation for every enable cycle. The Client graph,
+ options, and codecs are provider-local singletons; the module scope is the one scope inside that new provider, not a
+ persistent root that can be reused for a later enable.
- `Configure` explicitly loads the optional `appsettings.json` beside the plugin with `reloadOnChange: false` and
registers the generated `PluginLuaModule` through `AddLuaModule()`, then the application module.
- `PluginClientModule` demonstrates options, logging, a bounded AOB request, typed memory access, an Address List
@@ -70,6 +72,10 @@ list. Because Windows cannot transactionally swap a non-empty directory, run it
loading a table can execute Lua. Configuration and module registrations are rebuilt at the next plugin enable, not
reloaded while an activation is active.
+Let DI dispose objects that it creates. A module receives its disposable dependencies but does not call `Dispose` on
+them; Hosting closes the activation scope and provider after module callbacks. Register a disposable implementation
+under one owning service descriptor, and use a non-disposable facade if the application needs a second service view.
+
Before deployment, replace the illustrative AOB pattern and offset in `Modules/PluginClientModule.cs`, and choose an
application-specific Lua global name in `Modules/PluginLuaFunctions.cs`. Keep AOB operations bounded and avoid logging
memory contents or Lua scripts by default.
diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs
index e0bad9d..1aa32a5 100644
--- a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs
+++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs
@@ -23,6 +23,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
namespace CheatEngine.Client.Hosting.Tests;
@@ -326,6 +327,41 @@ public void FailedModuleEnableRollsBackAndTheSamePluginCanEnableAgain()
events);
}
+ [Fact]
+ public void FreshActivationProvidersDisposeOwnedModuleDependenciesOnceWhileAliasesAndOptionsRemainUsable()
+ {
+ List events = [];
+ FakeClient client = new(50);
+ RecordingCleanup cleanup = new(events);
+ DisposableModuleState state = new();
+ TestPlugin plugin = CreatePlugin(events, client, cleanup, builder =>
+ {
+ builder.Configuration["CheatEngineClient:AllowedTableRoots:0"] = Path.GetTempPath();
+ builder.Services.AddSingleton(state);
+ builder.Services.AddSingleton();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped(static services => new ActivationOwnedAlias(
+ services.GetRequiredService()));
+ builder.Client.AddModule();
+ });
+
+ plugin.EnableForTest();
+ plugin.DisableForTest();
+ plugin.EnableForTest();
+ plugin.DisableForTest();
+
+ Assert.True(state.AliasReferencedOwnedDisposable);
+ Assert.Equal(1, state.AllowedTableRootCount);
+ Assert.Equal(2, state.EnabledModuleIds.Count);
+ Assert.NotEqual(state.EnabledModuleIds[0], state.EnabledModuleIds[1]);
+ Assert.Equal(2, state.ProviderSingletonIds.Count);
+ Assert.NotEqual(state.ProviderSingletonIds[0], state.ProviderSingletonIds[1]);
+ Assert.Equal(2, state.ModuleDisableCount);
+ Assert.Equal(2, state.ModuleDisposeCount);
+ Assert.Equal(2, state.DependencyDisposeCount);
+ Assert.Equal(2, state.ProviderSingletonDisposeCount);
+ }
+
private static void AddFailingConstructionRegistrations(CheatEnginePluginBuilder builder, List events)
{
builder.Configuration.Sources.Add(new ThrowingDisposeConfigurationSource(events));
@@ -518,6 +554,122 @@ public bool HasFailed
}
}
+ public sealed class DisposableModuleState
+ {
+ internal bool AliasReferencedOwnedDisposable
+ {
+ get;
+ set;
+ }
+
+ internal int AllowedTableRootCount
+ {
+ get;
+ set;
+ }
+
+ internal List EnabledModuleIds
+ {
+ get;
+ } = [];
+
+ internal List ProviderSingletonIds
+ {
+ get;
+ } = [];
+
+ internal int ModuleDisableCount
+ {
+ get;
+ set;
+ }
+
+ internal int ModuleDisposeCount
+ {
+ get;
+ set;
+ }
+
+ internal int DependencyDisposeCount
+ {
+ get;
+ set;
+ }
+
+ internal int ProviderSingletonDisposeCount
+ {
+ get;
+ set;
+ }
+ }
+
+ public sealed class ProviderOwnedActivationSingleton(DisposableModuleState state) : IDisposable
+ {
+ internal Guid Id
+ {
+ get;
+ } = Guid.NewGuid();
+
+ public void Dispose()
+ {
+ state.ProviderSingletonDisposeCount++;
+ }
+ }
+
+ public sealed class ActivationOwnedDisposable(DisposableModuleState state) : IDisposable
+ {
+ public void Dispose()
+ {
+ state.DependencyDisposeCount++;
+ }
+ }
+
+ public interface IActivationOwnedAlias
+ {
+ ActivationOwnedDisposable OwnedDisposable
+ {
+ get;
+ }
+ }
+
+ public sealed class ActivationOwnedAlias(ActivationOwnedDisposable ownedDisposable) : IActivationOwnedAlias
+ {
+ public ActivationOwnedDisposable OwnedDisposable
+ {
+ get;
+ } = ownedDisposable;
+ }
+
+ public sealed class DisposableOptionsModule(
+ ProviderOwnedActivationSingleton providerOwnedSingleton,
+ ActivationOwnedDisposable ownedDisposable,
+ IActivationOwnedAlias alias,
+ IOptions options,
+ DisposableModuleState state) : ICheatEngineClientModule, IDisposable
+ {
+ private readonly Guid _id = Guid.NewGuid();
+
+ public void OnEnabled(ICheatEngineClient client)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ state.AliasReferencedOwnedDisposable = ReferenceEquals(ownedDisposable, alias.OwnedDisposable);
+ state.AllowedTableRootCount = options.Value.AllowedTableRoots?.Length ?? 0;
+ state.EnabledModuleIds.Add(_id);
+ state.ProviderSingletonIds.Add(providerOwnedSingleton.Id);
+ }
+
+ public void OnDisabling(ICheatEngineClient client)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ state.ModuleDisableCount++;
+ }
+
+ public void Dispose()
+ {
+ state.ModuleDisposeCount++;
+ }
+ }
+
private sealed class RecordingCleanup(
List events,
Exception? enterFailure = null,
diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs
index bc96221..52413ca 100644
--- a/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs
+++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs
@@ -33,4 +33,67 @@ public void BuildServiceProviderIsSingleUse()
Assert.Contains("only one provider", exception.Message, StringComparison.Ordinal);
}
+
+ [Fact]
+ public void TwoScopesShareProviderSingletonsButDisposeTheirOwnScopedServices()
+ {
+ CheatEnginePluginBuilder builder = new();
+ builder.Services.AddSingleton();
+ builder.Services.AddScoped();
+ ProviderOwnedDisposable providerOwned;
+ ScopeOwnedDisposable firstScoped;
+ ScopeOwnedDisposable secondScoped;
+
+ using (ServiceProvider provider = builder.BuildServiceProvider())
+ {
+ using (IServiceScope firstScope = provider.CreateScope())
+ {
+ providerOwned = firstScope.ServiceProvider.GetRequiredService();
+ firstScoped = firstScope.ServiceProvider.GetRequiredService();
+ }
+
+ Assert.Equal(1, firstScoped.DisposeCount);
+ Assert.Equal(0, providerOwned.DisposeCount);
+
+ using (IServiceScope secondScope = provider.CreateScope())
+ {
+ Assert.Same(providerOwned, secondScope.ServiceProvider.GetRequiredService());
+ secondScoped = secondScope.ServiceProvider.GetRequiredService();
+ }
+
+ Assert.NotSame(firstScoped, secondScoped);
+ Assert.Equal(1, secondScoped.DisposeCount);
+ Assert.Equal(0, providerOwned.DisposeCount);
+ }
+
+ Assert.Equal(1, providerOwned.DisposeCount);
+ }
+
+ private sealed class ProviderOwnedDisposable : IDisposable
+ {
+ internal int DisposeCount
+ {
+ get;
+ private set;
+ }
+
+ public void Dispose()
+ {
+ DisposeCount++;
+ }
+ }
+
+ private sealed class ScopeOwnedDisposable : IDisposable
+ {
+ internal int DisposeCount
+ {
+ get;
+ private set;
+ }
+
+ public void Dispose()
+ {
+ DisposeCount++;
+ }
+ }
}
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs
new file mode 100644
index 0000000..3d46bdd
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs
@@ -0,0 +1,57 @@
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.Loader;
+
+using CheatEngine.Client.Hosting;
+using CheatEngine.SDK.Hosting.Bootstrap;
+
+namespace LivePlugin.Coexistence;
+
+///
+/// Records copied identity facts from one fixture assembly. It observes the loader; it never creates or selects an
+/// and does not coordinate Cheat Engine or Lua state.
+///
+internal static class CoexistenceDiagnostics
+{
+ private static int s_allowedTableRootCount;
+ private static long s_epoch;
+ private static uint s_pluginId;
+
+ internal static void RecordEnabled(uint pluginId, long epoch, int allowedTableRootCount)
+ {
+ Volatile.Write(ref s_allowedTableRootCount, allowedTableRootCount);
+ Volatile.Write(ref s_epoch, epoch);
+ Volatile.Write(ref s_pluginId, pluginId);
+ }
+
+ internal static string GetIdentity(string pluginLabel, Assembly pluginAssembly)
+ {
+ Assembly clientHostingAssembly = typeof(CheatEngineClientPlugin).Assembly;
+ Assembly sdkHostingAssembly = typeof(PluginHost).Assembly;
+ AssemblyLoadContext? pluginLoadContext = AssemblyLoadContext.GetLoadContext(pluginAssembly);
+ AssemblyLoadContext? clientHostingLoadContext = AssemblyLoadContext.GetLoadContext(clientHostingAssembly);
+ AssemblyLoadContext? sdkHostingLoadContext = AssemblyLoadContext.GetLoadContext(sdkHostingAssembly);
+
+ return string.Create(
+ CultureInfo.InvariantCulture,
+ $"Plugin={pluginLabel}; PluginAssembly={pluginAssembly.FullName}; PluginMvid={pluginAssembly.ManifestModule.ModuleVersionId}; " +
+ $"ClientHostingAssembly={clientHostingAssembly.FullName}; ClientHostingMvid={clientHostingAssembly.ManifestModule.ModuleVersionId}; " +
+ $"SdkHostingAssembly={sdkHostingAssembly.FullName}; SdkHostingMvid={sdkHostingAssembly.ManifestModule.ModuleVersionId}; " +
+ $"PluginALC={Describe(pluginLoadContext)}; ClientHostingALC={Describe(clientHostingLoadContext)}; " +
+ $"SdkHostingALC={Describe(sdkHostingLoadContext)}; SameClientHostingALC={ReferenceEquals(pluginLoadContext, clientHostingLoadContext)}; " +
+ $"SameSdkHostingALC={ReferenceEquals(pluginLoadContext, sdkHostingLoadContext)}; PluginId={Volatile.Read(ref s_pluginId)}; ClientEpoch={Volatile.Read(ref s_epoch)}; " +
+ $"AllowedTableRootCount={Volatile.Read(ref s_allowedTableRootCount)}");
+ }
+
+ private static string Describe(AssemblyLoadContext? loadContext)
+ {
+ if (loadContext is null)
+ {
+ return "";
+ }
+
+ return string.Create(
+ CultureInfo.InvariantCulture,
+ $"Name={loadContext.Name ?? ""}, Collectible={loadContext.IsCollectible}");
+ }
+}
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props
new file mode 100644
index 0000000..099bc1a
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props
@@ -0,0 +1,27 @@
+
+
+
+
+ x64
+ x64
+ true
+ true
+ true
+ true
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CheatEngine.Client.LivePlugin.Coexistence.PluginA.csproj b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CheatEngine.Client.LivePlugin.Coexistence.PluginA.csproj
new file mode 100644
index 0000000..fffd4de
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CheatEngine.Client.LivePlugin.Coexistence.PluginA.csproj
@@ -0,0 +1,10 @@
+
+
+
+
+
+ CheatEngine.Client.LivePlugin.Coexistence.PluginA
+ LivePlugin.Coexistence.PluginA
+
+
+
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs
new file mode 100644
index 0000000..504b41f
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs
@@ -0,0 +1,59 @@
+using CheatEngine.Client;
+using CheatEngine.Client.Extensions.DependencyInjection;
+using CheatEngine.Client.Hosting;
+using CheatEngine.Client.Lua;
+using CheatEngine.Client.Modules;
+using CheatEngine.SDK.Annotations.Plugin;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace LivePlugin.Coexistence.PluginA;
+
+/// First half of the manual Client coexistence fixture. Its Lua export names are distinct from Plugin B's.
+[CheatEnginePlugin("CheatEngine.Client Coexistence Plugin A")]
+public sealed class CoexistencePluginA : CheatEngineClientPlugin
+{
+ ///
+ protected override void Configure(CheatEnginePluginBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.Services.AddSingleton(new CoexistencePluginIdentity(Context.PluginId));
+ builder.Client
+ .AddLuaModule()
+ .AddModule();
+ }
+}
+
+/// Records activation-local options and epoch facts without selecting a target or issuing a Client operation.
+internal sealed class CoexistencePluginAModule(
+ IOptions options,
+ CoexistencePluginIdentity pluginIdentity) : ICheatEngineClientModule
+{
+ private readonly CheatEngineClientOptions _options = options.Value;
+
+ ///
+ public void OnEnabled(ICheatEngineClient client)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client.Epoch, _options.AllowedTableRoots?.Length ?? 0);
+ }
+
+ ///
+ public void OnDisabling(ICheatEngineClient client)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ }
+}
+
+/// Declares Plugin A's generated activation-scoped Lua module.
+[CheatEngineLuaModule(typeof(CoexistencePluginAFunctions), "coexistence_a")]
+internal sealed partial class CoexistencePluginALuaModule : ILuaModule;
+
+internal sealed class CoexistencePluginIdentity(uint id)
+{
+ internal uint Id
+ {
+ get;
+ } = id;
+}
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs
new file mode 100644
index 0000000..414cf21
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs
@@ -0,0 +1,23 @@
+using CheatEngine.SDK.Annotations.Lua;
+
+namespace LivePlugin.Coexistence.PluginA;
+
+/// Distinct Lua exports used only by the manual coexistence protocol.
+internal static partial class CoexistencePluginAFunctions
+{
+ private static long s_pingCount;
+
+ /// Returns Plugin A's activation-local identity observations.
+ [LuaFunction("cheatengine_client_coexistence_a_identity")]
+ public static string Identity()
+ {
+ return CoexistenceDiagnostics.GetIdentity("A", typeof(CoexistencePluginA).Assembly);
+ }
+
+ /// Confirms that Plugin A's distinct Lua global remains callable.
+ [LuaFunction("cheatengine_client_coexistence_a_ping")]
+ public static long Ping()
+ {
+ return Interlocked.Increment(ref s_pingCount);
+ }
+}
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/README.md
new file mode 100644
index 0000000..0170197
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/README.md
@@ -0,0 +1,6 @@
+# CheatEngine.Client.LivePlugin.Coexistence.PluginA
+
+Plugin A is one half of the opt-in two-plugin fixture described by the parent
+[coexistence protocol](../README.md). It uses the public Client hosting and generated Lua-module path, with distinct
+Lua globals, and must remain in its own complete build output directory. It is not a unit test or evidence that a
+Cheat Engine loader isolates Plugin A from Plugin B.
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/packages.lock.json b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/packages.lock.json
new file mode 100644
index 0000000..30eb595
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/packages.lock.json
@@ -0,0 +1,145 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "CheatEngine.SDK": {
+ "type": "Direct",
+ "requested": "[1.0.0, )",
+ "resolved": "1.0.0",
+ "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA=="
+ },
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.12, )",
+ "resolved": "10.0.12",
+ "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
+ },
+ "Microsoft.Extensions.Configuration": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Binder": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.12",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
+ },
+ "Microsoft.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "10.0.12",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Options": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Options.ConfigurationExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.12",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Options": "10.0.12",
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Options.DataAnnotations": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Options": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ=="
+ },
+ "cheatengine.client.abstractions": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.SDK": "[1.0.0, 2.0.0)"
+ }
+ },
+ "cheatengine.client.core": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.Client.Abstractions": "[0.1.0, )",
+ "CheatEngine.SDK": "[1.0.0, 2.0.0)"
+ }
+ },
+ "cheatengine.client.extensions.dependencyinjection": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.Client.Abstractions": "[0.1.0, )",
+ "CheatEngine.Client.Core": "[0.1.0, )",
+ "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )",
+ "Microsoft.Extensions.DependencyInjection": "[10.0.12, )",
+ "Microsoft.Extensions.Logging": "[10.0.12, )",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )",
+ "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )"
+ }
+ },
+ "cheatengine.client.hosting": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )",
+ "CheatEngine.SDK": "[1.0.0, 2.0.0)",
+ "Microsoft.Extensions.DependencyInjection": "[10.0.12, )",
+ "Microsoft.Extensions.Logging": "[10.0.12, )"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CheatEngine.Client.LivePlugin.Coexistence.PluginB.csproj b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CheatEngine.Client.LivePlugin.Coexistence.PluginB.csproj
new file mode 100644
index 0000000..e7655e7
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CheatEngine.Client.LivePlugin.Coexistence.PluginB.csproj
@@ -0,0 +1,10 @@
+
+
+
+
+
+ CheatEngine.Client.LivePlugin.Coexistence.PluginB
+ LivePlugin.Coexistence.PluginB
+
+
+
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs
new file mode 100644
index 0000000..a530f86
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs
@@ -0,0 +1,59 @@
+using CheatEngine.Client;
+using CheatEngine.Client.Extensions.DependencyInjection;
+using CheatEngine.Client.Hosting;
+using CheatEngine.Client.Lua;
+using CheatEngine.Client.Modules;
+using CheatEngine.SDK.Annotations.Plugin;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace LivePlugin.Coexistence.PluginB;
+
+/// Second half of the manual Client coexistence fixture. Its Lua export names are distinct from Plugin A's.
+[CheatEnginePlugin("CheatEngine.Client Coexistence Plugin B")]
+public sealed class CoexistencePluginB : CheatEngineClientPlugin
+{
+ ///
+ protected override void Configure(CheatEnginePluginBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.Services.AddSingleton(new CoexistencePluginIdentity(Context.PluginId));
+ builder.Client
+ .AddLuaModule()
+ .AddModule();
+ }
+}
+
+/// Records activation-local options and epoch facts without selecting a target or issuing a Client operation.
+internal sealed class CoexistencePluginBModule(
+ IOptions options,
+ CoexistencePluginIdentity pluginIdentity) : ICheatEngineClientModule
+{
+ private readonly CheatEngineClientOptions _options = options.Value;
+
+ ///
+ public void OnEnabled(ICheatEngineClient client)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client.Epoch, _options.AllowedTableRoots?.Length ?? 0);
+ }
+
+ ///
+ public void OnDisabling(ICheatEngineClient client)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ }
+}
+
+/// Declares Plugin B's generated activation-scoped Lua module.
+[CheatEngineLuaModule(typeof(CoexistencePluginBFunctions), "coexistence_b")]
+internal sealed partial class CoexistencePluginBLuaModule : ILuaModule;
+
+internal sealed class CoexistencePluginIdentity(uint id)
+{
+ internal uint Id
+ {
+ get;
+ } = id;
+}
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs
new file mode 100644
index 0000000..07295ea
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs
@@ -0,0 +1,23 @@
+using CheatEngine.SDK.Annotations.Lua;
+
+namespace LivePlugin.Coexistence.PluginB;
+
+/// Distinct Lua exports used only by the manual coexistence protocol.
+internal static partial class CoexistencePluginBFunctions
+{
+ private static long s_pingCount;
+
+ /// Returns Plugin B's activation-local identity observations.
+ [LuaFunction("cheatengine_client_coexistence_b_identity")]
+ public static string Identity()
+ {
+ return CoexistenceDiagnostics.GetIdentity("B", typeof(CoexistencePluginB).Assembly);
+ }
+
+ /// Confirms that Plugin B's distinct Lua global remains callable.
+ [LuaFunction("cheatengine_client_coexistence_b_ping")]
+ public static long Ping()
+ {
+ return Interlocked.Increment(ref s_pingCount);
+ }
+}
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/README.md
new file mode 100644
index 0000000..bb50e99
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/README.md
@@ -0,0 +1,6 @@
+# CheatEngine.Client.LivePlugin.Coexistence.PluginB
+
+Plugin B is one half of the opt-in two-plugin fixture described by the parent
+[coexistence protocol](../README.md). It uses the public Client hosting and generated Lua-module path, with distinct
+Lua globals, and must remain in its own complete build output directory. It is not a unit test or evidence that a
+Cheat Engine loader isolates Plugin B from Plugin A.
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/packages.lock.json b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/packages.lock.json
new file mode 100644
index 0000000..30eb595
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/packages.lock.json
@@ -0,0 +1,145 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "CheatEngine.SDK": {
+ "type": "Direct",
+ "requested": "[1.0.0, )",
+ "resolved": "1.0.0",
+ "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA=="
+ },
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.12, )",
+ "resolved": "10.0.12",
+ "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
+ },
+ "Microsoft.Extensions.Configuration": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Binder": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.12",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
+ },
+ "Microsoft.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "10.0.12",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Options": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Options.ConfigurationExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.12",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Options": "10.0.12",
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Options.DataAnnotations": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Options": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ=="
+ },
+ "cheatengine.client.abstractions": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.SDK": "[1.0.0, 2.0.0)"
+ }
+ },
+ "cheatengine.client.core": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.Client.Abstractions": "[0.1.0, )",
+ "CheatEngine.SDK": "[1.0.0, 2.0.0)"
+ }
+ },
+ "cheatengine.client.extensions.dependencyinjection": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.Client.Abstractions": "[0.1.0, )",
+ "CheatEngine.Client.Core": "[0.1.0, )",
+ "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )",
+ "Microsoft.Extensions.DependencyInjection": "[10.0.12, )",
+ "Microsoft.Extensions.Logging": "[10.0.12, )",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )",
+ "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )"
+ }
+ },
+ "cheatengine.client.hosting": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )",
+ "CheatEngine.SDK": "[1.0.0, 2.0.0)",
+ "Microsoft.Extensions.DependencyInjection": "[10.0.12, )",
+ "Microsoft.Extensions.Logging": "[10.0.12, )"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md
new file mode 100644
index 0000000..04d453f
--- /dev/null
+++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md
@@ -0,0 +1,67 @@
+# CheatEngine.Client.LivePlugin.Coexistence
+
+This is an opt-in manual fixture for the Client-facing part of multi-plugin observation. It builds two distinct managed
+plugin assemblies, `PluginA` and `PluginB`, with the public `CheatEngineClientPlugin` composition path, generated Lua
+modules, and separate output directories. It aligns with the evidence protocol introduced by
+[SDK PR #56](https://github.com/CheatEngineNet/CheatEngine.SDK/pull/56): record the identities the exact Cheat Engine
+host actually chose; do not manufacture an `AssemblyLoadContext`, infer loader isolation from separate Client DI
+providers, or treat a successful build as a live result.
+
+## What the fixture observes
+
+Every plugin enable creates a fresh Client provider and one activation scope. Each fixture module records the SDK
+plugin ID, its local Client epoch, and options count, and its two generated Lua globals return a copied
+assembly/load-context identity or a monotonic ping count. The identity string includes the plugin, Client Hosting, and
+SDK Hosting assemblies plus their module-version IDs and load-context facts. The fixture does not select a process,
+mutate memory, allocate or release a target-owned resource, install a hook, create a loader policy, or add a
+process-wide synchronization mechanism.
+
+The two plugins deliberately use different Lua globals. That makes this fixture suitable for observing narrow
+enable/disable coexistence, but it does **not** qualify overlapping-export collision handling, shared Lua/CE state,
+worker concurrency, target switching, retained-owner safety, or side-by-side SDK versions. Those remain the
+`Specified_Not_Executed` R25/T049–T050, R26/T051–T052, and R34/T067–T068/T076 scenarios.
+
+## Package and host boundary
+
+The fixture references the changed Client Hosting/source-generator graph so a source build exercises the new Client
+contract. It still references the released `CheatEngine.SDK` 1.0.0 package directly, which supplies the SDK entry-point
+generator and bridge assets. This source fixture is not a replacement for a clean Client package consumer: package smoke
+must validate the eventual Client package. The resolved SDK package is not evidence that it contains the later SDK PR
+#56 source merge, nor is that merge a published-package or live-host qualification. Before a real run, identify the
+exact qualified Client/SDK package tuple, record package and DLL SHA-256 hashes, and keep the complete dependency
+closure for each plugin in its own directory. Never copy DLLs from one output into the other or infer the selected SDK
+version from a filename.
+
+Use the controlled Windows x64 Cheat Engine 7.7 profile. Before loading either plugin, record the Cheat Engine binary
+version/architecture/SHA-256, .NET and `hostfxr` policy, source or package identities, complete output paths and
+hashes, timestamp/operator, and the full diagnostic/Lua transcript. A missing field means an unqualified manual
+observation, not a portable hosting claim.
+
+## Build and manual protocol
+
+Build the projects independently from the repository root:
+
+```powershell
+dotnet build tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CheatEngine.Client.LivePlugin.Coexistence.PluginA.csproj -c Release
+dotnet build tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CheatEngine.Client.LivePlugin.Coexistence.PluginB.csproj -c Release
+```
+
+Keep the two output directories intact. In the controlled host's **Edit > Settings > Plugins** UI, add both plugin DLLs
+without changing the installed host configuration. Enable A, then B, and record the result of each command in the Lua
+Engine:
+
+```lua
+print(cheatengine_client_coexistence_a_identity())
+print(cheatengine_client_coexistence_b_identity())
+print(cheatengine_client_coexistence_a_ping())
+print(cheatengine_client_coexistence_b_ping())
+```
+
+Disable A and record that its two globals are absent while B's identity and ping remain callable. Re-enable A, then
+disable B and finally A, recording every enable/disable outcome. Stop and retain the failure evidence if either plugin
+cannot load or enable, a disabled plugin's global remains, or the surviving plugin stops answering. Do not repair a
+failed observation by assigning Lua globals manually.
+
+No Cheat Engine execution is performed by this repository fixture or its ordinary CI build. A managed Native AOT probe
+is publication evidence only; it does not prove that Cheat Engine can load, disable, remove, or unload a Native AOT
+plugin.