From e52e51b4fb1615306225ca7b72fabd77e6185009 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 19:11:36 +0200 Subject: [PATCH 1/7] feat: compose Client through DI and plugin Hosting Add the activation-scoped composition and plugin hosting layers. CheatEngineClientBuilder configures explicit codecs, modules, options, logging, trusted table roots, and unsafe-Lua policy. AddCheatEngineClient wires the Core dependencies into one per-activation client. CheatEngineClientPlugin builds a validated scoped provider on enable, enables modules deterministically, rolls back failures, drains owned Cheat Engine resources while the SDK context remains valid, and disposes services on disable. The aggregate CheatEngine.Client package now re-exports Fluent and Hosting as the consumer entry point. Tests cover options, codec registration, dependency graph composition, module order, activation cleanup, and public SDK-handle boundaries. Validation: - dotnet build libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj --configuration Release --no-restore --warnaserror - dotnet build libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj --configuration Release --no-restore --warnaserror - dotnet test --project tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on - dotnet test --project tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on - dotnet test --project tests/CheatEngine.Client.Tests/CheatEngine.Client.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on --- ...ient.Extensions.DependencyInjection.csproj | 18 + .../CheatEngineClientActivationCleanup.cs | 20 ++ .../CheatEngineClientBuilder.cs | 98 +++++ .../CheatEngineClientOptions.cs | 52 +++ ...eatEngineClientOptionsSemanticValidator.cs | 40 +++ ...EngineClientServiceCollectionExtensions.cs | 158 ++++++++ .../DefaultMemoryCodecs.cs | 120 +++++++ .../ICheatEngineClientActivationCleanup.cs | 13 + .../PublicAPI.Shipped.txt | 31 ++ .../PublicAPI.Unshipped.txt | 1 + .../README.md | 47 +++ .../ValidateCheatEngineClientOptions.cs | 9 + .../packages.lock.json | 167 +++++++++ .../CheatEngine.Client.Hosting.csproj | 22 ++ .../CheatEngineClientPlugin.cs | 311 ++++++++++++++++ .../CheatEnginePluginBuilder.cs | 70 ++++ .../ClientActivationLifecycle.cs | 85 +++++ .../ClientHostingLog.cs | 23 ++ .../PublicAPI.Shipped.txt | 15 + .../PublicAPI.Unshipped.txt | 1 + libs/CheatEngine.Client.Hosting/README.md | 66 ++++ .../CheatEngine.Client.Hosting.targets | 13 + .../packages.lock.json | 179 +++++++++ .../CheatEngine.Client.csproj | 9 +- src/CheatEngine.Client/PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 1 + src/CheatEngine.Client/README.md | 60 +++- src/CheatEngine.Client/packages.lock.json | 194 ++++++++++ ...xtensions.DependencyInjection.Tests.csproj | 7 + ...gineClientOptionsSemanticValidatorTests.cs | 67 ++++ ...eClientServiceCollectionExtensionsTests.cs | 177 +++++++++ .../DefaultMemoryCodecsTests.cs | 163 +++++++++ .../README.md | 26 ++ .../packages.lock.json | 317 ++++++++++++++++ .../CheatEngine.Client.Hosting.Tests.csproj | 7 + .../CheatEnginePluginBuilderTests.cs | 35 ++ .../ClientActivationLifecycleTests.cs | 159 ++++++++ .../README.md | 25 ++ .../packages.lock.json | 326 +++++++++++++++++ .../FacadeGraphSmokeTests.cs | 133 +++++++ tests/CheatEngine.Client.Tests/README.md | 28 +- .../packages.lock.json | 339 ++++++++++++++++++ 42 files changed, 3622 insertions(+), 11 deletions(-) create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientActivationCleanup.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/DefaultMemoryCodecs.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/ICheatEngineClientActivationCleanup.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/README.md create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/ValidateCheatEngineClientOptions.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/packages.lock.json create mode 100644 libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj create mode 100644 libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs create mode 100644 libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs create mode 100644 libs/CheatEngine.Client.Hosting/ClientActivationLifecycle.cs create mode 100644 libs/CheatEngine.Client.Hosting/ClientHostingLog.cs create mode 100644 libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.Client.Hosting/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.Client.Hosting/README.md create mode 100644 libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets create mode 100644 libs/CheatEngine.Client.Hosting/packages.lock.json create mode 100644 src/CheatEngine.Client/PublicAPI.Shipped.txt create mode 100644 src/CheatEngine.Client/PublicAPI.Unshipped.txt create mode 100644 src/CheatEngine.Client/packages.lock.json create mode 100644 tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj create mode 100644 tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs create mode 100644 tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs create mode 100644 tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs create mode 100644 tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/README.md create mode 100644 tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json create mode 100644 tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj create mode 100644 tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs create mode 100644 tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs create mode 100644 tests/CheatEngine.Client.Hosting.Tests/README.md create mode 100644 tests/CheatEngine.Client.Hosting.Tests/packages.lock.json create mode 100644 tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs create mode 100644 tests/CheatEngine.Client.Tests/packages.lock.json diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj new file mode 100644 index 0000000..aeb015b --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj @@ -0,0 +1,18 @@ + + + + true + + + + + + + + + + + + + + diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientActivationCleanup.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientActivationCleanup.cs new file mode 100644 index 0000000..26ff9ea --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientActivationCleanup.cs @@ -0,0 +1,20 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Delegates the Hosting cleanup boundary to the activation-owned Core lifetime. +internal sealed class CheatEngineClientActivationCleanup(CoreLifetime lifetime) + : ICheatEngineClientActivationCleanup +{ + private readonly CoreLifetime _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + + public IDisposable EnterCleanupScope() + { + return _lifetime.EnterCleanupScope(); + } + + public void DrainOwnedResourcesForDisable() + { + _lifetime.DrainOwnedResourcesForDisable(); + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs new file mode 100644 index 0000000..3186a13 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs @@ -0,0 +1,98 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Configures explicit registrations for one Cheat Engine client service provider. +/// +/// The builder never constructs a service provider. Plugin hosting creates and validates one provider for each Cheat +/// Engine activation epoch, after all registrations are complete. +/// +public sealed class CheatEngineClientBuilder +{ + internal CheatEngineClientBuilder(IServiceCollection services) + { + Services = services; + } + + /// Gets the service collection that will form a future activation provider. + public IServiceCollection Services + { + get; + } + + /// Adds a programmatic options configuration that runs after configuration binding. + public CheatEngineClientBuilder Configure(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + Services.Configure(configure); + return this; + } + + /// Binds client options from the default client section of a configuration root. + public CheatEngineClientBuilder BindConfiguration(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return BindConfiguration(configuration.GetSection(CheatEngineClientOptions.ConfigurationSectionName)); + } + + /// Binds client options from an explicitly selected configuration section. + public CheatEngineClientBuilder BindConfiguration(IConfigurationSection section) + { + ArgumentNullException.ThrowIfNull(section); + Services.AddOptions().Bind(section); + return this; + } + + /// Adds one activation module in registration order. + /// The concrete module type. + /// + /// Module construction is explicit through the generic service descriptor; no assembly scanning or runtime type + /// discovery is performed. Hosting enables modules in this order and disables successfully enabled modules in the + /// reverse order. + /// + public CheatEngineClientBuilder AddModule< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + TModule>() + where TModule : class, ICheatEngineClientModule + { + Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return this; + } + + /// Adds a singleton, deterministic codec for a managed memory value type. + /// The managed memory value type. + /// The concrete codec type. + /// + /// Codecs must not capture a Lua state, CE object, activation scope, or target-specific state. The default codecs + /// cover only fixed-width scalar and pointer representations; variable-length memory is deliberately opt-in. + /// + public CheatEngineClientBuilder AddMemoryCodec() + where TCodec : class, IMemoryCodec + { + Services.TryAdd(ServiceDescriptor.Singleton, TCodec>()); + return this; + } + + /// Opts this activation into trusted arbitrary Lua execution. + public CheatEngineClientBuilder EnableUnsafeLuaExecution() + { + Services.Configure(static options => options.EnableUnsafeLuaExecution = true); + Services.TryAddSingleton(static serviceProvider => new UnsafeLuaClient( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); + return this; + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs new file mode 100644 index 0000000..b68c861 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Configuration values used by the high-level Cheat Engine client during one plugin activation. +/// +/// The options intentionally contain only managed limits. Host capabilities, target architecture, and thread affinity +/// are established by Cheat Engine for each activation and are never configured from an application settings file. +/// +public sealed class CheatEngineClientOptions +{ + /// Gets the default configuration section used by plugin hosting. + public const string ConfigurationSectionName = "CheatEngineClient"; + + /// Gets or sets the default maximum number of AOB matches copied into managed memory. + [Range(1, 1_000_000)] + public int DefaultMaximumAobResults + { + get; + set; + } = 4_096; + + /// Gets or sets the default maximum number of value-scan matches copied in one page. + [Range(1, 1_000_000)] + public int DefaultMaximumValueScanPageSize + { + get; + set; + } = 1_024; + + /// Gets or sets absolute roots from which Client table files may be loaded or saved. + /// + /// An empty list denies table-file access by default. Paths are normalized and validated when an activation creates + /// its client scope; relative paths and blank entries are rejected. + /// + public string[] AllowedTableRoots + { + get; + set; + } = Array.Empty(); + + /// Gets or sets whether the explicit unsafe Lua execution module may be registered. + /// + /// This remains by default. Enabling it is a policy opt-in only; applications must still + /// register the optional module that exposes any unsafe operation. + /// + public bool EnableUnsafeLuaExecution + { + get; + set; + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs new file mode 100644 index 0000000..397217b --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Validates the security-sensitive options that cannot be expressed as data annotations. +public sealed class CheatEngineClientOptionsSemanticValidator : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, CheatEngineClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.AllowedTableRoots is null) + { + return ValidateOptionsResult.Fail("AllowedTableRoots must be an empty array or contain absolute paths."); + } + + HashSet roots = new(StringComparer.OrdinalIgnoreCase); + foreach (string root in options.AllowedTableRoots) + { + if (string.IsNullOrWhiteSpace(root)) + { + return ValidateOptionsResult.Fail("AllowedTableRoots cannot contain blank paths."); + } + + if (!Path.IsPathFullyQualified(root)) + { + return ValidateOptionsResult.Fail("AllowedTableRoots can contain only fully qualified paths."); + } + + string normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + if (!roots.Add(normalized)) + { + return ValidateOptionsResult.Fail("AllowedTableRoots cannot contain the same normalized path twice."); + } + } + + return ValidateOptionsResult.Success; + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs new file mode 100644 index 0000000..392e8d4 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs @@ -0,0 +1,158 @@ +using CheatEngine.Client.Core; +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Registers the high-level Cheat Engine client without building a nested service provider. +public static class CheatEngineClientServiceCollectionExtensions +{ + /// Adds the client options, deterministic memory codecs, and explicit Client service registrations. + public static CheatEngineClientBuilder AddCheatEngineClient(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLogging(); + services.AddOptions(); + services.TryAddEnumerable( + ServiceDescriptor + .Singleton, ValidateCheatEngineClientOptions>()); + services.TryAddEnumerable( + ServiceDescriptor + .Singleton, CheatEngineClientOptionsSemanticValidator>()); + DefaultMemoryCodecs.Add(services); + AddCoreServices(services); + + return new CheatEngineClientBuilder(services); + } + + /// Adds the client and binds its options from the default client section. + public static CheatEngineClientBuilder AddCheatEngineClient( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return services.AddCheatEngineClient().BindConfiguration(configuration); + } + + /// Adds the client and binds its options from an explicitly selected configuration section. + public static CheatEngineClientBuilder AddCheatEngineClient( + this IServiceCollection services, + IConfigurationSection section) + { + ArgumentNullException.ThrowIfNull(section); + return services.AddCheatEngineClient().BindConfiguration(section); + } + + /// Adds the client and applies a programmatic options configuration. + public static CheatEngineClientBuilder AddCheatEngineClient( + this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + return services.AddCheatEngineClient().Configure(configure); + } + + private static void AddCoreServices(IServiceCollection services) + { + // Every descriptor is a direct construction path. The client never scans assemblies, resolves arbitrary types, + // or creates a nested provider; Core internals are visible only to this composition assembly. + services.TryAddSingleton(static _ => CoreLifetime.Capture()); + services.TryAddSingleton(static serviceProvider => + new CheatEngineClientActivationCleanup(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + { + CheatEngineClientOptions options = + serviceProvider.GetRequiredService>().Value; + return new CoreClientPolicy(options.AllowedTableRoots, options.EnableUnsafeLuaExecution); + }); + + services.TryAddSingleton(static serviceProvider => + new SdkMainThreadDispatcher(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + new RuntimeClient( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + new ProcessClient( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + new MemoryClient(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + new PatternScanner(serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(); + + services.TryAddSingleton(static serviceProvider => + new InspectionClient( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + new TableClient( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + new LuaClient( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + + services.TryAddSingleton(static serviceProvider => + { + CoreLifetime lifetime = serviceProvider.GetRequiredService(); + ICheatEngineRuntime runtime = serviceProvider.GetRequiredService(); + ICheatEngineDispatcher dispatcher = serviceProvider.GetRequiredService(); + IProcessClient processes = serviceProvider.GetRequiredService(); + IMemoryClient memory = serviceProvider.GetRequiredService(); + IPatternScanner patterns = serviceProvider.GetRequiredService(); + IValueScanner scans = serviceProvider.GetRequiredService(); + IInspectionClient inspection = serviceProvider.GetRequiredService(); + ITableClient tables = serviceProvider.GetRequiredService(); + ILuaClient lua = serviceProvider.GetRequiredService(); + + return new CheatEngineClient( + lifetime, + new CheatEngineClientRuntimeServices(runtime, dispatcher), + new CheatEngineClientDomainServices(processes, memory, patterns, scans, inspection, tables, lua)); + }); + services.TryAddSingleton(static serviceProvider => + serviceProvider.GetRequiredService()); + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/DefaultMemoryCodecs.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/DefaultMemoryCodecs.cs new file mode 100644 index 0000000..ec9fb01 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/DefaultMemoryCodecs.cs @@ -0,0 +1,120 @@ +using System.Buffers.Binary; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using CheatEngine.Client.Memory; +using CheatEngine.SDK.Engine.Values; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +internal static class DefaultMemoryCodecs +{ + internal static void Add(IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(UnmanagedMemoryCodec.Instance)); + services.TryAdd(ServiceDescriptor.Singleton>(AddressMemoryCodec.Instance)); + } + + private sealed class UnmanagedMemoryCodec : IMemoryCodec + where T : unmanaged + { + internal static UnmanagedMemoryCodec Instance + { + get; + } = new(); + + public bool TryRead(IMemoryReadContext context, Address address, out T value) + { + ArgumentNullException.ThrowIfNull(context); + Span bytes = stackalloc byte[Unsafe.SizeOf()]; + if (context.TryReadBytes(address, bytes)) + { + value = MemoryMarshal.Read(bytes); + return true; + } + + value = default; + return false; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in T value) + { + ArgumentNullException.ThrowIfNull(context); + ReadOnlySpan values = MemoryMarshal.CreateReadOnlySpan(in value, 1); + return context.TryWriteBytes(address, MemoryMarshal.AsBytes(values)); + } + } + + private sealed class AddressMemoryCodec : IMemoryCodec
+ { + internal static AddressMemoryCodec Instance + { + get; + } = new(); + + public bool TryRead(IMemoryReadContext context, Address address, out Address value) + { + ArgumentNullException.ThrowIfNull(context); + if (!IsSupportedPointerSize(context.PointerSize)) + { + value = default; + return false; + } + + Span bytes = stackalloc byte[sizeof(ulong)]; + Span target = bytes[..context.PointerSize]; + if (!context.TryReadBytes(address, target)) + { + value = default; + return false; + } + + value = Address.FromUInt64(context.PointerSize == sizeof(ulong) + ? BinaryPrimitives.ReadUInt64LittleEndian(target) + : BinaryPrimitives.ReadUInt32LittleEndian(target)); + return true; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in Address value) + { + ArgumentNullException.ThrowIfNull(context); + if (!IsSupportedPointerSize(context.PointerSize) || + (context.PointerSize == sizeof(uint) && value.Value > uint.MaxValue)) + { + return false; + } + + Span bytes = stackalloc byte[sizeof(ulong)]; + Span target = bytes[..context.PointerSize]; + if (context.PointerSize == sizeof(ulong)) + { + BinaryPrimitives.WriteUInt64LittleEndian(target, value.Value); + } + else + { + BinaryPrimitives.WriteUInt32LittleEndian(target, (uint) value.Value); + } + + return context.TryWriteBytes(address, target); + } + + private static bool IsSupportedPointerSize(int pointerSize) + { + return pointerSize is sizeof(uint) or sizeof(ulong); + } + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/ICheatEngineClientActivationCleanup.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/ICheatEngineClientActivationCleanup.cs new file mode 100644 index 0000000..00bf812 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/ICheatEngineClientActivationCleanup.cs @@ -0,0 +1,13 @@ +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Internal bridge that gives Hosting its one, constrained shutdown-cleanup path. +/// +/// It is intentionally not a public Client service. The bridge does not reopen ordinary work admission: Core accepts +/// only the already-active Cheat Engine main thread while the SDK lifecycle callback is still executing. +/// +internal interface ICheatEngineClientActivationCleanup +{ + public IDisposable EnterCleanupScope(); + + public void DrainOwnedResourcesForDisable(); +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt new file mode 100644 index 0000000..be9e43f --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt @@ -0,0 +1,31 @@ +#nullable enable +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.AddMemoryCodec() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.AddModule() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.BindConfiguration(Microsoft.Extensions.Configuration.IConfiguration! configuration) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.BindConfiguration(Microsoft.Extensions.Configuration.IConfigurationSection! section) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.Configure(System.Action! configure) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.EnableUnsafeLuaExecution() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.get -> string![]! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.set -> void +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.CheatEngineClientOptions() -> void +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumAobResults.get -> int +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumAobResults.set -> void +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumValueScanPageSize.get -> int +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumValueScanPageSize.set -> void +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.EnableUnsafeLuaExecution.get -> bool +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.EnableUnsafeLuaExecution.set -> void +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator.CheatEngineClientOptionsSemanticValidator() -> void +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator.Validate(string? name, CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions! options) -> Microsoft.Extensions.Options.ValidateOptionsResult! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions +CheatEngine.Client.Extensions.DependencyInjection.ValidateCheatEngineClientOptions +CheatEngine.Client.Extensions.DependencyInjection.ValidateCheatEngineClientOptions.Validate(string? name, CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions! options) -> Microsoft.Extensions.Options.ValidateOptionsResult! +CheatEngine.Client.Extensions.DependencyInjection.ValidateCheatEngineClientOptions.ValidateCheatEngineClientOptions() -> void +const CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.ConfigurationSectionName = "CheatEngineClient" -> string! +static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.Configuration.IConfigurationSection! section) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +static CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientServiceCollectionExtensions.AddCheatEngineClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md new file mode 100644 index 0000000..cd9543f --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md @@ -0,0 +1,47 @@ +# CheatEngine.Client.Extensions.DependencyInjection + +Explicit, AOT-aware dependency-injection composition for the high-level Cheat Engine client. + +## Context + +`CheatEngine.Client.Extensions.DependencyInjection` is the composition package for Client contracts and their internal implementations. It registers the public domain services—`ICheatEngineClient`, memory, process, scanning, inspection, table, Lua, runtime, and dispatch services—without making consumers reference implementation namespaces. + +It depends on `Microsoft.Extensions.DependencyInjection`, `Microsoft.Extensions.Options`, and `Microsoft.Extensions.Configuration`. The package enables the .NET configuration-binding generator and uses generated options validation for `CheatEngineClientOptions`; it does not require the Generic Host. + +## Why this project exists + +Cheat Engine plugins are created by the SDK through parameterless construction and have a bounded enable/disable lifetime. A Client provider must therefore be composed explicitly for that lifetime: no global container, service discovery, assembly scanning, or nested `ServiceProvider` is needed. + +This package keeps the high-level API DI-first while preserving the Client's trimming and AOT constraints. It is the only supported place to wire Core implementation types into the functional public contracts. + +## How it helps CheatEngine.Client + +`AddCheatEngineClient` adds direct service registrations, the built-in deterministic memory codecs, options services, and the Client facade. It returns a `CheatEngineClientBuilder`; the builder only adds registrations and never builds a provider. + +Configuration is always opt-in. `BindConfiguration(IConfiguration)` reads the `CheatEngineClient` section, while `BindConfiguration(IConfigurationSection)` lets a plugin choose a different explicit section. The package never searches for, loads, or watches `appsettings.json` on its own. + +`CheatEngineClientOptions` controls managed limits and table-file policy: + +- `DefaultMaximumAobResults` and `DefaultMaximumValueScanPageSize` bound managed result materialization. +- `AllowedTableRoots` is empty by default, which denies table-file load/save access. +- `EnableUnsafeLuaExecution` remains disabled unless the builder explicitly enables it. + +The builder also provides explicit extension points: + +- `AddModule()` preserves module registration order; Hosting enables modules in that order and disables them in reverse order. +- `AddMemoryCodec()` adds a singleton deterministic codec without reflective structure marshalling. +- `EnableUnsafeLuaExecution()` registers the unsafe Lua facade only for the current activation policy. It never exposes an SDK `LuaState`. + +```csharp +using CheatEngine.Client.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +IConfiguration configuration = new ConfigurationBuilder().Build(); + +services.AddCheatEngineClient(configuration) + .AddMemoryCodec(); +``` + +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. diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/ValidateCheatEngineClientOptions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/ValidateCheatEngineClientOptions.cs new file mode 100644 index 0000000..f46350c --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/ValidateCheatEngineClientOptions.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Provides generated, trimming-safe validation for . +[OptionsValidator] +public sealed partial class ValidateCheatEngineClientOptions : IValidateOptions +{ +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/packages.lock.json b/libs/CheatEngine.Client.Extensions.DependencyInjection/packages.lock.json new file mode 100644 index 0000000..1747fcb --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/packages.lock.json @@ -0,0 +1,167 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Direct", + "requested": "[10.0.12, )", + "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.Options.ConfigurationExtensions": { + "type": "Direct", + "requested": "[10.0.12, )", + "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": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "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.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.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "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.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + } + } + } +} diff --git a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj new file mode 100644 index 0000000..1ba46be --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj @@ -0,0 +1,22 @@ + + + + true + true + + + + + + + + + + + + + + diff --git a/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs new file mode 100644 index 0000000..d3f15f7 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs @@ -0,0 +1,311 @@ +using System.Runtime.ExceptionServices; + +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Hosting.Plugin; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Hosting; + +/// Base class that activates a scoped for each Cheat Engine enable epoch. +/// +/// The SDK constructs a plugin through a parameterless factory and reuses that instance across enable/disable cycles. +/// This base class therefore creates a fresh validated provider and scope only from , when the +/// SDK has attached Lua, and disposes them before the SDK detaches Lua in . It does not create +/// a +/// Generic Host, discover assemblies, retain a Lua state, or cross an asynchronous boundary. +/// +public abstract class CheatEngineClientPlugin : CheatEnginePlugin +{ + private Activation? _activation; + + /// Initializes the SDK-loadable plugin base through its required public parameterless constructor. + /// + /// The SDK creates the concrete plugin type through a public parameterless constructor. Keeping this base + /// constructor public preserves the constructor chain required by that generated loading path. + /// + public CheatEngineClientPlugin() + { + } + + /// Gets the client for the active enable epoch. + /// The plugin is not currently enabled. + protected ICheatEngineClient Client => GetActiveClient(); + + /// Adds application services, explicit Client modules, codecs, and configuration sources for one activation. + /// + /// Do not build a provider here. The base class builds it after this method returns with scope and build validation + /// enabled. Application services that use Client APIs should be scoped and receive their dependencies by constructor + /// injection; the plugin itself is the one unavoidable composition boundary because SDK plugins use parameterless + /// construction. + /// + protected abstract void Configure(CheatEnginePluginBuilder builder); + + /// Runs after all registered Client modules have enabled successfully. + /// The client for the current activation. + protected virtual void OnClientEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + } + + /// Runs before enabled Client modules are disabled in reverse registration order. + /// The client for the current activation. + protected virtual void OnClientDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + } + + /// + protected sealed override void OnEnable() + { + if (Volatile.Read(ref _activation) is not null) + { + throw new CheatEngineClientLifecycleException( + "EnableClient", + "The Cheat Engine client is already active for this plugin instance."); + } + + CheatEnginePluginBuilder builder = new(); + Activation? activation = null; + + try + { + Configure(builder); + activation = CreateActivation(builder); + Volatile.Write(ref _activation, activation); + + activation.Lifecycle.Enable(OnClientEnabled); + ClientHostingLog.ActivationEnabled(activation.Logger, activation.Client.Epoch); + } + catch (Exception enableFailure) + { + if (activation is null) + { + builder.ReleaseConfiguration(); + throw; + } + + Interlocked.CompareExchange(ref _activation, null, activation); + ClientHostingLog.ActivationRollingBack(activation.Logger, activation.Client.Epoch); + RethrowAfterCleanup(enableFailure, + CleanupActivation(activation)); + } + } + + /// + protected sealed override void OnDisable() + { + Activation? activation = Interlocked.Exchange(ref _activation, null); + if (activation is null) + { + return; + } + + ClientHostingLog.ActivationDisabling(activation.Logger, activation.Client.Epoch); + List failures = CleanupActivation(activation); + if (failures.Count > 0) + { + ClientHostingLog.ActivationCleanupFailed(activation.Logger, activation.Client.Epoch, failures.Count); + } + else + { + ClientHostingLog.ActivationDisabled(activation.Logger, activation.Client.Epoch); + } + + ThrowCleanupFailures(failures); + } + + private static Activation CreateActivation(CheatEnginePluginBuilder builder) + { + ServiceProvider? provider = null; + IServiceScope? scope = null; + + try + { + provider = builder.BuildServiceProvider(); + scope = provider.CreateScope(); + + // IOptions.Value invokes the generated validator in plugin hosts that do not run Generic Host startup. + _ = scope.ServiceProvider.GetRequiredService>().Value; + ICheatEngineClient client = scope.ServiceProvider.GetRequiredService(); + ICheatEngineClientModule[] modules = GetModules(scope.ServiceProvider); + ILogger logger = + scope.ServiceProvider.GetRequiredService>(); + ICheatEngineClientActivationCleanup cleanup = + scope.ServiceProvider.GetRequiredService(); + + return new Activation(builder, provider, scope, client, modules, logger, cleanup); + } + catch + { + scope?.Dispose(); + provider?.Dispose(); + builder.ReleaseConfiguration(); + throw; + } + } + + private static ICheatEngineClientModule[] GetModules(IServiceProvider services) + { + List result = new(); + foreach (ICheatEngineClientModule module in services.GetServices()) + { + result.Add(module); + } + + return result.ToArray(); + } + + private static void RethrowAfterCleanup(Exception enableFailure, List cleanupFailures) + { + if (cleanupFailures.Count == 0) + { + ExceptionDispatchInfo.Capture(enableFailure).Throw(); + } + + cleanupFailures.Insert(0, enableFailure); + throw new AggregateException("Client activation failed and rollback encountered additional failures.", + cleanupFailures); + } + + private List CleanupActivation(Activation activation) + { + List failures = new(); + try + { + using (activation.Cleanup.EnterCleanupScope()) + { + failures.AddRange(activation.Lifecycle.Cleanup(OnClientDisabling)); + try + { + activation.Cleanup.DrainOwnedResourcesForDisable(); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + } + catch (Exception exception) + { + failures.Add(exception); + } + + try + { + activation.Scope.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } + + try + { + activation.Provider.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } + + try + { + activation.Builder.ReleaseConfiguration(); + } + catch (Exception exception) + { + failures.Add(exception); + } + + return failures; + } + + private static void ThrowCleanupFailures(List failures) + { + if (failures.Count == 0) + { + return; + } + + if (failures.Count == 1) + { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + throw new AggregateException("Client deactivation encountered one or more cleanup failures.", failures); + } + + private ICheatEngineClient GetActiveClient() + { + Activation? activation = Volatile.Read(ref _activation); + if (activation is not null) + { + return activation.Client; + } + + throw new CheatEngineClientLifecycleException( + "GetClient", + "The Cheat Engine client is available only while the plugin is enabled."); + } + + private sealed class Activation + { + internal Activation( + CheatEnginePluginBuilder builder, + ServiceProvider provider, + IServiceScope scope, + ICheatEngineClient client, + ICheatEngineClientModule[] modules, + ILogger logger, + ICheatEngineClientActivationCleanup cleanup) + { + Builder = builder; + Provider = provider; + Scope = scope; + Client = client; + Lifecycle = new ClientActivationLifecycle(client, modules); + Logger = logger; + Cleanup = cleanup; + } + + internal CheatEnginePluginBuilder Builder + { + get; + } + + internal ServiceProvider Provider + { + get; + } + + internal IServiceScope Scope + { + get; + } + + internal ICheatEngineClient Client + { + get; + } + + internal ClientActivationLifecycle Lifecycle + { + get; + } + + internal ILogger Logger + { + get; + } + + internal ICheatEngineClientActivationCleanup Cleanup + { + get; + } + } +} diff --git a/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs b/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs new file mode 100644 index 0000000..0ecddd3 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs @@ -0,0 +1,70 @@ +using CheatEngine.Client.Extensions.DependencyInjection; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CheatEngine.Client.Hosting; + +/// Builds the managed configuration and service provider for one Cheat Engine plugin activation. +/// +/// A builder is intentionally single-use. A new instance is created for every enable epoch so scoped Client services +/// cannot retain a Lua reference, CE object, cancellation token, or target-specific state from an earlier activation. +/// +public sealed class CheatEnginePluginBuilder +{ + private bool _built; + + /// Creates an empty configuration and a service collection for one activation. + public CheatEnginePluginBuilder() + { + Services = new ServiceCollection(); + Configuration = new ConfigurationManager(); + Services.AddSingleton(Configuration); + Services.AddSingleton(Configuration); + Client = Services.AddCheatEngineClient(Configuration); + } + + /// Gets the mutable configuration manager used before the activation provider is built. + /// + /// No source is loaded implicitly. Callers can add an optional appsettings.json or other explicit sources + /// during . Reloading should remain disabled because an activation's + /// ownership, capabilities, and cancellation boundary cannot safely be reconfigured while attached to CE. + /// + public ConfigurationManager Configuration + { + get; + } + + /// Gets the service collection for the new activation provider. + public IServiceCollection Services + { + get; + } + + /// Gets the Client-specific registration builder. + public CheatEngineClientBuilder Client + { + get; + } + + /// Builds a validating provider after all explicit registrations have been added. + /// The builder has already created its provider. + public ServiceProvider BuildServiceProvider() + { + if (_built) + { + throw new InvalidOperationException("A Cheat Engine plugin builder can create only one provider."); + } + + _built = true; + return Services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, ValidateScopes = true + }); + } + + internal void ReleaseConfiguration() + { + Configuration.Dispose(); + } +} diff --git a/libs/CheatEngine.Client.Hosting/ClientActivationLifecycle.cs b/libs/CheatEngine.Client.Hosting/ClientActivationLifecycle.cs new file mode 100644 index 0000000..9ce90cb --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/ClientActivationLifecycle.cs @@ -0,0 +1,85 @@ +using CheatEngine.Client.Modules; + +namespace CheatEngine.Client.Hosting; + +/// Owns the reversible application callbacks inside one already-created Client activation. +/// +/// This type deliberately knows nothing about the SDK, service-provider construction, or resource disposal. Keeping +/// the module/hook sequence isolated lets it be unit-tested without faking the SDK's process-wide plugin host. +/// +internal sealed class ClientActivationLifecycle +{ + private readonly ICheatEngineClient _client; + private readonly ICheatEngineClientModule[] _modules; + private bool _cleanupStarted; + private bool _clientEnableHookEntered; + private int _enabledModuleCount; + + internal ClientActivationLifecycle(ICheatEngineClient client, ICheatEngineClientModule[] modules) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _modules = modules ?? throw new ArgumentNullException(nameof(modules)); + } + + /// Enables modules in registration order, then invokes the application enable hook. + /// + /// Each callback is marked as entered before execution. Consequently a callback that partially initializes and then + /// throws still receives its compensating disable callback during rollback. + /// + internal void Enable(Action onClientEnabled) + { + ArgumentNullException.ThrowIfNull(onClientEnabled); + + for (int index = 0; index < _modules.Length; index++) + { + _enabledModuleCount = index + 1; + _modules[index].OnEnabled(_client); + } + + _clientEnableHookEntered = true; + onClientEnabled(_client); + } + + /// Runs compensations exactly once: application hook, then modules in reverse enable order. + /// + /// Cleanup is best-effort: every callback gets a chance to release its state and all failures are returned to the + /// owner for aggregation after provider and configuration cleanup also complete. + /// + internal List Cleanup(Action onClientDisabling) + { + ArgumentNullException.ThrowIfNull(onClientDisabling); + if (_cleanupStarted) + { + return []; + } + + _cleanupStarted = true; + List failures = new(); + + if (_clientEnableHookEntered) + { + try + { + onClientDisabling(_client); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + for (int index = _enabledModuleCount - 1; index >= 0; index--) + { + try + { + _modules[index].OnDisabling(_client); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + return failures; + } +} diff --git a/libs/CheatEngine.Client.Hosting/ClientHostingLog.cs b/libs/CheatEngine.Client.Hosting/ClientHostingLog.cs new file mode 100644 index 0000000..4380ba4 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/ClientHostingLog.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; + +namespace CheatEngine.Client.Hosting; + +/// Source-generated lifecycle logging that intentionally excludes memory contents and Lua source. +internal static partial class ClientHostingLog +{ + [LoggerMessage(1, LogLevel.Debug, "Cheat Engine Client activation {Epoch} enabled.")] + internal static partial void ActivationEnabled(ILogger logger, long epoch); + + [LoggerMessage(2, LogLevel.Warning, "Cheat Engine Client activation {Epoch} is rolling back after enable failed.")] + internal static partial void ActivationRollingBack(ILogger logger, long epoch); + + [LoggerMessage(3, LogLevel.Debug, "Cheat Engine Client activation {Epoch} is disabling.")] + internal static partial void ActivationDisabling(ILogger logger, long epoch); + + [LoggerMessage(4, LogLevel.Debug, "Cheat Engine Client activation {Epoch} disabled.")] + internal static partial void ActivationDisabled(ILogger logger, long epoch); + + [LoggerMessage(5, LogLevel.Warning, + "Cheat Engine Client activation {Epoch} completed cleanup with {FailureCount} callback failure(s).")] + internal static partial void ActivationCleanupFailed(ILogger logger, long epoch, int failureCount); +} diff --git a/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt new file mode 100644 index 0000000..926e139 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt @@ -0,0 +1,15 @@ +#nullable enable +abstract CheatEngine.Client.Hosting.CheatEngineClientPlugin.Configure(CheatEngine.Client.Hosting.CheatEnginePluginBuilder! builder) -> void +CheatEngine.Client.Hosting.CheatEngineClientPlugin +CheatEngine.Client.Hosting.CheatEngineClientPlugin.CheatEngineClientPlugin() -> void +CheatEngine.Client.Hosting.CheatEngineClientPlugin.Client.get -> CheatEngine.Client.ICheatEngineClient! +CheatEngine.Client.Hosting.CheatEnginePluginBuilder +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.BuildServiceProvider() -> Microsoft.Extensions.DependencyInjection.ServiceProvider! +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.CheatEnginePluginBuilder() -> void +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Client.get -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Configuration.get -> Microsoft.Extensions.Configuration.ConfigurationManager! +CheatEngine.Client.Hosting.CheatEnginePluginBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +override sealed CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnDisable() -> void +override sealed CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnEnable() -> void +virtual CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnClientDisabling(CheatEngine.Client.ICheatEngineClient! client) -> void +virtual CheatEngine.Client.Hosting.CheatEngineClientPlugin.OnClientEnabled(CheatEngine.Client.ICheatEngineClient! client) -> void diff --git a/libs/CheatEngine.Client.Hosting/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Hosting/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.Client.Hosting/README.md b/libs/CheatEngine.Client.Hosting/README.md new file mode 100644 index 0000000..8b1f048 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/README.md @@ -0,0 +1,66 @@ +# CheatEngine.Client.Hosting + +DI-first hosting for an SDK-loaded Cheat Engine plugin, with one validated Client provider per enable epoch. + +## Context + +`CheatEngine.Client.Hosting` supplies `CheatEngineClientPlugin` and `CheatEnginePluginBuilder`. A plugin derives from the base class, keeps the public parameterless construction required by `CheatEngine.SDK`, and configures its managed dependencies in `Configure`. + +The host is intentionally synchronous and in-process. It is not a Generic Host and does not create a process-wide service provider, retain a raw Lua state, discover services by reflection, or keep a configuration file watcher alive. + +## Why this project exists + +Cheat Engine controls plugin construction and the point at which its Lua runtime is attached. Reusing a provider across enable/disable cycles could retain handles, configuration state, target state, or resources from an expired activation. + +This package turns that lifecycle into a deterministic composition boundary. Each enable cycle has a new configuration, a new validated service provider, a new scope, and a new `ICheatEngineClient` epoch. Everything is disposed before the SDK detaches the runtime during disable. + +## How it helps CheatEngine.Client + +On enable, `CheatEngineClientPlugin` calls `Configure`, builds a provider with `ValidateOnBuild` and `ValidateScopes`, resolves options to force validation, resolves the Client, starts registered modules in registration order, and then calls `OnClientEnabled`. + +On disable—or if activation fails—the host invokes `OnClientDisabling` and module cleanup in reverse order, drains 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. + +Add configuration sources explicitly and keep reload disabled. The following is the normal plugin shape: + +```csharp +using CheatEngine.Client; +using CheatEngine.Client.Hosting; +using CheatEngine.SDK.Annotations.Plugin; +using Microsoft.Extensions.Configuration; + +namespace MyPlugin; + +[CheatEnginePlugin("My Plugin")] +public sealed class Plugin : CheatEngineClientPlugin +{ + protected override void Configure(CheatEnginePluginBuilder builder) + { + builder.Configuration + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + + builder.Client.AddModule(); + } + + protected override void OnClientEnabled(ICheatEngineClient client) + { + // Use the client only for this enable epoch. + } +} +``` + +Plugin projects must reference `CheatEngine.SDK` directly as well as `CheatEngine.Client`. The SDK's plugin entry-point generator and native bridge build assets cannot be supplied through a transitive NuGet dependency. Set `CheatEngineClientPluginProject` to `true` to opt into the Hosting build guard; it emits `CECLIENT001` at compile time when the direct SDK `PackageReference` is absent. + +```xml + + true + + + + + + + +``` + +`CheatEngine.Client.Templates` contains a complete plugin layout that applies this configuration and includes a bounded AOB, memory, Address List, and Lua-module example. diff --git a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets new file mode 100644 index 0000000..49256b5 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets @@ -0,0 +1,13 @@ + + + + <_CheatEngineClientDirectSdkReference Include="@(PackageReference)" + Condition="'%(Identity)' == 'CheatEngine.SDK'"/> + + + + diff --git a/libs/CheatEngine.Client.Hosting/packages.lock.json b/libs/CheatEngine.Client.Hosting/packages.lock.json new file mode 100644 index 0000000..1d9c394 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/packages.lock.json @@ -0,0 +1,179 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "CheatEngine.SDK": { + "type": "Direct", + "requested": "[1.0.0, 2.0.0)", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Direct", + "requested": "[10.0.12, )", + "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.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "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.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.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "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, )" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} diff --git a/src/CheatEngine.Client/CheatEngine.Client.csproj b/src/CheatEngine.Client/CheatEngine.Client.csproj index cf6712b..8ee8482 100644 --- a/src/CheatEngine.Client/CheatEngine.Client.csproj +++ b/src/CheatEngine.Client/CheatEngine.Client.csproj @@ -1,9 +1,14 @@ + + + false + $(NoWarn);NU5128 + + - - + diff --git a/src/CheatEngine.Client/PublicAPI.Shipped.txt b/src/CheatEngine.Client/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/CheatEngine.Client/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/CheatEngine.Client/PublicAPI.Unshipped.txt b/src/CheatEngine.Client/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/CheatEngine.Client/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/CheatEngine.Client/README.md b/src/CheatEngine.Client/README.md index 4294c5a..7b34e28 100644 --- a/src/CheatEngine.Client/README.md +++ b/src/CheatEngine.Client/README.md @@ -1,11 +1,57 @@ # CheatEngine.Client -The single project a consumer references. It is the composition root of CheatEngine.Client: it wires -`CheatEngine.Client.Fluent` and `CheatEngine.Client.Binding` behind the `CheatEngine.Client.Abstractions` contracts and -holds no logic of its own. +The recommended NuGet installation point for high-level, DI-oriented Cheat Engine plugins written in C# 14 and .NET 10. -## Rules +## Context -- The only place where `CheatEngine.Client.Fluent` and `CheatEngine.Client.Binding` meet. -- The assembly and the root namespace are both `CheatEngine.Client`, so never declare a type named `CheatEngine` or - `Client` in it (CA1724 matches each segment of the namespace). +`CheatEngine.Client` is a NuGet façade package. It has no operational implementation of its own; it composes the Fluent API and plugin Hosting packages, which in turn bring the public Client contracts and their implementation dependencies. + +The public surface is organized by function rather than by delivery assembly. Plugin code uses namespaces such as `CheatEngine.Client`, `CheatEngine.Client.Memory`, `CheatEngine.Client.Scanning`, `CheatEngine.Client.Tables`, `CheatEngine.Client.Lua`, and `CheatEngine.Client.Hosting`. It does not need to use implementation namespaces. + +## Why this project exists + +Most plugin authors should install one Client package, not reconstruct its package graph. This façade provides that stable installation point while keeping the lower-level packages separately consumable when a project needs only a focused capability. + +It deliberately does not hide `CheatEngine.SDK`: a real plugin must directly reference the SDK so that the SDK's source generators, build targets, and native bridge assets are active in the plugin project. + +## How it helps CheatEngine.Client + +Use this package together with an explicit SDK package reference: + +```xml + + net10.0 + 14.0 + x64 + true + + + + + + +``` + +The direct SDK reference is required even though Hosting has an SDK dependency. When `CheatEngineClientPluginProject` is `true`, Hosting's transitive build target reports `CECLIENT001` if the direct reference is missing. + +For a plugin, derive from `CheatEngineClientPlugin`, configure services and sources explicitly, and use `ICheatEngineClient` only within an enabled lifecycle. The Client facade exposes bounded synchronous APIs for runtime capabilities, process selection, typed memory, AOB scanning, inspection, tables, and typed Lua operations. Capability-dependent operations report Client failures when unavailable; value-scan functionality remains capability-gated. + +```csharp +using CheatEngine.Client; +using CheatEngine.Client.Hosting; + +public sealed class Plugin : CheatEngineClientPlugin +{ + protected override void Configure(CheatEnginePluginBuilder builder) + { + // Add explicit configuration, modules, and memory codecs here. + } + + protected override void OnClientEnabled(ICheatEngineClient client) + { + // The Client is valid only for this activation epoch. + } +} +``` + +For the complete, SDK-annotated entry point and project configuration, install `CheatEngine.Client.Templates` and create the `ceplugin` template. The template is the executable reference for the expected plugin shape. diff --git a/src/CheatEngine.Client/packages.lock.json b/src/CheatEngine.Client/packages.lock.json new file mode 100644 index 0000000..09c60e9 --- /dev/null +++ b/src/CheatEngine.Client/packages.lock.json @@ -0,0 +1,194 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "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.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.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "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.fluent": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )" + } + }, + "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, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj new file mode 100644 index 0000000..f47d10e --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs new file mode 100644 index 0000000..3e9c31a --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Extensions.DependencyInjection.Tests; + +public sealed class CheatEngineClientOptionsSemanticValidatorTests +{ + private readonly CheatEngineClientOptionsSemanticValidator _validator = new(); + + [Fact] + public void ValidateAcceptsAnEmptyAllowedRootList() + { + ValidateOptionsResult result = _validator.Validate(null, new CheatEngineClientOptions()); + + Assert.True(result.Succeeded); + } + + [Fact] + public void ValidateRejectsNullAllowedRootList() + { + ValidateOptionsResult result = + _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = null! }); + + Assert.True(result.Failed); + string? failureMessage = result.FailureMessage; + Assert.NotNull(failureMessage); + Assert.Contains("empty array", failureMessage, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("relative\\tables")] + public void ValidateRejectsBlankOrRelativeAllowedRoots(string root) + { + ValidateOptionsResult result = + _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = [root] }); + + Assert.True(result.Failed); + } + + [Fact] + public void ValidateRejectsDuplicateNormalizedAllowedRoots() + { + string root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests")); + string rootWithTrailingSeparator = root + Path.DirectorySeparatorChar; + + ValidateOptionsResult result = _validator.Validate(null, + new CheatEngineClientOptions { AllowedTableRoots = [root, rootWithTrailingSeparator] }); + + Assert.True(result.Failed); + string? failureMessage = result.FailureMessage; + Assert.NotNull(failureMessage); + Assert.Contains("same normalized path", failureMessage, StringComparison.Ordinal); + } + + [Fact] + public void ValidateAcceptsDistinctFullyQualifiedAllowedRoots() + { + string first = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", "one")); + string second = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", "two")); + + ValidateOptionsResult result = + _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = [first, second] }); + + Assert.True(result.Succeeded); + } +} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..6d3152a --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs @@ -0,0 +1,177 @@ +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.SDK.Engine.Values; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Extensions.DependencyInjection.Tests; + +public sealed class CheatEngineClientServiceCollectionExtensionsTests +{ + [Fact] + public void AddCheatEngineClientRegistersDescriptorsThatPassProviderValidationWithoutActivation() + { + ServiceCollection services = new(); + + CheatEngineClientBuilder builder = services.AddCheatEngineClient(); + + Assert.NotNull(builder); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ICheatEngineClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); + Assert.Contains(services, + static descriptor => descriptor.ServiceType == typeof(IValidateOptions)); + Assert.DoesNotContain(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); + + using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, ValidateScopes = true + }); + + Assert.NotNull(provider); + } + + [Fact] + public void AddMemoryCodecPreservesTheFirstExplicitRegistration() + { + ServiceCollection services = new(); + CheatEngineClientBuilder builder = services.AddCheatEngineClient(); + + builder.AddMemoryCodec(); + builder.AddMemoryCodec(); + + ServiceDescriptor descriptor = Assert.Single(services, + static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); + Assert.Equal(typeof(FirstCustomCodec), descriptor.ImplementationType); + } + + [Fact] + public void EnableUnsafeLuaExecutionAddsOnlyTheExplicitUnsafeLuaDescriptor() + { + ServiceCollection services = new(); + CheatEngineClientBuilder builder = services.AddCheatEngineClient(); + + builder.EnableUnsafeLuaExecution(); + + using ServiceProvider provider = services.BuildServiceProvider(); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); + Assert.True(provider.GetRequiredService>().Value.EnableUnsafeLuaExecution); + } + + [Fact] + public void SectionOverloadBindsThenProgrammaticConfigurationRunsLast() + { + using ConfigurationManager configuration = new(); + configuration["Configured:DefaultMaximumAobResults"] = "11"; + ServiceCollection services = new(); + + services.AddCheatEngineClient(configuration.GetSection("Configured")) + .Configure(static options => options.DefaultMaximumAobResults = 29); + + using ServiceProvider provider = services.BuildServiceProvider(); + CheatEngineClientOptions options = provider.GetRequiredService>().Value; + + Assert.Equal(29, options.DefaultMaximumAobResults); + } + + [Fact] + public void RootOverloadUsesTheDefaultClientSection() + { + using ConfigurationManager configuration = new(); + configuration["CheatEngineClient:DefaultMaximumValueScanPageSize"] = "87"; + ServiceCollection services = new(); + + services.AddCheatEngineClient(configuration); + + using ServiceProvider provider = services.BuildServiceProvider(); + CheatEngineClientOptions options = provider.GetRequiredService>().Value; + + Assert.Equal(87, options.DefaultMaximumValueScanPageSize); + } + + [Fact] + public void GeneratedOptionsValidatorRejectsOutOfRangeBoundConfiguration() + { + using ConfigurationManager configuration = new(); + configuration["CheatEngineClient:DefaultMaximumAobResults"] = "0"; + ServiceCollection services = new(); + services.AddCheatEngineClient(configuration); + using ServiceProvider provider = services.BuildServiceProvider(); + + OptionsValidationException exception = Assert.Throws(() => + _ = provider.GetRequiredService>().Value); + + Assert.Contains("DefaultMaximumAobResults", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AddModulePreservesExplicitRegistrationOrder() + { + ServiceCollection services = new(); + services.AddCheatEngineClient() + .AddModule() + .AddModule(); + + using ServiceProvider provider = services.BuildServiceProvider(); + ICheatEngineClientModule[] modules = provider.GetServices().ToArray(); + + Assert.Collection( + modules, + module => Assert.IsType(module), + module => Assert.IsType(module)); + } + + private readonly record struct CustomValue(int Value); + + private class FirstCustomCodec : IMemoryCodec + { + public virtual bool TryRead(IMemoryReadContext context, Address address, out CustomValue value) + { + value = default; + return false; + } + + public virtual bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value) + { + return false; + } + } + + private sealed class SecondCustomCodec : FirstCustomCodec + { + public override bool TryRead(IMemoryReadContext context, Address address, out CustomValue value) + { + return base.TryRead(context, address, out value); + } + + public override bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value) + { + return base.TryWrite(context, address, in value); + } + } + + public sealed class FirstModule : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + } + + public void OnDisabling(ICheatEngineClient client) + { + } + } + + public sealed class SecondModule : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + } + + public void OnDisabling(ICheatEngineClient client) + { + } + } +} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs new file mode 100644 index 0000000..a5fcfa5 --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs @@ -0,0 +1,163 @@ +using CheatEngine.Client.Memory; +using CheatEngine.SDK.Engine.Values; + +using Microsoft.Extensions.DependencyInjection; + +namespace CheatEngine.Client.Extensions.DependencyInjection.Tests; + +public sealed class DefaultMemoryCodecsTests +{ + [Fact] + public void AddRegistersEveryBuiltInScalarAndPointerCodecExactlyOnce() + { + ServiceCollection services = new(); + + DefaultMemoryCodecs.Add(services); + DefaultMemoryCodecs.Add(services); + + using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, ValidateScopes = true + }); + + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.IsAssignableFrom>(provider.GetRequiredService>()); + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec
)); + } + + [Fact] + public void ScalarCodecReadsAndWritesTheExactLittleEndianTargetBytes() + { + using ServiceProvider provider = CreateProvider(); + IMemoryCodec codec = provider.GetRequiredService>(); + BufferMemoryContext context = new(8, [0x78, 0x56, 0x34, 0x12]); + Address address = 0x401000; + + Assert.True(codec.TryRead(context, address, out int value)); + Assert.Equal(0x12345678, value); + Assert.Equal(address, context.LastReadAddress); + + Assert.True(codec.TryWrite(context, address, 0x0A0B0C0D)); + Assert.Equal(address, context.LastWriteAddress); + Assert.Equal([0x0D, 0x0C, 0x0B, 0x0A], context.LastWrittenBytes); + } + + [Fact] + public void ScalarCodecReturnsFalseAndTheDefaultValueWhenTheContextCannotFillItsFixedWidthBuffer() + { + using ServiceProvider provider = CreateProvider(); + IMemoryCodec codec = provider.GetRequiredService>(); + BufferMemoryContext context = new(8, [0x01, 0x02, 0x03, 0x04]); + Address address = 0x401080; + + Assert.False(codec.TryRead(context, address, out long value)); + Assert.Equal(0L, value); + Assert.Equal(address, context.LastReadAddress); + } + + [Theory] + [InlineData(4, 0xDEADBEEFul, 0xDEADBEEFu)] + [InlineData(8, 0x1122334455667788ul, 0x1122334455667788ul)] + public void AddressCodecUsesTheTargetPointerWidth(int pointerSize, ulong rawValue, ulong expectedValue) + { + using ServiceProvider provider = CreateProvider(); + IMemoryCodec
codec = provider.GetRequiredService>(); + BufferMemoryContext context = new(pointerSize, pointerSize == 4 + ? [0xEF, 0xBE, 0xAD, 0xDE] + : [0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11]); + Address address = 0x401100; + + Assert.True(codec.TryRead(context, address, out Address read)); + Assert.Equal(Address.FromUInt64(expectedValue), read); + + Assert.True(codec.TryWrite(context, address, Address.FromUInt64(rawValue))); + Assert.Equal(pointerSize, context.LastWrittenBytes.Length); + Assert.Equal(pointerSize == 4 + ? [0xEF, 0xBE, 0xAD, 0xDE] + : [0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11], context.LastWrittenBytes); + } + + [Fact] + public void AddressCodecRejectsUnsupportedPointerWidthsAndNarrowingWritesWithoutTouchingMemory() + { + using ServiceProvider provider = CreateProvider(); + IMemoryCodec
codec = provider.GetRequiredService>(); + Address address = 0x401200; + BufferMemoryContext malformedWidth = new(6, [0, 0, 0, 0, 0, 0]); + BufferMemoryContext narrowTarget = new(4, [0, 0, 0, 0]); + + Assert.False(codec.TryRead(malformedWidth, address, out Address malformedRead)); + Assert.Equal(Address.Zero, malformedRead); + Assert.Null(malformedWidth.LastReadAddress); + + Assert.False(codec.TryWrite(malformedWidth, address, Address.FromUInt64(0x1234))); + Assert.Null(malformedWidth.LastWriteAddress); + + Assert.False(codec.TryWrite(narrowTarget, address, Address.FromUInt64(0x1_0000_0000))); + Assert.Null(narrowTarget.LastWriteAddress); + } + + private static ServiceProvider CreateProvider() + { + ServiceCollection services = new(); + DefaultMemoryCodecs.Add(services); + return services.BuildServiceProvider(); + } + + private sealed class BufferMemoryContext(int pointerSize, byte[] bytes) : IMemoryReadContext, IMemoryWriteContext + { + private readonly byte[] _bytes = bytes; + + internal Address? LastReadAddress + { + get; + private set; + } + + internal Address? LastWriteAddress + { + get; + private set; + } + + internal byte[] LastWrittenBytes + { + get; + private set; + } = []; + + public int PointerSize + { + get; + } = pointerSize; + + public bool TryReadBytes(Address address, Span destination) + { + LastReadAddress = address; + if (_bytes.Length < destination.Length) + { + return false; + } + + _bytes.AsSpan(0, destination.Length).CopyTo(destination); + return true; + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source) + { + LastWriteAddress = address; + LastWrittenBytes = source.ToArray(); + return true; + } + } +} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/README.md b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/README.md new file mode 100644 index 0000000..60c47cf --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/README.md @@ -0,0 +1,26 @@ +# CheatEngine.Client.Extensions.DependencyInjection.Tests + +## Context + +This project validates the dependency-injection composition layer that turns a service collection into an +activation-scoped CheatEngine.Client facade. + +## Why this project exists + +DI is the public assembly point for options, logging, default memory codecs, custom codecs, modules, and the policy +that controls optional capabilities. The registration graph must stay deterministic and safe without a Generic Host or +a process-wide service provider. + +## How it helps improve CheatEngine.Client + +The suite verifies registration completeness, semantic options validation, explicit codec selection, and module +ordering. It catches accidental singleton leakage, invalid configuration defaults, or registration changes that would +make an otherwise valid plugin fail during enable. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Extensions.DependencyInjection.Tests\CheatEngine.Client.Extensions.DependencyInjection.Tests.csproj --configuration Release +``` diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json new file mode 100644 index 0000000..9f2e7b0 --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json @@ -0,0 +1,317 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Direct", + "requested": "[18.11.2, )", + "resolved": "18.11.2", + "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.2.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Testing.Platform": "2.4.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.2.10", + "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" + }, + "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.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "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.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "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.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj b/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj new file mode 100644 index 0000000..7673ddb --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs new file mode 100644 index 0000000..88cb1c9 --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs @@ -0,0 +1,35 @@ +using CheatEngine.Client.Extensions.DependencyInjection; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Hosting.Tests; + +public sealed class CheatEnginePluginBuilderTests +{ + [Fact] + public void BuildServiceProviderValidatesServicesAndBindsTheActivationConfiguration() + { + CheatEnginePluginBuilder builder = new(); + builder.Configuration["CheatEngineClient:DefaultMaximumAobResults"] = "19"; + + using ServiceProvider provider = builder.BuildServiceProvider(); + CheatEngineClientOptions options = provider.GetRequiredService>().Value; + + Assert.Equal(19, options.DefaultMaximumAobResults); + Assert.Same(builder.Configuration, provider.GetRequiredService()); + Assert.Same(builder.Configuration, provider.GetRequiredService()); + } + + [Fact] + public void BuildServiceProviderIsSingleUse() + { + CheatEnginePluginBuilder builder = new(); + using ServiceProvider provider = builder.BuildServiceProvider(); + + InvalidOperationException exception = Assert.Throws(builder.BuildServiceProvider); + + Assert.Contains("only one provider", exception.Message, StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs new file mode 100644 index 0000000..a9ad61f --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs @@ -0,0 +1,159 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +namespace CheatEngine.Client.Hosting.Tests; + +public sealed class ClientActivationLifecycleTests +{ + [Fact] + public void EnableThenCleanupRunsTheApplicationHookAndModulesInTheirSpecifiedOrder() + { + List events = new(); + RecordingModule first = new("first", events); + RecordingModule second = new("second", events); + ClientActivationLifecycle lifecycle = CreateLifecycle(events, out ICheatEngineClient client, first, second); + + lifecycle.Enable(_ => events.Add("application.enabled")); + List failures = lifecycle.Cleanup(_ => events.Add("application.disabling")); + + Assert.Empty(failures); + Assert.Equal( + [ + "first.enabled", "second.enabled", "application.enabled", "application.disabling", "second.disabling", + "first.disabling" + ], + events); + Assert.Same(client, first.LastClient); + Assert.Same(client, second.LastClient); + } + + [Fact] + public void FailedModuleEnableStillCompensatesTheFailingModuleThenEarlierModulesInReverseOrder() + { + List events = new(); + ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, + new RecordingModule("first", events), + new RecordingModule("second", events, new InvalidOperationException("module enable"))); + + InvalidOperationException exception = + Assert.Throws(() => lifecycle.Enable(_ => events.Add("application.enabled"))); + List failures = lifecycle.Cleanup(_ => events.Add("application.disabling")); + + Assert.Equal("module enable", exception.Message); + Assert.Empty(failures); + Assert.Equal(["first.enabled", "second.enabled", "second.disabling", "first.disabling"], events); + } + + [Fact] + public void FailedApplicationEnableStillRunsItsCompensatingHookBeforeModuleCleanup() + { + List events = new(); + ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, new RecordingModule("module", events)); + + InvalidOperationException exception = Assert.Throws(() => lifecycle.Enable(_ => + { + events.Add("application.enabled"); + throw new InvalidOperationException("application enable"); + })); + List failures = lifecycle.Cleanup(_ => events.Add("application.disabling")); + + Assert.Equal("application enable", exception.Message); + Assert.Empty(failures); + Assert.Equal(["module.enabled", "application.enabled", "application.disabling", "module.disabling"], events); + } + + [Fact] + public void CleanupContinuesAfterFailuresAndIsIdempotent() + { + List events = new(); + ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, + new RecordingModule("first", events, disableFailure: new InvalidOperationException("first disable")), + new RecordingModule("second", events, disableFailure: new InvalidOperationException("second disable"))); + lifecycle.Enable(_ => events.Add("application.enabled")); + + List failures = lifecycle.Cleanup(_ => + { + events.Add("application.disabling"); + throw new InvalidOperationException("application disable"); + }); + List repeatedFailures = lifecycle.Cleanup(_ => events.Add("unexpected")); + + Assert.Collection( + failures, + failure => Assert.Equal("application disable", failure.Message), + failure => Assert.Equal("second disable", failure.Message), + failure => Assert.Equal("first disable", failure.Message)); + Assert.Empty(repeatedFailures); + Assert.Equal( + [ + "first.enabled", "second.enabled", "application.enabled", "application.disabling", "second.disabling", + "first.disabling" + ], + events); + } + + private static ClientActivationLifecycle CreateLifecycle( + List events, + out ICheatEngineClient client, + params ICheatEngineClientModule[] modules) + { + ArgumentNullException.ThrowIfNull(events); + client = new FakeClient(); + return new ClientActivationLifecycle(client, modules); + } + + private sealed class RecordingModule( + string name, + List events, + Exception? enableFailure = null, + Exception? disableFailure = null) : ICheatEngineClientModule + { + internal ICheatEngineClient? LastClient + { + get; + private set; + } + + public void OnEnabled(ICheatEngineClient client) + { + LastClient = client; + events.Add($"{name}.enabled"); + if (enableFailure is not null) + { + throw enableFailure; + } + } + + public void OnDisabling(ICheatEngineClient client) + { + LastClient = client; + events.Add($"{name}.disabling"); + if (disableFailure is not null) + { + throw disableFailure; + } + } + } + + private sealed class FakeClient : ICheatEngineClient + { + public long Epoch => 42; + public CancellationToken Stopping => CancellationToken.None; + public ICheatEngineRuntime Runtime => null!; + public ICheatEngineDispatcher Dispatcher => null!; + public IProcessClient Processes => null!; + public IMemoryClient Memory => null!; + public IPatternScanner Patterns => null!; + public IValueScanner Scans => null!; + public IInspectionClient Inspection => null!; + public ITableClient Tables => null!; + public ILuaClient Lua => null!; + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/README.md b/tests/CheatEngine.Client.Hosting.Tests/README.md new file mode 100644 index 0000000..642649f --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/README.md @@ -0,0 +1,25 @@ +# CheatEngine.Client.Hosting.Tests + +## Context + +This project tests the plugin host built on `CheatEngineClientPlugin` and `CheatEnginePluginBuilder`. + +## Why this project exists + +Hosting translates Cheat Engine enable/disable callbacks into an ephemeral DI provider and a single Client activation. +It is responsible for configuration ordering, validation-before-use, module startup order, reverse shutdown, rollback, +and cleanup while the SDK context is still valid. + +## How it helps improve CheatEngine.Client + +The suite makes the lifecycle contract executable: a new activation is created per enable, stale work is rejected after +disable, modules observe a deterministic order, and cleanup is performed in reverse order. These tests guard the +boundary where a long-lived plugin host meets activation-scoped Client services. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Hosting.Tests\CheatEngine.Client.Hosting.Tests.csproj --configuration Release +``` diff --git a/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json b/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json new file mode 100644 index 0000000..df232a5 --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json @@ -0,0 +1,326 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Direct", + "requested": "[18.11.2, )", + "resolved": "18.11.2", + "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.2.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Testing.Platform": "2.4.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.2.10", + "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" + }, + "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.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "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.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "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, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} diff --git a/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs b/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs new file mode 100644 index 0000000..35f4ba9 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs @@ -0,0 +1,133 @@ +using System.Reflection; + +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.Values; + +using MemoryFluent = CheatEngine.Client.Memory.Memory; + +namespace CheatEngine.Client.Tests; + +public sealed class FacadeGraphSmokeTests +{ + [Fact] + public void MetaPackageReferenceProvidesTheFunctionalClientSurface() + { + Address address = 0x401000; + MemoryAddressBuilder memoryOperation = MemoryFluent.At(address); + AobPattern pattern = new("48 8B ?? 89"); + + Dictionary entryPoints = new() + { + [typeof(ICheatEngineClient)] = "CheatEngine.Client", + [typeof(ICheatEngineDispatcher)] = "CheatEngine.Client.Dispatching", + [typeof(IProcessClient)] = "CheatEngine.Client.Processes", + [typeof(IMemoryClient)] = "CheatEngine.Client.Memory", + [typeof(IPatternScanner)] = "CheatEngine.Client.Scanning", + [typeof(IValueScanner)] = "CheatEngine.Client.Scanning", + [typeof(IInspectionClient)] = "CheatEngine.Client.Inspection", + [typeof(ITableClient)] = "CheatEngine.Client.Tables", + [typeof(ILuaClient)] = "CheatEngine.Client.Lua", + [typeof(IUnsafeLuaClient)] = "CheatEngine.Client.Lua", + [typeof(ICheatEngineRuntime)] = "CheatEngine.Client.Runtime", + [typeof(MemoryAddressBuilder)] = "CheatEngine.Client.Memory", + [typeof(AobScanBuilder)] = "CheatEngine.Client.Scanning", + [typeof(CheatEngineClientBuilder)] = "CheatEngine.Client.Extensions.DependencyInjection", + [typeof(CheatEngineClientPlugin)] = "CheatEngine.Client.Hosting" + }; + + Assert.Equal(address, memoryOperation.Address); + Assert.Equal("48 8B ?? 89", pattern.Value); + Assert.All(entryPoints, static entryPoint => + Assert.Equal(entryPoint.Value, entryPoint.Key.Namespace)); + } + + [Fact] + public void PublicClientContractsDoNotExposeSdkOwnershipOrLuaHandles() + { + Type[] clientContracts = + [ + typeof(ICheatEngineClient), + typeof(ICheatEngineDispatcher), + typeof(IProcessClient), + typeof(IMemoryClient), + typeof(IPatternScanner), + typeof(IValueScanner), + typeof(IValueScanSession), + typeof(IInspectionClient), + typeof(ITableClient), + typeof(ILuaClient), + typeof(IUnsafeLuaClient), + typeof(ICheatEngineRuntime), + typeof(IMemoryCodec<>), + typeof(IMemoryReadContext), + typeof(IMemoryWriteContext), + typeof(MemoryAddressBuilder), + typeof(CheatEngineMemoryFluentExtensions), + typeof(MemoryFluent), + typeof(AobScanBuilder), + typeof(AobFirstMatchBuilder), + typeof(AobSingleMatchBuilder), + typeof(AobManyMatchBuilder), + typeof(CheatEngineAobFluentExtensions), + typeof(CheatEngineClientBuilder), + typeof(CheatEngineClientServiceCollectionExtensions), + typeof(CheatEngineClientPlugin) + ]; + + IEnumerable signatures = clientContracts.SelectMany(GetPublicSignatureTypes); + + Assert.DoesNotContain(signatures, IsForbiddenSdkHandle); + } + + private static IEnumerable GetPublicSignatureTypes(Type contract) + { + foreach (ConstructorInfo constructor in contract.GetConstructors()) + { + foreach (ParameterInfo parameter in constructor.GetParameters()) + { + yield return parameter.ParameterType; + } + } + + foreach (PropertyInfo property in contract.GetProperties(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static)) + { + yield return property.PropertyType; + } + + foreach (MethodInfo method in contract.GetMethods(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static)) + { + yield return method.ReturnType; + foreach (ParameterInfo parameter in method.GetParameters()) + { + yield return parameter.ParameterType; + } + } + } + + private static bool IsForbiddenSdkHandle(Type type) + { + while (type.HasElementType) + { + type = type.GetElementType()!; + } + + if (type.IsGenericType && type.GetGenericArguments().Any(IsForbiddenSdkHandle)) + { + return true; + } + + return type.Name is "LuaState" or "LuaRef" or "CEObject" or "MemScan" or "FoundList" + || type.Name.StartsWith("Owned`", StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Tests/README.md b/tests/CheatEngine.Client.Tests/README.md index de26846..781ea62 100644 --- a/tests/CheatEngine.Client.Tests/README.md +++ b/tests/CheatEngine.Client.Tests/README.md @@ -1,4 +1,28 @@ # CheatEngine.Client.Tests -Tests of [`CheatEngine.Client`](../../src/CheatEngine.Client/README.md): the composition, and the public API as a -consumer sees it. References its subject only. +## Context + +This is the consumer-facing smoke suite for the `CheatEngine.Client` meta-package project. It validates the public +graph that a plugin author receives through the recommended top-level package. + +## Why this project exists + +The meta-package must expose functional Client namespaces and fluent entry points without forcing consumers to know +the internal package layout. It must also keep SDK ownership wrappers and raw Lua handles behind the Client boundary. + +## How it helps improve CheatEngine.Client + +The smoke tests compile against the assembled consumer graph and inspect selected public contracts for prohibited +handle types. They catch accidental dependency omissions, namespace regressions, and public leakage of `LuaState`, +`LuaRef`, `CEObject`, `Owned`, `MemScan`, or `FoundList` before package smoke tests run. + +The suite is activation-independent. It does not replace Core lifecycle tests, template/package smoke tests, or the +opt-in live validation required for host-dependent capabilities such as value scans. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Tests\CheatEngine.Client.Tests.csproj --configuration Release +``` diff --git a/tests/CheatEngine.Client.Tests/packages.lock.json b/tests/CheatEngine.Client.Tests/packages.lock.json new file mode 100644 index 0000000..ae8ee85 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/packages.lock.json @@ -0,0 +1,339 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Direct", + "requested": "[18.11.2, )", + "resolved": "18.11.2", + "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.2.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Testing.Platform": "2.4.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.2.10", + "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" + }, + "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.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "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.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "cheatengine.client": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Fluent": "[0.1.0, )", + "CheatEngine.Client.Hosting": "[0.1.0, )" + } + }, + "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.fluent": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )" + } + }, + "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, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} From 8df393b0faab05110675264b69674850d7581059 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 20:35:52 +0200 Subject: [PATCH 2/7] fix: remediate DI and Hosting Sonar findings Make the nullable allowed-root configuration truthful and keep the service factory defensive after generated option validation. Restrict the abstract plugin constructor to derived plugins and replace the throwing Client property with an explicit lifecycle-checked method. --- .../CheatEngineClientOptions.cs | 4 +- ...EngineClientServiceCollectionExtensions.cs | 4 +- .../PublicAPI.Shipped.txt | 2 +- .../CheatEngineClientPlugin.cs | 10 ++-- .../PublicAPI.Shipped.txt | 2 +- libs/CheatEngine.Client.Hosting/README.md | 2 +- ...gineClientOptionsSemanticValidatorTests.cs | 2 +- ...eClientServiceCollectionExtensionsTests.cs | 20 ++++---- .../CheatEngineClientPluginTests.cs | 47 +++++++++++++++++++ 9 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs index b68c861..e533c9d 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs @@ -31,9 +31,9 @@ public int DefaultMaximumValueScanPageSize /// Gets or sets absolute roots from which Client table files may be loaded or saved. /// /// An empty list denies table-file access by default. Paths are normalized and validated when an activation creates - /// its client scope; relative paths and blank entries are rejected. + /// its client scope; relative paths, blank entries, and are rejected. /// - public string[] AllowedTableRoots + public string[]? AllowedTableRoots { get; set; diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs index 392e8d4..4380928 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs @@ -78,7 +78,9 @@ private static void AddCoreServices(IServiceCollection services) { CheatEngineClientOptions options = serviceProvider.GetRequiredService>().Value; - return new CoreClientPolicy(options.AllowedTableRoots, options.EnableUnsafeLuaExecution); + string[] allowedTableRoots = options.AllowedTableRoots + ?? throw new InvalidOperationException("AllowedTableRoots must be validated before the Client policy is created."); + return new CoreClientPolicy(allowedTableRoots, options.EnableUnsafeLuaExecution); }); services.TryAddSingleton(static serviceProvider => diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt index be9e43f..34404d5 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt @@ -8,7 +8,7 @@ CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.Confi CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.EnableUnsafeLuaExecution() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.get -> string![]! +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.get -> string![]? CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.set -> void CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.CheatEngineClientOptions() -> void CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumAobResults.get -> int diff --git a/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs index d3f15f7..26662f6 100644 --- a/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs +++ b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs @@ -23,18 +23,18 @@ public abstract class CheatEngineClientPlugin : CheatEnginePlugin { private Activation? _activation; - /// Initializes the SDK-loadable plugin base through its required public parameterless constructor. + /// Initializes the SDK-loadable plugin base for construction by a concrete plugin. /// - /// The SDK creates the concrete plugin type through a public parameterless constructor. Keeping this base - /// constructor public preserves the constructor chain required by that generated loading path. + /// The SDK creates the concrete plugin type through a public parameterless constructor. A concrete derived class + /// can use its implicit public parameterless constructor to call this protected base constructor. /// - public CheatEngineClientPlugin() + protected CheatEngineClientPlugin() { } /// Gets the client for the active enable epoch. /// The plugin is not currently enabled. - protected ICheatEngineClient Client => GetActiveClient(); + protected ICheatEngineClient GetRequiredClient() => GetActiveClient(); /// Adds application services, explicit Client modules, codecs, and configuration sources for one activation. /// diff --git a/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt index 926e139..de1a7b3 100644 --- a/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Hosting/PublicAPI.Shipped.txt @@ -2,7 +2,7 @@ abstract CheatEngine.Client.Hosting.CheatEngineClientPlugin.Configure(CheatEngine.Client.Hosting.CheatEnginePluginBuilder! builder) -> void CheatEngine.Client.Hosting.CheatEngineClientPlugin CheatEngine.Client.Hosting.CheatEngineClientPlugin.CheatEngineClientPlugin() -> void -CheatEngine.Client.Hosting.CheatEngineClientPlugin.Client.get -> CheatEngine.Client.ICheatEngineClient! +CheatEngine.Client.Hosting.CheatEngineClientPlugin.GetRequiredClient() -> CheatEngine.Client.ICheatEngineClient! CheatEngine.Client.Hosting.CheatEnginePluginBuilder CheatEngine.Client.Hosting.CheatEnginePluginBuilder.BuildServiceProvider() -> Microsoft.Extensions.DependencyInjection.ServiceProvider! CheatEngine.Client.Hosting.CheatEnginePluginBuilder.CheatEnginePluginBuilder() -> void diff --git a/libs/CheatEngine.Client.Hosting/README.md b/libs/CheatEngine.Client.Hosting/README.md index 8b1f048..9ae2bb2 100644 --- a/libs/CheatEngine.Client.Hosting/README.md +++ b/libs/CheatEngine.Client.Hosting/README.md @@ -4,7 +4,7 @@ DI-first hosting for an SDK-loaded Cheat Engine plugin, with one validated Clien ## Context -`CheatEngine.Client.Hosting` supplies `CheatEngineClientPlugin` and `CheatEnginePluginBuilder`. A plugin derives from the base class, keeps the public parameterless construction required by `CheatEngine.SDK`, and configures its managed dependencies in `Configure`. +`CheatEngine.Client.Hosting` supplies `CheatEngineClientPlugin` and `CheatEnginePluginBuilder`. A plugin derives from the base class, keeps its own public parameterless construction required by `CheatEngine.SDK`, and configures its managed dependencies in `Configure`. The base constructor is protected: the SDK generator instantiates the attributed concrete plugin, whose implicit public constructor may call it. The host is intentionally synchronous and in-process. It is not a Generic Host and does not create a process-wide service provider, retain a raw Lua state, discover services by reflection, or keep a configuration file watcher alive. diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs index 3e9c31a..356b0c3 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs @@ -18,7 +18,7 @@ public void ValidateAcceptsAnEmptyAllowedRootList() public void ValidateRejectsNullAllowedRootList() { ValidateOptionsResult result = - _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = null! }); + _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = null }); Assert.True(result.Failed); string? failureMessage = result.FailureMessage; diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs index 6d3152a..5541da2 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs @@ -27,7 +27,8 @@ public void AddCheatEngineClientRegistersDescriptorsThatPassProviderValidationWi using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, ValidateScopes = true + ValidateOnBuild = true, + ValidateScopes = true }); Assert.NotNull(provider); @@ -126,30 +127,31 @@ public void AddModulePreservesExplicitRegistrationOrder() private readonly record struct CustomValue(int Value); - private class FirstCustomCodec : IMemoryCodec + private sealed class FirstCustomCodec : IMemoryCodec { - public virtual bool TryRead(IMemoryReadContext context, Address address, out CustomValue value) + public bool TryRead(IMemoryReadContext context, Address address, out CustomValue value) { value = default; return false; } - public virtual bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value) + public bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value) { return false; } } - private sealed class SecondCustomCodec : FirstCustomCodec + private sealed class SecondCustomCodec : IMemoryCodec { - public override bool TryRead(IMemoryReadContext context, Address address, out CustomValue value) + public bool TryRead(IMemoryReadContext context, Address address, out CustomValue value) { - return base.TryRead(context, address, out value); + value = new CustomValue(2); + return true; } - public override bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value) + public bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value) { - return base.TryWrite(context, address, in value); + return value.Value == 2; } } diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs new file mode 100644 index 0000000..173a119 --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs @@ -0,0 +1,47 @@ +using System.Reflection; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Hosting.Tests; + +public sealed class CheatEngineClientPluginTests +{ + [Fact] + public void ProtectedBaseConstructorAllowsAPublicParameterlessConcretePlugin() + { + ConstructorInfo? baseConstructor = typeof(CheatEngineClientPlugin).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + types: Type.EmptyTypes, + modifiers: null); + ConstructorInfo? concreteConstructor = typeof(TestPlugin).GetConstructor(Type.EmptyTypes); + + Assert.NotNull(baseConstructor); + Assert.True(baseConstructor.IsFamily); + Assert.NotNull(concreteConstructor); + Assert.True(concreteConstructor.IsPublic); + Assert.NotNull(new TestPlugin()); + } + + [Fact] + public void GetRequiredClientWithoutAnActiveEnableEpochThrowsLifecycleException() + { + TestPlugin plugin = new(); + + CheatEngineClientLifecycleException exception = + Assert.Throws(plugin.GetRequiredClientForTest); + + Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); + Assert.Equal("GetClient", exception.Failure.Operation); + Assert.Contains("only while the plugin is enabled", exception.Message, StringComparison.Ordinal); + } + + private sealed class TestPlugin : CheatEngineClientPlugin + { + protected override void Configure(CheatEnginePluginBuilder builder) + { + } + + internal ICheatEngineClient GetRequiredClientForTest() => GetRequiredClient(); + } +} From 0e70fc2e4c79fe78c0272138d60b24de2fe078c7 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 22:12:10 +0200 Subject: [PATCH 3/7] fix: harden DI activation composition Resolve the PR #4 DI and Hosting review findings without widening the stacked-branch scope.\n\nRemove unconsumed scan-limit settings and prevent configuration binding from activating unsafe Lua. The explicit builder opt-in now establishes the unsafe facade and Core policy together. Register lifecycle modules in the activation scope so they can consume scoped application services, and classify malformed table-root paths as validation failures.\n\nAdd stable Sonar project identifiers for the DI and Hosting assemblies, clarify lifecycle cleanup documentation, update the shipped API baseline and package guidance, and cover the configuration, scoped-module, malformed-path, and hosting-binding regressions.\n\nValidation:\n- dotnet restore CheatEngine.Client.slnx --locked-mode\n- dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --warnaserror\n- DI tests: 22 passed, 0 failed, 0 skipped\n- Hosting tests: 8 passed, 0 failed, 0 skipped\n- git diff --check --- CheatEngine.Client.slnx | 2 + ...ient.Extensions.DependencyInjection.csproj | 2 + .../CheatEngineClientBuilder.cs | 25 +- .../CheatEngineClientOptions.cs | 37 +- ...eatEngineClientOptionsSemanticValidator.cs | 20 +- ...EngineClientServiceCollectionExtensions.cs | 14 +- .../PublicAPI.Shipped.txt | 6 - .../README.md | 10 +- .../CheatEngine.Client.Hosting.csproj | 1 + .../CheatEngineClientPlugin.cs | 17 +- .../CheatEnginePluginBuilder.cs | 6 + .../ClientActivationLifecycle.cs | 2 +- ...gineClientOptionsSemanticValidatorTests.cs | 14 + ...eClientServiceCollectionExtensionsTests.cs | 84 +++- .../CheatEngineClientPluginTests.cs | 359 ++++++++++++++++++ .../CheatEnginePluginBuilderTests.cs | 5 +- 16 files changed, 535 insertions(+), 69 deletions(-) diff --git a/CheatEngine.Client.slnx b/CheatEngine.Client.slnx index d872e9d..c8acdbc 100644 --- a/CheatEngine.Client.slnx +++ b/CheatEngine.Client.slnx @@ -24,7 +24,9 @@ + + diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj index aeb015b..776458e 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj @@ -1,11 +1,13 @@ + {5E8B0B81-9BCB-4505-AF18-6071D762CC91} true + diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs index 3186a13..f8162fe 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs @@ -58,15 +58,15 @@ public CheatEngineClientBuilder BindConfiguration(IConfigurationSection section) /// The concrete module type. /// /// Module construction is explicit through the generic service descriptor; no assembly scanning or runtime type - /// discovery is performed. Hosting enables modules in this order and disables successfully enabled modules in the - /// reverse order. + /// discovery is performed. Modules are scoped to the activation so they can depend on other scoped application + /// services. Hosting enables modules in this order and disables successfully enabled modules in the reverse order. /// public CheatEngineClientBuilder AddModule< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TModule>() where TModule : class, ICheatEngineClientModule { - Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + Services.TryAddEnumerable(ServiceDescriptor.Scoped()); return this; } @@ -87,10 +87,25 @@ public CheatEngineClientBuilder AddModule< } /// Opts this activation into trusted arbitrary Lua execution. + /// + /// This is the only supported opt-in path. Configuration binding cannot enable the capability or register the + /// unsafe facade, so the activation policy and service registration are established together. + /// public CheatEngineClientBuilder EnableUnsafeLuaExecution() { - Services.Configure(static options => options.EnableUnsafeLuaExecution = true); - Services.TryAddSingleton(static serviceProvider => new UnsafeLuaClient( + if (Services.Any(static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient))) + { + if (Services.Any(static descriptor => descriptor.ServiceType == typeof(UnsafeLuaExecutionRegistration))) + { + return this; + } + + throw new InvalidOperationException( + "IUnsafeLuaClient can only be registered through EnableUnsafeLuaExecution()."); + } + + Services.AddSingleton(); + Services.AddSingleton(static serviceProvider => new UnsafeLuaClient( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService())); return this; diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs index e533c9d..8a43708 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs @@ -4,49 +4,28 @@ namespace CheatEngine.Client.Extensions.DependencyInjection; /// Configuration values used by the high-level Cheat Engine client during one plugin activation. /// -/// The options intentionally contain only managed limits. Host capabilities, target architecture, and thread affinity -/// are established by Cheat Engine for each activation and are never configured from an application settings file. +/// Host capabilities, target architecture, thread affinity, and unsafe Lua execution are established by the active +/// Cheat Engine client registration and are never configured from an application settings file. /// public sealed class CheatEngineClientOptions { - /// Gets the default configuration section used by plugin hosting. - public const string ConfigurationSectionName = "CheatEngineClient"; - - /// Gets or sets the default maximum number of AOB matches copied into managed memory. - [Range(1, 1_000_000)] - public int DefaultMaximumAobResults + /// Initializes the default table-file policy, which denies table-file access until roots are configured. + public CheatEngineClientOptions() { - get; - set; - } = 4_096; + } - /// Gets or sets the default maximum number of value-scan matches copied in one page. - [Range(1, 1_000_000)] - public int DefaultMaximumValueScanPageSize - { - get; - set; - } = 1_024; + /// Gets the default configuration section used by plugin hosting. + public const string ConfigurationSectionName = "CheatEngineClient"; /// Gets or sets absolute roots from which Client table files may be loaded or saved. /// /// An empty list denies table-file access by default. Paths are normalized and validated when an activation creates /// its client scope; relative paths, blank entries, and are rejected. /// + [Required] public string[]? AllowedTableRoots { get; set; } = Array.Empty(); - - /// Gets or sets whether the explicit unsafe Lua execution module may be registered. - /// - /// This remains by default. Enabling it is a policy opt-in only; applications must still - /// register the optional module that exposes any unsafe operation. - /// - public bool EnableUnsafeLuaExecution - { - get; - set; - } } diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs index 397217b..35e9d25 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs @@ -5,6 +5,11 @@ namespace CheatEngine.Client.Extensions.DependencyInjection; /// Validates the security-sensitive options that cannot be expressed as data annotations. public sealed class CheatEngineClientOptionsSemanticValidator : IValidateOptions { + /// Initializes the semantic validator for the Client table-file policy. + public CheatEngineClientOptionsSemanticValidator() + { + } + /// public ValidateOptionsResult Validate(string? name, CheatEngineClientOptions options) { @@ -23,12 +28,21 @@ public ValidateOptionsResult Validate(string? name, CheatEngineClientOptions opt return ValidateOptionsResult.Fail("AllowedTableRoots cannot contain blank paths."); } - if (!Path.IsPathFullyQualified(root)) + string normalized; + try + { + if (!Path.IsPathFullyQualified(root)) + { + return ValidateOptionsResult.Fail("AllowedTableRoots can contain only fully qualified paths."); + } + + normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or IOException) { - return ValidateOptionsResult.Fail("AllowedTableRoots can contain only fully qualified paths."); + return ValidateOptionsResult.Fail("AllowedTableRoots must contain paths that can be normalized safely."); } - string normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); if (!roots.Add(normalized)) { return ValidateOptionsResult.Fail("AllowedTableRoots cannot contain the same normalized path twice."); diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs index 4380928..ced847a 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs @@ -80,7 +80,10 @@ private static void AddCoreServices(IServiceCollection services) serviceProvider.GetRequiredService>().Value; string[] allowedTableRoots = options.AllowedTableRoots ?? throw new InvalidOperationException("AllowedTableRoots must be validated before the Client policy is created."); - return new CoreClientPolicy(allowedTableRoots, options.EnableUnsafeLuaExecution); + bool enableUnsafeLuaExecution = serviceProvider + .GetService()? + .IsEnabled == true; + return new CoreClientPolicy(allowedTableRoots, enableUnsafeLuaExecution); }); services.TryAddSingleton(static serviceProvider => @@ -158,3 +161,12 @@ private static void AddCoreServices(IServiceCollection services) serviceProvider.GetRequiredService()); } } + +/// Records the builder-only opt-in required to enable unsafe Lua for an activation. +internal sealed class UnsafeLuaExecutionRegistration +{ + internal bool IsEnabled + { + get; + } = true; +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt index 34404d5..01efd9a 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt @@ -11,12 +11,6 @@ CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.get -> string![]? CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.AllowedTableRoots.set -> void CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.CheatEngineClientOptions() -> void -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumAobResults.get -> int -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumAobResults.set -> void -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumValueScanPageSize.get -> int -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.DefaultMaximumValueScanPageSize.set -> void -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.EnableUnsafeLuaExecution.get -> bool -CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions.EnableUnsafeLuaExecution.set -> void CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator.CheatEngineClientOptionsSemanticValidator() -> void CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptionsSemanticValidator.Validate(string? name, CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientOptions! options) -> Microsoft.Extensions.Options.ValidateOptionsResult! diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md index cd9543f..cfc0fe7 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md @@ -20,15 +20,17 @@ This package keeps the high-level API DI-first while preserving the Client's tri Configuration is always opt-in. `BindConfiguration(IConfiguration)` reads the `CheatEngineClient` section, while `BindConfiguration(IConfigurationSection)` lets a plugin choose a different explicit section. The package never searches for, loads, or watches `appsettings.json` on its own. -`CheatEngineClientOptions` controls managed limits and table-file policy: +`CheatEngineClientOptions` controls table-file policy: -- `DefaultMaximumAobResults` and `DefaultMaximumValueScanPageSize` bound managed result materialization. - `AllowedTableRoots` is empty by default, which denies table-file load/save access. -- `EnableUnsafeLuaExecution` remains disabled unless the builder explicitly enables it. + +AOB materialization and value-scan pages require their callers to provide an explicit bound. Unsafe Lua is deliberately +not an appsettings option: it can only be enabled with the explicit builder opt-in below. The builder also provides explicit extension points: -- `AddModule()` preserves module registration order; Hosting enables modules in that order and disables them in reverse order. +- `AddModule()` preserves module registration order and creates modules in the activation scope; Hosting enables + modules in that order and disables them in reverse order. - `AddMemoryCodec()` adds a singleton deterministic codec without reflective structure marshalling. - `EnableUnsafeLuaExecution()` registers the unsafe Lua facade only for the current activation policy. It never exposes an SDK `LuaState`. diff --git a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj index 1ba46be..ea347b1 100644 --- a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj +++ b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj @@ -1,6 +1,7 @@ + {CF0D14B3-B4CB-4BDD-91AD-4C8B6B8BBCD0} true true diff --git a/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs index 26662f6..eb7008b 100644 --- a/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs +++ b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs @@ -119,6 +119,12 @@ protected sealed override void OnDisable() ThrowCleanupFailures(failures); } + /// Builds and resolves the complete activation graph before publishing it to the plugin instance. + /// + /// The activation is not returned until options validation, the Client facade, modules, logging, and cleanup support + /// have all resolved from one scope. If any step fails, this method releases the scope, provider, and configuration + /// before propagating the original failure so a partially built epoch can never become observable. + /// private static Activation CreateActivation(CheatEnginePluginBuilder builder) { ServiceProvider? provider = null; @@ -151,7 +157,7 @@ private static Activation CreateActivation(CheatEnginePluginBuilder builder) private static ICheatEngineClientModule[] GetModules(IServiceProvider services) { - List result = new(); + List result = []; foreach (ICheatEngineClientModule module in services.GetServices()) { result.Add(module); @@ -172,9 +178,16 @@ private static void RethrowAfterCleanup(Exception enableFailure, List cleanupFailures); } + /// Closes an activation in the only safe disposal order and collects every cleanup failure. + /// + /// Module callbacks and Client-owned Cheat Engine resources run while the SDK context is valid. The activation scope, + /// root provider, and configuration are then released in that order. Each stage is attempted even after an earlier + /// stage fails, allowing the caller to report one aggregate failure only after all owned resources had a cleanup + /// opportunity. + /// private List CleanupActivation(Activation activation) { - List failures = new(); + List failures = []; try { using (activation.Cleanup.EnterCleanupScope()) diff --git a/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs b/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs index 0ecddd3..1914df3 100644 --- a/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs +++ b/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs @@ -63,6 +63,12 @@ public ServiceProvider BuildServiceProvider() }); } + /// Releases configuration sources after the activation can no longer resolve services from them. + /// + /// The host calls this only after activation construction fails or after the activation scope and provider have + /// been disposed. This keeps configuration providers, including an explicitly supplied file source, scoped to one + /// enable epoch. + /// internal void ReleaseConfiguration() { Configuration.Dispose(); diff --git a/libs/CheatEngine.Client.Hosting/ClientActivationLifecycle.cs b/libs/CheatEngine.Client.Hosting/ClientActivationLifecycle.cs index 9ce90cb..d416da1 100644 --- a/libs/CheatEngine.Client.Hosting/ClientActivationLifecycle.cs +++ b/libs/CheatEngine.Client.Hosting/ClientActivationLifecycle.cs @@ -54,7 +54,7 @@ internal List Cleanup(Action onClientDisabling) } _cleanupStarted = true; - List failures = new(); + List failures = []; if (_clientEnableHookEntered) { diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs index 356b0c3..7d9f08b 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs @@ -64,4 +64,18 @@ public void ValidateAcceptsDistinctFullyQualifiedAllowedRoots() Assert.True(result.Succeeded); } + + [Fact] + public void ValidateRejectsAnAllowedRootThatCannotBeNormalized() + { + string root = Path.GetPathRoot(Path.GetTempPath()) + "\0"; + + ValidateOptionsResult result = + _validator.Validate(null, new CheatEngineClientOptions { AllowedTableRoots = [root] }); + + Assert.True(result.Failed); + string? failureMessage = result.FailureMessage; + Assert.NotNull(failureMessage); + Assert.Contains("normalized safely", failureMessage, StringComparison.Ordinal); + } } diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs index 5541da2..a349af8 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs @@ -54,35 +54,37 @@ public void EnableUnsafeLuaExecutionAddsOnlyTheExplicitUnsafeLuaDescriptor() ServiceCollection services = new(); CheatEngineClientBuilder builder = services.AddCheatEngineClient(); - builder.EnableUnsafeLuaExecution(); + builder.EnableUnsafeLuaExecution().EnableUnsafeLuaExecution(); - using ServiceProvider provider = services.BuildServiceProvider(); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); - Assert.True(provider.GetRequiredService>().Value.EnableUnsafeLuaExecution); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(UnsafeLuaExecutionRegistration)); } [Fact] public void SectionOverloadBindsThenProgrammaticConfigurationRunsLast() { using ConfigurationManager configuration = new(); - configuration["Configured:DefaultMaximumAobResults"] = "11"; + string configuredRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "configured")); + string overriddenRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "overridden")); + configuration["Configured:AllowedTableRoots:0"] = configuredRoot; ServiceCollection services = new(); services.AddCheatEngineClient(configuration.GetSection("Configured")) - .Configure(static options => options.DefaultMaximumAobResults = 29); + .Configure(options => options.AllowedTableRoots = [overriddenRoot]); using ServiceProvider provider = services.BuildServiceProvider(); CheatEngineClientOptions options = provider.GetRequiredService>().Value; - Assert.Equal(29, options.DefaultMaximumAobResults); + Assert.Equal([overriddenRoot], Assert.IsType(options.AllowedTableRoots)); } [Fact] public void RootOverloadUsesTheDefaultClientSection() { using ConfigurationManager configuration = new(); - configuration["CheatEngineClient:DefaultMaximumValueScanPageSize"] = "87"; + string allowedRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "allowed")); + configuration["CheatEngineClient:AllowedTableRoots:0"] = allowedRoot; ServiceCollection services = new(); services.AddCheatEngineClient(configuration); @@ -90,22 +92,20 @@ public void RootOverloadUsesTheDefaultClientSection() using ServiceProvider provider = services.BuildServiceProvider(); CheatEngineClientOptions options = provider.GetRequiredService>().Value; - Assert.Equal(87, options.DefaultMaximumValueScanPageSize); + Assert.Equal([allowedRoot], Assert.IsType(options.AllowedTableRoots)); } [Fact] - public void GeneratedOptionsValidatorRejectsOutOfRangeBoundConfiguration() + public void ConfigurationCannotEnableUnsafeLuaExecution() { using ConfigurationManager configuration = new(); - configuration["CheatEngineClient:DefaultMaximumAobResults"] = "0"; + configuration["CheatEngineClient:EnableUnsafeLuaExecution"] = "true"; ServiceCollection services = new(); services.AddCheatEngineClient(configuration); using ServiceProvider provider = services.BuildServiceProvider(); - OptionsValidationException exception = Assert.Throws(() => - _ = provider.GetRequiredService>().Value); - - Assert.Contains("DefaultMaximumAobResults", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); + Assert.Empty(provider.GetServices()); } [Fact] @@ -113,11 +113,17 @@ public void AddModulePreservesExplicitRegistrationOrder() { ServiceCollection services = new(); services.AddCheatEngineClient() + .AddModule() .AddModule() .AddModule(); - using ServiceProvider provider = services.BuildServiceProvider(); - ICheatEngineClientModule[] modules = provider.GetServices().ToArray(); + using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + using IServiceScope scope = provider.CreateScope(); + ICheatEngineClientModule[] modules = scope.ServiceProvider.GetServices().ToArray(); Assert.Collection( modules, @@ -125,6 +131,28 @@ public void AddModulePreservesExplicitRegistrationOrder() module => Assert.IsType(module)); } + [Fact] + public void AddModuleConstructsAModuleWithScopedDependencyOncePerActivationScope() + { + ServiceCollection services = new(); + services.AddScoped(); + services.AddCheatEngineClient().AddModule(); + + using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + using IServiceScope scope = provider.CreateScope(); + ScopedDependencyModule first = Assert.IsType( + Assert.Single(scope.ServiceProvider.GetServices())); + ScopedDependencyModule second = Assert.IsType( + Assert.Single(scope.ServiceProvider.GetServices())); + + Assert.Same(first, second); + Assert.Same(first.Dependency, scope.ServiceProvider.GetRequiredService()); + } + private readonly record struct CustomValue(int Value); private sealed class FirstCustomCodec : IMemoryCodec @@ -176,4 +204,28 @@ public void OnDisabling(ICheatEngineClient client) { } } + + public sealed class ScopedModuleDependency + { + public Guid Identifier + { + get; + } = Guid.NewGuid(); + } + + public sealed class ScopedDependencyModule(ScopedModuleDependency dependency) : ICheatEngineClientModule + { + public ScopedModuleDependency Dependency + { + get; + } = dependency; + + public void OnEnabled(ICheatEngineClient client) + { + } + + public void OnDisabling(ICheatEngineClient client) + { + } + } } diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs index 173a119..bf7d64d 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs @@ -1,6 +1,18 @@ using System.Reflection; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Processes; using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +using Microsoft.Extensions.DependencyInjection; namespace CheatEngine.Client.Hosting.Tests; @@ -36,12 +48,359 @@ public void GetRequiredClientWithoutAnActiveEnableEpochThrowsLifecycleException( Assert.Contains("only while the plugin is enabled", exception.Message, StringComparison.Ordinal); } + [Fact] + public void EnableThenDisableUsesOneScopedClientLifecycleAndReleasesCleanupResourcesInOrder() + { + List events = []; + FakeClient client = new(42); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => builder.Client.AddModule()); + + plugin.EnableForTest(); + + Assert.Same(client, plugin.GetRequiredClientForTest()); + CheatEngineClientLifecycleException duplicateEnable = + Assert.Throws(plugin.EnableForTest); + Assert.Equal("EnableClient", duplicateEnable.Failure.Operation); + + plugin.DisableForTest(); + + Assert.Equal( + [ + "configure", "module.enabled", "client.enabled", "cleanup.enter", "client.disabling", + "module.disabling", "cleanup.drain", "cleanup.exit" + ], + events); + Assert.Equal(1, cleanup.DrainCount); + Assert.Equal(1, cleanup.ScopeDisposeCount); + Assert.Throws(plugin.GetRequiredClientForTest); + } + + [Fact] + public void FailedModuleEnableRollsBackAllEnteredModulesAndLeavesThePluginInactive() + { + List events = []; + FakeClient client = new(43); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => + { + builder.Client.AddModule().AddModule(); + }); + + InvalidOperationException exception = Assert.Throws(plugin.EnableForTest); + + Assert.Equal("module enable", exception.Message); + Assert.Equal( + [ + "configure", "module.enabled", "module.enable-failed", "cleanup.enter", "module.disable-failed", + "module.disabling", "cleanup.drain", "cleanup.exit" + ], + events); + Assert.Throws(plugin.GetRequiredClientForTest); + + plugin.DisableForTest(); + + Assert.Equal(1, cleanup.DrainCount); + } + + [Fact] + public void DefaultApplicationCallbacksParticipateInTheActivationLifecycle() + { + List events = []; + FakeClient client = new(46); + RecordingCleanup cleanup = new(events); + DefaultCallbacksPlugin plugin = new(CreateConfiguration(events, client, cleanup, static _ => { })); + + plugin.EnableForTest(); + + Assert.Same(client, plugin.GetRequiredClientForTest()); + + plugin.DisableForTest(); + + Assert.Equal(["configure", "cleanup.enter", "cleanup.drain", "cleanup.exit"], events); + } + + [Fact] + public void DisableRethrowsOneCleanupScopeFailureAfterClosingTheActivation() + { + List events = []; + FakeClient client = new(47); + RecordingCleanup cleanup = new(events, enterFailure: new InvalidOperationException("cleanup scope")); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static _ => { }); + plugin.EnableForTest(); + + InvalidOperationException exception = Assert.Throws(plugin.DisableForTest); + + Assert.Equal("cleanup scope", exception.Message); + Assert.Equal(["configure", "client.enabled", "cleanup.enter"], events); + Assert.Throws(plugin.GetRequiredClientForTest); + } + + [Fact] + public void ApplicationEnableFailureAndCleanupFailureAreReportedTogetherAfterRollback() + { + List events = []; + FakeClient client = new(44); + RecordingCleanup cleanup = new(events, drainFailure: new InvalidOperationException("drain")); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => builder.Client.AddModule(), + onClientEnabled: static _ => throw new InvalidOperationException("application enable")); + + AggregateException exception = Assert.Throws(plugin.EnableForTest); + + Assert.Collection( + exception.InnerExceptions, + failure => Assert.Equal("application enable", failure.Message), + failure => Assert.Equal("drain", failure.Message)); + Assert.Equal( + [ + "configure", "module.enabled", "cleanup.enter", "client.disabling", "module.disabling", "cleanup.drain", + "cleanup.exit" + ], + events); + Assert.Equal(1, cleanup.ScopeDisposeCount); + } + + [Fact] + public void DisableAggregatesApplicationModuleAndResourceCleanupFailures() + { + List events = []; + FakeClient client = new(45); + RecordingCleanup cleanup = new(events, drainFailure: new InvalidOperationException("drain")); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => builder.Client.AddModule(), + onClientDisabling: _ => + { + events.Add("client.disabling"); + throw new InvalidOperationException("application disable"); + }); + plugin.EnableForTest(); + + AggregateException exception = Assert.Throws(plugin.DisableForTest); + + Assert.Collection( + exception.InnerExceptions, + failure => Assert.Equal("application disable", failure.Message), + failure => Assert.Equal("module disable", failure.Message), + failure => Assert.Equal("drain", failure.Message)); + Assert.Equal( + [ + "configure", "module.enabled", "client.enabled", "cleanup.enter", "client.disabling", "module.disabling", + "cleanup.drain", "cleanup.exit" + ], + events); + Assert.Equal(1, cleanup.DrainCount); + } + + [Fact] + public void ConfigureFailureDoesNotPublishAnActivation() + { + int configureCalls = 0; + TestPlugin plugin = new(_ => + { + configureCalls++; + throw new InvalidOperationException("configuration"); + }); + + InvalidOperationException exception = Assert.Throws(plugin.EnableForTest); + + Assert.Equal("configuration", exception.Message); + Assert.Equal(1, configureCalls); + Assert.Throws(plugin.GetRequiredClientForTest); + + plugin.DisableForTest(); + } + + private static TestPlugin CreatePlugin( + List events, + FakeClient client, + RecordingCleanup cleanup, + Action configure, + Action? onClientEnabled = null, + Action? onClientDisabling = null) + { + return new TestPlugin(CreateConfiguration(events, client, cleanup, configure), onClientEnabled ?? (currentClient => events.Add("client.enabled")), + onClientDisabling ?? (currentClient => events.Add("client.disabling"))); + } + + private static Action CreateConfiguration( + List events, + FakeClient client, + RecordingCleanup cleanup, + Action configure) + { + return builder => + { + events.Add("configure"); + builder.Services.AddSingleton(events); + builder.Services.AddSingleton(client); + builder.Services.AddSingleton(cleanup); + configure(builder); + }; + } + private sealed class TestPlugin : CheatEngineClientPlugin + { + private readonly Action _configure; + private readonly Action _onClientEnabled; + private readonly Action _onClientDisabling; + + public TestPlugin() + : this(static _ => { }) + { + } + + internal TestPlugin( + Action configure, + Action? onClientEnabled = null, + Action? onClientDisabling = null) + { + _configure = configure; + _onClientEnabled = onClientEnabled ?? (static _ => { }); + _onClientDisabling = onClientDisabling ?? (static _ => { }); + } + + protected override void Configure(CheatEnginePluginBuilder builder) + { + _configure(builder); + } + + protected override void OnClientEnabled(ICheatEngineClient client) + { + _onClientEnabled(client); + } + + protected override void OnClientDisabling(ICheatEngineClient client) + { + _onClientDisabling(client); + } + + internal void EnableForTest() => OnEnable(); + + internal void DisableForTest() => OnDisable(); + + internal ICheatEngineClient GetRequiredClientForTest() => GetRequiredClient(); + } + + private sealed class DefaultCallbacksPlugin(Action configure) : CheatEngineClientPlugin { protected override void Configure(CheatEnginePluginBuilder builder) { + configure(builder); } + internal void EnableForTest() => OnEnable(); + + internal void DisableForTest() => OnDisable(); + internal ICheatEngineClient GetRequiredClientForTest() => GetRequiredClient(); } + + public sealed class RecordingModule(List events) : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + events.Add("module.enabled"); + } + + public void OnDisabling(ICheatEngineClient client) + { + events.Add("module.disabling"); + } + } + + public sealed class FailingEnableModule(List events) : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + events.Add("module.enable-failed"); + throw new InvalidOperationException("module enable"); + } + + public void OnDisabling(ICheatEngineClient client) + { + events.Add("module.disable-failed"); + } + } + + public sealed class FailingDisableModule(List events) : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + events.Add("module.enabled"); + } + + public void OnDisabling(ICheatEngineClient client) + { + events.Add("module.disabling"); + throw new InvalidOperationException("module disable"); + } + } + + private sealed class RecordingCleanup( + List events, + Exception? enterFailure = null, + Exception? drainFailure = null) + : ICheatEngineClientActivationCleanup + { + internal int DrainCount + { + get; + private set; + } + + internal int ScopeDisposeCount + { + get; + private set; + } + + public IDisposable EnterCleanupScope() + { + events.Add("cleanup.enter"); + if (enterFailure is not null) + { + throw enterFailure; + } + + return new CallbackDisposable(() => + { + ScopeDisposeCount++; + events.Add("cleanup.exit"); + }); + } + + public void DrainOwnedResourcesForDisable() + { + DrainCount++; + events.Add("cleanup.drain"); + if (drainFailure is not null) + { + throw drainFailure; + } + } + } + + private sealed class CallbackDisposable(Action dispose) : IDisposable + { + private Action? _dispose = dispose; + + public void Dispose() + { + Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } + } + + private sealed class FakeClient(long epoch) : ICheatEngineClient + { + public long Epoch => epoch; + public CancellationToken Stopping => CancellationToken.None; + public ICheatEngineRuntime Runtime => null!; + public ICheatEngineDispatcher Dispatcher => null!; + public IProcessClient Processes => null!; + public IMemoryClient Memory => null!; + public IPatternScanner Patterns => null!; + public IValueScanner Scans => null!; + public IInspectionClient Inspection => null!; + public ITableClient Tables => null!; + public ILuaClient Lua => null!; + } } diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs index 88cb1c9..bc96221 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs @@ -12,12 +12,13 @@ public sealed class CheatEnginePluginBuilderTests public void BuildServiceProviderValidatesServicesAndBindsTheActivationConfiguration() { CheatEnginePluginBuilder builder = new(); - builder.Configuration["CheatEngineClient:DefaultMaximumAobResults"] = "19"; + string allowedRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Hosting.Tests")); + builder.Configuration["CheatEngineClient:AllowedTableRoots:0"] = allowedRoot; using ServiceProvider provider = builder.BuildServiceProvider(); CheatEngineClientOptions options = provider.GetRequiredService>().Value; - Assert.Equal(19, options.DefaultMaximumAobResults); + Assert.Equal([allowedRoot], Assert.IsType(options.AllowedTableRoots)); Assert.Same(builder.Configuration, provider.GetRequiredService()); Assert.Same(builder.Configuration, provider.GetRequiredService()); } From 37f5eb5edb33c522f2fdbde5166c8f851553e0ca Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 19:13:46 +0200 Subject: [PATCH 4/7] chore: add delivery pipeline and ceplugin template Complete the release-facing repository shape after the modular client layers are in place. The solution now replaces the legacy Binding project with Core, publishes the template package and a Native AOT compatibility probe, adds package/template smoke scripts, and updates CI into reusable main and pull-request workflows. The canonical ceplugin template demonstrates explicit JSON configuration, DI modules, safe Lua module registration, direct SDK reference requirements, and managed-plugin deployment. The root documentation and ADR set now describe the package graph, activation lifecycle, capability gates, delivery policy, and local authorized-process boundary. Validation: - dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --warnaserror - dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore --report-trx --results-directory artifacts/test-results --fail-skips on - dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore --output artifacts/packages - eng/Invoke-PackageSmoke.ps1 -PackageSource artifacts/packages - eng/Invoke-TemplateSmoke.ps1 -PackageSource artifacts/packages - dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj --configuration Release --runtime win-x64 --no-restore --output artifacts/aot-probe - artifacts/aot-probe/CheatEngine.Client.AotProbe.exe --- .github/workflows/build.yml | 81 ----- .github/workflows/ci.yml | 137 +++++++ .github/workflows/main-ci.yml | 34 ++ .github/workflows/pull-request-ci.yml | 64 ++++ CheatEngine.Client.slnx | 12 +- README.md | 338 ++++++++++++++---- .../0001-layered-in-process-architecture.md | 46 +++ docs/adr/0002-plugin-activation-lifecycle.md | 43 +++ docs/adr/0003-package-and-aot-policy.md | 47 +++ docs/adr/0004-capability-matrix.md | 57 +++ docs/adr/README.md | 32 ++ eng/Invoke-PackageSmoke.ps1 | 171 +++++++++ eng/Invoke-TemplateSmoke.ps1 | 98 +++++ .../CheatEngine.Client.Binding.csproj | 7 - libs/CheatEngine.Client.Binding/README.md | 11 - .../CheatEngine.Client.Templates.csproj | 15 + .../CheatEngine.Client.Templates/README.md | 61 ++++ .../.template.config/template.json | 49 +++ .../CheatEngine.Plugin.csproj | 28 ++ .../Modules/PluginClientModule.cs | 129 +++++++ .../Modules/PluginLuaFunctions.cs | 13 + .../Modules/PluginLuaModule.cs | 34 ++ .../content/CheatEngine.Plugin/Plugin.cs | 29 ++ .../content/CheatEngine.Plugin/README.md | 62 ++++ .../CheatEngine.Plugin/appsettings.json | 8 + .../packages.lock.json | 36 ++ .../CheatEngine.Client.AotProbe.csproj | 14 + tests/CheatEngine.Client.AotProbe/Program.cs | 21 ++ tests/CheatEngine.Client.AotProbe/README.md | 31 ++ .../packages.lock.json | 217 +++++++++++ .../CheatEngine.Client.Binding.Tests.csproj | 7 - .../README.md | 3 - 32 files changed, 1758 insertions(+), 177 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/main-ci.yml create mode 100644 .github/workflows/pull-request-ci.yml create mode 100644 docs/adr/0001-layered-in-process-architecture.md create mode 100644 docs/adr/0002-plugin-activation-lifecycle.md create mode 100644 docs/adr/0003-package-and-aot-policy.md create mode 100644 docs/adr/0004-capability-matrix.md create mode 100644 docs/adr/README.md create mode 100644 eng/Invoke-PackageSmoke.ps1 create mode 100644 eng/Invoke-TemplateSmoke.ps1 delete mode 100644 libs/CheatEngine.Client.Binding/CheatEngine.Client.Binding.csproj delete mode 100644 libs/CheatEngine.Client.Binding/README.md create mode 100644 templates/CheatEngine.Client.Templates/CheatEngine.Client.Templates.csproj create mode 100644 templates/CheatEngine.Client.Templates/README.md create mode 100644 templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/template.json create mode 100644 templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj create mode 100644 templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs create mode 100644 templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs create mode 100644 templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaModule.cs create mode 100644 templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs create mode 100644 templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md create mode 100644 templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json create mode 100644 templates/CheatEngine.Client.Templates/packages.lock.json create mode 100644 tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj create mode 100644 tests/CheatEngine.Client.AotProbe/Program.cs create mode 100644 tests/CheatEngine.Client.AotProbe/README.md create mode 100644 tests/CheatEngine.Client.AotProbe/packages.lock.json delete mode 100644 tests/CheatEngine.Client.Binding.Tests/CheatEngine.Client.Binding.Tests.csproj delete mode 100644 tests/CheatEngine.Client.Binding.Tests/README.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 4f66b91..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: SonarQube -on: - push: - branches: - - main - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - -jobs: - build: - name: Build and analyze - runs-on: windows-latest - steps: - - name: Set up JDK 21 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 - with: - java-version: 21 - distribution: "zulu" # Alternative distribution options are available. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: 10.0.401 - - name: Cache SonarQube Cloud packages - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~\sonar\cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - name: Cache SonarQube Cloud scanner - id: cache-sonar-scanner - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ runner.temp }}\scanner - key: ${{ runner.os }}-sonar-scanner - restore-keys: ${{ runner.os }}-sonar-scanner - - name: Install SonarQube Cloud scanner - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - shell: powershell - run: | - $scannerPath = Join-Path $env:RUNNER_TEMP 'scanner' - $scannerExecutable = Join-Path $scannerPath 'dotnet-sonarscanner.exe' - New-Item -Path $scannerPath -ItemType Directory -Force | Out-Null - if (-not (Test-Path $scannerExecutable)) { - dotnet tool update dotnet-sonarscanner --tool-path $scannerPath - } - - name: Begin SonarQube Cloud analysis - id: sonar_begin - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - shell: powershell - run: | - & "$env:RUNNER_TEMP\scanner\dotnet-sonarscanner.exe" begin /k:"CheatEngineNet_CheatEngine.Client" /o:"cheatenginenet" "/d:sonar.token=$env:SONAR_TOKEN" /d:sonar.cs.cobertura.reportsPaths="artifacts/sonar-test-results/**/*.cobertura.xml" - - - name: Build - shell: powershell - run: | - dotnet build CheatEngine.Client.slnx --configuration Release - - - name: Test - shell: powershell - run: | - # The stacked branches deliberately introduce some test projects before their first tests. MTP reports exit - # code 8 for those empty intermediate projects; ignore only that code while genuine test failures still fail. - dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --coverage --coverage-output-format cobertura --results-directory artifacts/sonar-test-results --ignore-exit-code 8 - - - name: End SonarQube Cloud analysis - if: ${{ always() && steps.sonar_begin.outcome == 'success' }} - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - shell: powershell - run: | - & "$env:RUNNER_TEMP\scanner\dotnet-sonarscanner.exe" end "/d:sonar.token=$env:SONAR_TOKEN" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dcda338 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,137 @@ +name: Client validation + +on: + workflow_call: + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + MSBUILDDISABLENODEREUSE: true + +jobs: + validate: + name: Validate packages, template, and AOT graph + runs-on: windows-latest + timeout-minutes: 35 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: | + **/packages.lock.json + Directory.Packages.props + + - name: Restore locked dependency graph + run: dotnet restore CheatEngine.Client.slnx --locked-mode + + - name: Build Release + run: dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --warnaserror + + - name: Run unit tests through Microsoft Testing Platform + run: >- + dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore + --report-trx --results-directory artifacts/test-results --fail-skips on + + - name: Summarize test results + if: ${{ !cancelled() }} + run: | + $reports = @(Get-ChildItem -Path artifacts/test-results -Filter *.trx -Recurse -File -ErrorAction SilentlyContinue) + if ($reports.Count -eq 0) { + Write-Host '::warning::The test run produced no TRX report.' + return + } + + $results = @($reports | ForEach-Object { + ([xml](Get-Content -LiteralPath $_.FullName -Raw)).TestRun.Results.UnitTestResult + } | Where-Object { $_ }) + $passed = @($results | Where-Object outcome -eq 'Passed').Count + $skipped = @($results | Where-Object outcome -eq 'NotExecuted').Count + $failed = @($results | Where-Object { $_.outcome -notin 'Passed', 'NotExecuted' }) + + @( + '### Test results', + '', + '| Total | Passed | Failed | Skipped |', + '| ---: | ---: | ---: | ---: |', + "| $($results.Count) | $passed | $($failed.Count) | $skipped |" + ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-results + path: artifacts/test-results/**/*.trx + if-no-files-found: warn + retention-days: 14 + + - name: Pack and validate public package APIs + run: dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore + + - name: Smoke test isolated package consumption + shell: pwsh + run: ./eng/Invoke-PackageSmoke.ps1 -PackageSource ./artifacts/packages + + - name: Smoke test local template installation + run: ./eng/Invoke-TemplateSmoke.ps1 -PackageSource ./artifacts/packages + + - name: Upload package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nuget-packages + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg + if-no-files-found: error + retention-days: 14 + + - name: Publish Native AOT reference probe + run: dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj --configuration Release --runtime win-x64 --no-restore --output ./artifacts/aot-probe + + - name: Run Native AOT reference probe + run: ./artifacts/aot-probe/CheatEngine.Client.AotProbe.exe + + - name: Upload Native AOT probe + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-aot-probe + path: artifacts/aot-probe + if-no-files-found: warn + retention-days: 14 + + gate: + name: Gate + if: ${{ always() }} + needs: validate + runs-on: windows-latest + timeout-minutes: 5 + permissions: {} + + steps: + - name: Check validation result + env: + VALIDATE_RESULT: ${{ needs.validate.result }} + run: | + "| Job | Result |", "| --- | --- |", "| validate | $env:VALIDATE_RESULT |" | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($env:VALIDATE_RESULT -ne 'success') { + Write-Host "::error::Validation finished with '$env:VALIDATE_RESULT'." + exit 1 + } diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml new file mode 100644 index 0000000..3d69a2d --- /dev/null +++ b/.github/workflows/main-ci.yml @@ -0,0 +1,34 @@ +name: Main CI + +on: + push: + branches: [main] + paths: + - '.editorconfig' + - '.github/**' + - 'CheatEngine.Client.slnx' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'README.md' + - 'eng/**' + - 'global.json' + - 'libs/**' + - 'src/**' + - 'templates/**' + - 'tests/**' + merge_group: + types: [checks_requested] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + ci: + name: CI + uses: ./.github/workflows/ci.yml diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml new file mode 100644 index 0000000..65a94ba --- /dev/null +++ b/.github/workflows/pull-request-ci.yml @@ -0,0 +1,64 @@ +name: Pull request CI + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - '.editorconfig' + - '.github/**' + - 'CheatEngine.Client.slnx' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'README.md' + - 'eng/**' + - 'global.json' + - 'libs/**' + - 'src/**' + - 'templates/**' + - 'tests/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + ci: + name: CI + if: github.event.pull_request.draft == false + uses: ./.github/workflows/ci.yml + + lint-workflows: + name: Lint workflows + if: github.event.pull_request.draft == false + runs-on: windows-latest + timeout-minutes: 5 + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9 + + steps: + - name: Checkout workflow definitions + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: .github + persist-credentials: false + + - name: Run actionlint + run: | + $ErrorActionPreference = 'Stop' + $archive = Join-Path $env:RUNNER_TEMP 'actionlint.zip' + $url = "https://github.com/rhysd/actionlint/releases/download/v$env:ACTIONLINT_VERSION/actionlint_$($env:ACTIONLINT_VERSION)_windows_amd64.zip" + Invoke-WebRequest -Uri $url -OutFile $archive -MaximumRetryCount 3 -RetryIntervalSec 5 + + $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:ACTIONLINT_SHA256) { + throw "actionlint $env:ACTIONLINT_VERSION has SHA-256 $actual, expected $env:ACTIONLINT_SHA256." + } + + $destination = Join-Path $env:RUNNER_TEMP 'actionlint' + Expand-Archive -LiteralPath $archive -DestinationPath $destination + & (Join-Path $destination 'actionlint.exe') -color diff --git a/CheatEngine.Client.slnx b/CheatEngine.Client.slnx index c8acdbc..99628c9 100644 --- a/CheatEngine.Client.slnx +++ b/CheatEngine.Client.slnx @@ -11,22 +11,30 @@ + - + + + - + + + + + diff --git a/README.md b/README.md index 1fe3585..1a7b6a4 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,283 @@ +
+ # CheatEngine.Client -A public, fluent, strongly-typed C# 14 / .NET 10 client API, built as a mapper/binder over CheatEngine.SDK. +**High-level, lifecycle-safe C# APIs for modern Cheat Engine plugins.** + +[![CI](https://img.shields.io/github/actions/workflow/status/CheatEngineNet/CheatEngine.Client/main-ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&labelColor=24292f)](https://github.com/CheatEngineNet/CheatEngine.Client/actions/workflows/main-ci.yml) +[![NuGet](https://img.shields.io/nuget/vpre/CheatEngine.Client?style=flat-square&logo=nuget&logoColor=white&labelColor=24292f&color=004880)](https://www.nuget.org/packages/CheatEngine.Client) +[![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4?style=flat-square&logo=dotnet&logoColor=white&labelColor=24292f)](https://dotnet.microsoft.com/download/dotnet/10.0) +[![Windows x64](https://img.shields.io/badge/platform-Windows%20x64-0078D4?style=flat-square&labelColor=24292f)](#requirements) + +[Quick start](#quick-start) · [Lifecycle](#the-plugin-lifecycle) · [Packages](#packages-and-direct-sdk-reference) · [Capabilities](#v010-capability-status) · [Contributing](#build-and-validation) + +
+ +## Context + +`CheatEngine.Client` is an in-process, dependency-injection-first layer for plugins loaded by Cheat Engine. It builds on +[`CheatEngine.SDK`](https://www.nuget.org/packages/CheatEngine.SDK) and turns its low-level host bindings into +bounded, typed, fluent C# operations for the lifetime of one plugin activation. + +The aggregate `ICheatEngineClient` gives an enabled plugin access to runtime facts and capabilities, main-thread +dispatch, process selection, typed memory, AOB scans, inspection, address tables, and protected Lua operations. It +never exposes a `LuaState`, CE object handle, raw native pointer, or SDK ownership wrapper to plugin code. + +## Why this project exists + +`CheatEngine.SDK` deliberately owns the difficult boundary: the generated Cheat Engine entry point, Lua protection, +native bridge, host object model, and compile-time plugin/Lua diagnostics. Those are SDK concerns and +`CheatEngine.Client` does not reimplement them. + +The Client exists for the application layer above that boundary. It makes recurring plugin concerns explicit and +testable: + +| SDK boundary | Client policy above it | +|---|---| +| Plugin bootstrap and protected Lua calls | One activation-scoped `ICheatEngineClient`; no raw Lua lifetime escapes | +| Host-owned temporary objects and main-thread affinity | Synchronous dispatcher boundary, copied results, and deterministic cleanup | +| Primitive Lua/host operations | Typed memory codecs, bounded strings and pointer chains, immutable AOB builders | +| Plugin construction | One validated DI provider per enable epoch; explicit modules and configuration | +| Host failures and capability differences | `Try...` methods with `CheatEngineFailure`, convenience methods that throw, and runtime capability observations | + +This separation lets a plugin stay ordinary, DI-friendly C# while retaining the SDK as the sole authority for ABI and +Lua safety. It also keeps the high-level surface honest: a contract is not presented as a working Cheat Engine feature +until its ownership, thread-affinity, and lifecycle path are established. + +## How it helps improve Cheat Engine plugin projects + +The Client centralizes lifecycle, ownership, dispatch, options, and capability policy once, rather than requiring each +plugin to reproduce them around low-level SDK calls. This lowers the cost of adding a feature, gives tests a stable +contract boundary, and keeps the generated plugin template focused on application code. It also makes the supported +surface reviewable: high-level APIs remain fluent for consumers while the Core remains the only SDK mapper. + +## Requirements + +| Requirement | Baseline | +|---|---| +| .NET SDK | 10.0.401 or later | +| Target framework / language | `net10.0` / C# 14 | +| Cheat Engine host | 7.7, Windows x64 | +| Plugin form | Framework-dependent managed plugin output folder | +| SDK package | `CheatEngine.SDK` 1.x; the Client publishes a compatible range of `[1.0.0, 2.0.0)` | + +Cheat Engine remains the compatibility authority. The Client is not an IPC client, a remote-process service, or a +standalone executable; v0.1 runs only inside an enabled Cheat Engine plugin. + +## Quick start + +The maintained starting point is the `ceplugin` template. It is both a usable project and the repository's executable +example of the required plugin shape. ```powershell -dotnet build CheatEngine.Client.slnx +dotnet new install CheatEngine.Client.Templates +dotnet new ceplugin --name MyPlugin +cd MyPlugin +dotnet build --configuration Release +``` + +The generated project intentionally retains these direct dependencies: + +```xml + + net10.0 + 14.0 + x64 + true + true + + + + + + + +``` + +`CheatEngine.SDK` must be referenced **directly by the plugin project**. Its build assets generate the Cheat Engine +entry point and provide the native Lua bridge; NuGet transitivity is not sufficient at that host boundary. Setting +`CheatEngineClientPluginProject` opts the project into the Hosting package's `CECLIENT001` guard, which fails the +build if the direct SDK reference is removed. + +Start with the template rather than copying this fragment into an existing plugin: it also demonstrates module +registration, generated Lua exports, validated options, bounded AOB and typed-memory access, and an Address List +snapshot. See the [template guide](templates/CheatEngine.Client.Templates/README.md) and the generated +[plugin README](templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md). + +### Minimal plugin shape + +The SDK still owns the plugin annotation. The Client base owns the activation-scoped composition: + +```csharp +using CheatEngine.Client.Hosting; +using CheatEngine.SDK.Annotations.Plugin; +using Microsoft.Extensions.Configuration; + +namespace MyPlugin; + +[CheatEnginePlugin("My Plugin")] +public sealed class Plugin : CheatEngineClientPlugin +{ + protected override void Configure(CheatEnginePluginBuilder builder) + { + builder.Configuration + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + + builder.Client.AddModule(); + } +} +``` + +An activation module receives the scoped client in `OnEnabled` and `OnDisabling`. Fluent calls remain bounded and +handle-free: + +```csharp +Address address = client.Patterns + .Aob("48 8B ?? ?? ?? 89") + .InModule("game.exe") + .ReadableExecutable() + .RequireSingle() + .Execute(); + +client.Memory.At(address + 0x14).Write(999); ``` -## Layout +Use the `Try...` terminal operations when absence of a process, scan result, or runtime capability is an expected +condition. Do not make a worker wait for the Cheat Engine thread if that worker can call back into the Client. + +## The plugin lifecycle + +The parameterless plugin instance is created by the SDK, but every enable creates new managed state: -The solution folders of `CheatEngine.Client.slnx` mirror the directories below one to one, so a path in Rider's Solution -Explorer is also the path on disk. `/Solution Items/` is the only virtual folder. +```text +OnEnable + -> Configure a new builder (explicit configuration, services, modules, codecs) + -> Build and validate a new provider and scope + -> Resolve options and ICheatEngineClient + -> Enable modules in registration order + -> OnClientEnabled +OnDisable + -> Stop admitting the active client + -> OnClientDisabling + -> Disable modules in reverse order + -> Drain Client-owned CE resources while the SDK context is valid + -> Dispose scope, provider, and configuration ``` -CheatEngine.Client/ -├─ eng/ MSBuild profiles, selected by the top-level folder of a project -├─ libs/ Small layered libraries -│ ├─ CheatEngine.Client.Abstractions/ Public vocabulary and contracts -│ ├─ CheatEngine.Client.Binding/ Mapper/binder onto CheatEngine.SDK -│ └─ CheatEngine.Client.Fluent/ Fluent public API -├─ src/ -│ └─ CheatEngine.Client/ The one project a consumer references -└─ tests/ One .Tests twin per project above + +`ICheatEngineClient.Epoch` and `ICheatEngineClient.Stopping` identify that activation. Never retain the client, a +resource lease, a Lua reference, a cancellation token, or a target-bound value across disable/re-enable. Constructors, +field initializers, and static initialization must not call Cheat Engine; the SDK binding is valid only after enable. + +All Client operations are synchronous. A cancellation token can prevent dispatch or stop Client-managed work between +steps, but it does not claim to interrupt a Lua primitive that has already started. Read +[ADR 0002](docs/adr/0002-plugin-activation-lifecycle.md) before adding a service that touches Cheat Engine. + +## Packages and direct SDK reference + +The recommended package is `CheatEngine.Client`. The delivery graph stays deliberately one-way: + +```text +CheatEngine.Client +├─ CheatEngine.Client.Fluent ────────────────> public contracts +└─ CheatEngine.Client.Hosting + ├─ CheatEngine.Client.Extensions.DependencyInjection + │ ├─ CheatEngine.Client.Core ────────────> CheatEngine.SDK + │ └─ public contracts + └─ CheatEngine.SDK + +public contracts ────────────────────────────> stable SDK value/runtime types only ``` -## Projects - -| Project | Role | References | -|-----------------------------------|----------------------------------------------------------------------------------------|-------------------------------------| -| `CheatEngine.Client.Abstractions` | Strongly-typed public vocabulary, and the contracts between the fluent and the binding | nothing | -| `CheatEngine.Client.Fluent` | Fluent, composable public API | `Abstractions` | -| `CheatEngine.Client.Binding` | Mapper/binder onto CheatEngine.SDK. Internal by default | `Abstractions` | -| `CheatEngine.Client` | Composition root: the single project a consumer references | `Abstractions`, `Fluent`, `Binding` | - -Dependency rules: - -- `Abstractions` is the base. `Fluent` and `Binding` never reference each other, and only `CheatEngine.Client` composes - them. -- `libs/` never references `src/` or `tests/`. A consumer references `CheatEngine.Client` only. -- `Binding` is the only project meant to depend on CheatEngine.SDK (the default rule, to revisit if `Abstractions` reuses CheatEngine.SDK - vocabulary). -- A test project references its subject only. - -## Conventions - -- Folder name, project file name, assembly name and root namespace are the same string. -- Every project has a `README.md` next to its project file. The build fails without it (`CHEATENGINECLIENT9001`). -- Every project in `libs/` and `src/` has a twin `tests/.Tests`, which sees its internals. The `.Tests` suffix is - reserved for test projects. -- The top-level folder of a project selects its profile: `libs/` and `src/` use `eng/Shipping.props`, `tests/` uses - `eng/Tests.props`. A project outside these folders has no target framework and does not build. -- `Directory.Build.props` is the only one of the repository: no nested `Directory.Build.*` files. -- Build output goes to `artifacts/`, never inside a project folder. -- No `Common`, `Utils` or `Helpers` folders. - -## Solution folders - -- One solution folder per directory, with the same path (`/libs/`, `/src/`, `/tests/`, `/eng/`). They stay flat, and - nest only where the disk nests. -- Entries are sorted by path, case-insensitively. Project sources and project READMEs are not listed. `artifacts/`, - `.idea/` and `.claude/` are never listed. -- Adding a project: its folder, its `.csproj`, its `README.md`, one `` line in the matching solution folder, and - its `.Tests` twin. - -## Reserved, not created yet - -A folder exists only when it holds a real file: no empty placeholder directories, no `.gitkeep`. A new top-level folder -that holds projects (`samples/`, `benchmarks/`) needs its own profile in `eng/`, an `` for it in -`Directory.Build.props`, and its own solution folder. `tests/CheatEngine.Client.Tests.Shared/` reuses the `tests/` -profile and the `/tests/` solution folder. `docs/` holds no project, so it only gets a file-only solution folder. - -| Location | Created when | -|------------------------------------------|------------------------------------------------------------------------------------| -| `samples/` | The first public API is worth demonstrating | -| `tests/CheatEngine.Client.Tests.Shared/` | A second test project needs the same fake | -| `benchmarks/` | The first performance-sensitive path exists | -| `docs/` | A document no longer fits in a README (`docs/adr/` with the first decision record) | +| Package | Purpose | Consume directly when | +|---|---|---| +| [`CheatEngine.Client`](src/CheatEngine.Client/README.md) | Umbrella package for the high-level fluent and hosting experience | Building a normal plugin | +| [`CheatEngine.Client.Hosting`](libs/CheatEngine.Client.Hosting/README.md) | `CheatEngineClientPlugin` and one-provider-per-activation host | Integrating the host into an existing composition root | +| [`CheatEngine.Client.Extensions.DependencyInjection`](libs/CheatEngine.Client.Extensions.DependencyInjection/README.md) | Explicit DI registrations, modules, memory codecs, and options | Composing the Client without the plugin base | +| [`CheatEngine.Client.Fluent`](libs/CheatEngine.Client.Fluent/README.md) | Immutable fluent memory and AOB builders | Depending only on fluent request construction | +| [`CheatEngine.Client.Abstractions`](libs/CheatEngine.Client.Abstractions/README.md) | Contracts, requests, failures, and value vocabulary | Referencing contracts without an implementation | +| [`CheatEngine.Client.Core`](libs/CheatEngine.Client.Core/README.md) | SDK-facing implementation | Normally composed through DI, not called directly | +| [`CheatEngine.Client.Templates`](templates/CheatEngine.Client.Templates/README.md) | `dotnet new ceplugin` | Starting a new plugin | + +Package and assembly names describe delivery, not user code. Consumer-facing APIs use functional namespaces such as +`CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, `.Processes`, `.Runtime`, and `.Hosting`. + +## v0.1.0 capability status + +The Client reports runtime capability rather than assuming a particular Cheat Engine global or ownership contract. The +following table is a delivery statement, not a substitute for a live host check. + +| Area | v0.1.0 status | Boundary | +|---|---|---| +| Lifecycle, dispatch, DI, modules, options | Available | Per-enable provider and scope; modules stop in reverse order | +| Runtime facts and selected process | Available | Snapshot and attachment state are re-read through the active host | +| Typed memory and finite pointer chains | Available | Built-in primitives plus explicitly registered deterministic codecs; strings and byte ranges are bounded | +| Modules, regions, symbols, and custom-symbol leases | Available | Results are copied; leases are activation-scoped | +| AOB scanning | Available | Patterns are normalized; terminals are `FirstOrNone`, `RequireSingle`, or bounded `Take` | +| Address List and memory records | Available | Snapshots and hierarchy materialization are bounded; table file access requires an allowed root | +| Typed protected Lua and explicit Lua modules | Available | No Lua state crosses the public Client contract | +| Value scanning | **Capability-gated** | The public state machine exists, but Client session creation stays unavailable until the internal `MemScan`/`FoundList` ownership path passes its Cheat Engine 7.7 x64 live gate | +| Arbitrary Lua source | Policy-gated and off by default | Requires explicit unsafe opt-in; raw Lua state remains hidden | + +IPC, remote clients, UI/forms, debugger and breakpoints, Auto Assembler, injection, remote allocations, structures, +hotkeys/timers, speedhack, DBVM, Mono/IL2CPP, and advanced ABI hooks are outside v0.1. They have no placeholder +public API. The full current-state rationale is in [ADR 0004](docs/adr/0004-capability-matrix.md). + +## AOT, trimming, and deployment + +Shipping Client projects target `net10.0`, enable nullable analysis, warnings as errors, trim/AOT compatibility +analysis, reference-AOT verification, deterministic builds, XML documentation, Source Link, symbol packages, and +package/API validation. `CheatEngine.Client.AotProbe` publishes the complete Client graph as Native AOT for `win-x64` +to validate those library constraints. + +That is **not** a claim that Cheat Engine can load a Native AOT plugin DLL. The supported deployment remains the +framework-dependent managed plugin output folder. Deploy it as one unit: your plugin assembly, its `.deps.json` and +`.runtimeconfig.json`, Client and SDK assemblies, and the SDK's `cheatengine-sdk-lua-bridge.dll` must remain together. + +The template sets `IsAotCompatible` and `VerifyReferenceAotCompatibility` to protect the application code path, while +leaving the plugin itself in the SDK-supported managed form. See [ADR 0003](docs/adr/0003-package-and-aot-policy.md) +for the package and AOT policy. + +## Build and validation + +The repository pins the .NET SDK in [global.json](global.json), uses Central Package Management, and commits NuGet +lock files. Run the normal Windows validation sequence from the repository root: + +```powershell +dotnet restore CheatEngine.Client.slnx --locked-mode +dotnet build CheatEngine.Client.slnx --configuration Release --no-restore +dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore +dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore +./eng/Invoke-PackageSmoke.ps1 -PackageSource ./artifacts/packages +./eng/Invoke-TemplateSmoke.ps1 -PackageSource ./artifacts/packages +dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj --configuration Release --runtime win-x64 --no-restore --output ./artifacts/aot-probe +./artifacts/aot-probe/CheatEngine.Client.AotProbe.exe +``` + +The [Windows CI workflow](.github/workflows/ci.yml) runs the locked restore, Release build, Microsoft Testing Platform +tests, package API validation, isolated package smoke test, template smoke test, and Native AOT graph probe. The +Cheat Engine 7.7 x64 live suite is opt-in and intentionally excluded from ordinary CI; no CI result should be read as +proof that an untested live-host feature is available. + +## Security and scope + +This project is for local processes you are authorized to inspect or modify. It does not add network control, remote +transport, or a mechanism to bypass Cheat Engine or host protections. + +Table loading can execute Lua in the host. Keep `AllowedTableRoots` empty unless the plugin has an explicit, +trusted import/export location; an empty set disables table file access. Arbitrary Lua source is separately opt-in and +should remain disabled unless the plugin has a deliberate trust boundary. Avoid logging target-memory contents or Lua +source by default. + +## Architecture records + +The decisions that constrain the public surface and delivery model are maintained as short ADRs: + +- [Layered in-process architecture](docs/adr/0001-layered-in-process-architecture.md) +- [One Client activation per plugin enable epoch](docs/adr/0002-plugin-activation-lifecycle.md) +- [Package and AOT policy](docs/adr/0003-package-and-aot-policy.md) +- [Capability delivery matrix](docs/adr/0004-capability-matrix.md) + +For the SDK's bootstrap, generated Lua bindings, native bridge, and host ABI details, start with the +[CheatEngine.SDK README](https://github.com/CheatEngineNet/CheatEngine.SDK#readme). diff --git a/docs/adr/0001-layered-in-process-architecture.md b/docs/adr/0001-layered-in-process-architecture.md new file mode 100644 index 0000000..def67fe --- /dev/null +++ b/docs/adr/0001-layered-in-process-architecture.md @@ -0,0 +1,46 @@ +# ADR 0001: Layered in-process Client architecture + +- Status: Accepted +- Date: 2026-09-20 + +## Context + +`CheatEngine.SDK` exposes a managed route into a live Cheat Engine plugin host. Its Lua state, CE objects, ownership +wrappers, and thread-affinity rules are host-bound implementation details. The Client needs a higher-level, +dependency-injection-friendly API without recreating the SDK ABI or turning those implementation details into public +lifetime obligations. + +## Decision and why + +`CheatEngine.Client` is an in-process, high-level API hosted by a Cheat Engine plugin. It is not an external-process +adapter for `CheatEngine.SDK`, and V1 has no IPC endpoint. + +```text +Plugin assembly + -> CheatEngine.Client.Hosting + -> CheatEngine.Client.Extensions.DependencyInjection + -> CheatEngine.Client.Core -> CheatEngine.SDK -> Cheat Engine Lua/runtime + ^ +CheatEngine.Client.Abstractions <- CheatEngine.Client.Fluent +``` + +`Abstractions` owns public contracts, copied value types, failures, and module boundaries. `Fluent` creates immutable +operation descriptions and has no SDK access. `Core` is the only Client domain layer that maps operations to the SDK. +The DI extension owns explicit registrations and options validation; `Hosting` connects that composition to the plugin +lifecycle. The root `CheatEngine.Client` package is the consumer-facing umbrella. + +The labels in the diagram are package and assembly identities, not consumer namespace prefixes. Public contracts use +functional namespaces such as `CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, `.Runtime`, and `.Processes` +regardless of their delivery package. + +## Consequences and project value + +- Public APIs do not expose `LuaState`, `CEObject`, `Owned`, native pointers, or other host-bound SDK lifetimes. +- Application Lua exports are registered explicitly through `ILuaClient.RegisterModule`; the returned lease owns + activation-scoped unregistration. An `ILuaModule` may encapsulate generated SDK bindings internally but cannot let a + Lua state or SDK handle cross the Client contract. +- Builders remain pure. A complete Cheat Engine operation, including temporary-owner cleanup, crosses the SDK boundary + as one synchronous operation. This keeps fluent composition testable without a live host. +- A future remote bridge is a separate plugin-hosted product with its own protocol, authentication, backpressure, and + epoch-lifetime decision. It cannot introduce retries, transport concerns, or serializable handles into + `ICheatEngineClient` V1. diff --git a/docs/adr/0002-plugin-activation-lifecycle.md b/docs/adr/0002-plugin-activation-lifecycle.md new file mode 100644 index 0000000..069bc82 --- /dev/null +++ b/docs/adr/0002-plugin-activation-lifecycle.md @@ -0,0 +1,43 @@ +# ADR 0002: One Client activation per plugin enable epoch + +- Status: Accepted +- Date: 2026-09-20 + +## Context + +The SDK creates a plugin through a parameterless constructor and attaches the Lua runtime only for the enabled +lifetime. A long-lived provider, service, callback, or CE resource would therefore be able to outlive the host state +that makes it valid. + +## Decision and why + +`CheatEngineClientPlugin` creates a new `CheatEnginePluginBuilder`, service provider, and DI scope from `OnEnable`. +The derived plugin adds its own configuration sources in `Configure`; the Client does not load configuration implicitly. +For file-based configuration, a plugin may explicitly add an optional `appsettings.json` with `reloadOnChange: false`. + +The base validates options after building the provider, resolves the activation-scoped aggregate `ICheatEngineClient`, +enables registered modules in registration order, then invokes `OnClientEnabled`. A failed enable rolls back every +callback that was entered. + +On disable, the base clears the active Client reference first, invokes `OnClientDisabling`, disables enabled modules in +reverse order, drains Client-owned CE resources while the host is still attached, and finally disposes the scope, +provider, and configuration. Cleanup is best-effort and aggregates failures only after every step has been attempted. + +## Invariants + +- SDK calls are forbidden from plugin constructors, field initializers, and static initialization. SDK-dependent + services exist only while the plugin is enabled. +- `Configure` is a one-activation composition hook. It must not build a provider or reconfigure Client options after + 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. +- 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. + +## Consequences and project value + +- Re-enable starts from a new composition rather than a partially disposed singleton graph. +- Reverse-order cleanup and rollback make module ownership explicit and make failure paths unit-testable without + mocking SDK statics. +- Clearing admission before cleanup prevents consumers from acquiring an activation while its resources are being + released. diff --git a/docs/adr/0003-package-and-aot-policy.md b/docs/adr/0003-package-and-aot-policy.md new file mode 100644 index 0000000..7b02a80 --- /dev/null +++ b/docs/adr/0003-package-and-aot-policy.md @@ -0,0 +1,47 @@ +# ADR 0003: Package and AOT policy + +- Status: Accepted +- Date: 2026-09-20 + +## Context + +The SDK's plugin entry-point generator and native Lua bridge are activated by a direct package reference in the plugin +project. Indirect NuGet dependencies do not provide a safe substitute for those build assets. At the same time, Native +AOT compatibility analysis can validate library dependencies without proving that Cheat Engine can load a Native AOT +plugin DLL. + +## Decision and why + +The Client baseline is version `0.1.0`, targets .NET 10 with C# 14, and enables trimming and AOT compatibility analysis +for shipping projects. Central package management pins `CheatEngine.SDK` to `1.0.0` in this repository; SDK-facing +published packages declare the compatible dependency range `[1.0.0, 2.0.0)`. + +The standalone template targets Windows x64 and references the Client, SDK, and JSON configuration provider directly: + +```xml + + + +``` + +The direct `CheatEngine.SDK` reference is deliberate. It activates the generated Cheat Engine plugin entry point and +copies the native Lua bridge. Generated plugin projects set `true`; +the `CheatEngine.Client.Hosting` build target then reports `CECLIENT001` if the project omits that direct SDK reference. + +## Delivery verification + +Windows CI restores in locked mode; builds and tests Release; packs with public API validation; smoke-tests isolated +package consumption and local template installation; and publishes then runs the `win-x64` Native AOT reference probe. +Live Cheat Engine checks are intentionally outside ordinary CI and remain explicit host-validation gates. + +## Consequences and project value + +- The maintained template is the source example for real package consumption, including the two direct references a + plugin needs outside this repository. +- A standalone generated plugin uses explicit local package versions. A future template using central package management + must bring its own `Directory.Packages.props`; it cannot inherit this repository's file. +- `CheatEngine.Client.AotProbe` validates the shipping graph under Native AOT analysis. It does not claim that Cheat + Engine can load a Native AOT plugin; supported deployment remains the managed SDK plugin output together with its + runtime configuration and native bridge. +- A dependency that requires reflection, runtime type discovery, dynamic code, or reflection-based JSON serialization + needs a trimming-safe alternative before entering a shipping Client project. diff --git a/docs/adr/0004-capability-matrix.md b/docs/adr/0004-capability-matrix.md new file mode 100644 index 0000000..35ec06a --- /dev/null +++ b/docs/adr/0004-capability-matrix.md @@ -0,0 +1,57 @@ +# ADR 0004: Capability delivery matrix + +- Status: Accepted +- Date: 2026-09-20 + +## Context + +An interface alone does not establish that a Cheat Engine operation is safe to create, use, dispose, or repeat across +plugin activation. In particular, SDK 1.0.0 does not expose the ownership factory required for the Client to create and +adopt `MemScan` and `FoundList` instances without inventing an unverified handle-lifetime contract. + +## Decision and why + +The Client reports implementation status separately from public vocabulary. A capability is not represented as usable +only because an abstraction can describe it. + +### Status vocabulary + +- **Implemented**: the aggregate `ICheatEngineClient` composes an operational Client implementation for an enable + epoch. The listed host boundary still applies before claiming live-host qualification. +- **Capability-gated**: a public surface exists, but the implementation reports an unsupported capability instead of + assuming an unproven SDK ownership, affinity, or cancellation contract. +- **Deferred**: V1 intentionally provides no operational route through the aggregate Client. + +| Area | Public surface | Current source status | Boundary before live qualification | +|---|---|---|---| +| Plugin lifecycle and DI | `CheatEngineClientPlugin`, `CheatEnginePluginBuilder`, modules, options | Implemented | Exercise enable, rollback, disable, and repeated epoch activation in a CE host. | +| Runtime and capability facts | `ICheatEngineRuntime` | Implemented | Add evidence-backed observations per CE version and architecture as the SDK surface expands. | +| Process, module, and inspection | `IProcessClient`, `IInspectionClient` | Implemented | Map only SDK APIs with verified normal-return and thread contracts. | +| Typed memory | `IMemoryClient`, codecs, and requests | Implemented | Keep reads and writes bounded and classify host failures without leaking Lua state. | +| AOB scan | `IPatternScanner`, `AobScanRequest` | Implemented | Copy returned addresses and release temporary SDK owners in the same CE operation. | +| Value scan | `IValueScanner`, `IValueScanSession`, page records | Capability-gated | Require CE 7.7 x64 evidence for creation, first/next scan, read, ordered destruction, disable, and re-enable. | +| Address tables | `ITableClient`, copied record contracts | Implemented | Validate current-table lifetime, updates, and configured table-root enforcement. | +| Protected and unsafe Lua | `ILuaClient`, `IUnsafeLuaClient` | Protected operations implemented; arbitrary source is policy-gated | Keep arbitrary source opt-in and unavailable by default. | +| IPC or remote Client | None in V1 | Deferred | Define transport, authentication, handle epochs, and backpressure in a separate product decision. | + +## Consequences and project value + +- `IValueScanner.CreateSession` remains unavailable until the internal `createMemScan` and `createFoundList` owner has + passed creation, first and next scans, reading, ordered destruction, disable, and re-enable in Cheat Engine 7.7 x64. + The Client will not bypass the SDK restriction with reflection or a hand-rolled public owner. +- Templates and README examples can expose only guarded operations. They must not imply value scanning, arbitrary Lua, + or host-side behavior that ordinary CI has not established. +- The matrix gives reviewers one place to distinguish a deliberate product gate from a missing implementation, keeping + package release claims honest as SDK evidence changes. + +## Current template boundary + +The template is the maintained source example. It compiles against the hosted plugin shape with explicit JSON +configuration and no reload watcher, demonstrates explicit module DI and validated options, and registers an +application-owned `ILuaModule` through `ILuaClient.RegisterModule`. Its activation-scoped lease owns unregistration of +the generated SDK Lua export. SDK binding calls remain inside that application module; no Lua state or SDK ownership +handle crosses the Client contract. + +The generated project takes an Address List snapshot and performs a guarded AOB and typed-memory probe. It performs no +operation when the required runtime precondition is absent, does not log target-memory contents, and retains the epoch, +ownership, and main-thread constraints defined by these ADRs. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..8f024d2 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,32 @@ +# Architecture Decision Records + +## Context + +These records define the architectural constraints for `CheatEngine.Client`: its public boundaries, plugin lifetime, +package layout, and delivered capability scope. They complement API documentation; they are not a product roadmap or a +substitute for the SDK contract. + +## Why this directory exists + +The Client sits above a host-bound SDK with thread-affinity and ownership rules. Recording the decisions keeps a +convenient public API from silently acquiring unsafe handles, cross-epoch state, indirect SDK build assets, or +unsupported host assumptions. + +## How the records improve the project + +Each ADR is a review boundary. A change that alters a listed decision must update the relevant record or add a new one, +so source code, package behavior, templates, and CI gates remain aligned. + +| Record | Decision | Primary effect | +|---|---|---| +| [0001](0001-layered-in-process-architecture.md) | Keep the Client in-process and isolate SDK domain mapping in Core. | Prevent host handles and transport concerns from leaking through functional Client APIs. | +| [0002](0002-plugin-activation-lifecycle.md) | Create one composition and Client scope for each plugin enable epoch. | Makes cleanup, module rollback, and epoch invalidation deterministic. | +| [0003](0003-package-and-aot-policy.md) | Require a direct SDK reference in plugin projects and distinguish AOT analysis from an AOT plugin binary. | Preserves SDK generators and native bridge assets at the plugin boundary. | +| [0004](0004-capability-matrix.md) | Report implemented, gated, and deferred capabilities separately. | Prevents contracts and templates from implying unverified Cheat Engine behavior. | + +## Delivery alignment + +The Windows CI workflow restores the locked graph, builds and tests Release, packs the public APIs, validates isolated +package and template consumption, then publishes and runs the Native AOT graph probe. Live Cheat Engine validation is +intentionally opt-in and remains a release boundary where an ADR identifies it; ordinary CI does not claim to replace +that host evidence. diff --git a/eng/Invoke-PackageSmoke.ps1 b/eng/Invoke-PackageSmoke.ps1 new file mode 100644 index 0000000..2e6d4fc --- /dev/null +++ b/eng/Invoke-PackageSmoke.ps1 @@ -0,0 +1,171 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] + [string]$PackageSource, + + [ValidatePattern('^\d+\.\d+\.\d+([-.].+)?$')] + [string]$ClientVersion = '0.1.0' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$resolvedPackageSource = (Resolve-Path -LiteralPath $PackageSource).Path +$expectedPackage = Join-Path $resolvedPackageSource "CheatEngine.Client.$ClientVersion.nupkg" +if (-not (Test-Path -LiteralPath $expectedPackage -PathType Leaf)) { + throw "Expected package '$expectedPackage' was not found. Run dotnet pack before the smoke test." +} + +$temporaryBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$smokeDirectory = [IO.Path]::GetFullPath((Join-Path $temporaryBase ("CheatEngine.Client.PackageSmoke." + [Guid]::NewGuid().ToString('N')))) +if (-not $smokeDirectory.StartsWith($temporaryBase, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use a smoke-test directory outside the system temporary directory: '$smokeDirectory'." +} + +function Write-SmokeProject { + param( + [Parameter(Mandatory)] [string]$ProjectDirectory, + [Parameter(Mandatory)] [bool]$IncludeSdkReference + ) + + $sdkReference = if ($IncludeSdkReference) { + ' ' + } + else { + '' + } + + $projectXml = @" + + + net10.0 + 14.0 + enable + enable + true + false + true + obj/Generated + + + +$sdkReference + + +"@ + + Set-Content -LiteralPath (Join-Path $ProjectDirectory 'Smoke.Plugin.csproj') -Value $projectXml -Encoding utf8NoBOM + $pluginSource = @' +using CheatEngine.Client; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Annotations.Plugin; +using CheatEngine.SDK.Engine.Values; + +[CheatEnginePlugin("Package smoke plugin")] +public sealed class Plugin : CheatEngineClientPlugin +{ + protected override void Configure(CheatEnginePluginBuilder builder) + { + } + + protected override void OnClientEnabled(ICheatEngineClient client) + { + _ = client.Memory.At(default(Address)); + _ = client.Patterns.Aob("00").FirstOrNone(); + } +} +'@ + Set-Content -LiteralPath (Join-Path $ProjectDirectory 'Plugin.cs') -Value $pluginSource -Encoding utf8NoBOM +} + +try { + New-Item -ItemType Directory -Path $smokeDirectory | Out-Null + + $configurationPath = Join-Path $smokeDirectory 'NuGet.Config' + $escapedSource = [Security.SecurityElement]::Escape($resolvedPackageSource) + $escapedPackageCache = [Security.SecurityElement]::Escape((Join-Path $smokeDirectory '.packages')) + $nuGetConfiguration = @" + + + + + + + + + + + +"@ + Set-Content -LiteralPath $configurationPath -Value $nuGetConfiguration -Encoding utf8NoBOM + + $positiveDirectory = Join-Path $smokeDirectory 'positive' + New-Item -ItemType Directory -Path $positiveDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $positiveDirectory -IncludeSdkReference $true + & dotnet restore (Join-Path $positiveDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The positive isolated package restore failed.' + } + + & dotnet build (Join-Path $positiveDirectory 'Smoke.Plugin.csproj') --configuration Release --no-restore + if ($LASTEXITCODE -ne 0) { + throw 'The positive isolated package build failed.' + } + + $positiveOutput = Join-Path $positiveDirectory 'bin/Release/net10.0' + $requiredOutputFiles = @( + 'Smoke.Plugin.dll', + 'Smoke.Plugin.runtimeconfig.json', + 'cheatengine-sdk-lua-bridge.dll', + 'CheatEngine.SDK.dll', + 'CheatEngine.Client.Abstractions.dll', + 'CheatEngine.Client.Core.dll', + 'CheatEngine.Client.Fluent.dll', + 'CheatEngine.Client.Extensions.DependencyInjection.dll', + 'CheatEngine.Client.Hosting.dll' + ) + foreach ($requiredOutputFile in $requiredOutputFiles) { + $requiredOutputPath = Join-Path $positiveOutput $requiredOutputFile + if (-not (Test-Path -LiteralPath $requiredOutputPath -PathType Leaf)) { + throw "The positive isolated package output is missing '$requiredOutputFile'." + } + } + + $generatedEntryPoints = @(Get-ChildItem -LiteralPath (Join-Path $positiveDirectory 'obj') -Recurse -File | + Where-Object Name -eq 'CheatEngine.SDK.EntryPoint.g.cs') + if ($generatedEntryPoints.Count -ne 1) { + throw "Expected exactly one generated CESDK bootstrap source, found $($generatedEntryPoints.Count)." + } + $generatedEntryPoint = $generatedEntryPoints[0] + $generatedEntryPointText = Get-Content -LiteralPath $generatedEntryPoint.FullName -Raw + if ($generatedEntryPointText -notmatch 'namespace CESDK' -or + $generatedEntryPointText -notmatch 'CEPluginInitialize') { + throw 'The direct SDK reference did not emit the expected CESDK bootstrap source.' + } + + $negativeDirectory = Join-Path $smokeDirectory 'negative' + New-Item -ItemType Directory -Path $negativeDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $negativeDirectory -IncludeSdkReference $false + & dotnet restore (Join-Path $negativeDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The negative isolated package restore failed before CECLIENT001 could be evaluated.' + } + + $negativeOutput = & dotnet build (Join-Path $negativeDirectory 'Smoke.Plugin.csproj') --configuration Release --no-restore 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + throw 'The negative isolated plugin build unexpectedly succeeded without a direct CheatEngine.SDK reference.' + } + if ($negativeOutput -notmatch 'CECLIENT001') { + throw "The negative isolated plugin build failed, but did not report CECLIENT001.`n$negativeOutput" + } + + Write-Host 'Package smoke test passed: direct SDK reference accepted and CECLIENT001 enforced for marked plugin projects.' +} +finally { + if (Test-Path -LiteralPath $smokeDirectory -PathType Container) { + Remove-Item -LiteralPath $smokeDirectory -Recurse -Force + } +} diff --git a/eng/Invoke-TemplateSmoke.ps1 b/eng/Invoke-TemplateSmoke.ps1 new file mode 100644 index 0000000..7384445 --- /dev/null +++ b/eng/Invoke-TemplateSmoke.ps1 @@ -0,0 +1,98 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] + [string]$PackageSource, + + [ValidatePattern('^\d+\.\d+\.\d+([-.].+)?$')] + [string]$TemplateVersion = '0.1.0' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$resolvedPackageSource = (Resolve-Path -LiteralPath $PackageSource).Path +$templatePackage = Join-Path $resolvedPackageSource "CheatEngine.Client.Templates.$TemplateVersion.nupkg" +if (-not (Test-Path -LiteralPath $templatePackage -PathType Leaf)) { + throw "Expected template package '$templatePackage' was not found. Run dotnet pack before the smoke test." +} + +$temporaryBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$smokeDirectory = [IO.Path]::GetFullPath((Join-Path $temporaryBase ("CheatEngine.Client.TemplateSmoke." + [Guid]::NewGuid().ToString('N')))) +if (-not $smokeDirectory.StartsWith($temporaryBase, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use a smoke-test directory outside the system temporary directory: '$smokeDirectory'." +} + +$previousDotnetCliHome = $env:DOTNET_CLI_HOME +$previousDotnetNewHome = $env:DOTNET_NEW_HOME +try { + New-Item -ItemType Directory -Path $smokeDirectory | Out-Null + $env:DOTNET_CLI_HOME = Join-Path $smokeDirectory '.dotnet-cli' + $env:DOTNET_NEW_HOME = Join-Path $smokeDirectory '.template-engine' + + $configurationPath = Join-Path $smokeDirectory 'NuGet.Config' + $escapedSource = [Security.SecurityElement]::Escape($resolvedPackageSource) + $escapedPackageCache = [Security.SecurityElement]::Escape((Join-Path $smokeDirectory '.packages')) + $nuGetConfiguration = @" + + + + + + + + + + + +"@ + Set-Content -LiteralPath $configurationPath -Value $nuGetConfiguration -Encoding utf8NoBOM + + & dotnet new install $templatePackage --force + if ($LASTEXITCODE -ne 0) { + throw 'Local template installation failed.' + } + + & dotnet new ceplugin --dry-run --name Smoke.Plugin --output (Join-Path $smokeDirectory 'dry-run') + if ($LASTEXITCODE -ne 0) { + throw 'Template dry run failed.' + } + + $instantiatedDirectory = Join-Path $smokeDirectory 'Smoke.Plugin' + & dotnet new ceplugin --name Smoke.Plugin --output $instantiatedDirectory + if ($LASTEXITCODE -ne 0) { + throw 'Template instantiation failed.' + } + + $projectPath = Join-Path $instantiatedDirectory 'Smoke.Plugin.csproj' + & dotnet restore $projectPath --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'Instantiated template restore failed.' + } + + & dotnet build $projectPath --configuration Release --no-restore + if ($LASTEXITCODE -ne 0) { + throw 'Instantiated template build failed.' + } + + Write-Host 'Template smoke test passed: local installation, dry run, instantiation, restore, and Release build succeeded.' +} +finally { + if ([string]::IsNullOrEmpty($previousDotnetCliHome)) { + Remove-Item Env:DOTNET_CLI_HOME -ErrorAction SilentlyContinue + } + else { + $env:DOTNET_CLI_HOME = $previousDotnetCliHome + } + + if ([string]::IsNullOrEmpty($previousDotnetNewHome)) { + Remove-Item Env:DOTNET_NEW_HOME -ErrorAction SilentlyContinue + } + else { + $env:DOTNET_NEW_HOME = $previousDotnetNewHome + } + + if (Test-Path -LiteralPath $smokeDirectory -PathType Container) { + Remove-Item -LiteralPath $smokeDirectory -Recurse -Force + } +} diff --git a/libs/CheatEngine.Client.Binding/CheatEngine.Client.Binding.csproj b/libs/CheatEngine.Client.Binding/CheatEngine.Client.Binding.csproj deleted file mode 100644 index 9a9b672..0000000 --- a/libs/CheatEngine.Client.Binding/CheatEngine.Client.Binding.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/libs/CheatEngine.Client.Binding/README.md b/libs/CheatEngine.Client.Binding/README.md deleted file mode 100644 index 0480aa2..0000000 --- a/libs/CheatEngine.Client.Binding/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# CheatEngine.Client.Binding - -The mapper/binder layer of CheatEngine.Client: it implements the contracts declared in `CheatEngine.Client.Abstractions` -by mapping them onto CheatEngine.SDK. - -## Rules - -- References `CheatEngine.Client.Abstractions` only. It never references `CheatEngine.Client.Fluent`. -- The only project meant to depend on CheatEngine.SDK. This is the default layering rule: revisit it if - `CheatEngine.Client.Abstractions` ever reuses CheatEngine.SDK vocabulary. -- Internal by default: only what `CheatEngine.Client` composes is public. diff --git a/templates/CheatEngine.Client.Templates/CheatEngine.Client.Templates.csproj b/templates/CheatEngine.Client.Templates/CheatEngine.Client.Templates.csproj new file mode 100644 index 0000000..cdf4ff6 --- /dev/null +++ b/templates/CheatEngine.Client.Templates/CheatEngine.Client.Templates.csproj @@ -0,0 +1,15 @@ + + + + Template + false + $(NoWarn);NU5128 + + + + + + + + + diff --git a/templates/CheatEngine.Client.Templates/README.md b/templates/CheatEngine.Client.Templates/README.md new file mode 100644 index 0000000..838af70 --- /dev/null +++ b/templates/CheatEngine.Client.Templates/README.md @@ -0,0 +1,61 @@ +# CheatEngine.Client.Templates + +## Context + +`CheatEngine.Client.Templates` is the content-only NuGet package that provides the `dotnet new ceplugin` template. It +creates a C# 14, .NET 10, x64, managed in-process Cheat Engine plugin whose composition starts from +`CheatEngineClientPlugin`. + +The generated plugin is the repository's executable reference implementation. There is intentionally no separate +`samples/` project to keep in sync. + +## Why this project exists + +Cheat Engine plugins need a direct reference to both packages below: + +- `CheatEngine.Client` supplies the high-level facade, fluent API, dependency-injection composition, activation + lifecycle, and module model. +- `CheatEngine.SDK` supplies the plugin entry-point generator and native Lua bridge build assets. Those assets are not + guaranteed to flow through a transitive dependency. + +The template makes that deployment-critical relationship explicit. Its +`true` marker activates Hosting's `CECLIENT001` guard, +which fails a marked plugin build if the SDK reference stops being direct. + +## How it helps improve CheatEngine.Client + +The template turns the intended consumption model into buildable source. It exercises activation-scoped DI, generated +SDK plugin bootstrap, explicit configuration, bounded AOB probing, typed memory access, Address List inspection, and +an application-owned Lua module. Keeping this path executable prevents package, bootstrap, and documentation drift. + +It deliberately does not imply that a Native AOT binary is loadable by Cheat Engine. The project enables AOT +compatibility analysis for the library graph, but a plugin must be deployed as the complete managed output required by +the SDK. Value scans remain capability-gated until their full Cheat Engine 7.7 x64 lifecycle has passed the opt-in +live gate. + +## Create a plugin + +Install the published template, then instantiate it from the directory that should contain the new project: + +```powershell +dotnet new install CheatEngine.Client.Templates +dotnet new ceplugin --name Contoso.CheatEngine.Plugin --output .\Contoso.CheatEngine.Plugin +dotnet restore .\Contoso.CheatEngine.Plugin\Contoso.CheatEngine.Plugin.csproj +dotnet build .\Contoso.CheatEngine.Plugin\Contoso.CheatEngine.Plugin.csproj --configuration Release --no-restore +``` + +The generated project's [README](content/CheatEngine.Plugin/README.md) explains the composition, configuration, and +managed deployment requirements. Keep both direct package references when adapting the project. + +## Validate the template from this repository + +Pack the repository first so the smoke test can restore the Client packages from `artifacts/packages`, then run the +dedicated validation script: + +```powershell +dotnet pack CheatEngine.Client.slnx --configuration Release +.\eng\Invoke-TemplateSmoke.ps1 -PackageSource .\artifacts\packages +``` + +The smoke test installs the locally packed template, runs `dotnet new ceplugin --dry-run`, instantiates it into a +temporary directory, restores it against the local package source, and builds it in Release configuration. diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/template.json b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/template.json new file mode 100644 index 0000000..7fe9c26 --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/.template.config/template.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "CheatEngineNet", + "classifications": [ + "Cheat Engine", + "Plugin" + ], + "identity": "CheatEngineNet.CheatEngine.Client.Plugin", + "name": "CheatEngine Client Plugin", + "description": "A C# 14 .NET 10 Cheat Engine plugin hosted by CheatEngine.Client.", + "shortName": "ceplugin", + "sourceName": "CheatEngine.Plugin", + "defaultName": "CheatEngine.Plugin", + "tags": { + "language": "C#", + "type": "project" + }, + "constraints": { + "dotnet": { + "type": "host", + "args": [ + { + "hostname": "dotnetcli" + } + ] + }, + "net10": { + "type": "sdk-version", + "args": "[10.0.401,)" + } + }, + "primaryOutputs": [ + { + "path": "CheatEngine.Plugin.csproj" + } + ], + "postActions": [ + { + "description": "Restore NuGet packages.", + "manualInstructions": [ + { + "text": "Run 'dotnet restore'." + } + ], + "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", + "continueOnError": true + } + ] +} diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj new file mode 100644 index 0000000..324736f --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + 14.0 + enable + enable + x64 + true + + true + true + true + + false + + + + + + + + + + + + + diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs new file mode 100644 index 0000000..417cfb4 --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs @@ -0,0 +1,129 @@ +using CheatEngine.Client; +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.Values; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CheatEngine.Plugin.Modules; + +/// Demonstrates DI, options, bounded AOB/memory access, Address List snapshots and generated Lua exports. +internal sealed partial class PluginClientModule( + IMemoryCodec int32Codec, + IOptions options, + ILogger logger) : ICheatEngineClientModule +{ + private readonly CheatEngineClientOptions _options = options.Value; + private ILuaModuleLease? _luaModuleLease; + + public void OnEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + + if (_luaModuleLease is not null) + { + throw new InvalidOperationException("The Lua module is already registered for this activation."); + } + + _luaModuleLease = client.Lua.RegisterModule(new PluginLuaModule()); + + LogEnabled(logger, client.Epoch, _options.DefaultMaximumAobResults); + + if (client.Tables.TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure tableFailure)) + { + LogAddressList(logger, table.RecordCount); + } + else + { + LogSkipped("Address List", tableFailure); + } + + if (!client.Processes.TryGetCurrent(out ProcessSnapshot process, out CheatEngineFailure processFailure)) + { + LogSkipped("AOB/memory probe", processFailure); + return; + } + + AobScanBuilder scan = client.Patterns.Aob("48 8B ?? ?? ?? 89"); + if (process.Name is { } processName) + { + scan = scan.InModule(processName); + } + + if (!scan.ReadableExecutable() + .FirstOrNone() + .TryExecute(out Address? address, out CheatEngineFailure scanFailure)) + { + LogSkipped("AOB probe", scanFailure); + return; + } + + if (address is not { } match) + { + return; + } + + if (client.Memory.At(match + 0x14).TryReadWith(int32Codec, out _, out CheatEngineFailure readFailure)) + { + LogMemoryReadSucceeded(logger, match); + } + else + { + LogSkipped("Memory probe", readFailure); + } + } + + public void OnDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + + ILuaModuleLease? lease = _luaModuleLease; + _luaModuleLease = null; + if (lease is null) + { + return; + } + + try + { + lease.Dispose(); + } + catch (CheatEngineClientException exception) + { + LogLuaCleanupFailure(logger, exception.Failure.Message); + } + catch (InvalidOperationException exception) + { + LogLuaCleanupFailure(logger, exception.Message); + } + } + + private void LogSkipped(string operation, CheatEngineFailure failure) + { + LogClientFailure(logger, operation, failure); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "CheatEngine.Plugin enabled at epoch {Epoch}; configured AOB result ceiling is {MaximumResults}.")] + private static partial void LogEnabled(ILogger logger, long epoch, int maximumResults); + + [LoggerMessage(Level = LogLevel.Information, Message = "Current Address List contains {RecordCount} record(s).")] + private static partial void LogAddressList(ILogger logger, int recordCount); + + [LoggerMessage(Level = LogLevel.Information, + Message = "A typed Int32 memory read succeeded near AOB match {Address}.")] + private static partial void LogMemoryReadSucceeded(ILogger logger, Address address); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Skipped {Operation}: {Reason}")] + private static partial void LogClientFailure(ILogger logger, string operation, CheatEngineFailure reason); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Lua module cleanup did not complete: {Reason}")] + private static partial void LogLuaCleanupFailure(ILogger logger, string reason); +} diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs new file mode 100644 index 0000000..02c13ca --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs @@ -0,0 +1,13 @@ +using CheatEngine.SDK.Annotations.Lua; + +namespace CheatEngine.Plugin.Modules; + +/// Generated SDK Lua exports available while this plugin activation is enabled. +internal static partial class PluginLuaFunctions +{ + [LuaFunction("cheatengine_client_plugin_status")] + public static string Status() + { + return "CheatEngine.Plugin Lua module is enabled."; + } +} diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaModule.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaModule.cs new file mode 100644 index 0000000..5c550da --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaModule.cs @@ -0,0 +1,34 @@ +using CheatEngine.Client.Lua; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.Plugin.Modules; + +/// Application-owned Lua module that encapsulates the generated SDK binding calls. +/// +/// invokes this module only while the activation is current and owns the lease that calls +/// . The SDK state remains inside this implementation; it never crosses the public Client +/// contract or the consuming Client module. +/// +internal sealed class PluginLuaModule : ILuaModule +{ + /// + public void Register() + { + LuaStatus status = PluginLuaFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); + if (!status.IsOk) + { + throw new InvalidOperationException($"Unable to register the plugin Lua module: {status}."); + } + } + + /// + public void Unregister() + { + LuaStatus status = PluginLuaFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); + if (!status.IsOk) + { + throw new InvalidOperationException($"Unable to unregister the plugin Lua module: {status}."); + } + } +} diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs new file mode 100644 index 0000000..99700fc --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs @@ -0,0 +1,29 @@ +using CheatEngine.Client; +using CheatEngine.Client.Hosting; +using CheatEngine.Plugin.Modules; +using CheatEngine.SDK.Annotations.Plugin; +using Microsoft.Extensions.Configuration; + +namespace CheatEngine.Plugin; + +/// The plugin entry point generated and loaded by Cheat Engine. +[CheatEnginePlugin("CheatEngine Client Plugin")] +public sealed class Plugin : CheatEngineClientPlugin +{ + /// Adds explicit configuration sources for this activation. + protected override void Configure(CheatEnginePluginBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Configuration + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + + builder.Client.AddModule(); + } + + /// Runs after the Client activation scope and its modules have started. + protected override void OnClientEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + } +} diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md new file mode 100644 index 0000000..023eb17 --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md @@ -0,0 +1,62 @@ +# CheatEngine.Plugin + +## Context + +This project was created by `dotnet new ceplugin`. It is a C# 14, .NET 10, x64, managed in-process plugin for Cheat +Engine, built on the functional `CheatEngine.Client` API surface and hosted by `CheatEngineClientPlugin`. + +It is also the canonical executable example for the CheatEngine.Client repository. The repository intentionally keeps +this project inside the template instead of maintaining a separate `samples/` copy. + +## Why this project exists + +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. +- `Configure` explicitly loads the optional `appsettings.json` beside the plugin with `reloadOnChange: false` and + registers an application module. +- `PluginClientModule` demonstrates options, logging, a bounded AOB request, typed memory access, an Address List + snapshot, and an activation-scoped Lua module lease. + +The project references `CheatEngine.Client` **and** `CheatEngine.SDK` directly. The SDK reference must remain direct: +its plugin generator and native Lua bridge assets are build inputs, not a transitive implementation detail. +`true` enables the `CECLIENT001` build guard to enforce +that rule. + +## How it helps improve CheatEngine.Client + +This plugin is compiled by the template smoke test. It therefore continuously verifies the installation path that +matters to consumers: package restore, SDK-generated bootstrap, copied bridge assets, functional Client namespaces, +and the DI-first lifecycle. Its normal host preconditions use `Try...` APIs, so an absent process or pattern does not +turn the example into an artificial activation failure. + +The Lua implementation deliberately keeps `LuaRuntime.AcquireState()` and generated SDK calls inside +`PluginLuaModule`; no raw Lua state or SDK ownership handle crosses the Client-facing module boundary. The project does +not demonstrate value scans because their complete Create/Scan/Destroy lifecycle is still capability-gated pending +the opt-in Cheat Engine 7.7 x64 live validation. + +## Build + +From this project directory, restore and build the managed plugin: + +```powershell +dotnet restore .\CheatEngine.Plugin.csproj +dotnet build .\CheatEngine.Plugin.csproj --configuration Release --no-restore +``` + +Deploy the complete `bin\Release\net10.0` managed output produced by that build, including the plugin assembly, +`.runtimeconfig.json`, `CheatEngine.SDK` assemblies, and the SDK Lua bridge assets. Do not publish this project as a +Native AOT plugin binary: `IsAotCompatible` validates library compatibility only and is not a Cheat Engine plugin +loader guarantee. + +## Configure and adapt + +`appsettings.json` is optional and is loaded only because `Plugin.Configure` explicitly adds it. Leave +`CheatEngineClient:AllowedTableRoots` empty unless table import/export paths have been deliberately authorized; +loading a table can execute Lua. Configuration and module registrations are rebuilt at the next plugin enable, not +reloaded while an activation is active. + +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/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json new file mode 100644 index 0000000..4d2abb7 --- /dev/null +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json @@ -0,0 +1,8 @@ +{ + "CheatEngineClient": { + "DefaultMaximumAobResults": 4096, + "DefaultMaximumValueScanPageSize": 1024, + "AllowedTableRoots": [], + "EnableUnsafeLuaExecution": false + } +} diff --git a/templates/CheatEngine.Client.Templates/packages.lock.json b/templates/CheatEngine.Client.Templates/packages.lock.json new file mode 100644 index 0000000..009cf0c --- /dev/null +++ b/templates/CheatEngine.Client.Templates/packages.lock.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + } + } + } +} diff --git a/tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj b/tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj new file mode 100644 index 0000000..0cd445e --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj @@ -0,0 +1,14 @@ + + + + Exe + win-x64 + true + false + + + + + + + diff --git a/tests/CheatEngine.Client.AotProbe/Program.cs b/tests/CheatEngine.Client.AotProbe/Program.cs new file mode 100644 index 0000000..03350d8 --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/Program.cs @@ -0,0 +1,21 @@ +using CheatEngine.Client; +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Scanning; + +using Microsoft.Extensions.DependencyInjection; + +ServiceCollection services = new(); +services.AddCheatEngineClient().EnableUnsafeLuaExecution(); + +using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions +{ + ValidateOnBuild = true, ValidateScopes = true +}); + +_ = typeof(ICheatEngineClient); +_ = typeof(CheatEngineClientPlugin); +_ = typeof(AobScanBuilder); +_ = new AobPattern("90"); + +return services.Count == 0 ? 1 : 0; diff --git a/tests/CheatEngine.Client.AotProbe/README.md b/tests/CheatEngine.Client.AotProbe/README.md new file mode 100644 index 0000000..98ce434 --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/README.md @@ -0,0 +1,31 @@ +# CheatEngine.Client.AotProbe + +## Context + +This executable publishes the complete public Client graph as a `win-x64` Native AOT application. It is a build-time +compatibility probe, not a Cheat Engine plugin. + +## Why this project exists + +The shipped libraries promise trimming and Native AOT analysis compatibility. A normal library build cannot prove that +the full dependency graph remains compatible when the AOT compiler resolves it as an application. + +## How it helps improve CheatEngine.Client + +Publishing this probe turns AOT warnings into a delivery gate. It detects reflection-dependent paths, incompatible +metadata usage, or transitive AOT regressions while keeping the result separate from Cheat Engine's managed plugin +loader requirements. + +It does **not** claim that Cheat Engine can load a Native AOT plugin DLL. Plugins generated by `ceplugin` remain managed +and must include the SDK bootstrap and bridge assets. + +## Validate + +From the repository root: + +```powershell +dotnet publish --project .\tests\CheatEngine.Client.AotProbe\CheatEngine.Client.AotProbe.csproj --configuration Release +``` + +The successful output is a Native AOT executable under the repository artifacts path for `win-x64`; it is not intended +to be copied into a Cheat Engine plugin directory. diff --git a/tests/CheatEngine.Client.AotProbe/packages.lock.json b/tests/CheatEngine.Client.AotProbe/packages.lock.json new file mode 100644 index 0000000..4300e86 --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/packages.lock.json @@ -0,0 +1,217 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "AawF393Q+VkdrnrnI1gu612zh5iqpa1AGSvnKCQ3IkMgSKJXIQbO1sXYRgEzOU4f0cPZ6MaCCF29xZSGWlmbuQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "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.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.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "cheatengine.client": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Fluent": "[0.1.0, )", + "CheatEngine.Client.Hosting": "[0.1.0, )" + } + }, + "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.fluent": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )" + } + }, + "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, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "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": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + }, + "net10.0/win-x64": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "AawF393Q+VkdrnrnI1gu612zh5iqpa1AGSvnKCQ3IkMgSKJXIQbO1sXYRgEzOU4f0cPZ6MaCCF29xZSGWlmbuQ==", + "dependencies": { + "runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.12" + } + }, + "runtime.win-x64.Microsoft.DotNet.ILCompiler": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "clsgU9GnioCJ+PBzQTCoJHxLXRU+7O/BzYrwRT8CUP7jgyYjNgJmNsQ/kbzAA7MG5kDvFoFvIBGHGzYdINh/EQ==" + } + } + } +} diff --git a/tests/CheatEngine.Client.Binding.Tests/CheatEngine.Client.Binding.Tests.csproj b/tests/CheatEngine.Client.Binding.Tests/CheatEngine.Client.Binding.Tests.csproj deleted file mode 100644 index 9ba1298..0000000 --- a/tests/CheatEngine.Client.Binding.Tests/CheatEngine.Client.Binding.Tests.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/tests/CheatEngine.Client.Binding.Tests/README.md b/tests/CheatEngine.Client.Binding.Tests/README.md deleted file mode 100644 index 1700623..0000000 --- a/tests/CheatEngine.Client.Binding.Tests/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# CheatEngine.Client.Binding.Tests - -Tests of [`CheatEngine.Client.Binding`](../../libs/CheatEngine.Client.Binding/README.md). References its subject only. From 019814a2ce833d2aea6fb92f7c46483fcafaabfb Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 20:36:34 +0200 Subject: [PATCH 5/7] ci: publish Sonar coverage Run the scanner around a locked Release build and native-MTP Cobertura test execution, then publish the reports as a retained workflow artifact. Keep the CECLIENT001 negative package smoke check strict while clearing its expected native-command exit status only after the diagnostic is verified. --- .github/workflows/sonar.yml | 137 ++++++++++++++++++++++++++++++++++++ eng/Invoke-PackageSmoke.ps1 | 3 + 2 files changed, 140 insertions(+) create mode 100644 .github/workflows/sonar.yml diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000..0876c59 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,137 @@ +name: SonarQube Cloud + +on: + push: + branches: [main] + paths: + - '.editorconfig' + - '.github/**' + - 'CheatEngine.Client.slnx' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'eng/**' + - 'global.json' + - 'libs/**' + - 'src/**' + - 'templates/**' + - 'tests/**' + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - '.editorconfig' + - '.github/**' + - 'CheatEngine.Client.slnx' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'eng/**' + - 'global.json' + - 'libs/**' + - 'src/**' + - 'templates/**' + - 'tests/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + MSBUILDDISABLENODEREUSE: true + SONAR_SCANNER_VERSION: 11.3.0 + +jobs: + analyze: + name: Build, test, and analyze + # Secrets are intentionally never exposed to pull requests from forks. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: windows-latest + timeout-minutes: 35 + + steps: + - name: Set up JDK 21 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + with: + distribution: zulu + java-version: '21' + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: | + **/packages.lock.json + Directory.Packages.props + + - name: Install pinned SonarScanner for .NET + id: scanner + run: | + $scannerDirectory = Join-Path $env:RUNNER_TEMP 'sonar-scanner' + New-Item -Path $scannerDirectory -ItemType Directory -Force | Out-Null + dotnet tool install dotnet-sonarscanner --tool-path $scannerDirectory --version $env:SONAR_SCANNER_VERSION + "path=$(Join-Path $scannerDirectory 'dotnet-sonarscanner.exe')" >> $env:GITHUB_OUTPUT + + - name: Restore locked dependency graph + run: dotnet restore CheatEngine.Client.slnx --locked-mode + + - name: Begin Sonar analysis + env: + SONAR_SCANNER: ${{ steps.scanner.outputs.path }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + & $env:SONAR_SCANNER begin ` + /k:"CheatEngineNet_CheatEngine.Client" ` + /o:"cheatenginenet" ` + /d:sonar.token="$env:SONAR_TOKEN" ` + /d:sonar.coverage.exclusions="tests/CheatEngine.Client.AotProbe/**" ` + /d:sonar.cs.cobertura.reportsPaths="artifacts/sonar-test-results/**/*.cobertura.xml" + + - name: Build Release + run: dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --warnaserror + + - name: Run tests with Cobertura coverage + run: >- + dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore + --coverage --coverage-output-format cobertura --report-trx + --results-directory artifacts/sonar-test-results --fail-skips on + + - name: Verify coverage reports + run: | + $reports = @(Get-ChildItem -Path artifacts/sonar-test-results -Recurse -File -Filter '*.cobertura.xml' -ErrorAction SilentlyContinue) + if ($reports.Count -eq 0) { + throw 'Microsoft Testing Platform did not produce a Cobertura coverage report.' + } + + $reports | ForEach-Object { Write-Host "Coverage report: $($_.FullName)" } + + - name: End Sonar analysis + env: + SONAR_SCANNER: ${{ steps.scanner.outputs.path }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: '& $env:SONAR_SCANNER end /d:sonar.token="$env:SONAR_TOKEN"' + + - name: Upload Sonar coverage reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sonar-coverage + path: artifacts/sonar-test-results/**/*.cobertura.xml + if-no-files-found: warn + retention-days: 14 diff --git a/eng/Invoke-PackageSmoke.ps1 b/eng/Invoke-PackageSmoke.ps1 index 2e6d4fc..75a2d55 100644 --- a/eng/Invoke-PackageSmoke.ps1 +++ b/eng/Invoke-PackageSmoke.ps1 @@ -162,6 +162,9 @@ try { throw "The negative isolated plugin build failed, but did not report CECLIENT001.`n$negativeOutput" } + # The expected negative build leaves PowerShell's native-command status non-zero. Clear it only after both + # assertions prove that the failure was the intended CECLIENT001 guard. + $global:LASTEXITCODE = 0 Write-Host 'Package smoke test passed: direct SDK reference accepted and CECLIENT001 enforced for marked plugin projects.' } finally { From afc503af3be28301664c16953ece6b69c5d37de0 Mon Sep 17 00:00:00 2001 From: "dosubot[bot]" <131922026+dosubot[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:19:04 +0000 Subject: [PATCH 6/7] docs: Dosu updates for PR #6 --- src/CheatEngine.Client/README.md | 64 ++++++++------------------------ 1 file changed, 15 insertions(+), 49 deletions(-) diff --git a/src/CheatEngine.Client/README.md b/src/CheatEngine.Client/README.md index 7b34e28..7d6c0f0 100644 --- a/src/CheatEngine.Client/README.md +++ b/src/CheatEngine.Client/README.md @@ -1,57 +1,23 @@ # CheatEngine.Client -The recommended NuGet installation point for high-level, DI-oriented Cheat Engine plugins written in C# 14 and .NET 10. +The single project a consumer references. It is the composition root of CheatEngine.Client: it wires together +`CheatEngine.Client.Core`, `CheatEngine.Client.Hosting`, and `CheatEngine.Client.Fluent` behind the +`CheatEngine.Client.Abstractions` contracts and holds no logic of its own. -## Context +## Purpose -`CheatEngine.Client` is a NuGet façade package. It has no operational implementation of its own; it composes the Fluent API and plugin Hosting packages, which in turn bring the public Client contracts and their implementation dependencies. +As the final composition layer, this package: -The public surface is organized by function rather than by delivery assembly. Plugin code uses namespaces such as `CheatEngine.Client`, `CheatEngine.Client.Memory`, `CheatEngine.Client.Scanning`, `CheatEngine.Client.Tables`, `CheatEngine.Client.Lua`, and `CheatEngine.Client.Hosting`. It does not need to use implementation namespaces. +- Depends on `CheatEngine.Client.Hosting` for the DI container and plugin lifecycle +- Depends on `CheatEngine.Client.Fluent` for builder APIs +- Transitively brings in `CheatEngine.Client.Core` (the SDK mapper) through Hosting's DI registration layer +- Provides the umbrella package for normal plugin projects -## Why this project exists +See [ADR 0001](../../docs/adr/0001-layered-in-process-architecture.md) for the complete layered architecture and +[ADR 0002](../../docs/adr/0002-plugin-activation-lifecycle.md) for the plugin lifecycle model. -Most plugin authors should install one Client package, not reconstruct its package graph. This façade provides that stable installation point while keeping the lower-level packages separately consumable when a project needs only a focused capability. +## Rules -It deliberately does not hide `CheatEngine.SDK`: a real plugin must directly reference the SDK so that the SDK's source generators, build targets, and native bridge assets are active in the plugin project. - -## How it helps CheatEngine.Client - -Use this package together with an explicit SDK package reference: - -```xml - - net10.0 - 14.0 - x64 - true - - - - - - -``` - -The direct SDK reference is required even though Hosting has an SDK dependency. When `CheatEngineClientPluginProject` is `true`, Hosting's transitive build target reports `CECLIENT001` if the direct reference is missing. - -For a plugin, derive from `CheatEngineClientPlugin`, configure services and sources explicitly, and use `ICheatEngineClient` only within an enabled lifecycle. The Client facade exposes bounded synchronous APIs for runtime capabilities, process selection, typed memory, AOB scanning, inspection, tables, and typed Lua operations. Capability-dependent operations report Client failures when unavailable; value-scan functionality remains capability-gated. - -```csharp -using CheatEngine.Client; -using CheatEngine.Client.Hosting; - -public sealed class Plugin : CheatEngineClientPlugin -{ - protected override void Configure(CheatEnginePluginBuilder builder) - { - // Add explicit configuration, modules, and memory codecs here. - } - - protected override void OnClientEnabled(ICheatEngineClient client) - { - // The Client is valid only for this activation epoch. - } -} -``` - -For the complete, SDK-annotated entry point and project configuration, install `CheatEngine.Client.Templates` and create the `ceplugin` template. The template is the executable reference for the expected plugin shape. +- The only public package where Hosting, Core, and Fluent meet. +- The assembly and the root namespace are both `CheatEngine.Client`, so never declare a type named `CheatEngine` or + `Client` in it (CA1724 matches each segment of the namespace). From 0547617be0a9ee15965c08d0008afd19771eb44a Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 22:05:26 +0200 Subject: [PATCH 7/7] fix: harden delivery gates and template cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make missing TRX output a hard validation failure and move Sonar's JDK setup to the immutable Node 24-compatible setup-java v6.0.1 revision. Let generated-plugin Lua lease disposal reach the activation lifecycle for aggregation, document the generated callbacks and exports, correct the Native AOT publish invocation, and restore the façade package guidance for the required direct SDK reference. --- .github/workflows/ci.yml | 3 +- .github/workflows/sonar.yml | 2 +- src/CheatEngine.Client/README.md | 61 ++++++++++++++----- .../Modules/PluginClientModule.cs | 37 ++++++----- .../Modules/PluginLuaFunctions.cs | 1 + .../CheatEngine.Plugin/appsettings.json | 5 +- tests/CheatEngine.Client.AotProbe/README.md | 2 +- 7 files changed, 70 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcda338..47c1247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,7 @@ jobs: run: | $reports = @(Get-ChildItem -Path artifacts/test-results -Filter *.trx -Recurse -File -ErrorAction SilentlyContinue) if ($reports.Count -eq 0) { - Write-Host '::warning::The test run produced no TRX report.' - return + throw 'Microsoft Testing Platform did not produce a TRX test report.' } $results = @($reports | ForEach-Object { diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 0876c59..5b34005 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -60,7 +60,7 @@ jobs: steps: - name: Set up JDK 21 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: zulu java-version: '21' diff --git a/src/CheatEngine.Client/README.md b/src/CheatEngine.Client/README.md index 7d6c0f0..4e51357 100644 --- a/src/CheatEngine.Client/README.md +++ b/src/CheatEngine.Client/README.md @@ -1,23 +1,56 @@ # CheatEngine.Client -The single project a consumer references. It is the composition root of CheatEngine.Client: it wires together -`CheatEngine.Client.Core`, `CheatEngine.Client.Hosting`, and `CheatEngine.Client.Fluent` behind the -`CheatEngine.Client.Abstractions` contracts and holds no logic of its own. +## Context -## Purpose +`CheatEngine.Client` is the umbrella package for a normal in-process Cheat Engine plugin. It is the composition root +of the Client delivery graph: it combines Hosting and Fluent APIs over the public Client contracts, while keeping the +SDK-facing Core implementation behind the DI registration boundary. The package itself intentionally contains no +Cheat Engine host logic. -As the final composition layer, this package: +## Why this project exists -- Depends on `CheatEngine.Client.Hosting` for the DI container and plugin lifecycle -- Depends on `CheatEngine.Client.Fluent` for builder APIs -- Transitively brings in `CheatEngine.Client.Core` (the SDK mapper) through Hosting's DI registration layer -- Provides the umbrella package for normal plugin projects +Most plugin projects should take one Client package rather than recreate the Client package graph. This façade is that +stable installation point: Hosting supplies the activation-scoped DI lifecycle, Fluent supplies immutable request +builders, and Core is composed internally through Hosting's DI registration. -See [ADR 0001](../../docs/adr/0001-layered-in-process-architecture.md) for the complete layered architecture and -[ADR 0002](../../docs/adr/0002-plugin-activation-lifecycle.md) for the plugin lifecycle model. +It deliberately does not replace `CheatEngine.SDK`. A plugin must reference the SDK **directly** so its build assets +can generate the Cheat Engine entry point and copy the native Lua bridge. Those host-bound assets do not flow through +an ordinary transitive NuGet dependency. + +```xml + + net10.0 + 14.0 + x64 + true + + + + + + +``` + +When `CheatEngineClientPluginProject` is enabled, the Hosting build target emits `CECLIENT001` if that direct SDK +reference is missing. + +## How it helps improve CheatEngine.Client + +The package gives plugin authors a small, intentional composition boundary without exposing implementation or SDK +ownership types. It brings together: + +- `CheatEngine.Client.Hosting` for the enable-epoch DI container and plugin lifecycle; +- `CheatEngine.Client.Fluent` for immutable memory and AOB request builders; +- `CheatEngine.Client.Core`, composed through Hosting, as the only SDK mapper; +- functional public namespaces such as `CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, and `.Lua`. + +Use the generated `ceplugin` template for a complete, buildable plugin shape. The client and all Client-created +resources are valid only for one enable epoch; do not retain them across disable/re-enable. See the repository +[README](../../README.md) for installation and deployment guidance, [ADR 0001](../../docs/adr/0001-layered-in-process-architecture.md) +for the package architecture, and [ADR 0002](../../docs/adr/0002-plugin-activation-lifecycle.md) for lifecycle rules. ## Rules -- The only public package where Hosting, Core, and Fluent meet. -- The assembly and the root namespace are both `CheatEngine.Client`, so never declare a type named `CheatEngine` or - `Client` in it (CA1724 matches each segment of the namespace). +- This is the only public package where Hosting, Core, and Fluent meet. +- The assembly and root namespace are both `CheatEngine.Client`; do not declare a `CheatEngine` or `Client` type in + this namespace because CA1724 matches each namespace segment. diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs index 417cfb4..5039262 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs @@ -14,7 +14,9 @@ namespace CheatEngine.Plugin.Modules; -/// Demonstrates DI, options, bounded AOB/memory access, Address List snapshots and generated Lua exports. +/// +/// Demonstrates DI, options, bounded AOB/memory access, Address List snapshots, and generated Lua exports. +/// internal sealed partial class PluginClientModule( IMemoryCodec int32Codec, IOptions options, @@ -23,6 +25,7 @@ internal sealed partial class PluginClientModule( private readonly CheatEngineClientOptions _options = options.Value; private ILuaModuleLease? _luaModuleLease; + /// Registers the activation-scoped Lua module and demonstrates bounded Client operations. public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); @@ -34,7 +37,8 @@ public void OnEnabled(ICheatEngineClient client) _luaModuleLease = client.Lua.RegisterModule(new PluginLuaModule()); - LogEnabled(logger, client.Epoch, _options.DefaultMaximumAobResults); + int allowedTableRootCount = _options.AllowedTableRoots?.Length ?? 0; + LogEnabled(logger, client.Epoch, allowedTableRootCount); if (client.Tables.TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure tableFailure)) { @@ -80,6 +84,9 @@ public void OnEnabled(ICheatEngineClient client) } } + /// + /// Releases the activation-scoped Lua module so the hosting lifecycle can aggregate any cleanup failure. + /// public void OnDisabling(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); @@ -91,39 +98,31 @@ public void OnDisabling(ICheatEngineClient client) return; } - try - { - lease.Dispose(); - } - catch (CheatEngineClientException exception) - { - LogLuaCleanupFailure(logger, exception.Failure.Message); - } - catch (InvalidOperationException exception) - { - LogLuaCleanupFailure(logger, exception.Message); - } + lease.Dispose(); } + /// Writes a bounded Client operation failure without exposing target-memory data. private void LogSkipped(string operation, CheatEngineFailure failure) { LogClientFailure(logger, operation, failure); } + /// Logs the activation epoch and configured count of trusted table-file roots. [LoggerMessage(Level = LogLevel.Information, - Message = "CheatEngine.Plugin enabled at epoch {Epoch}; configured AOB result ceiling is {MaximumResults}.")] - private static partial void LogEnabled(ILogger logger, long epoch, int maximumResults); + Message = "CheatEngine.Plugin enabled at epoch {Epoch}; " + + "configured trusted table-file root count is {AllowedTableRootCount}.")] + private static partial void LogEnabled(ILogger logger, long epoch, int allowedTableRootCount); + /// Logs the number of records in the current Address List snapshot. [LoggerMessage(Level = LogLevel.Information, Message = "Current Address List contains {RecordCount} record(s).")] private static partial void LogAddressList(ILogger logger, int recordCount); + /// Logs a successful bounded Int32 memory probe without logging the value read. [LoggerMessage(Level = LogLevel.Information, Message = "A typed Int32 memory read succeeded near AOB match {Address}.")] private static partial void LogMemoryReadSucceeded(ILogger logger, Address address); + /// Logs a classified Client failure for an optional demonstration operation. [LoggerMessage(Level = LogLevel.Debug, Message = "Skipped {Operation}: {Reason}")] private static partial void LogClientFailure(ILogger logger, string operation, CheatEngineFailure reason); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Lua module cleanup did not complete: {Reason}")] - private static partial void LogLuaCleanupFailure(ILogger logger, string reason); } diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs index 02c13ca..63a5825 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs @@ -5,6 +5,7 @@ namespace CheatEngine.Plugin.Modules; /// Generated SDK Lua exports available while this plugin activation is enabled. internal static partial class PluginLuaFunctions { + /// Returns the activation-local status text for a generated Lua export. [LuaFunction("cheatengine_client_plugin_status")] public static string Status() { diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json index 4d2abb7..acd8651 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json @@ -1,8 +1,5 @@ { "CheatEngineClient": { - "DefaultMaximumAobResults": 4096, - "DefaultMaximumValueScanPageSize": 1024, - "AllowedTableRoots": [], - "EnableUnsafeLuaExecution": false + "AllowedTableRoots": [] } } diff --git a/tests/CheatEngine.Client.AotProbe/README.md b/tests/CheatEngine.Client.AotProbe/README.md index 98ce434..ccae8e6 100644 --- a/tests/CheatEngine.Client.AotProbe/README.md +++ b/tests/CheatEngine.Client.AotProbe/README.md @@ -24,7 +24,7 @@ and must include the SDK bootstrap and bridge assets. From the repository root: ```powershell -dotnet publish --project .\tests\CheatEngine.Client.AotProbe\CheatEngine.Client.AotProbe.csproj --configuration Release +dotnet publish .\tests\CheatEngine.Client.AotProbe\CheatEngine.Client.AotProbe.csproj --configuration Release ``` The successful output is a Native AOT executable under the repository artifacts path for `win-x64`; it is not intended