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.