From ee0e8b3e83fe7f1ab4b993290e814b8d18234830 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 19:10:17 +0200 Subject: [PATCH 01/10] feat: add immutable Fluent memory and AOB APIs Add the consumer-facing Fluent layer over the public contracts. The AOB builders normalize patterns and scan options, retain immutable state, provide module/range/protection/alignment filtering, and require bounded terminals (first, single, or explicit materialization limit). Memory builders bind an address to typed read and write operations without retaining Cheat Engine handles. The layer remains SDK-free and therefore cannot bypass activation, dispatch, policy, or ownership controls established by Core. Focused tests preserve request propagation and failure semantics. Validation: - dotnet build libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj --configuration Release --no-restore --warnaserror - dotnet test --project tests/CheatEngine.Client.Fluent.Tests/CheatEngine.Client.Fluent.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on --- .../CheatEngine.Client.Fluent.csproj | 4 + .../CheatEngineMemoryFluentExtensions.cs | 16 + .../Memory/Memory.cs | 25 ++ .../Memory/MemoryAddressBuilder.cs | 180 +++++++++++ .../PublicAPI.Shipped.txt | 82 +++++ .../PublicAPI.Unshipped.txt | 1 + libs/CheatEngine.Client.Fluent/README.md | 90 +++++- .../Scanning/AobFirstMatchBuilder.cs | 64 ++++ .../Scanning/AobManyMatchBuilder.cs | 63 ++++ .../Scanning/AobScanBuilder.cs | 153 ++++++++++ .../Scanning/AobSingleMatchBuilder.cs | 80 +++++ .../CheatEngineAobFluentExtensions.cs | 27 ++ .../packages.lock.json | 60 ++++ .../Memory/MemoryAddressBuilderTests.cs | 289 ++++++++++++++++++ .../CheatEngine.Client.Fluent.Tests/README.md | 25 +- .../Scanning/AobFluentBuilderTests.cs | 228 ++++++++++++++ .../packages.lock.json | 204 +++++++++++++ 17 files changed, 1584 insertions(+), 7 deletions(-) create mode 100644 libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs create mode 100644 libs/CheatEngine.Client.Fluent/Memory/Memory.cs create mode 100644 libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs create mode 100644 libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.Client.Fluent/Scanning/AobFirstMatchBuilder.cs create mode 100644 libs/CheatEngine.Client.Fluent/Scanning/AobManyMatchBuilder.cs create mode 100644 libs/CheatEngine.Client.Fluent/Scanning/AobScanBuilder.cs create mode 100644 libs/CheatEngine.Client.Fluent/Scanning/AobSingleMatchBuilder.cs create mode 100644 libs/CheatEngine.Client.Fluent/Scanning/CheatEngineAobFluentExtensions.cs create mode 100644 libs/CheatEngine.Client.Fluent/packages.lock.json create mode 100644 tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs create mode 100644 tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs create mode 100644 tests/CheatEngine.Client.Fluent.Tests/packages.lock.json diff --git a/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj b/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj index 9a9b672..12f8df2 100644 --- a/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj +++ b/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj @@ -1,5 +1,9 @@ + + CheatEngine.Client + + diff --git a/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs new file mode 100644 index 0000000..eca8f4b --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs @@ -0,0 +1,16 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// Fluent entry points for the scoped target-memory contract. +public static class CheatEngineMemoryFluentExtensions +{ + /// Starts a fluent, immutable operation against . + /// The scoped target-memory service used by terminal operations. + /// The target address to read or write. + /// An immutable address builder bound to . + public static MemoryAddressBuilder At(this IMemoryClient memory, Address address) + { + return Memory.At(memory, address); + } +} diff --git a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs new file mode 100644 index 0000000..81d8222 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs @@ -0,0 +1,25 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// Starts a fluent, handle-free operation against one target-memory address. +public static class Memory +{ + /// Creates an address builder that receives its memory service at its terminal operation. + /// The target address to read or write. + /// An immutable address builder. + public static MemoryAddressBuilder At(Address address) + { + return new MemoryAddressBuilder(address, null); + } + + /// Creates an address builder bound to the supplied memory service. + /// The scoped target-memory service used by terminal operations. + /// The target address to read or write. + /// An immutable address builder. + public static MemoryAddressBuilder At(IMemoryClient memory, Address address) + { + ArgumentNullException.ThrowIfNull(memory); + return new MemoryAddressBuilder(address, memory); + } +} diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs new file mode 100644 index 0000000..dc73256 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs @@ -0,0 +1,180 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// An immutable, handle-free builder for one target-memory address. +public readonly record struct MemoryAddressBuilder +{ + private readonly IMemoryClient? _memory; + + internal MemoryAddressBuilder(Address address, IMemoryClient? memory) + { + Address = address; + _memory = memory; + } + + /// Gets the target address used by terminal operations. + public Address Address + { + get; + } + + /// Returns an equivalent builder bound to a scoped target-memory service. + /// The scoped target-memory service used by terminal operations. + /// A new immutable builder. + public MemoryAddressBuilder Using(IMemoryClient memory) + { + ArgumentNullException.ThrowIfNull(memory); + return new MemoryAddressBuilder(Address, memory); + } + + /// Reads one built-in scalar or pointer type through the bound memory service. + public T Read(CancellationToken cancellationToken = default) + { + return RequireMemory().ReadPrimitive(Address, cancellationToken); + } + + /// Tries to read one built-in scalar or pointer type through the bound memory service. + public bool TryRead([MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return RequireMemory().TryReadPrimitive(Address, out value, out failure, cancellationToken); + } + + /// Writes one built-in scalar or pointer type through the bound memory service. + public void Write(T value, CancellationToken cancellationToken = default) + { + RequireMemory().WritePrimitive(Address, value, cancellationToken); + } + + /// Tries to write one built-in scalar or pointer type through the bound memory service. + public bool TryWrite(T value, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return RequireMemory().TryWritePrimitive(Address, value, out failure, cancellationToken); + } + + /// Reads one typed value through the service bound to this builder. + /// The managed value type represented by . + /// The deterministic codec that maps to Cheat Engine memory. + /// Cancels before the operation reaches Cheat Engine. + /// The managed value returned by Cheat Engine. + /// No memory service has been bound to this builder. + public T ReadWith(IMemoryCodec codec, CancellationToken cancellationToken = default) + { + return ReadWith(RequireMemory(), codec, cancellationToken); + } + + /// Reads one typed value through an explicit target-memory service. + /// The managed value type represented by . + /// The scoped target-memory service used for this operation. + /// The deterministic codec that maps to Cheat Engine memory. + /// Cancels before the operation reaches Cheat Engine. + /// The managed value returned by Cheat Engine. + public T ReadWith(IMemoryClient memory, IMemoryCodec codec, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(memory); + ArgumentNullException.ThrowIfNull(codec); + return memory.Read(new MemoryReadRequest(Address, codec), cancellationToken); + } + + /// Tries to read one typed value through the service bound to this builder. + /// The managed value type represented by . + /// The deterministic codec that maps to Cheat Engine memory. + /// The managed value when the method returns . + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when a value was read. + /// No memory service has been bound to this builder. + public bool TryReadWith(IMemoryCodec codec, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryReadWith(RequireMemory(), codec, out value, out failure, cancellationToken); + } + + /// Tries to read one typed value through an explicit target-memory service. + /// The managed value type represented by . + /// The scoped target-memory service used for this operation. + /// The deterministic codec that maps to Cheat Engine memory. + /// The managed value when the method returns . + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when a value was read. + public bool TryReadWith(IMemoryClient memory, IMemoryCodec codec, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(memory); + ArgumentNullException.ThrowIfNull(codec); + return memory.TryRead(new MemoryReadRequest(Address, codec), out value, out failure, cancellationToken); + } + + /// Writes one typed value through the service bound to this builder. + /// The managed value type represented by . + /// The managed value to write. + /// The deterministic codec that maps to Cheat Engine memory. + /// Cancels before the operation reaches Cheat Engine. + /// Nothing when Cheat Engine accepted the write. + /// No memory service has been bound to this builder. + public void WriteWith(T value, IMemoryCodec codec, CancellationToken cancellationToken = default) + { + WriteWith(RequireMemory(), value, codec, cancellationToken); + } + + /// Writes one typed value through an explicit target-memory service. + /// The managed value type represented by . + /// The scoped target-memory service used for this operation. + /// The managed value to write. + /// The deterministic codec that maps to Cheat Engine memory. + /// Cancels before the operation reaches Cheat Engine. + /// Nothing when Cheat Engine accepted the write. + public void WriteWith(IMemoryClient memory, T value, IMemoryCodec codec, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(memory); + ArgumentNullException.ThrowIfNull(codec); + memory.Write(new MemoryWriteRequest(Address, value, codec), cancellationToken); + } + + /// Tries to write one typed value through the service bound to this builder. + /// The managed value type represented by . + /// The managed value to write. + /// The deterministic codec that maps to Cheat Engine memory. + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when Cheat Engine accepted the write. + /// No memory service has been bound to this builder. + public bool TryWriteWith(T value, IMemoryCodec codec, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryWriteWith(RequireMemory(), value, codec, out failure, cancellationToken); + } + + /// Tries to write one typed value through an explicit target-memory service. + /// The managed value type represented by . + /// The scoped target-memory service used for this operation. + /// The managed value to write. + /// The deterministic codec that maps to Cheat Engine memory. + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when Cheat Engine accepted the write. + public bool TryWriteWith(IMemoryClient memory, T value, IMemoryCodec codec, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(memory); + ArgumentNullException.ThrowIfNull(codec); + return memory.TryWrite(new MemoryWriteRequest(Address, value, codec), out failure, cancellationToken); + } + + private IMemoryClient RequireMemory() + { + return _memory ?? throw new InvalidOperationException( + "This memory builder has no bound target-memory service. Use Memory.At(memory, address), " + + "memory.At(address), or pass the service to Read/Write."); + } +} diff --git a/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt new file mode 100644 index 0000000..fd41b59 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt @@ -0,0 +1,82 @@ +#nullable enable +~override CheatEngine.Client.Memory.MemoryAddressBuilder.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryAddressBuilder.ToString() -> string +~override CheatEngine.Client.Scanning.AobFirstMatchBuilder.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobFirstMatchBuilder.ToString() -> string +~override CheatEngine.Client.Scanning.AobManyMatchBuilder.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobManyMatchBuilder.ToString() -> string +~override CheatEngine.Client.Scanning.AobScanBuilder.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobScanBuilder.ToString() -> string +~override CheatEngine.Client.Scanning.AobSingleMatchBuilder.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobSingleMatchBuilder.ToString() -> string +CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions +CheatEngine.Client.Memory.Memory +CheatEngine.Client.Memory.MemoryAddressBuilder +CheatEngine.Client.Memory.MemoryAddressBuilder.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryAddressBuilder.Equals(CheatEngine.Client.Memory.MemoryAddressBuilder other) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.MemoryAddressBuilder() -> void +CheatEngine.Client.Memory.MemoryAddressBuilder.Read(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Memory.MemoryAddressBuilder.ReadWith(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Memory.MemoryAddressBuilder.ReadWith(CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Memory.MemoryAddressBuilder.TryRead(out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadWith(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.Client.Memory.IMemoryCodec! codec, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadWith(CheatEngine.Client.Memory.IMemoryCodec! codec, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryWrite(T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteWith(CheatEngine.Client.Memory.IMemoryClient! memory, T value, CheatEngine.Client.Memory.IMemoryCodec! codec, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteWith(T value, CheatEngine.Client.Memory.IMemoryCodec! codec, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.Using(CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryAddressBuilder +CheatEngine.Client.Memory.MemoryAddressBuilder.Write(T value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.MemoryAddressBuilder.WriteWith(CheatEngine.Client.Memory.IMemoryClient! memory, T value, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.MemoryAddressBuilder.WriteWith(T value, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Scanning.AobFirstMatchBuilder +CheatEngine.Client.Scanning.AobFirstMatchBuilder.AobFirstMatchBuilder() -> void +CheatEngine.Client.Scanning.AobFirstMatchBuilder.Equals(CheatEngine.Client.Scanning.AobFirstMatchBuilder other) -> bool +CheatEngine.Client.Scanning.AobFirstMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address? +CheatEngine.Client.Scanning.AobFirstMatchBuilder.TryExecute(out CheatEngine.SDK.Engine.Values.Address? address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.AobManyMatchBuilder +CheatEngine.Client.Scanning.AobManyMatchBuilder.AobManyMatchBuilder() -> void +CheatEngine.Client.Scanning.AobManyMatchBuilder.Equals(CheatEngine.Client.Scanning.AobManyMatchBuilder other) -> bool +CheatEngine.Client.Scanning.AobManyMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.AobScanResult +CheatEngine.Client.Scanning.AobManyMatchBuilder.TryExecute(out CheatEngine.Client.Scanning.AobScanResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.AobScanBuilder() -> void +CheatEngine.Client.Scanning.AobScanBuilder.Equals(CheatEngine.Client.Scanning.AobScanBuilder other) -> bool +CheatEngine.Client.Scanning.AobScanBuilder.FirstOrNone() -> CheatEngine.Client.Scanning.AobFirstMatchBuilder +CheatEngine.Client.Scanning.AobScanBuilder.InModule(CheatEngine.SDK.Engine.Inspection.ModuleName module) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.InModule(string! moduleName) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.InRange(CheatEngine.SDK.Engine.Values.Address start, CheatEngine.SDK.Engine.Values.Address end) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.Module.get -> CheatEngine.SDK.Engine.Inspection.ModuleName? +CheatEngine.Client.Scanning.AobScanBuilder.Options.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions +CheatEngine.Client.Scanning.AobScanBuilder.Pattern.get -> CheatEngine.Client.Scanning.AobPattern +CheatEngine.Client.Scanning.AobScanBuilder.Range.get -> CheatEngine.Client.Scanning.AobScanRange? +CheatEngine.Client.Scanning.AobScanBuilder.ReadableExecutable() -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.RequireSingle() -> CheatEngine.Client.Scanning.AobSingleMatchBuilder +CheatEngine.Client.Scanning.AobScanBuilder.Take(int maximumResults) -> CheatEngine.Client.Scanning.AobManyMatchBuilder +CheatEngine.Client.Scanning.AobScanBuilder.WithAlignment(CheatEngine.SDK.Engine.Enums.FastScanMethod method, string? parameter) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobScanBuilder.WithProtectionFlags(string? protectionFlags) -> CheatEngine.Client.Scanning.AobScanBuilder +CheatEngine.Client.Scanning.AobSingleMatchBuilder +CheatEngine.Client.Scanning.AobSingleMatchBuilder.AobSingleMatchBuilder() -> void +CheatEngine.Client.Scanning.AobSingleMatchBuilder.Equals(CheatEngine.Client.Scanning.AobSingleMatchBuilder other) -> bool +CheatEngine.Client.Scanning.AobSingleMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Scanning.AobSingleMatchBuilder.TryExecute(out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions +override CheatEngine.Client.Memory.MemoryAddressBuilder.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobFirstMatchBuilder.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobManyMatchBuilder.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobScanBuilder.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobSingleMatchBuilder.GetHashCode() -> int +static CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions.At(this CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder +static CheatEngine.Client.Memory.Memory.At(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder +static CheatEngine.Client.Memory.Memory.At(CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder +static CheatEngine.Client.Memory.MemoryAddressBuilder.operator !=(CheatEngine.Client.Memory.MemoryAddressBuilder left, CheatEngine.Client.Memory.MemoryAddressBuilder right) -> bool +static CheatEngine.Client.Memory.MemoryAddressBuilder.operator ==(CheatEngine.Client.Memory.MemoryAddressBuilder left, CheatEngine.Client.Memory.MemoryAddressBuilder right) -> bool +static CheatEngine.Client.Scanning.AobFirstMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobFirstMatchBuilder left, CheatEngine.Client.Scanning.AobFirstMatchBuilder right) -> bool +static CheatEngine.Client.Scanning.AobFirstMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobFirstMatchBuilder left, CheatEngine.Client.Scanning.AobFirstMatchBuilder right) -> bool +static CheatEngine.Client.Scanning.AobManyMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobManyMatchBuilder left, CheatEngine.Client.Scanning.AobManyMatchBuilder right) -> bool +static CheatEngine.Client.Scanning.AobManyMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobManyMatchBuilder left, CheatEngine.Client.Scanning.AobManyMatchBuilder right) -> bool +static CheatEngine.Client.Scanning.AobScanBuilder.operator !=(CheatEngine.Client.Scanning.AobScanBuilder left, CheatEngine.Client.Scanning.AobScanBuilder right) -> bool +static CheatEngine.Client.Scanning.AobScanBuilder.operator ==(CheatEngine.Client.Scanning.AobScanBuilder left, CheatEngine.Client.Scanning.AobScanBuilder right) -> bool +static CheatEngine.Client.Scanning.AobSingleMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobSingleMatchBuilder left, CheatEngine.Client.Scanning.AobSingleMatchBuilder right) -> bool +static CheatEngine.Client.Scanning.AobSingleMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobSingleMatchBuilder left, CheatEngine.Client.Scanning.AobSingleMatchBuilder right) -> bool +static CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions.Aob(this CheatEngine.Client.ICheatEngineClient! client, string! pattern) -> CheatEngine.Client.Scanning.AobScanBuilder +static CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions.Aob(this CheatEngine.Client.Scanning.IPatternScanner! scanner, string! pattern) -> CheatEngine.Client.Scanning.AobScanBuilder diff --git a/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.Client.Fluent/README.md b/libs/CheatEngine.Client.Fluent/README.md index b224307..aac3503 100644 --- a/libs/CheatEngine.Client.Fluent/README.md +++ b/libs/CheatEngine.Client.Fluent/README.md @@ -1,10 +1,88 @@ # CheatEngine.Client.Fluent -The fluent, composable public API of CheatEngine.Client: builders and entry points that express operations against the -`CheatEngine.Client.Abstractions` contracts. +## Context -## Rules +`CheatEngine.Client.Fluent` supplies the immutable, handle-free syntax for common Client +operations. It enriches the contracts in `CheatEngine.Client.Abstractions`; it does not execute +Cheat Engine calls by itself. -- References `CheatEngine.Client.Abstractions` only. It never references `CheatEngine.Client.Binding`, so it can be - tested against fakes of the contracts. -- Public API. +The package currently provides AOB request builders and typed target-memory address builders. A +terminal builder delegates work to a caller-supplied `IPatternScanner` or `IMemoryClient`, usually +the services available from an activation-scoped `ICheatEngineClient`. + +```csharp +using CheatEngine.Client.Memory; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Values; + +Address address = client.Aob("48 8B ?? ?? ?? 89") + .InModule("game.exe") + .ReadableExecutable() + .RequireSingle() + .Execute(); + +client.Memory.At(address + 0x14).Write(999); +``` + +## Why This Project Exists + +The public API needs expressive construction of bounded requests without coupling application code +to Core, service location, or SDK ownership. Fluent keeps that syntax as a small pure layer over +interfaces, so builders can be inspected and tested without Cheat Engine. + +It references `CheatEngine.Client.Abstractions` only. It has no project reference to Core and no +direct `CheatEngine.SDK` package reference. Core remains the only Client layer that maps a terminal +operation to Cheat Engine; Dependency Injection and Hosting own the concrete implementation. + +```text +Abstractions ← Fluent + ↑ + Core ← DependencyInjection ← Hosting +``` + +## How It Improves CheatEngine.Client + +- Represents operation configuration as immutable `readonly record struct` values rather than CE + handles or mutable builders. +- Validates and normalizes an AOB pattern and its options before a terminal operation is selected. +- Forces explicit result cardinality: `RequireSingle()`, `FirstOrNone()`, or `Take(maximumResults)`. +- Preserves bounded materialization rules; callers can inspect `AobScanResult.IsTruncated` when a + bounded scan is intentionally incomplete. +- Provides `Memory.At(...)` and `memory.At(...)` builders for primitive and codec-based reads and + writes without retaining a live target handle. +- Uses the normal `Try...` plus `CheatEngineFailure` pattern and leaves the actual lifecycle, + dispatch, and SDK translation to the supplied contract implementation. + +## Public Namespaces and Boundaries + +The package publishes functional namespaces only: + +| Namespace | Entry points | +|---|---| +| `CheatEngine.Client.Scanning` | `Aob(...)`, AOB filters, and bounded terminal builders | +| `CheatEngine.Client.Memory` | `Memory.At(...)`, `IMemoryClient.At(...)`, and `MemoryAddressBuilder` | + +`CheatEngine.Client.Fluent` is a package/assembly name, never a consumer namespace. The builders +may expose stable SDK value types already present in the Abstractions vocabulary, notably `Address` +and documented scan/inspection option types; they never expose Lua states, CE objects, or SDK +ownership wrappers. + +Fluent does not make a capability available. For example, it has no value-scan builder and cannot +turn the currently gated `IValueScanner` contract into a live scan. A builder remains valid as a +managed value, but executing it through a stale scoped service still follows the implementation's +activation and target-epoch rules. + +## Contribution and Validation + +Add a fluent surface only when it preserves an existing explicit contract and has a bounded terminal +operation. Do not store CE resources in a builder, add Core dependencies, or introduce +assembly-derived namespaces. Update `PublicAPI.Unshipped.txt` and add focused behavior tests in +`tests/CheatEngine.Client.Fluent.Tests` for every public member or terminal-condition change. + +Validate the complete graph 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 +``` diff --git a/libs/CheatEngine.Client.Fluent/Scanning/AobFirstMatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Scanning/AobFirstMatchBuilder.cs new file mode 100644 index 0000000..d14f695 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Scanning/AobFirstMatchBuilder.cs @@ -0,0 +1,64 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Scanning; + +/// An immutable terminal builder for an AOB scan that returns its first match, if any. +public readonly record struct AobFirstMatchBuilder +{ + private readonly AobScanRequest _request; + private readonly IPatternScanner? _scanner; + + internal AobFirstMatchBuilder(IPatternScanner scanner, AobScanRequest request) + { + _scanner = scanner; + _request = request; + } + + /// Runs the scan and returns its first match, or when no match exists. + /// Cancels before the scan reaches Cheat Engine. + /// The first copied target address, or . + /// The scan operation failed. + public Address? Execute(CancellationToken cancellationToken = default) + { + if (TryExecute(out Address? address, out CheatEngineFailure failure, cancellationToken)) + { + return address; + } + + throw new CheatEngineOperationException(failure); + } + + /// Runs the scan and attempts to return its first match. + /// The first target address, or when no match exists. + /// The scan failure when the method returns . + /// Cancels before the scan reaches Cheat Engine. + /// when the scan ran successfully, including a no-match result. + public bool TryExecute(out Address? address, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!RequireScanner().TryScan(_request, out AobScanResult result, out failure, cancellationToken)) + { + address = null; + return false; + } + + if (result.Matches.Length > _request.MaximumResults) + { + address = null; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Aob.FirstOrNone", + "The pattern scanner returned more matches than the first-match materialization limit."); + return false; + } + + address = result.Matches.Length == 0 ? null : result.Matches[0]; + failure = default; + return true; + } + + private IPatternScanner RequireScanner() + { + return _scanner ?? throw new InvalidOperationException( + "This AOB terminal builder has no bound pattern scanner. Create it through Aob(pattern)."); + } +} diff --git a/libs/CheatEngine.Client.Fluent/Scanning/AobManyMatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Scanning/AobManyMatchBuilder.cs new file mode 100644 index 0000000..dd785bb --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Scanning/AobManyMatchBuilder.cs @@ -0,0 +1,63 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Scanning; + +/// An immutable terminal builder for a bounded, copied AOB result set. +public readonly record struct AobManyMatchBuilder +{ + private readonly AobScanRequest _request; + private readonly IPatternScanner? _scanner; + + internal AobManyMatchBuilder(IPatternScanner scanner, AobScanRequest request) + { + _scanner = scanner; + _request = request; + } + + /// Runs the bounded scan and returns its copied result set. + /// Cancels before the scan reaches Cheat Engine. + /// + /// The bounded copied result set; inspect before treating it as + /// complete. + /// + /// The scan operation failed or violated its materialization limit. + public AobScanResult Execute(CancellationToken cancellationToken = default) + { + if (TryExecute(out AobScanResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + throw new CheatEngineOperationException(failure); + } + + /// Runs the bounded scan and attempts to return its copied result set. + /// The bounded copied result set when the method returns . + /// The scan or materialization failure when the method returns . + /// Cancels before the scan reaches Cheat Engine. + /// when the bounded result set was returned. + public bool TryExecute(out AobScanResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!RequireScanner().TryScan(_request, out result, out failure, cancellationToken)) + { + return false; + } + + if (result.Matches.Length <= _request.MaximumResults) + { + return true; + } + + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Aob.Take", + "The pattern scanner returned more matches than the request's materialization limit."); + return false; + } + + private IPatternScanner RequireScanner() + { + return _scanner ?? throw new InvalidOperationException( + "This AOB terminal builder has no bound pattern scanner. Create it through Aob(pattern)."); + } +} diff --git a/libs/CheatEngine.Client.Fluent/Scanning/AobScanBuilder.cs b/libs/CheatEngine.Client.Fluent/Scanning/AobScanBuilder.cs new file mode 100644 index 0000000..e4e96e2 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Scanning/AobScanBuilder.cs @@ -0,0 +1,153 @@ +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Scanning; + +/// An immutable, handle-free AOB scan builder before its result cardinality is selected. +public readonly record struct AobScanBuilder +{ + private readonly IPatternScanner? _scanner; + + internal AobScanBuilder(IPatternScanner? scanner, AobPattern pattern, AobScanOptions options, ModuleName? module, + AobScanRange? range) + { + _scanner = scanner; + Pattern = pattern; + Options = options; + Module = module; + Range = range; + } + + /// Gets the normalized pattern that the builder will submit to Cheat Engine. + public AobPattern Pattern + { + get; + } + + /// Gets the current evidence-backed Cheat Engine scan options. + public AobScanOptions Options + { + get; + } + + /// Gets the optional module-range filter that Core resolves before materializing matches. + public ModuleName? Module + { + get; + } + + /// Gets the optional inclusive copied-address range filter. + public AobScanRange? Range + { + get; + } + + /// Returns an equivalent builder that restricts results to one named target module. + /// The non-empty module name understood by Cheat Engine's symbol handler. + /// A new immutable builder. + public AobScanBuilder InModule(string moduleName) + { + return InModule(new ModuleName(moduleName)); + } + + /// Returns an equivalent builder that restricts results to one target module. + /// The module-range filter resolved by Core before result materialization. + /// A new immutable builder. + public AobScanBuilder InModule(ModuleName module) + { + if (string.IsNullOrWhiteSpace(module.Value)) + { + throw new ArgumentException("An AOB module filter must be non-empty.", nameof(module)); + } + + return new AobScanBuilder(_scanner, Pattern, Options, module, Range); + } + + /// Returns an equivalent builder that retains only match addresses in an inclusive target-address range. + /// The first included target address. + /// The last included target address. + /// A new immutable builder. + /// + /// The SDK's string-form AOBScan binding has no start/end arguments. Core applies this range while + /// copying the owned result list, before the requested materialization limit is counted. + /// + public AobScanBuilder InRange(Address start, Address end) + { + return new AobScanBuilder(_scanner, Pattern, Options, Module, new AobScanRange(start, end)); + } + + /// Returns an equivalent builder that searches read-only executable memory. + /// + /// Cheat Engine's documented protection grammar does not expose a readable bit. +X-C-W therefore means + /// executable, not copy-on-write, and not writable memory. + /// + /// A new immutable builder. + public AobScanBuilder ReadableExecutable() + { + return WithOptions(new AobScanOptions("+X-C-W", Options.AlignmentMethod, Options.AlignmentParameter)); + } + + /// Returns an equivalent builder with the exact Cheat Engine protection expression. + /// + /// The protection expression accepted by Cheat Engine, or to omit + /// it. + /// + /// A new immutable builder. + public AobScanBuilder WithProtectionFlags(string? protectionFlags) + { + return WithOptions(new AobScanOptions(protectionFlags, Options.AlignmentMethod, Options.AlignmentParameter)); + } + + /// Returns an equivalent builder with an explicit Cheat Engine alignment rule. + /// The documented Cheat Engine alignment method. + /// The divisor or hexadecimal trailing-digit expression required by . + /// A new immutable builder. + public AobScanBuilder WithAlignment(FastScanMethod method, string? parameter) + { + return WithOptions(new AobScanOptions(Options.ProtectionFlags, method, parameter)); + } + + /// Selects an operation that succeeds only when exactly one AOB match exists. + /// An immutable single-match terminal builder. + public AobSingleMatchBuilder RequireSingle() + { + return new AobSingleMatchBuilder(RequireScanner(), BuildRequest(2)); + } + + /// Selects an operation that returns the first match or when there is no match. + /// An immutable first-match terminal builder. + public AobFirstMatchBuilder FirstOrNone() + { + return new AobFirstMatchBuilder(RequireScanner(), BuildRequest(1)); + } + + /// Selects an operation that materializes no more than the requested number of matches. + /// The positive maximum number of copied addresses to materialize. + /// An immutable bounded-result terminal builder. + /// is zero or negative. + public AobManyMatchBuilder Take(int maximumResults) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumResults); + return new AobManyMatchBuilder(RequireScanner(), BuildRequest(maximumResults)); + } + + private IPatternScanner RequireScanner() + { + return _scanner ?? throw new InvalidOperationException( + "This AOB builder has no bound pattern scanner. Start the operation with client.Aob(pattern) or scanner.Aob(pattern)."); + } + + private AobScanRequest BuildRequest(int maximumResults) + { + return new AobScanRequest(Pattern, Options, maximumResults, Module, Range); + } + + private AobScanBuilder WithOptions(AobScanOptions options) + { + // Validate and normalize at configuration time, not after the caller selected a terminal operation. + AobScanRequest request = new(Pattern, options, 1, Module, Range); + return new AobScanBuilder(_scanner, Pattern, request.Options, Module, Range); + } +} diff --git a/libs/CheatEngine.Client.Fluent/Scanning/AobSingleMatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Scanning/AobSingleMatchBuilder.cs new file mode 100644 index 0000000..5842926 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Scanning/AobSingleMatchBuilder.cs @@ -0,0 +1,80 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Scanning; + +/// An immutable terminal builder for an AOB scan that must have exactly one match. +public readonly record struct AobSingleMatchBuilder +{ + private readonly AobScanRequest _request; + private readonly IPatternScanner? _scanner; + + internal AobSingleMatchBuilder(IPatternScanner scanner, AobScanRequest request) + { + _scanner = scanner; + _request = request; + } + + /// Runs the scan and returns its sole match. + /// Cancels before the scan reaches Cheat Engine. + /// The sole target address. + /// The scan failed, had no match, or had several matches. + public Address Execute(CancellationToken cancellationToken = default) + { + if (TryExecute(out Address address, out CheatEngineFailure failure, cancellationToken)) + { + return address; + } + + throw new CheatEngineOperationException(failure); + } + + /// Runs the scan and attempts to return its sole match. + /// The sole target address when the method returns . + /// The scan or cardinality failure when the method returns . + /// Cancels before the scan reaches Cheat Engine. + /// when exactly one match exists. + public bool TryExecute(out Address address, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!RequireScanner().TryScan(_request, out AobScanResult result, out failure, cancellationToken)) + { + address = default; + return false; + } + + if (result.Matches.Length > _request.MaximumResults) + { + address = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Aob.RequireSingle", + "The pattern scanner returned more matches than the single-match materialization limit."); + return false; + } + + if (result.Matches.Length == 0) + { + address = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, "Aob.RequireSingle", + "The AOB scan did not find a match."); + return false; + } + + if (result.Matches.Length != 1 || result.IsTruncated) + { + address = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.AmbiguousMatch, "Aob.RequireSingle", + "The AOB scan found more than one match."); + return false; + } + + address = result.Matches[0]; + failure = default; + return true; + } + + private IPatternScanner RequireScanner() + { + return _scanner ?? throw new InvalidOperationException( + "This AOB terminal builder has no bound pattern scanner. Create it through Aob(pattern)."); + } +} diff --git a/libs/CheatEngine.Client.Fluent/Scanning/CheatEngineAobFluentExtensions.cs b/libs/CheatEngine.Client.Fluent/Scanning/CheatEngineAobFluentExtensions.cs new file mode 100644 index 0000000..d9c1f70 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Scanning/CheatEngineAobFluentExtensions.cs @@ -0,0 +1,27 @@ +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Scanning; + +/// Starts immutable, handle-free AOB scans from a scoped pattern-scanner contract. +public static class CheatEngineAobFluentExtensions +{ + /// Starts an AOB scan bound to the supplied scoped scanner. + /// The scoped scanner used by terminal operations. + /// The AOB pattern to validate and normalize. + /// An immutable AOB scan builder. + public static AobScanBuilder Aob(this IPatternScanner scanner, string pattern) + { + ArgumentNullException.ThrowIfNull(scanner); + return new AobScanBuilder(scanner, new AobPattern(pattern), AobScanOptions.Default, null, null); + } + + /// Starts an AOB scan using the pattern scanner of a scoped Cheat Engine client. + /// The scoped Cheat Engine client. + /// The AOB pattern to validate and normalize. + /// An immutable AOB scan builder. + public static AobScanBuilder Aob(this ICheatEngineClient client, string pattern) + { + ArgumentNullException.ThrowIfNull(client); + return client.Patterns.Aob(pattern); + } +} diff --git a/libs/CheatEngine.Client.Fluent/packages.lock.json b/libs/CheatEngine.Client.Fluent/packages.lock.json new file mode 100644 index 0000000..e5efdf4 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/packages.lock.json @@ -0,0 +1,60 @@ +{ + "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.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.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + } + } + } +} diff --git a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs new file mode 100644 index 0000000..c5e6fb6 --- /dev/null +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs @@ -0,0 +1,289 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +using MemoryFluent = CheatEngine.Client.Memory.Memory; + +namespace CheatEngine.Client.Tests.Memory; + +public sealed class MemoryAddressBuilderTests +{ + [Fact] + public void AtWithoutServiceRejectsABuiltInTerminalOperation() + { + MemoryAddressBuilder builder = MemoryFluent.At(0x401000UL); + + Assert.Throws(() => builder.Read(TestContext.Current.CancellationToken)); + } + + [Fact] + public void UsingCreatesABoundBuilderAndForwardsBuiltInReads() + { + Address address = 0x401000; + FakeMemoryClient memory = new(1337); + MemoryAddressBuilder unbound = MemoryFluent.At(address); + + int value = unbound.Using(memory).Read(TestContext.Current.CancellationToken); + + Assert.Equal(1337, value); + Assert.Equal(address, memory.LastPrimitiveReadAddress); + Assert.Equal(typeof(int), memory.LastPrimitiveReadType); + Assert.Throws(() => unbound.Read(TestContext.Current.CancellationToken)); + } + + [Fact] + public void MemoryExtensionForwardsBuiltInWritesWithoutACodec() + { + Address address = 0x402000; + FakeMemoryClient memory = new(0); + + bool succeeded = memory.At(address) + .TryWrite(77, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastPrimitiveWriteAddress); + Assert.Equal(typeof(int), memory.LastPrimitiveWriteType); + Assert.Equal(77, memory.LastPrimitiveWriteValue); + } + + [Fact] + public void ReadWithForwardsTheExplicitCustomCodecWithoutReplacingIt() + { + Address address = 0x403000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(42); + + int value = MemoryFluent.At(address).Using(memory).ReadWith(codec, TestContext.Current.CancellationToken); + + Assert.Equal(42, value); + Assert.Equal(address, memory.LastCustomReadAddress); + Assert.Same(codec, memory.LastCustomReadCodec); + } + + [Fact] + public void TryWriteWithForwardsValueAddressAndTheExplicitCustomCodec() + { + Address address = 0x404000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + bool succeeded = MemoryFluent.At(address).Using(memory).TryWriteWith( + 77, codec, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastCustomWriteAddress); + Assert.Equal(77, memory.LastCustomWriteValue); + Assert.Same(codec, memory.LastCustomWriteCodec); + } + + private sealed class Int32Codec : IMemoryCodec + { + public bool TryRead(IMemoryReadContext context, Address address, out int value) + { + value = default; + return false; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + { + return false; + } + } + + private sealed class FakeMemoryClient(int primitiveReadValue) : IMemoryClient + { + internal Address LastPrimitiveReadAddress + { + get; + private set; + } + + internal Type? LastPrimitiveReadType + { + get; + private set; + } + + internal Address LastPrimitiveWriteAddress + { + get; + private set; + } + + internal Type? LastPrimitiveWriteType + { + get; + private set; + } + + internal object? LastPrimitiveWriteValue + { + get; + private set; + } + + internal Address LastCustomReadAddress + { + get; + private set; + } + + internal object? LastCustomReadCodec + { + get; + private set; + } + + internal Address LastCustomWriteAddress + { + get; + private set; + } + + internal object? LastCustomWriteCodec + { + get; + private set; + } + + internal object? LastCustomWriteValue + { + get; + private set; + } + + public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + LastPrimitiveReadAddress = address; + LastPrimitiveReadType = typeof(T); + value = (T) (object) primitiveReadValue; + failure = default; + return true; + } + + public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) + { + _ = TryReadPrimitive(address, out T? value, out _, cancellationToken); + return value!; + } + + public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + LastPrimitiveWriteAddress = address; + LastPrimitiveWriteType = typeof(T); + LastPrimitiveWriteValue = value; + failure = default; + return true; + } + + public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default) + { + _ = TryWritePrimitive(address, value, out _, cancellationToken); + } + + public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + bytes = []; + failure = default; + return true; + } + + public ImmutableArray ReadBytes(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default) + { + _ = TryReadBytes(request, out ImmutableArray bytes, out _, cancellationToken); + return bytes; + } + + public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + failure = default; + return true; + } + + public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancellationToken = default) + { + _ = TryWriteBytes(request, out _, cancellationToken); + } + + public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] out string? value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + value = string.Empty; + failure = default; + return true; + } + + public string ReadString(MemoryStringReadRequest request, CancellationToken cancellationToken = default) + { + _ = TryReadString(request, out string? value, out _, cancellationToken); + return value!; + } + + public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + failure = default; + return true; + } + + public void WriteString(MemoryStringWriteRequest request, CancellationToken cancellationToken = default) + { + _ = TryWriteString(request, out _, cancellationToken); + } + + public bool TryResolvePointerChain(PointerChainRequest request, out Address address, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + address = request.BaseAddress; + failure = default; + return true; + } + + public Address ResolvePointerChain(PointerChainRequest request, CancellationToken cancellationToken = default) + { + _ = TryResolvePointerChain(request, out Address address, out _, cancellationToken); + return address; + } + + public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + LastCustomReadAddress = request.Address; + LastCustomReadCodec = request.Codec; + value = (T) (object) primitiveReadValue; + failure = default; + return true; + } + + public T Read(MemoryReadRequest request, CancellationToken cancellationToken = default) + { + _ = TryRead(request, out T? value, out _, cancellationToken); + return value!; + } + + public bool TryWrite(MemoryWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + LastCustomWriteAddress = request.Address; + LastCustomWriteValue = request.Value; + LastCustomWriteCodec = request.Codec; + failure = default; + return true; + } + + public void Write(MemoryWriteRequest request, CancellationToken cancellationToken = default) + { + _ = TryWrite(request, out _, cancellationToken); + } + } +} diff --git a/tests/CheatEngine.Client.Fluent.Tests/README.md b/tests/CheatEngine.Client.Fluent.Tests/README.md index 11323cf..f9d7e76 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/README.md +++ b/tests/CheatEngine.Client.Fluent.Tests/README.md @@ -1,3 +1,26 @@ # CheatEngine.Client.Fluent.Tests -Tests of [`CheatEngine.Client.Fluent`](../../libs/CheatEngine.Client.Fluent/README.md). References its subject only. +## Context + +This project tests the public fluent builders for AOB scanning and typed memory access. The builders are pure managed +values; they do not retain a Cheat Engine object, Lua state, or SDK ownership handle. + +## Why this project exists + +The fluent layer is the consumer-facing expression of limits and intent. It must preserve the caller's pattern, +module/range/protection/alignment filters, codec choice, and bounded terminal behavior until execution is delegated to +the Client facade. + +## How it helps improve CheatEngine.Client + +The tests prove builder immutability, correct forwarding, invalid-argument rejection, and the bounded semantics of +`FirstOrNone`, `RequireSingle`, and `Take`. This keeps ergonomic calls such as `client.Patterns.Aob(...).Take(...)` +predictable without making a live scan part of a unit test. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Fluent.Tests\CheatEngine.Client.Fluent.Tests.csproj --configuration Release +``` diff --git a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs new file mode 100644 index 0000000..facdee3 --- /dev/null +++ b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs @@ -0,0 +1,228 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Tests.Scanning; + +public sealed class AobFluentBuilderTests +{ + [Fact] + public void ConfigurationMethodsReturnNewBuilderWithoutChangingTheOriginal() + { + FakePatternScanner scanner = new(); + AobScanBuilder original = scanner.Aob("48 8B ?? 89"); + + AobScanBuilder configured = original.InModule("game.exe").InRange(0x400000, 0x4FFFFF).ReadableExecutable(); + + Assert.Null(original.Module); + Assert.Null(original.Range); + Assert.Null(original.Options.ProtectionFlags); + Assert.Equal("game.exe", configured.Module!.Value.Value); + Assert.Equal(new AobScanRange(0x400000, 0x4FFFFF), configured.Range); + Assert.Equal("+X-C-W", configured.Options.ProtectionFlags); + Assert.Equal("48 8B ?? 89", configured.Pattern.Value); + } + + [Fact] + public void RequireSingleUsesTwoResultLimitAndReturnsTheOnlyMatch() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], false)); + + Address actual = scanner.Aob("90 90").InModule("game.exe").RequireSingle() + .Execute(TestContext.Current.CancellationToken); + + Assert.Equal(expected, actual); + AobScanRequest? request = scanner.LastRequest; + Assert.NotNull(request); + Assert.Equal(2, request.Value.MaximumResults); + Assert.Equal("game.exe", request.Value.Module!.Value.Value); + } + + [Fact] + public void FluentFiltersNormalizeAndForwardProtectionAlignmentAndRange() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], false)); + + Address actual = scanner.Aob("90") + .WithProtectionFlags("-w+x-c") + .WithAlignment(FastScanMethod.LastDigits, "f0") + .InRange(0x400000, 0x4FFFFF) + .RequireSingle() + .Execute(TestContext.Current.CancellationToken); + + Assert.Equal(expected, actual); + AobScanRequest request = Assert.IsType(scanner.LastRequest); + Assert.Equal("+X-C-W", request.Options.ProtectionFlags); + Assert.Equal(FastScanMethod.LastDigits, request.Options.AlignmentMethod); + Assert.Equal("F0", request.Options.AlignmentParameter); + Assert.Equal(new AobScanRange(0x400000, 0x4FFFFF), request.Range); + } + + [Theory] + [InlineData("+X+X")] + [InlineData("X")] + public void WithProtectionFlagsRejectsMalformedExpressionsBeforeTerminalSelection(string protection) + { + FakePatternScanner scanner = new(); + + Assert.Throws(() => scanner.Aob("90").WithProtectionFlags(protection)); + Assert.Null(scanner.LastRequest); + } + + [Fact] + public void WithAlignmentRejectsAnInvalidDivisorBeforeTerminalSelection() + { + FakePatternScanner scanner = new(); + + Assert.Throws(() => scanner.Aob("90").WithAlignment(FastScanMethod.Aligned, "0")); + Assert.Null(scanner.LastRequest); + } + + [Fact] + public void RequireSingleReportsAmbiguousMatchWhenTheBoundedResultIsTruncated() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], true)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, failure.Kind); + Assert.Equal("Aob.RequireSingle", failure.Operation); + } + + [Fact] + public void FirstOrNoneUsesOneResultLimitAndTreatsNoMatchAsSuccess() + { + FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false)); + + bool succeeded = scanner.Aob("90").FirstOrNone().TryExecute( + out Address? address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Null(address); + Assert.Equal(default, failure); + AobScanRequest? request = scanner.LastRequest; + Assert.NotNull(request); + Assert.Equal(1, request.Value.MaximumResults); + } + + [Fact] + public void FirstOrNoneRejectsAHostResponseBeyondItsOneMatchLimit() + { + FakePatternScanner scanner = new(new AobScanResult([0x401000, 0x402000], false)); + + bool succeeded = scanner.Aob("90").FirstOrNone().TryExecute( + out Address? address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(address); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Aob.FirstOrNone", failure.Operation); + } + + [Fact] + public void RequireSingleRejectsAHostResponseBeyondItsTwoMatchLimit() + { + FakePatternScanner scanner = new(new AobScanResult([0x401000, 0x402000, 0x403000], false)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Aob.RequireSingle", failure.Operation); + } + + [Fact] + public void TakeRejectsAHostResponseBeyondTheRequestedLimit() + { + Address first = 0x401000; + Address second = 0x402000; + FakePatternScanner scanner = new(new AobScanResult([first, second], false)); + + bool succeeded = scanner.Aob("90").Take(1).TryExecute( + out AobScanResult result, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Aob.Take", failure.Operation); + AobScanRequest? request = scanner.LastRequest; + Assert.NotNull(request); + Assert.Equal(1, request.Value.MaximumResults); + } + + [Fact] + public void TryExecutePreservesFailureReturnedByThePatternScanner() + { + CheatEngineFailure expectedFailure = new( + CheatEngineFailureKind.CapabilityUnavailable, + "Patterns.Scan", + "AOB scanning is unavailable."); + FakePatternScanner scanner = new(expectedFailure); + + bool succeeded = scanner.Aob("90").Take(1).TryExecute( + out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(expectedFailure, failure); + } + + private sealed class FakePatternScanner : IPatternScanner + { + private readonly CheatEngineFailure _failure; + private readonly AobScanResult _result; + private readonly bool _succeeds; + + internal FakePatternScanner() + : this(new AobScanResult(ImmutableArray
.Empty, false)) + { + } + + internal FakePatternScanner(AobScanResult result) + { + _result = result; + _succeeds = true; + } + + internal FakePatternScanner(CheatEngineFailure failure) + { + _failure = failure; + } + + internal AobScanRequest? LastRequest + { + get; + private set; + } + + public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + LastRequest = request; + result = _result; + failure = _failure; + return _succeeds; + } + + public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default) + { + if (TryScan(request, out AobScanResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + } +} diff --git a/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json b/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json new file mode 100644 index 0000000..be25ffb --- /dev/null +++ b/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json @@ -0,0 +1,204 @@ +{ + "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.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "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.fluent": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + } + } + } +} From 05e3c0c06549db24febc3a1ada0ed72025393db5 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 20:35:13 +0200 Subject: [PATCH 02/10] test: cover Fluent Sonar paths Exercise the AOB and memory terminal builders across successful forwarding, host failures, defaults, and invalid argument paths. The added cases bring the Fluent project to full local line and branch coverage for the Sonar quality gate. --- .../Memory/MemoryAddressBuilderTests.cs | 120 +++++++++++ .../Scanning/AobFluentBuilderTests.cs | 204 ++++++++++++++++++ 2 files changed, 324 insertions(+) diff --git a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs index c5e6fb6..1c90436 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs @@ -81,6 +81,126 @@ public void TryWriteWithForwardsValueAddressAndTheExplicitCustomCodec() Assert.Same(codec, memory.LastCustomWriteCodec); } + [Fact] + public void TryReadForwardsBuiltInReadToTheBoundService() + { + Address address = 0x405000; + FakeMemoryClient memory = new(1337); + + bool succeeded = MemoryFluent.At(address).Using(memory).TryRead( + out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(1337, value); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastPrimitiveReadAddress); + Assert.Equal(typeof(int), memory.LastPrimitiveReadType); + } + + [Fact] + public void WriteForwardsBuiltInWriteToTheBoundService() + { + Address address = 0x406000; + FakeMemoryClient memory = new(0); + + MemoryFluent.At(address).Using(memory).Write(77, TestContext.Current.CancellationToken); + + Assert.Equal(address, memory.LastPrimitiveWriteAddress); + Assert.Equal(typeof(int), memory.LastPrimitiveWriteType); + Assert.Equal(77, memory.LastPrimitiveWriteValue); + } + + [Fact] + public void TryReadWithForwardsTheBoundCustomCodecWithoutReplacingIt() + { + Address address = 0x407000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(42); + + bool succeeded = MemoryFluent.At(address).Using(memory).TryReadWith( + codec, out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(42, value); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastCustomReadAddress); + Assert.Same(codec, memory.LastCustomReadCodec); + } + + [Fact] + public void TryReadWithForwardsTheExplicitMemoryServiceAndCustomCodec() + { + Address address = 0x408000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(42); + + bool succeeded = MemoryFluent.At(address).TryReadWith( + memory, codec, out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(42, value); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastCustomReadAddress); + Assert.Same(codec, memory.LastCustomReadCodec); + } + + [Fact] + public void TryReadWithRejectsMissingBoundMemoryAndNullExplicitArguments() + { + MemoryAddressBuilder unbound = MemoryFluent.At(0x409000UL); + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + Assert.Throws(() => unbound.TryReadWith( + codec, out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => unbound.TryReadWith( + null!, codec, out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => unbound.TryReadWith( + memory, null!, out int _, out _, TestContext.Current.CancellationToken)); + } + + [Fact] + public void WriteWithForwardsTheBoundCustomCodecWithoutReplacingIt() + { + Address address = 0x40A000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + MemoryFluent.At(address).Using(memory).WriteWith(77, codec, TestContext.Current.CancellationToken); + + Assert.Equal(address, memory.LastCustomWriteAddress); + Assert.Equal(77, memory.LastCustomWriteValue); + Assert.Same(codec, memory.LastCustomWriteCodec); + } + + [Fact] + public void WriteWithForwardsTheExplicitMemoryServiceAndCustomCodec() + { + Address address = 0x40B000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + MemoryFluent.At(address).WriteWith(memory, 77, codec, TestContext.Current.CancellationToken); + + Assert.Equal(address, memory.LastCustomWriteAddress); + Assert.Equal(77, memory.LastCustomWriteValue); + Assert.Same(codec, memory.LastCustomWriteCodec); + } + + [Fact] + public void WriteWithRejectsMissingBoundMemoryAndNullExplicitArguments() + { + MemoryAddressBuilder unbound = MemoryFluent.At(0x40C000UL); + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + Assert.Throws(() => unbound.WriteWith(77, codec, TestContext.Current.CancellationToken)); + Assert.Throws(() => unbound.WriteWith( + null!, 77, codec, TestContext.Current.CancellationToken)); + Assert.Throws(() => unbound.WriteWith( + memory, 77, null!, TestContext.Current.CancellationToken)); + } + private sealed class Int32Codec : IMemoryCodec { public bool TryRead(IMemoryReadContext context, Address address, out int value) diff --git a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs index facdee3..dcc6de9 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs @@ -1,8 +1,16 @@ using System.Collections.Immutable; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Tests.Scanning; @@ -177,6 +185,181 @@ public void TryExecutePreservesFailureReturnedByThePatternScanner() Assert.Equal(expectedFailure, failure); } + [Fact] + public void FirstOrNoneExecuteReturnsTheFirstCopiedMatch() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], false)); + + Address? actual = scanner.Aob("90").FirstOrNone().Execute(TestContext.Current.CancellationToken); + + Assert.NotNull(actual); + Assert.Equal(expected, actual.Value); + Assert.Equal(1, Assert.IsType(scanner.LastRequest).MaximumResults); + } + + [Fact] + public void FirstOrNoneExecuteThrowsThePatternScannerFailure() + { + CheatEngineFailure expectedFailure = new( + CheatEngineFailureKind.CapabilityUnavailable, + "Patterns.Scan", + "AOB scanning is unavailable."); + FakePatternScanner scanner = new(expectedFailure); + + CheatEngineOperationException exception = Assert.Throws( + () => scanner.Aob("90").FirstOrNone().Execute(TestContext.Current.CancellationToken)); + + Assert.Equal(expectedFailure, exception.Failure); + } + + [Fact] + public void DefaultFirstOrNoneBuilderRejectsExecution() + { + AobFirstMatchBuilder builder = default; + + Assert.Throws(() => builder.Execute(TestContext.Current.CancellationToken)); + } + + [Fact] + public void TakeExecuteReturnsTheBoundedCopiedResult() + { + Address first = 0x401000; + Address second = 0x402000; + FakePatternScanner scanner = new(new AobScanResult([first, second], true)); + + AobScanResult actual = scanner.Aob("90").Take(2).Execute(TestContext.Current.CancellationToken); + + Assert.Equal([first, second], actual.Matches); + Assert.True(actual.IsTruncated); + Assert.Equal(2, Assert.IsType(scanner.LastRequest).MaximumResults); + } + + [Fact] + public void TakeExecuteThrowsThePatternScannerFailure() + { + CheatEngineFailure expectedFailure = new( + CheatEngineFailureKind.CapabilityUnavailable, + "Patterns.Scan", + "AOB scanning is unavailable."); + FakePatternScanner scanner = new(expectedFailure); + + CheatEngineOperationException exception = Assert.Throws( + () => scanner.Aob("90").Take(1).Execute(TestContext.Current.CancellationToken)); + + Assert.Equal(expectedFailure, exception.Failure); + } + + [Fact] + public void DefaultManyMatchBuilderRejectsExecution() + { + AobManyMatchBuilder builder = default; + + Assert.Throws(() => builder.Execute(TestContext.Current.CancellationToken)); + } + + [Fact] + public void AobRejectsWhitespaceOnlyPatterns() + { + FakePatternScanner scanner = new(); + + Assert.Throws(() => scanner.Aob(" \t\r\n ")); + } + + [Fact] + public void DefaultAobScanBuilderRejectsTerminalSelection() + { + AobScanBuilder builder = default; + + Assert.Throws(() => builder.RequireSingle()); + } + + [Fact] + public void InModuleRejectsTheDefaultModuleNameBeforeTerminalSelection() + { + FakePatternScanner scanner = new(); + + Assert.Throws(() => scanner.Aob("90").InModule(default(ModuleName))); + Assert.Null(scanner.LastRequest); + } + + [Fact] + public void RequireSingleReportsNotFoundForNoMatches() + { + FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); + Assert.Equal("Aob.RequireSingle", failure.Operation); + } + + [Fact] + public void RequireSinglePreservesThePatternScannerFailure() + { + CheatEngineFailure expectedFailure = new( + CheatEngineFailureKind.CapabilityUnavailable, + "Patterns.Scan", + "AOB scanning is unavailable."); + FakePatternScanner scanner = new(expectedFailure); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(expectedFailure, failure); + } + + [Fact] + public void RequireSingleExecuteThrowsWhenNoMatchExists() + { + FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false)); + + CheatEngineOperationException exception = Assert.Throws( + () => scanner.Aob("90").RequireSingle().Execute(TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.NotFound, exception.Failure.Kind); + Assert.Equal("Aob.RequireSingle", exception.Failure.Operation); + } + + [Fact] + public void RequireSingleReportsAmbiguousMatchForTwoNonTruncatedResults() + { + FakePatternScanner scanner = new(new AobScanResult([0x401000, 0x402000], false)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, failure.Kind); + } + + [Fact] + public void DefaultSingleMatchBuilderRejectsExecution() + { + AobSingleMatchBuilder builder = default; + + Assert.Throws(() => builder.Execute(TestContext.Current.CancellationToken)); + } + + [Fact] + public void ClientAobExtensionUsesTheClientPatternScanner() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], false)); + ICheatEngineClient client = new FakeCheatEngineClient(scanner); + + Address actual = client.Aob("90").RequireSingle().Execute(TestContext.Current.CancellationToken); + + Assert.Equal(expected, actual); + Assert.Equal(2, Assert.IsType(scanner.LastRequest).MaximumResults); + } + private sealed class FakePatternScanner : IPatternScanner { private readonly CheatEngineFailure _failure; @@ -225,4 +408,25 @@ public AobScanResult Scan(AobScanRequest request, CancellationToken cancellation return default; } } + + private sealed class FakeCheatEngineClient(IPatternScanner patterns) : ICheatEngineClient + { + public long Epoch => 0; + public CancellationToken Stopping => CancellationToken.None; + public ICheatEngineRuntime Runtime => NotUsed(); + public ICheatEngineDispatcher Dispatcher => NotUsed(); + public IProcessClient Processes => NotUsed(); + public IMemoryClient Memory => NotUsed(); + public IPatternScanner Patterns => patterns; + public IValueScanner Scans => NotUsed(); + public IInspectionClient Inspection => NotUsed(); + public ITableClient Tables => NotUsed(); + public ILuaClient Lua => NotUsed(); + + private static T NotUsed() + where T : class + { + throw new InvalidOperationException("This test client only provides a pattern scanner."); + } + } } From 8c43bf3b3d0a25e5e16a11e64dd9384b4dbcab65 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 22:04:43 +0200 Subject: [PATCH 03/10] fix: clarify Fluent memory binding recovery Correct the unbound builder error guidance so it names the three valid binding paths instead of suggesting unsupported Read/Write overloads. Add a focused regression assertion and complete the relevant public XML documentation for null and binding failures. --- .../CheatEngineMemoryFluentExtensions.cs | 1 + .../Memory/Memory.cs | 8 +++-- .../Memory/MemoryAddressBuilder.cs | 31 ++++++++++++++++++- .../Memory/MemoryAddressBuilderTests.cs | 8 ++++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs index eca8f4b..3cbd0b6 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs @@ -9,6 +9,7 @@ public static class CheatEngineMemoryFluentExtensions /// The scoped target-memory service used by terminal operations. /// The target address to read or write. /// An immutable address builder bound to . + /// is . public static MemoryAddressBuilder At(this IMemoryClient memory, Address address) { return Memory.At(memory, address); diff --git a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs index 81d8222..1f07c8e 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs @@ -5,9 +5,12 @@ namespace CheatEngine.Client.Memory; /// Starts a fluent, handle-free operation against one target-memory address. public static class Memory { - /// Creates an address builder that receives its memory service at its terminal operation. + /// Creates an unbound address builder. /// The target address to read or write. - /// An immutable address builder. + /// + /// An immutable address builder that must be bound with Using(memory) + /// before a built-in terminal operation. + /// public static MemoryAddressBuilder At(Address address) { return new MemoryAddressBuilder(address, null); @@ -17,6 +20,7 @@ public static MemoryAddressBuilder At(Address address) /// The scoped target-memory service used by terminal operations. /// The target address to read or write. /// An immutable address builder. + /// is . public static MemoryAddressBuilder At(IMemoryClient memory, Address address) { ArgumentNullException.ThrowIfNull(memory); diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs index dc73256..73b3b63 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs @@ -25,6 +25,7 @@ public Address Address /// Returns an equivalent builder bound to a scoped target-memory service. /// The scoped target-memory service used by terminal operations. /// A new immutable builder. + /// is . public MemoryAddressBuilder Using(IMemoryClient memory) { ArgumentNullException.ThrowIfNull(memory); @@ -32,12 +33,22 @@ public MemoryAddressBuilder Using(IMemoryClient memory) } /// Reads one built-in scalar or pointer type through the bound memory service. + /// The built-in scalar or pointer type to read. + /// Cancels before the operation reaches Cheat Engine. + /// The value read from . + /// No memory service has been bound to this builder. public T Read(CancellationToken cancellationToken = default) { return RequireMemory().ReadPrimitive(Address, cancellationToken); } /// Tries to read one built-in scalar or pointer type through the bound memory service. + /// The built-in scalar or pointer type to read. + /// The value read when the method returns . + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when a value was read. + /// No memory service has been bound to this builder. public bool TryRead([MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -45,12 +56,22 @@ public bool TryRead([MaybeNullWhen(false)] out T value, out CheatEngineFailur } /// Writes one built-in scalar or pointer type through the bound memory service. + /// The built-in scalar or pointer type to write. + /// The value to write to . + /// Cancels before the operation reaches Cheat Engine. + /// No memory service has been bound to this builder. public void Write(T value, CancellationToken cancellationToken = default) { RequireMemory().WritePrimitive(Address, value, cancellationToken); } /// Tries to write one built-in scalar or pointer type through the bound memory service. + /// The built-in scalar or pointer type to write. + /// The value to write to . + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when Cheat Engine accepted the write. + /// No memory service has been bound to this builder. public bool TryWrite(T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -63,6 +84,7 @@ public bool TryWrite(T value, out CheatEngineFailure failure, /// Cancels before the operation reaches Cheat Engine. /// The managed value returned by Cheat Engine. /// No memory service has been bound to this builder. + /// is . public T ReadWith(IMemoryCodec codec, CancellationToken cancellationToken = default) { return ReadWith(RequireMemory(), codec, cancellationToken); @@ -74,6 +96,7 @@ public T ReadWith(IMemoryCodec codec, CancellationToken cancellationToken /// The deterministic codec that maps to Cheat Engine memory. /// Cancels before the operation reaches Cheat Engine. /// The managed value returned by Cheat Engine. + /// or is . public T ReadWith(IMemoryClient memory, IMemoryCodec codec, CancellationToken cancellationToken = default) { @@ -90,6 +113,7 @@ public T ReadWith(IMemoryClient memory, IMemoryCodec codec, /// Cancels before the operation reaches Cheat Engine. /// when a value was read. /// No memory service has been bound to this builder. + /// is . public bool TryReadWith(IMemoryCodec codec, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) @@ -105,6 +129,7 @@ public bool TryReadWith(IMemoryCodec codec, [MaybeNullWhen(false)] out T v /// The classified operation failure when the method returns . /// Cancels before the operation reaches Cheat Engine. /// when a value was read. + /// or is . public bool TryReadWith(IMemoryClient memory, IMemoryCodec codec, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -120,6 +145,7 @@ public bool TryReadWith(IMemoryClient memory, IMemoryCodec codec, [MaybeNu /// Cancels before the operation reaches Cheat Engine. /// Nothing when Cheat Engine accepted the write. /// No memory service has been bound to this builder. + /// is . public void WriteWith(T value, IMemoryCodec codec, CancellationToken cancellationToken = default) { WriteWith(RequireMemory(), value, codec, cancellationToken); @@ -132,6 +158,7 @@ public void WriteWith(T value, IMemoryCodec codec, CancellationToken cance /// The deterministic codec that maps to Cheat Engine memory. /// Cancels before the operation reaches Cheat Engine. /// Nothing when Cheat Engine accepted the write. + /// or is . public void WriteWith(IMemoryClient memory, T value, IMemoryCodec codec, CancellationToken cancellationToken = default) { @@ -148,6 +175,7 @@ public void WriteWith(IMemoryClient memory, T value, IMemoryCodec codec, /// Cancels before the operation reaches Cheat Engine. /// when Cheat Engine accepted the write. /// No memory service has been bound to this builder. + /// is . public bool TryWriteWith(T value, IMemoryCodec codec, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -162,6 +190,7 @@ public bool TryWriteWith(T value, IMemoryCodec codec, out CheatEngineFailu /// The classified operation failure when the method returns . /// Cancels before the operation reaches Cheat Engine. /// when Cheat Engine accepted the write. + /// or is . public bool TryWriteWith(IMemoryClient memory, T value, IMemoryCodec codec, out CheatEngineFailure failure, CancellationToken cancellationToken = default) @@ -175,6 +204,6 @@ private IMemoryClient RequireMemory() { return _memory ?? throw new InvalidOperationException( "This memory builder has no bound target-memory service. Use Memory.At(memory, address), " + - "memory.At(address), or pass the service to Read/Write."); + "memory.At(address), or bind the builder with Using(memory) before a terminal operation."); } } diff --git a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs index 1c90436..f45d7fe 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs @@ -16,7 +16,13 @@ public void AtWithoutServiceRejectsABuiltInTerminalOperation() { MemoryAddressBuilder builder = MemoryFluent.At(0x401000UL); - Assert.Throws(() => builder.Read(TestContext.Current.CancellationToken)); + InvalidOperationException exception = Assert.Throws( + () => builder.Read(TestContext.Current.CancellationToken)); + + Assert.Contains("Memory.At(memory, address)", exception.Message); + Assert.Contains("memory.At(address)", exception.Message); + Assert.Contains("Using(memory)", exception.Message); + Assert.DoesNotContain("pass the service to Read/Write", exception.Message); } [Fact] From e52e51b4fb1615306225ca7b72fabd77e6185009 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 19:11:36 +0200 Subject: [PATCH 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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