From 838fe8b6466522a51ad9aa0b41a4836e6701af4e Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 19:07:43 +0200 Subject: [PATCH 01/17] feat: establish public client contracts and build foundation Introduce the functional CheatEngine.Client contract surface and the repository policies required to build, validate, and package it deterministically. The change adds immutable request and snapshot types, failure and lifetime contracts, public domain interfaces, capability descriptions, PublicAPI baselines, and focused contract tests. It also configures central package versions, Native AOT/package validation, Microsoft Testing Platform defaults, package metadata, and editor rules shared by Rider and command-line builds. This creates the handle-free boundary that later Core, Fluent, DI, and Hosting layers consume. Validation: - dotnet build libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj --configuration Release --no-restore --warnaserror - dotnet test --project tests/CheatEngine.Client.Abstractions.Tests/CheatEngine.Client.Abstractions.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on --- .editorconfig | 134 ++++ .gitignore | 3 +- Directory.Build.props | 22 + Directory.Build.targets | 65 +- Directory.Packages.props | 24 + eng/Shipping.props | 20 + eng/Templates.props | 9 + eng/Tests.props | 13 + global.json | 3 + .../CheatEngine.Client.Abstractions.csproj | 11 + .../Dispatching/ICheatEngineDispatcher.cs | 37 ++ .../ICheatEngineClient.cs | 85 +++ .../Inspection/IInspectionClient.cs | 100 +++ .../Inspection/ISymbolRegistrationLease.cs | 29 + .../Inspection/InspectionCollectionRequest.cs | 18 + .../Inspection/SymbolRegistration.cs | 39 ++ .../Lua/ILuaClient.cs | 32 + .../Lua/ILuaExecutionContext.cs | 30 + .../Lua/ILuaModule.cs | 25 + .../Lua/ILuaModuleLease.cs | 22 + .../Lua/ILuaOperation.cs | 22 + .../Lua/IUnsafeLuaClient.cs | 14 + .../Lua/LuaScript.cs | 30 + .../Memory/IMemoryClient.cs | 76 +++ .../Memory/IMemoryCodec.cs | 20 + .../Memory/IMemoryReadContext.cs | 16 + .../Memory/IMemoryWriteContext.cs | 16 + .../Memory/MemoryBytesReadRequest.cs | 27 + .../Memory/MemoryBytesWriteRequest.cs | 33 + .../Memory/MemoryReadRequest.cs | 27 + .../Memory/MemoryStringReadRequest.cs | 34 + .../Memory/MemoryStringWriteRequest.cs | 34 + .../Memory/MemoryWriteRequest.cs | 34 + .../Memory/PointerChainRequest.cs | 38 ++ .../Modules/ICheatEngineClientModule.cs | 11 + .../Processes/IProcessClient.cs | 40 ++ .../Processes/ProcessSnapshot.cs | 77 +++ .../PublicAPI.Shipped.txt | 625 ++++++++++++++++++ .../PublicAPI.Unshipped.txt | 1 + .../CheatEngine.Client.Abstractions/README.md | 94 ++- .../CheatEngineActivationExpiredException.cs | 11 + .../Results/CheatEngineClientException.cs | 18 + .../CheatEngineClientLifecycleException.cs | 11 + .../Results/CheatEngineFailure.cs | 52 ++ .../Results/CheatEngineFailureKind.cs | 53 ++ .../Results/CheatEngineOperationException.cs | 11 + .../Runtime/CheatEngineRuntimeSnapshot.cs | 121 ++++ .../Runtime/ClientCapabilities.cs | 107 +++ .../Runtime/ClientCapabilityAvailability.cs | 58 ++ .../ClientCapabilityAvailabilityState.cs | 14 + .../Runtime/ClientCapabilityId.cs | 80 +++ .../Runtime/ICheatEngineRuntime.cs | 47 ++ .../Scanning/AobPattern.cs | 180 +++++ .../Scanning/AobScanOptionsNormalizer.cs | 155 +++++ .../Scanning/AobScanRange.cs | 46 ++ .../Scanning/AobScanRequest.cs | 60 ++ .../Scanning/AobScanResult.cs | 34 + .../Scanning/IPatternScanner.cs | 14 + .../Scanning/IValueScanSession.cs | 54 ++ .../Scanning/IValueScanner.cs | 14 + .../Scanning/ValueScanMatch.cs | 35 + .../Scanning/ValueScanPage.cs | 26 + .../Scanning/ValueScanReadRequest.cs | 26 + .../Scanning/ValueScanSessionState.cs | 33 + .../Tables/AddressTableSnapshot.cs | 38 ++ .../Tables/ITableClient.cs | 125 ++++ .../Tables/MemoryRecordCollectionRequest.cs | 18 + .../Tables/MemoryRecordDefinition.cs | 54 ++ .../Tables/MemoryRecordHierarchyRequest.cs | 27 + .../Tables/MemoryRecordHierarchySnapshot.cs | 8 + .../Tables/MemoryRecordSearch.cs | 50 ++ .../Tables/MemoryRecordSnapshot.cs | 92 +++ .../Tables/MemoryRecordUpdate.cs | 63 ++ .../Tables/TableLoadRequest.cs | 4 + .../Tables/TableSaveRequest.cs | 4 + .../Tables/TrustedTableFile.cs | 23 + .../packages.lock.json | 54 ++ .../Inspection/InspectionContractsTests.cs | 25 + .../Lua/LuaContractTests.cs | 70 ++ .../Memory/MemoryBoundedRequestTests.cs | 125 ++++ .../Processes/ProcessSnapshotTests.cs | 49 ++ .../README.md | 29 +- .../Results/CheatEngineFailureTests.cs | 78 +++ .../CheatEngineRuntimeSnapshotTests.cs | 65 ++ .../Runtime/ClientCapabilitiesTests.cs | 65 ++ .../Scanning/AobPatternTests.cs | 137 ++++ .../Scanning/ValueScanContractTests.cs | 43 ++ .../Tables/TableContractTests.cs | 103 +++ .../packages.lock.json | 198 ++++++ 89 files changed, 4787 insertions(+), 10 deletions(-) create mode 100644 eng/Templates.props create mode 100644 libs/CheatEngine.Client.Abstractions/Dispatching/ICheatEngineDispatcher.cs create mode 100644 libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Inspection/IInspectionClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Inspection/ISymbolRegistrationLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Inspection/InspectionCollectionRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Inspection/SymbolRegistration.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/ILuaExecutionContext.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/ILuaModuleLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/ILuaOperation.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/IUnsafeLuaClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/LuaScript.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/IMemoryCodec.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesWriteRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryReadRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryWriteRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/PointerChainRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Modules/ICheatEngineClientModule.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.Client.Abstractions/Results/CheatEngineActivationExpiredException.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientException.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientLifecycleException.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailureKind.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationException.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilities.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailability.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailabilityState.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Runtime/ICheatEngineRuntime.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/AobScanOptionsNormalizer.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/AobScanRange.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/AobScanRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/AobScanResult.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/IPatternScanner.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/IValueScanSession.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/IValueScanner.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/ValueScanMatch.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/ValueScanPage.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/ValueScanReadRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Scanning/ValueScanSessionState.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/AddressTableSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/ITableClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordCollectionRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordDefinition.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchyRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordUpdate.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/TableLoadRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/TableSaveRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/TrustedTableFile.cs create mode 100644 libs/CheatEngine.Client.Abstractions/packages.lock.json create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json diff --git a/.editorconfig b/.editorconfig index 208be63..c1897ef 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,11 +1,145 @@ root = true +# Repository-wide, editor-agnostic defaults. Language-specific sections below +# intentionally override these values where a format has stricter constraints. [*] charset = utf-8 insert_final_newline = true indent_style = space indent_size = 4 +tab_width = 4 +max_line_length = 120 trim_trailing_whitespace = true [*.{csproj,props,targets,slnx,config,json,yml,yaml}] +# YAML cannot use tabs for indentation. Keep project and configuration files +# compact and portable even though C# uses tabs below. +indent_style = space indent_size = 2 +tab_width = 2 + +[*.{cs,csx}] +################################################################################ +# C# team contract +# +# Roslyn settings are the portable source of truth: Rider, VS, dotnet format, +# and CI all understand them. Rider-only settings are marked as such. +################################################################################ + +# Tabs describe logical nesting; spaces are retained solely to align continuations. +indent_style = tab +indent_size = tab +tab_width = 4 +csharp_indent_style = tab +csharp_indent_size = 4 +csharp_tab_width = 4 +csharp_alignment_tab_fill_style = use_spaces + +# Make structural boundaries easy to scan. +csharp_new_line_before_open_brace = all +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_between_query_expression_clauses = true +csharp_indent_braces = false +csharp_indent_switch_labels = true +csharp_preserve_single_line_blocks = false +csharp_preserve_single_line_statements = false +csharp_space_after_cast = true +csharp_space_around_binary_operators = before_and_after +csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async + +# Keep imports and namespaces deterministic and minimise needless nesting. +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +csharp_using_directive_placement = outside_namespace +csharp_style_namespace_declarations = file_scoped + +# Prefer syntax that leaves declarations, construction, and contracts visible. +# `var` is statically typed in C#, but explicit types are the repository's +# readability convention whenever the language can express them. +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false +csharp_style_var_elsewhere = false +csharp_style_implicit_object_creation_when_type_is_apparent = false +dotnet_style_require_accessibility_modifiers = always +dotnet_style_readonly_field = true +dotnet_style_explicit_tuple_names = true +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true +csharp_prefer_braces = true +csharp_style_prefer_top_level_statements = false + +# Enforced now: redundant suppressions report genuine noise and remain build +# blockers because Directory.Build.props treats warnings as errors. +dotnet_diagnostic.IDE0079.severity = error + +# Migration rules: surface the historical backlog in every Roslyn-aware editor +# and participate in code cleanup without breaking the currently dirty tree. +# Promote these to warning/error in a dedicated cleanup change once the existing +# source has been reformatted and explicit types have been introduced. +dotnet_diagnostic.IDE0007.severity = none +dotnet_diagnostic.IDE0008.severity = suggestion +dotnet_diagnostic.IDE0011.severity = suggestion +dotnet_diagnostic.IDE0033.severity = suggestion +dotnet_diagnostic.IDE0040.severity = suggestion +dotnet_diagnostic.IDE0044.severity = suggestion +# The formatter still follows the tab contract above. Do not flood every open +# legacy file with a formatting diagnostic before the dedicated reformat pass. +dotnet_diagnostic.IDE0055.severity = none +dotnet_diagnostic.IDE0005.severity = suggestion +dotnet_diagnostic.IDE0065.severity = suggestion +dotnet_diagnostic.IDE0090.severity = suggestion +dotnet_diagnostic.IDE0160.severity = none +dotnet_diagnostic.IDE0161.severity = suggestion +dotnet_diagnostic.IDE1006.severity = suggestion + +# Rider derives its own "var or explicit type" inspections from the shared +# csharp_style_var_* values. Silence those copies so IDE0008 is the one +# portable diagnostic developers see, rather than reporting each issue twice. +resharper_suggest_var_or_type_built_in_types_highlighting = none +resharper_suggest_var_or_type_simple_types_highlighting = none +resharper_suggest_var_or_type_elsewhere_highlighting = none +resharper_suggest_var_or_type_deconstruction_declarations_highlighting = none + +# Rider-specific, live diagnostic. It complements compiler nullable analysis; +# it is deliberately a warning rather than a CI-only, non-portable error. +resharper_possible_null_reference_exception_highlighting = warning + +# Naming rules keep public contracts recognisable before a reader opens a type. +dotnet_naming_rule.interfaces_must_be_prefixed_i.severity = suggestion +dotnet_naming_rule.interfaces_must_be_prefixed_i.symbols = interfaces +dotnet_naming_rule.interfaces_must_be_prefixed_i.style = i_pascal_case +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.i_pascal_case.required_prefix = I +dotnet_naming_style.i_pascal_case.capitalization = pascal_case + +dotnet_naming_rule.types_must_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_must_be_pascal_case.symbols = types +dotnet_naming_rule.types_must_be_pascal_case.style = pascal_case +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum, delegate +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_rule.non_private_members_must_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_members_must_be_pascal_case.symbols = non_private_members +dotnet_naming_rule.non_private_members_must_be_pascal_case.style = pascal_case +dotnet_naming_symbols.non_private_members.applicable_kinds = property, method, event +dotnet_naming_symbols.non_private_members.applicable_accessibilities = public, internal, protected, protected_internal, private_protected + +dotnet_naming_rule.private_fields_must_be_underscore_camel_case.severity = suggestion +dotnet_naming_rule.private_fields_must_be_underscore_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_must_be_underscore_camel_case.style = underscore_camel_case +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel_case.required_prefix = _ +dotnet_naming_style.underscore_camel_case.capitalization = camel_case + +# IDE0005 requires XML documentation generation to run during a command-line +# build. Shipping projects already enable it; tests and samples intentionally +# retain it as an editor suggestion rather than acquiring build-only XML output. +[libs/**.cs] +dotnet_diagnostic.IDE0005.severity = error + +[src/**.cs] +dotnet_diagnostic.IDE0005.severity = error diff --git a/.gitignore b/.gitignore index c6c696b..029b475 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,5 @@ TestResults/ *.user *.suo -.claude/scheduled_tasks.lock -.claude/settings.local.json +.claude/* /CLAUDE.md diff --git a/Directory.Build.props b/Directory.Build.props index 71c605e..769878f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,6 +11,26 @@ 14.0 enable + enable + true + latest-recommended + true + true + true + true + + + + 0.1.0 + CheatEngineNet + CheatEngineNet + git + https://github.com/CheatEngineNet/CheatEngine.Client + MIT + false + README.md + true + true @@ -26,4 +46,6 @@ Condition="$(_CheatEngineClientFolder.StartsWith('src/', System.StringComparison.OrdinalIgnoreCase)) or $(_CheatEngineClientFolder.StartsWith('libs/', System.StringComparison.OrdinalIgnoreCase))"/> + diff --git a/Directory.Build.targets b/Directory.Build.targets index 9d2a1d8..f7d374e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,9 +1,72 @@ - + + + + + + + + + + + + + + + <_CheatEngineClientDirectProjectReference Include="@(ProjectReference)" + Condition="'%(ProjectReference.DefiningProjectName)' == '$(MSBuildProjectName)'"/> + <_CheatEngineClientSdkReference Include="@(PackageReference)" + Condition="'%(PackageReference.Identity)' == 'CheatEngine.SDK' and '%(PackageReference.DefiningProjectName)' == '$(MSBuildProjectName)'"/> + <_CheatEngineClientSdkRangeReference Include="@(_CheatEngineClientSdkReference)" + Condition="'%(_CheatEngineClientSdkReference.VersionOverride)' == '[1.0.0,2.0.0)'"/> + + + <_CheatEngineClientProjectReferences>@(_CheatEngineClientDirectProjectReference->'%(Filename)') + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 1932808..07c6748 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,4 +2,28 @@ true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eng/Shipping.props b/eng/Shipping.props index 8f3d8a9..0983b8d 100644 --- a/eng/Shipping.props +++ b/eng/Shipping.props @@ -3,5 +3,25 @@ net10.0 + true + true + true + true + snupkg + true + true + $(ArtifactsPath)/packages + Fluent, dependency-injection-friendly C# APIs for authorized local Cheat Engine plugins. + cheat-engine;plugin;memory;dotnet;fluent;dependency-injection + + + + + + + diff --git a/eng/Templates.props b/eng/Templates.props new file mode 100644 index 0000000..1a1c92e --- /dev/null +++ b/eng/Templates.props @@ -0,0 +1,9 @@ + + + net10.0 + false + $(ArtifactsPath)/packages + dotnet new template for an in-process, dependency-injection-first Cheat Engine plugin. + dotnet-new;template;cheat-engine;plugin + + diff --git a/eng/Tests.props b/eng/Tests.props index 3da4387..b66e29e 100644 --- a/eng/Tests.props +++ b/eng/Tests.props @@ -5,4 +5,17 @@ net10.0 false + + + true + Exe + true + + + + + + + + diff --git a/global.json b/global.json index b5715a7..9773a5f 100644 --- a/global.json +++ b/global.json @@ -3,5 +3,8 @@ "version": "10.0.401", "rollForward": "latestFeature", "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } diff --git a/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj b/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj index 61718a1..294c798 100644 --- a/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj +++ b/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj @@ -1,3 +1,14 @@ + + CheatEngine.Client + + + + + + diff --git a/libs/CheatEngine.Client.Abstractions/Dispatching/ICheatEngineDispatcher.cs b/libs/CheatEngine.Client.Abstractions/Dispatching/ICheatEngineDispatcher.cs new file mode 100644 index 0000000..080f467 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dispatching/ICheatEngineDispatcher.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Dispatching; + +/// Synchronously dispatches managed work to Cheat Engine's captured main thread. +public interface ICheatEngineDispatcher +{ + /// Gets whether the caller is already on Cheat Engine's captured main thread. + public bool IsMainThread + { + get; + } + + /// Runs a callback on the captured main thread. + /// + /// is observed before dispatch admission only. It never attempts to + /// interrupt a callback or Lua primitive that has already begun on Cheat Engine's main thread. + /// + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Runs a callback on the captured main thread and returns its managed result. + /// Cancellation is observed before dispatch admission and never while the callback is running. + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Runs a callback on the captured main thread or throws when dispatch fails. + /// Cancellation is observed before dispatch admission only, never while a callback is running. + public void Invoke(Action callback, CancellationToken cancellationToken = default); + + /// Runs a callback on the captured main thread or throws when dispatch fails. + /// Cancellation is observed before dispatch admission only, never while a callback is running. + public T Invoke(Func callback, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs b/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs new file mode 100644 index 0000000..a5c1938 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs @@ -0,0 +1,85 @@ +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; + +namespace CheatEngine.Client; + +/// A scoped, high-level client for the active Cheat Engine plugin lifecycle. +/// +/// The client never exposes Lua states, CE object handles, or SDK ownership wrappers. Its members are synchronous +/// because +/// an attached Cheat Engine Lua runtime cannot safely be retained across an await boundary. +/// +public interface ICheatEngineClient +{ + /// Gets the SDK lifecycle epoch captured for this scoped client. + public long Epoch + { + get; + } + + /// Gets a token cancelled when the active plugin lifecycle begins stopping. + public CancellationToken Stopping + { + get; + } + + /// Gets the runtime and capability service. + public ICheatEngineRuntime Runtime + { + get; + } + + /// Gets the explicit main-thread dispatcher. + public ICheatEngineDispatcher Dispatcher + { + get; + } + + /// Gets the selected-target process service. + public IProcessClient Processes + { + get; + } + + /// Gets typed target-memory operations. + public IMemoryClient Memory + { + get; + } + + /// Gets AOB and value-scan operations. + public IPatternScanner Patterns + { + get; + } + + /// Gets value-scan operations. + public IValueScanner Scans + { + get; + } + + /// Gets copied module, symbol, and region inspection operations. + public IInspectionClient Inspection + { + get; + } + + /// Gets copied address-table and memory-record operations. + public ITableClient Tables + { + get; + } + + /// Gets the protected, handle-free Lua execution service. + public ILuaClient Lua + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/IInspectionClient.cs b/libs/CheatEngine.Client.Abstractions/Inspection/IInspectionClient.cs new file mode 100644 index 0000000..2e64d8b --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Inspection/IInspectionClient.cs @@ -0,0 +1,100 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Inspection; + +/// Reads copied modules, sections, symbols, and memory regions from the selected Cheat Engine target. +public interface IInspectionClient +{ + /// Tries to copy the target modules, optionally for an explicit process identifier. + public bool TryGetModules( + InspectionCollectionRequest request, + out ImmutableArray modules, + out CheatEngineFailure failure, + TargetProcessId? processId = null, + CancellationToken cancellationToken = default); + + /// Copies the target modules or throws when inspection fails. + public ImmutableArray GetModules(InspectionCollectionRequest request, + TargetProcessId? processId = null, CancellationToken cancellationToken = default); + + /// Copies the sections belonging to one module. + public bool TryGetModuleSections( + ModuleName moduleName, + InspectionCollectionRequest request, + out ImmutableArray sections, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Copies one module's sections or throws when inspection fails. + public ImmutableArray GetModuleSections(ModuleName moduleName, + InspectionCollectionRequest request, CancellationToken cancellationToken = default); + + /// Copies the target memory-region map. + public bool TryGetMemoryRegions( + InspectionCollectionRequest request, + out ImmutableArray regions, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Copies the target memory-region map or throws when inspection fails. + public ImmutableArray GetMemoryRegions(InspectionCollectionRequest request, + CancellationToken cancellationToken = default); + + /// Copies the memory-region metadata containing one target address. + public bool TryGetMemoryRegion( + Address address, + out MemoryRegionInfo region, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets one region or throws when inspection fails. + public MemoryRegionInfo GetMemoryRegion(Address address, CancellationToken cancellationToken = default); + + /// Copies metadata for one Cheat Engine symbol expression. + public bool TryGetSymbol( + SymbolExpression expression, + out SymbolInfo symbol, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets symbol information or throws when inspection fails. + public SymbolInfo GetSymbol(SymbolExpression expression, CancellationToken cancellationToken = default); + + /// Resolves the best Cheat Engine symbol name for one target address. + public bool TryResolveName( + Address address, + [NotNullWhen(true)] out string? name, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Resolves a symbol name or throws when no name can be resolved. + public string ResolveName(Address address, CancellationToken cancellationToken = default); + + /// Registers a Client-owned Cheat Engine symbol and returns the lease that removes it. + public bool TryRegisterSymbol( + SymbolRegistration registration, + [NotNullWhen(true)] out ISymbolRegistrationLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Registers a Client-owned symbol or throws when Cheat Engine rejects the registration. + public ISymbolRegistrationLease RegisterSymbol(SymbolRegistration registration, + CancellationToken cancellationToken = default); + + /// Resolves one Cheat Engine address expression with the documented SDK options. + public bool TryResolveAddress( + SymbolExpression expression, + AddressResolutionOptions options, + out Address address, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Resolves one address or throws when resolution fails. + public Address ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/ISymbolRegistrationLease.cs b/libs/CheatEngine.Client.Abstractions/Inspection/ISymbolRegistrationLease.cs new file mode 100644 index 0000000..24c2d14 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Inspection/ISymbolRegistrationLease.cs @@ -0,0 +1,29 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Inspection; + +/// Owns one Client-created Cheat Engine symbol registration for the current activation epoch. +/// +/// Disposing the lease removes only the symbol registered through this lease. The activation owner also disposes a +/// forgotten lease before the SDK detaches the Lua runtime. +/// +public interface ISymbolRegistrationLease : IDisposable +{ + /// Gets the exact symbol name registered in Cheat Engine. + public string Name + { + get; + } + + /// Gets the target address bound to . + public Address Address + { + get; + } + + /// Gets a value indicating whether this lease has already released its registration. + public bool IsReleased + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/InspectionCollectionRequest.cs b/libs/CheatEngine.Client.Abstractions/Inspection/InspectionCollectionRequest.cs new file mode 100644 index 0000000..1aced3c --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Inspection/InspectionCollectionRequest.cs @@ -0,0 +1,18 @@ +namespace CheatEngine.Client.Inspection; + +/// Bounds the number of copied inspection records an operation may materialize. +public readonly record struct InspectionCollectionRequest +{ + /// Creates a bounded inspection request. + public InspectionCollectionRequest(int maximumItems) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumItems); + MaximumItems = maximumItems; + } + + /// Gets the maximum number of copied records the caller permits. + public int MaximumItems + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Inspection/SymbolRegistration.cs b/libs/CheatEngine.Client.Abstractions/Inspection/SymbolRegistration.cs new file mode 100644 index 0000000..e4680dc --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Inspection/SymbolRegistration.cs @@ -0,0 +1,39 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Inspection; + +/// Describes one explicitly Client-owned symbol registration. +/// +/// Names share Cheat Engine's session-wide symbol table. Callers should use a plugin-specific prefix and retain the +/// resulting lease instead of registering a name already owned by another component. +/// +public readonly record struct SymbolRegistration +{ + /// Creates a custom symbol definition. + public SymbolRegistration(string name, Address address, bool doNotSave = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + Address = address; + DoNotSave = doNotSave; + } + + /// Gets the session-wide symbol name. + public string Name + { + get; + } + + /// Gets the target address to bind. + public Address Address + { + get; + } + + /// Gets whether Cheat Engine should omit this registration when saving a table. + public bool DoNotSave + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs new file mode 100644 index 0000000..a4c698e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Lua; + +/// Executes typed Lua operations without exposing a Lua state or CE object handle. +public interface ILuaClient +{ + /// Tries to register one explicitly supplied application Lua module on Cheat Engine's main thread. + /// + /// The Client neither scans assemblies nor discovers exports by reflection. The module owns its generated SDK + /// calls; the returned lease gives the Client deterministic, activation-scoped cleanup ownership. + /// + public bool TryRegisterModule( + ILuaModule luaModule, + [NotNullWhen(true)] out ILuaModuleLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Registers one explicitly supplied application Lua module or throws when registration fails. + public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default); + + /// Tries to execute one typed operation on Cheat Engine's main thread. + public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation; + + /// Executes one typed operation or throws when it fails. + public TResult Execute(TOperation operation, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation; +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaExecutionContext.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaExecutionContext.cs new file mode 100644 index 0000000..c960cb1 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaExecutionContext.cs @@ -0,0 +1,30 @@ +namespace CheatEngine.Client.Lua; + +/// +/// Describes the short-lived, handle-free scope in which a typed Lua operation executes. +/// +/// +/// A context is valid only while the typed operation invocation that received it is running. It deliberately exposes +/// neither LuaState, LuaRef, CE objects nor raw Lua stack access. +/// Operations should use their SDK-generated [LuaGlobal] bindings while this context is active. +/// +public interface ILuaExecutionContext +{ + /// Gets the plugin activation epoch captured for this operation. + public long Epoch + { + get; + } + + /// Gets whether the context is still valid on the current Client execution boundary. + public bool IsActive + { + get; + } + + /// Throws when this context no longer belongs to the active plugin activation. + /// + /// The operation returned, its activation ended, or the context was observed outside its Client execution boundary. + /// + public void ThrowIfExpired(); +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs new file mode 100644 index 0000000..c50778d --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs @@ -0,0 +1,25 @@ +namespace CheatEngine.Client.Lua; + +/// Explicitly registers one application-owned set of Lua exports for the current Client activation. +/// +/// The Client invokes both methods synchronously on Cheat Engine's main thread and owns the resulting registration +/// lease. An implementation can call its SDK-generated RegisterLuaFunctions and +/// UnregisterLuaFunctions methods internally, including acquisition of the SDK state required by those +/// generated methods. No SDK Lua state, reference, or raw stack access crosses this Client contract. +/// +public interface ILuaModule +{ + /// Registers this module's exports for the current Cheat Engine activation. + /// + /// Throw when the generated SDK registration reports a non-success status so + /// can return the mapped failure. + /// + public void Register(); + + /// Unregisters this module's exports for the current Cheat Engine activation. + /// + /// This is called at most once by the Client, either by disposing the returned lease or during activation + /// cleanup. Implementations should make their own cleanup safe if an SDK operation reports a failure. + /// + public void Unregister(); +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaModuleLease.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModuleLease.cs new file mode 100644 index 0000000..bc448ca --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModuleLease.cs @@ -0,0 +1,22 @@ +namespace CheatEngine.Client.Lua; + +/// Owns one explicit Lua module registration in a single Cheat Engine client epoch. +/// +/// Disposing the lease unregisters its module synchronously on Cheat Engine's main thread. Forgotten leases are +/// released in LIFO order while the activation cleanup scope still permits SDK dispatch. Disposing a lease more +/// than once has no effect. The lease never exposes a Lua state, Lua reference, or native Lua handle. +/// +public interface ILuaModuleLease : IDisposable +{ + /// Gets the Client activation epoch that owns this registration. + public long Epoch + { + get; + } + + /// Gets whether this lease has already completed its one release attempt. + public bool IsReleased + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaOperation.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaOperation.cs new file mode 100644 index 0000000..d13e675 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaOperation.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Lua; + +/// A typed, handle-free Lua operation implemented with SDK-generated bindings. +/// The copied managed result type. +public interface ILuaOperation +{ + /// Executes while the Client holds a valid activation and main-thread boundary. + /// The ephemeral, handle-free context for this one synchronous operation. + /// The copied managed result on success. + /// The expected operation failure on failure. + /// when the operation completed successfully. + /// + /// Do not retain . Generated SDK bindings own their own protected Lua calls; this + /// context only proves that the Client dispatched the operation in its active activation scope. + /// + public bool TryExecute(ILuaExecutionContext context, [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure); +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/IUnsafeLuaClient.cs b/libs/CheatEngine.Client.Abstractions/Lua/IUnsafeLuaClient.cs new file mode 100644 index 0000000..38b7277 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/IUnsafeLuaClient.cs @@ -0,0 +1,14 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Lua; + +/// Opt-in execution of trusted arbitrary Lua source. +public interface IUnsafeLuaClient +{ + /// Tries to execute trusted Lua source through the SDK protected-call boundary. + public bool TryExecute(LuaScript script, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Executes trusted Lua source or throws when execution fails. + public void Execute(LuaScript script, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/LuaScript.cs b/libs/CheatEngine.Client.Abstractions/Lua/LuaScript.cs new file mode 100644 index 0000000..0f709eb --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/LuaScript.cs @@ -0,0 +1,30 @@ +namespace CheatEngine.Client.Lua; + +/// An immutable Lua chunk to execute under the SDK's protected Lua boundary. +public readonly record struct LuaScript +{ + /// Creates a Lua script request. + public LuaScript(string source, string? chunkName = null) + { + ArgumentNullException.ThrowIfNull(source); + if (chunkName is { Length: 0 }) + { + throw new ArgumentException("A Lua chunk name must be null or non-empty.", nameof(chunkName)); + } + + Source = source; + ChunkName = chunkName; + } + + /// Gets the UTF-16 source text to encode as UTF-8 for Lua. + public string Source + { + get; + } + + /// Gets the optional chunk name used in Lua diagnostics. + public string? ChunkName + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs new file mode 100644 index 0000000..7e61307 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs @@ -0,0 +1,76 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// Executes typed target-memory reads and writes without exposing a Lua state. +public interface IMemoryClient +{ + /// Tries to read a built-in scalar or pointer type without requiring a custom codec. + public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Reads a built-in scalar or pointer type or throws when unsupported or rejected. + public T ReadPrimitive(Address address, CancellationToken cancellationToken = default); + + /// Tries to write a built-in scalar or pointer type without requiring a custom codec. + public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Writes a built-in scalar or pointer type or throws when unsupported or rejected. + public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default); + + /// Tries to copy an exact, caller-bounded byte range. + public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Copies an exact byte range or throws on an expected host failure. + public ImmutableArray ReadBytes(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default); + + /// Tries to write an immutable byte request. + public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Writes bytes or throws on an expected host failure. + public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancellationToken = default); + + /// Tries to read a string with an explicit maximum length. + public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] out string? value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Reads a bounded string or throws on an expected host failure. + public string ReadString(MemoryStringReadRequest request, CancellationToken cancellationToken = default); + + /// Tries to write managed text with an explicit narrow/wide target representation. + public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Writes managed text or throws on an expected host failure. + public void WriteString(MemoryStringWriteRequest request, CancellationToken cancellationToken = default); + + /// Tries to resolve a finite target-aware pointer chain. + public bool TryResolvePointerChain(PointerChainRequest request, out Address address, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Resolves a finite pointer chain or throws on an expected host failure. + public Address ResolvePointerChain(PointerChainRequest request, + CancellationToken cancellationToken = default); + + /// Tries to read one typed value from target memory. + public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Reads one typed value or throws when the operation fails. + public T Read(MemoryReadRequest request, CancellationToken cancellationToken = default); + + /// Tries to write one typed value to target memory. + public bool TryWrite(MemoryWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Writes one typed value or throws when the operation fails. + public void Write(MemoryWriteRequest request, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryCodec.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryCodec.cs new file mode 100644 index 0000000..4e96201 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryCodec.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// Maps one managed value type to bounded target-memory operations. +/// The managed value type. +/// +/// Implementations must be deterministic, allocation-conscious, AOT-safe, and independent of Lua or CE object +/// handles. A codec is invoked synchronously on Cheat Engine's dispatch boundary and must not retain its context. +/// +public interface IMemoryCodec +{ + /// Tries to read one value from the requested target address. + public bool TryRead(IMemoryReadContext context, Address address, [MaybeNullWhen(false)] out T value); + + /// Tries to write one value to the requested target address. + public bool TryWrite(IMemoryWriteContext context, Address address, in T value); +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs new file mode 100644 index 0000000..ca411be --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryReadContext.cs @@ -0,0 +1,16 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// Provides the bounded raw-memory operations available to an application read codec. +public interface IMemoryReadContext +{ + /// Gets the selected target's pointer size in bytes. + public int PointerSize + { + get; + } + + /// Tries to fill the exact caller-provided buffer from target memory. + public bool TryReadBytes(Address address, Span destination); +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs new file mode 100644 index 0000000..f7cc6ab --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryWriteContext.cs @@ -0,0 +1,16 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// Provides the bounded raw-memory operations available to an application write codec. +public interface IMemoryWriteContext +{ + /// Gets the selected target's pointer size in bytes. + public int PointerSize + { + get; + } + + /// Tries to write the exact caller-provided bytes to target memory. + public bool TryWriteBytes(Address address, ReadOnlySpan source); +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadRequest.cs new file mode 100644 index 0000000..37736cb --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesReadRequest.cs @@ -0,0 +1,27 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// A bounded request to copy a fixed number of target bytes. +public readonly record struct MemoryBytesReadRequest +{ + /// Creates a bounded byte read. + public MemoryBytesReadRequest(Address address, int length) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); + Address = address; + Length = length; + } + + /// Gets the first target address. + public Address Address + { + get; + } + + /// Gets the exact number of bytes to copy. + public int Length + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesWriteRequest.cs new file mode 100644 index 0000000..a64b651 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBytesWriteRequest.cs @@ -0,0 +1,33 @@ +using System.Collections.Immutable; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// A copied byte sequence to write to target memory. +public readonly record struct MemoryBytesWriteRequest +{ + /// Creates a byte write and copies the caller's data immediately. + public MemoryBytesWriteRequest(Address address, ReadOnlySpan bytes) + { + if (bytes.IsEmpty) + { + throw new ArgumentException("At least one byte is required.", nameof(bytes)); + } + + Address = address; + Bytes = ImmutableArray.Create(bytes.ToArray()); + } + + /// Gets the first target address. + public Address Address + { + get; + } + + /// Gets the independent immutable bytes. + public ImmutableArray Bytes + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryReadRequest.cs new file mode 100644 index 0000000..23d0d7b --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryReadRequest.cs @@ -0,0 +1,27 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// An immutable typed target-memory read request. +public readonly record struct MemoryReadRequest +{ + /// Creates a memory read request. + public MemoryReadRequest(Address address, IMemoryCodec codec) + { + ArgumentNullException.ThrowIfNull(codec); + Address = address; + Codec = codec; + } + + /// Gets the target address to read. + public Address Address + { + get; + } + + /// Gets the codec used to translate the value. + public IMemoryCodec Codec + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs new file mode 100644 index 0000000..4d9b66f --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs @@ -0,0 +1,34 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// A bounded target-string read. +public readonly record struct MemoryStringReadRequest +{ + /// Creates a bounded text read. + public MemoryStringReadRequest(Address address, int maximumLength, bool wideCharacter = false) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumLength); + Address = address; + MaximumLength = maximumLength; + WideCharacter = wideCharacter; + } + + /// Gets the first target address. + public Address Address + { + get; + } + + /// Gets the maximum character count passed to Cheat Engine. + public int MaximumLength + { + get; + } + + /// Gets whether Cheat Engine should interpret the target as UTF-16 text. + public bool WideCharacter + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs new file mode 100644 index 0000000..c7240a9 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs @@ -0,0 +1,34 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// A copied string to write to target memory. +public readonly record struct MemoryStringWriteRequest +{ + /// Creates a text write. + public MemoryStringWriteRequest(Address address, string value, bool wideCharacter = false) + { + ArgumentNullException.ThrowIfNull(value); + Address = address; + Value = value; + WideCharacter = wideCharacter; + } + + /// Gets the first target address. + public Address Address + { + get; + } + + /// Gets the managed text copied by the request. + public string Value + { + get; + } + + /// Gets whether Cheat Engine should write UTF-16 target text. + public bool WideCharacter + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryWriteRequest.cs new file mode 100644 index 0000000..9e91b3c --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryWriteRequest.cs @@ -0,0 +1,34 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// An immutable typed target-memory write request. +public readonly record struct MemoryWriteRequest +{ + /// Creates a memory write request. + public MemoryWriteRequest(Address address, T value, IMemoryCodec codec) + { + ArgumentNullException.ThrowIfNull(codec); + Address = address; + Value = value; + Codec = codec; + } + + /// Gets the target address to write. + public Address Address + { + get; + } + + /// Gets the value to write. + public T Value + { + get; + } + + /// Gets the codec used to translate the value. + public IMemoryCodec Codec + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/PointerChainRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/PointerChainRequest.cs new file mode 100644 index 0000000..a7d2007 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/PointerChainRequest.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// A finite pointer chain whose offsets are applied after each target-aware pointer dereference. +public readonly record struct PointerChainRequest +{ + /// Creates a bounded pointer chain by copying its offsets. + public PointerChainRequest(Address baseAddress, ReadOnlySpan offsets) + { + if (offsets.IsEmpty) + { + throw new ArgumentException("A pointer chain requires at least one offset.", nameof(offsets)); + } + + if (offsets.Length > 64) + { + throw new ArgumentOutOfRangeException(nameof(offsets), "A pointer chain is limited to 64 hops."); + } + + BaseAddress = baseAddress; + Offsets = ImmutableArray.Create(offsets.ToArray()); + } + + /// Gets the address containing the first target pointer. + public Address BaseAddress + { + get; + } + + /// Gets the finite offsets, one per dereference. + public ImmutableArray Offsets + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Modules/ICheatEngineClientModule.cs b/libs/CheatEngine.Client.Abstractions/Modules/ICheatEngineClientModule.cs new file mode 100644 index 0000000..6954ab9 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Modules/ICheatEngineClientModule.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Modules; + +/// A scoped feature that participates in the Cheat Engine client lifecycle. +public interface ICheatEngineClientModule +{ + /// Runs after the client scope and Lua runtime are active. + public void OnEnabled(ICheatEngineClient client); + + /// Runs before the client scope is disposed and the Lua runtime is detached. + public void OnDisabling(ICheatEngineClient client); +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs b/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs new file mode 100644 index 0000000..efa5018 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs @@ -0,0 +1,40 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; + +namespace CheatEngine.Client.Processes; + +/// Reads the process selected by the active Cheat Engine session. +public interface IProcessClient +{ + /// Tries to get a copied snapshot of the currently selected target process. + public bool TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets the selected process or throws when no target is attached. + public ProcessSnapshot GetCurrent(CancellationToken cancellationToken = default); + + /// + /// Re-reads Cheat Engine's selected target and advances the selection epoch when its PID or observed + /// architecture changed. + /// + public bool TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Refreshes the selected target or throws when no target is attached. + public ProcessSnapshot Refresh(CancellationToken cancellationToken = default); + + /// Tries to attach Cheat Engine to an explicit process identifier and verifies the selected target. + public bool TryAttach(TargetProcessId processId, out ProcessSnapshot snapshot, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Attaches to an explicit process identifier or throws when the host rejects it. + public ProcessSnapshot Attach(TargetProcessId processId, + CancellationToken cancellationToken = default); + + /// Tries to attach to the single local process whose executable name matches exactly. + public bool TryAttachExactName(string processName, out ProcessSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Attaches to the single exact-name match or throws when none or several exist. + public ProcessSnapshot AttachExactName(string processName, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs new file mode 100644 index 0000000..c5bab29 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessSnapshot.cs @@ -0,0 +1,77 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Processes; + +/// An immutable snapshot of the process currently selected in Cheat Engine. +public readonly record struct ProcessSnapshot +{ + /// Creates a selected-process snapshot without a target-architecture observation. + public ProcessSnapshot(TargetProcessId id, string? name, string? executablePath) + : this(id, name, executablePath, CheatEngineArchitecture.Unknown, 0) + { + } + + /// Creates a selected-process snapshot from copied host observations. + public ProcessSnapshot( + TargetProcessId id, + string? name, + string? executablePath, + CheatEngineArchitecture targetArchitecture, + long selectionEpoch) + { + if (name is { Length: 0 }) + { + throw new ArgumentException("A process name must be null or non-empty.", nameof(name)); + } + + if (executablePath is { Length: 0 }) + { + throw new ArgumentException("An executable path must be null or non-empty.", nameof(executablePath)); + } + + ArgumentOutOfRangeException.ThrowIfNegative(selectionEpoch); + + Id = id; + Name = name; + ExecutablePath = executablePath; + TargetArchitecture = targetArchitecture; + SelectionEpoch = selectionEpoch; + } + + /// Gets the selected process identifier. + public TargetProcessId Id + { + get; + } + + /// Gets the process display name when the host supplied one. + public string? Name + { + get; + } + + /// Gets the executable path when the host supplied one. + public string? ExecutablePath + { + get; + } + + /// Gets the target architecture observed by Cheat Engine, or unknown when no probe established it. + public CheatEngineArchitecture TargetArchitecture + { + get; + } + + /// Gets the pointer width implied by , or unknown. + public PointerSize TargetPointerSize => PointerSize.FromArchitecture(TargetArchitecture); + + /// + /// Gets the target-selection epoch. A change means target-bound sessions and leases captured for an earlier + /// selection are no longer valid. + /// + public long SelectionEpoch + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e1099e4 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt @@ -0,0 +1,625 @@ +#nullable enable +~override CheatEngine.Client.Inspection.InspectionCollectionRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Inspection.InspectionCollectionRequest.ToString() -> string +~override CheatEngine.Client.Inspection.SymbolRegistration.Equals(object obj) -> bool +~override CheatEngine.Client.Inspection.SymbolRegistration.ToString() -> string +~override CheatEngine.Client.Lua.LuaScript.Equals(object obj) -> bool +~override CheatEngine.Client.Lua.LuaScript.ToString() -> string +~override CheatEngine.Client.Memory.MemoryBytesReadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryBytesReadRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryBytesWriteRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryBytesWriteRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryReadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryReadRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryStringReadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryStringReadRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryStringWriteRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryStringWriteRequest.ToString() -> string +~override CheatEngine.Client.Memory.MemoryWriteRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.MemoryWriteRequest.ToString() -> string +~override CheatEngine.Client.Memory.PointerChainRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Memory.PointerChainRequest.ToString() -> string +~override CheatEngine.Client.Processes.ProcessSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Processes.ProcessSnapshot.ToString() -> string +~override CheatEngine.Client.Results.CheatEngineFailure.Equals(object obj) -> bool +~override CheatEngine.Client.Results.CheatEngineFailure.ToString() -> string +~override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ToString() -> string +~override CheatEngine.Client.Runtime.ClientCapabilityAvailability.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.ClientCapabilityAvailability.ToString() -> string +~override CheatEngine.Client.Scanning.AobPattern.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobScanRange.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobScanRange.ToString() -> string +~override CheatEngine.Client.Scanning.AobScanRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobScanRequest.ToString() -> string +~override CheatEngine.Client.Scanning.AobScanResult.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.AobScanResult.ToString() -> string +~override CheatEngine.Client.Scanning.ValueScanMatch.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.ValueScanMatch.ToString() -> string +~override CheatEngine.Client.Scanning.ValueScanPage.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.ValueScanPage.ToString() -> string +~override CheatEngine.Client.Scanning.ValueScanReadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Scanning.ValueScanReadRequest.ToString() -> string +~override CheatEngine.Client.Tables.AddressTableSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.AddressTableSnapshot.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordDefinition.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordDefinition.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordSearch.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordSearch.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordSnapshot.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordUpdate.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordUpdate.ToString() -> string +~override CheatEngine.Client.Tables.TableLoadRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.TableLoadRequest.ToString() -> string +~override CheatEngine.Client.Tables.TableSaveRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.TableSaveRequest.ToString() -> string +~override CheatEngine.Client.Tables.TrustedTableFile.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.TrustedTableFile.ToString() -> string +CheatEngine.Client.Dispatching.ICheatEngineDispatcher +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.Invoke(System.Action! callback, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.Invoke(System.Func! callback, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.IsMainThread.get -> bool +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.TryInvoke(System.Action! callback, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Dispatching.ICheatEngineDispatcher.TryInvoke(System.Func! callback, out T result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.ICheatEngineClient +CheatEngine.Client.ICheatEngineClient.Dispatcher.get -> CheatEngine.Client.Dispatching.ICheatEngineDispatcher! +CheatEngine.Client.ICheatEngineClient.Epoch.get -> long +CheatEngine.Client.ICheatEngineClient.Inspection.get -> CheatEngine.Client.Inspection.IInspectionClient! +CheatEngine.Client.ICheatEngineClient.Lua.get -> CheatEngine.Client.Lua.ILuaClient! +CheatEngine.Client.ICheatEngineClient.Memory.get -> CheatEngine.Client.Memory.IMemoryClient! +CheatEngine.Client.ICheatEngineClient.Patterns.get -> CheatEngine.Client.Scanning.IPatternScanner! +CheatEngine.Client.ICheatEngineClient.Processes.get -> CheatEngine.Client.Processes.IProcessClient! +CheatEngine.Client.ICheatEngineClient.Runtime.get -> CheatEngine.Client.Runtime.ICheatEngineRuntime! +CheatEngine.Client.ICheatEngineClient.Scans.get -> CheatEngine.Client.Scanning.IValueScanner! +CheatEngine.Client.ICheatEngineClient.Stopping.get -> System.Threading.CancellationToken +CheatEngine.Client.ICheatEngineClient.Tables.get -> CheatEngine.Client.Tables.ITableClient! +CheatEngine.Client.Inspection.IInspectionClient +CheatEngine.Client.Inspection.IInspectionClient.GetMemoryRegion(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo +CheatEngine.Client.Inspection.IInspectionClient.GetMemoryRegions(CheatEngine.Client.Inspection.InspectionCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Inspection.IInspectionClient.GetModules(CheatEngine.Client.Inspection.InspectionCollectionRequest request, CheatEngine.SDK.Engine.Inspection.TargetProcessId? processId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Inspection.IInspectionClient.GetModuleSections(CheatEngine.SDK.Engine.Inspection.ModuleName moduleName, CheatEngine.Client.Inspection.InspectionCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Inspection.IInspectionClient.GetSymbol(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Inspection.SymbolInfo +CheatEngine.Client.Inspection.IInspectionClient.RegisterSymbol(CheatEngine.Client.Inspection.SymbolRegistration registration, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Inspection.ISymbolRegistrationLease! +CheatEngine.Client.Inspection.IInspectionClient.ResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Inspection.IInspectionClient.ResolveName(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Inspection.IInspectionClient.TryGetMemoryRegion(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo region, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryGetMemoryRegions(CheatEngine.Client.Inspection.InspectionCollectionRequest request, out System.Collections.Immutable.ImmutableArray regions, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryGetModules(CheatEngine.Client.Inspection.InspectionCollectionRequest request, out System.Collections.Immutable.ImmutableArray modules, out CheatEngine.Client.Results.CheatEngineFailure failure, CheatEngine.SDK.Engine.Inspection.TargetProcessId? processId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryGetModuleSections(CheatEngine.SDK.Engine.Inspection.ModuleName moduleName, CheatEngine.Client.Inspection.InspectionCollectionRequest request, out System.Collections.Immutable.ImmutableArray sections, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryGetSymbol(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, out CheatEngine.SDK.Engine.Inspection.SymbolInfo symbol, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryRegisterSymbol(CheatEngine.Client.Inspection.SymbolRegistration registration, out CheatEngine.Client.Inspection.ISymbolRegistrationLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions options, out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.IInspectionClient.TryResolveName(CheatEngine.SDK.Engine.Values.Address address, out string? name, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Inspection.InspectionCollectionRequest +CheatEngine.Client.Inspection.InspectionCollectionRequest.Equals(CheatEngine.Client.Inspection.InspectionCollectionRequest other) -> bool +CheatEngine.Client.Inspection.InspectionCollectionRequest.InspectionCollectionRequest() -> void +CheatEngine.Client.Inspection.InspectionCollectionRequest.InspectionCollectionRequest(int maximumItems) -> void +CheatEngine.Client.Inspection.InspectionCollectionRequest.MaximumItems.get -> int +CheatEngine.Client.Inspection.ISymbolRegistrationLease +CheatEngine.Client.Inspection.ISymbolRegistrationLease.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Inspection.ISymbolRegistrationLease.IsReleased.get -> bool +CheatEngine.Client.Inspection.ISymbolRegistrationLease.Name.get -> string! +CheatEngine.Client.Inspection.SymbolRegistration +CheatEngine.Client.Inspection.SymbolRegistration.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Inspection.SymbolRegistration.DoNotSave.get -> bool +CheatEngine.Client.Inspection.SymbolRegistration.Equals(CheatEngine.Client.Inspection.SymbolRegistration other) -> bool +CheatEngine.Client.Inspection.SymbolRegistration.Name.get -> string! +CheatEngine.Client.Inspection.SymbolRegistration.SymbolRegistration() -> void +CheatEngine.Client.Inspection.SymbolRegistration.SymbolRegistration(string! name, CheatEngine.SDK.Engine.Values.Address address, bool doNotSave = true) -> void +CheatEngine.Client.Lua.ILuaClient +CheatEngine.Client.Lua.ILuaClient.Execute(TOperation operation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult +CheatEngine.Client.Lua.ILuaClient.RegisterModule(CheatEngine.Client.Lua.ILuaModule! luaModule, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Lua.ILuaModuleLease! +CheatEngine.Client.Lua.ILuaClient.TryExecute(TOperation operation, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Lua.ILuaClient.TryRegisterModule(CheatEngine.Client.Lua.ILuaModule! luaModule, out CheatEngine.Client.Lua.ILuaModuleLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Lua.ILuaExecutionContext +CheatEngine.Client.Lua.ILuaExecutionContext.Epoch.get -> long +CheatEngine.Client.Lua.ILuaExecutionContext.IsActive.get -> bool +CheatEngine.Client.Lua.ILuaExecutionContext.ThrowIfExpired() -> void +CheatEngine.Client.Lua.ILuaModule +CheatEngine.Client.Lua.ILuaModule.Register() -> void +CheatEngine.Client.Lua.ILuaModule.Unregister() -> void +CheatEngine.Client.Lua.ILuaModuleLease +CheatEngine.Client.Lua.ILuaModuleLease.Epoch.get -> long +CheatEngine.Client.Lua.ILuaModuleLease.IsReleased.get -> bool +CheatEngine.Client.Lua.ILuaOperation +CheatEngine.Client.Lua.ILuaOperation.TryExecute(CheatEngine.Client.Lua.ILuaExecutionContext! context, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure) -> bool +CheatEngine.Client.Lua.IUnsafeLuaClient +CheatEngine.Client.Lua.IUnsafeLuaClient.Execute(CheatEngine.Client.Lua.LuaScript script, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Lua.IUnsafeLuaClient.TryExecute(CheatEngine.Client.Lua.LuaScript script, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Lua.LuaScript +CheatEngine.Client.Lua.LuaScript.ChunkName.get -> string? +CheatEngine.Client.Lua.LuaScript.Equals(CheatEngine.Client.Lua.LuaScript other) -> bool +CheatEngine.Client.Lua.LuaScript.LuaScript() -> void +CheatEngine.Client.Lua.LuaScript.LuaScript(string! source, string? chunkName = null) -> void +CheatEngine.Client.Lua.LuaScript.Source.get -> string! +CheatEngine.Client.Memory.IMemoryClient +CheatEngine.Client.Memory.IMemoryClient.Read(CheatEngine.Client.Memory.MemoryReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Memory.IMemoryClient.ReadBytes(CheatEngine.Client.Memory.MemoryBytesReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.IMemoryClient.ReadPrimitive(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T +CheatEngine.Client.Memory.IMemoryClient.ReadString(CheatEngine.Client.Memory.MemoryStringReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Memory.IMemoryClient.ResolvePointerChain(CheatEngine.Client.Memory.PointerChainRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.IMemoryClient.TryRead(CheatEngine.Client.Memory.MemoryReadRequest request, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryReadBytes(CheatEngine.Client.Memory.MemoryBytesReadRequest request, out System.Collections.Immutable.ImmutableArray bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryReadPrimitive(CheatEngine.SDK.Engine.Values.Address address, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryReadString(CheatEngine.Client.Memory.MemoryStringReadRequest request, out string? value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryResolvePointerChain(CheatEngine.Client.Memory.PointerChainRequest request, out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWrite(CheatEngine.Client.Memory.MemoryWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWriteBytes(CheatEngine.Client.Memory.MemoryBytesWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWritePrimitive(CheatEngine.SDK.Engine.Values.Address address, T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWriteString(CheatEngine.Client.Memory.MemoryStringWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.Write(CheatEngine.Client.Memory.MemoryWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryClient.WriteBytes(CheatEngine.Client.Memory.MemoryBytesWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryClient.WritePrimitive(CheatEngine.SDK.Engine.Values.Address address, T value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryClient.WriteString(CheatEngine.Client.Memory.MemoryStringWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.IMemoryCodec +CheatEngine.Client.Memory.IMemoryCodec.TryRead(CheatEngine.Client.Memory.IMemoryReadContext! context, CheatEngine.SDK.Engine.Values.Address address, out T value) -> bool +CheatEngine.Client.Memory.IMemoryCodec.TryWrite(CheatEngine.Client.Memory.IMemoryWriteContext! context, CheatEngine.SDK.Engine.Values.Address address, in T value) -> bool +CheatEngine.Client.Memory.IMemoryReadContext +CheatEngine.Client.Memory.IMemoryReadContext.PointerSize.get -> int +CheatEngine.Client.Memory.IMemoryReadContext.TryReadBytes(CheatEngine.SDK.Engine.Values.Address address, System.Span destination) -> bool +CheatEngine.Client.Memory.IMemoryWriteContext +CheatEngine.Client.Memory.IMemoryWriteContext.PointerSize.get -> int +CheatEngine.Client.Memory.IMemoryWriteContext.TryWriteBytes(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan source) -> bool +CheatEngine.Client.Memory.MemoryBytesReadRequest +CheatEngine.Client.Memory.MemoryBytesReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryBytesReadRequest.Equals(CheatEngine.Client.Memory.MemoryBytesReadRequest other) -> bool +CheatEngine.Client.Memory.MemoryBytesReadRequest.Length.get -> int +CheatEngine.Client.Memory.MemoryBytesReadRequest.MemoryBytesReadRequest() -> void +CheatEngine.Client.Memory.MemoryBytesReadRequest.MemoryBytesReadRequest(CheatEngine.SDK.Engine.Values.Address address, int length) -> void +CheatEngine.Client.Memory.MemoryBytesWriteRequest +CheatEngine.Client.Memory.MemoryBytesWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryBytesWriteRequest.Bytes.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.MemoryBytesWriteRequest.Equals(CheatEngine.Client.Memory.MemoryBytesWriteRequest other) -> bool +CheatEngine.Client.Memory.MemoryBytesWriteRequest.MemoryBytesWriteRequest() -> void +CheatEngine.Client.Memory.MemoryBytesWriteRequest.MemoryBytesWriteRequest(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan bytes) -> void +CheatEngine.Client.Memory.MemoryReadRequest +CheatEngine.Client.Memory.MemoryReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryReadRequest.Codec.get -> CheatEngine.Client.Memory.IMemoryCodec! +CheatEngine.Client.Memory.MemoryReadRequest.Equals(CheatEngine.Client.Memory.MemoryReadRequest other) -> bool +CheatEngine.Client.Memory.MemoryReadRequest.MemoryReadRequest() -> void +CheatEngine.Client.Memory.MemoryReadRequest.MemoryReadRequest(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.Client.Memory.IMemoryCodec! codec) -> void +CheatEngine.Client.Memory.MemoryStringReadRequest +CheatEngine.Client.Memory.MemoryStringReadRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryStringReadRequest.Equals(CheatEngine.Client.Memory.MemoryStringReadRequest other) -> bool +CheatEngine.Client.Memory.MemoryStringReadRequest.MaximumLength.get -> int +CheatEngine.Client.Memory.MemoryStringReadRequest.MemoryStringReadRequest() -> void +CheatEngine.Client.Memory.MemoryStringReadRequest.MemoryStringReadRequest(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, bool wideCharacter = false) -> void +CheatEngine.Client.Memory.MemoryStringReadRequest.WideCharacter.get -> bool +CheatEngine.Client.Memory.MemoryStringWriteRequest +CheatEngine.Client.Memory.MemoryStringWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryStringWriteRequest.Equals(CheatEngine.Client.Memory.MemoryStringWriteRequest other) -> bool +CheatEngine.Client.Memory.MemoryStringWriteRequest.MemoryStringWriteRequest() -> void +CheatEngine.Client.Memory.MemoryStringWriteRequest.MemoryStringWriteRequest(CheatEngine.SDK.Engine.Values.Address address, string! value, bool wideCharacter = false) -> void +CheatEngine.Client.Memory.MemoryStringWriteRequest.Value.get -> string! +CheatEngine.Client.Memory.MemoryStringWriteRequest.WideCharacter.get -> bool +CheatEngine.Client.Memory.MemoryWriteRequest +CheatEngine.Client.Memory.MemoryWriteRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryWriteRequest.Codec.get -> CheatEngine.Client.Memory.IMemoryCodec! +CheatEngine.Client.Memory.MemoryWriteRequest.Equals(CheatEngine.Client.Memory.MemoryWriteRequest other) -> bool +CheatEngine.Client.Memory.MemoryWriteRequest.MemoryWriteRequest() -> void +CheatEngine.Client.Memory.MemoryWriteRequest.MemoryWriteRequest(CheatEngine.SDK.Engine.Values.Address address, T value, CheatEngine.Client.Memory.IMemoryCodec! codec) -> void +CheatEngine.Client.Memory.MemoryWriteRequest.Value.get -> T +CheatEngine.Client.Memory.PointerChainRequest +CheatEngine.Client.Memory.PointerChainRequest.BaseAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.PointerChainRequest.Equals(CheatEngine.Client.Memory.PointerChainRequest other) -> bool +CheatEngine.Client.Memory.PointerChainRequest.Offsets.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.PointerChainRequest.PointerChainRequest() -> void +CheatEngine.Client.Memory.PointerChainRequest.PointerChainRequest(CheatEngine.SDK.Engine.Values.Address baseAddress, System.ReadOnlySpan offsets) -> void +CheatEngine.Client.Modules.ICheatEngineClientModule +CheatEngine.Client.Modules.ICheatEngineClientModule.OnDisabling(CheatEngine.Client.ICheatEngineClient! client) -> void +CheatEngine.Client.Modules.ICheatEngineClientModule.OnEnabled(CheatEngine.Client.ICheatEngineClient! client) -> void +CheatEngine.Client.Processes.IProcessClient +CheatEngine.Client.Processes.IProcessClient.Attach(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.AttachExactName(string! processName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.GetCurrent(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.Refresh(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.TryAttach(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryAttachExactName(string! processName, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryGetCurrent(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryRefresh(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.ProcessSnapshot.Equals(CheatEngine.Client.Processes.ProcessSnapshot other) -> bool +CheatEngine.Client.Processes.ProcessSnapshot.ExecutablePath.get -> string? +CheatEngine.Client.Processes.ProcessSnapshot.Id.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.Client.Processes.ProcessSnapshot.Name.get -> string? +CheatEngine.Client.Processes.ProcessSnapshot.ProcessSnapshot() -> void +CheatEngine.Client.Processes.ProcessSnapshot.ProcessSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId id, string? name, string? executablePath, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, long selectionEpoch) -> void +CheatEngine.Client.Processes.ProcessSnapshot.ProcessSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId id, string? name, string? executablePath) -> void +CheatEngine.Client.Processes.ProcessSnapshot.SelectionEpoch.get -> long +CheatEngine.Client.Processes.ProcessSnapshot.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.Client.Processes.ProcessSnapshot.TargetPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Results.CheatEngineActivationExpiredException +CheatEngine.Client.Results.CheatEngineActivationExpiredException.CheatEngineActivationExpiredException(string! operation, string! message, System.Exception? innerException = null) -> void +CheatEngine.Client.Results.CheatEngineClientException +CheatEngine.Client.Results.CheatEngineClientException.CheatEngineClientException(CheatEngine.Client.Results.CheatEngineFailure failure) -> void +CheatEngine.Client.Results.CheatEngineClientException.Failure.get -> CheatEngine.Client.Results.CheatEngineFailure +CheatEngine.Client.Results.CheatEngineClientLifecycleException +CheatEngine.Client.Results.CheatEngineClientLifecycleException.CheatEngineClientLifecycleException(string! operation, string! message, System.Exception? innerException = null) -> void +CheatEngine.Client.Results.CheatEngineFailure +CheatEngine.Client.Results.CheatEngineFailure.CheatEngineFailure() -> void +CheatEngine.Client.Results.CheatEngineFailure.CheatEngineFailure(CheatEngine.Client.Results.CheatEngineFailureKind kind, string! operation, string! message, System.Exception? exception = null) -> void +CheatEngine.Client.Results.CheatEngineFailure.Equals(CheatEngine.Client.Results.CheatEngineFailure other) -> bool +CheatEngine.Client.Results.CheatEngineFailure.Exception.get -> System.Exception? +CheatEngine.Client.Results.CheatEngineFailure.Kind.get -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailure.Message.get -> string! +CheatEngine.Client.Results.CheatEngineFailure.Operation.get -> string! +CheatEngine.Client.Results.CheatEngineFailure.Throw() -> void +CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.ActivationExpired = 14 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.AmbiguousMatch = 5 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.BindingError = 8 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.Cancelled = 1 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.CapabilityUnavailable = 2 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.InvalidHostResult = 9 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.InvalidState = 15 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.LuaError = 7 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.MemoryReadFailed = 12 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.MemoryWriteFailed = 13 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.NotFound = 4 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.OperationRejected = 3 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.ResultLimitExceeded = 6 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.TargetNotAttached = 11 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.Unknown = 0 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineFailureKind.Unsupported = 10 -> CheatEngine.Client.Results.CheatEngineFailureKind +CheatEngine.Client.Results.CheatEngineOperationException +CheatEngine.Client.Results.CheatEngineOperationException.CheatEngineOperationException(CheatEngine.Client.Results.CheatEngineFailure failure) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot() -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot(long epoch, double? observedCheatEngineVersion, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion qualifiedCheatEngineBaseline, System.Version! clientAssemblyVersion, System.Version! sdkAssemblyVersion, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture systemArchitecture, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, CheatEngine.SDK.Engine.Runtime.PointerSize targetPointerSize, CheatEngine.SDK.Engine.Runtime.TargetAbi targetAbi, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! sdkCapabilities, CheatEngine.Client.Runtime.ClientCapabilities! clientCapabilities) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ClientAssemblyVersion.get -> System.Version! +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ClientCapabilities.get -> CheatEngine.Client.Runtime.ClientCapabilities! +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Epoch.get -> long +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot other) -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.IsOnQualifiedCheatEngineLine.get -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ObservedCheatEngineVersion.get -> double? +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.QualifiedCheatEngineBaseline.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.SdkAssemblyVersion.get -> System.Version! +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.SdkCapabilities.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Runtime.ClientCapabilities +CheatEngine.Client.Runtime.ClientCapabilities.Count.get -> int +CheatEngine.Client.Runtime.ClientCapabilities.Entries.get -> System.ReadOnlySpan +CheatEngine.Client.Runtime.ClientCapabilities.Equals(CheatEngine.Client.Runtime.ClientCapabilities? other) -> bool +CheatEngine.Client.Runtime.ClientCapabilities.TryGet(CheatEngine.Client.Runtime.ClientCapabilityId capability, out CheatEngine.Client.Runtime.ClientCapabilityAvailability availability) -> bool +CheatEngine.Client.Runtime.ClientCapabilityAvailability +CheatEngine.Client.Runtime.ClientCapabilityAvailability.Capability.get -> CheatEngine.Client.Runtime.ClientCapabilityId +CheatEngine.Client.Runtime.ClientCapabilityAvailability.ClientCapabilityAvailability() -> void +CheatEngine.Client.Runtime.ClientCapabilityAvailability.ClientCapabilityAvailability(CheatEngine.Client.Runtime.ClientCapabilityId capability, CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState state, string! reason) -> void +CheatEngine.Client.Runtime.ClientCapabilityAvailability.Equals(CheatEngine.Client.Runtime.ClientCapabilityAvailability other) -> bool +CheatEngine.Client.Runtime.ClientCapabilityAvailability.IsAvailable.get -> bool +CheatEngine.Client.Runtime.ClientCapabilityAvailability.IsKnown.get -> bool +CheatEngine.Client.Runtime.ClientCapabilityAvailability.Reason.get -> string! +CheatEngine.Client.Runtime.ClientCapabilityAvailability.State.get -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Available = 1 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Unavailable = 2 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState.Unknown = 0 -> CheatEngine.Client.Runtime.ClientCapabilityAvailabilityState +CheatEngine.Client.Runtime.ClientCapabilityId +CheatEngine.Client.Runtime.ClientCapabilityId.ClientCapabilityId() -> void +CheatEngine.Client.Runtime.ClientCapabilityId.ClientCapabilityId(string! value) -> void +CheatEngine.Client.Runtime.ClientCapabilityId.Equals(CheatEngine.Client.Runtime.ClientCapabilityId other) -> bool +CheatEngine.Client.Runtime.ClientCapabilityId.IsEmpty.get -> bool +CheatEngine.Client.Runtime.ClientCapabilityId.Value.get -> string! +CheatEngine.Client.Runtime.ICheatEngineRuntime +CheatEngine.Client.Runtime.ICheatEngineRuntime.Epoch.get -> long +CheatEngine.Client.Runtime.ICheatEngineRuntime.GetClientCapability(CheatEngine.Client.Runtime.ClientCapabilityId capability, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Runtime.ClientCapabilityAvailability +CheatEngine.Client.Runtime.ICheatEngineRuntime.GetSdkCapability(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId capability, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability +CheatEngine.Client.Runtime.ICheatEngineRuntime.GetSnapshot(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot +CheatEngine.Client.Runtime.ICheatEngineRuntime.TryGetClientCapability(CheatEngine.Client.Runtime.ClientCapabilityId capability, out CheatEngine.Client.Runtime.ClientCapabilityAvailability availability, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Runtime.ICheatEngineRuntime.TryGetSdkCapability(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId capability, out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability availability, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Runtime.ICheatEngineRuntime.TryGetSnapshot(out CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.AobPattern +CheatEngine.Client.Scanning.AobPattern.AobPattern() -> void +CheatEngine.Client.Scanning.AobPattern.AobPattern(string! value) -> void +CheatEngine.Client.Scanning.AobPattern.ByteLength.get -> int +CheatEngine.Client.Scanning.AobPattern.Equals(CheatEngine.Client.Scanning.AobPattern other) -> bool +CheatEngine.Client.Scanning.AobPattern.IsWildcardOnly.get -> bool +CheatEngine.Client.Scanning.AobPattern.Value.get -> string! +CheatEngine.Client.Scanning.AobScanRange +CheatEngine.Client.Scanning.AobScanRange.AobScanRange() -> void +CheatEngine.Client.Scanning.AobScanRange.AobScanRange(CheatEngine.SDK.Engine.Values.Address start, CheatEngine.SDK.Engine.Values.Address end) -> void +CheatEngine.Client.Scanning.AobScanRange.Contains(CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.Client.Scanning.AobScanRange.End.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Scanning.AobScanRange.Equals(CheatEngine.Client.Scanning.AobScanRange other) -> bool +CheatEngine.Client.Scanning.AobScanRange.Start.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Scanning.AobScanRequest +CheatEngine.Client.Scanning.AobScanRequest.AobScanRequest() -> void +CheatEngine.Client.Scanning.AobScanRequest.AobScanRequest(CheatEngine.Client.Scanning.AobPattern pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, int maximumResults, CheatEngine.SDK.Engine.Inspection.ModuleName? module = null, CheatEngine.Client.Scanning.AobScanRange? range = null) -> void +CheatEngine.Client.Scanning.AobScanRequest.Equals(CheatEngine.Client.Scanning.AobScanRequest other) -> bool +CheatEngine.Client.Scanning.AobScanRequest.MaximumResults.get -> int +CheatEngine.Client.Scanning.AobScanRequest.Module.get -> CheatEngine.SDK.Engine.Inspection.ModuleName? +CheatEngine.Client.Scanning.AobScanRequest.Options.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions +CheatEngine.Client.Scanning.AobScanRequest.Pattern.get -> CheatEngine.Client.Scanning.AobPattern +CheatEngine.Client.Scanning.AobScanRequest.Range.get -> CheatEngine.Client.Scanning.AobScanRange? +CheatEngine.Client.Scanning.AobScanResult +CheatEngine.Client.Scanning.AobScanResult.AobScanResult() -> void +CheatEngine.Client.Scanning.AobScanResult.AobScanResult(System.Collections.Immutable.ImmutableArray matches, bool isTruncated) -> void +CheatEngine.Client.Scanning.AobScanResult.Equals(CheatEngine.Client.Scanning.AobScanResult other) -> bool +CheatEngine.Client.Scanning.AobScanResult.IsTruncated.get -> bool +CheatEngine.Client.Scanning.AobScanResult.Matches.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Scanning.IPatternScanner +CheatEngine.Client.Scanning.IPatternScanner.Scan(CheatEngine.Client.Scanning.AobScanRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.AobScanResult +CheatEngine.Client.Scanning.IPatternScanner.TryScan(CheatEngine.Client.Scanning.AobScanRequest request, out CheatEngine.Client.Scanning.AobScanResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.IValueScanner +CheatEngine.Client.Scanning.IValueScanner.CreateSession(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.IValueScanSession! +CheatEngine.Client.Scanning.IValueScanner.TryCreateSession(out CheatEngine.Client.Scanning.IValueScanSession? session, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.IValueScanSession +CheatEngine.Client.Scanning.IValueScanSession.GetResultCount(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ulong +CheatEngine.Client.Scanning.IValueScanSession.Read(CheatEngine.Client.Scanning.ValueScanReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.ValueScanPage +CheatEngine.Client.Scanning.IValueScanSession.Reset(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Scanning.IValueScanSession.RunNextScan(CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Scanning.IValueScanSession.Start(CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Scanning.IValueScanSession.State.get -> CheatEngine.Client.Scanning.ValueScanSessionState +CheatEngine.Client.Scanning.IValueScanSession.TryGetResultCount(out ulong resultCount, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.IValueScanSession.TryRead(CheatEngine.Client.Scanning.ValueScanReadRequest request, out CheatEngine.Client.Scanning.ValueScanPage page, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.IValueScanSession.TryReset(out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.IValueScanSession.TryRunNextScan(CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.IValueScanSession.TryStart(CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Scanning.ValueScanMatch +CheatEngine.Client.Scanning.ValueScanMatch.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Scanning.ValueScanMatch.Equals(CheatEngine.Client.Scanning.ValueScanMatch other) -> bool +CheatEngine.Client.Scanning.ValueScanMatch.Index.get -> int +CheatEngine.Client.Scanning.ValueScanMatch.Value.get -> string! +CheatEngine.Client.Scanning.ValueScanMatch.ValueScanMatch() -> void +CheatEngine.Client.Scanning.ValueScanMatch.ValueScanMatch(int index, CheatEngine.SDK.Engine.Values.Address address, string! value) -> void +CheatEngine.Client.Scanning.ValueScanPage +CheatEngine.Client.Scanning.ValueScanPage.Equals(CheatEngine.Client.Scanning.ValueScanPage other) -> bool +CheatEngine.Client.Scanning.ValueScanPage.Matches.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Scanning.ValueScanPage.TotalCount.get -> ulong +CheatEngine.Client.Scanning.ValueScanPage.ValueScanPage() -> void +CheatEngine.Client.Scanning.ValueScanPage.ValueScanPage(ulong totalCount, System.Collections.Immutable.ImmutableArray matches) -> void +CheatEngine.Client.Scanning.ValueScanReadRequest +CheatEngine.Client.Scanning.ValueScanReadRequest.Equals(CheatEngine.Client.Scanning.ValueScanReadRequest other) -> bool +CheatEngine.Client.Scanning.ValueScanReadRequest.MaximumCount.get -> int +CheatEngine.Client.Scanning.ValueScanReadRequest.StartIndex.get -> int +CheatEngine.Client.Scanning.ValueScanReadRequest.ValueScanReadRequest() -> void +CheatEngine.Client.Scanning.ValueScanReadRequest.ValueScanReadRequest(int startIndex, int maximumCount) -> void +CheatEngine.Client.Scanning.ValueScanSessionState +CheatEngine.Client.Scanning.ValueScanSessionState.Created = 0 -> CheatEngine.Client.Scanning.ValueScanSessionState +CheatEngine.Client.Scanning.ValueScanSessionState.Disposed = 4 -> CheatEngine.Client.Scanning.ValueScanSessionState +CheatEngine.Client.Scanning.ValueScanSessionState.Invalidated = 3 -> CheatEngine.Client.Scanning.ValueScanSessionState +CheatEngine.Client.Scanning.ValueScanSessionState.ResultsReady = 2 -> CheatEngine.Client.Scanning.ValueScanSessionState +CheatEngine.Client.Scanning.ValueScanSessionState.Scanning = 1 -> CheatEngine.Client.Scanning.ValueScanSessionState +CheatEngine.Client.Tables.AddressTableSnapshot +CheatEngine.Client.Tables.AddressTableSnapshot.AddressTableSnapshot() -> void +CheatEngine.Client.Tables.AddressTableSnapshot.AddressTableSnapshot(int recordCount) -> void +CheatEngine.Client.Tables.AddressTableSnapshot.AddressTableSnapshot(System.Collections.Immutable.ImmutableArray records) -> void +CheatEngine.Client.Tables.AddressTableSnapshot.Equals(CheatEngine.Client.Tables.AddressTableSnapshot other) -> bool +CheatEngine.Client.Tables.AddressTableSnapshot.RecordCount.get -> int +CheatEngine.Client.Tables.AddressTableSnapshot.Records.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Tables.ITableClient +CheatEngine.Client.Tables.ITableClient.Create(CheatEngine.Client.Tables.MemoryRecordDefinition definition, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.Delete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Tables.ITableClient.Find(CheatEngine.Client.Tables.MemoryRecordSearch search, CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Tables.ITableClient.GetCurrent(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.AddressTableSnapshot +CheatEngine.Client.Tables.ITableClient.GetHierarchy(CheatEngine.SDK.Engine.AddressList.MemoryRecordId rootId, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot +CheatEngine.Client.Tables.ITableClient.GetRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.GetRecord(int index, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.GetSelected(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.GetSnapshot(CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.AddressTableSnapshot +CheatEngine.Client.Tables.ITableClient.LoadTrustedTable(CheatEngine.Client.Tables.TableLoadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Tables.ITableClient.SaveTable(CheatEngine.Client.Tables.TableSaveRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Tables.ITableClient.SelectRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.SetActive(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, bool isActive, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.SetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId childId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.ITableClient.TryCreate(CheatEngine.Client.Tables.MemoryRecordDefinition definition, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryDelete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryFind(CheatEngine.Client.Tables.MemoryRecordSearch search, CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, out System.Collections.Immutable.ImmutableArray records, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetCurrent(out CheatEngine.Client.Tables.AddressTableSnapshot table, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetHierarchy(CheatEngine.SDK.Engine.AddressList.MemoryRecordId rootId, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest request, out CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot hierarchy, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetRecord(int index, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetSelected(out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryGetSnapshot(CheatEngine.Client.Tables.MemoryRecordCollectionRequest request, out CheatEngine.Client.Tables.AddressTableSnapshot table, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryLoadTrustedTable(CheatEngine.Client.Tables.TableLoadRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TrySaveTable(CheatEngine.Client.Tables.TableSaveRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TrySelect(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TrySetActive(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, bool isActive, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TrySetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId childId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.TryUpdate(CheatEngine.Client.Tables.MemoryRecordUpdate update, out CheatEngine.Client.Tables.MemoryRecordSnapshot record, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Tables.ITableClient.Update(CheatEngine.Client.Tables.MemoryRecordUpdate update, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.MemoryRecordCollectionRequest +CheatEngine.Client.Tables.MemoryRecordCollectionRequest.Equals(CheatEngine.Client.Tables.MemoryRecordCollectionRequest other) -> bool +CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MaximumItems.get -> int +CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MemoryRecordCollectionRequest() -> void +CheatEngine.Client.Tables.MemoryRecordCollectionRequest.MemoryRecordCollectionRequest(int maximumItems) -> void +CheatEngine.Client.Tables.MemoryRecordDefinition +CheatEngine.Client.Tables.MemoryRecordDefinition.AddressExpression.get -> string! +CheatEngine.Client.Tables.MemoryRecordDefinition.Description.get -> string! +CheatEngine.Client.Tables.MemoryRecordDefinition.Equals(CheatEngine.Client.Tables.MemoryRecordDefinition other) -> bool +CheatEngine.Client.Tables.MemoryRecordDefinition.MemoryRecordDefinition() -> void +CheatEngine.Client.Tables.MemoryRecordDefinition.MemoryRecordDefinition(string! description, string! addressExpression, string! value, CheatEngine.SDK.Engine.Enums.VariableType variableType, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId = null) -> void +CheatEngine.Client.Tables.MemoryRecordDefinition.ParentId.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordId? +CheatEngine.Client.Tables.MemoryRecordDefinition.Value.get -> string! +CheatEngine.Client.Tables.MemoryRecordDefinition.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.Equals(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest other) -> bool +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MaximumDepth.get -> int +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MaximumItems.get -> int +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MemoryRecordHierarchyRequest() -> void +CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.MemoryRecordHierarchyRequest(int maximumItems, int maximumDepth) -> void +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Children.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Children.init -> void +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Deconstruct(out CheatEngine.Client.Tables.MemoryRecordSnapshot Record, out System.Collections.Immutable.ImmutableArray Children) -> void +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot other) -> bool +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.MemoryRecordHierarchySnapshot() -> void +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.MemoryRecordHierarchySnapshot(CheatEngine.Client.Tables.MemoryRecordSnapshot Record, System.Collections.Immutable.ImmutableArray Children) -> void +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Record.get -> CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.Record.init -> void +CheatEngine.Client.Tables.MemoryRecordSearch +CheatEngine.Client.Tables.MemoryRecordSearch.AddressExpression.get -> string? +CheatEngine.Client.Tables.MemoryRecordSearch.DescriptionContains.get -> string? +CheatEngine.Client.Tables.MemoryRecordSearch.Equals(CheatEngine.Client.Tables.MemoryRecordSearch other) -> bool +CheatEngine.Client.Tables.MemoryRecordSearch.IsActive.get -> bool? +CheatEngine.Client.Tables.MemoryRecordSearch.MemoryRecordSearch() -> void +CheatEngine.Client.Tables.MemoryRecordSearch.MemoryRecordSearch(string? descriptionContains = null, string? addressExpression = null, CheatEngine.SDK.Engine.Enums.VariableType? variableType = null, bool? isActive = null) -> void +CheatEngine.Client.Tables.MemoryRecordSearch.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType? +CheatEngine.Client.Tables.MemoryRecordSnapshot +CheatEngine.Client.Tables.MemoryRecordSnapshot.AddressExpression.get -> string! +CheatEngine.Client.Tables.MemoryRecordSnapshot.ChildCount.get -> int +CheatEngine.Client.Tables.MemoryRecordSnapshot.CurrentAddress.get -> CheatEngine.SDK.Engine.Values.Address? +CheatEngine.Client.Tables.MemoryRecordSnapshot.Description.get -> string! +CheatEngine.Client.Tables.MemoryRecordSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordSnapshot other) -> bool +CheatEngine.Client.Tables.MemoryRecordSnapshot.Id.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordId +CheatEngine.Client.Tables.MemoryRecordSnapshot.Index.get -> int +CheatEngine.Client.Tables.MemoryRecordSnapshot.IsActive.get -> bool +CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot() -> void +CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, int index, string! description, string! addressExpression, string! value, CheatEngine.SDK.Engine.Enums.VariableType variableType, CheatEngine.SDK.Engine.Values.Address? currentAddress, bool isActive = false, int childCount = 0) -> void +CheatEngine.Client.Tables.MemoryRecordSnapshot.Value.get -> string! +CheatEngine.Client.Tables.MemoryRecordSnapshot.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.Client.Tables.MemoryRecordUpdate +CheatEngine.Client.Tables.MemoryRecordUpdate.AddressExpression.get -> string? +CheatEngine.Client.Tables.MemoryRecordUpdate.Description.get -> string? +CheatEngine.Client.Tables.MemoryRecordUpdate.Equals(CheatEngine.Client.Tables.MemoryRecordUpdate other) -> bool +CheatEngine.Client.Tables.MemoryRecordUpdate.Id.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordId +CheatEngine.Client.Tables.MemoryRecordUpdate.MemoryRecordUpdate() -> void +CheatEngine.Client.Tables.MemoryRecordUpdate.MemoryRecordUpdate(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, string? description = null, string? addressExpression = null, string? value = null, CheatEngine.SDK.Engine.Enums.VariableType? variableType = null) -> void +CheatEngine.Client.Tables.MemoryRecordUpdate.Value.get -> string? +CheatEngine.Client.Tables.MemoryRecordUpdate.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType? +CheatEngine.Client.Tables.TableLoadRequest +CheatEngine.Client.Tables.TableLoadRequest.Deconstruct(out CheatEngine.Client.Tables.TrustedTableFile File, out bool Merge) -> void +CheatEngine.Client.Tables.TableLoadRequest.Equals(CheatEngine.Client.Tables.TableLoadRequest other) -> bool +CheatEngine.Client.Tables.TableLoadRequest.File.get -> CheatEngine.Client.Tables.TrustedTableFile +CheatEngine.Client.Tables.TableLoadRequest.File.init -> void +CheatEngine.Client.Tables.TableLoadRequest.Merge.get -> bool +CheatEngine.Client.Tables.TableLoadRequest.Merge.init -> void +CheatEngine.Client.Tables.TableLoadRequest.TableLoadRequest() -> void +CheatEngine.Client.Tables.TableLoadRequest.TableLoadRequest(CheatEngine.Client.Tables.TrustedTableFile File, bool Merge = false) -> void +CheatEngine.Client.Tables.TableSaveRequest +CheatEngine.Client.Tables.TableSaveRequest.Deconstruct(out CheatEngine.Client.Tables.TrustedTableFile File) -> void +CheatEngine.Client.Tables.TableSaveRequest.Equals(CheatEngine.Client.Tables.TableSaveRequest other) -> bool +CheatEngine.Client.Tables.TableSaveRequest.File.get -> CheatEngine.Client.Tables.TrustedTableFile +CheatEngine.Client.Tables.TableSaveRequest.File.init -> void +CheatEngine.Client.Tables.TableSaveRequest.TableSaveRequest() -> void +CheatEngine.Client.Tables.TableSaveRequest.TableSaveRequest(CheatEngine.Client.Tables.TrustedTableFile File) -> void +CheatEngine.Client.Tables.TrustedTableFile +CheatEngine.Client.Tables.TrustedTableFile.Equals(CheatEngine.Client.Tables.TrustedTableFile other) -> bool +CheatEngine.Client.Tables.TrustedTableFile.FullPath.get -> string! +CheatEngine.Client.Tables.TrustedTableFile.TrustedTableFile() -> void +CheatEngine.Client.Tables.TrustedTableFile.TrustedTableFile(string! path) -> void +override CheatEngine.Client.Inspection.InspectionCollectionRequest.GetHashCode() -> int +override CheatEngine.Client.Inspection.SymbolRegistration.GetHashCode() -> int +override CheatEngine.Client.Lua.LuaScript.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryBytesReadRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryBytesWriteRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryReadRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryStringReadRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryStringWriteRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.MemoryWriteRequest.GetHashCode() -> int +override CheatEngine.Client.Memory.PointerChainRequest.GetHashCode() -> int +override CheatEngine.Client.Processes.ProcessSnapshot.GetHashCode() -> int +override CheatEngine.Client.Results.CheatEngineFailure.GetHashCode() -> int +override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.GetHashCode() -> int +override CheatEngine.Client.Runtime.ClientCapabilities.Equals(object? obj) -> bool +override CheatEngine.Client.Runtime.ClientCapabilities.GetHashCode() -> int +override CheatEngine.Client.Runtime.ClientCapabilityAvailability.GetHashCode() -> int +override CheatEngine.Client.Runtime.ClientCapabilityId.Equals(object? obj) -> bool +override CheatEngine.Client.Runtime.ClientCapabilityId.GetHashCode() -> int +override CheatEngine.Client.Runtime.ClientCapabilityId.ToString() -> string! +override CheatEngine.Client.Scanning.AobPattern.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobPattern.ToString() -> string! +override CheatEngine.Client.Scanning.AobScanRange.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobScanRequest.GetHashCode() -> int +override CheatEngine.Client.Scanning.AobScanResult.GetHashCode() -> int +override CheatEngine.Client.Scanning.ValueScanMatch.GetHashCode() -> int +override CheatEngine.Client.Scanning.ValueScanPage.GetHashCode() -> int +override CheatEngine.Client.Scanning.ValueScanReadRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.AddressTableSnapshot.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordCollectionRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordDefinition.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordSearch.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordSnapshot.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordUpdate.GetHashCode() -> int +override CheatEngine.Client.Tables.TableLoadRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.TableSaveRequest.GetHashCode() -> int +override CheatEngine.Client.Tables.TrustedTableFile.GetHashCode() -> int +static CheatEngine.Client.Inspection.InspectionCollectionRequest.operator !=(CheatEngine.Client.Inspection.InspectionCollectionRequest left, CheatEngine.Client.Inspection.InspectionCollectionRequest right) -> bool +static CheatEngine.Client.Inspection.InspectionCollectionRequest.operator ==(CheatEngine.Client.Inspection.InspectionCollectionRequest left, CheatEngine.Client.Inspection.InspectionCollectionRequest right) -> bool +static CheatEngine.Client.Inspection.SymbolRegistration.operator !=(CheatEngine.Client.Inspection.SymbolRegistration left, CheatEngine.Client.Inspection.SymbolRegistration right) -> bool +static CheatEngine.Client.Inspection.SymbolRegistration.operator ==(CheatEngine.Client.Inspection.SymbolRegistration left, CheatEngine.Client.Inspection.SymbolRegistration right) -> bool +static CheatEngine.Client.Lua.LuaScript.operator !=(CheatEngine.Client.Lua.LuaScript left, CheatEngine.Client.Lua.LuaScript right) -> bool +static CheatEngine.Client.Lua.LuaScript.operator ==(CheatEngine.Client.Lua.LuaScript left, CheatEngine.Client.Lua.LuaScript right) -> bool +static CheatEngine.Client.Memory.MemoryBytesReadRequest.operator !=(CheatEngine.Client.Memory.MemoryBytesReadRequest left, CheatEngine.Client.Memory.MemoryBytesReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryBytesReadRequest.operator ==(CheatEngine.Client.Memory.MemoryBytesReadRequest left, CheatEngine.Client.Memory.MemoryBytesReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryBytesWriteRequest.operator !=(CheatEngine.Client.Memory.MemoryBytesWriteRequest left, CheatEngine.Client.Memory.MemoryBytesWriteRequest right) -> bool +static CheatEngine.Client.Memory.MemoryBytesWriteRequest.operator ==(CheatEngine.Client.Memory.MemoryBytesWriteRequest left, CheatEngine.Client.Memory.MemoryBytesWriteRequest right) -> bool +static CheatEngine.Client.Memory.MemoryReadRequest.operator !=(CheatEngine.Client.Memory.MemoryReadRequest left, CheatEngine.Client.Memory.MemoryReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryReadRequest.operator ==(CheatEngine.Client.Memory.MemoryReadRequest left, CheatEngine.Client.Memory.MemoryReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryStringReadRequest.operator !=(CheatEngine.Client.Memory.MemoryStringReadRequest left, CheatEngine.Client.Memory.MemoryStringReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryStringReadRequest.operator ==(CheatEngine.Client.Memory.MemoryStringReadRequest left, CheatEngine.Client.Memory.MemoryStringReadRequest right) -> bool +static CheatEngine.Client.Memory.MemoryStringWriteRequest.operator !=(CheatEngine.Client.Memory.MemoryStringWriteRequest left, CheatEngine.Client.Memory.MemoryStringWriteRequest right) -> bool +static CheatEngine.Client.Memory.MemoryStringWriteRequest.operator ==(CheatEngine.Client.Memory.MemoryStringWriteRequest left, CheatEngine.Client.Memory.MemoryStringWriteRequest right) -> bool +static CheatEngine.Client.Memory.MemoryWriteRequest.operator !=(CheatEngine.Client.Memory.MemoryWriteRequest left, CheatEngine.Client.Memory.MemoryWriteRequest right) -> bool +static CheatEngine.Client.Memory.MemoryWriteRequest.operator ==(CheatEngine.Client.Memory.MemoryWriteRequest left, CheatEngine.Client.Memory.MemoryWriteRequest right) -> bool +static CheatEngine.Client.Memory.PointerChainRequest.operator !=(CheatEngine.Client.Memory.PointerChainRequest left, CheatEngine.Client.Memory.PointerChainRequest right) -> bool +static CheatEngine.Client.Memory.PointerChainRequest.operator ==(CheatEngine.Client.Memory.PointerChainRequest left, CheatEngine.Client.Memory.PointerChainRequest right) -> bool +static CheatEngine.Client.Processes.ProcessSnapshot.operator !=(CheatEngine.Client.Processes.ProcessSnapshot left, CheatEngine.Client.Processes.ProcessSnapshot right) -> bool +static CheatEngine.Client.Processes.ProcessSnapshot.operator ==(CheatEngine.Client.Processes.ProcessSnapshot left, CheatEngine.Client.Processes.ProcessSnapshot right) -> bool +static CheatEngine.Client.Results.CheatEngineFailure.operator !=(CheatEngine.Client.Results.CheatEngineFailure left, CheatEngine.Client.Results.CheatEngineFailure right) -> bool +static CheatEngine.Client.Results.CheatEngineFailure.operator ==(CheatEngine.Client.Results.CheatEngineFailure left, CheatEngine.Client.Results.CheatEngineFailure right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot left, CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot left, CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilities.Create(System.ReadOnlySpan entries) -> CheatEngine.Client.Runtime.ClientCapabilities! +static CheatEngine.Client.Runtime.ClientCapabilities.Empty.get -> CheatEngine.Client.Runtime.ClientCapabilities! +static CheatEngine.Client.Runtime.ClientCapabilityAvailability.operator !=(CheatEngine.Client.Runtime.ClientCapabilityAvailability left, CheatEngine.Client.Runtime.ClientCapabilityAvailability right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilityAvailability.operator ==(CheatEngine.Client.Runtime.ClientCapabilityAvailability left, CheatEngine.Client.Runtime.ClientCapabilityAvailability right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilityId.Inspection.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.operator !=(CheatEngine.Client.Runtime.ClientCapabilityId left, CheatEngine.Client.Runtime.ClientCapabilityId right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilityId.operator ==(CheatEngine.Client.Runtime.ClientCapabilityId left, CheatEngine.Client.Runtime.ClientCapabilityId right) -> bool +static CheatEngine.Client.Runtime.ClientCapabilityId.PatternScanning.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.ProcessSelection.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.ProtectedLua.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Tables.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.TypedMemory.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.UnsafeLuaExecution.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.ValueScanning.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Scanning.AobPattern.operator !=(CheatEngine.Client.Scanning.AobPattern left, CheatEngine.Client.Scanning.AobPattern right) -> bool +static CheatEngine.Client.Scanning.AobPattern.operator ==(CheatEngine.Client.Scanning.AobPattern left, CheatEngine.Client.Scanning.AobPattern right) -> bool +static CheatEngine.Client.Scanning.AobPattern.TryParse(string? value, out CheatEngine.Client.Scanning.AobPattern pattern) -> bool +static CheatEngine.Client.Scanning.AobScanRange.operator !=(CheatEngine.Client.Scanning.AobScanRange left, CheatEngine.Client.Scanning.AobScanRange right) -> bool +static CheatEngine.Client.Scanning.AobScanRange.operator ==(CheatEngine.Client.Scanning.AobScanRange left, CheatEngine.Client.Scanning.AobScanRange right) -> bool +static CheatEngine.Client.Scanning.AobScanRequest.operator !=(CheatEngine.Client.Scanning.AobScanRequest left, CheatEngine.Client.Scanning.AobScanRequest right) -> bool +static CheatEngine.Client.Scanning.AobScanRequest.operator ==(CheatEngine.Client.Scanning.AobScanRequest left, CheatEngine.Client.Scanning.AobScanRequest right) -> bool +static CheatEngine.Client.Scanning.AobScanResult.operator !=(CheatEngine.Client.Scanning.AobScanResult left, CheatEngine.Client.Scanning.AobScanResult right) -> bool +static CheatEngine.Client.Scanning.AobScanResult.operator ==(CheatEngine.Client.Scanning.AobScanResult left, CheatEngine.Client.Scanning.AobScanResult right) -> bool +static CheatEngine.Client.Scanning.ValueScanMatch.operator !=(CheatEngine.Client.Scanning.ValueScanMatch left, CheatEngine.Client.Scanning.ValueScanMatch right) -> bool +static CheatEngine.Client.Scanning.ValueScanMatch.operator ==(CheatEngine.Client.Scanning.ValueScanMatch left, CheatEngine.Client.Scanning.ValueScanMatch right) -> bool +static CheatEngine.Client.Scanning.ValueScanPage.operator !=(CheatEngine.Client.Scanning.ValueScanPage left, CheatEngine.Client.Scanning.ValueScanPage right) -> bool +static CheatEngine.Client.Scanning.ValueScanPage.operator ==(CheatEngine.Client.Scanning.ValueScanPage left, CheatEngine.Client.Scanning.ValueScanPage right) -> bool +static CheatEngine.Client.Scanning.ValueScanReadRequest.operator !=(CheatEngine.Client.Scanning.ValueScanReadRequest left, CheatEngine.Client.Scanning.ValueScanReadRequest right) -> bool +static CheatEngine.Client.Scanning.ValueScanReadRequest.operator ==(CheatEngine.Client.Scanning.ValueScanReadRequest left, CheatEngine.Client.Scanning.ValueScanReadRequest right) -> bool +static CheatEngine.Client.Tables.AddressTableSnapshot.operator !=(CheatEngine.Client.Tables.AddressTableSnapshot left, CheatEngine.Client.Tables.AddressTableSnapshot right) -> bool +static CheatEngine.Client.Tables.AddressTableSnapshot.operator ==(CheatEngine.Client.Tables.AddressTableSnapshot left, CheatEngine.Client.Tables.AddressTableSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordCollectionRequest.operator !=(CheatEngine.Client.Tables.MemoryRecordCollectionRequest left, CheatEngine.Client.Tables.MemoryRecordCollectionRequest right) -> bool +static CheatEngine.Client.Tables.MemoryRecordCollectionRequest.operator ==(CheatEngine.Client.Tables.MemoryRecordCollectionRequest left, CheatEngine.Client.Tables.MemoryRecordCollectionRequest right) -> bool +static CheatEngine.Client.Tables.MemoryRecordDefinition.operator !=(CheatEngine.Client.Tables.MemoryRecordDefinition left, CheatEngine.Client.Tables.MemoryRecordDefinition right) -> bool +static CheatEngine.Client.Tables.MemoryRecordDefinition.operator ==(CheatEngine.Client.Tables.MemoryRecordDefinition left, CheatEngine.Client.Tables.MemoryRecordDefinition right) -> bool +static CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.operator !=(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest left, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest right) -> bool +static CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.operator ==(CheatEngine.Client.Tables.MemoryRecordHierarchyRequest left, CheatEngine.Client.Tables.MemoryRecordHierarchyRequest right) -> bool +static CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot left, CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot left, CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordSearch.operator !=(CheatEngine.Client.Tables.MemoryRecordSearch left, CheatEngine.Client.Tables.MemoryRecordSearch right) -> bool +static CheatEngine.Client.Tables.MemoryRecordSearch.operator ==(CheatEngine.Client.Tables.MemoryRecordSearch left, CheatEngine.Client.Tables.MemoryRecordSearch right) -> bool +static CheatEngine.Client.Tables.MemoryRecordSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordSnapshot left, CheatEngine.Client.Tables.MemoryRecordSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordSnapshot left, CheatEngine.Client.Tables.MemoryRecordSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordUpdate.operator !=(CheatEngine.Client.Tables.MemoryRecordUpdate left, CheatEngine.Client.Tables.MemoryRecordUpdate right) -> bool +static CheatEngine.Client.Tables.MemoryRecordUpdate.operator ==(CheatEngine.Client.Tables.MemoryRecordUpdate left, CheatEngine.Client.Tables.MemoryRecordUpdate right) -> bool +static CheatEngine.Client.Tables.TableLoadRequest.operator !=(CheatEngine.Client.Tables.TableLoadRequest left, CheatEngine.Client.Tables.TableLoadRequest right) -> bool +static CheatEngine.Client.Tables.TableLoadRequest.operator ==(CheatEngine.Client.Tables.TableLoadRequest left, CheatEngine.Client.Tables.TableLoadRequest right) -> bool +static CheatEngine.Client.Tables.TableSaveRequest.operator !=(CheatEngine.Client.Tables.TableSaveRequest left, CheatEngine.Client.Tables.TableSaveRequest right) -> bool +static CheatEngine.Client.Tables.TableSaveRequest.operator ==(CheatEngine.Client.Tables.TableSaveRequest left, CheatEngine.Client.Tables.TableSaveRequest right) -> bool +static CheatEngine.Client.Tables.TrustedTableFile.operator !=(CheatEngine.Client.Tables.TrustedTableFile left, CheatEngine.Client.Tables.TrustedTableFile right) -> bool +static CheatEngine.Client.Tables.TrustedTableFile.operator ==(CheatEngine.Client.Tables.TrustedTableFile left, CheatEngine.Client.Tables.TrustedTableFile right) -> bool diff --git a/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.Client.Abstractions/README.md b/libs/CheatEngine.Client.Abstractions/README.md index 1cac69f..fa5b960 100644 --- a/libs/CheatEngine.Client.Abstractions/README.md +++ b/libs/CheatEngine.Client.Abstractions/README.md @@ -1,9 +1,93 @@ # CheatEngine.Client.Abstractions -The base layer of CheatEngine.Client: the strongly-typed public vocabulary (contracts, value types, enums, options, -error types) and the contracts that separate the fluent surface from the binding layer. +## Context -## Rules +`CheatEngine.Client.Abstractions` is the stable, handle-free vocabulary of the in-process +`CheatEngine.Client` plugin API. It targets C# 14 and .NET 10 on top of +[CheatEngine.SDK](https://www.nuget.org/packages/CheatEngine.SDK), and is valid only for the +current Cheat Engine plugin activation. -- References no other project. Everything above depends on it, nothing below it exists. -- Public API: what lives here is what consumers and the other layers agree on. +It defines the public contracts used by the product facade, implementations, fluent helpers, +hosting, extensions, and application code. It is intentionally synchronous: an attached Cheat +Engine Lua runtime and its main-thread work must not be retained across an `await` boundary. + +## Why This Project Exists + +The SDK correctly exposes the Cheat Engine runtime, Lua bridge, ownership wrappers, and ABI-level +concepts. Application code should not need to carry those handles through its own architecture. +This project establishes a smaller product boundary with explicit failure, ownership, lifetime, +and materialization rules. + +It is the bottom of the Client project graph: it has no Client project reference. Its only package +dependency is the `CheatEngine.SDK` compile/runtime surface required for stable value and query +types such as `Address`, target identifiers, inspection metadata, scan requests, and address-list +identifiers. SDK build assets, generators, native assets, and analyzers deliberately do not flow +through this package. + +```text +CheatEngine.Client.Abstractions + ↑ ↑ + Fluent Core + ↑ + DependencyInjection / Hosting +``` + +## How It Improves CheatEngine.Client + +- Makes domain behavior testable against interfaces instead of Cheat Engine statics. +- Keeps expected failures explicit through `Try...(..., out CheatEngineFailure)` and throws + `CheatEngineClientException`-derived exceptions only from the convenience methods. +- Keeps CE-owned objects out of the public surface: no `LuaState`, `LuaRef`, `CEObject`, + `Owned`, or raw native handle escapes this package. +- Requires bounded copies for scans, table snapshots, strings, byte reads, pointer chains, and + inspection collections, avoiding unbounded materialization and leaked SDK ownership. +- Makes lifecycle constraints visible: `ICheatEngineClient.Epoch`, `Stopping`, leases, and scan + sessions are activation-scoped; stale resources report `CheatEngineActivationExpiredException`. + +## Public Surface + +Package and assembly names are not consumer namespaces. Public code belongs to functional +namespaces only: + +| Namespace | Responsibility | +|---|---| +| `CheatEngine.Client` | `ICheatEngineClient`, the activation-scoped facade | +| `.Dispatching` / `.Runtime` | main-thread dispatch and runtime/capability observations | +| `.Processes` / `.Inspection` | target selection, copied process/module/region/symbol data | +| `.Memory` | bounded primitive, byte, string, codec, and pointer-chain operations | +| `.Scanning` | AOB contracts and the value-scan session contract | +| `.Tables` | copied Address List records and explicitly trusted table I/O requests | +| `.Lua` / `.Modules` | typed protected Lua operations, explicit modules, and leases | +| `.Results` | classified expected failures and lifecycle exceptions | + +No public consumer should use `CheatEngine.Client.Abstractions` as a namespace. + +### Capability Boundary + +The contracts describe runtime, process, memory, inspection, AOB scanning, tables, protected Lua, +and explicitly disposable value-scan sessions. A contract is not an availability promise: callers +must inspect `ICheatEngineRuntime` capability observations or handle `CapabilityUnavailable`. + +In particular, the value-scan contract and state model are published, but the Core implementation +does **not** currently create a live `MemScan`/`FoundList` session. CheatEngine.SDK 1.0.0 has no +public ownership factory for the required objects; Client enablement remains blocked by the +Cheat Engine 7.7 x64 ownership and reactivation live gate. Do not treat `IValueScanner` as an +available v1 runtime feature. + +`IUnsafeLuaClient` is intentionally separate from `ILuaClient` and is not registered by default. +It is for explicitly trusted source only and still never exposes a raw Lua state. + +## Contribution and Validation + +Changes here are public API changes. Keep request/value types immutable, preserve functional +namespaces, add XML documentation, update `PublicAPI.Unshipped.txt`, and add focused contract +tests in `tests/CheatEngine.Client.Abstractions.Tests` before moving an entry to +`PublicAPI.Shipped.txt`. + +From the repository root, validate the complete package graph: + +```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.Abstractions/Results/CheatEngineActivationExpiredException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineActivationExpiredException.cs new file mode 100644 index 0000000..8466e60 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineActivationExpiredException.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Results; + +/// Thrown when a resource is used after its plugin activation epoch has ended. +public sealed class CheatEngineActivationExpiredException : CheatEngineClientException +{ + /// Creates an expired-activation exception for the attempted operation. + public CheatEngineActivationExpiredException(string operation, string message, Exception? innerException = null) + : base(new CheatEngineFailure(CheatEngineFailureKind.ActivationExpired, operation, message, innerException)) + { + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientException.cs new file mode 100644 index 0000000..777cb35 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientException.cs @@ -0,0 +1,18 @@ +namespace CheatEngine.Client.Results; + +/// Base exception for an invalid use of the Cheat Engine client contract. +public class CheatEngineClientException : Exception +{ + /// Creates an exception from a classified client failure. + public CheatEngineClientException(CheatEngineFailure failure) + : base(failure.Message, failure.Exception) + { + Failure = failure; + } + + /// Gets the failure that caused the exception. + public CheatEngineFailure Failure + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientLifecycleException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientLifecycleException.cs new file mode 100644 index 0000000..a935b8b --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineClientLifecycleException.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Results; + +/// Thrown when code uses a client, session, or handle outside its active plugin lifecycle. +public sealed class CheatEngineClientLifecycleException : CheatEngineClientException +{ + /// Creates a lifecycle exception with the operation that was attempted. + public CheatEngineClientLifecycleException(string operation, string message, Exception? innerException = null) + : base(new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, message, innerException)) + { + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs new file mode 100644 index 0000000..5647e10 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs @@ -0,0 +1,52 @@ +namespace CheatEngine.Client.Results; + +/// An immutable description of an expected Cheat Engine operation failure. +public readonly record struct CheatEngineFailure +{ + /// Creates a failure while retaining an optional SDK exception for diagnostics. + public CheatEngineFailure(CheatEngineFailureKind kind, string operation, string message, + Exception? exception = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + Kind = kind; + Operation = operation; + Message = message; + Exception = exception; + } + + /// Gets the stable failure category. + public CheatEngineFailureKind Kind + { + get; + } + + /// Gets the client operation that failed. + public string Operation + { + get; + } + + /// Gets a human-readable diagnostic message. + public string Message + { + get; + } + + /// Gets the originating SDK exception when one exists. + public Exception? Exception + { + get; + } + + /// Throws this failure as an operation exception. + public readonly void Throw() + { + if (Kind == CheatEngineFailureKind.ActivationExpired) + { + throw new CheatEngineActivationExpiredException(Operation, Message, Exception); + } + + throw new CheatEngineOperationException(this); + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailureKind.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailureKind.cs new file mode 100644 index 0000000..6d0bef9 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailureKind.cs @@ -0,0 +1,53 @@ +namespace CheatEngine.Client.Results; + +/// Classifies a failure observed while executing a Cheat Engine client operation. +public enum CheatEngineFailureKind +{ + /// The operation could not be classified more precisely. + Unknown = 0, + + /// The operation was cancelled before a Cheat Engine call began. + Cancelled = 1, + + /// The required Cheat Engine capability or Lua global is unavailable. + CapabilityUnavailable = 2, + + /// Cheat Engine rejected the requested operation. + OperationRejected = 3, + + /// The requested resource was not found. + NotFound = 4, + + /// The operation expected one result but observed several. + AmbiguousMatch = 5, + + /// The host result exceeded the caller's materialization limit. + ResultLimitExceeded = 6, + + /// A protected Lua call failed. + LuaError = 7, + + /// The SDK binding could not uphold its documented contract. + BindingError = 8, + + /// The host returned a value outside its documented result shape. + InvalidHostResult = 9, + + /// The requested feature is intentionally not supported by this client version. + Unsupported = 10, + + /// No target process is currently attached. + TargetNotAttached = 11, + + /// A target-memory read failed. + MemoryReadFailed = 12, + + /// A target-memory write failed. + MemoryWriteFailed = 13, + + /// The client resource belongs to an expired activation epoch. + ActivationExpired = 14, + + /// The operation is not valid in the resource's current lifecycle or session state. + InvalidState = 15 +} diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationException.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationException.cs new file mode 100644 index 0000000..c1902da --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineOperationException.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Results; + +/// Thrown when a throwing convenience operation observes an expected Cheat Engine failure. +public sealed class CheatEngineOperationException : CheatEngineClientException +{ + /// Creates an operation exception from a classified failure. + public CheatEngineOperationException(CheatEngineFailure failure) + : base(failure) + { + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs new file mode 100644 index 0000000..57eaf23 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs @@ -0,0 +1,121 @@ +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Runtime; + +/// Immutable runtime observations captured during one active Cheat Engine activation. +/// +/// is the coarse number returned by Cheat Engine's +/// getCEVersion global. It is deliberately separate from +/// , because that global cannot establish a complete four-part file +/// version. +/// +public readonly record struct CheatEngineRuntimeSnapshot +{ + /// Creates a runtime snapshot from independently observed facts. + public CheatEngineRuntimeSnapshot( + long epoch, + double? observedCheatEngineVersion, + CheatEngineVersion qualifiedCheatEngineBaseline, + Version clientAssemblyVersion, + Version sdkAssemblyVersion, + CheatEngineArchitecture systemArchitecture, + CheatEngineArchitecture targetArchitecture, + PointerSize targetPointerSize, + TargetAbi targetAbi, + RuntimeCapabilities sdkCapabilities, + ClientCapabilities clientCapabilities) + { + ArgumentOutOfRangeException.ThrowIfNegative(epoch); + if (observedCheatEngineVersion is { } observed && + (!double.IsFinite(observed) || observed < 0)) + { + throw new ArgumentOutOfRangeException(nameof(observedCheatEngineVersion), observed, + "The observed Cheat Engine version must be a finite non-negative number when supplied."); + } + + Epoch = epoch; + ObservedCheatEngineVersion = observedCheatEngineVersion; + QualifiedCheatEngineBaseline = qualifiedCheatEngineBaseline; + ClientAssemblyVersion = clientAssemblyVersion ?? throw new ArgumentNullException(nameof(clientAssemblyVersion)); + SdkAssemblyVersion = sdkAssemblyVersion ?? throw new ArgumentNullException(nameof(sdkAssemblyVersion)); + SystemArchitecture = systemArchitecture; + TargetArchitecture = targetArchitecture; + TargetPointerSize = targetPointerSize; + TargetAbi = targetAbi; + SdkCapabilities = sdkCapabilities ?? throw new ArgumentNullException(nameof(sdkCapabilities)); + ClientCapabilities = clientCapabilities ?? throw new ArgumentNullException(nameof(clientCapabilities)); + } + + /// Gets the current plugin activation epoch. + public long Epoch + { + get; + } + + /// Gets the coarse number returned by CE's getCEVersion global, when it was callable. + public double? ObservedCheatEngineVersion + { + get; + } + + /// Gets the complete CE build against which this Client release was qualified. + public CheatEngineVersion QualifiedCheatEngineBaseline + { + get; + } + + /// Gets the assembly version of this Client abstraction assembly. + public Version ClientAssemblyVersion + { + get; + } + + /// Gets the assembly version of the SDK runtime-contract assembly. + public Version SdkAssemblyVersion + { + get; + } + + /// Gets the CE host architecture observed from CE's system-architecture global. + public CheatEngineArchitecture SystemArchitecture + { + get; + } + + /// Gets the target architecture observed by a target-specific probe, or unknown. + public CheatEngineArchitecture TargetArchitecture + { + get; + } + + /// Gets the pointer width implied by the observed target architecture, or unknown. + public PointerSize TargetPointerSize + { + get; + } + + /// Gets the target ABI observed from CE's ABI global, or unknown. + public TargetAbi TargetAbi + { + get; + } + + /// Gets the explicit availability observation for each SDK runtime capability that was probed. + public RuntimeCapabilities SdkCapabilities + { + get; + } + + /// Gets the explicit availability observation for each Client high-level capability. + public ClientCapabilities ClientCapabilities + { + get; + } + + /// Gets whether the observed coarse CE version belongs to the qualified major/minor line. + public bool IsOnQualifiedCheatEngineLine => ObservedCheatEngineVersion is { } observed && + observed >= QualifiedCheatEngineBaseline.Major + + QualifiedCheatEngineBaseline.Minor / 10d && + observed < QualifiedCheatEngineBaseline.Major + + (QualifiedCheatEngineBaseline.Minor + 1) / 10d; +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilities.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilities.cs new file mode 100644 index 0000000..a3ff002 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilities.cs @@ -0,0 +1,107 @@ +namespace CheatEngine.Client.Runtime; + +/// An immutable, allocation-free-to-enumerate collection of Client capability observations. +public sealed class ClientCapabilities : IEquatable +{ + private readonly ClientCapabilityAvailability[] _entries; + + private ClientCapabilities(ClientCapabilityAvailability[] entries) + { + _entries = entries; + } + + /// Gets an empty capability collection. + public static ClientCapabilities Empty + { + get; + } = new([]); + + /// Gets the number of explicit capability observations. + public int Count => _entries.Length; + + /// Gets the ordered observations as a read-only span. + public ReadOnlySpan Entries => _entries; + + /// + public bool Equals(ClientCapabilities? other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other is null || _entries.Length != other._entries.Length) + { + return false; + } + + for (int index = 0; index < _entries.Length; index++) + { + if (_entries[index] != other._entries[index]) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) + { + return obj is ClientCapabilities other && Equals(other); + } + + /// Copies and validates a set of distinct Client capability observations. + public static ClientCapabilities Create(ReadOnlySpan entries) + { + if (entries.IsEmpty) + { + return Empty; + } + + ClientCapabilityAvailability[] copy = entries.ToArray(); + for (int index = 0; index < copy.Length; index++) + { + _ = new ClientCapabilityAvailability(copy[index].Capability, copy[index].State, copy[index].Reason); + for (int previous = 0; previous < index; previous++) + { + if (copy[previous].Capability == copy[index].Capability) + { + throw new ArgumentException("A Client capability identifier occurs more than once.", + nameof(entries)); + } + } + } + + return new ClientCapabilities(copy); + } + + /// Tries to get one explicit capability observation. + public bool TryGet(ClientCapabilityId capability, out ClientCapabilityAvailability availability) + { + for (int index = 0; index < _entries.Length; index++) + { + if (_entries[index].Capability == capability) + { + availability = _entries[index]; + return true; + } + } + + availability = default; + return false; + } + + /// + public override int GetHashCode() + { + HashCode hash = new(); + for (int index = 0; index < _entries.Length; index++) + { + hash.Add(_entries[index]); + } + + return hash.ToHashCode(); + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailability.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailability.cs new file mode 100644 index 0000000..1a75db2 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailability.cs @@ -0,0 +1,58 @@ +using System.Runtime.InteropServices; + +namespace CheatEngine.Client.Runtime; + +/// An immutable observation of a Client-owned high-level capability and its evidence. +[StructLayout(LayoutKind.Sequential)] +public readonly record struct ClientCapabilityAvailability +{ + /// Creates an observation after validating its stable public shape. + /// The stable Client-owned capability identifier. + /// The observed availability state. + /// The explicit probe, policy, or gate reason behind the state. + public ClientCapabilityAvailability( + ClientCapabilityId capability, + ClientCapabilityAvailabilityState state, + string reason) + { + if (capability.IsEmpty) + { + throw new ArgumentException("A Client capability identifier is required.", nameof(capability)); + } + + if (!Enum.IsDefined(state)) + { + throw new ArgumentOutOfRangeException(nameof(state), state, "The Client capability state is not defined."); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + + Capability = capability; + State = state; + Reason = reason; + } + + /// Gets the stable Client-owned capability identifier. + public ClientCapabilityId Capability + { + get; + } + + /// Gets the observed availability state. + public ClientCapabilityAvailabilityState State + { + get; + } + + /// Gets the explicit probe, policy, or gate reason behind the state. + public string Reason + { + get; + } + + /// Gets whether this capability was explicitly established as available. + public bool IsAvailable => State == ClientCapabilityAvailabilityState.Available; + + /// Gets whether this capability was explicitly established as available or unavailable. + public bool IsKnown => State != ClientCapabilityAvailabilityState.Unknown; +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailabilityState.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailabilityState.cs new file mode 100644 index 0000000..922c236 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityAvailabilityState.cs @@ -0,0 +1,14 @@ +namespace CheatEngine.Client.Runtime; + +/// Describes the observed availability of a Client-owned high-level capability. +public enum ClientCapabilityAvailabilityState : byte +{ + /// The Client has not established every prerequisite for the capability. + Unknown = 0, + + /// The Client has established that the capability is available for this activation. + Available = 1, + + /// The Client explicitly does not offer the capability for this activation. + Unavailable = 2 +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs new file mode 100644 index 0000000..93c4614 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs @@ -0,0 +1,80 @@ +namespace CheatEngine.Client.Runtime; + +/// A stable, Client-owned identifier for one high-level Cheat Engine capability. +public readonly struct ClientCapabilityId : IEquatable +{ + private readonly string? _value; + + /// Creates a non-empty Client capability identifier. + public ClientCapabilityId(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the identifier value, or an empty string for the default value. + public string Value => _value ?? string.Empty; + + /// Gets whether this is the default, unusable identifier. + public bool IsEmpty => _value is null; + + /// Gets the Client capability for process selection and attachment. + public static ClientCapabilityId ProcessSelection => new("Client.ProcessSelection"); + + /// Gets the Client capability for typed target-memory operations. + public static ClientCapabilityId TypedMemory => new("Client.TypedMemory"); + + /// Gets the Client capability for AOB pattern scanning. + public static ClientCapabilityId PatternScanning => new("Client.PatternScanning"); + + /// Gets the Client capability for value scan sessions. + public static ClientCapabilityId ValueScanning => new("Client.ValueScanning"); + + /// Gets the Client capability for copied inspection snapshots and symbol operations. + public static ClientCapabilityId Inspection => new("Client.Inspection"); + + /// Gets the Client capability for Address List and trusted table operations. + public static ClientCapabilityId Tables => new("Client.Tables"); + + /// Gets the Client capability for protected typed Lua operations. + public static ClientCapabilityId ProtectedLua => new("Client.ProtectedLua"); + + /// Gets the Client capability for explicitly opted-in arbitrary Lua source execution. + public static ClientCapabilityId UnsafeLuaExecution => new("Client.UnsafeLuaExecution"); + + /// + public bool Equals(ClientCapabilityId other) + { + return string.Equals(_value, other._value, StringComparison.Ordinal); + } + + /// + public override bool Equals(object? obj) + { + return obj is ClientCapabilityId other && Equals(other); + } + + /// + public override int GetHashCode() + { + return _value is null ? 0 : StringComparer.Ordinal.GetHashCode(_value); + } + + /// + public override string ToString() + { + return Value; + } + + /// Tests two identifiers for ordinal equality. + public static bool operator ==(ClientCapabilityId left, ClientCapabilityId right) + { + return left.Equals(right); + } + + /// Tests two identifiers for ordinal inequality. + public static bool operator !=(ClientCapabilityId left, ClientCapabilityId right) + { + return !left.Equals(right); + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ICheatEngineRuntime.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ICheatEngineRuntime.cs new file mode 100644 index 0000000..29451d8 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ICheatEngineRuntime.cs @@ -0,0 +1,47 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Runtime; + +/// Reads immutable runtime facts and capability observations for the active Cheat Engine activation. +public interface ICheatEngineRuntime +{ + /// Gets the activation epoch for which this runtime service is valid. + public long Epoch + { + get; + } + + /// Tries to capture the runtime facts available to the active plugin. + public bool TryGetSnapshot( + out CheatEngineRuntimeSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Captures the runtime facts or throws when they are unavailable. + public CheatEngineRuntimeSnapshot GetSnapshot(CancellationToken cancellationToken = default); + + /// Reads one SDK capability observation without exposing its backing Lua global. + public bool TryGetSdkCapability( + RuntimeCapabilityId capability, + out RuntimeCapabilityAvailability availability, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Reads one SDK capability observation or throws when it cannot be observed. + public RuntimeCapabilityAvailability GetSdkCapability( + RuntimeCapabilityId capability, + CancellationToken cancellationToken = default); + + /// Reads one Client capability observation without exposing internal adapters or handles. + public bool TryGetClientCapability( + ClientCapabilityId capability, + out ClientCapabilityAvailability availability, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Reads one Client capability observation or throws when it cannot be observed. + public ClientCapabilityAvailability GetClientCapability( + ClientCapabilityId capability, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs new file mode 100644 index 0000000..aa3e46c --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs @@ -0,0 +1,180 @@ +using System.Text; + +namespace CheatEngine.Client.Scanning; + +/// An immutable, normalized sequence of Cheat Engine AOB byte and wildcard tokens. +/// +/// +/// The client deliberately accepts the documented, portable subset of string-form AOBScan: hexadecimal +/// bytes and ?? wildcards. It canonicalizes hexadecimal digits to upper case and emits exactly one ASCII +/// space between tokens. This prevents an invalid token from reaching Cheat Engine while retaining CE as the +/// authority for matching semantics. +/// +/// +/// The byte-array overload of CE's Lua function has broader numeric coercion rules. It is not used by this +/// string-based client surface, so numeric values above 255 and non-integral values are intentionally not +/// accepted here. +/// +/// +public readonly record struct AobPattern +{ + /// Creates and normalizes an AOB pattern. + /// Hexadecimal byte tokens and ?? wildcard tokens. + /// is . + /// is empty or has an invalid token. + public AobPattern(string value) + { + ArgumentNullException.ThrowIfNull(value); + if (!TryNormalize(value.AsSpan(), out string normalized, out int byteLength)) + { + throw new ArgumentException( + "An AOB pattern must contain only two-digit hexadecimal bytes or ?? wildcard tokens.", nameof(value)); + } + + Value = normalized; + ByteLength = byteLength; + } + + private AobPattern(string normalized, int byteLength) + { + Value = normalized; + ByteLength = byteLength; + } + + /// Gets the normalized Cheat Engine pattern text. + public string Value { get; } + + /// Gets the number of byte positions represented by the pattern. + public int ByteLength { get; } + + /// Gets whether every byte position is a wildcard. + public bool IsWildcardOnly + { + get + { + if (string.IsNullOrEmpty(Value)) + { + return false; + } + + for (int index = 0; index < Value.Length; index += 3) + { + if (Value[index] != '?' || Value[index + 1] != '?') + { + return false; + } + } + + return true; + } + } + + /// Attempts to parse and normalize an AOB pattern without throwing for malformed text. + /// The candidate pattern text, or . + /// The normalized pattern when this method returns . + /// when is a valid documented string-form AOB pattern. + public static bool TryParse(string? value, out AobPattern pattern) + { + if (value is not null && TryNormalize(value.AsSpan(), out string normalized, out int byteLength)) + { + pattern = new AobPattern(normalized, byteLength); + return true; + } + + pattern = default; + return false; + } + + /// Formats the pattern as normalized Cheat Engine text. + public override string ToString() + { + return Value ?? string.Empty; + } + + private static bool TryNormalize(ReadOnlySpan value, out string normalized, out int byteLength) + { + StringBuilder builder = new(value.Length); + int index = 0; + byteLength = 0; + + while (TryMoveToNextToken(value, ref index)) + { + if (!TryReadNormalizedToken(value, ref index, out char high, out char low)) + { + normalized = string.Empty; + byteLength = 0; + return false; + } + + AppendToken(builder, high, low); + byteLength++; + } + + if (byteLength != 0) + { + normalized = builder.ToString(); + return true; + } + + normalized = string.Empty; + return false; + } + + private static bool TryMoveToNextToken(ReadOnlySpan value, ref int index) + { + while (index < value.Length && char.IsWhiteSpace(value[index])) + { + index++; + } + + return index < value.Length; + } + + private static bool TryReadNormalizedToken(ReadOnlySpan value, ref int index, out char high, out char low) + { + if (index + 1 >= value.Length || char.IsWhiteSpace(value[index + 1])) + { + high = default; + low = default; + return false; + } + + high = value[index++]; + low = value[index++]; + if (high == '?' && low == '?') + { + return true; + } + + return TryGetHexDigit(high, out high) && TryGetHexDigit(low, out low); + } + + private static void AppendToken(StringBuilder builder, char high, char low) + { + if (builder.Length != 0) + { + builder.Append(' '); + } + + builder.Append(high); + builder.Append(low); + } + + private static bool TryGetHexDigit(char value, out char normalized) + { + if (value is >= '0' and <= '9' or >= 'A' and <= 'F') + { + normalized = value; + return true; + } + + if (value is >= 'a' and <= 'f') + { + normalized = (char) (value - ('a' - 'A')); + return true; + } + + normalized = default; + return false; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanOptionsNormalizer.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanOptionsNormalizer.cs new file mode 100644 index 0000000..0364356 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanOptionsNormalizer.cs @@ -0,0 +1,155 @@ +using System.Globalization; + +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Scanning; + +/// Validates the documented CE AOB protection and alignment text before an SDK call. +internal static class AobScanOptionsNormalizer +{ + internal static AobScanOptions Normalize(AobScanOptions options) + { + string? protection = NormalizeProtection(options.ProtectionFlags); + string? alignment = NormalizeAlignmentParameter(options.AlignmentMethod, options.AlignmentParameter); + return new AobScanOptions(protection, options.AlignmentMethod, alignment); + } + + private static string? NormalizeProtection(string? value) + { + if (value is null || value.Length == 0) + { + return value; + } + + Span modes = stackalloc char[3]; + int seen = 0; + for (int index = 0; index < value.Length;) + { + AddProtectionClause(value, ref index, modes, ref seen); + } + + return FormatProtection(modes, seen); + } + + private static void AddProtectionClause(string value, ref int index, Span modes, ref int seen) + { + if (index + 1 >= value.Length) + { + throw new ArgumentException( + "An AOB protection expression consists of +, -, or * followed by X, C, or W.", nameof(value)); + } + + char mode = value[index++]; + char flag = value[index++]; + if (mode is not ('+' or '-' or '*')) + { + throw new ArgumentException( + "An AOB protection expression consists of unique +, -, or * X/C/W clauses.", nameof(value)); + } + + int slot = GetProtectionSlot(flag); + if (slot < 0 || IsProtectionSlotPresent(seen, slot)) + { + throw new ArgumentException( + "An AOB protection expression consists of unique +, -, or * X/C/W clauses.", nameof(value)); + } + + modes[slot] = mode; + seen |= 1 << slot; + } + + private static string FormatProtection(ReadOnlySpan modes, int seen) + { + Span normalized = stackalloc char[6]; + int written = 0; + for (int slot = 0; slot < modes.Length; slot++) + { + if (!IsProtectionSlotPresent(seen, slot)) + { + continue; + } + + normalized[written++] = modes[slot]; + normalized[written++] = GetProtectionFlag(slot); + } + + return new string(normalized[..written]); + } + + private static int GetProtectionSlot(char flag) + { + return flag switch + { + 'X' or 'x' => 0, + 'C' or 'c' => 1, + 'W' or 'w' => 2, + _ => -1 + }; + } + + private static char GetProtectionFlag(int slot) + { + return slot switch { 0 => 'X', 1 => 'C', _ => 'W' }; + } + + private static bool IsProtectionSlotPresent(int seen, int slot) + { + return (seen & (1 << slot)) != 0; + } + + private static string? NormalizeAlignmentParameter(FastScanMethod method, string? value) + { + return method switch + { + FastScanMethod.NotAligned => value, + FastScanMethod.Aligned => NormalizeDivisor(value), + FastScanMethod.LastDigits => NormalizeHexSuffix(value), + _ => throw new ArgumentOutOfRangeException(nameof(method), method, + "AOB alignment must use a documented Cheat Engine fast-scan method.") + }; + } + + private static string NormalizeDivisor(string? value) + { + if (string.IsNullOrEmpty(value) || + !ulong.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out ulong divisor) || divisor == 0) + { + throw new ArgumentException( + "An aligned AOB scan requires a positive decimal alignment divisor.", nameof(value)); + } + + return divisor.ToString(CultureInfo.InvariantCulture); + } + + private static string NormalizeHexSuffix(string? value) + { + if (string.IsNullOrEmpty(value) || value.Length > sizeof(ulong) * 2) + { + throw new ArgumentException( + "A last-digits AOB scan requires one to sixteen hexadecimal digits.", nameof(value)); + } + + Span normalized = stackalloc char[value.Length]; + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + if (character is >= '0' and <= '9' or >= 'A' and <= 'F') + { + normalized[index] = character; + continue; + } + + if (character is >= 'a' and <= 'f') + { + normalized[index] = (char) (character - ('a' - 'A')); + continue; + } + + throw new ArgumentException( + "A last-digits AOB scan requires one to sixteen hexadecimal digits.", nameof(value)); + } + + return new string(normalized); + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRange.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRange.cs new file mode 100644 index 0000000..6690fed --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRange.cs @@ -0,0 +1,46 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Scanning; + +/// An inclusive target-address range used to filter copied AOB match addresses. +/// +/// String-form AOBScan does not accept start and stop address arguments. Core therefore applies this filter +/// after copying each matching address from the SDK-owned result list and before it contributes to the caller's +/// materialization limit. +/// +public readonly record struct AobScanRange +{ + /// Creates an inclusive target-address range. + /// The first included address. + /// The last included address. + /// precedes . + public AobScanRange(Address start, Address end) + { + if (end < start) + { + throw new ArgumentOutOfRangeException(nameof(end), end, + "An AOB range end address must not precede its start address."); + } + + Start = start; + End = end; + } + + /// Gets the first included target address. + public Address Start + { + get; + } + + /// Gets the last included target address. + public Address End + { + get; + } + + /// Gets whether is inside this inclusive range. + public bool Contains(Address address) + { + return address >= Start && address <= End; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRequest.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRequest.cs new file mode 100644 index 0000000..fe0cce5 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanRequest.cs @@ -0,0 +1,60 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Scanning; + +/// An immutable AOB scan request with an explicit managed materialization limit. +public readonly record struct AobScanRequest +{ + /// Creates an AOB scan request. + public AobScanRequest(AobPattern pattern, AobScanOptions options, int maximumResults, ModuleName? module = null, + AobScanRange? range = null) + { + if (string.IsNullOrWhiteSpace(pattern.Value)) + { + throw new ArgumentException("An AOB pattern must be non-empty.", nameof(pattern)); + } + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumResults); + if (module.HasValue && string.IsNullOrWhiteSpace(module.Value.Value)) + { + throw new ArgumentException("An AOB module filter must be non-empty.", nameof(module)); + } + + Pattern = pattern; + Options = AobScanOptionsNormalizer.Normalize(options); + MaximumResults = maximumResults; + Module = module; + Range = range; + } + + /// Gets the scan pattern. + public AobPattern Pattern + { + get; + } + + /// Gets the SDK's evidence-backed optional scan arguments. + public AobScanOptions Options + { + get; + } + + /// Gets the maximum number of managed matches the caller permits. + public int MaximumResults + { + get; + } + + /// Gets the optional module whose copied address range filters the results. + public ModuleName? Module + { + get; + } + + /// Gets the optional inclusive range that filters copied address results. + public AobScanRange? Range + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobScanResult.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanResult.cs new file mode 100644 index 0000000..09826fc --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobScanResult.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Scanning; + +/// A copied, handle-free AOB scan result. +public readonly record struct AobScanResult +{ + /// Creates a copied AOB scan result. + public AobScanResult(ImmutableArray
matches, bool isTruncated) + { + Matches = matches.IsDefault ? ImmutableArray
.Empty : matches; + if (isTruncated && Matches.IsEmpty) + { + throw new ArgumentException("A truncated AOB result must retain at least one copied match.", + nameof(matches)); + } + + IsTruncated = isTruncated; + } + + /// Gets the materialized target addresses. + public ImmutableArray
Matches + { + get; + } + + /// Gets whether additional CE matches were omitted because the request limit was reached. + public bool IsTruncated + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/IPatternScanner.cs b/libs/CheatEngine.Client.Abstractions/Scanning/IPatternScanner.cs new file mode 100644 index 0000000..23da161 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/IPatternScanner.cs @@ -0,0 +1,14 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Scanning; + +/// Runs bounded AOB scans and copies all returned addresses before releasing SDK-owned objects. +public interface IPatternScanner +{ + /// Tries to run one bounded pattern scan. + public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Runs one bounded pattern scan or throws when the operation fails. + public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanSession.cs b/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanSession.cs new file mode 100644 index 0000000..e7f60ae --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanSession.cs @@ -0,0 +1,54 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.Client.Scanning; + +/// A main-thread-bound, explicitly disposable high-level value-scan session. +/// +/// The session exposes a Client-managed rather than the SDK's raw scan state. Its +/// operations complete Cheat Engine's required wait-and-initialize sequence before publishing copied results, and +/// never +/// expose MemScan, FoundList, Lua, or ownership wrappers. +/// +public interface IValueScanSession : IDisposable +{ + /// Gets the session's current conservative scan state. + public ValueScanSessionState State + { + get; + } + + /// Runs a first scan and prepares its results for reading. + public bool TryStart(FirstScanRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Runs a first scan or throws when it fails. + public void Start(FirstScanRequest request, CancellationToken cancellationToken = default); + + /// Runs a next scan and prepares its results for reading. + public bool TryRunNextScan(NextScanRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Runs a next scan or throws when it fails. + public void RunNextScan(NextScanRequest request, CancellationToken cancellationToken = default); + + /// Resets the scan session to its initial state. + public bool TryReset(out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Resets the scan session or throws when it fails. + public void Reset(CancellationToken cancellationToken = default); + + /// Gets the current result count after results are ready. + public bool TryGetResultCount(out ulong resultCount, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets the current result count or throws when results are not ready. + public ulong GetResultCount(CancellationToken cancellationToken = default); + + /// Copies a bounded page of current results after results are ready. + public bool TryRead(ValueScanReadRequest request, out ValueScanPage page, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Copies a bounded page or throws when the session is not ready. + public ValueScanPage Read(ValueScanReadRequest request, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanner.cs b/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanner.cs new file mode 100644 index 0000000..0960dd0 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/IValueScanner.cs @@ -0,0 +1,14 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Scanning; + +/// Creates explicitly owned value-scan sessions. +public interface IValueScanner +{ + /// Tries to create a value-scan session for the current activation. + public bool TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Creates a value-scan session or throws when the host capability is unavailable. + public IValueScanSession CreateSession(CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanMatch.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanMatch.cs new file mode 100644 index 0000000..b87698b --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanMatch.cs @@ -0,0 +1,35 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Scanning; + +/// A copied value-scan match; it owns no FoundList or other CE resource. +public readonly record struct ValueScanMatch +{ + /// Creates a copied value-scan match. + public ValueScanMatch(int index, Address address, string value) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentNullException.ThrowIfNull(value); + Index = index; + Address = address; + Value = value; + } + + /// Gets the zero-based index in the CE found list. + public int Index + { + get; + } + + /// Gets the parsed target address. + public Address Address + { + get; + } + + /// Gets the verbatim value text returned by Cheat Engine. + public string Value + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanPage.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanPage.cs new file mode 100644 index 0000000..402ffdd --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanPage.cs @@ -0,0 +1,26 @@ +using System.Collections.Immutable; + +namespace CheatEngine.Client.Scanning; + +/// An immutable page of copied value-scan results. +public readonly record struct ValueScanPage +{ + /// Creates a value-scan result page. + public ValueScanPage(ulong totalCount, ImmutableArray matches) + { + TotalCount = totalCount; + Matches = matches.IsDefault ? ImmutableArray.Empty : matches; + } + + /// Gets the total CE result count observed while reading this page. + public ulong TotalCount + { + get; + } + + /// Gets the copied result matches. + public ImmutableArray Matches + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanReadRequest.cs new file mode 100644 index 0000000..aa76e9e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanReadRequest.cs @@ -0,0 +1,26 @@ +namespace CheatEngine.Client.Scanning; + +/// Describes a bounded, zero-based value-scan result read. +public readonly record struct ValueScanReadRequest +{ + /// Creates a bounded result-read request. + public ValueScanReadRequest(int startIndex, int maximumCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(startIndex); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCount); + StartIndex = startIndex; + MaximumCount = maximumCount; + } + + /// Gets the first zero-based result index to read. + public int StartIndex + { + get; + } + + /// Gets the maximum number of copied matches to materialize. + public int MaximumCount + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanSessionState.cs b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanSessionState.cs new file mode 100644 index 0000000..97535e1 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Scanning/ValueScanSessionState.cs @@ -0,0 +1,33 @@ +namespace CheatEngine.Client.Scanning; + +/// Describes the conservative Client-managed lifecycle of one value-scan session. +/// +/// +/// The successful path is , , then +/// . A subsequent scan transitions from through +/// again. No SDK object or Lua handle is represented by this value. +/// +/// +/// is deliberately conservative. It means a Cheat Engine operation began but failed +/// before +/// the Client could establish a safe next state; the session must be reset or disposed rather than reused +/// speculatively. +/// +/// +public enum ValueScanSessionState +{ + /// The session was created and has no readable result set. + Created = 0, + + /// A first or subsequent scan was accepted and has not yet completed. + Scanning = 1, + + /// The scan completed and its copied-result view is available for bounded reads. + ResultsReady = 2, + + /// A started operation did not establish a safe continuation state. + Invalidated = 3, + + /// The session has released its Client-owned resources and accepts no further operation. + Disposed = 4 +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/AddressTableSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/AddressTableSnapshot.cs new file mode 100644 index 0000000..2a6e124 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/AddressTableSnapshot.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; + +namespace CheatEngine.Client.Tables; + +/// A copied snapshot of Cheat Engine's current address-list cardinality. +public readonly record struct AddressTableSnapshot +{ + /// Creates an address-table snapshot. + public AddressTableSnapshot(int recordCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(recordCount); + RecordCount = recordCount; + Records = ImmutableArray.Empty; + } + + /// Creates a bounded copied snapshot of every top-level record in the address list. + public AddressTableSnapshot(ImmutableArray records) + { + Records = records.IsDefault ? ImmutableArray.Empty : records; + RecordCount = Records.Length; + } + + /// Gets the number of top-level records observed in the current address list. + public int RecordCount + { + get; + } + + /// Gets the copied top-level records when this snapshot was explicitly materialized. + /// + /// A cardinality-only value returned by has an empty collection. Use + /// to request records with an explicit materialization limit. + /// + public ImmutableArray Records + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/ITableClient.cs b/libs/CheatEngine.Client.Abstractions/Tables/ITableClient.cs new file mode 100644 index 0000000..b8849f9 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/ITableClient.cs @@ -0,0 +1,125 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Tables; + +/// Reads and changes the current Cheat Engine address list through copied record snapshots. +public interface ITableClient +{ + /// Tries to get a snapshot of the current address list. + public bool TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets the current table or throws when it is unavailable. + public AddressTableSnapshot GetCurrent(CancellationToken cancellationToken = default); + + /// Copies every top-level record only when the caller supplies a materialization limit. + public bool TryGetSnapshot(MemoryRecordCollectionRequest request, out AddressTableSnapshot table, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Copies a bounded top-level record snapshot or throws when the table exceeds the limit. + public AddressTableSnapshot GetSnapshot(MemoryRecordCollectionRequest request, + CancellationToken cancellationToken = default); + + /// Searches a bounded copied top-level record snapshot with conjunctive managed predicates. + public bool TryFind(MemoryRecordSearch search, MemoryRecordCollectionRequest request, + out ImmutableArray records, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Searches a bounded top-level record snapshot or throws when it cannot be materialized. + public ImmutableArray Find(MemoryRecordSearch search, MemoryRecordCollectionRequest request, + CancellationToken cancellationToken = default); + + /// Gets one record by its current zero-based address-list index. + public bool TryGetRecord(int index, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets one record by its stable Cheat Engine identifier. + public bool TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets one record by index or throws when it is unavailable. + public MemoryRecordSnapshot GetRecord(int index, CancellationToken cancellationToken = default); + + /// Gets one record by identifier or throws when it is unavailable. + public MemoryRecordSnapshot GetRecord(MemoryRecordId id, CancellationToken cancellationToken = default); + + /// Copies the selected address-list record when Cheat Engine has one. + public bool TryGetSelected(out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets the selected record or throws when Cheat Engine has no selection. + public MemoryRecordSnapshot GetSelected(CancellationToken cancellationToken = default); + + /// Selects one record by its stable identifier and returns its copied snapshot. + public bool TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Selects one record or throws when the record is unavailable. + public MemoryRecordSnapshot SelectRecord(MemoryRecordId id, CancellationToken cancellationToken = default); + + /// Creates a memory record, assigns its defined fields, and returns a copied snapshot. + public bool TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Creates a record or throws when the host rejects it. + public MemoryRecordSnapshot Create(MemoryRecordDefinition definition, + CancellationToken cancellationToken = default); + + /// Applies a partial update and returns a copied snapshot of the changed record. + public bool TryUpdate(MemoryRecordUpdate update, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Updates a record or throws when the host rejects the change. + public MemoryRecordSnapshot Update(MemoryRecordUpdate update, CancellationToken cancellationToken = default); + + /// Deletes one memory record from the current Cheat Engine address list. + public bool TryDelete(MemoryRecordId id, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Deletes one memory record or throws when Cheat Engine rejects the operation. + public void Delete(MemoryRecordId id, CancellationToken cancellationToken = default); + + /// Activates or deactivates one memory record and returns its copied post-change snapshot. + public bool TrySetActive(MemoryRecordId id, bool isActive, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Activates or deactivates one memory record or throws when Cheat Engine rejects the change. + public MemoryRecordSnapshot SetActive(MemoryRecordId id, bool isActive, + CancellationToken cancellationToken = default); + + /// Moves one record under a parent, or passes to restore it to the root. + public bool TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Moves one record under a parent, or restores it to the root, and returns its copied post-change state. + public MemoryRecordSnapshot SetParent(MemoryRecordId childId, MemoryRecordId? parentId, + CancellationToken cancellationToken = default); + + /// Copies a bounded hierarchy rooted at one memory record. + public bool TryGetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, + out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Copies a bounded hierarchy or throws when its depth, cardinality, or host shape is invalid. + public MemoryRecordHierarchySnapshot GetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, + CancellationToken cancellationToken = default); + + /// Tries to load a trusted table through Cheat Engine's native table loader. + public bool TryLoadTrustedTable(TableLoadRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Loads a trusted table or throws when policy or host execution rejects it. + public void LoadTrustedTable(TableLoadRequest request, CancellationToken cancellationToken = default); + + /// Tries to save the current table to an explicitly allowed path. + public bool TrySaveTable(TableSaveRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Saves the current table or throws when policy or host execution rejects it. + public void SaveTable(TableSaveRequest request, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordCollectionRequest.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordCollectionRequest.cs new file mode 100644 index 0000000..98b57f8 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordCollectionRequest.cs @@ -0,0 +1,18 @@ +namespace CheatEngine.Client.Tables; + +/// Bounds copied top-level or child memory records materialized from the Cheat Engine address list. +public readonly record struct MemoryRecordCollectionRequest +{ + /// Creates a bounded record materialization request. + public MemoryRecordCollectionRequest(int maximumItems) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumItems); + MaximumItems = maximumItems; + } + + /// Gets the maximum number of record snapshots the caller permits. + public int MaximumItems + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordDefinition.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordDefinition.cs new file mode 100644 index 0000000..ecd7aff --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordDefinition.cs @@ -0,0 +1,54 @@ +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Tables; + +/// The initial managed fields for a newly created Cheat Engine memory record. +public readonly record struct MemoryRecordDefinition +{ + /// Creates a memory-record definition. + public MemoryRecordDefinition(string description, string addressExpression, string value, VariableType variableType, + MemoryRecordId? parentId = null) + { + ArgumentNullException.ThrowIfNull(description); + ArgumentException.ThrowIfNullOrWhiteSpace(addressExpression); + ArgumentNullException.ThrowIfNull(value); + + Description = description; + AddressExpression = addressExpression; + Value = value; + VariableType = variableType; + ParentId = parentId; + } + + /// Gets the display description to assign. + public string Description + { + get; + } + + /// Gets the CE address expression to assign. + public string AddressExpression + { + get; + } + + /// Gets the CE string value to assign. + public string Value + { + get; + } + + /// Gets the CE value type to assign. + public VariableType VariableType + { + get; + } + + /// Gets the optional parent record to assign after the record fields have been initialized. + /// A value creates the record at the address-list root. + public MemoryRecordId? ParentId + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchyRequest.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchyRequest.cs new file mode 100644 index 0000000..875e991 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchyRequest.cs @@ -0,0 +1,27 @@ +namespace CheatEngine.Client.Tables; + +/// Bounds a copied memory-record hierarchy traversal. +public readonly record struct MemoryRecordHierarchyRequest +{ + /// Creates a bounded hierarchy materialization request. + public MemoryRecordHierarchyRequest(int maximumItems, int maximumDepth) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumItems); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumDepth); + + MaximumItems = maximumItems; + MaximumDepth = maximumDepth; + } + + /// Gets the maximum number of snapshots, including the requested root. + public int MaximumItems + { + get; + } + + /// Gets the maximum depth, where the requested root has depth one. + public int MaximumDepth + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs new file mode 100644 index 0000000..9e7892e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs @@ -0,0 +1,8 @@ +using System.Collections.Immutable; + +namespace CheatEngine.Client.Tables; + +/// A copied, recursively bounded memory-record tree. +public readonly record struct MemoryRecordHierarchySnapshot( + MemoryRecordSnapshot Record, + ImmutableArray Children); diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs new file mode 100644 index 0000000..6e9c5b9 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs @@ -0,0 +1,50 @@ +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Tables; + +/// Conjunctive managed predicates used to search a bounded address-list snapshot. +public readonly record struct MemoryRecordSearch +{ + /// Creates a search that matches all supplied predicates. + public MemoryRecordSearch( + string? descriptionContains = null, + string? addressExpression = null, + VariableType? variableType = null, + bool? isActive = null) + { + if (descriptionContains is { Length: 0 }) + { + throw new ArgumentException("A description search value must be null or non-empty.", + nameof(descriptionContains)); + } + + if (addressExpression is { Length: 0 }) + { + throw new ArgumentException("An address expression search value must be null or non-empty.", + nameof(addressExpression)); + } + + if (descriptionContains is null && addressExpression is null && variableType is null && isActive is null) + { + throw new ArgumentException("A memory-record search must specify at least one predicate.", + nameof(descriptionContains)); + } + + DescriptionContains = descriptionContains; + AddressExpression = addressExpression; + VariableType = variableType; + IsActive = isActive; + } + + /// Gets the case-insensitive description substring predicate. + public string? DescriptionContains { get; } + + /// Gets the case-insensitive exact address-expression predicate. + public string? AddressExpression { get; } + + /// Gets the optional exact Cheat Engine value-type predicate. + public VariableType? VariableType { get; } + + /// Gets the optional active/frozen state predicate. + public bool? IsActive { get; } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs new file mode 100644 index 0000000..383c323 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs @@ -0,0 +1,92 @@ +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Tables; + +/// A copied, handle-free snapshot of a Cheat Engine memory record. +public readonly record struct MemoryRecordSnapshot +{ + /// Creates a copied memory-record snapshot. + public MemoryRecordSnapshot( + MemoryRecordId id, + int index, + string description, + string addressExpression, + string value, + VariableType variableType, + Address? currentAddress, + bool isActive = false, + int childCount = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfNegative(childCount); + ArgumentNullException.ThrowIfNull(description); + ArgumentNullException.ThrowIfNull(addressExpression); + ArgumentNullException.ThrowIfNull(value); + + Id = id; + Index = index; + Description = description; + AddressExpression = addressExpression; + Value = value; + VariableType = variableType; + CurrentAddress = currentAddress; + IsActive = isActive; + ChildCount = childCount; + } + + /// Gets the stable Cheat Engine record identifier. + public MemoryRecordId Id + { + get; + } + + /// Gets the current zero-based address-list position. + public int Index + { + get; + } + + /// Gets the record display description. + public string Description + { + get; + } + + /// Gets the record's unresolved Cheat Engine address expression. + public string AddressExpression + { + get; + } + + /// Gets the record's verbatim value text. + public string Value + { + get; + } + + /// Gets the record's Cheat Engine value type. + public VariableType VariableType + { + get; + } + + /// Gets the currently resolved target address when it could be obtained. + public Address? CurrentAddress + { + get; + } + + /// Gets whether Cheat Engine reports this record as active or frozen. + public bool IsActive + { + get; + } + + /// Gets the number of immediate child records reported by Cheat Engine. + public int ChildCount + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordUpdate.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordUpdate.cs new file mode 100644 index 0000000..2959c0e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordUpdate.cs @@ -0,0 +1,63 @@ +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Tables; + +/// A partial change set for an existing Cheat Engine memory record. +public readonly record struct MemoryRecordUpdate +{ + /// Creates a memory-record update containing at least one changed field. + public MemoryRecordUpdate( + MemoryRecordId id, + string? description = null, + string? addressExpression = null, + string? value = null, + VariableType? variableType = null) + { + if (description is null && addressExpression is null && value is null && variableType is null) + { + throw new ArgumentException("A memory-record update must change at least one field.", nameof(description)); + } + + if (addressExpression is { Length: 0 }) + { + throw new ArgumentException("An address expression must be null or non-empty.", nameof(addressExpression)); + } + + Id = id; + Description = description; + AddressExpression = addressExpression; + Value = value; + VariableType = variableType; + } + + /// Gets the identifier of the record to change. + public MemoryRecordId Id + { + get; + } + + /// Gets the optional replacement description. + public string? Description + { + get; + } + + /// Gets the optional replacement address expression. + public string? AddressExpression + { + get; + } + + /// Gets the optional replacement value text. + public string? Value + { + get; + } + + /// Gets the optional replacement value type. + public VariableType? VariableType + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/TableLoadRequest.cs b/libs/CheatEngine.Client.Abstractions/Tables/TableLoadRequest.cs new file mode 100644 index 0000000..3b7192b --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/TableLoadRequest.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.Client.Tables; + +/// Options for loading one explicitly trusted Cheat Engine table. +public readonly record struct TableLoadRequest(TrustedTableFile File, bool Merge = false); diff --git a/libs/CheatEngine.Client.Abstractions/Tables/TableSaveRequest.cs b/libs/CheatEngine.Client.Abstractions/Tables/TableSaveRequest.cs new file mode 100644 index 0000000..3e0d459 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/TableSaveRequest.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.Client.Tables; + +/// Options for saving the current Cheat Engine table without advanced signing or protection. +public readonly record struct TableSaveRequest(TrustedTableFile File); diff --git a/libs/CheatEngine.Client.Abstractions/Tables/TrustedTableFile.cs b/libs/CheatEngine.Client.Abstractions/Tables/TrustedTableFile.cs new file mode 100644 index 0000000..b90aa54 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/TrustedTableFile.cs @@ -0,0 +1,23 @@ +namespace CheatEngine.Client.Tables; + +/// An explicitly trusted, normalized Cheat Engine table path. +public readonly record struct TrustedTableFile +{ + /// Creates a trusted table path. Policy validation is still performed by the configured table client. + public TrustedTableFile(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!Path.IsPathFullyQualified(path)) + { + throw new ArgumentException("A trusted Cheat Engine table path must be absolute.", nameof(path)); + } + + FullPath = Path.GetFullPath(path); + } + + /// Gets the normalized absolute path. + public string FullPath + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/packages.lock.json b/libs/CheatEngine.Client.Abstractions/packages.lock.json new file mode 100644 index 0000000..a256510 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/packages.lock.json @@ -0,0 +1,54 @@ +{ + "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.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==" + } + } + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs new file mode 100644 index 0000000..93a05a1 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs @@ -0,0 +1,25 @@ +using CheatEngine.Client.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Tests.Inspection; + +public sealed class InspectionContractsTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SymbolRegistrationRejectsBlankNames(string name) + { + Assert.Throws(() => new SymbolRegistration(name, Address.Zero)); + } + + [Fact] + public void SymbolRegistrationPreservesExplicitLeaseDefinition() + { + SymbolRegistration registration = new("sample.Health", Address.FromUInt64(0x1234), false); + + Assert.Equal("sample.Health", registration.Name); + Assert.Equal(Address.FromUInt64(0x1234), registration.Address); + Assert.False(registration.DoNotSave); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs new file mode 100644 index 0000000..33db27f --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs @@ -0,0 +1,70 @@ +using System.Reflection; + +using CheatEngine.Client.Lua; + +namespace CheatEngine.Client.Tests.Lua; + +public sealed class LuaContractTests +{ + [Fact] + public void LuaScriptRejectsAnEmptyChunkName() + { + Assert.Throws(() => new LuaScript("return true", string.Empty)); + } + + [Fact] + public void LuaScriptRejectsNullSource() + { + Assert.Throws(() => new LuaScript(null!)); + } + + [Fact] + public void LuaScriptPreservesValidatedSourceAndChunkName() + { + LuaScript script = new("return 21 * 2", "=calculation"); + + Assert.Equal("return 21 * 2", script.Source); + Assert.Equal("=calculation", script.ChunkName); + } + + [Fact] + public void PublicLuaContractsDoNotExposeRawSdkLuaHandles() + { + Type[] contracts = + [ + typeof(ILuaClient), + typeof(ILuaModule), + typeof(ILuaModuleLease), + typeof(ILuaOperation<>), + typeof(ILuaExecutionContext), + typeof(IUnsafeLuaClient), + typeof(LuaScript) + ]; + + IEnumerable signatures = contracts.SelectMany(GetPublicSignatureTypes) + .Select(static type => type.FullName ?? type.Name); + + Assert.DoesNotContain(signatures, static typeName => + typeName.StartsWith("CheatEngine.SDK.Lua.State.LuaState", StringComparison.Ordinal) + || typeName.StartsWith("CheatEngine.SDK.Lua.References.LuaRef", StringComparison.Ordinal)); + } + + private static IEnumerable GetPublicSignatureTypes(Type type) + { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static)) + { + yield return property.PropertyType; + } + + foreach (MethodInfo method in + type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + yield return method.ReturnType; + foreach (ParameterInfo parameter in method.GetParameters()) + { + yield return parameter.ParameterType; + } + } + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs new file mode 100644 index 0000000..47321d6 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs @@ -0,0 +1,125 @@ +using CheatEngine.Client.Memory; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Tests.Memory; + +public sealed class MemoryBoundedRequestTests +{ + [Fact] + public void BytesWriteRequestCopiesCallerDataIntoAnImmutablePayload() + { + byte[] source = [0x90, 0x90]; + Address address = 0x401000; + + MemoryBytesWriteRequest request = new(address, source); + source[0] = 0xCC; + + Assert.Equal(address, request.Address); + Assert.Equal([0x90, 0x90], request.Bytes); + } + + [Fact] + public void BytesWriteRequestRejectsAnEmptyPayload() + { + Assert.Throws(() => new MemoryBytesWriteRequest(0x401000, Array.Empty())); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void BytesReadRequestRejectsANonPositiveLength(int length) + { + Assert.Throws(() => new MemoryBytesReadRequest(0x401000, length)); + } + + [Fact] + public void BytesReadRequestPreservesTheExactPositiveLength() + { + Address address = 0x401020; + + MemoryBytesReadRequest request = new(address, 64); + + Assert.Equal(address, request.Address); + Assert.Equal(64, request.Length); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void StringReadRequestRejectsANonPositiveMaximumLength(int maximumLength) + { + Assert.Throws(() => new MemoryStringReadRequest( + 0x401000, maximumLength, true)); + } + + [Fact] + public void StringReadRequestPreservesTheExplicitMaximumAndEncodingChoice() + { + Address address = 0x402000; + + MemoryStringReadRequest request = new(address, 128, true); + + Assert.Equal(address, request.Address); + Assert.Equal(128, request.MaximumLength); + Assert.True(request.WideCharacter); + } + + [Fact] + public void StringWriteRequestPreservesTextAndTheExplicitEncodingChoice() + { + Address address = 0x402100; + + MemoryStringWriteRequest request = new(address, "Player one", true); + + Assert.Equal(address, request.Address); + Assert.Equal("Player one", request.Value); + Assert.True(request.WideCharacter); + } + + [Fact] + public void StringWriteRequestRejectsNullText() + { + Assert.Throws(() => new MemoryStringWriteRequest(0x402200, null!)); + } + + [Fact] + public void PointerChainRequestCopiesOffsetsAndPreservesTheirOrder() + { + long[] offsets = [0x10, -0x20, 0x30]; + Address baseAddress = 0x500000; + + PointerChainRequest request = new(baseAddress, offsets); + offsets[0] = 0x7F; + + Assert.Equal(baseAddress, request.BaseAddress); + Assert.Equal([0x10L, -0x20L, 0x30L], request.Offsets); + } + + [Fact] + public void PointerChainRequestRejectsEmptyOrOverlongChains() + { + long[] overlongOffsets = new long[65]; + + Assert.Throws(() => new PointerChainRequest(0x500000, Array.Empty())); + Assert.Throws(() => new PointerChainRequest(0x500000, overlongOffsets)); + } + + [Fact] + public void PointerChainRequestAcceptsTheDocumentedMaximumHopCount() + { + long[] offsets = new long[64]; + offsets[63] = 0x40; + + PointerChainRequest request = new(0x501000, offsets); + + Assert.Equal(64, request.Offsets.Length); + Assert.Equal(0x40, request.Offsets[^1]); + } + + [Fact] + public void TypedRequestsRejectANullCodecBeforeTheyCanReachCheatEngine() + { + Assert.Throws(() => new MemoryReadRequest(0x502000, null!)); + Assert.Throws(() => new MemoryWriteRequest(0x502000, 123, null!)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs new file mode 100644 index 0000000..73f51c6 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs @@ -0,0 +1,49 @@ +using CheatEngine.Client.Processes; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Tests.Processes; + +public sealed class ProcessSnapshotTests +{ + [Fact] + public void SnapshotPreservesCopiedIdentityArchitectureAndSelectionEpoch() + { + ProcessSnapshot snapshot = new( + new TargetProcessId(42), + "fixture", + "C:\\fixtures\\fixture.exe", + CheatEngineArchitecture.X64, + 7); + + Assert.Equal(new TargetProcessId(42), snapshot.Id); + Assert.Equal("fixture", snapshot.Name); + Assert.Equal("C:\\fixtures\\fixture.exe", snapshot.ExecutablePath); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, snapshot.TargetPointerSize); + Assert.Equal(7, snapshot.SelectionEpoch); + } + + [Fact] + public void LegacyShapeLeavesTargetFactsUnknownAtTheInitialSelectionEpoch() + { + ProcessSnapshot snapshot = new(new TargetProcessId(42), "fixture", null); + + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, snapshot.TargetPointerSize); + Assert.Equal(0, snapshot.SelectionEpoch); + } + + [Theory] + [InlineData(-1)] + [InlineData(long.MinValue)] + public void SnapshotRejectsNegativeSelectionEpoch(long selectionEpoch) + { + Assert.Throws(() => new ProcessSnapshot( + new TargetProcessId(42), + "fixture", + null, + CheatEngineArchitecture.X64, + selectionEpoch)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/README.md b/tests/CheatEngine.Client.Abstractions.Tests/README.md index c5d58f5..b41c2e3 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/README.md +++ b/tests/CheatEngine.Client.Abstractions.Tests/README.md @@ -1,4 +1,29 @@ # CheatEngine.Client.Abstractions.Tests -Tests of [`CheatEngine.Client.Abstractions`](../../libs/CheatEngine.Client.Abstractions/README.md). References its -subject only. +## Context + +This is the contract test project for the immutable public requests, snapshots, failures, and interfaces used by the +functional CheatEngine.Client namespaces. + +## Why this project exists + +Contracts are consumed by every layer and should remain meaningful without a running Cheat Engine host. This suite +tests AOB pattern normalization, bounded memory requests and pointer chains, process and runtime snapshots, failure +classification, inspection leases, table definitions, Lua contracts, and the public value-scan state model. + +## How it helps improve CheatEngine.Client + +The tests make validation rules and default values executable. They prevent an API change from silently broadening a +request, erasing an expected failure category, or exposing a host-specific behavior in a contract that consumers must +be able to use during design time and unit testing. + +They do not run Cheat Engine and do not claim live value-scan support; the latter is tested here only as a +capability-gated public contract. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Abstractions.Tests\CheatEngine.Client.Abstractions.Tests.csproj --configuration Release +``` diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs new file mode 100644 index 0000000..553b6e1 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs @@ -0,0 +1,78 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Tests.Results; + +public sealed class CheatEngineFailureTests +{ + [Fact] + public void ConstructorPreservesClassifiedDiagnosticAndInnerException() + { + InvalidOperationException innerException = new("sdk failure"); + + CheatEngineFailure failure = new( + CheatEngineFailureKind.MemoryReadFailed, + "Memory.Read", + "The target rejected the read.", + innerException); + + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, failure.Kind); + Assert.Equal("Memory.Read", failure.Operation); + Assert.Equal("The target rejected the read.", failure.Message); + Assert.Same(innerException, failure.Exception); + } + + [Fact] + public void ConstructorRejectsMissingOperationOrMessage() + { + Assert.Throws(() => new CheatEngineFailure( + CheatEngineFailureKind.Unknown, " ", "message")); + Assert.Throws(() => new CheatEngineFailure( + CheatEngineFailureKind.Unknown, "Operation", " ")); + } + + [Fact] + public void ThrowCreatesOperationExceptionThatRetainsFailure() + { + InvalidOperationException innerException = new("lua failure"); + CheatEngineFailure failure = new( + CheatEngineFailureKind.LuaError, + "Lua.Execute", + "Lua returned an error.", + innerException); + + CheatEngineOperationException exception = Assert.Throws(failure.Throw); + + Assert.Equal(failure, exception.Failure); + Assert.Equal(failure.Message, exception.Message); + Assert.Same(innerException, exception.InnerException); + } + + [Fact] + public void ThrowPreservesTheDedicatedActivationExpiredException() + { + InvalidOperationException innerException = new("epoch changed"); + CheatEngineFailure failure = new( + CheatEngineFailureKind.ActivationExpired, + "Memory.Read", + "The client epoch is stale.", + innerException); + + CheatEngineActivationExpiredException exception = + Assert.Throws(failure.Throw); + + Assert.Equal(failure, exception.Failure); + Assert.Same(innerException, exception.InnerException); + } + + [Fact] + public void LifecycleExceptionsClassifyTheirSpecificLifecycleFailures() + { + CheatEngineActivationExpiredException expired = new("Table.Read", "The epoch changed."); + CheatEngineClientLifecycleException stopping = new("Client.Track", "The provider is stopping."); + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, expired.Failure.Kind); + Assert.Equal("Table.Read", expired.Failure.Operation); + Assert.Equal(CheatEngineFailureKind.InvalidState, stopping.Failure.Kind); + Assert.Equal("Client.Track", stopping.Failure.Operation); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs new file mode 100644 index 0000000..be6a661 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs @@ -0,0 +1,65 @@ +using System.Globalization; + +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Tests.Runtime; + +public sealed class CheatEngineRuntimeSnapshotTests +{ + [Fact] + public void SnapshotKeepsObservedCeVersionSeparateFromTheQualifiedFourPartBaseline() + { + CheatEngineRuntimeSnapshot snapshot = Create(); + + Assert.Equal(7.7d, snapshot.ObservedCheatEngineVersion); + Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), snapshot.QualifiedCheatEngineBaseline); + Assert.NotEqual( + snapshot.ObservedCheatEngineVersion!.Value.ToString(CultureInfo.InvariantCulture), + snapshot.QualifiedCheatEngineBaseline.ToString()); + Assert.True(snapshot.IsOnQualifiedCheatEngineLine); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(-0.1d)] + public void SnapshotRejectsInvalidObservedCeVersion(double observedVersion) + { + Assert.Throws(() => Create(observedVersion)); + } + + [Fact] + public void SnapshotAllowsAnUnavailableObservedVersionAndKeepsUnknownTargetFacts() + { + CheatEngineRuntimeSnapshot snapshot = Create(null); + + Assert.Null(snapshot.ObservedCheatEngineVersion); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, snapshot.TargetPointerSize); + } + + [Theory] + [InlineData(-1)] + [InlineData(long.MinValue)] + public void SnapshotRejectsNegativeActivationEpoch(long epoch) + { + Assert.Throws(() => Create(epoch: epoch)); + } + + private static CheatEngineRuntimeSnapshot Create(double? observedVersion = 7.7d, long epoch = 42) + { + return new CheatEngineRuntimeSnapshot( + epoch, + observedVersion, + CheatEngineVersion.Ce77010621, + new Version(0, 1, 0), + new Version(1, 0, 0), + CheatEngineArchitecture.X64, + CheatEngineArchitecture.Unknown, + PointerSize.Unknown, + TargetAbi.Windows, + RuntimeCapabilities.Empty, + ClientCapabilities.Empty); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs new file mode 100644 index 0000000..f94b68c --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs @@ -0,0 +1,65 @@ +using CheatEngine.Client.Runtime; + +namespace CheatEngine.Client.Tests.Runtime; + +public sealed class ClientCapabilitiesTests +{ + [Fact] + public void CollectionPreservesDistinctObservedCapabilitiesAndTheirReasons() + { + ClientCapabilityAvailability unsafeLua = new( + ClientCapabilityId.UnsafeLuaExecution, + ClientCapabilityAvailabilityState.Available, + "Explicit opt-in."); + ClientCapabilityAvailability valueScanning = new( + ClientCapabilityId.ValueScanning, + ClientCapabilityAvailabilityState.Unavailable, + "Live ownership gate pending."); + + ClientCapabilities capabilities = ClientCapabilities.Create([unsafeLua, valueScanning]); + + Assert.Equal(2, capabilities.Count); + Assert.True(capabilities.TryGet(ClientCapabilityId.UnsafeLuaExecution, + out ClientCapabilityAvailability foundUnsafeLua)); + Assert.Equal(unsafeLua, foundUnsafeLua); + Assert.True(foundUnsafeLua.IsAvailable); + Assert.True(capabilities.TryGet(ClientCapabilityId.ValueScanning, + out ClientCapabilityAvailability foundValueScanning)); + Assert.Equal(valueScanning, foundValueScanning); + Assert.False(foundValueScanning.IsAvailable); + Assert.True(foundValueScanning.IsKnown); + } + + [Fact] + public void CollectionRejectsDuplicateCapabilityIdentifiers() + { + ClientCapabilityAvailability observation = new( + ClientCapabilityId.ValueScanning, + ClientCapabilityAvailabilityState.Unavailable, + "Live ownership gate pending."); + + Assert.Throws(() => ClientCapabilities.Create([observation, observation])); + } + + [Theory] + [InlineData(3)] + [InlineData(255)] + public void AvailabilityRejectsUndefinedStates(byte state) + { + Assert.Throws(() => new ClientCapabilityAvailability( + ClientCapabilityId.ValueScanning, + (ClientCapabilityAvailabilityState) state, + "Invalid test state.")); + } + + [Fact] + public void CapabilityIdentifierRejectsBlankInputAndFormatsItsStableValue() + { + Assert.Throws(() => new ClientCapabilityId(" ")); + + ClientCapabilityId capability = new("Client.Sample"); + + Assert.Equal("Client.Sample", capability.Value); + Assert.Equal(capability.Value, capability.ToString()); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs new file mode 100644 index 0000000..48cfe0a --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs @@ -0,0 +1,137 @@ +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Tests.Scanning; + +public sealed class AobPatternTests +{ + [Fact] + public void PatternNormalizesWhitespaceAndHexadecimalCasing() + { + AobPattern pattern = new(" 48\t8b\r\n??89 "); + + Assert.Equal("48 8B ?? 89", pattern.Value); + Assert.Equal(pattern.Value, pattern.ToString()); + Assert.Equal(4, pattern.ByteLength); + Assert.False(pattern.IsWildcardOnly); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public void PatternRejectsBlankText(string patternText) + { + Assert.Throws(() => new AobPattern(patternText)); + } + + [Theory] + [InlineData("4")] + [InlineData("GG")] + [InlineData("?")] + [InlineData("?0")] + [InlineData("00,")] + [InlineData("00 ? ?")] + public void PatternRejectsTokensOutsideTheDocumentedStringFormSubset(string patternText) + { + Assert.Throws(() => new AobPattern(patternText)); + Assert.False(AobPattern.TryParse(patternText, out _)); + } + + [Fact] + public void PatternTryParseNormalizesWildcardOnlyPatternsWithoutThrowing() + { + bool parsed = AobPattern.TryParse("??\t??", out AobPattern pattern); + + Assert.True(parsed); + Assert.Equal("?? ??", pattern.Value); + Assert.Equal(2, pattern.ByteLength); + Assert.True(pattern.IsWildcardOnly); + } + + [Fact] + public void RequestPreservesPatternOptionsLimitAndModule() + { + AobPattern pattern = new("90 90"); + AobScanOptions options = new("-w+x-c", default, null); + ModuleName module = new("game.exe"); + AobScanRange range = new(0x400000, 0x4FFFFF); + + AobScanRequest request = new(pattern, options, 2, module, range); + + Assert.Equal(pattern, request.Pattern); + Assert.Equal("+X-C-W", request.Options.ProtectionFlags); + Assert.Equal(2, request.MaximumResults); + Assert.Equal(module, request.Module); + Assert.Equal(range, request.Range); + } + + [Fact] + public void RequestNormalizesAlignmentParametersBeforeTheyReachTheScanner() + { + AobScanRequest aligned = new( + new AobPattern("90"), new AobScanOptions(null, FastScanMethod.Aligned, "00016"), 1); + AobScanRequest lastDigits = new( + new AobPattern("90"), new AobScanOptions(null, FastScanMethod.LastDigits, "f0"), 1); + + Assert.Equal("16", aligned.Options.AlignmentParameter); + Assert.Equal("F0", lastDigits.Options.AlignmentParameter); + } + + [Theory] + [InlineData("X")] + [InlineData("+X+X")] + [InlineData("+Q")] + [InlineData("+X ")] + public void RequestRejectsMalformedProtectionExpressions(string protection) + { + Assert.Throws(() => new AobScanRequest( + new AobPattern("90"), new AobScanOptions(protection, FastScanMethod.NotAligned, null), 1)); + } + + [Theory] + [InlineData("0")] + [InlineData("-4")] + [InlineData("FFGG")] + public void RequestRejectsMalformedAlignmentParameters(string parameter) + { + Assert.Throws(() => new AobScanRequest( + new AobPattern("90"), new AobScanOptions(null, FastScanMethod.Aligned, parameter), 1)); + } + + [Fact] + public void RangeIsInclusiveAtBothBoundaries() + { + AobScanRange range = new(0x1000, 0x1FFF); + + Assert.True(range.Contains(0x1000)); + Assert.True(range.Contains(0x1FFF)); + Assert.False(range.Contains(0x0FFF)); + Assert.False(range.Contains(0x2000)); + Assert.Throws(() => new AobScanRange(0x2000, 0x1FFF)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void RequestRejectsNonPositiveMaterializationLimit(int maximumResults) + { + Assert.Throws(() => new AobScanRequest( + new AobPattern("90"), AobScanOptions.Default, maximumResults)); + } + + [Fact] + public void RequestRejectsDefaultPatternBeforeReachingTheScanner() + { + Assert.Throws(() => new AobScanRequest( + default, AobScanOptions.Default, 1)); + } + + [Fact] + public void ResultRejectsTruncationWithoutAnyRetainedMatch() + { + Assert.Throws(() => new AobScanResult([], true)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs new file mode 100644 index 0000000..1f29218 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs @@ -0,0 +1,43 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Tests.Scanning; + +public sealed class ValueScanContractTests +{ + [Fact] + public void SessionStateUsesTheClientLifecycleWithoutSdkHandles() + { + Assert.Equal(0, (int) ValueScanSessionState.Created); + Assert.Equal(1, (int) ValueScanSessionState.Scanning); + Assert.Equal(2, (int) ValueScanSessionState.ResultsReady); + Assert.Equal(3, (int) ValueScanSessionState.Invalidated); + Assert.Equal(4, (int) ValueScanSessionState.Disposed); + } + + [Fact] + public void ReadRequestRejectsNegativeStartAndNonPositiveMaterializationLimit() + { + Assert.Throws(() => new ValueScanReadRequest(-1, 1)); + Assert.Throws(() => new ValueScanReadRequest(0, 0)); + } + + [Fact] + public void ResultPageNormalizesDefaultImmutableArrayWithoutChangingTheObservedCount() + { + ValueScanPage page = new(42, default); + + Assert.Equal((ulong) 42, page.TotalCount); + Assert.True(page.Matches.IsEmpty); + Assert.Equal(ImmutableArray.Empty, page.Matches); + } + + [Fact] + public void MatchRejectsNegativeIndexAndNullValue() + { + Assert.Throws(() => new ValueScanMatch(-1, Address.Zero, "7")); + Assert.Throws(() => new ValueScanMatch(0, Address.Zero, null!)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs new file mode 100644 index 0000000..8ce3f16 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Tests.Tables; + +public sealed class TableContractTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MemoryRecordCollectionRequestRejectsNonPositiveBounds(int maximumItems) + { + Assert.Throws(() => new MemoryRecordCollectionRequest(maximumItems)); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 0)] + [InlineData(-1, 1)] + [InlineData(1, -1)] + public void MemoryRecordHierarchyRequestRejectsNonPositiveBounds(int maximumItems, int maximumDepth) + { + Assert.Throws(() => + new MemoryRecordHierarchyRequest(maximumItems, maximumDepth)); + } + + [Fact] + public void MemoryRecordSearchRequiresAtLeastOneMeaningfulPredicate() + { + Assert.Throws(() => new MemoryRecordSearch(null)); + Assert.Throws(() => new MemoryRecordSearch(string.Empty)); + Assert.Throws(() => new MemoryRecordSearch(addressExpression: string.Empty)); + } + + [Fact] + public void MemoryRecordSearchPreservesConjunctivePredicates() + { + MemoryRecordSearch search = new("health", "game.exe+20", VariableType.Dword, true); + + Assert.Equal("health", search.DescriptionContains); + Assert.Equal("game.exe+20", search.AddressExpression); + Assert.Equal(VariableType.Dword, search.VariableType); + Assert.True(search.IsActive); + } + + [Fact] + public void SnapshotCarriesCopiedRecordsAndCardinality() + { + MemoryRecordSnapshot record = new(new MemoryRecordId(12), 0, "Health", "game.exe+20", "100", + VariableType.Dword, Address.FromUInt64(0x1400), true, 2); + AddressTableSnapshot snapshot = new(ImmutableArray.Create(record)); + + Assert.Equal(1, snapshot.RecordCount); + Assert.Single(snapshot.Records); + Assert.Equal(record, snapshot.Records[0]); + Assert.True(snapshot.Records[0].IsActive); + Assert.Equal(2, snapshot.Records[0].ChildCount); + } + + [Fact] + public void MemoryRecordSnapshotRejectsNegativeChildCount() + { + Assert.Throws(() => new MemoryRecordSnapshot(new MemoryRecordId(12), 0, "Health", + "game.exe+20", "100", VariableType.Dword, null, childCount: -1)); + } + + [Fact] + public void HierarchySnapshotRetainsItsCopiedRootAndChildren() + { + MemoryRecordSnapshot root = new(new MemoryRecordId(12), 0, "Health", "game.exe+20", "100", + VariableType.Dword, null, childCount: 1); + MemoryRecordSnapshot child = new(new MemoryRecordId(13), 1, "Ammo", "game.exe+24", "50", + VariableType.Dword, null); + MemoryRecordHierarchySnapshot hierarchy = new(root, + ImmutableArray.Create(new MemoryRecordHierarchySnapshot(child, []))); + + Assert.Equal(root, hierarchy.Record); + Assert.Single(hierarchy.Children); + Assert.Equal(child, hierarchy.Children[0].Record); + Assert.Empty(hierarchy.Children[0].Children); + } + + [Fact] + public void MemoryRecordDefinitionRetainsAnOptionalParentId() + { + MemoryRecordDefinition definition = new("Ammo", "game.exe+24", "50", VariableType.Dword, + new MemoryRecordId(12)); + + Assert.Equal(new MemoryRecordId(12), definition.ParentId); + } + + [Fact] + public void MemoryRecordDefinitionDefaultsToTheAddressListRoot() + { + MemoryRecordDefinition definition = new("Ammo", "game.exe+24", "50", VariableType.Dword); + + Assert.Null(definition.ParentId); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json b/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json new file mode 100644 index 0000000..ef33d6d --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json @@ -0,0 +1,198 @@ +{ + "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.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + } + } + } +} From e688f0e7aa322d240e8f98c553ad200a7c9803f4 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 20:33:09 +0200 Subject: [PATCH 02/17] fix: remediate foundation Sonar findings Replace wide runtime and memory-record snapshot constructors with cohesive, explicitly named value contracts while preserving the leaf accessors. Resolve the foundation xUnit single-item assertions, cover cardinality and bounded-record contract behavior, and publish native-MTP Cobertura coverage for the historical Foundation quality gate. --- .github/workflows/build.yml | 7 +- .../PublicAPI.Shipped.txt | 59 +++++++- .../Runtime/CheatEngineRuntimePlatformInfo.cs | 44 ++++++ .../Runtime/CheatEngineRuntimeSnapshot.cs | 64 ++++---- .../Runtime/CheatEngineRuntimeVersionInfo.cs | 51 +++++++ .../Tables/MemoryRecordContentSnapshot.cs | 44 ++++++ .../Tables/MemoryRecordSnapshot.cs | 50 +++---- .../Tables/MemoryRecordStateSnapshot.cs | 35 +++++ .../CheatEngineRuntimeSnapshotTests.cs | 94 +++++++++++- .../Tables/TableContractTests.cs | 138 ++++++++++++++++-- 10 files changed, 499 insertions(+), 87 deletions(-) create mode 100644 libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordStateSnapshot.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ea07b55..905a0bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,9 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"CheatEngineNet_CheatEngine.Client" /o:"cheatenginenet" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" - dotnet build + ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"CheatEngineNet_CheatEngine.Client" /o:"cheatenginenet" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.cobertura.reportsPaths="artifacts/sonar-test-results/**/*.cobertura.xml" + dotnet build CheatEngine.Client.slnx --configuration Release + # The stacked branches deliberately introduce some test projects before their first tests. Test failures still + # terminate dotnet test; the default runner behavior simply permits these empty intermediate projects. + dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --coverage --coverage-output-format cobertura --results-directory artifacts/sonar-test-results ${{ runner.temp }}\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt index e1099e4..06c28ef 100644 --- a/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt @@ -23,8 +23,12 @@ ~override CheatEngine.Client.Processes.ProcessSnapshot.ToString() -> string ~override CheatEngine.Client.Results.CheatEngineFailure.Equals(object obj) -> bool ~override CheatEngine.Client.Results.CheatEngineFailure.ToString() -> string +~override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.ToString() -> string ~override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Equals(object obj) -> bool ~override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ToString() -> string +~override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.Equals(object obj) -> bool +~override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.ToString() -> string ~override CheatEngine.Client.Runtime.ClientCapabilityAvailability.Equals(object obj) -> bool ~override CheatEngine.Client.Runtime.ClientCapabilityAvailability.ToString() -> string ~override CheatEngine.Client.Scanning.AobPattern.Equals(object obj) -> bool @@ -52,8 +56,12 @@ ~override CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.ToString() -> string ~override CheatEngine.Client.Tables.MemoryRecordSearch.Equals(object obj) -> bool ~override CheatEngine.Client.Tables.MemoryRecordSearch.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.ToString() -> string ~override CheatEngine.Client.Tables.MemoryRecordSnapshot.Equals(object obj) -> bool ~override CheatEngine.Client.Tables.MemoryRecordSnapshot.ToString() -> string +~override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.ToString() -> string ~override CheatEngine.Client.Tables.MemoryRecordUpdate.Equals(object obj) -> bool ~override CheatEngine.Client.Tables.MemoryRecordUpdate.ToString() -> string ~override CheatEngine.Client.Tables.TableLoadRequest.Equals(object obj) -> bool @@ -270,9 +278,17 @@ CheatEngine.Client.Results.CheatEngineFailureKind.Unknown = 0 -> CheatEngine.Cli CheatEngine.Client.Results.CheatEngineFailureKind.Unsupported = 10 -> CheatEngine.Client.Results.CheatEngineFailureKind CheatEngine.Client.Results.CheatEngineOperationException CheatEngine.Client.Results.CheatEngineOperationException.CheatEngineOperationException(CheatEngine.Client.Results.CheatEngineFailure failure) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.CheatEngineRuntimePlatformInfo() -> void +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.CheatEngineRuntimePlatformInfo(CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture systemArchitecture, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, CheatEngine.SDK.Engine.Runtime.PointerSize targetPointerSize, CheatEngine.SDK.Engine.Runtime.TargetAbi targetAbi) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo other) -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.TargetPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot() -> void -CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot(long epoch, double? observedCheatEngineVersion, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion qualifiedCheatEngineBaseline, System.Version! clientAssemblyVersion, System.Version! sdkAssemblyVersion, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture systemArchitecture, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, CheatEngine.SDK.Engine.Runtime.PointerSize targetPointerSize, CheatEngine.SDK.Engine.Runtime.TargetAbi targetAbi, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! sdkCapabilities, CheatEngine.Client.Runtime.ClientCapabilities! clientCapabilities) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.CheatEngineRuntimeSnapshot(long epoch, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo versionInfo, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo platformInfo, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! sdkCapabilities, CheatEngine.Client.Runtime.ClientCapabilities! clientCapabilities) -> void CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ClientAssemblyVersion.get -> System.Version! CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.ClientCapabilities.get -> CheatEngine.Client.Runtime.ClientCapabilities! CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Epoch.get -> long @@ -286,6 +302,16 @@ CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.SystemArchitecture.get -> CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.TargetPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Platform.get -> CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo +CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.Version.get -> CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.CheatEngineRuntimeVersionInfo() -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.CheatEngineRuntimeVersionInfo(double? observedCheatEngineVersion, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion qualifiedCheatEngineBaseline, System.Version! clientAssemblyVersion, System.Version! sdkAssemblyVersion) -> void +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.ClientAssemblyVersion.get -> System.Version! +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.Equals(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo other) -> bool +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.ObservedCheatEngineVersion.get -> double? +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.QualifiedCheatEngineBaseline.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.SdkAssemblyVersion.get -> System.Version! CheatEngine.Client.Runtime.ClientCapabilities CheatEngine.Client.Runtime.ClientCapabilities.Count.get -> int CheatEngine.Client.Runtime.ClientCapabilities.Entries.get -> System.ReadOnlySpan @@ -465,6 +491,14 @@ CheatEngine.Client.Tables.MemoryRecordSearch.IsActive.get -> bool? CheatEngine.Client.Tables.MemoryRecordSearch.MemoryRecordSearch() -> void CheatEngine.Client.Tables.MemoryRecordSearch.MemoryRecordSearch(string? descriptionContains = null, string? addressExpression = null, CheatEngine.SDK.Engine.Enums.VariableType? variableType = null, bool? isActive = null) -> void CheatEngine.Client.Tables.MemoryRecordSearch.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType? +CheatEngine.Client.Tables.MemoryRecordContentSnapshot +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.AddressExpression.get -> string! +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Description.get -> string! +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordContentSnapshot other) -> bool +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.MemoryRecordContentSnapshot() -> void +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.MemoryRecordContentSnapshot(string! description, string! addressExpression, string! value, CheatEngine.SDK.Engine.Enums.VariableType variableType) -> void +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.Value.get -> string! +CheatEngine.Client.Tables.MemoryRecordContentSnapshot.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType CheatEngine.Client.Tables.MemoryRecordSnapshot CheatEngine.Client.Tables.MemoryRecordSnapshot.AddressExpression.get -> string! CheatEngine.Client.Tables.MemoryRecordSnapshot.ChildCount.get -> int @@ -475,9 +509,18 @@ CheatEngine.Client.Tables.MemoryRecordSnapshot.Id.get -> CheatEngine.SDK.Engine. CheatEngine.Client.Tables.MemoryRecordSnapshot.Index.get -> int CheatEngine.Client.Tables.MemoryRecordSnapshot.IsActive.get -> bool CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot() -> void -CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, int index, string! description, string! addressExpression, string! value, CheatEngine.SDK.Engine.Enums.VariableType variableType, CheatEngine.SDK.Engine.Values.Address? currentAddress, bool isActive = false, int childCount = 0) -> void +CheatEngine.Client.Tables.MemoryRecordSnapshot.MemoryRecordSnapshot(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, int index, CheatEngine.Client.Tables.MemoryRecordContentSnapshot content, CheatEngine.Client.Tables.MemoryRecordStateSnapshot state) -> void +CheatEngine.Client.Tables.MemoryRecordSnapshot.Content.get -> CheatEngine.Client.Tables.MemoryRecordContentSnapshot +CheatEngine.Client.Tables.MemoryRecordSnapshot.State.get -> CheatEngine.Client.Tables.MemoryRecordStateSnapshot CheatEngine.Client.Tables.MemoryRecordSnapshot.Value.get -> string! CheatEngine.Client.Tables.MemoryRecordSnapshot.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.Client.Tables.MemoryRecordStateSnapshot +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.ChildCount.get -> int +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.CurrentAddress.get -> CheatEngine.SDK.Engine.Values.Address? +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.Equals(CheatEngine.Client.Tables.MemoryRecordStateSnapshot other) -> bool +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.IsActive.get -> bool +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.MemoryRecordStateSnapshot() -> void +CheatEngine.Client.Tables.MemoryRecordStateSnapshot.MemoryRecordStateSnapshot(CheatEngine.SDK.Engine.Values.Address? currentAddress, bool isActive = false, int childCount = 0) -> void CheatEngine.Client.Tables.MemoryRecordUpdate CheatEngine.Client.Tables.MemoryRecordUpdate.AddressExpression.get -> string? CheatEngine.Client.Tables.MemoryRecordUpdate.Description.get -> string? @@ -520,7 +563,9 @@ override CheatEngine.Client.Memory.MemoryWriteRequest.GetHashCode() -> int override CheatEngine.Client.Memory.PointerChainRequest.GetHashCode() -> int override CheatEngine.Client.Processes.ProcessSnapshot.GetHashCode() -> int override CheatEngine.Client.Results.CheatEngineFailure.GetHashCode() -> int +override CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.GetHashCode() -> int override CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.GetHashCode() -> int +override CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.GetHashCode() -> int override CheatEngine.Client.Runtime.ClientCapabilities.Equals(object? obj) -> bool override CheatEngine.Client.Runtime.ClientCapabilities.GetHashCode() -> int override CheatEngine.Client.Runtime.ClientCapabilityAvailability.GetHashCode() -> int @@ -541,7 +586,9 @@ override CheatEngine.Client.Tables.MemoryRecordDefinition.GetHashCode() -> int override CheatEngine.Client.Tables.MemoryRecordHierarchyRequest.GetHashCode() -> int override CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.GetHashCode() -> int override CheatEngine.Client.Tables.MemoryRecordSearch.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordContentSnapshot.GetHashCode() -> int override CheatEngine.Client.Tables.MemoryRecordSnapshot.GetHashCode() -> int +override CheatEngine.Client.Tables.MemoryRecordStateSnapshot.GetHashCode() -> int override CheatEngine.Client.Tables.MemoryRecordUpdate.GetHashCode() -> int override CheatEngine.Client.Tables.TableLoadRequest.GetHashCode() -> int override CheatEngine.Client.Tables.TableSaveRequest.GetHashCode() -> int @@ -570,8 +617,12 @@ static CheatEngine.Client.Processes.ProcessSnapshot.operator !=(CheatEngine.Clie static CheatEngine.Client.Processes.ProcessSnapshot.operator ==(CheatEngine.Client.Processes.ProcessSnapshot left, CheatEngine.Client.Processes.ProcessSnapshot right) -> bool static CheatEngine.Client.Results.CheatEngineFailure.operator !=(CheatEngine.Client.Results.CheatEngineFailure left, CheatEngine.Client.Results.CheatEngineFailure right) -> bool static CheatEngine.Client.Results.CheatEngineFailure.operator ==(CheatEngine.Client.Results.CheatEngineFailure left, CheatEngine.Client.Results.CheatEngineFailure right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimePlatformInfo right) -> bool static CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot left, CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot right) -> bool static CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot left, CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.operator !=(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo right) -> bool +static CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo.operator ==(CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo left, CheatEngine.Client.Runtime.CheatEngineRuntimeVersionInfo right) -> bool static CheatEngine.Client.Runtime.ClientCapabilities.Create(System.ReadOnlySpan entries) -> CheatEngine.Client.Runtime.ClientCapabilities! static CheatEngine.Client.Runtime.ClientCapabilities.Empty.get -> CheatEngine.Client.Runtime.ClientCapabilities! static CheatEngine.Client.Runtime.ClientCapabilityAvailability.operator !=(CheatEngine.Client.Runtime.ClientCapabilityAvailability left, CheatEngine.Client.Runtime.ClientCapabilityAvailability right) -> bool @@ -613,8 +664,12 @@ static CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.operator !=(Cheat static CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot left, CheatEngine.Client.Tables.MemoryRecordHierarchySnapshot right) -> bool static CheatEngine.Client.Tables.MemoryRecordSearch.operator !=(CheatEngine.Client.Tables.MemoryRecordSearch left, CheatEngine.Client.Tables.MemoryRecordSearch right) -> bool static CheatEngine.Client.Tables.MemoryRecordSearch.operator ==(CheatEngine.Client.Tables.MemoryRecordSearch left, CheatEngine.Client.Tables.MemoryRecordSearch right) -> bool +static CheatEngine.Client.Tables.MemoryRecordContentSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordContentSnapshot left, CheatEngine.Client.Tables.MemoryRecordContentSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordContentSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordContentSnapshot left, CheatEngine.Client.Tables.MemoryRecordContentSnapshot right) -> bool static CheatEngine.Client.Tables.MemoryRecordSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordSnapshot left, CheatEngine.Client.Tables.MemoryRecordSnapshot right) -> bool static CheatEngine.Client.Tables.MemoryRecordSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordSnapshot left, CheatEngine.Client.Tables.MemoryRecordSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordStateSnapshot.operator !=(CheatEngine.Client.Tables.MemoryRecordStateSnapshot left, CheatEngine.Client.Tables.MemoryRecordStateSnapshot right) -> bool +static CheatEngine.Client.Tables.MemoryRecordStateSnapshot.operator ==(CheatEngine.Client.Tables.MemoryRecordStateSnapshot left, CheatEngine.Client.Tables.MemoryRecordStateSnapshot right) -> bool static CheatEngine.Client.Tables.MemoryRecordUpdate.operator !=(CheatEngine.Client.Tables.MemoryRecordUpdate left, CheatEngine.Client.Tables.MemoryRecordUpdate right) -> bool static CheatEngine.Client.Tables.MemoryRecordUpdate.operator ==(CheatEngine.Client.Tables.MemoryRecordUpdate left, CheatEngine.Client.Tables.MemoryRecordUpdate right) -> bool static CheatEngine.Client.Tables.TableLoadRequest.operator !=(CheatEngine.Client.Tables.TableLoadRequest left, CheatEngine.Client.Tables.TableLoadRequest right) -> bool diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs new file mode 100644 index 0000000..71b72e0 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs @@ -0,0 +1,44 @@ +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Runtime; + +/// Immutable platform observations captured during one active Cheat Engine activation. +public readonly record struct CheatEngineRuntimePlatformInfo +{ + /// Creates runtime platform observations from copied host facts. + public CheatEngineRuntimePlatformInfo( + CheatEngineArchitecture systemArchitecture, + CheatEngineArchitecture targetArchitecture, + PointerSize targetPointerSize, + TargetAbi targetAbi) + { + SystemArchitecture = systemArchitecture; + TargetArchitecture = targetArchitecture; + TargetPointerSize = targetPointerSize; + TargetAbi = targetAbi; + } + + /// Gets the CE host architecture observed from CE's system-architecture global. + public CheatEngineArchitecture SystemArchitecture + { + get; + } + + /// Gets the target architecture observed by a target-specific probe, or unknown. + public CheatEngineArchitecture TargetArchitecture + { + get; + } + + /// Gets the pointer width implied by the observed target architecture, or unknown. + public PointerSize TargetPointerSize + { + get; + } + + /// Gets the target ABI observed from CE's ABI global, or unknown. + public TargetAbi TargetAbi + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs index 57eaf23..bebfe29 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs @@ -11,37 +11,19 @@ namespace CheatEngine.Client.Runtime; /// public readonly record struct CheatEngineRuntimeSnapshot { - /// Creates a runtime snapshot from independently observed facts. + /// Creates a runtime snapshot from grouped version, platform, and capability observations. public CheatEngineRuntimeSnapshot( long epoch, - double? observedCheatEngineVersion, - CheatEngineVersion qualifiedCheatEngineBaseline, - Version clientAssemblyVersion, - Version sdkAssemblyVersion, - CheatEngineArchitecture systemArchitecture, - CheatEngineArchitecture targetArchitecture, - PointerSize targetPointerSize, - TargetAbi targetAbi, + CheatEngineRuntimeVersionInfo versionInfo, + CheatEngineRuntimePlatformInfo platformInfo, RuntimeCapabilities sdkCapabilities, ClientCapabilities clientCapabilities) { ArgumentOutOfRangeException.ThrowIfNegative(epoch); - if (observedCheatEngineVersion is { } observed && - (!double.IsFinite(observed) || observed < 0)) - { - throw new ArgumentOutOfRangeException(nameof(observedCheatEngineVersion), observed, - "The observed Cheat Engine version must be a finite non-negative number when supplied."); - } Epoch = epoch; - ObservedCheatEngineVersion = observedCheatEngineVersion; - QualifiedCheatEngineBaseline = qualifiedCheatEngineBaseline; - ClientAssemblyVersion = clientAssemblyVersion ?? throw new ArgumentNullException(nameof(clientAssemblyVersion)); - SdkAssemblyVersion = sdkAssemblyVersion ?? throw new ArgumentNullException(nameof(sdkAssemblyVersion)); - SystemArchitecture = systemArchitecture; - TargetArchitecture = targetArchitecture; - TargetPointerSize = targetPointerSize; - TargetAbi = targetAbi; + Version = versionInfo; + Platform = platformInfo; SdkCapabilities = sdkCapabilities ?? throw new ArgumentNullException(nameof(sdkCapabilities)); ClientCapabilities = clientCapabilities ?? throw new ArgumentNullException(nameof(clientCapabilities)); } @@ -52,52 +34,64 @@ public long Epoch get; } + /// Gets the grouped version observations captured for this activation. + public CheatEngineRuntimeVersionInfo Version + { + get; + } + + /// Gets the grouped platform observations captured for this activation. + public CheatEngineRuntimePlatformInfo Platform + { + get; + } + /// Gets the coarse number returned by CE's getCEVersion global, when it was callable. public double? ObservedCheatEngineVersion { - get; + get => Version.ObservedCheatEngineVersion; } /// Gets the complete CE build against which this Client release was qualified. public CheatEngineVersion QualifiedCheatEngineBaseline { - get; + get => Version.QualifiedCheatEngineBaseline; } /// Gets the assembly version of this Client abstraction assembly. public Version ClientAssemblyVersion { - get; + get => Version.ClientAssemblyVersion; } /// Gets the assembly version of the SDK runtime-contract assembly. public Version SdkAssemblyVersion { - get; + get => Version.SdkAssemblyVersion; } /// Gets the CE host architecture observed from CE's system-architecture global. public CheatEngineArchitecture SystemArchitecture { - get; + get => Platform.SystemArchitecture; } /// Gets the target architecture observed by a target-specific probe, or unknown. public CheatEngineArchitecture TargetArchitecture { - get; + get => Platform.TargetArchitecture; } /// Gets the pointer width implied by the observed target architecture, or unknown. public PointerSize TargetPointerSize { - get; + get => Platform.TargetPointerSize; } /// Gets the target ABI observed from CE's ABI global, or unknown. public TargetAbi TargetAbi { - get; + get => Platform.TargetAbi; } /// Gets the explicit availability observation for each SDK runtime capability that was probed. @@ -114,8 +108,8 @@ public ClientCapabilities ClientCapabilities /// Gets whether the observed coarse CE version belongs to the qualified major/minor line. public bool IsOnQualifiedCheatEngineLine => ObservedCheatEngineVersion is { } observed && - observed >= QualifiedCheatEngineBaseline.Major + - QualifiedCheatEngineBaseline.Minor / 10d && - observed < QualifiedCheatEngineBaseline.Major + - (QualifiedCheatEngineBaseline.Minor + 1) / 10d; + observed >= QualifiedCheatEngineBaseline.Major + + QualifiedCheatEngineBaseline.Minor / 10d && + observed < QualifiedCheatEngineBaseline.Major + + (QualifiedCheatEngineBaseline.Minor + 1) / 10d; } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs new file mode 100644 index 0000000..f879e54 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs @@ -0,0 +1,51 @@ +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Runtime; + +/// Immutable version observations captured during one active Cheat Engine activation. +public readonly record struct CheatEngineRuntimeVersionInfo +{ + /// Creates runtime version observations from independently observed facts. + public CheatEngineRuntimeVersionInfo( + double? observedCheatEngineVersion, + CheatEngineVersion qualifiedCheatEngineBaseline, + Version clientAssemblyVersion, + Version sdkAssemblyVersion) + { + if (observedCheatEngineVersion is { } observed && + (!double.IsFinite(observed) || observed < 0)) + { + throw new ArgumentOutOfRangeException(nameof(observedCheatEngineVersion), observed, + "The observed Cheat Engine version must be a finite non-negative number when supplied."); + } + + ObservedCheatEngineVersion = observedCheatEngineVersion; + QualifiedCheatEngineBaseline = qualifiedCheatEngineBaseline; + ClientAssemblyVersion = clientAssemblyVersion ?? throw new ArgumentNullException(nameof(clientAssemblyVersion)); + SdkAssemblyVersion = sdkAssemblyVersion ?? throw new ArgumentNullException(nameof(sdkAssemblyVersion)); + } + + /// Gets the coarse number returned by CE's getCEVersion global, when it was callable. + public double? ObservedCheatEngineVersion + { + get; + } + + /// Gets the complete CE build against which this Client release was qualified. + public CheatEngineVersion QualifiedCheatEngineBaseline + { + get; + } + + /// Gets the assembly version of this Client abstraction assembly. + public Version ClientAssemblyVersion + { + get; + } + + /// Gets the assembly version of the SDK runtime-contract assembly. + public Version SdkAssemblyVersion + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs new file mode 100644 index 0000000..7c59637 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs @@ -0,0 +1,44 @@ +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Tables; + +/// Copied content fields of a Cheat Engine memory record. +public readonly record struct MemoryRecordContentSnapshot +{ + /// Creates copied content fields for a memory-record snapshot. + public MemoryRecordContentSnapshot(string description, string addressExpression, string value, VariableType variableType) + { + ArgumentNullException.ThrowIfNull(description); + ArgumentNullException.ThrowIfNull(addressExpression); + ArgumentNullException.ThrowIfNull(value); + + Description = description; + AddressExpression = addressExpression; + Value = value; + VariableType = variableType; + } + + /// Gets the record display description. + public string Description + { + get; + } + + /// Gets the record's unresolved Cheat Engine address expression. + public string AddressExpression + { + get; + } + + /// Gets the record's verbatim value text. + public string Value + { + get; + } + + /// Gets the record's Cheat Engine value type. + public VariableType VariableType + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs index 383c323..4564ee7 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs @@ -7,33 +7,19 @@ namespace CheatEngine.Client.Tables; /// A copied, handle-free snapshot of a Cheat Engine memory record. public readonly record struct MemoryRecordSnapshot { - /// Creates a copied memory-record snapshot. + /// Creates a copied memory-record snapshot from grouped content and state fields. public MemoryRecordSnapshot( MemoryRecordId id, int index, - string description, - string addressExpression, - string value, - VariableType variableType, - Address? currentAddress, - bool isActive = false, - int childCount = 0) + MemoryRecordContentSnapshot content, + MemoryRecordStateSnapshot state) { ArgumentOutOfRangeException.ThrowIfNegative(index); - ArgumentOutOfRangeException.ThrowIfNegative(childCount); - ArgumentNullException.ThrowIfNull(description); - ArgumentNullException.ThrowIfNull(addressExpression); - ArgumentNullException.ThrowIfNull(value); Id = id; Index = index; - Description = description; - AddressExpression = addressExpression; - Value = value; - VariableType = variableType; - CurrentAddress = currentAddress; - IsActive = isActive; - ChildCount = childCount; + Content = content; + State = state; } /// Gets the stable Cheat Engine record identifier. @@ -48,45 +34,57 @@ public int Index get; } + /// Gets the copied content fields for this memory record. + public MemoryRecordContentSnapshot Content + { + get; + } + + /// Gets the copied runtime state fields for this memory record. + public MemoryRecordStateSnapshot State + { + get; + } + /// Gets the record display description. public string Description { - get; + get => Content.Description; } /// Gets the record's unresolved Cheat Engine address expression. public string AddressExpression { - get; + get => Content.AddressExpression; } /// Gets the record's verbatim value text. public string Value { - get; + get => Content.Value; } /// Gets the record's Cheat Engine value type. public VariableType VariableType { - get; + get => Content.VariableType; } /// Gets the currently resolved target address when it could be obtained. public Address? CurrentAddress { - get; + get => State.CurrentAddress; } /// Gets whether Cheat Engine reports this record as active or frozen. public bool IsActive { - get; + get => State.IsActive; } /// Gets the number of immediate child records reported by Cheat Engine. public int ChildCount { - get; + get => State.ChildCount; } } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordStateSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordStateSnapshot.cs new file mode 100644 index 0000000..40d7768 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordStateSnapshot.cs @@ -0,0 +1,35 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Tables; + +/// Copied runtime state fields of a Cheat Engine memory record. +public readonly record struct MemoryRecordStateSnapshot +{ + /// Creates copied runtime state fields for a memory-record snapshot. + public MemoryRecordStateSnapshot(Address? currentAddress, bool isActive = false, int childCount = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(childCount); + + CurrentAddress = currentAddress; + IsActive = isActive; + ChildCount = childCount; + } + + /// Gets the currently resolved target address when it could be obtained. + public Address? CurrentAddress + { + get; + } + + /// Gets whether Cheat Engine reports this record as active or frozen. + public bool IsActive + { + get; + } + + /// Gets the number of immediate child records reported by Cheat Engine. + public int ChildCount + { + get; + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs index be6a661..67d27ca 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs @@ -20,13 +20,61 @@ public void SnapshotKeepsObservedCeVersionSeparateFromTheQualifiedFourPartBaseli Assert.True(snapshot.IsOnQualifiedCheatEngineLine); } + [Fact] + public void SnapshotForwardsVersionAndPlatformComponentsToItsExistingLeafProperties() + { + Version clientAssemblyVersion = new(0, 1, 2, 3); + Version sdkAssemblyVersion = new(1, 2, 3, 4); + CheatEngineRuntimeVersionInfo version = new(7.7d, CheatEngineVersion.Ce77010621, clientAssemblyVersion, sdkAssemblyVersion); + CheatEngineRuntimePlatformInfo platform = new( + CheatEngineArchitecture.X64, + CheatEngineArchitecture.X86, + PointerSize.Bit32, + TargetAbi.Windows); + CheatEngineRuntimeSnapshot snapshot = new( + 42, + version, + platform, + RuntimeCapabilities.Empty, + ClientCapabilities.Empty); + + Assert.Equal(version, snapshot.Version); + Assert.Equal(platform, snapshot.Platform); + Assert.Equal(version.ObservedCheatEngineVersion, snapshot.ObservedCheatEngineVersion); + Assert.Equal(version.QualifiedCheatEngineBaseline, snapshot.QualifiedCheatEngineBaseline); + Assert.Same(clientAssemblyVersion, snapshot.ClientAssemblyVersion); + Assert.Same(sdkAssemblyVersion, snapshot.SdkAssemblyVersion); + Assert.Equal(platform.SystemArchitecture, snapshot.SystemArchitecture); + Assert.Equal(platform.TargetArchitecture, snapshot.TargetArchitecture); + Assert.Equal(platform.TargetPointerSize, snapshot.TargetPointerSize); + Assert.Equal(platform.TargetAbi, snapshot.TargetAbi); + } + [Theory] [InlineData(double.NaN)] [InlineData(double.PositiveInfinity)] [InlineData(-0.1d)] - public void SnapshotRejectsInvalidObservedCeVersion(double observedVersion) + public void VersionInfoRejectsInvalidObservedCeVersion(double observedVersion) { - Assert.Throws(() => Create(observedVersion)); + Assert.Throws(() => CreateVersionInfo(observedVersion)); + } + + [Fact] + public void VersionInfoRejectsNullAssemblyVersions() + { + ArgumentNullException clientVersion = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( + 7.7d, + CheatEngineVersion.Ce77010621, + Null(), + new Version(1, 0, 0))); + ArgumentNullException sdkVersion = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( + 7.7d, + CheatEngineVersion.Ce77010621, + new Version(0, 1, 0), + Null())); + + Assert.Equal("clientAssemblyVersion", clientVersion.ParamName); + Assert.Equal("sdkAssemblyVersion", sdkVersion.ParamName); } [Fact] @@ -47,19 +95,53 @@ public void SnapshotRejectsNegativeActivationEpoch(long epoch) Assert.Throws(() => Create(epoch: epoch)); } + [Fact] + public void SnapshotRejectsNullCapabilityCollections() + { + ArgumentNullException sdkCapabilities = Assert.Throws(() => new CheatEngineRuntimeSnapshot( + 42, + CreateVersionInfo(), + CreatePlatformInfo(), + Null(), + ClientCapabilities.Empty)); + ArgumentNullException clientCapabilities = Assert.Throws(() => new CheatEngineRuntimeSnapshot( + 42, + CreateVersionInfo(), + CreatePlatformInfo(), + RuntimeCapabilities.Empty, + Null())); + + Assert.Equal("sdkCapabilities", sdkCapabilities.ParamName); + Assert.Equal("clientCapabilities", clientCapabilities.ParamName); + } + private static CheatEngineRuntimeSnapshot Create(double? observedVersion = 7.7d, long epoch = 42) { return new CheatEngineRuntimeSnapshot( epoch, + CreateVersionInfo(observedVersion), + CreatePlatformInfo(), + RuntimeCapabilities.Empty, + ClientCapabilities.Empty); + } + + private static CheatEngineRuntimeVersionInfo CreateVersionInfo(double? observedVersion = 7.7d) + { + return new CheatEngineRuntimeVersionInfo( observedVersion, CheatEngineVersion.Ce77010621, new Version(0, 1, 0), - new Version(1, 0, 0), + new Version(1, 0, 0)); + } + + private static CheatEngineRuntimePlatformInfo CreatePlatformInfo() + { + return new CheatEngineRuntimePlatformInfo( CheatEngineArchitecture.X64, CheatEngineArchitecture.Unknown, PointerSize.Unknown, - TargetAbi.Windows, - RuntimeCapabilities.Empty, - ClientCapabilities.Empty); + TargetAbi.Windows); } + + private static T Null() where T : class => default!; } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs index 8ce3f16..9ddb23c 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs @@ -17,6 +17,23 @@ public void MemoryRecordCollectionRequestRejectsNonPositiveBounds(int maximumIte Assert.Throws(() => new MemoryRecordCollectionRequest(maximumItems)); } + [Fact] + public void MemoryRecordCollectionRequestRetainsItsPositiveBound() + { + MemoryRecordCollectionRequest request = new(32); + + Assert.Equal(32, request.MaximumItems); + } + + [Fact] + public void CardinalityOnlyAddressTableSnapshotRetainsTheCountWithoutMaterializingRecords() + { + AddressTableSnapshot snapshot = new(3); + + Assert.Equal(3, snapshot.RecordCount); + Assert.Empty(snapshot.Records); + } + [Theory] [InlineData(0, 1)] [InlineData(1, 0)] @@ -50,38 +67,106 @@ public void MemoryRecordSearchPreservesConjunctivePredicates() [Fact] public void SnapshotCarriesCopiedRecordsAndCardinality() { - MemoryRecordSnapshot record = new(new MemoryRecordId(12), 0, "Health", "game.exe+20", "100", - VariableType.Dword, Address.FromUInt64(0x1400), true, 2); + MemoryRecordSnapshot record = CreateSnapshot( + new MemoryRecordId(12), + 0, + CreateContent(), + CreateState(Address.FromUInt64(0x1400), true, 2)); AddressTableSnapshot snapshot = new(ImmutableArray.Create(record)); Assert.Equal(1, snapshot.RecordCount); - Assert.Single(snapshot.Records); - Assert.Equal(record, snapshot.Records[0]); - Assert.True(snapshot.Records[0].IsActive); - Assert.Equal(2, snapshot.Records[0].ChildCount); + MemoryRecordSnapshot onlyRecord = Assert.Single(snapshot.Records); + Assert.Equal(record, onlyRecord); + Assert.True(onlyRecord.IsActive); + Assert.Equal(2, onlyRecord.ChildCount); + } + + [Fact] + public void MemoryRecordSnapshotForwardsContentAndStateComponentsToItsExistingLeafProperties() + { + MemoryRecordId id = new(12); + Address address = Address.FromUInt64(0x1400); + MemoryRecordContentSnapshot content = CreateContent(); + MemoryRecordStateSnapshot state = CreateState(address, true, 2); + MemoryRecordSnapshot snapshot = CreateSnapshot(id, 0, content, state); + + Assert.Equal(id, snapshot.Id); + Assert.Equal(0, snapshot.Index); + Assert.Equal(content, snapshot.Content); + Assert.Equal(state, snapshot.State); + Assert.Equal(content.Description, snapshot.Description); + Assert.Equal(content.AddressExpression, snapshot.AddressExpression); + Assert.Equal(content.Value, snapshot.Value); + Assert.Equal(content.VariableType, snapshot.VariableType); + Assert.Equal(state.CurrentAddress, snapshot.CurrentAddress); + Assert.Equal(state.IsActive, snapshot.IsActive); + Assert.Equal(state.ChildCount, snapshot.ChildCount); + } + + [Theory] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void MemoryRecordSnapshotRejectsNegativeIndex(int index) + { + Assert.Throws(() => CreateSnapshot( + new MemoryRecordId(12), + index, + CreateContent(), + CreateState())); + } + + [Theory] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void StateSnapshotRejectsNegativeChildCount(int childCount) + { + Assert.Throws(() => CreateState(childCount: childCount)); } [Fact] - public void MemoryRecordSnapshotRejectsNegativeChildCount() + public void ContentSnapshotRejectsNullRequiredValues() { - Assert.Throws(() => new MemoryRecordSnapshot(new MemoryRecordId(12), 0, "Health", - "game.exe+20", "100", VariableType.Dword, null, childCount: -1)); + ArgumentNullException description = Assert.Throws(() => new MemoryRecordContentSnapshot( + Null(), + "game.exe+20", + "100", + VariableType.Dword)); + ArgumentNullException addressExpression = Assert.Throws(() => new MemoryRecordContentSnapshot( + "Health", + Null(), + "100", + VariableType.Dword)); + ArgumentNullException value = Assert.Throws(() => new MemoryRecordContentSnapshot( + "Health", + "game.exe+20", + Null(), + VariableType.Dword)); + + Assert.Equal("description", description.ParamName); + Assert.Equal("addressExpression", addressExpression.ParamName); + Assert.Equal("value", value.ParamName); } [Fact] public void HierarchySnapshotRetainsItsCopiedRootAndChildren() { - MemoryRecordSnapshot root = new(new MemoryRecordId(12), 0, "Health", "game.exe+20", "100", - VariableType.Dword, null, childCount: 1); - MemoryRecordSnapshot child = new(new MemoryRecordId(13), 1, "Ammo", "game.exe+24", "50", - VariableType.Dword, null); + MemoryRecordSnapshot root = CreateSnapshot( + new MemoryRecordId(12), + 0, + CreateContent(), + CreateState(childCount: 1)); + MemoryRecordSnapshot child = CreateSnapshot( + new MemoryRecordId(13), + 1, + new MemoryRecordContentSnapshot("Ammo", "game.exe+24", "50", VariableType.Dword), + CreateState()); MemoryRecordHierarchySnapshot hierarchy = new(root, ImmutableArray.Create(new MemoryRecordHierarchySnapshot(child, []))); Assert.Equal(root, hierarchy.Record); - Assert.Single(hierarchy.Children); - Assert.Equal(child, hierarchy.Children[0].Record); - Assert.Empty(hierarchy.Children[0].Children); + MemoryRecordHierarchySnapshot onlyChild = Assert.Single(hierarchy.Children); + Assert.Equal(child, onlyChild.Record); + Assert.Empty(onlyChild.Children); } [Fact] @@ -100,4 +185,25 @@ public void MemoryRecordDefinitionDefaultsToTheAddressListRoot() Assert.Null(definition.ParentId); } + + private static MemoryRecordSnapshot CreateSnapshot( + MemoryRecordId id, + int index, + MemoryRecordContentSnapshot content, + MemoryRecordStateSnapshot state) + { + return new MemoryRecordSnapshot(id, index, content, state); + } + + private static MemoryRecordContentSnapshot CreateContent() + { + return new MemoryRecordContentSnapshot("Health", "game.exe+20", "100", VariableType.Dword); + } + + private static MemoryRecordStateSnapshot CreateState(Address? currentAddress = null, bool isActive = false, int childCount = 0) + { + return new MemoryRecordStateSnapshot(currentAddress, isActive, childCount); + } + + private static T Null() where T : class => default!; } From a968ada52ef5b4ebf4eee6ef0b938f8c84b15640 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 22:24:21 +0200 Subject: [PATCH 03/17] fix: harden foundation contracts and CI review gates Make typed Lua result operations inferable, preserve InvalidState lifecycle exceptions, and reject or normalize invalid runtime and table snapshots. Add focused contract regressions and XML documentation for the affected test surface. Scope Sonar credentials to scanner steps, apply least-privilege workflow permissions, update SHA-pinned actions to Node 24 releases, and explicitly handle MTP's documented empty-test-project exit code without masking real failures. --- .github/workflows/build.yml | 46 ++++++-- .../Lua/ILuaClient.cs | 8 +- .../PublicAPI.Shipped.txt | 4 +- .../Results/CheatEngineFailure.cs | 5 + .../Runtime/CheatEngineRuntimePlatformInfo.cs | 12 ++ .../Runtime/CheatEngineRuntimeSnapshot.cs | 6 + .../Tables/MemoryRecordHierarchySnapshot.cs | 37 +++++- .../Tables/MemoryRecordSnapshot.cs | 6 + .../Lua/LuaContractTests.cs | 108 +++++++++++++++++- .../Results/CheatEngineFailureTests.cs | 24 ++++ .../CheatEngineRuntimeSnapshotTests.cs | 52 +++++++++ .../Tables/TableContractTests.cs | 50 ++++++++ 12 files changed, 335 insertions(+), 23 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 905a0bc..4f66b91 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,37 +5,44 @@ on: - 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@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: java-version: 21 distribution: "zulu" # Alternative distribution options are available. - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - 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@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.401 - name: Cache SonarQube Cloud packages - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + 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 - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + 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' @@ -44,14 +51,31 @@ jobs: if (-not (Test-Path $scannerExecutable)) { dotnet tool update dotnet-sonarscanner --tool-path $scannerPath } - - name: Build and analyze + - 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: | - ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"CheatEngineNet_CheatEngine.Client" /o:"cheatenginenet" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.cobertura.reportsPaths="artifacts/sonar-test-results/**/*.cobertura.xml" + & "$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 - # The stacked branches deliberately introduce some test projects before their first tests. Test failures still - # terminate dotnet test; the default runner behavior simply permits these empty intermediate projects. - dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --coverage --coverage-output-format cobertura --results-directory artifacts/sonar-test-results - ${{ runner.temp }}\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + + - 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/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs index a4c698e..b6d6349 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs @@ -22,11 +22,9 @@ public bool TryRegisterModule( public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default); /// Tries to execute one typed operation on Cheat Engine's main thread. - public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, - out CheatEngineFailure failure, CancellationToken cancellationToken = default) - where TOperation : ILuaOperation; + public bool TryExecute(ILuaOperation operation, [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Executes one typed operation or throws when it fails. - public TResult Execute(TOperation operation, CancellationToken cancellationToken = default) - where TOperation : ILuaOperation; + public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt index 06c28ef..f755b96 100644 --- a/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt +++ b/libs/CheatEngine.Client.Abstractions/PublicAPI.Shipped.txt @@ -122,9 +122,9 @@ CheatEngine.Client.Inspection.SymbolRegistration.Name.get -> string! CheatEngine.Client.Inspection.SymbolRegistration.SymbolRegistration() -> void CheatEngine.Client.Inspection.SymbolRegistration.SymbolRegistration(string! name, CheatEngine.SDK.Engine.Values.Address address, bool doNotSave = true) -> void CheatEngine.Client.Lua.ILuaClient -CheatEngine.Client.Lua.ILuaClient.Execute(TOperation operation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult +CheatEngine.Client.Lua.ILuaClient.Execute(CheatEngine.Client.Lua.ILuaOperation! operation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult CheatEngine.Client.Lua.ILuaClient.RegisterModule(CheatEngine.Client.Lua.ILuaModule! luaModule, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Lua.ILuaModuleLease! -CheatEngine.Client.Lua.ILuaClient.TryExecute(TOperation operation, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Lua.ILuaClient.TryExecute(CheatEngine.Client.Lua.ILuaOperation! operation, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Lua.ILuaClient.TryRegisterModule(CheatEngine.Client.Lua.ILuaModule! luaModule, out CheatEngine.Client.Lua.ILuaModuleLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool CheatEngine.Client.Lua.ILuaExecutionContext CheatEngine.Client.Lua.ILuaExecutionContext.Epoch.get -> long diff --git a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs index 5647e10..1dfe257 100644 --- a/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs +++ b/libs/CheatEngine.Client.Abstractions/Results/CheatEngineFailure.cs @@ -47,6 +47,11 @@ public readonly void Throw() throw new CheatEngineActivationExpiredException(Operation, Message, Exception); } + if (Kind == CheatEngineFailureKind.InvalidState) + { + throw new CheatEngineClientLifecycleException(Operation, Message, Exception); + } + throw new CheatEngineOperationException(this); } } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs index 71b72e0..f107d25 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimePlatformInfo.cs @@ -6,12 +6,24 @@ namespace CheatEngine.Client.Runtime; public readonly record struct CheatEngineRuntimePlatformInfo { /// Creates runtime platform observations from copied host facts. + /// + /// does not match the width implied by + /// . + /// public CheatEngineRuntimePlatformInfo( CheatEngineArchitecture systemArchitecture, CheatEngineArchitecture targetArchitecture, PointerSize targetPointerSize, TargetAbi targetAbi) { + PointerSize expectedTargetPointerSize = PointerSize.FromArchitecture(targetArchitecture); + if (targetPointerSize != expectedTargetPointerSize) + { + throw new ArgumentException( + "The target pointer size must match the target architecture.", + nameof(targetPointerSize)); + } + SystemArchitecture = systemArchitecture; TargetArchitecture = targetArchitecture; TargetPointerSize = targetPointerSize; diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs index bebfe29..30e6f6b 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs @@ -12,6 +12,10 @@ namespace CheatEngine.Client.Runtime; public readonly record struct CheatEngineRuntimeSnapshot { /// Creates a runtime snapshot from grouped version, platform, and capability observations. + /// is negative. + /// + /// is uninitialized, or a capability collection is . + /// public CheatEngineRuntimeSnapshot( long epoch, CheatEngineRuntimeVersionInfo versionInfo, @@ -20,6 +24,8 @@ public CheatEngineRuntimeSnapshot( ClientCapabilities clientCapabilities) { ArgumentOutOfRangeException.ThrowIfNegative(epoch); + ArgumentNullException.ThrowIfNull(versionInfo.ClientAssemblyVersion); + ArgumentNullException.ThrowIfNull(versionInfo.SdkAssemblyVersion); Epoch = epoch; Version = versionInfo; diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs index 9e7892e..0787f9f 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordHierarchySnapshot.cs @@ -3,6 +3,37 @@ namespace CheatEngine.Client.Tables; /// A copied, recursively bounded memory-record tree. -public readonly record struct MemoryRecordHierarchySnapshot( - MemoryRecordSnapshot Record, - ImmutableArray Children); +public readonly record struct MemoryRecordHierarchySnapshot +{ + /// Creates a copied memory-record tree and normalizes unavailable child storage to empty. + public MemoryRecordHierarchySnapshot( + MemoryRecordSnapshot Record, + ImmutableArray Children) + { + this.Record = Record; + this.Children = Children; + } + + /// Gets the copied root record. + public MemoryRecordSnapshot Record + { + get; + init; + } + + /// Gets the copied child records; a default array is exposed as empty. + public ImmutableArray Children + { + get => field.IsDefault ? ImmutableArray.Empty : field; + init => field = value.IsDefault ? ImmutableArray.Empty : value; + } + + /// Deconstructs the copied root record and normalized child records. + public void Deconstruct( + out MemoryRecordSnapshot Record, + out ImmutableArray Children) + { + Record = this.Record; + Children = this.Children; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs index 4564ee7..2d0b6f7 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs @@ -8,6 +8,8 @@ namespace CheatEngine.Client.Tables; public readonly record struct MemoryRecordSnapshot { /// Creates a copied memory-record snapshot from grouped content and state fields. + /// is negative. + /// is uninitialized. public MemoryRecordSnapshot( MemoryRecordId id, int index, @@ -15,6 +17,10 @@ public MemoryRecordSnapshot( MemoryRecordStateSnapshot state) { ArgumentOutOfRangeException.ThrowIfNegative(index); + if (content.Description is null || content.AddressExpression is null || content.Value is null) + { + throw new ArgumentException("Content requires non-null text fields.", nameof(content)); + } Id = id; Index = index; diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs index 33db27f..ee1ec46 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs @@ -1,23 +1,28 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; namespace CheatEngine.Client.Tests.Lua; public sealed class LuaContractTests { + /// Rejects a chunk identity that cannot identify an executed Lua source. [Fact] public void LuaScriptRejectsAnEmptyChunkName() { Assert.Throws(() => new LuaScript("return true", string.Empty)); } + /// Rejects absent Lua source before a script contract can be constructed. [Fact] public void LuaScriptRejectsNullSource() { Assert.Throws(() => new LuaScript(null!)); } + /// Retains validated source text and the caller-supplied diagnostic chunk name. [Fact] public void LuaScriptPreservesValidatedSourceAndChunkName() { @@ -27,6 +32,7 @@ public void LuaScriptPreservesValidatedSourceAndChunkName() Assert.Equal("=calculation", script.ChunkName); } + /// Ensures public Lua contracts remain handle-free at every reflected signature boundary. [Fact] public void PublicLuaContractsDoNotExposeRawSdkLuaHandles() { @@ -49,16 +55,37 @@ public void PublicLuaContractsDoNotExposeRawSdkLuaHandles() || typeName.StartsWith("CheatEngine.SDK.Lua.References.LuaRef", StringComparison.Ordinal)); } + /// Infers an operation result through the Lua client interface and forwards its successful result. + [Fact] + public void InterfaceExecutionInfersTheOperationResultTypeAndForwardsSuccess() + { + ILuaClient client = new ForwardingLuaClient(); + ConstantIntLuaOperation operation = new(42); + + int executeResult = client.Execute(operation, TestContext.Current.CancellationToken); + bool tryExecuteSucceeded = client.TryExecute( + operation, + out var tryExecuteResult, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.Equal(42, executeResult); + Assert.True(tryExecuteSucceeded); + Assert.Equal(42, tryExecuteResult); + Assert.Equal(default(CheatEngineFailure), failure); + Assert.Equal(2, operation.ExecutionCount); + } + private static IEnumerable GetPublicSignatureTypes(Type type) { foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | - BindingFlags.Static)) + BindingFlags.Static)) { yield return property.PropertyType; } foreach (MethodInfo method in - type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) { yield return method.ReturnType; foreach (ParameterInfo parameter in method.GetParameters()) @@ -67,4 +94,81 @@ private static IEnumerable GetPublicSignatureTypes(Type type) } } } + + private sealed class ForwardingLuaClient : ILuaClient + { + private readonly ILuaExecutionContext _context = new ActiveLuaExecutionContext(); + + public bool TryRegisterModule( + ILuaModule luaModule, + [NotNullWhen(true)] out ILuaModuleLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(luaModule); + lease = null; + failure = new CheatEngineFailure( + CheatEngineFailureKind.InvalidState, + "Lua.RegisterModule", + "Module registration is outside this forwarding test double."); + return false; + } + + public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(luaModule); + throw new NotSupportedException("Module registration is outside this forwarding test double."); + } + + public bool TryExecute( + ILuaOperation operation, + [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + return operation.TryExecute(_context, out result, out failure); + } + + public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) + { + if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, cancellationToken)) + { + return result!; + } + + failure.Throw(); + throw new InvalidOperationException("A failed Lua operation must throw its mapped exception."); + } + } + + private sealed class ConstantIntLuaOperation(int result) : ILuaOperation + { + public int ExecutionCount + { + get; + private set; + } + + public bool TryExecute(ILuaExecutionContext context, out int operationResult, out CheatEngineFailure failure) + { + ArgumentNullException.ThrowIfNull(context); + context.ThrowIfExpired(); + ExecutionCount++; + operationResult = result; + failure = default; + return true; + } + } + + private sealed class ActiveLuaExecutionContext : ILuaExecutionContext + { + public long Epoch => 1; + + public bool IsActive => true; + + public void ThrowIfExpired() + { + } + } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs index 553b6e1..94bf17c 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs @@ -4,6 +4,7 @@ namespace CheatEngine.Client.Tests.Results; public sealed class CheatEngineFailureTests { + /// Preserves the classified diagnostic fields and their originating exception. [Fact] public void ConstructorPreservesClassifiedDiagnosticAndInnerException() { @@ -21,6 +22,7 @@ public void ConstructorPreservesClassifiedDiagnosticAndInnerException() Assert.Same(innerException, failure.Exception); } + /// Rejects diagnostics that cannot name an operation or provide a human-readable message. [Fact] public void ConstructorRejectsMissingOperationOrMessage() { @@ -30,6 +32,7 @@ public void ConstructorRejectsMissingOperationOrMessage() CheatEngineFailureKind.Unknown, "Operation", " ")); } + /// Maps a general expected failure to an operation exception retaining its source failure. [Fact] public void ThrowCreatesOperationExceptionThatRetainsFailure() { @@ -47,6 +50,7 @@ public void ThrowCreatesOperationExceptionThatRetainsFailure() Assert.Same(innerException, exception.InnerException); } + /// Maps activation expiration to its dedicated exception without losing its cause. [Fact] public void ThrowPreservesTheDedicatedActivationExpiredException() { @@ -64,6 +68,26 @@ public void ThrowPreservesTheDedicatedActivationExpiredException() Assert.Same(innerException, exception.InnerException); } + /// Maps an invalid lifecycle state to its dedicated exception while retaining all diagnostics. + [Fact] + public void ThrowPreservesTheDedicatedLifecycleExceptionForInvalidState() + { + InvalidOperationException innerException = new("provider is stopping"); + CheatEngineFailure failure = new( + CheatEngineFailureKind.InvalidState, + "Client.Track", + "The provider is stopping.", + innerException); + + CheatEngineClientLifecycleException exception = + Assert.Throws(failure.Throw); + + Assert.Equal(failure, exception.Failure); + Assert.Equal(failure.Message, exception.Message); + Assert.Same(innerException, exception.InnerException); + } + + /// Assigns each concrete lifecycle exception the stable failure kind it represents. [Fact] public void LifecycleExceptionsClassifyTheirSpecificLifecycleFailures() { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs index 67d27ca..9124969 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs @@ -7,6 +7,7 @@ namespace CheatEngine.Client.Tests.Runtime; public sealed class CheatEngineRuntimeSnapshotTests { + /// Keeps an observed coarse CE version distinct from its qualified four-part release baseline. [Fact] public void SnapshotKeepsObservedCeVersionSeparateFromTheQualifiedFourPartBaseline() { @@ -20,6 +21,7 @@ public void SnapshotKeepsObservedCeVersionSeparateFromTheQualifiedFourPartBaseli Assert.True(snapshot.IsOnQualifiedCheatEngineLine); } + /// Forwards grouped runtime observations through the established leaf-property compatibility surface. [Fact] public void SnapshotForwardsVersionAndPlatformComponentsToItsExistingLeafProperties() { @@ -50,6 +52,7 @@ public void SnapshotForwardsVersionAndPlatformComponentsToItsExistingLeafPropert Assert.Equal(platform.TargetAbi, snapshot.TargetAbi); } + /// Rejects non-finite or negative observed CE version values. [Theory] [InlineData(double.NaN)] [InlineData(double.PositiveInfinity)] @@ -59,6 +62,7 @@ public void VersionInfoRejectsInvalidObservedCeVersion(double observedVersion) Assert.Throws(() => CreateVersionInfo(observedVersion)); } + /// Rejects absent assembly versions that would leave a version observation uninitialized. [Fact] public void VersionInfoRejectsNullAssemblyVersions() { @@ -77,6 +81,7 @@ public void VersionInfoRejectsNullAssemblyVersions() Assert.Equal("sdkAssemblyVersion", sdkVersion.ParamName); } + /// Retains unavailable version and target facts without inferring values that CE did not provide. [Fact] public void SnapshotAllowsAnUnavailableObservedVersionAndKeepsUnknownTargetFacts() { @@ -87,6 +92,7 @@ public void SnapshotAllowsAnUnavailableObservedVersionAndKeepsUnknownTargetFacts Assert.Equal(PointerSize.Unknown, snapshot.TargetPointerSize); } + /// Rejects a snapshot epoch that cannot identify a real activation. [Theory] [InlineData(-1)] [InlineData(long.MinValue)] @@ -95,6 +101,7 @@ public void SnapshotRejectsNegativeActivationEpoch(long epoch) Assert.Throws(() => Create(epoch: epoch)); } + /// Rejects absent capability collections instead of accepting an incomplete runtime snapshot. [Fact] public void SnapshotRejectsNullCapabilityCollections() { @@ -115,6 +122,51 @@ public void SnapshotRejectsNullCapabilityCollections() Assert.Equal("clientCapabilities", clientCapabilities.ParamName); } + /// Rejects target pointer widths that disagree with the observed target architecture. + [Theory] + [InlineData(CheatEngineArchitecture.X64, 4)] + [InlineData(CheatEngineArchitecture.X86, 8)] + public void PlatformInfoRejectsPointerSizeThatDoesNotMatchTargetArchitecture( + CheatEngineArchitecture targetArchitecture, + int pointerBytes) + { + ArgumentException exception = Assert.Throws(() => new CheatEngineRuntimePlatformInfo( + CheatEngineArchitecture.X64, + targetArchitecture, + new PointerSize(pointerBytes), + TargetAbi.Windows)); + + Assert.Equal("targetPointerSize", exception.ParamName); + } + + /// Accepts unknown architecture and pointer-size facts without inventing a target platform. + [Fact] + public void PlatformInfoAcceptsConsistentlyUnknownTargetArchitectureAndPointerSize() + { + CheatEngineRuntimePlatformInfo platform = new( + CheatEngineArchitecture.Unknown, + CheatEngineArchitecture.Unknown, + PointerSize.Unknown, + TargetAbi.Unknown); + + Assert.Equal(CheatEngineArchitecture.Unknown, platform.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, platform.TargetPointerSize); + } + + /// Rejects the default version-info value before it can produce a partially initialized snapshot. + [Fact] + public void SnapshotRejectsDefaultVersionInfo() + { + ArgumentNullException exception = Assert.Throws(() => new CheatEngineRuntimeSnapshot( + 42, + default, + CreatePlatformInfo(), + RuntimeCapabilities.Empty, + ClientCapabilities.Empty)); + + Assert.Equal("versionInfo.ClientAssemblyVersion", exception.ParamName); + } + private static CheatEngineRuntimeSnapshot Create(double? observedVersion = 7.7d, long epoch = 42) { return new CheatEngineRuntimeSnapshot( diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs index 9ddb23c..393d632 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs @@ -9,6 +9,7 @@ namespace CheatEngine.Client.Tests.Tables; public sealed class TableContractTests { + /// Rejects collection bounds that cannot cap table-record materialization. [Theory] [InlineData(0)] [InlineData(-1)] @@ -17,6 +18,7 @@ public void MemoryRecordCollectionRequestRejectsNonPositiveBounds(int maximumIte Assert.Throws(() => new MemoryRecordCollectionRequest(maximumItems)); } + /// Retains a valid upper bound for a bounded memory-record collection request. [Fact] public void MemoryRecordCollectionRequestRetainsItsPositiveBound() { @@ -25,6 +27,7 @@ public void MemoryRecordCollectionRequestRetainsItsPositiveBound() Assert.Equal(32, request.MaximumItems); } + /// Represents a known record count without forcing records to be copied into the snapshot. [Fact] public void CardinalityOnlyAddressTableSnapshotRetainsTheCountWithoutMaterializingRecords() { @@ -34,6 +37,7 @@ public void CardinalityOnlyAddressTableSnapshotRetainsTheCountWithoutMaterializi Assert.Empty(snapshot.Records); } + /// Rejects hierarchy limits that cannot bound either breadth or depth. [Theory] [InlineData(0, 1)] [InlineData(1, 0)] @@ -45,6 +49,7 @@ public void MemoryRecordHierarchyRequestRejectsNonPositiveBounds(int maximumItem new MemoryRecordHierarchyRequest(maximumItems, maximumDepth)); } + /// Requires a non-empty predicate before a record search can be evaluated. [Fact] public void MemoryRecordSearchRequiresAtLeastOneMeaningfulPredicate() { @@ -53,6 +58,7 @@ public void MemoryRecordSearchRequiresAtLeastOneMeaningfulPredicate() Assert.Throws(() => new MemoryRecordSearch(addressExpression: string.Empty)); } + /// Retains every supplied record-search predicate as one conjunctive request. [Fact] public void MemoryRecordSearchPreservesConjunctivePredicates() { @@ -64,6 +70,7 @@ public void MemoryRecordSearchPreservesConjunctivePredicates() Assert.True(search.IsActive); } + /// Retains copied record data while preserving the separately reported table cardinality. [Fact] public void SnapshotCarriesCopiedRecordsAndCardinality() { @@ -81,6 +88,7 @@ public void SnapshotCarriesCopiedRecordsAndCardinality() Assert.Equal(2, onlyRecord.ChildCount); } + /// Forwards grouped content and state fields through the established record leaf properties. [Fact] public void MemoryRecordSnapshotForwardsContentAndStateComponentsToItsExistingLeafProperties() { @@ -103,6 +111,7 @@ public void MemoryRecordSnapshotForwardsContentAndStateComponentsToItsExistingLe Assert.Equal(state.ChildCount, snapshot.ChildCount); } + /// Rejects a record index that cannot identify a valid address-list position. [Theory] [InlineData(-1)] [InlineData(int.MinValue)] @@ -115,6 +124,7 @@ public void MemoryRecordSnapshotRejectsNegativeIndex(int index) CreateState())); } + /// Rejects a negative immediate-child count in a copied record state. [Theory] [InlineData(-1)] [InlineData(int.MinValue)] @@ -123,6 +133,7 @@ public void StateSnapshotRejectsNegativeChildCount(int childCount) Assert.Throws(() => CreateState(childCount: childCount)); } + /// Rejects required text fields that would make copied record content incomplete. [Fact] public void ContentSnapshotRejectsNullRequiredValues() { @@ -147,6 +158,43 @@ public void ContentSnapshotRejectsNullRequiredValues() Assert.Equal("value", value.ParamName); } + /// Normalizes default child arrays to empty both during construction and with-expression updates. + [Fact] + public void HierarchySnapshotNormalizesDefaultChildrenDuringConstructionAndWithUpdate() + { + MemoryRecordHierarchySnapshot hierarchy = new(CreateSnapshot( + new MemoryRecordId(12), + 0, + CreateContent(), + CreateState()), default); + MemoryRecordHierarchySnapshot updatedHierarchy = hierarchy with + { + Children = default + }; + MemoryRecordHierarchySnapshot uninitializedHierarchy = default; + + Assert.False(hierarchy.Children.IsDefault); + Assert.Empty(hierarchy.Children); + Assert.False(updatedHierarchy.Children.IsDefault); + Assert.Empty(updatedHierarchy.Children); + Assert.False(uninitializedHierarchy.Children.IsDefault); + Assert.Empty(uninitializedHierarchy.Children); + } + + /// Rejects default record content before it can leak null text fields through a snapshot. + [Fact] + public void MemoryRecordSnapshotRejectsDefaultContent() + { + ArgumentException exception = Assert.Throws(() => new MemoryRecordSnapshot( + new MemoryRecordId(12), + 0, + default, + CreateState())); + + Assert.Equal("content", exception.ParamName); + } + + /// Preserves the root record and recursively copied child snapshots in hierarchy order. [Fact] public void HierarchySnapshotRetainsItsCopiedRootAndChildren() { @@ -169,6 +217,7 @@ public void HierarchySnapshotRetainsItsCopiedRootAndChildren() Assert.Empty(onlyChild.Children); } + /// Retains an explicitly supplied parent record identifier for a new table definition. [Fact] public void MemoryRecordDefinitionRetainsAnOptionalParentId() { @@ -178,6 +227,7 @@ public void MemoryRecordDefinitionRetainsAnOptionalParentId() Assert.Equal(new MemoryRecordId(12), definition.ParentId); } + /// Defaults a new record definition to the address-list root when no parent is supplied. [Fact] public void MemoryRecordDefinitionDefaultsToTheAddressListRoot() { From 7ee7e75353f574f6e65f3c51713c315ce0640adf Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 19:09:19 +0200 Subject: [PATCH 04/17] feat: add activation-bound Core runtime Implement the internal Core layer that adapts CheatEngine.SDK behind the public Abstractions contracts. The implementation introduces activation and target-selection lifetimes, synchronized main-thread dispatch, classified failures, protected Lua/module leases, typed memory codecs and pointer chains, process/runtime inspection, bounded AOB materialization, table record operations, and a capability-gated value-scan state machine. SDK ownership and Lua handles remain internal. The Core resource registry disposes forgotten resources in LIFO order during activation cleanup, while the dispatcher preserves callback exceptions and epoch expiration semantics. Focused tests cover lifecycle cleanup, memory, processes, AOB, Lua, symbols, tables, and unavailable value scanning. Validation: - dotnet build libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj --configuration Release --no-restore --warnaserror - dotnet test --project tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on --- .../CheatEngine.Client.Core.csproj | 19 + .../CheatEngineClient.cs | 99 +++ .../Dispatching/SdkMainThreadDispatcher.cs | 161 ++++ .../Domains/IProcessHost.cs | 13 + .../Domains/IRuntimeProbe.cs | 11 + .../Domains/ITableRecordMutationPort.cs | 16 + .../Domains/InspectionClient.cs | 367 ++++++++ .../Domains/LocalProcessHost.cs | 103 +++ .../Domains/LocalProcessInfo.cs | 6 + .../Domains/LuaClient.cs | 261 ++++++ .../Domains/LuaModuleLease.cs | 70 ++ .../Domains/LuaOperationContext.cs | 35 + .../Domains/LuaRuntimeProbe.cs | 31 + .../Domains/MemoryClient.cs | 609 +++++++++++++ .../Domains/ParentChainStep.cs | 16 + .../Domains/ParentChainStepKind.cs | 9 + .../Domains/PatternScanner.cs | 281 ++++++ .../Domains/ProbeResult.cs | 29 + .../Domains/ProcessClient.cs | 292 +++++++ .../Domains/RuntimeClient.cs | 320 +++++++ .../Domains/SdkTableRecordMutationPort.cs | 127 +++ .../Domains/SymbolRegistrationLease.cs | 71 ++ .../Domains/TableClient.cs | 821 ++++++++++++++++++ .../Domains/TableParentRelationshipGuard.cs | 40 + .../Domains/TableRecordMutationStatus.cs | 11 + .../Domains/UnavailableValueScanner.cs | 46 + .../Domains/UnsafeLuaClient.cs | 90 ++ .../Domains/ValueScanSessionStateMachine.cs | 85 ++ .../Infrastructure/ClientLuaGlobals.cs | 42 + .../Infrastructure/CoreClientPolicy.cs | 210 +++++ .../Infrastructure/CoreFailureFactory.cs | 50 ++ .../Infrastructure/CoreLifetime.cs | 209 +++++ .../Infrastructure/CoreResourceRegistry.cs | 177 ++++ .../Infrastructure/TargetSelectionLifetime.cs | 128 +++ .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 1 + libs/CheatEngine.Client.Core/README.md | 97 +++ .../packages.lock.json | 60 ++ .../CheatEngine.Client.Core.Tests.csproj | 7 + .../Domains/MemoryClientTests.cs | 184 ++++ .../Domains/PatternScannerTests.cs | 30 + .../Domains/ProcessClientTests.cs | 389 +++++++++ .../Domains/RuntimeClientTests.cs | 356 ++++++++ .../Domains/SymbolRegistrationLeaseTests.cs | 117 +++ .../Domains/TableClientMutationTests.cs | 316 +++++++ .../Domains/UnavailableValueScannerTests.cs | 52 ++ .../ValueScanSessionStateMachineTests.cs | 64 ++ .../Infrastructure/CoreClientPolicyTests.cs | 77 ++ .../Infrastructure/CoreFailureFactoryTests.cs | 31 + .../CoreResourceRegistryTests.cs | 148 ++++ .../TargetSelectionLifetimeTests.cs | 133 +++ .../Lua/LuaClientTests.cs | 200 +++++ .../Lua/LuaModuleRegistrationTests.cs | 346 ++++++++ .../Lua/LuaOperationContextTests.cs | 38 + .../Lua/UnsafeLuaClientTests.cs | 75 ++ tests/CheatEngine.Client.Core.Tests/README.md | 32 + .../packages.lock.json | 205 +++++ 57 files changed, 7814 insertions(+) create mode 100644 libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj create mode 100644 libs/CheatEngine.Client.Core/CheatEngineClient.cs create mode 100644 libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/IProcessHost.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/InspectionClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/LocalProcessInfo.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/LuaClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/MemoryClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/PatternScanner.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/ProbeResult.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/ProcessClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/TableClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs create mode 100644 libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs create mode 100644 libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs create mode 100644 libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs create mode 100644 libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs create mode 100644 libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs create mode 100644 libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs create mode 100644 libs/CheatEngine.Client.Core/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.Client.Core/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.Client.Core/README.md create mode 100644 libs/CheatEngine.Client.Core/packages.lock.json create mode 100644 tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Lua/LuaOperationContextTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/README.md create mode 100644 tests/CheatEngine.Client.Core.Tests/packages.lock.json diff --git a/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj new file mode 100644 index 0000000..cb7032f --- /dev/null +++ b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj @@ -0,0 +1,19 @@ + + + + true + + false + + + + + + + + + + + + + diff --git a/libs/CheatEngine.Client.Core/CheatEngineClient.cs b/libs/CheatEngine.Client.Core/CheatEngineClient.cs new file mode 100644 index 0000000..8c33dfd --- /dev/null +++ b/libs/CheatEngine.Client.Core/CheatEngineClient.cs @@ -0,0 +1,99 @@ +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; + +namespace CheatEngine.Client.Core; + +internal sealed class CheatEngineClient : ICheatEngineClient +{ + private readonly CoreLifetime _lifetime; + + internal CheatEngineClient( + CoreLifetime lifetime, + CheatEngineClientRuntimeServices runtimeServices, + CheatEngineClientDomainServices domainServices) + { + _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + ArgumentNullException.ThrowIfNull(runtimeServices); + ArgumentNullException.ThrowIfNull(domainServices); + + Runtime = runtimeServices.Runtime ?? throw new ArgumentNullException(nameof(runtimeServices)); + Dispatcher = runtimeServices.Dispatcher ?? throw new ArgumentNullException(nameof(runtimeServices)); + Processes = domainServices.Processes ?? throw new ArgumentNullException(nameof(domainServices)); + Memory = domainServices.Memory ?? throw new ArgumentNullException(nameof(domainServices)); + Patterns = domainServices.Patterns ?? throw new ArgumentNullException(nameof(domainServices)); + Scans = domainServices.Scans ?? throw new ArgumentNullException(nameof(domainServices)); + Inspection = domainServices.Inspection ?? throw new ArgumentNullException(nameof(domainServices)); + Tables = domainServices.Tables ?? throw new ArgumentNullException(nameof(domainServices)); + Lua = domainServices.Lua ?? throw new ArgumentNullException(nameof(domainServices)); + } + + public long Epoch => _lifetime.Epoch; + public CancellationToken Stopping => _lifetime.Stopping; + + public ICheatEngineRuntime Runtime + { + get; + } + + public ICheatEngineDispatcher Dispatcher + { + get; + } + + public IProcessClient Processes + { + get; + } + + public IMemoryClient Memory + { + get; + } + + public IPatternScanner Patterns + { + get; + } + + public IValueScanner Scans + { + get; + } + + public IInspectionClient Inspection + { + get; + } + + public ITableClient Tables + { + get; + } + + public ILuaClient Lua + { + get; + } +} + +/// Internal grouping of the façade services that describe the active runtime and its dispatch boundary. +internal sealed record CheatEngineClientRuntimeServices( + ICheatEngineRuntime Runtime, + ICheatEngineDispatcher Dispatcher); + +/// Internal grouping of the independently consumable high-level Client domains. +internal sealed record CheatEngineClientDomainServices( + IProcessClient Processes, + IMemoryClient Memory, + IPatternScanner Patterns, + IValueScanner Scans, + IInspectionClient Inspection, + ITableClient Tables, + ILuaClient Lua); diff --git a/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs new file mode 100644 index 0000000..d082c38 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs @@ -0,0 +1,161 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Hosting.Threading; + +namespace CheatEngine.Client.Core.Dispatching; + +/// Adapts the SDK's synchronous main-thread dispatcher without retaining Lua state. +internal sealed class SdkMainThreadDispatcher(CoreLifetime lifetime) : ICheatEngineDispatcher +{ + private const string InvokeOperation = "Dispatcher.Invoke"; + + private readonly CoreLifetime _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + + public bool IsMainThread => _lifetime.CanDispatch && MainThread.IsMainThread; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + _lifetime.ThrowIfDispatchAllowed(InvokeOperation); + if (cancellationToken.IsCancellationRequested) + { + failure = CoreFailureFactory.Cancelled(InvokeOperation); + return false; + } + + Exception? callbackException; + try + { + callbackException = MainThread.Invoke(static action => InvokeCallback(action), callback); + } + catch (CheatEngineActivationExpiredException) + { + throw; + } + catch (Exception exception) when (!_lifetime.IsActivationCurrent) + { + throw new CheatEngineActivationExpiredException(InvokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + } + catch (Exception exception) + { + failure = CoreFailureFactory.FromException(InvokeOperation, exception); + return false; + } + + if (callbackException is not null) + { + RethrowCallback(callbackException); + } + + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + _lifetime.ThrowIfDispatchAllowed(InvokeOperation); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = CoreFailureFactory.Cancelled(InvokeOperation); + return false; + } + + CallbackResult callbackResult; + try + { + callbackResult = MainThread.Invoke(static function => InvokeCallback(function), callback); + } + catch (CheatEngineActivationExpiredException) + { + throw; + } + catch (Exception exception) when (!_lifetime.IsActivationCurrent) + { + result = default; + throw new CheatEngineActivationExpiredException(InvokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + } + catch (Exception exception) + { + result = default; + failure = CoreFailureFactory.FromException(InvokeOperation, exception); + return false; + } + + if (callbackResult.Exception is not null) + { + RethrowCallback(callbackResult.Exception); + } + + result = callbackResult.Result; + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + _ = ThrowFailure(failure); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out var result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + return ThrowFailure(failure); + } + + private static T ThrowFailure(CheatEngineFailure failure) + { + failure.Throw(); + throw new UnreachableException(); + } + + private static void RethrowCallback(Exception callbackException) + { + ExceptionDispatchInfo.Capture(callbackException).Throw(); + throw new UnreachableException(); + } + + private static Exception? InvokeCallback(Action callback) + { + try + { + callback(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static CallbackResult InvokeCallback(Func callback) + { + try + { + return new CallbackResult(callback(), null); + } + catch (Exception exception) + { + return new CallbackResult(default!, exception); + } + } + + private readonly record struct CallbackResult(T Result, Exception? Exception); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs b/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs new file mode 100644 index 0000000..2defb5b --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs @@ -0,0 +1,13 @@ +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal adapter boundary for CE process globals and local managed process metadata. +internal interface IProcessHost +{ + public long GetOpenedProcessId(); + public void OpenProcess(long processId); + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process); + public IReadOnlyList FindProcessesByExactName(string processName); + public CheatEngineArchitecture GetTargetArchitecture(); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs b/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs new file mode 100644 index 0000000..caab5fa --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Core.Domains; + +/// Internal port for CE runtime globals, so the domain is testable without mocking generated SDK statics. +internal interface IRuntimeProbe +{ + public double GetCheatEngineVersion(); + public int GetSystemArchitecture(); + public int GetTargetAbi(); + public long GetOpenedProcessId(); + public bool TargetIs64Bit(); +} diff --git a/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs b/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs new file mode 100644 index 0000000..2e1c7f1 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs @@ -0,0 +1,16 @@ +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Separates the static SDK address-list access from mutation classification so the Client's public failure contract +/// remains deterministic and directly testable. +/// +internal interface ITableRecordMutationPort +{ + public TableRecordMutationStatus TryDelete(MemoryRecordId id); + + public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record); +} diff --git a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs new file mode 100644 index 0000000..0fd41aa --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs @@ -0,0 +1,367 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class InspectionClient(SdkMainThreadDispatcher dispatcher, CoreLifetime lifetime) : IInspectionClient +{ + private readonly SdkMainThreadDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly CoreLifetime _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + private readonly HashSet _registeredSymbolNames = new(StringComparer.Ordinal); + private readonly object _registeredSymbolNamesLock = new(); + + public bool TryGetModules(InspectionCollectionRequest request, out ImmutableArray modules, + out CheatEngineFailure failure, TargetProcessId? processId = null, + CancellationToken cancellationToken = default) + { + ImmutableArray result = ImmutableArray.Empty; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => + { + ModuleInfo[] buffer = new ModuleInfo[request.MaximumItems]; + status = processId.HasValue + ? EngineInspection.EnumerateModules(processId.Value, buffer, out int written) + : EngineInspection.EnumerateModules(buffer, out written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, out failure, cancellationToken)) + { + modules = []; + return false; + } + + modules = result; + return TryMap(status, "Inspection.GetModules", out failure); + } + + public ImmutableArray GetModules(InspectionCollectionRequest request, + TargetProcessId? processId = null, CancellationToken cancellationToken = default) + { + if (TryGetModules(request, out ImmutableArray result, out CheatEngineFailure failure, processId, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return []; + } + + public bool TryGetModuleSections(ModuleName moduleName, InspectionCollectionRequest request, + out ImmutableArray sections, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ImmutableArray result = ImmutableArray.Empty; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => + { + ModuleSectionInfo[] buffer = new ModuleSectionInfo[request.MaximumItems]; + status = EngineInspection.EnumerateSections(moduleName, buffer, out int written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, out failure, cancellationToken)) + { + sections = []; + return false; + } + + sections = result; + return TryMap(status, "Inspection.GetModuleSections", out failure); + } + + public ImmutableArray GetModuleSections(ModuleName moduleName, + InspectionCollectionRequest request, CancellationToken cancellationToken = default) + { + if (TryGetModuleSections(moduleName, request, out ImmutableArray result, + out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return []; + } + + public bool TryGetMemoryRegions(InspectionCollectionRequest request, + out ImmutableArray regions, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ImmutableArray result = ImmutableArray.Empty; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => + { + MemoryRegionInfo[] buffer = new MemoryRegionInfo[request.MaximumItems]; + status = EngineInspection.EnumerateMemoryRegions(buffer, out int written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, out failure, cancellationToken)) + { + regions = []; + return false; + } + + regions = result; + return TryMap(status, "Inspection.GetMemoryRegions", out failure); + } + + public ImmutableArray GetMemoryRegions(InspectionCollectionRequest request, + CancellationToken cancellationToken = default) + { + if (TryGetMemoryRegions(request, out ImmutableArray result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return []; + } + + public bool TryGetMemoryRegion(Address address, out MemoryRegionInfo region, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + MemoryRegionInfo captured = default; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => status = EngineInspection.GetMemoryRegionInfo(address, out captured), + out failure, cancellationToken)) + { + region = default; + return false; + } + + region = captured; + return TryMap(status, "Inspection.GetMemoryRegion", out failure); + } + + public MemoryRegionInfo GetMemoryRegion(Address address, CancellationToken cancellationToken = default) + { + if (TryGetMemoryRegion(address, out MemoryRegionInfo result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryGetSymbol(SymbolExpression expression, out SymbolInfo symbol, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + SymbolInfo captured = default; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => status = EngineInspection.GetSymbolInfo(expression, out captured), + out failure, cancellationToken)) + { + symbol = default; + return false; + } + + symbol = captured; + return TryMap(status, "Inspection.GetSymbol", out failure); + } + + public SymbolInfo GetSymbol(SymbolExpression expression, CancellationToken cancellationToken = default) + { + if (TryGetSymbol(expression, out SymbolInfo result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryResolveName(Address address, [NotNullWhen(true)] out string? name, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + string? captured = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + succeeded = ClientLuaGlobals.TryGetNameFromAddress(ToNativeAddress(address), out captured), + out failure, + cancellationToken)) + { + name = null; + return false; + } + + if (succeeded && captured is not null) + { + name = captured; + return true; + } + + name = null; + failure = succeeded + ? new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Inspection.ResolveName", + "Cheat Engine returned an invalid symbol-name result.") + : new CheatEngineFailure(CheatEngineFailureKind.NotFound, "Inspection.ResolveName", + "Cheat Engine did not return a symbol name for the requested address."); + return false; + } + + public string ResolveName(Address address, CancellationToken cancellationToken = default) + { + if (TryResolveName(address, out string? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return string.Empty; + } + + public bool TryRegisterSymbol(SymbolRegistration registration, + [NotNullWhen(true)] out ISymbolRegistrationLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + lease = null; + if (!TryReserveSymbolName(registration.Name, out failure)) + { + return false; + } + + if (!_dispatcher.TryInvoke(() => ClientLuaGlobals.RegisterSymbol(registration.Name, + ToNativeAddress(registration.Address), registration.DoNotSave), out failure, cancellationToken)) + { + ReleaseSymbolName(registration.Name); + return false; + } + + SymbolRegistrationLease created = new( + registration, + _dispatcher, + lease => _lifetime.Untrack(lease), + ClientLuaGlobals.UnregisterSymbol, + ReleaseSymbolName); + try + { + _lifetime.Track(created); + lease = created; + failure = default; + return true; + } + catch (Exception exception) + { + try + { + created.Dispose(); + } + catch + { + // The original lifecycle failure is the meaningful result. The registration name was still released below. + } + + ReleaseSymbolName(registration.Name); + failure = CoreFailureFactory.FromException("Inspection.RegisterSymbol", exception); + return false; + } + } + + public ISymbolRegistrationLease RegisterSymbol(SymbolRegistration registration, + CancellationToken cancellationToken = default) + { + if (TryRegisterSymbol(registration, out ISymbolRegistrationLease? result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + throw new InvalidOperationException("Unreachable failure flow."); + } + + public bool TryResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + out Address address, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + Address captured = Address.Zero; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => status = EngineInspection.ResolveAddress(expression, options, out captured), + out failure, cancellationToken)) + { + address = default; + return false; + } + + address = captured; + return TryMap(status, "Inspection.ResolveAddress", out failure); + } + + public Address ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + CancellationToken cancellationToken = default) + { + if (TryResolveAddress(expression, options, out Address result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + private static bool TryMap(InspectionStatus status, string operation, out CheatEngineFailure failure) + { + if (status == InspectionStatus.Success) + { + failure = default; + return true; + } + + CheatEngineFailureKind kind = status switch + { + InspectionStatus.NotFound => CheatEngineFailureKind.NotFound, + InspectionStatus.DestinationTooSmall => CheatEngineFailureKind.ResultLimitExceeded, + InspectionStatus.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + InspectionStatus.LuaFailure => CheatEngineFailureKind.LuaError, + InspectionStatus.InvalidResult => CheatEngineFailureKind.InvalidHostResult, + _ => CheatEngineFailureKind.Unknown + }; + failure = new CheatEngineFailure(kind, operation, $"Cheat Engine inspection returned '{status}'."); + return false; + } + + private bool TryReserveSymbolName(string name, out CheatEngineFailure failure) + { + lock (_registeredSymbolNamesLock) + { + if (_registeredSymbolNames.Add(name)) + { + failure = default; + return true; + } + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Inspection.RegisterSymbol", + "This client activation already owns a symbol registration with the requested name."); + return false; + } + + private void ReleaseSymbolName(string name) + { + lock (_registeredSymbolNamesLock) + { + _registeredSymbolNames.Remove(name); + } + } + + private static nuint ToNativeAddress(Address address) + { + return checked((nuint) address.Value); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs new file mode 100644 index 0000000..42d79e0 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs @@ -0,0 +1,103 @@ +using System.ComponentModel; +using System.Diagnostics; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class LocalProcessHost : IProcessHost +{ + public long GetOpenedProcessId() + { + return ClientLuaGlobals.GetOpenedProcessId(); + } + + public void OpenProcess(long processId) + { + ClientLuaGlobals.OpenProcess(processId); + } + + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + try + { + using Process managedProcess = Process.GetProcessById(processId); + return TryCapture(managedProcess, out process); + } + catch (ArgumentException) + { + process = default; + return false; + } + } + + public IReadOnlyList FindProcessesByExactName(string processName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(processName); + using ProcessCollection processes = new(Process.GetProcessesByName(processName)); + List matches = new(processes.Count); + for (int index = 0; index < processes.Count; index++) + { + if (TryCapture(processes[index], out LocalProcessInfo process) && + string.Equals(process.Name, processName, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(process); + } + } + + return matches; + } + + public CheatEngineArchitecture GetTargetArchitecture() + { + return ClientLuaGlobals.TargetIs64Bit() + ? CheatEngineArchitecture.X64 + : CheatEngineArchitecture.X86; + } + + private static bool TryCapture(Process process, out LocalProcessInfo captured) + { + try + { + string? executablePath = TryGetExecutablePath(process); + captured = new LocalProcessInfo(process.Id, process.ProcessName, executablePath); + return true; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + captured = default; + return false; + } + } + + private static string? TryGetExecutablePath(Process process) + { + try + { + return process.MainModule?.FileName; + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception + or NotSupportedException) + { + return null; + } + } + + private sealed class ProcessCollection(Process[] processes) : IDisposable + { + private readonly Process[] _processes = processes; + + internal int Count => _processes.Length; + + internal Process this[int index] => _processes[index]; + + public void Dispose() + { + foreach (Process process in _processes) + { + process.Dispose(); + } + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessInfo.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessInfo.cs new file mode 100644 index 0000000..e43d3cb --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LocalProcessInfo.cs @@ -0,0 +1,6 @@ +using System.Diagnostics; + +namespace CheatEngine.Client.Core.Domains; + +/// Copied local process metadata; it does not own a handle. +internal readonly record struct LocalProcessInfo(int Id, string? Name, string? ExecutablePath); diff --git a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs new file mode 100644 index 0000000..6d5cd62 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs @@ -0,0 +1,261 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class LuaClient : ILuaClient +{ + private readonly ICheatEngineDispatcher _dispatcher; + private readonly Func _epochProvider; + private readonly Func _isContextCurrent; + private readonly HashSet _registeredModules = new(ReferenceEqualityComparer.Instance); + private readonly object _registeredModulesLock = new(); + private readonly Action _trackLease; + private readonly Action _untrackLease; + + internal LuaClient(SdkMainThreadDispatcher dispatcher, CoreLifetime lifetime) + : this(dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)), + CreateProductionInitialization(lifetime)) + { + } + + private LuaClient(ICheatEngineDispatcher dispatcher, LuaClientInitialization initialization) + : this( + dispatcher, + initialization.EpochProvider, + initialization.IsContextCurrent, + initialization.TrackLease, + initialization.UntrackLease) + { + } + + /// Deterministic internal seam for Core tests; production construction uses the SDK dispatcher overload. + internal LuaClient( + ICheatEngineDispatcher dispatcher, + Func epochProvider, + Func isContextCurrent, + Action? trackLease = null, + Action? untrackLease = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _epochProvider = epochProvider ?? throw new ArgumentNullException(nameof(epochProvider)); + _isContextCurrent = isContextCurrent ?? throw new ArgumentNullException(nameof(isContextCurrent)); + _trackLease = trackLease ?? (static _ => + { + }); + _untrackLease = untrackLease ?? (static _ => + { + }); + } + + public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILuaModuleLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(luaModule); + lease = null; + if (cancellationToken.IsCancellationRequested) + { + failure = CoreFailureFactory.Cancelled("Lua.RegisterModule"); + return false; + } + + if (!TryReserveModule(luaModule, out failure)) + { + return false; + } + + bool registered = false; + CheatEngineFailure moduleFailure = default; + if (!_dispatcher.TryInvoke( + () => + { + try + { + luaModule.Register(); + registered = true; + } + catch (Exception exception) + { + moduleFailure = CoreFailureFactory.FromException("Lua.RegisterModule", exception); + } + }, + out failure, + cancellationToken)) + { + ReleaseModule(luaModule); + return false; + } + + if (!registered) + { + ReleaseModule(luaModule); + failure = moduleFailure; + return false; + } + + LuaModuleLease created = new(luaModule, _epochProvider(), _dispatcher, _isContextCurrent, _untrackLease, + ReleaseModule); + try + { + _trackLease(created); + lease = created; + failure = default; + return true; + } + catch (Exception exception) + { + try + { + created.Dispose(); + } + catch + { + // The track failure remains the meaningful result; the module was still removed from this Client scope. + } + + failure = CoreFailureFactory.FromException("Lua.RegisterModule", exception); + return false; + } + } + + public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default) + { + if (TryRegisterModule(luaModule, out ILuaModuleLease? lease, out CheatEngineFailure failure, cancellationToken)) + { + return lease; + } + + failure.Throw(); + throw new InvalidOperationException("Unreachable failure flow."); + } + + public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation + { + ArgumentNullException.ThrowIfNull(operation); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = CoreFailureFactory.Cancelled("Lua.Execute"); + return false; + } + + long epoch = _epochProvider(); + LuaOperationResult operationResult = default; + if (!_dispatcher.TryInvoke( + () => operationResult = ExecuteOperation(operation, epoch), + out failure, + cancellationToken)) + { + result = default; + return false; + } + + if (!operationResult.Succeeded) + { + result = default; + failure = IsDefined(operationResult.Failure) + ? operationResult.Failure + : new CheatEngineFailure( + CheatEngineFailureKind.Unknown, + "Lua.Execute", + "The typed Lua operation returned failure without an associated Cheat Engine failure."); + return false; + } + + result = operationResult.Result; + failure = default; + return true; + } + + public TResult Execute(TOperation operation, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation + { + if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + return ThrowFailure(failure); + } + + private static T ThrowFailure(CheatEngineFailure failure) + { + failure.Throw(); + throw new UnreachableException(); + } + + private LuaOperationResult ExecuteOperation(TOperation operation, long epoch) + where TOperation : ILuaOperation + { + LuaOperationContext context = new(epoch, _isContextCurrent); + try + { + context.ThrowIfExpired(); + bool succeeded = operation.TryExecute(context, out TResult? result, out CheatEngineFailure failure); + return new LuaOperationResult(succeeded, result!, failure); + } + finally + { + context.Expire(); + } + } + + private static bool IsDefined(CheatEngineFailure failure) + { + return !string.IsNullOrWhiteSpace(failure.Operation) && !string.IsNullOrWhiteSpace(failure.Message); + } + + private bool TryReserveModule(ILuaModule module, out CheatEngineFailure failure) + { + lock (_registeredModulesLock) + { + if (_registeredModules.Add(module)) + { + failure = default; + return true; + } + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", + "This client activation already owns the supplied Lua module instance."); + return false; + } + + private void ReleaseModule(ILuaModule module) + { + lock (_registeredModulesLock) + { + _registeredModules.Remove(module); + } + } + + private static LuaClientInitialization CreateProductionInitialization(CoreLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(lifetime); + return new LuaClientInitialization( + () => lifetime.Epoch, + () => lifetime.IsActivationCurrent, + lease => lifetime.Track(lease), + lease => lifetime.Untrack(lease)); + } + + private readonly record struct LuaOperationResult( + bool Succeeded, + TResult Result, + CheatEngineFailure Failure); + + private readonly record struct LuaClientInitialization( + Func EpochProvider, + Func IsContextCurrent, + Action TrackLease, + Action UntrackLease); +} diff --git a/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs b/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs new file mode 100644 index 0000000..12d02f1 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs @@ -0,0 +1,70 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; + +namespace CheatEngine.Client.Core.Domains; + +/// Activation-owned release path for one explicitly registered application Lua module. +internal sealed class LuaModuleLease( + ILuaModule module, + long epoch, + ICheatEngineDispatcher dispatcher, + Func isActivationCurrent, + Action untrack, + Action releaseModule) : ILuaModuleLease +{ + private readonly ICheatEngineDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly object _disposeLock = new(); + + private readonly Func _isActivationCurrent = + isActivationCurrent ?? throw new ArgumentNullException(nameof(isActivationCurrent)); + + private readonly ILuaModule _module = module ?? throw new ArgumentNullException(nameof(module)); + + private readonly Action _releaseModule = + releaseModule ?? throw new ArgumentNullException(nameof(releaseModule)); + + private readonly Action _untrack = untrack ?? throw new ArgumentNullException(nameof(untrack)); + private int _released; + + public long Epoch + { + get; + } = epoch; + + public bool IsReleased => Volatile.Read(ref _released) != 0; + + public void Dispose() + { + lock (_disposeLock) + { + if (Volatile.Read(ref _released) != 0) + { + return; + } + + // A detached activation has no legal SDK dispatch path left. There is nothing more that this lease can + // safely do, so release managed ownership without attempting to call Cheat Engine. + if (!_isActivationCurrent()) + { + CompleteRelease(); + return; + } + + _dispatcher.Invoke(_module.Unregister); + + // Do not make any ownership transition until the application module confirmed unregistration by returning. + // The write occurs while holding the lock, so another disposer either observes the completed release or + // waits and retries after the original dispatch fault. + CompleteRelease(); + } + } + + private void CompleteRelease() + { + Volatile.Write(ref _released, 1); + _untrack(this); + _releaseModule(_module); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs b/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs new file mode 100644 index 0000000..16e0a12 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs @@ -0,0 +1,35 @@ +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains; + +/// Epoch-bound context that is invalidated immediately after a typed Lua operation returns. +internal sealed class LuaOperationContext(long epoch, Func isCurrent) : ILuaExecutionContext +{ + private readonly Func _isCurrent = isCurrent ?? throw new ArgumentNullException(nameof(isCurrent)); + private int _expired; + + public long Epoch + { + get; + } = epoch; + + public bool IsActive => Volatile.Read(ref _expired) == 0 && _isCurrent(); + + public void ThrowIfExpired() + { + if (IsActive) + { + return; + } + + throw new CheatEngineActivationExpiredException( + "Lua.OperationContext", + "The Lua operation context is no longer valid for the current Cheat Engine activation."); + } + + internal void Expire() + { + Volatile.Write(ref _expired, 1); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs b/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs new file mode 100644 index 0000000..52ae412 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs @@ -0,0 +1,31 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class LuaRuntimeProbe : IRuntimeProbe +{ + public double GetCheatEngineVersion() + { + return ClientLuaGlobals.GetCheatEngineVersion(); + } + + public int GetSystemArchitecture() + { + return ClientLuaGlobals.GetSystemArchitecture(); + } + + public int GetTargetAbi() + { + return ClientLuaGlobals.GetTargetAbi(); + } + + public long GetOpenedProcessId() + { + return ClientLuaGlobals.GetOpenedProcessId(); + } + + public bool TargetIs64Bit() + { + return ClientLuaGlobals.TargetIs64Bit(); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs new file mode 100644 index 0000000..76b7fbf --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs @@ -0,0 +1,609 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class MemoryClient(ICheatEngineDispatcher dispatcher) : IMemoryClient +{ + private readonly ICheatEngineDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + T? captured = default; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryReadKnown(address, out captured, out hostFailure), + out failure, cancellationToken)) + { + value = default; + return false; + } + + if (!succeeded) + { + value = default; + failure = hostFailure is null + ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.ReadPrimitive", + $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") + : new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadPrimitive", hostFailure); + return false; + } + + value = captured!; + failure = default; + return true; + } + + public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) + { + if (TryReadPrimitive(address, out T? value, out var failure, cancellationToken)) + { + return value!; + } + + failure.Throw(); + return default!; + } + + public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + string? hostFailure = null; + bool handled = false; + bool succeeded = false; + if (!_dispatcher.TryInvoke( + () => succeeded = TryWriteKnown(address, value, out handled, out hostFailure), + out failure, cancellationToken)) + { + return false; + } + + if (!succeeded) + { + failure = !handled + ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.WritePrimitive", + $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") + : new CheatEngineFailure(CheatEngineFailureKind.MemoryWriteFailed, "Memory.WritePrimitive", + hostFailure ?? "Cheat Engine rejected the target-memory write."); + return false; + } + + failure = default; + return true; + } + + public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default) + { + if (!TryWritePrimitive(address, value, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + T? captured = default; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryReadCore(request, out captured, out hostFailure), + out failure, cancellationToken)) + { + value = default; + return false; + } + + if (!succeeded) + { + value = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.Read", + hostFailure ?? "Cheat Engine rejected the target-memory read."); + return false; + } + + value = captured!; + failure = default; + return true; + } + + public T Read(MemoryReadRequest request, CancellationToken cancellationToken = default) + { + if (TryRead(request, out T? value, out var failure, cancellationToken)) + { + return value; + } + + failure.Throw(); + return default!; + } + + public bool TryWrite(MemoryWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryWriteCore(request, out hostFailure), + out failure, cancellationToken)) + { + return false; + } + + if (!succeeded) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.MemoryWriteFailed, "Memory.Write", + hostFailure ?? "Cheat Engine rejected the target-memory write."); + return false; + } + + failure = default; + return true; + } + + public void Write(MemoryWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWrite(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.Length); + ImmutableArray captured = ImmutableArray.Empty; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + byte[] buffer = new byte[request.Length]; + succeeded = TargetMemory.TryReadBytes(request.Address, buffer, out MemoryAccessFailure sdkFailure); + if (succeeded) + { + captured = ImmutableArray.CreateRange(buffer); + } + else + { + hostFailure = sdkFailure.ToString(); + } + }, out failure, cancellationToken)) + { + bytes = []; + return false; + } + + bytes = captured; + return TryMapMemoryFailure(succeeded, false, "Memory.ReadBytes", hostFailure, out failure); + } + + public ImmutableArray ReadBytes(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default) + { + if (TryReadBytes(request, out ImmutableArray bytes, out CheatEngineFailure failure, cancellationToken)) + { + return bytes; + } + + failure.Throw(); + return []; + } + + public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (request.Bytes.IsDefaultOrEmpty) + { + throw new ArgumentException("At least one byte is required.", nameof(request)); + } + + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + succeeded = TargetMemory.TryWriteBytes(request.Address, request.Bytes.AsSpan(), + out MemoryAccessFailure sdkFailure); + if (!succeeded) + { + hostFailure = sdkFailure.ToString(); + } + }, out failure, cancellationToken)) + { + return false; + } + + return TryMapMemoryFailure(succeeded, true, "Memory.WriteBytes", hostFailure, out failure); + } + + public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWriteBytes(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] out string? value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumLength); + string? captured = null; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + succeeded = TargetMemory.TryReadString(request.Address, request.MaximumLength, + request.WideCharacter, out captured, out MemoryAccessFailure sdkFailure); + if (!succeeded) + { + hostFailure = sdkFailure.ToString(); + } + }, out failure, cancellationToken)) + { + value = null; + return false; + } + + value = captured; + return TryMapMemoryFailure(succeeded, false, "Memory.ReadString", hostFailure, out failure); + } + + public string ReadString(MemoryStringReadRequest request, CancellationToken cancellationToken = default) + { + if (TryReadString(request, out string? value, out CheatEngineFailure failure, cancellationToken)) + { + return value; + } + + failure.Throw(); + return string.Empty; + } + + public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request.Value); + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + succeeded = TargetMemory.TryWriteString(request.Address, request.Value.AsSpan(), + request.WideCharacter, out MemoryAccessFailure sdkFailure); + if (!succeeded) + { + hostFailure = sdkFailure.ToString(); + } + }, out failure, cancellationToken)) + { + return false; + } + + return TryMapMemoryFailure(succeeded, true, "Memory.WriteString", hostFailure, out failure); + } + + public void WriteString(MemoryStringWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWriteString(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TryResolvePointerChain(PointerChainRequest request, out Address address, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + if (request.Offsets.IsDefaultOrEmpty) + { + throw new ArgumentException("A pointer chain requires at least one offset.", nameof(request)); + } + + if (request.Offsets.Length > 64) + { + throw new ArgumentOutOfRangeException(nameof(request), "A pointer chain is limited to 64 hops."); + } + + Address captured = request.BaseAddress; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + Address current = request.BaseAddress; + foreach (long offset in request.Offsets) + { + if (!TargetMemory.TryReadPointer(current, out Address pointer, out MemoryAccessFailure sdkFailure)) + { + hostFailure = sdkFailure.ToString(); + return; + } + + current = pointer + offset; + } + + captured = current; + succeeded = true; + }, out failure, cancellationToken)) + { + address = default; + return false; + } + + address = captured; + return TryMapMemoryFailure(succeeded, false, "Memory.ResolvePointerChain", hostFailure, out failure); + } + + public Address ResolvePointerChain(PointerChainRequest request, CancellationToken cancellationToken = default) + { + if (TryResolvePointerChain(request, out Address address, out CheatEngineFailure failure, cancellationToken)) + { + return address; + } + + failure.Throw(); + return default; + } + + private static bool TryReadCore(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, + out string? failure) + { + failure = null; + TargetMemoryCodecContext context = TargetMemoryCodecContext.Create(); + if (request.Codec.TryRead(context, request.Address, out value)) + { + return true; + } + + failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory read."; + return false; + } + + private static bool TryWriteCore(MemoryWriteRequest request, out string? failure) + { + failure = null; + TargetMemoryCodecContext context = TargetMemoryCodecContext.Create(); + T value = request.Value; + if (request.Codec.TryWrite(context, request.Address, in value)) + { + return true; + } + + failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory write."; + return false; + } + + private static bool TryReadKnown(Address address, [MaybeNullWhen(false)] out T value, out string? failure) + { + object? boxed = null; + bool succeeded; + if (typeof(T) == typeof(byte)) + { + succeeded = Read(TargetMemory.TryReadUInt8, address, out boxed, out failure); + } + else if (typeof(T) == typeof(sbyte)) + { + succeeded = Read(TargetMemory.TryReadInt8, address, out boxed, out failure); + } + else if (typeof(T) == typeof(ushort)) + { + succeeded = Read(TargetMemory.TryReadUInt16, address, out boxed, out failure); + } + else if (typeof(T) == typeof(short)) + { + succeeded = Read(TargetMemory.TryReadInt16, address, out boxed, out failure); + } + else if (typeof(T) == typeof(uint)) + { + succeeded = Read(TargetMemory.TryReadUInt32, address, out boxed, out failure); + } + else if (typeof(T) == typeof(int)) + { + succeeded = Read(TargetMemory.TryReadInt32, address, out boxed, out failure); + } + else if (typeof(T) == typeof(ulong)) + { + succeeded = Read(TargetMemory.TryReadUInt64, address, out boxed, out failure); + } + else if (typeof(T) == typeof(long)) + { + succeeded = Read(TargetMemory.TryReadInt64, address, out boxed, out failure); + } + else if (typeof(T) == typeof(float)) + { + succeeded = Read(TargetMemory.TryReadSingle, address, out boxed, out failure); + } + else if (typeof(T) == typeof(double)) + { + succeeded = Read(TargetMemory.TryReadDouble, address, out boxed, out failure); + } + else if (typeof(T) == typeof(Address)) + { + succeeded = Read
(TargetMemory.TryReadPointer, address, out boxed, out failure); + } + else + { + value = default; + failure = null; + return false; + } + + if (succeeded && boxed is T typed) + { + value = typed; + return true; + } + + value = default; + return false; + } + + private static bool TryWriteKnown(Address address, T value, out bool handled, out string? failure) + { + handled = true; + if (typeof(T) == typeof(byte)) + { + return Write(TargetMemory.TryWriteUInt8, address, (byte) (object) value!, out failure); + } + + if (typeof(T) == typeof(sbyte)) + { + return Write(TargetMemory.TryWriteInt8, address, (sbyte) (object) value!, out failure); + } + + if (typeof(T) == typeof(ushort)) + { + return Write(TargetMemory.TryWriteUInt16, address, (ushort) (object) value!, out failure); + } + + if (typeof(T) == typeof(short)) + { + return Write(TargetMemory.TryWriteInt16, address, (short) (object) value!, out failure); + } + + if (typeof(T) == typeof(uint)) + { + return Write(TargetMemory.TryWriteUInt32, address, (uint) (object) value!, out failure); + } + + if (typeof(T) == typeof(int)) + { + return Write(TargetMemory.TryWriteInt32, address, (int) (object) value!, out failure); + } + + if (typeof(T) == typeof(ulong)) + { + return Write(TargetMemory.TryWriteUInt64, address, (ulong) (object) value!, out failure); + } + + if (typeof(T) == typeof(long)) + { + return Write(TargetMemory.TryWriteInt64, address, (long) (object) value!, out failure); + } + + if (typeof(T) == typeof(float)) + { + return Write(TargetMemory.TryWriteSingle, address, (float) (object) value!, out failure); + } + + if (typeof(T) == typeof(double)) + { + return Write(TargetMemory.TryWriteDouble, address, (double) (object) value!, out failure); + } + + if (typeof(T) == typeof(Address)) + { + return Write(TargetMemory.TryWritePointer, address, (Address) (object) value!, out failure); + } + + handled = false; + failure = null; + return false; + } + + private static bool Read(Reader reader, Address address, out object? value, out string? failure) + { + if (reader(address, out T result, out MemoryAccessFailure sdkFailure)) + { + value = result; + failure = null; + return true; + } + + value = null; + failure = sdkFailure.ToString(); + return false; + } + + private static bool Write(Writer writer, Address address, T value, out string? failure) + { + if (writer(address, value, out MemoryAccessFailure sdkFailure)) + { + failure = null; + return true; + } + + failure = sdkFailure.ToString(); + return false; + } + + private static bool TryMapMemoryFailure(bool succeeded, bool isWrite, string operation, string? hostFailure, + out CheatEngineFailure failure) + { + if (succeeded) + { + failure = default; + return true; + } + + failure = new CheatEngineFailure( + isWrite ? CheatEngineFailureKind.MemoryWriteFailed : CheatEngineFailureKind.MemoryReadFailed, + operation, + hostFailure ?? "Cheat Engine rejected the target-memory operation."); + return false; + } + + private delegate bool Reader(Address address, out T value, out MemoryAccessFailure failure); + + private delegate bool Writer(Address address, T value, out MemoryAccessFailure failure); + + private sealed class TargetMemoryCodecContext : IMemoryReadContext, IMemoryWriteContext + { + private int _pointerSize; + + internal string? Failure + { + get; + private set; + } + + public int PointerSize + { + get + { + if (_pointerSize != 0) + { + return _pointerSize; + } + + int pointerSize = ClientLuaGlobals.TargetIs64Bit() ? sizeof(ulong) : sizeof(uint); + _pointerSize = pointerSize; + return pointerSize; + } + } + + public bool TryReadBytes(Address address, Span destination) + { + if (TargetMemory.TryReadBytes(address, destination, out MemoryAccessFailure sdkFailure)) + { + Failure = null; + return true; + } + + Failure = sdkFailure.ToString(); + return false; + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source) + { + if (TargetMemory.TryWriteBytes(address, source, out MemoryAccessFailure sdkFailure)) + { + Failure = null; + return true; + } + + Failure = sdkFailure.ToString(); + return false; + } + + internal static TargetMemoryCodecContext Create() + { + return new TargetMemoryCodecContext(); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs b/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs new file mode 100644 index 0000000..39afede --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs @@ -0,0 +1,16 @@ +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// One inspected link while validating that a record can be reparented without forming a cycle. +internal readonly record struct ParentChainStep(ParentChainStepKind Kind, MemoryRecordId ParentId) +{ + internal static ParentChainStep Root => new(ParentChainStepKind.Root, default); + + internal static ParentChainStep HostRejected => new(ParentChainStepKind.HostRejected, default); + + internal static ParentChainStep Parent(MemoryRecordId parentId) + { + return new ParentChainStep(ParentChainStepKind.Parent, parentId); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs b/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs new file mode 100644 index 0000000..ff53736 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs @@ -0,0 +1,9 @@ +namespace CheatEngine.Client.Core.Domains; + +/// Represents the outcome of reading one candidate-parent link. +internal enum ParentChainStepKind +{ + Root, + Parent, + HostRejected +} diff --git a/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs b/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs new file mode 100644 index 0000000..ed5d792 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs @@ -0,0 +1,281 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class PatternScanner(SdkMainThreadDispatcher dispatcher) : IPatternScanner +{ + private const int MaximumModuleSnapshot = 4096; + private const string InModuleOperation = "Patterns.InModule"; + private const string ScanOperation = "Patterns.Scan"; + + private readonly SdkMainThreadDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!TryValidateRequest(request, out failure)) + { + result = default; + return false; + } + + AobScanResult captured = default; + CheatEngineFailure hostFailure = default; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryScanCore(request, out captured, out hostFailure), + out failure, cancellationToken)) + { + result = default; + return false; + } + + if (!succeeded) + { + result = default; + failure = hostFailure; + return false; + } + + result = captured; + failure = default; + return true; + } + + public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default) + { + if (TryScan(request, out AobScanResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + private static bool TryScanCore(AobScanRequest request, out AobScanResult result, + out CheatEngineFailure failure) + { + if (!AobScanner.TryScan(request.Pattern.Value, request.Options, out Owned? owner)) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + "Cheat Engine did not return an AOB result list."); + return false; + } + + using (owner) + { + StringList list = owner.Value; + if (!TryGetResultCount(list, out int count, out failure)) + { + result = default; + return false; + } + + bool hasModuleRange = false; + ModuleRange moduleRange = default; + if (request.Module.HasValue && + !TryGetModuleRange(request.Module.Value, out hasModuleRange, out moduleRange, + out failure)) + { + result = default; + return false; + } + + return TryMaterializeMatches(list, count, request, hasModuleRange, moduleRange, out result, out failure); + } + } + + private static bool TryGetResultCount(StringList list, out int count, out CheatEngineFailure failure) + { + if (list.TryGetCount(out count) && count >= 0) + { + failure = default; + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ScanOperation, + "Cheat Engine returned an invalid AOB result count."); + return false; + } + + private static bool TryMaterializeMatches( + StringList list, + int count, + AobScanRequest request, + bool hasModuleRange, + ModuleRange moduleRange, + out AobScanResult result, + out CheatEngineFailure failure) + { + // Do not preallocate to a caller-controlled materialization limit. The limit remains strict below, while + // storage grows only for addresses that survived every managed filter. + ImmutableArray
.Builder matches = ImmutableArray.CreateBuilder
(); + for (int index = 0; index < count; index++) + { + if (!TryGetMatchAddress(list, index, out Address address, out failure)) + { + result = default; + return false; + } + + if (!IsIncluded(address, request, hasModuleRange, moduleRange)) + { + continue; + } + + if (matches.Count == request.MaximumResults) + { + result = new AobScanResult(matches.ToImmutable(), true); + failure = default; + return true; + } + + matches.Add(address); + } + + result = new AobScanResult(matches.ToImmutable(), false); + failure = default; + return true; + } + + private static bool TryGetMatchAddress(StringList list, int index, out Address address, + out CheatEngineFailure failure) + { + if (list.TryGetItem(index, out string? text) && Address.TryParse(text, out address)) + { + failure = default; + return true; + } + + address = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ScanOperation, + $"AOB result {index} was not a hexadecimal address."); + return false; + } + + private static bool IsIncluded(Address address, AobScanRequest request, bool hasModuleRange, ModuleRange moduleRange) + { + return (!hasModuleRange || moduleRange.Contains(address)) && + (!request.Range.HasValue || request.Range.Value.Contains(address)); + } + + internal static bool TryValidateRequest(AobScanRequest request, out CheatEngineFailure failure) + { + if (string.IsNullOrWhiteSpace(request.Pattern.Value)) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + "An AOB scan requires a normalized, non-empty pattern."); + return false; + } + + if (request.MaximumResults <= 0) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + "An AOB scan requires a positive materialization limit."); + return false; + } + + if (request.Module.HasValue && string.IsNullOrWhiteSpace(request.Module.Value.Value)) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + "An AOB module filter must be non-empty."); + return false; + } + + if (request.Range.HasValue && request.Range.Value.End < request.Range.Value.Start) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + "An AOB range end address must not precede its start address."); + return false; + } + + failure = default; + return true; + } + + private static bool TryGetModuleRange(ModuleName requested, out bool hasRange, out ModuleRange range, + out CheatEngineFailure failure) + { + ModuleInfo[] modules = new ModuleInfo[MaximumModuleSnapshot]; + InspectionStatus status = EngineInspection.EnumerateModules(modules, out int written); + if (status != InspectionStatus.Success) + { + hasRange = false; + range = default; + failure = new CheatEngineFailure( + status == InspectionStatus.DestinationTooSmall + ? CheatEngineFailureKind.ResultLimitExceeded + : CheatEngineFailureKind.CapabilityUnavailable, + InModuleOperation, $"Module inspection returned '{status}'."); + return false; + } + + bool found = false; + range = default; + for (int index = 0; index < written; index++) + { + ModuleInfo module = modules[index]; + if (!string.Equals(module.Name, requested.Value, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (found) + { + hasRange = false; + failure = new CheatEngineFailure(CheatEngineFailureKind.AmbiguousMatch, InModuleOperation, + $"More than one module named '{requested.Value}' was present in the selected target."); + return false; + } + + if (!module.ImageSize.HasValue) + { + hasRange = false; + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, + InModuleOperation, "Cheat Engine did not report the requested module's image size."); + return false; + } + + if (module.ImageSize.Value.Value == 0) + { + hasRange = false; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, InModuleOperation, + "Cheat Engine reported a zero-length requested module."); + return false; + } + + found = true; + range = new ModuleRange(module.BaseAddress.Value, module.ImageSize.Value.Value); + } + + if (found) + { + hasRange = true; + failure = default; + return true; + } + + hasRange = false; + failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, InModuleOperation, + $"Module '{requested.Value}' was not present in the selected target."); + return false; + } + + private readonly record struct ModuleRange(ulong Start, ulong Size) + { + internal bool Contains(Address address) + { + return address.Value >= Start && address.Value - Start < Size; + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs b/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs new file mode 100644 index 0000000..c076e2a --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs @@ -0,0 +1,29 @@ +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +internal readonly record struct ProbeResult( + RuntimeCapabilityId Capability, + RuntimeCapabilityAvailabilityState State, + T? Value) +{ + internal static ProbeResult Available(RuntimeCapabilityId capability, T value) + { + return new ProbeResult(capability, RuntimeCapabilityAvailabilityState.Available, value); + } + + internal static ProbeResult Unavailable(RuntimeCapabilityId capability) + { + return new ProbeResult(capability, RuntimeCapabilityAvailabilityState.Unavailable, default); + } + + internal static ProbeResult Unknown(RuntimeCapabilityId capability) + { + return new ProbeResult(capability, RuntimeCapabilityAvailabilityState.Unknown, default); + } + + internal RuntimeCapabilityAvailability ToAvailability() + { + return new RuntimeCapabilityAvailability(Capability, State, RuntimeCapabilityContract.Unknown); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs new file mode 100644 index 0000000..a51718d --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs @@ -0,0 +1,292 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// Owns deterministic target selection without taking ownership of Cheat Engine's global process state. +internal sealed class ProcessClient : IProcessClient +{ + private readonly ICheatEngineDispatcher _dispatcher; + private readonly IProcessHost _host; + private readonly Lock _selectionGate = new(); + private readonly TargetSelectionLifetime _selectionLifetime; + private ProcessSelection? _lastSelection; + + internal ProcessClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime) + : this(dispatcher, new LocalProcessHost(), + lifetime?.TargetSelection ?? throw new ArgumentNullException(nameof(lifetime))) + { + } + + internal ProcessClient( + ICheatEngineDispatcher dispatcher, + IProcessHost host, + TargetSelectionLifetime selectionLifetime) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _host = host ?? throw new ArgumentNullException(nameof(host)); + _selectionLifetime = selectionLifetime ?? throw new ArgumentNullException(nameof(selectionLifetime)); + } + + public bool TryGetCurrent( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryReadCurrent("Processes.GetCurrent", out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot GetCurrent(CancellationToken cancellationToken = default) + { + if (TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryRefresh( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryReadCurrent("Processes.Refresh", out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot Refresh(CancellationToken cancellationToken = default) + { + if (TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryAttach( + TargetProcessId processId, + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (processId.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(processId)); + } + + ProcessSnapshot captured = default; + if (!_dispatcher.TryInvoke( + () => + { + _host.OpenProcess(processId.Value); + captured = CaptureCurrent("Processes.Attach"); + if (captured.Id != processId) + { + throw new InvalidOperationException("Cheat Engine did not select the requested process."); + } + }, + out failure, + cancellationToken)) + { + snapshot = default; + return false; + } + + snapshot = captured; + return true; + } + + public ProcessSnapshot Attach(TargetProcessId processId, CancellationToken cancellationToken = default) + { + if (TryAttach(processId, out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryAttachExactName( + string processName, + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + string expectedName = NormalizeExactProcessName(processName); + IReadOnlyList matches = _host.FindProcessesByExactName(expectedName); + if (matches.Count == 0) + { + snapshot = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.NotFound, + "Processes.AttachExactName", + $"No local process named '{processName}' was found."); + return false; + } + + if (matches.Count != 1) + { + snapshot = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.AmbiguousMatch, + "Processes.AttachExactName", + $"Several local processes named '{processName}' were found."); + return false; + } + + return TryAttach(new TargetProcessId(matches[0].Id), out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot AttachExactName(string processName, CancellationToken cancellationToken = default) + { + if (TryAttachExactName(processName, out ProcessSnapshot snapshot, out CheatEngineFailure failure, + cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + private bool TryReadCurrent( + string operation, + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken) + { + ProcessSnapshot captured = default; + if (!_dispatcher.TryInvoke(() => captured = CaptureCurrent(operation), out failure, cancellationToken)) + { + snapshot = default; + if (failure.Kind == CheatEngineFailureKind.OperationRejected) + { + failure = new CheatEngineFailure( + CheatEngineFailureKind.TargetNotAttached, + operation, + failure.Message, + failure.Exception); + } + + return false; + } + + snapshot = captured; + return true; + } + + private ProcessSnapshot CaptureCurrent(string operation) + { + long processId = _host.GetOpenedProcessId(); + if (processId is <= 0 or > int.MaxValue) + { + ClearObservedSelection(operation); + throw new InvalidOperationException("Cheat Engine has no selected local target process."); + } + + TargetProcessId id = new(checked((int) processId)); + if (!_host.TryGetLocalProcess(id.Value, out LocalProcessInfo process)) + { + ClearObservedSelection(operation); + throw new InvalidOperationException("The selected process no longer exists locally."); + } + + if (process.Id != id.Value) + { + throw new EngineMarshallingException( + "Processes.GetCurrent", + EngineMarshallingDirection.Result, + "metadata for the selected process identifier", + $"metadata for process {process.Id}"); + } + + CheatEngineArchitecture architecture = TryGetTargetArchitecture(); + return ObserveSelection(id, process, architecture, operation); + } + + private CheatEngineArchitecture TryGetTargetArchitecture() + { + try + { + return _host.GetTargetArchitecture(); + } + catch (EngineGlobalUnavailableException) + { + return CheatEngineArchitecture.Unknown; + } + catch (EngineCapabilityUnavailableException) + { + return CheatEngineArchitecture.Unknown; + } + } + + private ProcessSnapshot ObserveSelection( + TargetProcessId id, + LocalProcessInfo process, + CheatEngineArchitecture architecture, + string operation) + { + lock (_selectionGate) + { + ProcessSelection selection = new(id, architecture); + if (_lastSelection is { } previous && previous != selection) + { + _selectionLifetime.Advance(operation); + } + + _lastSelection = selection; + return new ProcessSnapshot( + id, + process.Name, + process.ExecutablePath, + architecture, + _selectionLifetime.Epoch); + } + } + + private void ClearObservedSelection(string operation) + { + lock (_selectionGate) + { + if (_lastSelection is null) + { + return; + } + + _lastSelection = null; + _selectionLifetime.Advance(operation); + } + } + + private static string NormalizeExactProcessName(string processName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(processName); + if (processName.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0) + { + throw new ArgumentException("An exact process name must not include a directory path.", + nameof(processName)); + } + + string normalized = processName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? processName[..^4] + : processName; + if (normalized.Length == 0) + { + throw new ArgumentException("An exact process name must include a file name before '.exe'.", + nameof(processName)); + } + + return normalized; + } + + private readonly record struct ProcessSelection(TargetProcessId Id, CheatEngineArchitecture Architecture); +} diff --git a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs new file mode 100644 index 0000000..21c3889 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs @@ -0,0 +1,320 @@ +using System.Globalization; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// Captures only independently observed, synchronous runtime facts from the active Cheat Engine host. +internal sealed class RuntimeClient : ICheatEngineRuntime +{ + private readonly Version _clientAssemblyVersion; + private readonly ICheatEngineDispatcher _dispatcher; + private readonly Func _getEpoch; + private readonly CoreClientPolicy _policy; + private readonly IRuntimeProbe _probe; + private readonly Version _sdkAssemblyVersion; + + internal RuntimeClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime, CoreClientPolicy policy) + : this( + dispatcher, + new LuaRuntimeProbe(), + () => lifetime.Epoch, + typeof(ICheatEngineRuntime).Assembly.GetName().Version, + typeof(RuntimeInfo).Assembly.GetName().Version, + policy) + { + ArgumentNullException.ThrowIfNull(lifetime); + } + + internal RuntimeClient( + ICheatEngineDispatcher dispatcher, + IRuntimeProbe probe, + Func getEpoch, + Version? clientAssemblyVersion = null, + Version? sdkAssemblyVersion = null, + CoreClientPolicy? policy = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _probe = probe ?? throw new ArgumentNullException(nameof(probe)); + _getEpoch = getEpoch ?? throw new ArgumentNullException(nameof(getEpoch)); + _policy = policy ?? CoreClientPolicy.SafeDefaults; + _clientAssemblyVersion = clientAssemblyVersion ?? typeof(ICheatEngineRuntime).Assembly.GetName().Version ?? + throw new InvalidOperationException("The Client assembly does not declare an assembly version."); + _sdkAssemblyVersion = sdkAssemblyVersion ?? typeof(RuntimeInfo).Assembly.GetName().Version ?? + throw new InvalidOperationException("The SDK runtime assembly does not declare an assembly version."); + } + + public long Epoch => _getEpoch(); + + public bool TryGetSnapshot( + out CheatEngineRuntimeSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + CheatEngineRuntimeSnapshot captured = default; + if (!_dispatcher.TryInvoke(() => captured = Capture(), out failure, cancellationToken)) + { + snapshot = default; + return false; + } + + snapshot = captured; + return true; + } + + public CheatEngineRuntimeSnapshot GetSnapshot(CancellationToken cancellationToken = default) + { + if (TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryGetSdkCapability( + RuntimeCapabilityId capability, + out RuntimeCapabilityAvailability availability, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (capability.IsEmpty) + { + throw new ArgumentException("A runtime capability identifier is required.", nameof(capability)); + } + + if (!TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out failure, cancellationToken)) + { + availability = default; + return false; + } + + if (snapshot.SdkCapabilities.TryGet(capability, out availability)) + { + return true; + } + + availability = new RuntimeCapabilityAvailability( + capability, + RuntimeCapabilityAvailabilityState.Unknown, + RuntimeCapabilityContract.Unknown); + failure = default; + return true; + } + + public RuntimeCapabilityAvailability GetSdkCapability( + RuntimeCapabilityId capability, + CancellationToken cancellationToken = default) + { + if (TryGetSdkCapability(capability, out RuntimeCapabilityAvailability availability, + out CheatEngineFailure failure, cancellationToken)) + { + return availability; + } + + failure.Throw(); + return default; + } + + public bool TryGetClientCapability( + ClientCapabilityId capability, + out ClientCapabilityAvailability availability, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (capability.IsEmpty) + { + throw new ArgumentException("A Client capability identifier is required.", nameof(capability)); + } + + if (!TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out failure, cancellationToken)) + { + availability = default; + return false; + } + + if (snapshot.ClientCapabilities.TryGet(capability, out availability)) + { + return true; + } + + availability = new ClientCapabilityAvailability( + capability, + ClientCapabilityAvailabilityState.Unknown, + "This Client release does not define a probe or policy gate for the requested capability."); + failure = default; + return true; + } + + public ClientCapabilityAvailability GetClientCapability( + ClientCapabilityId capability, + CancellationToken cancellationToken = default) + { + if (TryGetClientCapability(capability, out ClientCapabilityAvailability availability, + out CheatEngineFailure failure, cancellationToken)) + { + return availability; + } + + failure.Throw(); + return default; + } + + private CheatEngineRuntimeSnapshot Capture() + { + ProbeResult version = Probe(RuntimeCapabilityId.CheatEngineVersion, _probe.GetCheatEngineVersion); + ProbeResult systemArchitecture = + Probe(RuntimeCapabilityId.SystemArchitecture, _probe.GetSystemArchitecture); + ProbeResult targetAbi = Probe(RuntimeCapabilityId.TargetAbi, _probe.GetTargetAbi); + + bool hasTarget = TryReadOpenedProcess(out long openedProcessId) && openedProcessId > 0; + ProbeResult targetArchitecture = hasTarget + ? Probe(RuntimeCapabilityId.TargetArchitecture, _probe.TargetIs64Bit) + : ProbeResult.Unknown(RuntimeCapabilityId.TargetArchitecture); + + double? observedVersion = version.State == RuntimeCapabilityAvailabilityState.Available + ? version.Value + : null; + if (observedVersion is { } reported && (!double.IsFinite(reported) || reported < 0)) + { + throw new EngineMarshallingException( + "Runtime.GetCheatEngineVersion", + EngineMarshallingDirection.Result, + "a finite non-negative number", + reported.ToString(CultureInfo.InvariantCulture)); + } + + CheatEngineArchitecture decodedSystemArchitecture = DecodeSystemArchitecture(systemArchitecture); + TargetAbi decodedTargetAbi = DecodeTargetAbi(targetAbi); + CheatEngineArchitecture decodedTargetArchitecture = + DecodeTargetArchitecture(targetArchitecture, decodedTargetAbi); + + RuntimeCapabilityAvailability[] capabilities = + [ + version.ToAvailability(), + systemArchitecture.ToAvailability(), + targetArchitecture.ToAvailability(), + targetAbi.ToAvailability() + ]; + + return new CheatEngineRuntimeSnapshot( + Epoch, + observedVersion, + CheatEngineVersion.Ce77010621, + _clientAssemblyVersion, + _sdkAssemblyVersion, + decodedSystemArchitecture, + decodedTargetArchitecture, + PointerSize.FromArchitecture(decodedTargetArchitecture), + decodedTargetAbi, + RuntimeCapabilities.Create(capabilities), + CreateClientCapabilities()); + } + + private ClientCapabilities CreateClientCapabilities() + { + ClientCapabilityAvailability[] capabilities = + [ + Unknown(ClientCapabilityId.ProcessSelection, + "The runtime snapshot does not probe every backing process-selection primitive, including openProcess."), + Unknown(ClientCapabilityId.TypedMemory, + "The runtime snapshot does not probe every backing typed-memory primitive."), + Unknown(ClientCapabilityId.PatternScanning, + "The runtime snapshot does not probe every backing AOB scan primitive."), + new( + ClientCapabilityId.ValueScanning, + ClientCapabilityAvailabilityState.Unavailable, + "Value scan sessions remain unavailable until the CE 7.7 live ownership and cleanup gate passes."), + Unknown(ClientCapabilityId.Inspection, + "The runtime snapshot does not probe every backing inspection primitive."), + Unknown(ClientCapabilityId.Tables, + "The runtime snapshot does not probe every backing Address List and table primitive."), + Unknown(ClientCapabilityId.ProtectedLua, + "The runtime snapshot does not probe every backing protected Lua primitive."), + _policy.EnableUnsafeLuaExecution + ? new ClientCapabilityAvailability( + ClientCapabilityId.UnsafeLuaExecution, + ClientCapabilityAvailabilityState.Available, + "Unsafe Lua execution was explicitly enabled for this activation.") + : new ClientCapabilityAvailability( + ClientCapabilityId.UnsafeLuaExecution, + ClientCapabilityAvailabilityState.Unavailable, + "Unsafe Lua execution requires explicit EnableUnsafeLuaExecution opt-in for this activation.") + ]; + + return ClientCapabilities.Create(capabilities); + } + + private static ClientCapabilityAvailability Unknown(ClientCapabilityId capability, string reason) + { + return new ClientCapabilityAvailability(capability, ClientCapabilityAvailabilityState.Unknown, reason); + } + + private static CheatEngineArchitecture DecodeSystemArchitecture(ProbeResult probe) + { + return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int architecture } && + RuntimeInfo.TryDecodeSystemArchitecture(architecture, out CheatEngineArchitecture decoded) + ? decoded + : CheatEngineArchitecture.Unknown; + } + + private static TargetAbi DecodeTargetAbi(ProbeResult probe) + { + return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int abi } && + RuntimeInfo.TryDecodeTargetAbi(abi, out TargetAbi decoded) + ? decoded + : TargetAbi.Unknown; + } + + private static CheatEngineArchitecture DecodeTargetArchitecture(ProbeResult probe, TargetAbi targetAbi) + { + if (targetAbi != TargetAbi.Windows || + probe is not { State: RuntimeCapabilityAvailabilityState.Available, Value: bool is64Bit }) + { + return CheatEngineArchitecture.Unknown; + } + + return is64Bit ? CheatEngineArchitecture.X64 : CheatEngineArchitecture.X86; + } + + private bool TryReadOpenedProcess(out long processId) + { + try + { + processId = _probe.GetOpenedProcessId(); + return true; + } + catch (EngineGlobalUnavailableException) + { + processId = 0; + return false; + } + catch (EngineCapabilityUnavailableException) + { + processId = 0; + return false; + } + } + + private static ProbeResult Probe(RuntimeCapabilityId capability, Func probe) + { + try + { + return ProbeResult.Available(capability, probe()); + } + catch (EngineGlobalUnavailableException) + { + return ProbeResult.Unavailable(capability); + } + catch (EngineCapabilityUnavailableException) + { + return ProbeResult.Unavailable(capability); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs new file mode 100644 index 0000000..028232f --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs @@ -0,0 +1,127 @@ +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// Protected SDK implementation of record destruction and parent reassignment. +internal sealed class SdkTableRecordMutationPort : ITableRecordMutationPort +{ + public TableRecordMutationStatus TryDelete(MemoryRecordId id) + { + if (!AddressListAccess.TryGetCurrent(out AddressList list)) + { + return TableRecordMutationStatus.HostRejected; + } + + if (!list.TryGetMemoryRecordById(id, out MemoryRecord record)) + { + return TableRecordMutationStatus.RecordNotFound; + } + + return record.Handle.TryCallMethod("destroy"u8) + ? TableRecordMutationStatus.Success + : TableRecordMutationStatus.HostRejected; + } + + public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + record = default; + if (parentId is { } requestedParentId && requestedParentId == childId) + { + return TableRecordMutationStatus.InvalidRelationship; + } + + TableRecordMutationStatus status = TryResolveMutationContext(childId, parentId, out AddressList list, + out MemoryRecord child, out MemoryRecord parent); + if (status != TableRecordMutationStatus.Success) + { + return status; + } + + if (parentId is { } candidateParentId) + { + status = ValidateParentRelationship(list, childId, candidateParentId, parent); + if (status != TableRecordMutationStatus.Success) + { + return status; + } + } + + return TryAssignParent(child, parent, out record); + } + + private static TableRecordMutationStatus TryResolveMutationContext(MemoryRecordId childId, MemoryRecordId? parentId, + out AddressList list, out MemoryRecord child, out MemoryRecord parent) + { + list = default; + child = default; + parent = MemoryRecord.Null; + if (!AddressListAccess.TryGetCurrent(out list)) + { + return TableRecordMutationStatus.HostRejected; + } + + if (!list.TryGetMemoryRecordById(childId, out child)) + { + return TableRecordMutationStatus.RecordNotFound; + } + + if (parentId is { } parentIdValue && !list.TryGetMemoryRecordById(parentIdValue, out parent)) + { + return TableRecordMutationStatus.ParentNotFound; + } + + return TableRecordMutationStatus.Success; + } + + private static TableRecordMutationStatus ValidateParentRelationship(AddressList list, MemoryRecordId childId, + MemoryRecordId candidateParentId, MemoryRecord parent) + { + if (!list.TryGetCount(out int topLevelCount)) + { + return TableRecordMutationStatus.HostRejected; + } + + MemoryRecord current = parent; + return TableParentRelationshipGuard.Validate(childId, candidateParentId, GetMaximumParentHops(topLevelCount), + _ => GetNextParentChainStep(ref current)); + } + + private static ParentChainStep GetNextParentChainStep(ref MemoryRecord current) + { + if (!current.TryGetParent(out MemoryRecord next)) + { + return ParentChainStep.Root; + } + + current = next; + if (!current.TryGetId(out MemoryRecordId nextId)) + { + return ParentChainStep.HostRejected; + } + + return ParentChainStep.Parent(nextId); + } + + private static TableRecordMutationStatus TryAssignParent(MemoryRecord child, MemoryRecord parent, + out MemoryRecordSnapshot record) + { + record = default; + if (!child.Handle.TrySetProperty("Parent"u8, parent) || + !TableClient.TrySnapshot(child, out record)) + { + return TableRecordMutationStatus.HostRejected; + } + + return TableRecordMutationStatus.Success; + } + + private static int GetMaximumParentHops(int topLevelCount) + { + const int traversalSlack = 1024; + return topLevelCount > int.MaxValue - traversalSlack + ? int.MaxValue + : Math.Max(1, topLevelCount) + traversalSlack; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs b/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs new file mode 100644 index 0000000..918fff3 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs @@ -0,0 +1,71 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// Activation-owned release path for a custom symbol that Client registered in Cheat Engine. +internal sealed class SymbolRegistrationLease( + SymbolRegistration registration, + ICheatEngineDispatcher dispatcher, + Action untrack, + Action unregisterSymbol, + Action releaseName) : ISymbolRegistrationLease +{ + private readonly ICheatEngineDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly Lock _gate = new(); + private readonly Action _releaseName = releaseName ?? throw new ArgumentNullException(nameof(releaseName)); + + private readonly Action _unregisterSymbol = + unregisterSymbol ?? throw new ArgumentNullException(nameof(unregisterSymbol)); + + private readonly Action _untrack = + untrack ?? throw new ArgumentNullException(nameof(untrack)); + + private int _released; + + public string Name + { + get; + } = registration.Name; + + public Address Address + { + get; + } = registration.Address; + + public bool IsReleased => Volatile.Read(ref _released) != 0; + + public void Dispose() + { + lock (_gate) + { + if (Volatile.Read(ref _released) != 0) + { + return; + } + + // Keep the lease active and registered when normal dispatch admission is closed during disable. The hosting + // cleanup scope can then retry this exact disposal on CE's main thread before Lua detaches. + _dispatcher.Invoke(() => _unregisterSymbol(Name)); + + try + { + _untrack(this); + } + finally + { + try + { + _releaseName(Name); + } + finally + { + Volatile.Write(ref _released, 1); + } + } + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableClient.cs b/libs/CheatEngine.Client.Core/Domains/TableClient.cs new file mode 100644 index 0000000..399e1c9 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableClient.cs @@ -0,0 +1,821 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Marshalling; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class TableClient( + ICheatEngineDispatcher dispatcher, + CoreClientPolicy policy, + ITableRecordMutationPort? recordMutations = null) : ITableClient +{ + private const string GetHierarchyOperation = "Tables.GetHierarchy"; + + private readonly ICheatEngineDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly CoreClientPolicy _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + private readonly ITableRecordMutationPort _recordMutations = recordMutations ?? new SdkTableRecordMutationPort(); + + public bool TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + AddressTableSnapshot captured = default; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + succeeded = AddressListAccess.TryGetCurrent(out AddressList list) && list.TryGetCount(out int count) && + CaptureTable(count, out captured), out failure, cancellationToken)) + { + table = default; + return false; + } + + table = captured; + if (succeeded) + { + return true; + } + + failure = HostFailure("Tables.GetCurrent"); + return false; + } + + public AddressTableSnapshot GetCurrent(CancellationToken cancellationToken = default) + { + if (TryGetCurrent(out AddressTableSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryGetSnapshot(MemoryRecordCollectionRequest request, out AddressTableSnapshot table, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + AddressTableSnapshot captured = default; + bool succeeded = false; + bool exceededLimit = false; + if (!_dispatcher.TryInvoke( + () => succeeded = TryCaptureSnapshot(request, out captured, out exceededLimit), + out failure, cancellationToken)) + { + table = default; + return false; + } + + table = captured; + if (succeeded) + { + return true; + } + + failure = exceededLimit + ? ResultLimitFailure("Tables.GetSnapshot", request.MaximumItems) + : HostFailure("Tables.GetSnapshot"); + return false; + } + + public AddressTableSnapshot GetSnapshot(MemoryRecordCollectionRequest request, + CancellationToken cancellationToken = default) + { + if (TryGetSnapshot(request, out AddressTableSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryFind(MemoryRecordSearch search, MemoryRecordCollectionRequest request, + out ImmutableArray records, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!HasPredicate(search)) + { + records = []; + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Tables.Find", + "A memory-record search must specify at least one predicate."); + return false; + } + + if (!TryGetSnapshot(request, out AddressTableSnapshot snapshot, out failure, cancellationToken)) + { + records = []; + return false; + } + + if (cancellationToken.IsCancellationRequested) + { + records = []; + failure = CoreFailureFactory.Cancelled("Tables.Find"); + return false; + } + + records = snapshot.Records.Where(record => Matches(search, record)).ToImmutableArray(); + failure = default; + return true; + } + + public ImmutableArray Find(MemoryRecordSearch search, + MemoryRecordCollectionRequest request, CancellationToken cancellationToken = default) + { + if (TryFind(search, request, out ImmutableArray result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return []; + } + + public bool TryGetRecord(int index, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + return TryRecord("Tables.GetRecord", + list => list.TryGetMemoryRecord(index, out MemoryRecord value) ? value : null, + out record, out failure, cancellationToken); + } + + public bool TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return TryRecord("Tables.GetRecord", + list => list.TryGetMemoryRecordById(id, out MemoryRecord value) ? value : null, + out record, out failure, cancellationToken); + } + + public MemoryRecordSnapshot GetRecord(int index, CancellationToken cancellationToken = default) + { + if (TryGetRecord(index, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public MemoryRecordSnapshot GetRecord(MemoryRecordId id, CancellationToken cancellationToken = default) + { + if (TryGetRecord(id, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryGetSelected(out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryRecord("Tables.GetSelected", list => list.TryGetSelectedRecord(out MemoryRecord value) ? value : null, + out record, out failure, cancellationToken); + } + + public MemoryRecordSnapshot GetSelected(CancellationToken cancellationToken = default) + { + if (TryGetSelected(out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryMutateRecord("Tables.Select", id, + static (list, value) => list.TrySetSelectedRecord(value), out record, out failure, cancellationToken); + } + + public MemoryRecordSnapshot SelectRecord(MemoryRecordId id, CancellationToken cancellationToken = default) + { + if (TrySelect(id, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + MemoryRecordSnapshot captured = default; + bool succeeded = false; + TableRecordMutationStatus parentMutationStatus = TableRecordMutationStatus.Success; + if (!_dispatcher.TryInvoke( + () => succeeded = TryCreateRecord(definition, out captured, out parentMutationStatus), + out failure, cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (succeeded) + { + return true; + } + + failure = parentMutationStatus == TableRecordMutationStatus.Success + ? HostFailure("Tables.Create") + : MutationFailure("Tables.Create", parentMutationStatus); + return false; + } + + public MemoryRecordSnapshot Create(MemoryRecordDefinition definition, + CancellationToken cancellationToken = default) + { + if (TryCreate(definition, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryUpdate(MemoryRecordUpdate update, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + MemoryRecordSnapshot captured = default; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryUpdateRecord(update, out captured), out failure, + cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (succeeded) + { + return true; + } + + failure = HostFailure("Tables.Update"); + return false; + } + + public MemoryRecordSnapshot Update(MemoryRecordUpdate update, CancellationToken cancellationToken = default) + { + if (TryUpdate(update, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryDelete(MemoryRecordId id, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + TableRecordMutationStatus status = TableRecordMutationStatus.HostRejected; + if (!_dispatcher.TryInvoke(() => status = _recordMutations.TryDelete(id), out failure, cancellationToken)) + { + return false; + } + + if (status == TableRecordMutationStatus.Success) + { + failure = default; + return true; + } + + failure = MutationFailure("Tables.Delete", status); + return false; + } + + public void Delete(MemoryRecordId id, CancellationToken cancellationToken = default) + { + if (!TryDelete(id, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TrySetActive(MemoryRecordId id, bool isActive, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return TryMutateRecord("Tables.SetActive", id, + static (_, value, active) => value.Handle.TrySetProperty("Active"u8, active), + isActive, out record, out failure, cancellationToken); + } + + public MemoryRecordSnapshot SetActive(MemoryRecordId id, bool isActive, + CancellationToken cancellationToken = default) + { + if (TrySetActive(id, isActive, out MemoryRecordSnapshot result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + if (parentId is { } requestedParentId && requestedParentId == childId) + { + record = default; + failure = MutationFailure("Tables.SetParent", TableRecordMutationStatus.InvalidRelationship); + return false; + } + + MemoryRecordSnapshot captured = default; + TableRecordMutationStatus status = TableRecordMutationStatus.HostRejected; + if (!_dispatcher.TryInvoke(() => status = _recordMutations.TrySetParent(childId, parentId, out captured), + out failure, cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (status == TableRecordMutationStatus.Success) + { + failure = default; + return true; + } + + failure = MutationFailure("Tables.SetParent", status); + return false; + } + + public MemoryRecordSnapshot SetParent(MemoryRecordId childId, MemoryRecordId? parentId, + CancellationToken cancellationToken = default) + { + if (TrySetParent(childId, parentId, out MemoryRecordSnapshot result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryGetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, + out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + MemoryRecordHierarchySnapshot captured = default; + bool found = false; + bool succeeded = false; + HierarchyBuildProblem problem = HierarchyBuildProblem.None; + if (!_dispatcher.TryInvoke(() => + { + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(rootId, out MemoryRecord root)) + { + return; + } + + found = true; + HashSet visited = new(); + int materialized = 0; + succeeded = TryBuildHierarchy(root, request, 1, visited, ref materialized, out captured, out problem); + }, out failure, cancellationToken)) + { + hierarchy = default; + return false; + } + + hierarchy = captured; + if (succeeded) + { + return true; + } + + failure = GetHierarchyFailure(problem, found, request); + return false; + } + + public MemoryRecordHierarchySnapshot GetHierarchy(MemoryRecordId rootId, + MemoryRecordHierarchyRequest request, CancellationToken cancellationToken = default) + { + if (TryGetHierarchy(rootId, request, out MemoryRecordHierarchySnapshot result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryLoadTrustedTable(TableLoadRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!TryAuthorize(request.File.FullPath, "Tables.LoadTrustedTable", out failure)) + { + return false; + } + + return _dispatcher.TryInvoke(() => ClientLuaGlobals.LoadTable(request.File.FullPath, request.Merge), + out failure, cancellationToken); + } + + public void LoadTrustedTable(TableLoadRequest request, CancellationToken cancellationToken = default) + { + if (!TryLoadTrustedTable(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TrySaveTable(TableSaveRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!TryAuthorize(request.File.FullPath, "Tables.SaveTable", out failure)) + { + return false; + } + + return _dispatcher.TryInvoke(() => ClientLuaGlobals.SaveTable(request.File.FullPath), + out failure, cancellationToken); + } + + public void SaveTable(TableSaveRequest request, CancellationToken cancellationToken = default) + { + if (!TrySaveTable(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + private bool TryRecord(string operation, Func selector, + out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken) + { + MemoryRecordSnapshot captured = default; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + if (!AddressListAccess.TryGetCurrent(out AddressList list)) + { + return; + } + + MemoryRecord? value = selector(list); + succeeded = value.HasValue && TrySnapshot(value.Value, out captured); + }, out failure, cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (succeeded) + { + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, operation, + "The requested Cheat Engine memory record was not found or was malformed."); + return false; + } + + private static bool TryCaptureSnapshot(MemoryRecordCollectionRequest request, out AddressTableSnapshot table, + out bool exceededLimit) + { + table = default; + exceededLimit = false; + if (!AddressListAccess.TryGetCurrent(out AddressList list) || !list.TryGetCount(out int count)) + { + return false; + } + + if (count > request.MaximumItems) + { + exceededLimit = true; + return false; + } + + return TryCaptureRecords(list, count, out table); + } + + private static bool TryCaptureRecords(AddressList list, int count, out AddressTableSnapshot table) + { + ImmutableArray.Builder records = + ImmutableArray.CreateBuilder(count); + for (int index = 0; index < count; index++) + { + if (!list.TryGetMemoryRecord(index, out MemoryRecord value) || + !TrySnapshot(value, out MemoryRecordSnapshot snapshot)) + { + table = default; + return false; + } + + records.Add(snapshot); + } + + table = new AddressTableSnapshot(records.MoveToImmutable()); + return true; + } + + private bool TryCreateRecord(MemoryRecordDefinition definition, out MemoryRecordSnapshot record, + out TableRecordMutationStatus parentMutationStatus) + { + record = default; + parentMutationStatus = TableRecordMutationStatus.Success; + if (!AddressListAccess.TryGetCurrent(out AddressList list) || !list.TryCreateMemoryRecord(out MemoryRecord value)) + { + return false; + } + + bool succeeded = TryInitializeRecord(value, definition) && + TryCompleteRecordCreation(value, definition, out record, out parentMutationStatus); + if (!succeeded) + { + _ = value.Handle.TryCallMethod("destroy"u8); + } + + return succeeded; + } + + private static bool TryInitializeRecord(MemoryRecord value, MemoryRecordDefinition definition) + { + return value.TrySetDescription(definition.Description) && + value.TrySetAddressExpression(definition.AddressExpression) && + value.TrySetVariableType(definition.VariableType) && + value.TrySetValue(definition.Value); + } + + private bool TryCompleteRecordCreation(MemoryRecord value, MemoryRecordDefinition definition, + out MemoryRecordSnapshot record, out TableRecordMutationStatus parentMutationStatus) + { + record = default; + parentMutationStatus = TableRecordMutationStatus.Success; + if (definition.ParentId is not { } parentId) + { + return TrySnapshot(value, out record); + } + + if (!value.TryGetId(out MemoryRecordId createdId)) + { + parentMutationStatus = TableRecordMutationStatus.HostRejected; + return false; + } + + parentMutationStatus = _recordMutations.TrySetParent(createdId, parentId, out record); + return parentMutationStatus == TableRecordMutationStatus.Success; + } + + private static bool TryUpdateRecord(MemoryRecordUpdate update, out MemoryRecordSnapshot record) + { + record = default; + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(update.Id, out MemoryRecord value)) + { + return false; + } + + return TryApplyUpdate(value, update) && TrySnapshot(value, out record); + } + + private static bool TryApplyUpdate(MemoryRecord value, MemoryRecordUpdate update) + { + return (update.Description is null || value.TrySetDescription(update.Description)) && + (update.AddressExpression is null || value.TrySetAddressExpression(update.AddressExpression)) && + (!update.VariableType.HasValue || value.TrySetVariableType(update.VariableType.Value)) && + (update.Value is null || value.TrySetValue(update.Value)); + } + + private bool TryAuthorize(string path, string operation, out CheatEngineFailure failure) + { + if (_policy.AllowedTableRoots.Count == 0) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, operation, + "Table import and export are disabled because no allowed root is configured."); + return false; + } + + if (_policy.TryAuthorizeTableFile(path, operation == "Tables.LoadTrustedTable", out string reason)) + { + failure = default; + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + reason); + return false; + } + + internal static bool TrySnapshot(MemoryRecord value, out MemoryRecordSnapshot snapshot) + { + if (!value.TryGetId(out MemoryRecordId id) || !value.TryGetIndex(out int index) || + !value.TryGetDescription(out string? description) || + !value.TryGetAddressExpression(out string? expression) || + !value.TryGetValue(out string? text) || !value.TryGetVariableType(out VariableType variableType) || + !value.Handle.TryGetProperty("Active"u8, out bool isActive) || + !value.Handle.TryGetProperty("Count"u8, out int childCount) || childCount < 0) + { + snapshot = default; + return false; + } + + Address? currentAddress = value.TryGetCurrentAddress(out Address address) ? address : null; + snapshot = new MemoryRecordSnapshot(id, index, description, expression, text, variableType, currentAddress, + isActive, childCount); + return true; + } + + private bool TryMutateRecord(string operation, MemoryRecordId id, + Func mutation, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(mutation); + MemoryRecordSnapshot captured = default; + bool found = false; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(id, out MemoryRecord value)) + { + return; + } + + found = true; + succeeded = mutation(list, value) && TrySnapshot(value, out captured); + }, out failure, cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (succeeded) + { + return true; + } + + failure = found + ? HostFailure(operation) + : new CheatEngineFailure(CheatEngineFailureKind.NotFound, operation, + "The requested Cheat Engine memory record was not found."); + return false; + } + + private bool TryMutateRecord(string operation, MemoryRecordId id, + Func mutation, TArgument argument, + out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(mutation); + return TryMutateRecord(operation, id, (list, value) => mutation(list, value, argument), out record, + out failure, cancellationToken); + } + + private static bool TryBuildHierarchy(MemoryRecord value, MemoryRecordHierarchyRequest request, int depth, + HashSet visited, ref int materialized, out MemoryRecordHierarchySnapshot hierarchy, + out HierarchyBuildProblem problem) + { + hierarchy = default; + if (materialized >= request.MaximumItems) + { + problem = HierarchyBuildProblem.ItemLimit; + return false; + } + + if (!TrySnapshot(value, out MemoryRecordSnapshot snapshot) || !visited.Add(snapshot.Id)) + { + problem = HierarchyBuildProblem.InvalidShape; + return false; + } + + materialized++; + if (snapshot.ChildCount == 0) + { + hierarchy = new MemoryRecordHierarchySnapshot(snapshot, []); + problem = HierarchyBuildProblem.None; + return true; + } + + if (depth >= request.MaximumDepth) + { + problem = HierarchyBuildProblem.DepthLimit; + return false; + } + + if (snapshot.ChildCount > request.MaximumItems - materialized) + { + problem = HierarchyBuildProblem.ItemLimit; + return false; + } + + ImmutableArray.Builder children = + ImmutableArray.CreateBuilder(snapshot.ChildCount); + for (int index = 0; index < snapshot.ChildCount; index++) + { + problem = HierarchyBuildProblem.InvalidShape; + if (!value.TryGetChild(index, out MemoryRecord child) || + !TryBuildHierarchy(child, request, depth + 1, visited, ref materialized, + out MemoryRecordHierarchySnapshot childSnapshot, + out problem)) + { + return false; + } + + children.Add(childSnapshot); + } + + hierarchy = new MemoryRecordHierarchySnapshot(snapshot, children.MoveToImmutable()); + problem = HierarchyBuildProblem.None; + return true; + } + + private static bool Matches(MemoryRecordSearch search, MemoryRecordSnapshot record) + { + return (search.DescriptionContains is null || record.Description.Contains(search.DescriptionContains, + StringComparison.OrdinalIgnoreCase)) && + (search.AddressExpression is null || string.Equals(record.AddressExpression, search.AddressExpression, + StringComparison.OrdinalIgnoreCase)) && + (!search.VariableType.HasValue || record.VariableType == search.VariableType.Value) && + (!search.IsActive.HasValue || record.IsActive == search.IsActive.Value); + } + + private static bool HasPredicate(MemoryRecordSearch search) + { + return search.DescriptionContains is not null || search.AddressExpression is not null || + search.VariableType.HasValue || search.IsActive.HasValue; + } + + private static bool CaptureTable(int count, out AddressTableSnapshot table) + { + table = new AddressTableSnapshot(count); + return true; + } + + private static CheatEngineFailure HostFailure(string operation) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, operation, + "Cheat Engine did not return the expected Address List contract."); + } + + private static CheatEngineFailure ResultLimitFailure(string operation, int maximumItems) + { + return new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, operation, + $"The operation requires more records than the explicit limit of {maximumItems}."); + } + + private static CheatEngineFailure GetHierarchyFailure(HierarchyBuildProblem problem, bool found, + MemoryRecordHierarchyRequest request) + { + return problem switch + { + HierarchyBuildProblem.ItemLimit => ResultLimitFailure(GetHierarchyOperation, request.MaximumItems), + HierarchyBuildProblem.DepthLimit => new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, + GetHierarchyOperation, + $"The operation requires a hierarchy depth greater than the explicit limit of {request.MaximumDepth}."), + HierarchyBuildProblem.InvalidShape => HostFailure(GetHierarchyOperation), + _ when !found => new CheatEngineFailure(CheatEngineFailureKind.NotFound, GetHierarchyOperation, + "The requested Cheat Engine memory record was not found."), + _ => HostFailure(GetHierarchyOperation) + }; + } + + private static CheatEngineFailure MutationFailure(string operation, TableRecordMutationStatus status) + { + return status switch + { + TableRecordMutationStatus.RecordNotFound => new CheatEngineFailure(CheatEngineFailureKind.NotFound, + operation, "The requested Cheat Engine memory record was not found."), + TableRecordMutationStatus.ParentNotFound => new CheatEngineFailure(CheatEngineFailureKind.NotFound, + operation, "The requested parent Cheat Engine memory record was not found."), + TableRecordMutationStatus.InvalidRelationship => new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, operation, + "A memory record cannot be its own parent."), + _ => HostFailure(operation) + }; + } + + private enum HierarchyBuildProblem + { + None, + ItemLimit, + DepthLimit, + InvalidShape + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs b/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs new file mode 100644 index 0000000..29af200 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs @@ -0,0 +1,40 @@ +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// Bounded, handle-free validation of a parent chain before Cheat Engine is asked to mutate it. +internal static class TableParentRelationshipGuard +{ + internal static TableRecordMutationStatus Validate(MemoryRecordId childId, MemoryRecordId candidateParentId, + int maximumHops, Func getNext) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumHops); + ArgumentNullException.ThrowIfNull(getNext); + + HashSet visited = new(); + MemoryRecordId current = candidateParentId; + for (int hop = 0; hop < maximumHops; hop++) + { + if (current == childId || !visited.Add(current)) + { + return TableRecordMutationStatus.InvalidRelationship; + } + + ParentChainStep step = getNext(current); + switch (step.Kind) + { + case ParentChainStepKind.Root: + return TableRecordMutationStatus.Success; + case ParentChainStepKind.Parent: + current = step.ParentId; + break; + case ParentChainStepKind.HostRejected: + return TableRecordMutationStatus.HostRejected; + default: + return TableRecordMutationStatus.HostRejected; + } + } + + return TableRecordMutationStatus.InvalidRelationship; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs b/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs new file mode 100644 index 0000000..e0b4b5e --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Core.Domains; + +/// Outcome of one protected address-list record mutation. +internal enum TableRecordMutationStatus +{ + Success, + RecordNotFound, + ParentNotFound, + InvalidRelationship, + HostRejected +} diff --git a/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs new file mode 100644 index 0000000..ac333fb --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; + +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Keeps the public capability honest until the owned MemScan/FoundList lifecycle passes the required live CE 7.7 +/// gate. +/// +internal sealed class UnavailableValueScanner : IValueScanner +{ + // This is a deliberate product gate, not a transient host-capability probe. SDK 1.0.0 exposes the state machine + // only through MemoryScanSession.Adopt(Owned, Owned), while Owned has an internal + // constructor and the Lua-global generator cannot marshal CEObject results. Bypassing that with reflection or a + // hand-rolled destroy owner would make an unverified CE ownership assumption part of the Client contract. + private const string OwnershipGateMessage = + "Value scans are disabled: CheatEngine.SDK 1.0.0 has no public ownership factory for createMemScan or " + + "createFoundList, and its generated Lua globals cannot return CEObject handles. Enablement requires the " + + "Cheat Engine 7.7 ownership and reactivation live gate."; + + public bool TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + session = null; + failure = cancellationToken.IsCancellationRequested + ? new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Scans.CreateSession", + "The operation was cancelled before Cheat Engine work began.") + : new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, "Scans.CreateSession", + OwnershipGateMessage); + return false; + } + + public IValueScanSession CreateSession(CancellationToken cancellationToken = default) + { + _ = TryCreateSession(out _, out CheatEngineFailure failure, cancellationToken); + return ThrowFailure(failure); + } + + private static T ThrowFailure(CheatEngineFailure failure) + { + failure.Throw(); + throw new UnreachableException(); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs b/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs new file mode 100644 index 0000000..c75ecbe --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs @@ -0,0 +1,90 @@ +using System.Text; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class UnsafeLuaClient : IUnsafeLuaClient +{ + private readonly ICheatEngineDispatcher _dispatcher; + private readonly CoreClientPolicy _policy; + + internal UnsafeLuaClient(SdkMainThreadDispatcher dispatcher, CoreClientPolicy policy) + : this((ICheatEngineDispatcher) (dispatcher ?? throw new ArgumentNullException(nameof(dispatcher))), policy) + { + } + + /// Deterministic internal seam for policy tests; production construction uses the SDK dispatcher overload. + internal UnsafeLuaClient(ICheatEngineDispatcher dispatcher, CoreClientPolicy policy) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + } + + public bool TryExecute(LuaScript script, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(script.Source); + if (script.ChunkName is { Length: 0 }) + { + throw new ArgumentException("A Lua chunk name must be null or non-empty.", nameof(script)); + } + + if (!_policy.EnableUnsafeLuaExecution) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, "Lua.ExecuteUnsafe", + "Arbitrary Lua execution was not enabled for this activation."); + return false; + } + + string luaStatus = "unknown"; + string? luaMessage = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + byte[] source = Encoding.UTF8.GetBytes(script.Source); + ReadOnlySpan name = script.ChunkName is null + ? ReadOnlySpan.Empty + : Encoding.UTF8.GetBytes(script.ChunkName); + LuaStatus status = state.TryExecute(source, 0, name); + succeeded = status.IsOk; + luaStatus = status.ToString(); + if (!succeeded) + { + luaMessage = LuaError.FromStack(state, status).Message; + } + }, out failure, cancellationToken)) + { + return false; + } + + if (succeeded) + { + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.LuaError, "Lua.ExecuteUnsafe", + luaMessage is { Length: > 0 } + ? $"The protected Lua call failed with status '{luaStatus}': {luaMessage}" + : $"The protected Lua call failed with status '{luaStatus}'."); + return false; + } + + public void Execute(LuaScript script, CancellationToken cancellationToken = default) + { + if (!TryExecute(script, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs new file mode 100644 index 0000000..c52e62b --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs @@ -0,0 +1,85 @@ +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Keeps the client-visible value-scan lifecycle conservative and independent of SDK object handles. +/// +/// +/// This state machine is deliberately usable without a live Cheat Engine host. The eventual CE-backed session must +/// call +/// or immediately before work begins, then either +/// after wait-and-initialize succeeds or when a protected Lua +/// call +/// leaves the safe continuation state unknown. +/// +internal sealed class ValueScanSessionStateMachine +{ + public ValueScanSessionState State + { + get; + private set; + } = ValueScanSessionState.Created; + + internal void BeginFirstScan() + { + Require(ValueScanSessionState.Created, "a first scan"); + State = ValueScanSessionState.Scanning; + } + + internal void BeginNextScan() + { + Require(ValueScanSessionState.ResultsReady, "a subsequent scan"); + State = ValueScanSessionState.Scanning; + } + + internal void CompleteScan() + { + Require(ValueScanSessionState.Scanning, "scan completion"); + State = ValueScanSessionState.ResultsReady; + } + + internal void Invalidate() + { + if (State != ValueScanSessionState.Disposed) + { + State = ValueScanSessionState.Invalidated; + } + } + + internal void Reset() + { + if (State == ValueScanSessionState.Created) + { + return; + } + + if (State is ValueScanSessionState.Scanning or ValueScanSessionState.Disposed) + { + ThrowInvalidTransition("a reset"); + } + + State = ValueScanSessionState.Created; + } + + internal void MarkDisposed() + { + State = ValueScanSessionState.Disposed; + } + + private void Require(ValueScanSessionState expected, string operation) + { + if (State == expected) + { + return; + } + + ThrowInvalidTransition(operation); + } + + private void ThrowInvalidTransition(string operation) + { + throw new InvalidOperationException( + $"The value-scan session is {State} and cannot begin {operation}."); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs b/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs new file mode 100644 index 0000000..4839b35 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Annotations.Lua; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Internal generated bindings for CE globals that the SDK does not expose as high-level services. +internal static partial class ClientLuaGlobals +{ + [LuaGlobal("getOpenedProcessID")] + internal static partial long GetOpenedProcessId(); + + [LuaGlobal("openProcess")] + internal static partial void OpenProcess(long processId); + + [LuaGlobal("getCEVersion")] + internal static partial double GetCheatEngineVersion(); + + [LuaGlobal("getSystemArchitecture")] + internal static partial int GetSystemArchitecture(); + + [LuaGlobal("getABI")] + internal static partial int GetTargetAbi(); + + [LuaGlobal("targetIs64Bit")] + internal static partial bool TargetIs64Bit(); + + [LuaGlobal("loadTable")] + internal static partial void LoadTable(string path, bool merge); + + [LuaGlobal("saveTable")] + internal static partial void SaveTable(string path); + + [LuaGlobal("getNameFromAddress")] + internal static partial bool TryGetNameFromAddress(nuint address, [MaybeNullWhen(false)] out string? name); + + [LuaGlobal("registerSymbol")] + internal static partial void RegisterSymbol(string name, nuint address, bool doNotSave); + + [LuaGlobal("unregisterSymbol")] + internal static partial void UnregisterSymbol(string name); +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs new file mode 100644 index 0000000..4a57e0c --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs @@ -0,0 +1,210 @@ +namespace CheatEngine.Client.Core.Infrastructure; + +/// Immutable activation policy supplied by the DI integration. +internal sealed class CoreClientPolicy +{ + internal CoreClientPolicy(IEnumerable allowedTableRoots, bool enableUnsafeLuaExecution) + { + ArgumentNullException.ThrowIfNull(allowedTableRoots); + List roots = new(); + foreach (string root in allowedTableRoots) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + roots.Add(NormalizeDirectory(root)); + } + + AllowedTableRoots = roots.ToArray(); + EnableUnsafeLuaExecution = enableUnsafeLuaExecution; + } + + internal static CoreClientPolicy SafeDefaults + { + get; + } = new([], false); + + internal IReadOnlyList AllowedTableRoots + { + get; + } + + internal bool EnableUnsafeLuaExecution + { + get; + } + + /// + /// Validates a table path against the activation's explicit roots without following any observed link or junction. + /// + /// + /// Cheat Engine's native table functions accept only a path, not a managed file handle. A full handle-based + /// TOCTOU-proof authorization is therefore impossible at this boundary. This policy deliberately rejects every + /// path with an observed reparse point instead of resolving and trusting a link target; callers must keep the + /// approved root tree non-reparse-pointed until the native load or save completes. + /// + internal bool TryAuthorizeTableFile(string path, bool forLoad, out string reason) + { + if (string.IsNullOrWhiteSpace(path)) + { + reason = "The table path must be non-empty and fully qualified."; + return false; + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(path); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + reason = "The table path cannot be normalized safely."; + return false; + } + + foreach (string root in AllowedTableRoots) + { + if (!IsContainedBy(root, fullPath)) + { + continue; + } + + if (!TryVerifySafePath(root, fullPath, forLoad, out reason)) + { + return false; + } + + reason = string.Empty; + return true; + } + + reason = "The normalized table path is outside every configured allowed root."; + return false; + } + + private static string NormalizeDirectory(string path) + { + string fullPath = Path.GetFullPath(path); + return Path.EndsInDirectorySeparator(fullPath) ? fullPath : fullPath + Path.DirectorySeparatorChar; + } + + private static bool IsContainedBy(string normalizedRoot, string fullPath) + { + string root = Path.TrimEndingDirectorySeparator(normalizedRoot); + string relative = Path.GetRelativePath(root, fullPath); + return !Path.IsPathFullyQualified(relative) && + !string.Equals(relative, "..", StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal); + } + + private static bool TryVerifySafePath(string normalizedRoot, string fullPath, bool forLoad, out string reason) + { + string root = Path.TrimEndingDirectorySeparator(normalizedRoot); + if (!Directory.Exists(root)) + { + reason = "The configured table root does not currently exist and cannot be verified."; + return false; + } + + if (!TryVerifyDirectoryChain(root, out reason)) + { + return false; + } + + if (forLoad) + { + if (!File.Exists(fullPath)) + { + reason = "A trusted table import source must be an existing regular file."; + return false; + } + + if (!TryVerifyDirectoryChain(Path.GetDirectoryName(fullPath)!, out reason)) + { + return false; + } + + return TryVerifyRegularFile(fullPath, out reason); + } + + string? parent = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(parent) || !Directory.Exists(parent)) + { + reason = "A trusted table export destination must have an existing parent directory."; + return false; + } + + if (!TryVerifyDirectoryChain(parent, out reason)) + { + return false; + } + + return !File.Exists(fullPath) || TryVerifyRegularFile(fullPath, out reason); + } + + private static bool TryVerifyDirectoryChain(string directoryPath, out string reason) + { + string fullPath = Path.GetFullPath(directoryPath); + string? root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + { + reason = "The table path does not have a verifiable filesystem root."; + return false; + } + + string current = Path.TrimEndingDirectorySeparator(root); + if (current.Length == 2 && current[1] == ':') + { + current += Path.DirectorySeparatorChar; + } + + if (!TryVerifyNotReparsePoint(new DirectoryInfo(current), out reason)) + { + return false; + } + + string relative = Path.GetRelativePath(current, fullPath); + if (relative == ".") + { + return true; + } + + foreach (string segment in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if (!TryVerifyNotReparsePoint(new DirectoryInfo(current), out reason)) + { + return false; + } + } + + return true; + } + + private static bool TryVerifyRegularFile(string fullPath, out string reason) + { + return TryVerifyNotReparsePoint(new FileInfo(fullPath), out reason); + } + + private static bool TryVerifyNotReparsePoint(FileSystemInfo item, out string reason) + { + try + { + item.Refresh(); + if ((item.Attributes & FileAttributes.ReparsePoint) != 0 || item.LinkTarget is not null) + { + reason = "The trusted table path contains a symbolic link, junction, or another reparse point."; + return false; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or NotSupportedException) + { + reason = "The trusted table path cannot be verified without following a filesystem link."; + return false; + } + + reason = string.Empty; + return true; + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs new file mode 100644 index 0000000..29612fb --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs @@ -0,0 +1,50 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Maps implementation exceptions to the stable public client result vocabulary. +internal static class CoreFailureFactory +{ + internal static CheatEngineFailure Cancelled(string operation) + { + return new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + operation, + "The operation was cancelled before Cheat Engine work began."); + } + + internal static CheatEngineFailure FromException(string operation, Exception exception) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + ArgumentNullException.ThrowIfNull(exception); + + return new CheatEngineFailure(GetKind(exception), operation, exception.Message, exception); + } + + internal static CheatEngineFailure Lifecycle(string operation, string message, Exception? exception = null) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, message, exception); + } + + private static CheatEngineFailureKind GetKind(Exception exception) + { + return exception switch + { + CheatEngineActivationExpiredException => CheatEngineFailureKind.ActivationExpired, + CheatEngineClientLifecycleException => CheatEngineFailureKind.InvalidState, + EngineCapabilityUnavailableException => CheatEngineFailureKind.CapabilityUnavailable, + EngineGlobalUnavailableException => CheatEngineFailureKind.CapabilityUnavailable, + EngineOperationFailedException => CheatEngineFailureKind.OperationRejected, + EngineLuaException => CheatEngineFailureKind.LuaError, + LuaException => CheatEngineFailureKind.LuaError, + EngineBindingException => CheatEngineFailureKind.BindingError, + EngineMarshallingException => CheatEngineFailureKind.InvalidHostResult, + ObjectDisposedException => CheatEngineFailureKind.InvalidState, + ArgumentException => CheatEngineFailureKind.OperationRejected, + InvalidOperationException => CheatEngineFailureKind.OperationRejected, + _ => CheatEngineFailureKind.Unknown + }; + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs new file mode 100644 index 0000000..2c0e02e --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs @@ -0,0 +1,209 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Context; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Captures one enabled plugin context and owns all client-created resources for its epoch. +internal sealed class CoreLifetime : IDisposable +{ + private readonly PluginContext _context; + private readonly CoreResourceRegistry _resources = new(); + private int _cleanupScopeDepth; + private int _disposed; + private int _resourcesDrained; + + private CoreLifetime(PluginContext context) + { + _context = context; + TargetSelection = new TargetSelectionLifetime(ThrowIfInactive); + } + + internal long Epoch => _context.Epoch; + + internal CancellationToken Stopping => _context.ShutdownToken; + + /// Gets the per-activation target-selection lifetime; this is deliberately independent from . + internal TargetSelectionLifetime TargetSelection { get; } + + /// Gets whether the captured SDK activation context itself is still current, regardless of admission closure. + internal bool IsActivationCurrent => Volatile.Read(ref _disposed) == 0 && _context.IsCurrent; + + internal bool IsCurrent => + IsActivationCurrent && !Stopping.IsCancellationRequested; + + /// + /// Gets whether synchronous dispatch is currently legal. During disable, only code running inside the explicit + /// main-thread cleanup scope may dispatch; no worker admission is reopened. + /// + internal bool CanDispatch => IsActivationCurrent && + (!Stopping.IsCancellationRequested || IsInCleanupScopeOnMainThread); + + private bool IsInCleanupScopeOnMainThread => Volatile.Read(ref _cleanupScopeDepth) != 0 && + IsActivationCurrent && + _context.IsMainThread; + + /// Releases client-owned resources while the hosting plugin still owns SDK detach sequencing. + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (Interlocked.Exchange(ref _resourcesDrained, 1) != 0) + { + return; + } + + DisposeOwnedResources(); + } + + /// + /// Opens the hosting-only cleanup path on Cheat Engine's main thread. It permits direct, synchronous cleanup + /// dispatch after SDK shutdown cancellation, but never permits a worker to enter the SDK work-admission gate. + /// + internal IDisposable EnterCleanupScope() + { + ThrowIfActivationCurrent("Client.EnterCleanupScope"); + if (!_context.IsMainThread) + { + throw new CheatEngineClientLifecycleException("Client.EnterCleanupScope", + "Cheat Engine cleanup must run on the plugin main thread."); + } + + checked + { + _cleanupScopeDepth++; + } + + return new CleanupScope(this); + } + + /// Drains client-owned CE resources while the SDK context remains attached and the cleanup scope is active. + internal void DrainOwnedResourcesForDisable() + { + if (!IsInCleanupScopeOnMainThread) + { + throw new CheatEngineClientLifecycleException("Client.DrainResources", + "Client-owned Cheat Engine resources can only be drained by the active main-thread cleanup scope."); + } + + if (Interlocked.Exchange(ref _resourcesDrained, 1) != 0) + { + return; + } + + DisposeOwnedResources(); + } + + private void DisposeOwnedResources() + { + Exception? firstFailure = null; + try + { + TargetSelection.Dispose(); + } + catch (Exception exception) + { + firstFailure = exception; + } + + try + { + _resources.Dispose(); + } + catch (Exception exception) + { + firstFailure ??= exception; + } + + if (firstFailure is not null) + { + throw firstFailure; + } + } + + internal static CoreLifetime Capture() + { + PluginContext? context = PluginHost.Context; + if (context is null || !context.IsCurrent) + { + throw new CheatEngineClientLifecycleException( + "Client.Activate", + "Cheat Engine has not enabled a plugin context for this client scope."); + } + + return new CoreLifetime(context); + } + + internal T Track(T resource) + where T : class, IDisposable + { + ThrowIfInactive("Client.TrackResource"); + return _resources.Track(resource); + } + + internal bool Untrack(IDisposable resource) + { + return _resources.Untrack(resource); + } + + internal void ThrowIfInactive(string operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + ThrowIfActivationCurrent(operation); + + if (Stopping.IsCancellationRequested) + { + throw new CheatEngineClientLifecycleException(operation, + "The Cheat Engine plugin lifecycle is stopping and no new client work is admitted."); + } + } + + /// Rejects ordinary dispatch once admission closes, except for the current main-thread cleanup scope. + internal void ThrowIfDispatchAllowed(string operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + ThrowIfActivationCurrent(operation); + + if (!Stopping.IsCancellationRequested || IsInCleanupScopeOnMainThread) + { + return; + } + + throw new CheatEngineClientLifecycleException(operation, + "The Cheat Engine plugin lifecycle is stopping and no new client work is admitted."); + } + + private void ThrowIfActivationCurrent(string operation) + { + if (!IsActivationCurrent) + { + throw new CheatEngineActivationExpiredException(operation, + "The Cheat Engine plugin lifecycle changed, so this client epoch is stale."); + } + } + + private void ExitCleanupScope() + { + if (Interlocked.Decrement(ref _cleanupScopeDepth) >= 0) + { + return; + } + + Interlocked.Exchange(ref _cleanupScopeDepth, 0); + throw new InvalidOperationException("The Cheat Engine cleanup scope was released more than once."); + } + + private sealed class CleanupScope(CoreLifetime lifetime) : IDisposable + { + private CoreLifetime? _lifetime = lifetime; + + public void Dispose() + { + CoreLifetime? owner = Interlocked.Exchange(ref _lifetime, null); + owner?.ExitCleanupScope(); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs new file mode 100644 index 0000000..f0a05e3 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs @@ -0,0 +1,177 @@ +namespace CheatEngine.Client.Core.Infrastructure; + +/// Owns client-created resources for one active plugin epoch and releases them in reverse creation order. +internal sealed class CoreResourceRegistry : IDisposable +{ + private readonly List _resources = []; + private bool _disposed; + + /// Releases every tracked resource even when an earlier cleanup fails. + public void Dispose() + { + DisposeDetached(DetachAll()); + } + + internal T Track(T resource) + where T : class, IDisposable + { + return TrackCore(resource, null); + } + + /// Tracks a resource that must be released when its target selection expires. + internal T Track(T resource, long targetSelectionEpoch) + where T : class, IDisposable + { + return TrackCore(resource, targetSelectionEpoch); + } + + /// Detaches and releases resources created for one target selection in reverse creation order. + internal void DisposeTargetSelection(long targetSelectionEpoch) + { + DisposeDetached(DetachTargetSelection(targetSelectionEpoch)); + } + + /// Detaches every registration and marks the registry closed. + internal IDisposable[] DetachAll() + { + lock (_resources) + { + if (_disposed) + { + return []; + } + + _disposed = true; + IDisposable[] resources = new IDisposable[_resources.Count]; + for (int index = 0; index < resources.Length; index++) + { + resources[index] = _resources[index].Resource; + } + + _resources.Clear(); + return resources; + } + } + + /// Detaches the resources registered to one selection without closing the registry. + internal IDisposable[] DetachTargetSelection(long targetSelectionEpoch) + { + lock (_resources) + { + if (_disposed) + { + return []; + } + + List? detached = null; + for (int index = 0; index < _resources.Count; index++) + { + if (_resources[index].TargetSelectionEpoch != targetSelectionEpoch) + { + continue; + } + + (detached ??= []).Add(_resources[index].Resource); + } + + if (detached is null) + { + return []; + } + + for (int index = _resources.Count - 1; index >= 0; index--) + { + if (_resources[index].TargetSelectionEpoch == targetSelectionEpoch) + { + _resources.RemoveAt(index); + } + } + + return [.. detached]; + } + } + + /// Disposes an insertion-ordered resource snapshot in reverse order, preserving the first fault. + internal static void DisposeDetached(IReadOnlyList resources) + { + Exception? firstFailure = null; + for (int index = resources.Count - 1; index >= 0; index--) + { + try + { + resources[index].Dispose(); + } + catch (Exception exception) + { + firstFailure ??= exception; + } + } + + if (firstFailure is not null) + { + throw firstFailure; + } + } + + private T TrackCore(T resource, long? targetSelectionEpoch) + where T : class, IDisposable + { + ArgumentNullException.ThrowIfNull(resource); + + lock (_resources) + { + ThrowIfDisposed(); + if (!ContainsReference(resource)) + { + _resources.Add(new ResourceEntry(resource, targetSelectionEpoch)); + } + } + + return resource; + } + + internal bool Untrack(IDisposable resource) + { + ArgumentNullException.ThrowIfNull(resource); + + lock (_resources) + { + for (int index = _resources.Count - 1; index >= 0; index--) + { + if (!ReferenceEquals(_resources[index].Resource, resource)) + { + continue; + } + + _resources.RemoveAt(index); + return true; + } + + return false; + } + } + + private bool ContainsReference(IDisposable resource) + { + for (int index = 0; index < _resources.Count; index++) + { + if (ReferenceEquals(_resources[index].Resource, resource)) + { + return true; + } + } + + return false; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(CoreResourceRegistry), + "Resources cannot be registered after the Cheat Engine client lifecycle has stopped."); + } + } + + private readonly record struct ResourceEntry(IDisposable Resource, long? TargetSelectionEpoch); +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs b/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs new file mode 100644 index 0000000..0ecb6a2 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs @@ -0,0 +1,128 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// +/// Owns the independently advancing target-selection epoch for one plugin activation. It deliberately does not +/// change the SDK activation epoch: selecting another process invalidates target-bound leases, not the client. +/// +internal sealed class TargetSelectionLifetime(Action activationGuard) : IDisposable +{ + private readonly Action _activationGuard = + activationGuard ?? throw new ArgumentNullException(nameof(activationGuard)); + + private readonly Lock _gate = new(); + private readonly CoreResourceRegistry _resources = new(); + private bool _disposed; + private long _epoch; + + /// Gets the current target-selection epoch, starting at zero for each activation. + internal long Epoch => Volatile.Read(ref _epoch); + + /// Releases every target-bound resource that remains at activation shutdown. + public void Dispose() + { + IDisposable[] resources; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + resources = _resources.DetachAll(); + } + + CoreResourceRegistry.DisposeDetached(resources); + } + + /// + /// Advances the target selection after a PID or architecture change and releases the old selection's + /// client-owned leases in reverse creation order. + /// + internal long Advance(string operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + + IDisposable[] staleResources; + long nextEpoch; + lock (_gate) + { + _activationGuard(operation); + ThrowIfDisposed(); + long staleEpoch = _epoch; + nextEpoch = checked(staleEpoch + 1); + Volatile.Write(ref _epoch, nextEpoch); + staleResources = _resources.DetachTargetSelection(staleEpoch); + } + + CoreResourceRegistry.DisposeDetached(staleResources); + return nextEpoch; + } + + /// Throws when a target-bound lease belongs to an earlier target selection. + internal void ThrowIfExpired(long capturedEpoch, string operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + + lock (_gate) + { + _activationGuard(operation); + ThrowIfDisposed(); + if (capturedEpoch == _epoch) + { + return; + } + + throw new CheatEngineClientLifecycleException(operation, + "The target process selection changed, so this resource is no longer valid."); + } + } + + /// Registers a disposable resource against the current target selection. + internal T Track(T resource, long capturedEpoch) + where T : class, IDisposable + { + ArgumentNullException.ThrowIfNull(resource); + + lock (_gate) + { + ThrowIfExpiredCore(capturedEpoch, "TargetSelection.Track"); + return _resources.Track(resource, capturedEpoch); + } + } + + /// Removes a resource after its owner released it explicitly. + internal bool Untrack(IDisposable resource) + { + ArgumentNullException.ThrowIfNull(resource); + + lock (_gate) + { + return _resources.Untrack(resource); + } + } + + private void ThrowIfExpiredCore(long capturedEpoch, string operation) + { + _activationGuard(operation); + ThrowIfDisposed(); + if (capturedEpoch == _epoch) + { + return; + } + + throw new CheatEngineClientLifecycleException(operation, + "The target process selection changed, so this resource is no longer valid."); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(TargetSelectionLifetime), + "Target-bound resources cannot be registered after the Cheat Engine client lifecycle has stopped."); + } + } +} diff --git a/libs/CheatEngine.Client.Core/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Core/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/libs/CheatEngine.Client.Core/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.Client.Core/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Core/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/libs/CheatEngine.Client.Core/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.Client.Core/README.md b/libs/CheatEngine.Client.Core/README.md new file mode 100644 index 0000000..412326f --- /dev/null +++ b/libs/CheatEngine.Client.Core/README.md @@ -0,0 +1,97 @@ +# CheatEngine.Client.Core + +## Context + +`CheatEngine.Client.Core` is the SDK-facing implementation layer of `CheatEngine.Client`. It maps +the public contracts from `CheatEngine.Client.Abstractions` onto `CheatEngine.SDK` 1.x while a +Cheat Engine plugin activation is current. + +This package contains the main-thread dispatcher adapter, runtime probes, target-selection +tracking, typed memory and AOB adapters, copied inspection/table operations, protected Lua +operations, and lifecycle-owned cleanup infrastructure. It is an in-process layer for local, +authorized targets; it is not an IPC client or a standalone Cheat Engine host. + +## Why This Project Exists + +The Client must use the SDK directly for its real runtime work, but its SDK-specific ownership and +Lua details must not become application-level implementation concerns. Core provides that boundary: +it translates stable contracts to SDK calls, normalizes expected failures, and owns the rules that +make an enable/disable/re-enable cycle safe. + +Core references `CheatEngine.Client.Abstractions` and the full SDK package. It never references +`CheatEngine.Client.Fluent`. The Dependency Injection and Hosting packages compose Core into the +activation-scoped `ICheatEngineClient`; applications should obtain that facade through hosting or +DI instead of constructing Core services. + +```text +Abstractions ← Core ← DependencyInjection ← Hosting + ↑ + CheatEngine.SDK + +Fluent ← Abstractions +``` + +## How It Improves CheatEngine.Client + +- Sends every Cheat Engine operation through the synchronous SDK main-thread dispatcher. +- Captures an SDK activation epoch and rejects stale work instead of letting a resource survive a + disable/re-enable boundary. +- Closes ordinary work admission during disable, then drains Client-owned resources on the CE main + thread in reverse creation order while the SDK context is still valid. +- Separates the plugin epoch from the selected-target epoch so target changes invalidate only + target-bound resources. +- Copies SDK-owned scan/list data into managed values before releasing the owner; the public API + never leaks SDK Lua state, CE objects, or `Owned` wrappers. +- Maps predictable host failures to `CheatEngineFailure` and preserves ordinary .NET exceptions + for programming errors. + +## Ownership and Delivery Boundary + +Core is a delivery package, not a second public facade. Its concrete service implementations and +infrastructure are internal; the supported public contracts remain in functional namespaces from +the Abstractions package such as `CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, and +`.Runtime`. Do not add consumer namespaces such as `CheatEngine.Client.Core`. + +The package enables neither an SDK plugin entry point nor dynamic loading on its own. The final +plugin project must directly reference both `CheatEngine.Client` and `CheatEngine.SDK` so that the +SDK generator, build assets, native Lua bridge, and host bootstrap execute at the actual plugin +boundary. + +## Capability Status and Limits + +Core implements the currently qualified Client mappings for runtime facts and capabilities, +process selection, typed memory, bounded pointer chains and strings, AOB scans, copied inspection, +Address List operations, trusted table paths, and protected typed Lua work. All CE calls remain +synchronous; a cancellation token is observed before dispatch or between Client-managed steps, not +as an interruption of an already-running Lua primitive. + +Value scan creation is deliberately unavailable. Although the public session contract and state +machine are present, CheatEngine.SDK 1.0.0 does not expose a public owner factory for the +`MemScan` and `FoundList` instances that Client would need. Core reports a capability failure +rather than making an unverified ownership assumption. It must not be documented or released as +a live value-scanning implementation until the Cheat Engine 7.7 x64 creation, destruction, +disable, and reactivation gate passes. + +UI/forms, debugger and breakpoints, Auto Assembler, injection, public remote allocations, +structures, hotkeys/timers, speedhack, DBVM, Mono/IL2CPP, and ABI hooks are outside this layer's +current delivered capability. + +## Contribution and Validation + +Treat lifetime, dispatch, and disposal changes as host-safety changes. Keep SDK handles internal, +route new CE work through the dispatcher, add a capability observation for optional bindings, and +test target/activation invalidation and reverse-order cleanup in +`tests/CheatEngine.Client.Core.Tests`. + +Core's direct public baseline is intentionally empty; changes that create a public type require an +explicit product-surface decision and corresponding `PublicAPI.Unshipped.txt` update. Validate the +repository from its 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 +``` + +The ordinary test suite uses SDK-facing ports/fakes and does not replace the opt-in live Cheat +Engine qualification gate. diff --git a/libs/CheatEngine.Client.Core/packages.lock.json b/libs/CheatEngine.Client.Core/packages.lock.json new file mode 100644 index 0000000..48b14cb --- /dev/null +++ b/libs/CheatEngine.Client.Core/packages.lock.json @@ -0,0 +1,60 @@ +{ + "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.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)" + } + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj b/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj new file mode 100644 index 0000000..7d2fc0f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs new file mode 100644 index 0000000..4a3bf96 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs @@ -0,0 +1,184 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class MemoryClientTests +{ + [Fact] + public void TypedPrimitiveReadUsesTheCallerSuppliedCodec() + { + RecordingInt32Codec codec = new() { ReadValue = 1234 }; + MemoryClient client = new(new InlineDispatcher()); + Address address = 0x401000; + + bool succeeded = client.TryRead(new MemoryReadRequest(address, codec), out int value, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(1234, value); + Assert.Equal(default, failure); + Assert.Equal(1, codec.ReadCount); + Assert.Equal(address, codec.LastReadAddress); + } + + [Fact] + public void TypedPrimitiveWriteUsesTheCallerSuppliedCodec() + { + RecordingInt32Codec codec = new(); + MemoryClient client = new(new InlineDispatcher()); + Address address = 0x402000; + + bool succeeded = client.TryWrite(new MemoryWriteRequest(address, 77, codec), + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(1, codec.WriteCount); + Assert.Equal(address, codec.LastWriteAddress); + Assert.Equal(77, codec.LastWrittenValue); + } + + [Fact] + public void TypedCodecFailureBecomesAClassifiedMemoryReadFailure() + { + RecordingInt32Codec codec = new() { ReadSucceeds = false }; + MemoryClient client = new(new InlineDispatcher()); + + bool succeeded = client.TryRead(new MemoryReadRequest(0x403000, codec), out int value, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, value); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, failure.Kind); + Assert.Equal("Memory.Read", failure.Operation); + Assert.Contains("codec", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, codec.ReadCount); + } + + private sealed class RecordingInt32Codec : IMemoryCodec + { + internal int ReadValue + { + get; + init; + } + + internal bool ReadSucceeds + { + get; + init; + } = true; + + internal int ReadCount + { + get; + private set; + } + + internal int WriteCount + { + get; + private set; + } + + internal Address LastReadAddress + { + get; + private set; + } + + internal Address LastWriteAddress + { + get; + private set; + } + + internal int LastWrittenValue + { + get; + private set; + } + + public bool TryRead(IMemoryReadContext context, Address address, out int value) + { + Assert.NotNull(context); + ReadCount++; + LastReadAddress = address; + value = ReadValue; + return ReadSucceeds; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + { + Assert.NotNull(context); + WriteCount++; + LastWriteAddress = address; + LastWrittenValue = value; + return true; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out var failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs new file mode 100644 index 0000000..adea745 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs @@ -0,0 +1,30 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class PatternScannerTests +{ + [Fact] + public void TryValidateRequestRejectsTheDefaultStructBeforeAnyDispatcherOrSdkOperation() + { + bool valid = PatternScanner.TryValidateRequest(default, out CheatEngineFailure failure); + + Assert.False(valid); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + } + + [Fact] + public void TryValidateRequestAcceptsAConstructedBoundedRequest() + { + AobScanRequest request = new(new AobPattern("90"), AobScanOptions.Default, 1); + + bool valid = PatternScanner.TryValidateRequest(request, out CheatEngineFailure failure); + + Assert.True(valid); + Assert.Equal(default, failure); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs new file mode 100644 index 0000000..cf8d4d7 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs @@ -0,0 +1,389 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class ProcessClientTests +{ + [Fact] + public void RefreshKeepsTheSelectionEpochForTheSamePidAndArchitecture() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(initial.Id, refreshed.Id); + Assert.Equal(initial.TargetArchitecture, refreshed.TargetArchitecture); + Assert.Equal(0, initial.SelectionEpoch); + Assert.Equal(initial.SelectionEpoch, refreshed.SelectionEpoch); + } + + [Fact] + public void RefreshChangesPidAdvancesSelectionEpochAndDisposesTheOldTargetLease() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); + host.OpenedProcessId = 43; + + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(new TargetProcessId(43), refreshed.Id); + Assert.Equal(1, refreshed.SelectionEpoch); + Assert.Equal(1, lease.DisposeCount); + Assert.Throws(() => + selectionLifetime.ThrowIfExpired(initial.SelectionEpoch, "Test.TargetLease")); + } + + [Fact] + public void RefreshChangesArchitectureForTheSamePidAndAdvancesSelectionEpoch() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X86); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + host.TargetArchitecture = CheatEngineArchitecture.X64; + + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(new TargetProcessId(42), refreshed.Id); + Assert.Equal(CheatEngineArchitecture.X64, refreshed.TargetArchitecture); + Assert.Equal(1, refreshed.SelectionEpoch); + Assert.NotEqual(initial.SelectionEpoch, refreshed.SelectionEpoch); + } + + [Fact] + public void TryGetCurrentReportsTargetNotAttachedAndInvalidatesTheKnownSelectionWhenCeDetaches() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + host.OpenedProcessId = 0; + + bool succeeded = client.TryGetCurrent( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.TargetNotAttached, failure.Kind); + Assert.Equal(1, selectionLifetime.Epoch); + Assert.Throws(() => + selectionLifetime.ThrowIfExpired(initial.SelectionEpoch, "Test.TargetLease")); + + CheatEngineOperationException exception = Assert.Throws(() => + client.GetCurrent(TestContext.Current.CancellationToken)); + Assert.Equal(CheatEngineFailureKind.TargetNotAttached, exception.Failure.Kind); + } + + [Fact] + public void AttachVerifiesThePidSelectedByCheatEngineBeforeReturningTheSnapshot() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); + host.SelectedAfterOpenOverride = 42; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + bool succeeded = client.TryAttach( + new TargetProcessId(43), + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal([43L], host.OpenProcessCalls); + } + + [Fact] + public void TryAttachSelectsTheRequestedPidAndAdvancesAnAlreadyObservedSelection() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); + + bool succeeded = client.TryAttach( + new TargetProcessId(43), + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(new TargetProcessId(43), snapshot.Id); + Assert.Equal(1, snapshot.SelectionEpoch); + Assert.Equal(1, lease.DisposeCount); + Assert.Equal([43L], host.OpenProcessCalls); + } + + [Fact] + public void TryAttachExactNameRejectsZeroAndMultipleCandidatesWithoutOpeningAnyProcess() + { + FakeProcessHost noMatchHost = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime noMatchLifetime = CreateSelectionLifetime(); + ProcessClient noMatchClient = new(new InlineDispatcher(), noMatchHost, noMatchLifetime); + + bool noMatchSucceeded = noMatchClient.TryAttachExactName( + "absent.exe", + out ProcessSnapshot noMatchSnapshot, + out CheatEngineFailure noMatchFailure, + TestContext.Current.CancellationToken); + + Assert.False(noMatchSucceeded); + Assert.Equal(default, noMatchSnapshot); + Assert.Equal(CheatEngineFailureKind.NotFound, noMatchFailure.Kind); + Assert.Empty(noMatchHost.OpenProcessCalls); + + FakeProcessHost ambiguousHost = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + ambiguousHost.NameMatches["fixture"] = + [ + new LocalProcessInfo(43, "fixture", "C:\\fixtures\\one.exe"), + new LocalProcessInfo(44, "fixture", "C:\\fixtures\\two.exe") + ]; + using TargetSelectionLifetime ambiguousLifetime = CreateSelectionLifetime(); + ProcessClient ambiguousClient = new(new InlineDispatcher(), ambiguousHost, ambiguousLifetime); + + bool ambiguousSucceeded = ambiguousClient.TryAttachExactName( + "fixture.exe", + out ProcessSnapshot ambiguousSnapshot, + out CheatEngineFailure ambiguousFailure, + TestContext.Current.CancellationToken); + + Assert.False(ambiguousSucceeded); + Assert.Equal(default, ambiguousSnapshot); + Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, ambiguousFailure.Kind); + Assert.Empty(ambiguousHost.OpenProcessCalls); + } + + [Fact] + public void AttachExactNameUsesTheSingleExactCandidateAndReturnsCopiedMetadata() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture", "C:\\fixtures\\fixture.exe"); + host.NameMatches["fixture"] = [host.LocalProcesses[43]]; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + ProcessSnapshot snapshot = client.AttachExactName("fixture.exe", TestContext.Current.CancellationToken); + + Assert.Equal(new TargetProcessId(43), snapshot.Id); + Assert.Equal("fixture", snapshot.Name); + Assert.Equal("C:\\fixtures\\fixture.exe", snapshot.ExecutablePath); + Assert.Equal([43L], host.OpenProcessCalls); + } + + [Theory] + [InlineData("C:\\fixtures\\fixture.exe")] + [InlineData(".exe")] + [InlineData(" ")] + public void ExactNameAttachmentRejectsPathsAndInvalidNamesBeforeProcessDiscovery(string name) + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + Assert.Throws(() => + client.TryAttachExactName(name, out _, out _, TestContext.Current.CancellationToken)); + Assert.Empty(host.OpenProcessCalls); + } + + private static TargetSelectionLifetime CreateSelectionLifetime() + { + return new TargetSelectionLifetime(static _ => + { + }); + } + + private sealed class RecordingDisposable : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + } + } + + private sealed class FakeProcessHost : IProcessHost + { + internal Dictionary LocalProcesses + { + get; + } = []; + + internal Dictionary> NameMatches + { + get; + } = + new(StringComparer.OrdinalIgnoreCase); + + internal List OpenProcessCalls + { + get; + } = []; + + internal long OpenedProcessId + { + get; + set; + } + + internal long? SelectedAfterOpenOverride + { + get; + set; + } + + internal CheatEngineArchitecture TargetArchitecture + { + get; + set; + } + + public long GetOpenedProcessId() + { + return OpenedProcessId; + } + + public void OpenProcess(long processId) + { + OpenProcessCalls.Add(processId); + OpenedProcessId = SelectedAfterOpenOverride ?? processId; + } + + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + return LocalProcesses.TryGetValue(processId, out process); + } + + public IReadOnlyList FindProcessesByExactName(string processName) + { + return NameMatches.TryGetValue(processName, out IReadOnlyList? matches) ? matches : []; + } + + public CheatEngineArchitecture GetTargetArchitecture() + { + return TargetArchitecture; + } + + internal static FakeProcessHost CreateSelected(int processId, CheatEngineArchitecture architecture) + { + FakeProcessHost host = new() { OpenedProcessId = processId, TargetArchitecture = architecture }; + host.LocalProcesses[processId] = new LocalProcessInfo( + processId, + "fixture", + "C:\\fixtures\\fixture.exe"); + return host; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + "Dispatcher.Invoke", + "Cancelled before dispatch."); + return false; + } + + try + { + callback(); + failure = default; + return true; + } + catch (Exception exception) + { + failure = new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, + "Dispatcher.Invoke", + exception.Message, + exception); + return false; + } + } + + public bool TryInvoke(Func callback, out T result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default!; + failure = new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + "Dispatcher.Invoke", + "Cancelled before dispatch."); + return false; + } + + try + { + result = callback(); + failure = default; + return true; + } + catch (Exception exception) + { + result = default!; + failure = new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, + "Dispatcher.Invoke", + exception.Message, + exception); + return false; + } + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs new file mode 100644 index 0000000..6f30eed --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs @@ -0,0 +1,356 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class RuntimeClientTests +{ + [Fact] + public void SnapshotReportsOnlyTheObservedCeLineAndIndependentCapabilities() + { + InlineDispatcher dispatcher = new(); + FakeRuntimeProbe probe = new() + { + ReportedVersion = 7.7d, + SystemArchitectureCode = 1, + TargetAbiCode = 0, + OpenedProcessId = 42, + TargetIs64BitValue = true + }; + RuntimeClient runtime = new(dispatcher, probe, static () => 84, new Version(0, 1, 0), new Version(1, 0, 0)); + + bool succeeded = + runtime.TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(84, snapshot.Epoch); + Assert.Equal(7.7d, snapshot.ObservedCheatEngineVersion); + Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), snapshot.QualifiedCheatEngineBaseline); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.SystemArchitecture); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, snapshot.TargetPointerSize); + Assert.Equal(TargetAbi.Windows, snapshot.TargetAbi); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, + out RuntimeCapabilityAvailability versionCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, versionCapability.State); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, + out RuntimeCapabilityAvailability targetCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, targetCapability.State); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void SnapshotMarksOnlyTheUnavailableProbeUnavailableWithoutInventingItsValue() + { + FakeRuntimeProbe probe = new() + { + ReportedVersion = 7.7d, + SystemArchitectureCode = 1, + TargetAbiCode = 0, + OpenedProcessId = 42, + TargetIs64BitException = new EngineGlobalUnavailableException("Runtime.TargetArchitecture") + }; + RuntimeClient runtime = new(new InlineDispatcher(), probe, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, snapshot.TargetPointerSize); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, + out RuntimeCapabilityAvailability targetCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, targetCapability.State); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.SystemArchitecture, + out RuntimeCapabilityAvailability systemCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, systemCapability.State); + } + + [Fact] + public void SnapshotLeavesTargetArchitectureUnknownWhenNoTargetIsSelectedWithoutCallingTargetProbe() + { + FakeRuntimeProbe probe = new() + { + ReportedVersion = 7.7d, SystemArchitectureCode = 1, TargetAbiCode = 0, OpenedProcessId = 0 + }; + RuntimeClient runtime = new(new InlineDispatcher(), probe, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, + out RuntimeCapabilityAvailability targetCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, targetCapability.State); + Assert.Equal(0, probe.TargetIs64BitCallCount); + } + + [Fact] + public void TryGetSnapshotObservesCancellationBeforeDispatchingAProbe() + { + InlineDispatcher dispatcher = new(); + RuntimeClient runtime = new(dispatcher, new FakeRuntimeProbe(), static () => 1); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = runtime.TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void TryGetSdkCapabilityReturnsUnknownForAnIdentifierThatWasNotProbed() + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 1); + RuntimeCapabilityId customCapability = new("Client.Custom"); + + bool succeeded = runtime.TryGetSdkCapability( + customCapability, + out RuntimeCapabilityAvailability availability, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(customCapability, availability.Capability); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, availability.State); + } + + [Fact] + public void SnapshotReportsClientGatesWithoutClaimingUnprobedDomainsAreAvailable() + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.ValueScanning, + out ClientCapabilityAvailability valueScanning)); + Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, valueScanning.State); + Assert.Contains("live ownership", valueScanning.Reason, StringComparison.OrdinalIgnoreCase); + Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.TypedMemory, + out ClientCapabilityAvailability typedMemory)); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, typedMemory.State); + Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.UnsafeLuaExecution, + out ClientCapabilityAvailability unsafeLua)); + Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, unsafeLua.State); + } + + [Fact] + public void SnapshotReportsUnsafeLuaAvailableOnlyForTheExplicitActivationOptIn() + { + RuntimeClient runtime = new( + new InlineDispatcher(), + new FakeRuntimeProbe(), + static () => 1, + policy: new CoreClientPolicy([], true)); + + bool succeeded = runtime.TryGetClientCapability( + ClientCapabilityId.UnsafeLuaExecution, + out ClientCapabilityAvailability availability, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(ClientCapabilityAvailabilityState.Available, availability.State); + Assert.True(availability.IsAvailable); + } + + [Fact] + public void GetSnapshotThrowsTheClassifiedFailureWhenTheHostReportsAnInvalidVersion() + { + RuntimeClient runtime = new( + new InlineDispatcher(), + new FakeRuntimeProbe { ReportedVersion = double.NaN }, + static () => 1); + + CheatEngineOperationException exception = Assert.Throws(() => + runtime.GetSnapshot(TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, exception.Failure.Kind); + Assert.Equal("Dispatcher.Invoke", exception.Failure.Operation); + } + + private sealed class FakeRuntimeProbe : IRuntimeProbe + { + internal double ReportedVersion + { + get; + init; + } = 7.7d; + + internal int SystemArchitectureCode + { + get; + init; + } = 1; + + internal int TargetAbiCode + { + get; + init; + } + + internal long OpenedProcessId + { + get; + init; + } + + internal bool TargetIs64BitValue + { + get; + init; + } + + internal Exception? TargetIs64BitException + { + get; + init; + } + + internal int TargetIs64BitCallCount + { + get; + private set; + } + + public double GetCheatEngineVersion() + { + return ReportedVersion; + } + + public int GetSystemArchitecture() + { + return SystemArchitectureCode; + } + + public int GetTargetAbi() + { + return TargetAbiCode; + } + + public long GetOpenedProcessId() + { + return OpenedProcessId; + } + + public bool TargetIs64Bit() + { + TargetIs64BitCallCount++; + if (TargetIs64BitException is not null) + { + throw TargetIs64BitException; + } + + return TargetIs64BitValue; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + internal int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = Cancelled(); + return false; + } + + InvocationCount++; + try + { + callback(); + failure = default; + return true; + } + catch (Exception exception) + { + failure = FromException(exception); + return false; + } + } + + public bool TryInvoke(Func callback, out T result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default!; + failure = Cancelled(); + return false; + } + + InvocationCount++; + try + { + result = callback(); + failure = default; + return true; + } + catch (Exception exception) + { + result = default!; + failure = FromException(exception); + return false; + } + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + + private static CheatEngineFailure Cancelled() + { + return new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + "Dispatcher.Invoke", + "Cancelled before dispatch."); + } + + private static CheatEngineFailure FromException(Exception exception) + { + return new CheatEngineFailure( + exception is EngineMarshallingException + ? CheatEngineFailureKind.InvalidHostResult + : CheatEngineFailureKind.OperationRejected, + "Dispatcher.Invoke", + exception.Message, + exception); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs new file mode 100644 index 0000000..c57d1d4 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs @@ -0,0 +1,117 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class SymbolRegistrationLeaseTests +{ + [Fact] + public void FailedDispatchKeepsTheLeaseTrackedUntilTheCleanupRetryUnregistersIt() + { + List events = new(); + CoreResourceRegistry registry = new(); + RetriableDispatcher dispatcher = new() { RejectDispatch = true }; + SymbolRegistrationLease lease = new( + new SymbolRegistration("fixture-symbol", Address.Zero), + dispatcher, + registeredLease => + { + registry.Untrack(registeredLease); + events.Add("untrack"); + }, + _ => events.Add("unregister"), + _ => events.Add("release-name")); + registry.Track(lease); + + Assert.Throws(lease.Dispose); + + Assert.False(lease.IsReleased); + Assert.Empty(events); + + dispatcher.RejectDispatch = false; + registry.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["unregister", "untrack", "release-name"], events); + Assert.Equal(2, dispatcher.InvocationCount); + } + + private sealed class RetriableDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + public bool RejectDispatch + { + get; + set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + if (RejectDispatch) + { + failure = new CheatEngineFailure( + CheatEngineFailureKind.InvalidState, + "Test.Dispatch", + "Dispatch admission is closed."); + return false; + } + + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + if (RejectDispatch) + { + result = default!; + failure = new CheatEngineFailure( + CheatEngineFailureKind.InvalidState, + "Test.Dispatch", + "Dispatch admission is closed."); + return false; + } + + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs new file mode 100644 index 0000000..bbbe1de --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs @@ -0,0 +1,316 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class TableClientMutationTests +{ + [Fact] + public void TryDeleteDispatchesTheRequestedRecordAndReturnsSuccess() + { + FakeRecordMutationPort mutations = new() { DeleteStatus = TableRecordMutationStatus.Success }; + TableClient client = CreateClient(mutations); + + bool succeeded = + client.TryDelete(new MemoryRecordId(41), out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(new MemoryRecordId(41), mutations.LastDeletedId); + Assert.Equal(default, failure); + } + + [Fact] + public void TryDeleteClassifiesAnAbsentRecord() + { + TableClient client = CreateClient(new FakeRecordMutationPort + { + DeleteStatus = TableRecordMutationStatus.RecordNotFound + }); + + bool succeeded = + client.TryDelete(new MemoryRecordId(41), out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); + Assert.Equal("Tables.Delete", failure.Operation); + } + + [Fact] + public void DeleteThrowsTheClassifiedHostFailure() + { + TableClient client = CreateClient(new FakeRecordMutationPort + { + DeleteStatus = TableRecordMutationStatus.HostRejected + }); + + CheatEngineOperationException exception = Assert.Throws(() => + client.Delete(new MemoryRecordId(41), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, exception.Failure.Kind); + Assert.Equal("Tables.Delete", exception.Failure.Operation); + } + + [Fact] + public void TrySetParentPassesBothIdsAndReturnsTheCopiedPostMutationSnapshot() + { + MemoryRecordSnapshot expected = Snapshot(41, "Ammo"); + FakeRecordMutationPort mutations = new() + { + SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = expected + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(12), + out MemoryRecordSnapshot actual, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(new MemoryRecordId(41), mutations.LastChildId); + Assert.Equal(new MemoryRecordId(12), mutations.LastParentId); + Assert.Equal(expected, actual); + Assert.Equal(default, failure); + } + + [Fact] + public void TrySetParentWithNullParentRequestsTheAddressListRoot() + { + FakeRecordMutationPort mutations = new() + { + SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = Snapshot(41, "Ammo") + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), null, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Null(mutations.LastParentId); + Assert.Equal(default, failure); + } + + [Fact] + public void TrySetParentRejectsARecordAsItsOwnParentBeforeDispatching() + { + FakeRecordMutationPort mutations = new(); + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(41), + out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Tables.SetParent", failure.Operation); + Assert.Equal(0, mutations.SetParentCallCount); + } + + [Fact] + public void SetParentThrowsAClassifiedMissingParentFailure() + { + TableClient client = CreateClient(new FakeRecordMutationPort + { + SetParentStatus = TableRecordMutationStatus.ParentNotFound + }); + + CheatEngineOperationException exception = Assert.Throws(() => + client.SetParent(new MemoryRecordId(41), new MemoryRecordId(12), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.NotFound, exception.Failure.Kind); + Assert.Equal("Tables.SetParent", exception.Failure.Operation); + Assert.Contains("parent", exception.Failure.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ParentRelationshipGuardPermitsARootTerminatedCandidateChain() + { + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 4, + static id => id == new MemoryRecordId(12) ? ParentChainStep.Root : ParentChainStep.HostRejected); + + Assert.Equal(TableRecordMutationStatus.Success, status); + } + + [Fact] + public void ParentRelationshipGuardRejectsAnIndirectCycleBackToTheChild() + { + Dictionary links = new() + { + [new MemoryRecordId(12)] = ParentChainStep.Parent(new MemoryRecordId(24)), + [new MemoryRecordId(24)] = ParentChainStep.Parent(new MemoryRecordId(41)) + }; + + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 4, + id => links[id]); + + Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + } + + [Fact] + public void ParentRelationshipGuardRejectsAnExistingParentLoop() + { + Dictionary links = new() + { + [new MemoryRecordId(12)] = ParentChainStep.Parent(new MemoryRecordId(24)), + [new MemoryRecordId(24)] = ParentChainStep.Parent(new MemoryRecordId(12)) + }; + + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 4, + id => links[id]); + + Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + } + + [Fact] + public void ParentRelationshipGuardRejectsAChainThatExceedsItsBound() + { + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 2, + static id => id switch + { + { Value: 12 } => ParentChainStep.Parent(new MemoryRecordId(24)), + { Value: 24 } => ParentChainStep.Parent(new MemoryRecordId(36)), + _ => ParentChainStep.Root + }); + + Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + } + + private static TableClient CreateClient(FakeRecordMutationPort mutations) + { + return new TableClient(new InlineDispatcher(), CoreClientPolicy.SafeDefaults, mutations); + } + + private static MemoryRecordSnapshot Snapshot(int id, string description) + { + return new MemoryRecordSnapshot(new MemoryRecordId(id), 0, description, "game.exe+24", "50", + VariableType.Dword, null); + } + + private sealed class FakeRecordMutationPort : ITableRecordMutationPort + { + internal TableRecordMutationStatus DeleteStatus + { + get; + init; + } = TableRecordMutationStatus.Success; + + internal TableRecordMutationStatus SetParentStatus + { + get; + init; + } = TableRecordMutationStatus.Success; + + internal MemoryRecordSnapshot SetParentRecord + { + get; + init; + } + + internal MemoryRecordId LastDeletedId + { + get; + private set; + } + + internal MemoryRecordId LastChildId + { + get; + private set; + } + + internal MemoryRecordId? LastParentId + { + get; + private set; + } + + internal int SetParentCallCount + { + get; + private set; + } + + public TableRecordMutationStatus TryDelete(MemoryRecordId id) + { + LastDeletedId = id; + return DeleteStatus; + } + + public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + SetParentCallCount++; + LastChildId = childId; + LastParentId = parentId; + record = SetParentRecord; + return SetParentStatus; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out var failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs new file mode 100644 index 0000000..52a590a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs @@ -0,0 +1,52 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class UnavailableValueScannerTests +{ + [Fact] + public void TryCreateSessionReportsCapabilityUnavailableBeforeTheLiveOwnershipGate() + { + UnavailableValueScanner scanner = new(); + + bool succeeded = scanner.TryCreateSession( + out IValueScanSession? session, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(session); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal((string?) "Scans.CreateSession", (string?) failure.Operation); + Assert.Contains("no public ownership factory", failure.Message, StringComparison.Ordinal); + Assert.Contains("cannot return CEObject", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void TryCreateSessionReportsCancellationWithoutAttemptingHostWork() + { + UnavailableValueScanner scanner = new(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = scanner.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Null(session); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + } + + [Fact] + public void CreateSessionThrowsTheClassifiedUnavailableFailure() + { + UnavailableValueScanner scanner = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = scanner.CreateSession(TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs new file mode 100644 index 0000000..812faf9 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs @@ -0,0 +1,64 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class ValueScanSessionStateMachineTests +{ + [Fact] + public void FirstAndNextScansFollowTheManagedHappyPath() + { + ValueScanSessionStateMachine state = new(); + + state.BeginFirstScan(); + Assert.Equal(ValueScanSessionState.Scanning, state.State); + + state.CompleteScan(); + Assert.Equal(ValueScanSessionState.ResultsReady, state.State); + + state.BeginNextScan(); + Assert.Equal(ValueScanSessionState.Scanning, state.State); + + state.CompleteScan(); + Assert.Equal(ValueScanSessionState.ResultsReady, state.State); + } + + [Fact] + public void FailedStartedOperationInvalidatesUntilReset() + { + ValueScanSessionStateMachine state = new(); + state.BeginFirstScan(); + + state.Invalidate(); + + Assert.Equal(ValueScanSessionState.Invalidated, state.State); + Assert.Throws(state.BeginFirstScan); + Assert.Throws(state.BeginNextScan); + + state.Reset(); + Assert.Equal(ValueScanSessionState.Created, state.State); + } + + [Fact] + public void ResetDoesNotInterruptScanning() + { + ValueScanSessionStateMachine state = new(); + state.BeginFirstScan(); + + Assert.Throws(state.Reset); + Assert.Equal(ValueScanSessionState.Scanning, state.State); + } + + [Fact] + public void DisposalIsIdempotentAndTerminal() + { + ValueScanSessionStateMachine state = new(); + + state.MarkDisposed(); + state.MarkDisposed(); + + Assert.Equal(ValueScanSessionState.Disposed, state.State); + Assert.Throws(state.Reset); + Assert.Throws(state.BeginFirstScan); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyTests.cs new file mode 100644 index 0000000..3afd660 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyTests.cs @@ -0,0 +1,77 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreClientPolicyTests +{ + [Fact] + public void TryAuthorizeTableFileAllowsARegularExistingImportInsideItsConfiguredRoot() + { + using TemporaryDirectory directory = new(); + string source = Path.Combine(directory.Path, "profile.ct"); + File.WriteAllText(source, ""); + CoreClientPolicy policy = new([directory.Path], false); + + bool allowed = policy.TryAuthorizeTableFile(source, true, out string reason); + + Assert.True(allowed); + Assert.Equal(string.Empty, reason); + } + + [Fact] + public void TryAuthorizeTableFileRejectsAPathWithOnlyTheSameTextPrefixAsTheConfiguredRoot() + { + using TemporaryDirectory parent = new(); + string approved = Path.Combine(parent.Path, "tables"); + string similarlyNamed = Path.Combine(parent.Path, "tables-escape"); + Directory.CreateDirectory(approved); + Directory.CreateDirectory(similarlyNamed); + string candidate = Path.Combine(similarlyNamed, "profile.ct"); + File.WriteAllText(candidate, ""); + CoreClientPolicy policy = new([approved], false); + + bool allowed = policy.TryAuthorizeTableFile(candidate, true, out string reason); + + Assert.False(allowed); + Assert.Contains("outside", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryAuthorizeTableFileRejectsMissingImportSourceButAllowsANewExportInAnExistingDirectory() + { + using TemporaryDirectory directory = new(); + string candidate = Path.Combine(directory.Path, "new-profile.ct"); + CoreClientPolicy policy = new([directory.Path], false); + + bool importAllowed = policy.TryAuthorizeTableFile(candidate, true, out string importReason); + bool exportAllowed = policy.TryAuthorizeTableFile(candidate, false, out string exportReason); + + Assert.False(importAllowed); + Assert.Contains("existing regular file", importReason, StringComparison.OrdinalIgnoreCase); + Assert.True(exportAllowed); + Assert.Equal(string.Empty, exportReason); + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "CheatEngine.Client.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + internal string Path + { + get; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs new file mode 100644 index 0000000..575422d --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs @@ -0,0 +1,31 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreFailureFactoryTests +{ + [Fact] + public void LifecycleCreatesTheStableInvalidStateFailure() + { + CheatEngineFailure failure = CoreFailureFactory.Lifecycle("Client.DrainResources", "The client is stopping."); + + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal("Client.DrainResources", failure.Operation); + Assert.Equal("The client is stopping.", failure.Message); + Assert.Null(failure.Exception); + } + + [Fact] + public void FromExceptionPreservesDedicatedActivationExpiryClassification() + { + CheatEngineActivationExpiredException exception = new("Memory.Read", "The epoch changed."); + + CheatEngineFailure failure = CoreFailureFactory.FromException("Dispatcher.Invoke", exception); + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, failure.Kind); + Assert.Equal("Dispatcher.Invoke", failure.Operation); + Assert.Equal(exception.Message, failure.Message); + Assert.Same(exception, failure.Exception); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs new file mode 100644 index 0000000..8be73e6 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs @@ -0,0 +1,148 @@ +using System.Runtime.ExceptionServices; + +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreResourceRegistryTests +{ + [Fact] + public void DisposeReleasesDistinctResourcesInReverseRegistrationOrderAndIsIdempotent() + { + List events = new(); + RecordingDisposable first = new("first", events); + RecordingDisposable second = new("second", events); + RecordingDisposable third = new("third", events); + CoreResourceRegistry registry = new(); + + Assert.Same(first, registry.Track(first)); + registry.Track(second); + registry.Track(third); + registry.Track(second); + + registry.Dispose(); + registry.Dispose(); + + Assert.Equal(["third", "second", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, second.DisposeCount); + Assert.Equal(1, third.DisposeCount); + } + + [Fact] + public void DisposeContinuesInReverseOrderAfterAnEarlierResourceThrows() + { + List events = new(); + RecordingDisposable first = new("first", events); + RecordingDisposable failing = new("failing", events, new InvalidOperationException("expected")); + RecordingDisposable last = new("last", events); + CoreResourceRegistry registry = new(); + registry.Track(first); + registry.Track(failing); + registry.Track(last); + + InvalidOperationException exception = Assert.Throws(registry.Dispose); + + Assert.Equal("expected", exception.Message); + Assert.Equal(["last", "failing", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, failing.DisposeCount); + Assert.Equal(1, last.DisposeCount); + } + + [Fact] + public void UntrackRemovesOnlyThatResourceAndReportsWhetherItWasTracked() + { + List events = new(); + RecordingDisposable retained = new("retained", events); + RecordingDisposable removed = new("removed", events); + RecordingDisposable untracked = new("untracked", events); + CoreResourceRegistry registry = new(); + registry.Track(retained); + registry.Track(removed); + + Assert.True(registry.Untrack(removed)); + Assert.False(registry.Untrack(untracked)); + + registry.Dispose(); + + Assert.Equal(["retained"], events); + Assert.Equal(0, removed.DisposeCount); + } + + [Fact] + public void TrackAfterDisposeRejectsNewResources() + { + CoreResourceRegistry registry = new(); + registry.Dispose(); + + Assert.Throws(() => registry.Track(new RecordingDisposable("late", []))); + } + + [Fact] + public void DisposeTargetSelectionReleasesOnlyMatchingResourcesInReverseRegistrationOrder() + { + List events = new(); + RecordingDisposable firstSelectionResource = new("first-selection", events); + RecordingDisposable retainedResource = new("retained", events); + RecordingDisposable lastSelectionResource = new("last-selection", events); + CoreResourceRegistry registry = new(); + registry.Track(firstSelectionResource, 4); + registry.Track(retainedResource, 5); + registry.Track(lastSelectionResource, 4); + + registry.DisposeTargetSelection(4); + + Assert.Equal(["last-selection", "first-selection"], events); + Assert.Equal(1, firstSelectionResource.DisposeCount); + Assert.Equal(0, retainedResource.DisposeCount); + Assert.Equal(1, lastSelectionResource.DisposeCount); + + registry.Dispose(); + + Assert.Equal(["last-selection", "first-selection", "retained"], events); + Assert.Equal(1, retainedResource.DisposeCount); + } + + [Fact] + public void DisposeTargetSelectionContinuesAfterFailureAndPreservesTheFirstException() + { + List events = new(); + RecordingDisposable first = new("first", events); + RecordingDisposable failing = new("failing", events, new InvalidOperationException("expected")); + RecordingDisposable last = new("last", events); + CoreResourceRegistry registry = new(); + registry.Track(first, 9); + registry.Track(failing, 9); + registry.Track(last, 9); + + InvalidOperationException exception = + Assert.Throws(() => registry.DisposeTargetSelection(9)); + + Assert.Equal("expected", exception.Message); + Assert.Equal(["last", "failing", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, failing.DisposeCount); + Assert.Equal(1, last.DisposeCount); + } + + private sealed class RecordingDisposable(string name, List events, Exception? exception = null) + : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + events.Add(name); + if (exception is not null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs new file mode 100644 index 0000000..c46753a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs @@ -0,0 +1,133 @@ +using System.Runtime.ExceptionServices; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class TargetSelectionLifetimeTests +{ + [Fact] + public void AdvanceInvalidatesOnlyThePreviousSelectionAndDisposesItsResourcesInLifoOrder() + { + List events = new(); + TargetSelectionLifetime lifetime = new(static _ => + { + }); + long firstEpoch = lifetime.Epoch; + RecordingDisposable first = new("first", events); + RecordingDisposable second = new("second", events); + lifetime.Track(first, firstEpoch); + lifetime.Track(second, firstEpoch); + + long nextEpoch = lifetime.Advance("Process.Refresh"); + + Assert.Equal(firstEpoch + 1, nextEpoch); + Assert.Equal(["second", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, second.DisposeCount); + + CheatEngineClientLifecycleException exception = Assert.Throws(() => + lifetime.ThrowIfExpired(firstEpoch, "Memory.Read")); + + Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); + Assert.Equal("Memory.Read", exception.Failure.Operation); + + lifetime.ThrowIfExpired(nextEpoch, "Memory.Read"); + } + + [Fact] + public void AdvanceContinuesCleanupAfterAResourceFails() + { + List events = new(); + TargetSelectionLifetime lifetime = new(static _ => + { + }); + long epoch = lifetime.Epoch; + RecordingDisposable first = new("first", events); + RecordingDisposable failing = new("failing", events, new InvalidOperationException("expected")); + RecordingDisposable last = new("last", events); + lifetime.Track(first, epoch); + lifetime.Track(failing, epoch); + lifetime.Track(last, epoch); + + InvalidOperationException exception = + Assert.Throws(() => lifetime.Advance("Process.Refresh")); + + Assert.Equal("expected", exception.Message); + Assert.Equal(["last", "failing", "first"], events); + Assert.Equal(epoch + 1, lifetime.Epoch); + } + + [Fact] + public void DisposeIsIdempotentAndRejectsNewTargetBoundRegistrations() + { + List events = new(); + TargetSelectionLifetime lifetime = new(static _ => + { + }); + RecordingDisposable resource = new("resource", events); + lifetime.Track(resource, lifetime.Epoch); + + lifetime.Dispose(); + lifetime.Dispose(); + + Assert.Equal(["resource"], events); + Assert.Equal(1, resource.DisposeCount); + Assert.Throws(() => lifetime.Track(new RecordingDisposable("late", events), 0)); + } + + [Fact] + public void UntrackPreventsTheNextSelectionAdvanceFromDisposingTheReleasedResource() + { + List events = new(); + TargetSelectionLifetime lifetime = new(static _ => + { + }); + RecordingDisposable resource = new("resource", events); + lifetime.Track(resource, lifetime.Epoch); + + Assert.True(lifetime.Untrack(resource)); + Assert.False(lifetime.Untrack(resource)); + + lifetime.Advance("Process.Refresh"); + + Assert.Empty(events); + Assert.Equal(0, resource.DisposeCount); + } + + [Fact] + public void ActivationGuardRejectsAnAdvanceWithoutChangingTheTargetSelectionEpoch() + { + CheatEngineActivationExpiredException expected = new("Process.Refresh", "The plugin epoch changed."); + TargetSelectionLifetime lifetime = new(static operation => + throw new CheatEngineActivationExpiredException(operation, "The plugin epoch changed.")); + + CheatEngineActivationExpiredException exception = + Assert.Throws(() => lifetime.Advance("Process.Refresh")); + + Assert.Equal(expected.Failure.Kind, exception.Failure.Kind); + Assert.Equal(expected.Failure.Operation, exception.Failure.Operation); + Assert.Equal(0, lifetime.Epoch); + } + + private sealed class RecordingDisposable(string name, List events, Exception? exception = null) + : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + events.Add(name); + if (exception is not null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs new file mode 100644 index 0000000..786bb13 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs @@ -0,0 +1,200 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +public sealed class LuaClientTests +{ + [Fact] + public void TryExecuteProvidesTheCapturedEpochOnlyDuringItsSynchronousOperation() + { + ImmediateDispatcher dispatcher = new(); + RetainingOperation operation = new(); + LuaClient client = new(dispatcher, static () => 42, static () => true); + + bool succeeded = client.TryExecute(operation, out int result, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(42, result); + Assert.Equal(default, failure); + Assert.Equal(1, dispatcher.InvocationCount); + ILuaExecutionContext? context = operation.Context; + Assert.NotNull(context); + Assert.Equal(42, context.Epoch); + Assert.False(context.IsActive); + Assert.Throws(context.ThrowIfExpired); + } + + [Fact] + public void TryExecutePreservesAnExpectedOperationFailure() + { + CheatEngineFailure expected = new(CheatEngineFailureKind.LuaError, "Lua.Custom", "The Lua global failed."); + LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); + + bool succeeded = client.TryExecute(new FailingOperation(expected), out int result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(0, result); + Assert.Equal(expected, failure); + } + + [Fact] + public void TryExecuteNormalizesAnOperationFailureWithoutDetails() + { + LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); + + bool succeeded = client.TryExecute(new FailingOperation(default), out _, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.Unknown, failure.Kind); + Assert.Equal("Lua.Execute", failure.Operation); + Assert.Contains("without an associated", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void ExecuteTurnsAnExpectedFailureIntoTheClientException() + { + CheatEngineFailure expected = new(CheatEngineFailureKind.LuaError, "Lua.Custom", "The Lua global failed."); + LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); + + CheatEngineOperationException exception = Assert.Throws(() => + client.Execute(new FailingOperation(expected), + TestContext.Current.CancellationToken)); + + Assert.Equal(expected, exception.Failure); + } + + [Fact] + public void TryExecuteThrowsActivationExpiredWhenTheContextCannotEnterTheCurrentActivation() + { + RetainingOperation operation = new(); + LuaClient client = new(new ImmediateDispatcher(), static () => 7, static () => false); + + CheatEngineActivationExpiredException exception = Assert.Throws(() => + client.TryExecute(operation, out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); + Assert.Equal("Lua.OperationContext", exception.Failure.Operation); + Assert.Null(operation.Context); + } + + [Fact] + public void TryExecuteHonorsCancellationBeforeTheTypedOperationRuns() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + ImmediateDispatcher dispatcher = new(); + RetainingOperation operation = new(); + LuaClient client = new(dispatcher, static () => 7, static () => true); + + bool succeeded = client.TryExecute( + operation, + out _, + out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(0, dispatcher.InvocationCount); + Assert.Null(operation.Context); + } + + private sealed class RetainingOperation : ILuaOperation + { + internal ILuaExecutionContext? Context + { + get; + private set; + } + + public bool TryExecute(ILuaExecutionContext context, out int result, out CheatEngineFailure failure) + { + context.ThrowIfExpired(); + Context = context; + result = checked((int) context.Epoch); + failure = default; + return true; + } + } + + private sealed class FailingOperation(CheatEngineFailure expectedFailure) : ILuaOperation + { + public bool TryExecute(ILuaExecutionContext context, out int result, out CheatEngineFailure failure) + { + context.ThrowIfExpired(); + result = default; + failure = expectedFailure; + return false; + } + } + + private sealed class ImmediateDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatch", "Cancelled."); + return false; + } + + InvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default!; + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatch", "Cancelled."); + return false; + } + + InvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs new file mode 100644 index 0000000..28344c1 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs @@ -0,0 +1,346 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +public sealed class LuaModuleRegistrationTests +{ + [Fact] + public void TryRegisterModuleTracksItsLeaseAndDisposesItThroughTheMainThreadDispatcher() + { + ImmediateDispatcher dispatcher = new(); + List tracked = new(); + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => + { + tracked.Remove(lease); + }); + + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.NotNull(lease); + Assert.Equal(81, lease.Epoch); + Assert.False(lease.IsReleased); + Assert.Equal(["diagnostics.register"], module.Events); + Assert.Equal(1, dispatcher.InvocationCount); + Assert.Single(tracked, lease); + + lease.Dispose(); + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + Assert.Equal(2, dispatcher.InvocationCount); + Assert.Empty(tracked); + } + + [Fact] + public void TryRegisterModuleRejectsTheSameModuleInstanceUntilItsLeaseIsReleased() + { + ImmediateDispatcher dispatcher = new(); + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, static () => true); + + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? firstLease, out _, + TestContext.Current.CancellationToken)); + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? duplicateLease, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(duplicateLease); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal("Lua.RegisterModule", failure.Operation); + Assert.Equal(["diagnostics.register"], module.Events); + + firstLease.Dispose(); + + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + } + + [Fact] + public void RegisterModuleReturnsTheLeaseAndThrowsTheMappedFailureForARejectedRegistration() + { + ImmediateDispatcher dispatcher = new(); + LuaClient client = CreateClient(dispatcher, static () => true); + RecordingModule accepted = new("accepted"); + RecordingModule rejected = new("rejected", new InvalidOperationException("generated registration failed")); + + using ILuaModuleLease lease = client.RegisterModule(accepted, TestContext.Current.CancellationToken); + CheatEngineOperationException exception = Assert.Throws(() => + client.RegisterModule(rejected, TestContext.Current.CancellationToken)); + + Assert.Equal(81, lease.Epoch); + Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); + Assert.Equal("Lua.RegisterModule", exception.Failure.Operation); + Assert.Equal(["accepted.register"], accepted.Events); + Assert.Equal(["rejected.register"], rejected.Events); + } + + [Fact] + public void TryRegisterModuleMapsAnApplicationModuleFailureAndDoesNotCreateALease() + { + ImmediateDispatcher dispatcher = new(); + RecordingModule module = new("diagnostics", new InvalidOperationException("generated registration failed")); + LuaClient client = CreateClient(dispatcher, static () => true); + + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Lua.RegisterModule", failure.Operation); + Assert.Equal("generated registration failed", failure.Message); + Assert.Equal(["diagnostics.register"], module.Events); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void TryRegisterModuleRollsBackTheGeneratedRegistrationWhenTheActivationCannotTrackItsLease() + { + ImmediateDispatcher dispatcher = new(); + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient( + dispatcher, + static () => true, + static _ => throw new InvalidOperationException("activation is closed")); + + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + Assert.Equal(2, dispatcher.InvocationCount); + } + + [Fact] + public void ForgottenModuleLeasesReleaseInReverseRegistrationOrderFromTheActivationResourceRegistry() + { + ImmediateDispatcher dispatcher = new(); + CoreResourceRegistry registry = new(); + List events = new(); + RecordingModule first = new("first", events: events); + RecordingModule second = new("second", events: events); + LuaClient client = CreateClient( + dispatcher, + static () => true, + lease => + { + registry.Track(lease); + }, + lease => + { + registry.Untrack(lease); + }); + + Assert.True(client.TryRegisterModule(first, out _, out _, TestContext.Current.CancellationToken)); + Assert.True(client.TryRegisterModule(second, out _, out _, TestContext.Current.CancellationToken)); + + registry.Dispose(); + + Assert.Equal(["first.register", "second.register", "second.unregister", "first.unregister"], events); + Assert.Equal(4, dispatcher.InvocationCount); + } + + [Fact] + public void StaleModuleLeaseDoesNotAttemptToDispatchIntoAChangedActivation() + { + ImmediateDispatcher dispatcher = new(); + bool activationIsCurrent = true; + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, () => activationIsCurrent); + + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + activationIsCurrent = false; + + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["diagnostics.register"], module.Events); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void DispatcherRejectionLeavesTheLeaseTrackedForTheLaterCleanupDispatch() + { + ImmediateDispatcher dispatcher = new(); + List tracked = new(); + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => + { + tracked.Remove(lease); + }); + + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + dispatcher.InvokeException = + new CheatEngineClientLifecycleException("Dispatcher.Invoke", "Activation stopping."); + + Assert.Throws(lease.Dispose); + + Assert.False(lease.IsReleased); + Assert.Single(tracked, lease); + Assert.Equal(["diagnostics.register"], module.Events); + + dispatcher.InvokeException = null; + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Empty(tracked); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + } + + [Fact] + public void FailedLeaseDisposeRemainsTrackedAndCanBeRetriedByTheActivationCleanupPath() + { + ImmediateDispatcher dispatcher = new(); + List tracked = new(); + RecordingModule module = new("diagnostics", unregisterFailureCount: 1); + LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => + { + tracked.Remove(lease); + }); + + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + + InvalidOperationException exception = Assert.Throws(lease.Dispose); + + Assert.Equal("generated unregistration failed", exception.Message); + Assert.False(lease.IsReleased); + Assert.Single(tracked, lease); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Empty(tracked); + Assert.Equal(["diagnostics.register", "diagnostics.unregister", "diagnostics.unregister"], module.Events); + Assert.Equal(3, dispatcher.InvocationCount); + } + + private static LuaClient CreateClient( + ICheatEngineDispatcher dispatcher, + Func isActivationCurrent, + Action? trackLease = null, + Action? untrackLease = null) + { + return new LuaClient(dispatcher, static () => 81, isActivationCurrent, trackLease, untrackLease); + } + + private sealed class RecordingModule( + string name, + Exception? registerException = null, + List? events = null, + int unregisterFailureCount = 0) : ILuaModule + { + private int _remainingUnregisterFailures = unregisterFailureCount; + + internal List Events + { + get; + } = events ?? []; + + public void Register() + { + Events.Add(name + ".register"); + if (registerException is not null) + { + throw registerException; + } + } + + public void Unregister() + { + Events.Add(name + ".unregister"); + if (_remainingUnregisterFailures-- > 0) + { + throw new InvalidOperationException("generated unregistration failed"); + } + } + } + + private sealed class ImmediateDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + internal Exception? InvokeException + { + get; + set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatch", "Cancelled."); + return false; + } + + InvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default!; + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatch", "Cancelled."); + return false; + } + + InvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (InvokeException is not null) + { + throw InvokeException; + } + + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaOperationContextTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaOperationContextTests.cs new file mode 100644 index 0000000..38a6360 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaOperationContextTests.cs @@ -0,0 +1,38 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +public sealed class LuaOperationContextTests +{ + [Fact] + public void ContextIsActiveUntilItIsExplicitlyExpired() + { + LuaOperationContext context = new(12, static () => true); + + Assert.Equal(12, context.Epoch); + Assert.True(context.IsActive); + context.ThrowIfExpired(); + + context.Expire(); + + Assert.False(context.IsActive); + CheatEngineActivationExpiredException exception = + Assert.Throws(context.ThrowIfExpired); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); + } + + [Fact] + public void ContextRejectsAStaleActivationWithoutNeedingExplicitExpiration() + { + bool isCurrent = false; + LuaOperationContext context = new(12, () => isCurrent); + + Assert.False(context.IsActive); + Assert.Throws(context.ThrowIfExpired); + + isCurrent = true; + + Assert.True(context.IsActive); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs new file mode 100644 index 0000000..8c1c020 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs @@ -0,0 +1,75 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +public sealed class UnsafeLuaClientTests +{ + [Fact] + public void TryExecuteRejectsUnsafeLuaBeforeDispatchWhenTheActivationDidNotOptIn() + { + RecordingDispatcher dispatcher = new(); + UnsafeLuaClient client = new(dispatcher, new CoreClientPolicy([], false)); + + bool succeeded = client.TryExecute(new LuaScript("return 42"), out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal("Lua.ExecuteUnsafe", failure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void TryExecuteRejectsTheDefaultLuaScriptBeforeCheckingOrDispatchingUnsafeExecution() + { + RecordingDispatcher dispatcher = new(); + UnsafeLuaClient client = new(dispatcher, new CoreClientPolicy([], true)); + + Assert.Throws(() => + client.TryExecute(default, out _, TestContext.Current.CancellationToken)); + Assert.Equal(0, dispatcher.InvocationCount); + } + + private sealed class RecordingDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + InvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + InvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + callback(); + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + return callback(); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/README.md b/tests/CheatEngine.Client.Core.Tests/README.md new file mode 100644 index 0000000..5dd0f0a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/README.md @@ -0,0 +1,32 @@ +# CheatEngine.Client.Core.Tests + +## Context + +This is the unit-test suite for Core's internal adapters and activation-bound orchestration. It uses internal ports and +fakes where a real Cheat Engine host would otherwise be required. + +## Why this project exists + +Core owns the behavior that is easy to get wrong at the SDK boundary: dispatch admission, epoch expiration, target +selection changes, LIFO resource cleanup, failure mapping, protected Lua calls and module leases, memory codecs, AOB +result ownership, process/runtime adapters, table mutations, and symbol-registration cleanup. + +It also verifies the conservative value-scan gate and its managed state machine. It does not fabricate SDK-owned scan +handles or present an unvalidated Cheat Engine 7.7 x64 value-scan lifecycle as supported. + +## How it helps improve CheatEngine.Client + +The suite separates deterministic policy tests from live-host validation. That lets Core prove cleanup ordering, +rollback behavior, bounded materialization, and stale-resource rejection without mocking SDK statics or requiring a +user process. Regressions in lifecycle code fail quickly before they can leak into a plugin activation. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Core.Tests\CheatEngine.Client.Core.Tests.csproj --configuration Release +``` + +Run the opt-in Cheat Engine live suite separately when it is available and a controlled local fixture has been +authorized; this project is not a substitute for that host-level gate. diff --git a/tests/CheatEngine.Client.Core.Tests/packages.lock.json b/tests/CheatEngine.Client.Core.Tests/packages.lock.json new file mode 100644 index 0000000..13e5d02 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/packages.lock.json @@ -0,0 +1,205 @@ +{ + "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.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==" + } + } + } +} From 34d5163a89a2fbffbfdfc5b63b73b1fdcfa37981 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 20:33:44 +0200 Subject: [PATCH 05/17] fix: remediate Core Sonar findings Construct runtime and table snapshots through the cohesive foundation contracts, reducing public constructor complexity without leaking SDK ownership. Keep Core table mutation tests aligned with the handle-free snapshot boundary. --- .../Domains/RuntimeClient.cs | 24 ++-- .../Domains/TableClient.cs | 123 +++++++++--------- .../Domains/TableClientMutationTests.cs | 18 ++- 3 files changed, 87 insertions(+), 78 deletions(-) diff --git a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs index 21c3889..9a9234d 100644 --- a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs @@ -113,7 +113,7 @@ public RuntimeCapabilityAvailability GetSdkCapability( CancellationToken cancellationToken = default) { if (TryGetSdkCapability(capability, out RuntimeCapabilityAvailability availability, - out CheatEngineFailure failure, cancellationToken)) + out CheatEngineFailure failure, cancellationToken)) { return availability; } @@ -157,7 +157,7 @@ public ClientCapabilityAvailability GetClientCapability( CancellationToken cancellationToken = default) { if (TryGetClientCapability(capability, out ClientCapabilityAvailability availability, - out CheatEngineFailure failure, cancellationToken)) + out CheatEngineFailure failure, cancellationToken)) { return availability; } @@ -205,14 +205,12 @@ private CheatEngineRuntimeSnapshot Capture() return new CheatEngineRuntimeSnapshot( Epoch, - observedVersion, - CheatEngineVersion.Ce77010621, - _clientAssemblyVersion, - _sdkAssemblyVersion, - decodedSystemArchitecture, - decodedTargetArchitecture, - PointerSize.FromArchitecture(decodedTargetArchitecture), - decodedTargetAbi, + new CheatEngineRuntimeVersionInfo(observedVersion, CheatEngineVersion.Ce77010621, _clientAssemblyVersion, _sdkAssemblyVersion), + new CheatEngineRuntimePlatformInfo( + decodedSystemArchitecture, + decodedTargetArchitecture, + PointerSize.FromArchitecture(decodedTargetArchitecture), + decodedTargetAbi), RuntimeCapabilities.Create(capabilities), CreateClientCapabilities()); } @@ -259,7 +257,7 @@ private static ClientCapabilityAvailability Unknown(ClientCapabilityId capabilit private static CheatEngineArchitecture DecodeSystemArchitecture(ProbeResult probe) { return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int architecture } && - RuntimeInfo.TryDecodeSystemArchitecture(architecture, out CheatEngineArchitecture decoded) + RuntimeInfo.TryDecodeSystemArchitecture(architecture, out CheatEngineArchitecture decoded) ? decoded : CheatEngineArchitecture.Unknown; } @@ -267,7 +265,7 @@ private static CheatEngineArchitecture DecodeSystemArchitecture(ProbeResult private static TargetAbi DecodeTargetAbi(ProbeResult probe) { return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int abi } && - RuntimeInfo.TryDecodeTargetAbi(abi, out TargetAbi decoded) + RuntimeInfo.TryDecodeTargetAbi(abi, out TargetAbi decoded) ? decoded : TargetAbi.Unknown; } @@ -275,7 +273,7 @@ private static TargetAbi DecodeTargetAbi(ProbeResult probe) private static CheatEngineArchitecture DecodeTargetArchitecture(ProbeResult probe, TargetAbi targetAbi) { if (targetAbi != TargetAbi.Windows || - probe is not { State: RuntimeCapabilityAvailabilityState.Available, Value: bool is64Bit }) + probe is not { State: RuntimeCapabilityAvailabilityState.Available, Value: bool is64Bit }) { return CheatEngineArchitecture.Unknown; } diff --git a/libs/CheatEngine.Client.Core/Domains/TableClient.cs b/libs/CheatEngine.Client.Core/Domains/TableClient.cs index 399e1c9..ae9f0b9 100644 --- a/libs/CheatEngine.Client.Core/Domains/TableClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/TableClient.cs @@ -30,8 +30,8 @@ public bool TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure AddressTableSnapshot captured = default; bool succeeded = false; if (!_dispatcher.TryInvoke(() => - succeeded = AddressListAccess.TryGetCurrent(out AddressList list) && list.TryGetCount(out int count) && - CaptureTable(count, out captured), out failure, cancellationToken)) + succeeded = AddressListAccess.TryGetCurrent(out AddressList list) && list.TryGetCount(out int count) && + CaptureTable(count, out captured), out failure, cancellationToken)) { table = default; return false; @@ -130,7 +130,7 @@ public ImmutableArray Find(MemoryRecordSearch search, MemoryRecordCollectionRequest request, CancellationToken cancellationToken = default) { if (TryFind(search, request, out ImmutableArray result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } @@ -324,7 +324,7 @@ public MemoryRecordSnapshot SetActive(MemoryRecordId id, bool isActive, CancellationToken cancellationToken = default) { if (TrySetActive(id, isActive, out MemoryRecordSnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } @@ -346,7 +346,7 @@ record = default; MemoryRecordSnapshot captured = default; TableRecordMutationStatus status = TableRecordMutationStatus.HostRejected; if (!_dispatcher.TryInvoke(() => status = _recordMutations.TrySetParent(childId, parentId, out captured), - out failure, cancellationToken)) + out failure, cancellationToken)) { record = default; return false; @@ -367,7 +367,7 @@ public MemoryRecordSnapshot SetParent(MemoryRecordId childId, MemoryRecordId? pa CancellationToken cancellationToken = default) { if (TrySetParent(childId, parentId, out MemoryRecordSnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } @@ -385,18 +385,18 @@ public bool TryGetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest bool succeeded = false; HierarchyBuildProblem problem = HierarchyBuildProblem.None; if (!_dispatcher.TryInvoke(() => - { - if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(rootId, out MemoryRecord root)) - { - return; - } - - found = true; - HashSet visited = new(); - int materialized = 0; - succeeded = TryBuildHierarchy(root, request, 1, visited, ref materialized, out captured, out problem); - }, out failure, cancellationToken)) + { + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(rootId, out MemoryRecord root)) + { + return; + } + + found = true; + HashSet visited = new(); + int materialized = 0; + succeeded = TryBuildHierarchy(root, request, 1, visited, ref materialized, out captured, out problem); + }, out failure, cancellationToken)) { hierarchy = default; return false; @@ -416,7 +416,7 @@ public MemoryRecordHierarchySnapshot GetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, CancellationToken cancellationToken = default) { if (TryGetHierarchy(rootId, request, out MemoryRecordHierarchySnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } @@ -471,15 +471,15 @@ private bool TryRecord(string operation, Func select MemoryRecordSnapshot captured = default; bool succeeded = false; if (!_dispatcher.TryInvoke(() => - { - if (!AddressListAccess.TryGetCurrent(out AddressList list)) - { - return; - } + { + if (!AddressListAccess.TryGetCurrent(out AddressList list)) + { + return; + } - MemoryRecord? value = selector(list); - succeeded = value.HasValue && TrySnapshot(value.Value, out captured); - }, out failure, cancellationToken)) + MemoryRecord? value = selector(list); + succeeded = value.HasValue && TrySnapshot(value.Value, out captured); + }, out failure, cancellationToken)) { record = default; return false; @@ -522,7 +522,7 @@ private static bool TryCaptureRecords(AddressList list, int count, out AddressTa for (int index = 0; index < count; index++) { if (!list.TryGetMemoryRecord(index, out MemoryRecord value) || - !TrySnapshot(value, out MemoryRecordSnapshot snapshot)) + !TrySnapshot(value, out MemoryRecordSnapshot snapshot)) { table = default; return false; @@ -558,9 +558,9 @@ record = default; private static bool TryInitializeRecord(MemoryRecord value, MemoryRecordDefinition definition) { return value.TrySetDescription(definition.Description) && - value.TrySetAddressExpression(definition.AddressExpression) && - value.TrySetVariableType(definition.VariableType) && - value.TrySetValue(definition.Value); + value.TrySetAddressExpression(definition.AddressExpression) && + value.TrySetVariableType(definition.VariableType) && + value.TrySetValue(definition.Value); } private bool TryCompleteRecordCreation(MemoryRecord value, MemoryRecordDefinition definition, @@ -587,7 +587,7 @@ private static bool TryUpdateRecord(MemoryRecordUpdate update, out MemoryRecordS { record = default; if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(update.Id, out MemoryRecord value)) + !list.TryGetMemoryRecordById(update.Id, out MemoryRecord value)) { return false; } @@ -598,9 +598,9 @@ record = default; private static bool TryApplyUpdate(MemoryRecord value, MemoryRecordUpdate update) { return (update.Description is null || value.TrySetDescription(update.Description)) && - (update.AddressExpression is null || value.TrySetAddressExpression(update.AddressExpression)) && - (!update.VariableType.HasValue || value.TrySetVariableType(update.VariableType.Value)) && - (update.Value is null || value.TrySetValue(update.Value)); + (update.AddressExpression is null || value.TrySetAddressExpression(update.AddressExpression)) && + (!update.VariableType.HasValue || value.TrySetVariableType(update.VariableType.Value)) && + (update.Value is null || value.TrySetValue(update.Value)); } private bool TryAuthorize(string path, string operation, out CheatEngineFailure failure) @@ -626,19 +626,22 @@ private bool TryAuthorize(string path, string operation, out CheatEngineFailure internal static bool TrySnapshot(MemoryRecord value, out MemoryRecordSnapshot snapshot) { if (!value.TryGetId(out MemoryRecordId id) || !value.TryGetIndex(out int index) || - !value.TryGetDescription(out string? description) || - !value.TryGetAddressExpression(out string? expression) || - !value.TryGetValue(out string? text) || !value.TryGetVariableType(out VariableType variableType) || - !value.Handle.TryGetProperty("Active"u8, out bool isActive) || - !value.Handle.TryGetProperty("Count"u8, out int childCount) || childCount < 0) + !value.TryGetDescription(out string? description) || + !value.TryGetAddressExpression(out string? expression) || + !value.TryGetValue(out string? text) || !value.TryGetVariableType(out VariableType variableType) || + !value.Handle.TryGetProperty("Active"u8, out bool isActive) || + !value.Handle.TryGetProperty("Count"u8, out int childCount) || childCount < 0) { snapshot = default; return false; } Address? currentAddress = value.TryGetCurrentAddress(out Address address) ? address : null; - snapshot = new MemoryRecordSnapshot(id, index, description, expression, text, variableType, currentAddress, - isActive, childCount); + snapshot = new MemoryRecordSnapshot( + id, + index, + new MemoryRecordContentSnapshot(description, expression, text, variableType), + new MemoryRecordStateSnapshot(currentAddress, isActive, childCount)); return true; } @@ -651,16 +654,16 @@ private bool TryMutateRecord(string operation, MemoryRecordId id, bool found = false; bool succeeded = false; if (!_dispatcher.TryInvoke(() => - { - if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(id, out MemoryRecord value)) - { - return; - } + { + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(id, out MemoryRecord value)) + { + return; + } - found = true; - succeeded = mutation(list, value) && TrySnapshot(value, out captured); - }, out failure, cancellationToken)) + found = true; + succeeded = mutation(list, value) && TrySnapshot(value, out captured); + }, out failure, cancellationToken)) { record = default; return false; @@ -731,9 +734,9 @@ private static bool TryBuildHierarchy(MemoryRecord value, MemoryRecordHierarchyR { problem = HierarchyBuildProblem.InvalidShape; if (!value.TryGetChild(index, out MemoryRecord child) || - !TryBuildHierarchy(child, request, depth + 1, visited, ref materialized, - out MemoryRecordHierarchySnapshot childSnapshot, - out problem)) + !TryBuildHierarchy(child, request, depth + 1, visited, ref materialized, + out MemoryRecordHierarchySnapshot childSnapshot, + out problem)) { return false; } @@ -749,17 +752,17 @@ private static bool TryBuildHierarchy(MemoryRecord value, MemoryRecordHierarchyR private static bool Matches(MemoryRecordSearch search, MemoryRecordSnapshot record) { return (search.DescriptionContains is null || record.Description.Contains(search.DescriptionContains, - StringComparison.OrdinalIgnoreCase)) && - (search.AddressExpression is null || string.Equals(record.AddressExpression, search.AddressExpression, - StringComparison.OrdinalIgnoreCase)) && - (!search.VariableType.HasValue || record.VariableType == search.VariableType.Value) && - (!search.IsActive.HasValue || record.IsActive == search.IsActive.Value); + StringComparison.OrdinalIgnoreCase)) && + (search.AddressExpression is null || string.Equals(record.AddressExpression, search.AddressExpression, + StringComparison.OrdinalIgnoreCase)) && + (!search.VariableType.HasValue || record.VariableType == search.VariableType.Value) && + (!search.IsActive.HasValue || record.IsActive == search.IsActive.Value); } private static bool HasPredicate(MemoryRecordSearch search) { return search.DescriptionContains is not null || search.AddressExpression is not null || - search.VariableType.HasValue || search.IsActive.HasValue; + search.VariableType.HasValue || search.IsActive.HasValue; } private static bool CaptureTable(int count, out AddressTableSnapshot table) diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs index bbbe1de..6724147 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs @@ -15,7 +15,10 @@ public sealed class TableClientMutationTests [Fact] public void TryDeleteDispatchesTheRequestedRecordAndReturnsSuccess() { - FakeRecordMutationPort mutations = new() { DeleteStatus = TableRecordMutationStatus.Success }; + FakeRecordMutationPort mutations = new() + { + DeleteStatus = TableRecordMutationStatus.Success + }; TableClient client = CreateClient(mutations); bool succeeded = @@ -65,7 +68,8 @@ public void TrySetParentPassesBothIdsAndReturnsTheCopiedPostMutationSnapshot() MemoryRecordSnapshot expected = Snapshot(41, "Ammo"); FakeRecordMutationPort mutations = new() { - SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = expected + SetParentStatus = TableRecordMutationStatus.Success, + SetParentRecord = expected }; TableClient client = CreateClient(mutations); @@ -85,7 +89,8 @@ public void TrySetParentWithNullParentRequestsTheAddressListRoot() { FakeRecordMutationPort mutations = new() { - SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = Snapshot(41, "Ammo") + SetParentStatus = TableRecordMutationStatus.Success, + SetParentRecord = Snapshot(41, "Ammo") }; TableClient client = CreateClient(mutations); @@ -194,8 +199,11 @@ private static TableClient CreateClient(FakeRecordMutationPort mutations) private static MemoryRecordSnapshot Snapshot(int id, string description) { - return new MemoryRecordSnapshot(new MemoryRecordId(id), 0, description, "game.exe+24", "50", - VariableType.Dword, null); + return new MemoryRecordSnapshot( + new MemoryRecordId(id), + 0, + new MemoryRecordContentSnapshot(description, "game.exe+24", "50", VariableType.Dword), + new MemoryRecordStateSnapshot(null)); } private sealed class FakeRecordMutationPort : ITableRecordMutationPort From 7966554fa8a546bf3522e9d9e536a7de0ad4fe8c Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 22:10:21 +0200 Subject: [PATCH 06/17] fix: harden Core review findings Resolve the actionable CodeRabbit findings in the activation-bound Core layer without expanding its public surface.\n\nDisambiguate a root MemoryRecord from a protected Lua parent-read failure before hierarchy validation, and end that raw Lua operation before the ID lookup opens another SDK operation. Harden symbol-name reservation cleanup against a successful lease disposal releasing the same name twice, and make cleanup-scope entry atomic with overflow rollback.\n\nClarify rejected parent-relationship diagnostics, cover target-independent resource survival, correct the package graph documentation, and add focused table/registry regressions. Align LuaClient with the simplified TResult-inferred ILua contract while retaining internal forwarding overloads until the stacked Foundation update is synchronized.\n\nValidation:\n- dotnet build tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj --configuration Release --no-restore --warnaserror\n- dotnet test --project tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj --configuration Release --no-build --no-restore --fail-skips on --report-trx --results-directory artifacts/test-results/pr2-core\n- 80 passed, 0 failed, 0 skipped --- .../Domains/InspectionClient.cs | 8 +++- .../Domains/LuaClient.cs | 25 ++++++++---- .../Domains/SdkTableRecordMutationPort.cs | 39 ++++++++++++++++++- .../Domains/TableClient.cs | 3 +- .../Infrastructure/CoreLifetime.cs | 6 ++- libs/CheatEngine.Client.Core/README.md | 2 +- .../Domains/SymbolRegistrationLeaseTests.cs | 2 +- .../Domains/TableClientMutationTests.cs | 34 ++++++++++++++++ .../CoreResourceRegistryTests.cs | 6 ++- .../Lua/LuaClientTests.cs | 12 +++--- 10 files changed, 114 insertions(+), 23 deletions(-) diff --git a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs index 0fd41aa..9cf37aa 100644 --- a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs @@ -264,10 +264,14 @@ public bool TryRegisterSymbol(SymbolRegistration registration, } catch { - // The original lifecycle failure is the meaningful result. The registration name was still released below. + // The original lifecycle failure is the meaningful result. Release the name below only if disposal did not. + } + + if (!created.IsReleased) + { + ReleaseSymbolName(registration.Name); } - ReleaseSymbolName(registration.Name); failure = CoreFailureFactory.FromException("Inspection.RegisterSymbol", exception); return false; } diff --git a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs index 6d5cd62..818f037 100644 --- a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs @@ -135,9 +135,8 @@ public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken ca throw new InvalidOperationException("Unreachable failure flow."); } - public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, + public bool TryExecute(ILuaOperation operation, [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default) - where TOperation : ILuaOperation { ArgumentNullException.ThrowIfNull(operation); if (cancellationToken.IsCancellationRequested) @@ -150,7 +149,7 @@ public bool TryExecute(TOperation operation, [MaybeNullWhen long epoch = _epochProvider(); LuaOperationResult operationResult = default; if (!_dispatcher.TryInvoke( - () => operationResult = ExecuteOperation(operation, epoch), + () => operationResult = ExecuteOperation(operation, epoch), out failure, cancellationToken)) { @@ -175,11 +174,16 @@ public bool TryExecute(TOperation operation, [MaybeNullWhen return true; } - public TResult Execute(TOperation operation, CancellationToken cancellationToken = default) + public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) where TOperation : ILuaOperation { - if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, - cancellationToken)) + return TryExecute(operation, out result, out failure, cancellationToken); + } + + public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) + { + if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, cancellationToken)) { return result; } @@ -187,14 +191,19 @@ public TResult Execute(TOperation operation, CancellationTo return ThrowFailure(failure); } + public TResult Execute(TOperation operation, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation + { + return Execute(operation, cancellationToken); + } + private static T ThrowFailure(CheatEngineFailure failure) { failure.Throw(); throw new UnreachableException(); } - private LuaOperationResult ExecuteOperation(TOperation operation, long epoch) - where TOperation : ILuaOperation + private LuaOperationResult ExecuteOperation(ILuaOperation operation, long epoch) { LuaOperationContext context = new(epoch, _isContextCurrent); try diff --git a/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs index 028232f..435b188 100644 --- a/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs +++ b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs @@ -1,5 +1,7 @@ using CheatEngine.Client.Tables; using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; namespace CheatEngine.Client.Core.Domains; @@ -90,11 +92,17 @@ private static TableRecordMutationStatus ValidateParentRelationship(AddressList private static ParentChainStep GetNextParentChainStep(ref MemoryRecord current) { - if (!current.TryGetParent(out MemoryRecord next)) + ParentReadStatus parentReadStatus = TryReadParent(current, out MemoryRecord next); + if (parentReadStatus == ParentReadStatus.Root) { return ParentChainStep.Root; } + if (parentReadStatus != ParentReadStatus.Parent) + { + return ParentChainStep.HostRejected; + } + current = next; if (!current.TryGetId(out MemoryRecordId nextId)) { @@ -104,6 +112,28 @@ private static ParentChainStep GetNextParentChainStep(ref MemoryRecord current) return ParentChainStep.Parent(nextId); } + private static ParentReadStatus TryReadParent(MemoryRecord current, out MemoryRecord parent) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + if (!current.Handle.TryGetProperty(state, "Parent"u8).IsOk) + { + parent = default; + return ParentReadStatus.HostRejected; + } + + if (state.IsNil(-1)) + { + parent = default; + return ParentReadStatus.Root; + } + + return MemoryRecord.TryRead(state, -1, out parent) + ? ParentReadStatus.Parent + : ParentReadStatus.HostRejected; + } + private static TableRecordMutationStatus TryAssignParent(MemoryRecord child, MemoryRecord parent, out MemoryRecordSnapshot record) { @@ -124,4 +154,11 @@ private static int GetMaximumParentHops(int topLevelCount) ? int.MaxValue : Math.Max(1, topLevelCount) + traversalSlack; } + + private enum ParentReadStatus + { + Root, + Parent, + HostRejected + } } diff --git a/libs/CheatEngine.Client.Core/Domains/TableClient.cs b/libs/CheatEngine.Client.Core/Domains/TableClient.cs index ae9f0b9..d4a3b52 100644 --- a/libs/CheatEngine.Client.Core/Domains/TableClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/TableClient.cs @@ -809,7 +809,8 @@ private static CheatEngineFailure MutationFailure(string operation, TableRecordM operation, "The requested parent Cheat Engine memory record was not found."), TableRecordMutationStatus.InvalidRelationship => new CheatEngineFailure( CheatEngineFailureKind.OperationRejected, operation, - "A memory record cannot be its own parent."), + "The requested parent relationship is invalid: it is self-referential, cyclic, or exceeds the " + + "supported hierarchy depth."), _ => HostFailure(operation) }; } diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs index 2c0e02e..9602554 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs @@ -72,9 +72,11 @@ internal IDisposable EnterCleanupScope() "Cheat Engine cleanup must run on the plugin main thread."); } - checked + int cleanupScopeDepth = Interlocked.Increment(ref _cleanupScopeDepth); + if (cleanupScopeDepth <= 0) { - _cleanupScopeDepth++; + Interlocked.Decrement(ref _cleanupScopeDepth); + throw new InvalidOperationException("The Cheat Engine cleanup scope depth overflowed."); } return new CleanupScope(this); diff --git a/libs/CheatEngine.Client.Core/README.md b/libs/CheatEngine.Client.Core/README.md index 412326f..0dc9ee5 100644 --- a/libs/CheatEngine.Client.Core/README.md +++ b/libs/CheatEngine.Client.Core/README.md @@ -28,7 +28,7 @@ Abstractions ← Core ← DependencyInjection ← Hosting ↑ CheatEngine.SDK -Fluent ← Abstractions +Abstractions ← Fluent ``` ## How It Improves CheatEngine.Client diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs index c57d1d4..33cd842 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs @@ -27,7 +27,7 @@ public void FailedDispatchKeepsTheLeaseTrackedUntilTheCleanupRetryUnregistersIt( _ => events.Add("release-name")); registry.Track(lease); - Assert.Throws(lease.Dispose); + Assert.Throws(lease.Dispose); Assert.False(lease.IsReleased); Assert.Empty(events); diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs index 6724147..daafcdb 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs @@ -116,9 +116,34 @@ public void TrySetParentRejectsARecordAsItsOwnParentBeforeDispatching() Assert.Equal(default, record); Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); Assert.Equal("Tables.SetParent", failure.Operation); + Assert.Equal( + "The requested parent relationship is invalid: it is self-referential, cyclic, or exceeds the " + + "supported hierarchy depth.", + failure.Message); Assert.Equal(0, mutations.SetParentCallCount); } + [Fact] + public void TrySetParentMapsAHostRejectedMutationToTheExactHostFailure() + { + FakeRecordMutationPort mutations = new() + { + SetParentStatus = TableRecordMutationStatus.HostRejected + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(12), + out MemoryRecordSnapshot record, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(new MemoryRecordId(41), mutations.LastChildId); + Assert.Equal(new MemoryRecordId(12), mutations.LastParentId); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Tables.SetParent", failure.Operation); + Assert.Equal("Cheat Engine did not return the expected Address List contract.", failure.Message); + } + [Fact] public void SetParentThrowsAClassifiedMissingParentFailure() { @@ -145,6 +170,15 @@ public void ParentRelationshipGuardPermitsARootTerminatedCandidateChain() Assert.Equal(TableRecordMutationStatus.Success, status); } + [Fact] + public void ParentRelationshipGuardPropagatesARejectedHostRead() + { + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 4, static _ => ParentChainStep.HostRejected); + + Assert.Equal(TableRecordMutationStatus.HostRejected, status); + } + [Fact] public void ParentRelationshipGuardRejectsAnIndirectCycleBackToTheChild() { diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs index 8be73e6..74bd56a 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs @@ -84,10 +84,12 @@ public void DisposeTargetSelectionReleasesOnlyMatchingResourcesInReverseRegistra { List events = new(); RecordingDisposable firstSelectionResource = new("first-selection", events); + RecordingDisposable activationResource = new("activation", events); RecordingDisposable retainedResource = new("retained", events); RecordingDisposable lastSelectionResource = new("last-selection", events); CoreResourceRegistry registry = new(); registry.Track(firstSelectionResource, 4); + registry.Track(activationResource); registry.Track(retainedResource, 5); registry.Track(lastSelectionResource, 4); @@ -95,12 +97,14 @@ public void DisposeTargetSelectionReleasesOnlyMatchingResourcesInReverseRegistra Assert.Equal(["last-selection", "first-selection"], events); Assert.Equal(1, firstSelectionResource.DisposeCount); + Assert.Equal(0, activationResource.DisposeCount); Assert.Equal(0, retainedResource.DisposeCount); Assert.Equal(1, lastSelectionResource.DisposeCount); registry.Dispose(); - Assert.Equal(["last-selection", "first-selection", "retained"], events); + Assert.Equal(["last-selection", "first-selection", "retained", "activation"], events); + Assert.Equal(1, activationResource.DisposeCount); Assert.Equal(1, retainedResource.DisposeCount); } diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs index 786bb13..7c42260 100644 --- a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs @@ -14,7 +14,7 @@ public void TryExecuteProvidesTheCapturedEpochOnlyDuringItsSynchronousOperation( RetainingOperation operation = new(); LuaClient client = new(dispatcher, static () => 42, static () => true); - bool succeeded = client.TryExecute(operation, out int result, + bool succeeded = client.TryExecute(operation, out int result, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -35,7 +35,7 @@ public void TryExecutePreservesAnExpectedOperationFailure() CheatEngineFailure expected = new(CheatEngineFailureKind.LuaError, "Lua.Custom", "The Lua global failed."); LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); - bool succeeded = client.TryExecute(new FailingOperation(expected), out int result, + bool succeeded = client.TryExecute(new FailingOperation(expected), out int result, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); @@ -48,7 +48,7 @@ public void TryExecuteNormalizesAnOperationFailureWithoutDetails() { LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); - bool succeeded = client.TryExecute(new FailingOperation(default), out _, + bool succeeded = client.TryExecute(new FailingOperation(default), out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -65,7 +65,7 @@ public void ExecuteTurnsAnExpectedFailureIntoTheClientException() LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); CheatEngineOperationException exception = Assert.Throws(() => - client.Execute(new FailingOperation(expected), + client.Execute(new FailingOperation(expected), TestContext.Current.CancellationToken)); Assert.Equal(expected, exception.Failure); @@ -78,7 +78,7 @@ public void TryExecuteThrowsActivationExpiredWhenTheContextCannotEnterTheCurrent LuaClient client = new(new ImmediateDispatcher(), static () => 7, static () => false); CheatEngineActivationExpiredException exception = Assert.Throws(() => - client.TryExecute(operation, out _, out _, TestContext.Current.CancellationToken)); + client.TryExecute(operation, out _, out _, TestContext.Current.CancellationToken)); Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); Assert.Equal("Lua.OperationContext", exception.Failure.Operation); @@ -94,7 +94,7 @@ public void TryExecuteHonorsCancellationBeforeTheTypedOperationRuns() RetainingOperation operation = new(); LuaClient client = new(dispatcher, static () => 7, static () => true); - bool succeeded = client.TryExecute( + bool succeeded = client.TryExecute( operation, out _, out CheatEngineFailure failure, From ee0e8b3e83fe7f1ab4b993290e814b8d18234830 Mon Sep 17 00:00:00 2001 From: AriusII Date: Sun, 20 Sep 2026 19:10:17 +0200 Subject: [PATCH 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 16/17] 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 From b50146d6b1f223c5f67579289f2359f937a732de Mon Sep 17 00:00:00 2001 From: AriusII Date: Mon, 21 Sep 2026 00:40:39 +0200 Subject: [PATCH 17/17] Improve client coverage and quality gates --- .github/workflows/ci.yml | 41 ++- .github/workflows/main-ci.yml | 11 + .github/workflows/pull-request-ci.yml | 15 + .github/workflows/sonar.yml | 190 +++++------ CheatEngine.Client.slnx | 3 +- README.md | 68 ++-- .../CheatEngine.Client.Abstractions/README.md | 20 +- .../Runtime/CheatEngineRuntimeSnapshot.cs | 48 +-- .../Runtime/CheatEngineRuntimeVersionInfo.cs | 2 +- .../Scanning/AobPattern.cs | 10 +- .../Tables/MemoryRecordContentSnapshot.cs | 3 +- .../Tables/MemoryRecordSearch.cs | 20 +- .../Tables/MemoryRecordSnapshot.cs | 35 +-- .../CheatEngineClient.cs | 15 - .../CheatEngineClientDomainServices.cs | 18 ++ .../CheatEngineClientRuntimeServices.cs | 9 + .../Dispatching/IMainThreadInvoker.cs | 8 + .../Dispatching/MainThreadInvocationResult.cs | 3 + .../Dispatching/SdkMainThreadDispatcher.cs | 66 ++-- .../Domains/AobScanHostStatus.cs | 9 + .../Domains/IAobMatchList.cs | 11 + .../Domains/IAobScanPort.cs | 15 + .../Domains/IInspectionPort.cs | 29 ++ .../Domains/InspectionClient.cs | 31 +- .../Domains/LuaClient.cs | 18 +- .../Domains/MemoryClient.cs | 4 +- .../Domains/PatternScanner.cs | 99 +++--- .../Domains/RuntimeClient.cs | 13 +- .../Domains/SdkAobScanPort.cs | 49 +++ .../Domains/SdkInspectionPort.cs | 60 ++++ .../Domains/TableClient.cs | 143 ++++----- .../Domains/TableParentRelationshipGuard.cs | 2 +- .../Domains/UnavailableValueScanner.cs | 4 +- .../Infrastructure/CoreClientPolicy.cs | 2 +- .../Infrastructure/CoreLifetime.cs | 29 +- .../Infrastructure/ICoreLifetimeContext.cs | 24 ++ .../CheatEngineClientOptions.cs | 6 +- ...eatEngineClientOptionsSemanticValidator.cs | 3 +- ...EngineClientServiceCollectionExtensions.cs | 12 +- .../README.md | 33 +- .../UnsafeLuaExecutionRegistration.cs | 10 + .../Memory/Memory.cs | 4 +- .../Memory/MemoryAddressBuilder.cs | 20 +- libs/CheatEngine.Client.Fluent/README.md | 8 +- .../CheatEngineClientPlugin.cs | 5 +- libs/CheatEngine.Client.Hosting/README.md | 31 +- src/CheatEngine.Client/README.md | 3 +- .../CheatEngine.Client.Templates/README.md | 3 - .../Inspection/InspectionContractsTests.cs | 2 +- .../Lua/LuaContractTests.cs | 10 +- .../Memory/MemoryBoundedRequestTests.cs | 2 +- .../Processes/ProcessSnapshotTests.cs | 2 +- .../Results/CheatEngineFailureTests.cs | 2 +- .../CheatEngineRuntimeSnapshotTests.cs | 47 +-- .../Runtime/ClientCapabilitiesTests.cs | 2 +- .../Scanning/AobPatternTests.cs | 2 +- .../Scanning/ValueScanContractTests.cs | 2 +- .../Tables/TableContractTests.cs | 26 +- .../Composition/CheatEngineClientTests.cs | 59 ++++ .../SdkMainThreadDispatcherBehaviorTests.cs | 144 +++++++++ .../SdkMainThreadDispatcherTests.cs | 68 ++++ .../Domains/InspectionClientBehaviorTests.cs | 285 +++++++++++++++++ .../Domains/InspectionClientCoverageTests.cs | 28 ++ .../Domains/LocalProcessHostTests.cs | 32 ++ .../Domains/LuaRuntimeProbeTests.cs | 17 + .../MemoryClientBehaviorCoverageTests.cs | 295 ++++++++++++++++++ .../MemoryClientDispatchFailureTests.cs | 270 ++++++++++++++++ .../Domains/MemoryClientTests.cs | 2 +- .../Domains/PatternScannerCoverageTests.cs | 51 +++ .../Domains/ProcessClientTests.cs | 2 +- .../Domains/RuntimeClientTests.cs | 113 ++++++- ...SdkTableRecordMutationPortCoverageTests.cs | 26 ++ .../SdkTableRecordMutationPortTests.cs | 20 ++ ...olRegistrationLeaseCleanupCoverageTests.cs | 81 +++++ .../Domains/SymbolRegistrationLeaseTests.cs | 4 +- .../Domains/TableClientCoverageTests.cs | 272 ++++++++++++++++ .../Domains/TableClientMutationTests.cs | 18 +- ...lableValueScannerExceptionCoverageTests.cs | 24 ++ .../CoreClientPolicyFailureCoverageTests.cs | 78 +++++ .../Infrastructure/CoreFailureFactoryTests.cs | 39 +++ .../CoreLifetimeBehaviorTests.cs | 103 ++++++ .../Infrastructure/CoreLifetimeTests.cs | 18 ++ ...CoreResourceRegistryClosedCoverageTests.cs | 33 ++ .../CoreResourceRegistryTests.cs | 8 +- .../TargetSelectionLifetimeTests.cs | 8 +- .../Lua/LuaClientTests.cs | 10 +- .../Lua/LuaModuleRegistrationTests.cs | 8 +- .../ControlledCoreLifetimeContext.cs | 44 +++ .../TestSupport/InertCoreLifetime.cs | 13 + .../TestSupport/InlineMainThreadInvoker.cs | 31 ++ ...eClientServiceCollectionExtensionsTests.cs | 12 +- .../DefaultMemoryCodecsTests.cs | 22 +- .../Memory/MemoryAddressBuilderTests.cs | 13 +- .../Scanning/AobFluentBuilderTests.cs | 14 +- .../CheatEngineClientPluginTests.cs | 77 +++-- .../ClientActivationLifecycleTests.cs | 8 +- 96 files changed, 3129 insertions(+), 583 deletions(-) create mode 100644 libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs create mode 100644 libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs create mode 100644 libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs create mode 100644 libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs create mode 100644 libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyFailureCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryClosedCoverageTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/TestSupport/ControlledCoreLifetimeContext.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/TestSupport/InlineMainThreadInvoker.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47c1247..5bbd905 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,11 @@ name: Client validation on: workflow_call: + inputs: + collect-coverage: + description: Emit Visual Studio XML coverage for the SonarQube Cloud analysis workflow. + type: boolean + default: false permissions: contents: read @@ -44,9 +49,30 @@ jobs: 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 + env: + COLLECT_COVERAGE: ${{ inputs.collect-coverage }} + run: | + $projects = @(Get-ChildItem -Path tests -Filter '*.Tests.csproj' -Recurse -File | Sort-Object Name) + if ($projects.Count -eq 0) { + throw 'No *.Tests.csproj project found under tests.' + } + + foreach ($project in $projects) { + $results = Join-Path 'artifacts/test-results' $project.BaseName + $options = @( + '--project', $project.FullName, + '--configuration', 'Release', + '--no-build', '--no-restore', + '--report-trx', + '--results-directory', $results, + '--fail-skips', 'on' + ) + if ($env:COLLECT_COVERAGE -eq 'true') { + $options += '--coverage', '--coverage-output-format', 'xml', '--coverage-output', 'coverage.xml' + } + + dotnet test @options + } - name: Summarize test results if: ${{ !cancelled() }} @@ -80,6 +106,15 @@ jobs: if-no-files-found: warn retention-days: 14 + - name: Upload Sonar coverage + if: ${{ inputs.collect-coverage && !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-client + path: artifacts/test-results/*/coverage.xml + if-no-files-found: error + retention-days: 7 + - name: Pack and validate public package APIs run: dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 3d69a2d..c820499 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -32,3 +32,14 @@ jobs: ci: name: CI uses: ./.github/workflows/ci.yml + with: + collect-coverage: ${{ github.ref == 'refs/heads/main' }} + + # Main analysis is the SonarQube Cloud baseline for pull-request new-code comparisons. + sonar: + name: Sonar + needs: ci + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/sonar.yml + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 65a94ba..fb67677 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -30,6 +30,21 @@ jobs: name: CI if: github.event.pull_request.draft == false uses: ./.github/workflows/ci.yml + with: + collect-coverage: >- + ${{ github.event.pull_request.head.repo.full_name == github.repository + && github.actor != 'dependabot[bot]' }} + + # Fork and Dependabot pull requests receive no repository secret. Keep this condition equal to collect-coverage. + sonar: + name: Sonar + needs: ci + if: >- + github.event.pull_request.head.repo.full_name == github.repository + && github.actor != 'dependabot[bot]' + uses: ./.github/workflows/sonar.yml + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} lint-workflows: name: Lint workflows diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 5b34005..57650ee 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,41 +1,24 @@ -name: SonarQube Cloud +name: Sonar 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 + workflow_call: + inputs: + project-key: + description: SonarQube Cloud project key. + type: string + default: CheatEngineNet_CheatEngine.Client + organization: + description: SonarQube Cloud organization key. + type: string + default: cheatenginenet + wait-quality-gate: + description: Fail the analysis job when SonarQube Cloud reports a failing Quality Gate. + type: boolean + default: false + secrets: + SONAR_TOKEN: + description: SonarQube Cloud token. Analysis is skipped when it is unavailable. + required: false permissions: contents: read @@ -52,26 +35,59 @@ env: 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 + name: Analyze + if: github.event_name != 'merge_group' runs-on: windows-latest - timeout-minutes: 35 + timeout-minutes: 30 + env: + SONAR_ENABLED: ${{ secrets.SONAR_TOKEN != '' }} + SONAR_PROJECT_KEY: ${{ inputs.project-key }} + SONAR_ORGANIZATION: ${{ inputs.organization }} + SONAR_WAIT_QUALITY_GATE: ${{ inputs.wait-quality-gate }} steps: - - name: Set up JDK 21 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 - with: - distribution: zulu - java-version: '21' + - name: Skip without token + if: env.SONAR_ENABLED != 'true' + run: >- + Write-Host '::notice title=Sonar analysis skipped::SONAR_TOKEN is empty. Fork and Dependabot pull requests + receive no repository secret; configure SONAR_TOKEN to enable analysis.' + + - name: Check analysis method + if: env.SONAR_ENABLED == 'true' + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + $uri = "https://sonarcloud.io/api/settings/values?component=$env:SONAR_PROJECT_KEY&keys=sonar.autoscan.enabled" + try { + $response = Invoke-RestMethod -Uri $uri -Headers @{ Authorization = "Bearer $env:SONAR_TOKEN" } ` + -TimeoutSec 30 -MaximumRetryCount 2 -RetryIntervalSec 3 + } + catch { + Write-Host "::warning::Could not read the analysis method of $env:SONAR_PROJECT_KEY. $($_.Exception.Message)" + return + } + + if (@($response.settings | Where-Object { $_.key -eq 'sonar.autoscan.enabled' -and $_.value -eq 'true' })) { + Write-Host "::error::Automatic Analysis is enabled on $env:SONAR_PROJECT_KEY, so SonarQube Cloud rejects CI analysis. Turn it off in Administration, Analysis Method." + exit 1 + } - name: Checkout + if: env.SONAR_ENABLED == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + - name: Set up JDK 21 + if: env.SONAR_ENABLED == 'true' + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: zulu + java-version: '21' + - name: Install pinned .NET SDK + if: env.SONAR_ENABLED == 'true' uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json @@ -80,58 +96,54 @@ jobs: **/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: Download coverage + if: env.SONAR_ENABLED == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage-client + path: ${{ runner.temp }}/coverage + + - name: Install scanner + if: env.SONAR_ENABLED == 'true' + run: >- + dotnet tool install dotnet-sonarscanner --tool-path "$env:RUNNER_TEMP/sonar-scanner" + --version $env:SONAR_SCANNER_VERSION - name: Restore locked dependency graph + if: env.SONAR_ENABLED == 'true' run: dotnet restore CheatEngine.Client.slnx --locked-mode - - name: Begin Sonar analysis + - name: Begin analysis + if: env.SONAR_ENABLED == 'true' env: - SONAR_SCANNER: ${{ steps.scanner.outputs.path }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONARQUBE_SCANNER_PARAMS: '{"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) + $coverage = "$env:RUNNER_TEMP/coverage" + $reports = @(Get-ChildItem -Path $coverage -Filter *.xml -Recurse -File -ErrorAction SilentlyContinue) if ($reports.Count -eq 0) { - throw 'Microsoft Testing Platform did not produce a Cobertura coverage report.' + Write-Host '::error::No coverage report was downloaded. Call ci.yml with collect-coverage set to true.' + exit 1 } - $reports | ForEach-Object { Write-Host "Coverage report: $($_.FullName)" } + Write-Host "Coverage reports: $($reports.Count)" + $arguments = @( + "/k:$env:SONAR_PROJECT_KEY", + "/o:$env:SONAR_ORGANIZATION", + '/d:sonar.exclusions=artifacts/**', + '/d:sonar.coverage.exclusions=tests/**', + "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml", + "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" + ) + & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" begin @arguments + + - name: Build + if: env.SONAR_ENABLED == 'true' + run: >- + dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --no-incremental + --disable-build-servers --warnaserror - - name: End Sonar analysis + - name: End analysis + if: env.SONAR_ENABLED == 'true' 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 + SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + run: '& "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" end' diff --git a/CheatEngine.Client.slnx b/CheatEngine.Client.slnx index 99628c9..710e369 100644 --- a/CheatEngine.Client.slnx +++ b/CheatEngine.Client.slnx @@ -29,7 +29,8 @@ - + diff --git a/README.md b/README.md index 1a7b6a4..4a6b39e 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,13 @@ native bridge, host object model, and compile-time plugin/Lua diagnostics. Those 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 | +| 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 @@ -53,13 +53,13 @@ surface reviewable: high-level APIs remain fluent for consumers while the Core r ## 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)` | +| 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. @@ -190,15 +190,15 @@ CheatEngine.Client public contracts ────────────────────────────> stable SDK value/runtime types only ``` -| 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 | 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`. @@ -208,17 +208,17 @@ Package and assembly names describe delivery, not user code. Consumer-facing API 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 | +| 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 diff --git a/libs/CheatEngine.Client.Abstractions/README.md b/libs/CheatEngine.Client.Abstractions/README.md index fa5b960..499cbcc 100644 --- a/libs/CheatEngine.Client.Abstractions/README.md +++ b/libs/CheatEngine.Client.Abstractions/README.md @@ -49,16 +49,16 @@ CheatEngine.Client.Abstractions Package and assembly names are not consumer namespaces. Public code belongs to functional namespaces only: -| Namespace | Responsibility | -|---|---| -| `CheatEngine.Client` | `ICheatEngineClient`, the activation-scoped facade | -| `.Dispatching` / `.Runtime` | main-thread dispatch and runtime/capability observations | -| `.Processes` / `.Inspection` | target selection, copied process/module/region/symbol data | -| `.Memory` | bounded primitive, byte, string, codec, and pointer-chain operations | -| `.Scanning` | AOB contracts and the value-scan session contract | -| `.Tables` | copied Address List records and explicitly trusted table I/O requests | -| `.Lua` / `.Modules` | typed protected Lua operations, explicit modules, and leases | -| `.Results` | classified expected failures and lifecycle exceptions | +| Namespace | Responsibility | +|------------------------------|-----------------------------------------------------------------------| +| `CheatEngine.Client` | `ICheatEngineClient`, the activation-scoped facade | +| `.Dispatching` / `.Runtime` | main-thread dispatch and runtime/capability observations | +| `.Processes` / `.Inspection` | target selection, copied process/module/region/symbol data | +| `.Memory` | bounded primitive, byte, string, codec, and pointer-chain operations | +| `.Scanning` | AOB contracts and the value-scan session contract | +| `.Tables` | copied Address List records and explicitly trusted table I/O requests | +| `.Lua` / `.Modules` | typed protected Lua operations, explicit modules, and leases | +| `.Results` | classified expected failures and lifecycle exceptions | No public consumer should use `CheatEngine.Client.Abstractions` as a namespace. diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs index 30e6f6b..0995fee 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs @@ -53,52 +53,28 @@ public CheatEngineRuntimePlatformInfo Platform } /// Gets the coarse number returned by CE's getCEVersion global, when it was callable. - public double? ObservedCheatEngineVersion - { - get => Version.ObservedCheatEngineVersion; - } + public double? ObservedCheatEngineVersion => Version.ObservedCheatEngineVersion; /// Gets the complete CE build against which this Client release was qualified. - public CheatEngineVersion QualifiedCheatEngineBaseline - { - get => Version.QualifiedCheatEngineBaseline; - } + public CheatEngineVersion QualifiedCheatEngineBaseline => Version.QualifiedCheatEngineBaseline; /// Gets the assembly version of this Client abstraction assembly. - public Version ClientAssemblyVersion - { - get => Version.ClientAssemblyVersion; - } + public Version ClientAssemblyVersion => Version.ClientAssemblyVersion; /// Gets the assembly version of the SDK runtime-contract assembly. - public Version SdkAssemblyVersion - { - get => Version.SdkAssemblyVersion; - } + public Version SdkAssemblyVersion => Version.SdkAssemblyVersion; /// Gets the CE host architecture observed from CE's system-architecture global. - public CheatEngineArchitecture SystemArchitecture - { - get => Platform.SystemArchitecture; - } + public CheatEngineArchitecture SystemArchitecture => Platform.SystemArchitecture; /// Gets the target architecture observed by a target-specific probe, or unknown. - public CheatEngineArchitecture TargetArchitecture - { - get => Platform.TargetArchitecture; - } + public CheatEngineArchitecture TargetArchitecture => Platform.TargetArchitecture; /// Gets the pointer width implied by the observed target architecture, or unknown. - public PointerSize TargetPointerSize - { - get => Platform.TargetPointerSize; - } + public PointerSize TargetPointerSize => Platform.TargetPointerSize; /// Gets the target ABI observed from CE's ABI global, or unknown. - public TargetAbi TargetAbi - { - get => Platform.TargetAbi; - } + public TargetAbi TargetAbi => Platform.TargetAbi; /// Gets the explicit availability observation for each SDK runtime capability that was probed. public RuntimeCapabilities SdkCapabilities @@ -114,8 +90,8 @@ public ClientCapabilities ClientCapabilities /// Gets whether the observed coarse CE version belongs to the qualified major/minor line. public bool IsOnQualifiedCheatEngineLine => ObservedCheatEngineVersion is { } observed && - observed >= QualifiedCheatEngineBaseline.Major + - QualifiedCheatEngineBaseline.Minor / 10d && - observed < QualifiedCheatEngineBaseline.Major + - (QualifiedCheatEngineBaseline.Minor + 1) / 10d; + observed >= QualifiedCheatEngineBaseline.Major + + QualifiedCheatEngineBaseline.Minor / 10d && + observed < QualifiedCheatEngineBaseline.Major + + (QualifiedCheatEngineBaseline.Minor + 1) / 10d; } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs index f879e54..66c1791 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs @@ -13,7 +13,7 @@ public CheatEngineRuntimeVersionInfo( Version sdkAssemblyVersion) { if (observedCheatEngineVersion is { } observed && - (!double.IsFinite(observed) || observed < 0)) + (!double.IsFinite(observed) || observed < 0)) { throw new ArgumentOutOfRangeException(nameof(observedCheatEngineVersion), observed, "The observed Cheat Engine version must be a finite non-negative number when supplied."); diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs index aa3e46c..c8ac7c8 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs @@ -42,10 +42,16 @@ private AobPattern(string normalized, int byteLength) } /// Gets the normalized Cheat Engine pattern text. - public string Value { get; } + public string Value + { + get; + } /// Gets the number of byte positions represented by the pattern. - public int ByteLength { get; } + public int ByteLength + { + get; + } /// Gets whether every byte position is a wildcard. public bool IsWildcardOnly diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs index 7c59637..afced03 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs @@ -6,7 +6,8 @@ namespace CheatEngine.Client.Tables; public readonly record struct MemoryRecordContentSnapshot { /// Creates copied content fields for a memory-record snapshot. - public MemoryRecordContentSnapshot(string description, string addressExpression, string value, VariableType variableType) + public MemoryRecordContentSnapshot(string description, string addressExpression, string value, + VariableType variableType) { ArgumentNullException.ThrowIfNull(description); ArgumentNullException.ThrowIfNull(addressExpression); diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs index 6e9c5b9..a21e300 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs @@ -37,14 +37,26 @@ public MemoryRecordSearch( } /// Gets the case-insensitive description substring predicate. - public string? DescriptionContains { get; } + public string? DescriptionContains + { + get; + } /// Gets the case-insensitive exact address-expression predicate. - public string? AddressExpression { get; } + public string? AddressExpression + { + get; + } /// Gets the optional exact Cheat Engine value-type predicate. - public VariableType? VariableType { get; } + public VariableType? VariableType + { + get; + } /// Gets the optional active/frozen state predicate. - public bool? IsActive { get; } + public bool? IsActive + { + get; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs index 2d0b6f7..e9ca221 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs @@ -53,44 +53,23 @@ public MemoryRecordStateSnapshot State } /// Gets the record display description. - public string Description - { - get => Content.Description; - } + public string Description => Content.Description; /// Gets the record's unresolved Cheat Engine address expression. - public string AddressExpression - { - get => Content.AddressExpression; - } + public string AddressExpression => Content.AddressExpression; /// Gets the record's verbatim value text. - public string Value - { - get => Content.Value; - } + public string Value => Content.Value; /// Gets the record's Cheat Engine value type. - public VariableType VariableType - { - get => Content.VariableType; - } + public VariableType VariableType => Content.VariableType; /// Gets the currently resolved target address when it could be obtained. - public Address? CurrentAddress - { - get => State.CurrentAddress; - } + public Address? CurrentAddress => State.CurrentAddress; /// Gets whether Cheat Engine reports this record as active or frozen. - public bool IsActive - { - get => State.IsActive; - } + public bool IsActive => State.IsActive; /// Gets the number of immediate child records reported by Cheat Engine. - public int ChildCount - { - get => State.ChildCount; - } + public int ChildCount => State.ChildCount; } diff --git a/libs/CheatEngine.Client.Core/CheatEngineClient.cs b/libs/CheatEngine.Client.Core/CheatEngineClient.cs index 8c33dfd..4ce4fc2 100644 --- a/libs/CheatEngine.Client.Core/CheatEngineClient.cs +++ b/libs/CheatEngine.Client.Core/CheatEngineClient.cs @@ -82,18 +82,3 @@ public ILuaClient Lua get; } } - -/// Internal grouping of the façade services that describe the active runtime and its dispatch boundary. -internal sealed record CheatEngineClientRuntimeServices( - ICheatEngineRuntime Runtime, - ICheatEngineDispatcher Dispatcher); - -/// Internal grouping of the independently consumable high-level Client domains. -internal sealed record CheatEngineClientDomainServices( - IProcessClient Processes, - IMemoryClient Memory, - IPatternScanner Patterns, - IValueScanner Scans, - IInspectionClient Inspection, - ITableClient Tables, - ILuaClient Lua); diff --git a/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs b/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs new file mode 100644 index 0000000..7ce2156 --- /dev/null +++ b/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs @@ -0,0 +1,18 @@ +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +namespace CheatEngine.Client.Core; + +/// Internal grouping of the independently consumable high-level Client domains. +internal sealed record CheatEngineClientDomainServices( + IProcessClient Processes, + IMemoryClient Memory, + IPatternScanner Patterns, + IValueScanner Scans, + IInspectionClient Inspection, + ITableClient Tables, + ILuaClient Lua); diff --git a/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs b/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs new file mode 100644 index 0000000..18bdb18 --- /dev/null +++ b/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs @@ -0,0 +1,9 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Runtime; + +namespace CheatEngine.Client.Core; + +/// Internal grouping of the façade services that describe the active runtime and its dispatch boundary. +internal sealed record CheatEngineClientRuntimeServices( + ICheatEngineRuntime Runtime, + ICheatEngineDispatcher Dispatcher); diff --git a/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs b/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs new file mode 100644 index 0000000..db33040 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs @@ -0,0 +1,8 @@ +namespace CheatEngine.Client.Core.Dispatching; + +internal interface IMainThreadInvoker +{ + public Exception? Invoke(Action callback); + + public MainThreadInvocationResult Invoke(Func callback); +} diff --git a/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs b/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs new file mode 100644 index 0000000..dd31ef8 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs @@ -0,0 +1,3 @@ +namespace CheatEngine.Client.Core.Dispatching; + +internal readonly record struct MainThreadInvocationResult(T Result, Exception? Exception); diff --git a/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs index d082c38..b4395f5 100644 --- a/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs +++ b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs @@ -10,11 +10,23 @@ namespace CheatEngine.Client.Core.Dispatching; /// Adapts the SDK's synchronous main-thread dispatcher without retaining Lua state. -internal sealed class SdkMainThreadDispatcher(CoreLifetime lifetime) : ICheatEngineDispatcher +internal sealed class SdkMainThreadDispatcher : ICheatEngineDispatcher { - private const string InvokeOperation = "Dispatcher.Invoke"; + private const string _invokeOperation = "Dispatcher.Invoke"; - private readonly CoreLifetime _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + private readonly CoreLifetime _lifetime; + private readonly IMainThreadInvoker _mainThread; + + internal SdkMainThreadDispatcher(CoreLifetime lifetime) + : this(lifetime, SdkMainThreadInvoker.Instance) + { + } + + internal SdkMainThreadDispatcher(CoreLifetime lifetime, IMainThreadInvoker mainThread) + { + _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + _mainThread = mainThread ?? throw new ArgumentNullException(nameof(mainThread)); + } public bool IsMainThread => _lifetime.CanDispatch && MainThread.IsMainThread; @@ -22,17 +34,17 @@ public bool TryInvoke(Action callback, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(callback); - _lifetime.ThrowIfDispatchAllowed(InvokeOperation); + _lifetime.ThrowIfDispatchAllowed(_invokeOperation); if (cancellationToken.IsCancellationRequested) { - failure = CoreFailureFactory.Cancelled(InvokeOperation); + failure = CoreFailureFactory.Cancelled(_invokeOperation); return false; } Exception? callbackException; try { - callbackException = MainThread.Invoke(static action => InvokeCallback(action), callback); + callbackException = _mainThread.Invoke(callback); } catch (CheatEngineActivationExpiredException) { @@ -40,12 +52,12 @@ public bool TryInvoke(Action callback, out CheatEngineFailure failure, } catch (Exception exception) when (!_lifetime.IsActivationCurrent) { - throw new CheatEngineActivationExpiredException(InvokeOperation, + throw new CheatEngineActivationExpiredException(_invokeOperation, "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); } catch (Exception exception) { - failure = CoreFailureFactory.FromException(InvokeOperation, exception); + failure = CoreFailureFactory.FromException(_invokeOperation, exception); return false; } @@ -62,18 +74,18 @@ public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(callback); - _lifetime.ThrowIfDispatchAllowed(InvokeOperation); + _lifetime.ThrowIfDispatchAllowed(_invokeOperation); if (cancellationToken.IsCancellationRequested) { result = default; - failure = CoreFailureFactory.Cancelled(InvokeOperation); + failure = CoreFailureFactory.Cancelled(_invokeOperation); return false; } - CallbackResult callbackResult; + MainThreadInvocationResult callbackResult; try { - callbackResult = MainThread.Invoke(static function => InvokeCallback(function), callback); + callbackResult = _mainThread.Invoke(callback); } catch (CheatEngineActivationExpiredException) { @@ -82,13 +94,13 @@ public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, catch (Exception exception) when (!_lifetime.IsActivationCurrent) { result = default; - throw new CheatEngineActivationExpiredException(InvokeOperation, + throw new CheatEngineActivationExpiredException(_invokeOperation, "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); } catch (Exception exception) { result = default; - failure = CoreFailureFactory.FromException(InvokeOperation, exception); + failure = CoreFailureFactory.FromException(_invokeOperation, exception); return false; } @@ -112,7 +124,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul public T Invoke(Func callback, CancellationToken cancellationToken = default) { - if (TryInvoke(callback, out var result, out CheatEngineFailure failure, cancellationToken)) + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) { return result; } @@ -145,17 +157,33 @@ private static void RethrowCallback(Exception callbackException) } } - private static CallbackResult InvokeCallback(Func callback) + private static MainThreadInvocationResult InvokeCallback(Func callback) { try { - return new CallbackResult(callback(), null); + return new MainThreadInvocationResult(callback(), null); } catch (Exception exception) { - return new CallbackResult(default!, exception); + return new MainThreadInvocationResult(default!, exception); } } - private readonly record struct CallbackResult(T Result, Exception? Exception); + private sealed class SdkMainThreadInvoker : IMainThreadInvoker + { + internal static SdkMainThreadInvoker Instance + { + get; + } = new(); + + public Exception? Invoke(Action callback) + { + return MainThread.Invoke(static action => InvokeCallback(action), callback); + } + + public MainThreadInvocationResult Invoke(Func callback) + { + return MainThread.Invoke(static function => InvokeCallback(function), callback); + } + } } diff --git a/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs b/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs new file mode 100644 index 0000000..ef36dd5 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs @@ -0,0 +1,9 @@ +namespace CheatEngine.Client.Core.Domains; + +/// Classifies the SDK AOB result-list boundary without exposing its ownership handle. +internal enum AobScanHostStatus +{ + Success, + Rejected, + InvalidResult +} diff --git a/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs b/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs new file mode 100644 index 0000000..d0d3992 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal, activation-thread-owned view of an SDK AOB result list. +internal interface IAobMatchList : IDisposable +{ + public bool TryGetCount(out int count); + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs b/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs new file mode 100644 index 0000000..3cf27a5 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal adapter boundary that copies SDK-owned AOB results before Client materializes them. +internal interface IAobScanPort +{ + public AobScanHostStatus TryScan(string pattern, AobScanOptions options, + [NotNullWhen(true)] out IAobMatchList? matches); + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs b/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs new file mode 100644 index 0000000..f40f6ac --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs @@ -0,0 +1,29 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal adapter boundary for the protected SDK inspection primitives. +internal interface IInspectionPort +{ + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written); + + public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] destination, out int written); + + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, out int written); + + public InspectionStatus EnumerateMemoryRegions(MemoryRegionInfo[] destination, out int written); + + public InspectionStatus GetMemoryRegion(Address address, out MemoryRegionInfo region); + + public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol); + + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + out Address address); + + public bool TryResolveName(nuint address, out string? name); + + public void RegisterSymbol(string name, nuint address, bool doNotSave); + + public void UnregisterSymbol(string name); +} diff --git a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs index 9cf37aa..d09e9a2 100644 --- a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs @@ -10,14 +10,19 @@ namespace CheatEngine.Client.Core.Domains; -internal sealed class InspectionClient(SdkMainThreadDispatcher dispatcher, CoreLifetime lifetime) : IInspectionClient +internal sealed class InspectionClient( + SdkMainThreadDispatcher dispatcher, + CoreLifetime lifetime, + IInspectionPort? inspection = null) : IInspectionClient { private readonly SdkMainThreadDispatcher _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + private readonly IInspectionPort _inspection = inspection ?? new SdkInspectionPort(); + private readonly CoreLifetime _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); private readonly HashSet _registeredSymbolNames = new(StringComparer.Ordinal); - private readonly object _registeredSymbolNamesLock = new(); + private readonly Lock _registeredSymbolNamesLock = new(); public bool TryGetModules(InspectionCollectionRequest request, out ImmutableArray modules, out CheatEngineFailure failure, TargetProcessId? processId = null, @@ -29,8 +34,8 @@ public bool TryGetModules(InspectionCollectionRequest request, out ImmutableArra { ModuleInfo[] buffer = new ModuleInfo[request.MaximumItems]; status = processId.HasValue - ? EngineInspection.EnumerateModules(processId.Value, buffer, out int written) - : EngineInspection.EnumerateModules(buffer, out written); + ? _inspection.EnumerateModules(processId.Value, buffer, out int written) + : _inspection.EnumerateModules(buffer, out written); if (status == InspectionStatus.Success) { result = ImmutableArray.Create(buffer, 0, written); @@ -67,7 +72,7 @@ public bool TryGetModuleSections(ModuleName moduleName, InspectionCollectionRequ if (!_dispatcher.TryInvoke(() => { ModuleSectionInfo[] buffer = new ModuleSectionInfo[request.MaximumItems]; - status = EngineInspection.EnumerateSections(moduleName, buffer, out int written); + status = _inspection.EnumerateSections(moduleName, buffer, out int written); if (status == InspectionStatus.Success) { result = ImmutableArray.Create(buffer, 0, written); @@ -99,12 +104,12 @@ public bool TryGetMemoryRegions(InspectionCollectionRequest request, out ImmutableArray regions, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - ImmutableArray result = ImmutableArray.Empty; + ImmutableArray result = []; InspectionStatus status = InspectionStatus.InvalidResult; if (!_dispatcher.TryInvoke(() => { MemoryRegionInfo[] buffer = new MemoryRegionInfo[request.MaximumItems]; - status = EngineInspection.EnumerateMemoryRegions(buffer, out int written); + status = _inspection.EnumerateMemoryRegions(buffer, out int written); if (status == InspectionStatus.Success) { result = ImmutableArray.Create(buffer, 0, written); @@ -137,7 +142,7 @@ public bool TryGetMemoryRegion(Address address, out MemoryRegionInfo region, { MemoryRegionInfo captured = default; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => status = EngineInspection.GetMemoryRegionInfo(address, out captured), + if (!_dispatcher.TryInvoke(() => status = _inspection.GetMemoryRegion(address, out captured), out failure, cancellationToken)) { region = default; @@ -164,7 +169,7 @@ public bool TryGetSymbol(SymbolExpression expression, out SymbolInfo symbol, { SymbolInfo captured = default; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => status = EngineInspection.GetSymbolInfo(expression, out captured), + if (!_dispatcher.TryInvoke(() => status = _inspection.GetSymbol(expression, out captured), out failure, cancellationToken)) { symbol = default; @@ -192,7 +197,7 @@ public bool TryResolveName(Address address, [NotNullWhen(true)] out string? name string? captured = null; bool succeeded = false; if (!_dispatcher.TryInvoke(() => - succeeded = ClientLuaGlobals.TryGetNameFromAddress(ToNativeAddress(address), out captured), + succeeded = _inspection.TryResolveName(ToNativeAddress(address), out captured), out failure, cancellationToken)) { @@ -236,7 +241,7 @@ public bool TryRegisterSymbol(SymbolRegistration registration, return false; } - if (!_dispatcher.TryInvoke(() => ClientLuaGlobals.RegisterSymbol(registration.Name, + if (!_dispatcher.TryInvoke(() => _inspection.RegisterSymbol(registration.Name, ToNativeAddress(registration.Address), registration.DoNotSave), out failure, cancellationToken)) { ReleaseSymbolName(registration.Name); @@ -247,7 +252,7 @@ public bool TryRegisterSymbol(SymbolRegistration registration, registration, _dispatcher, lease => _lifetime.Untrack(lease), - ClientLuaGlobals.UnregisterSymbol, + _inspection.UnregisterSymbol, ReleaseSymbolName); try { @@ -295,7 +300,7 @@ public bool TryResolveAddress(SymbolExpression expression, AddressResolutionOpti { Address captured = Address.Zero; InspectionStatus status = InspectionStatus.InvalidResult; - if (!_dispatcher.TryInvoke(() => status = EngineInspection.ResolveAddress(expression, options, out captured), + if (!_dispatcher.TryInvoke(() => status = _inspection.ResolveAddress(expression, options, out captured), out failure, cancellationToken)) { address = default; diff --git a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs index 818f037..047b2ad 100644 --- a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs @@ -15,7 +15,7 @@ internal sealed class LuaClient : ILuaClient private readonly Func _epochProvider; private readonly Func _isContextCurrent; private readonly HashSet _registeredModules = new(ReferenceEqualityComparer.Instance); - private readonly object _registeredModulesLock = new(); + private readonly Lock _registeredModulesLock = new(); private readonly Action _trackLease; private readonly Action _untrackLease; @@ -174,13 +174,6 @@ public bool TryExecute(ILuaOperation operation, [MaybeNullWhen return true; } - public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, - out CheatEngineFailure failure, CancellationToken cancellationToken = default) - where TOperation : ILuaOperation - { - return TryExecute(operation, out result, out failure, cancellationToken); - } - public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) { if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, cancellationToken)) @@ -191,10 +184,17 @@ public TResult Execute(ILuaOperation operation, CancellationTo return ThrowFailure(failure); } + public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation + { + return TryExecute(operation, out result, out failure, cancellationToken); + } + public TResult Execute(TOperation operation, CancellationToken cancellationToken = default) where TOperation : ILuaOperation { - return Execute(operation, cancellationToken); + return Execute(operation, cancellationToken); } private static T ThrowFailure(CheatEngineFailure failure) diff --git a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs index 76b7fbf..fa2d872 100644 --- a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs @@ -45,7 +45,7 @@ public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T va public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) { - if (TryReadPrimitive(address, out T? value, out var failure, cancellationToken)) + if (TryReadPrimitive(address, out T? value, out CheatEngineFailure failure, cancellationToken)) { return value!; } @@ -117,7 +117,7 @@ public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out public T Read(MemoryReadRequest request, CancellationToken cancellationToken = default) { - if (TryRead(request, out T? value, out var failure, cancellationToken)) + if (TryRead(request, out T? value, out CheatEngineFailure failure, cancellationToken)) { return value; } diff --git a/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs b/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs index ed5d792..4649dae 100644 --- a/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs +++ b/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs @@ -4,21 +4,22 @@ using CheatEngine.Client.Results; using CheatEngine.Client.Scanning; using CheatEngine.SDK.Engine.Inspection; -using CheatEngine.SDK.Engine.Objects; -using CheatEngine.SDK.Engine.Scanning.Aob; using CheatEngine.SDK.Engine.Values; namespace CheatEngine.Client.Core.Domains; -internal sealed class PatternScanner(SdkMainThreadDispatcher dispatcher) : IPatternScanner +internal sealed class PatternScanner(SdkMainThreadDispatcher dispatcher, IAobScanPort? scanPort = null) + : IPatternScanner { - private const int MaximumModuleSnapshot = 4096; - private const string InModuleOperation = "Patterns.InModule"; - private const string ScanOperation = "Patterns.Scan"; + private const int _maximumModuleSnapshot = 4096; + private const string _inModuleOperation = "Patterns.InModule"; + private const string _scanOperation = "Patterns.Scan"; private readonly SdkMainThreadDispatcher _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + private readonly IAobScanPort _scanPort = scanPort ?? new SdkAobScanPort(); + public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -61,23 +62,34 @@ public AobScanResult Scan(AobScanRequest request, CancellationToken cancellation return default; } - private static bool TryScanCore(AobScanRequest request, out AobScanResult result, + private bool TryScanCore(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure) { - if (!AobScanner.TryScan(request.Pattern.Value, request.Options, out Owned? owner)) + AobScanHostStatus status = + _scanPort.TryScan(request.Pattern.Value, request.Options, out IAobMatchList? matchList); + if (status == AobScanHostStatus.Rejected) { result = default; - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, "Cheat Engine did not return an AOB result list."); return false; } - using (owner) + if (status != AobScanHostStatus.Success || matchList is null) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, + "Cheat Engine returned an invalid AOB result list."); + return false; + } + + using (matchList) { - StringList list = owner.Value; - if (!TryGetResultCount(list, out int count, out failure)) + if (!matchList.TryGetCount(out int count) || count < 0) { result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, + "Cheat Engine returned an invalid AOB result count."); return false; } @@ -91,25 +103,13 @@ private static bool TryScanCore(AobScanRequest request, out AobScanResult result return false; } - return TryMaterializeMatches(list, count, request, hasModuleRange, moduleRange, out result, out failure); + return TryMaterializeMatches(matchList, count, request, hasModuleRange, moduleRange, out result, + out failure); } } - private static bool TryGetResultCount(StringList list, out int count, out CheatEngineFailure failure) - { - if (list.TryGetCount(out count) && count >= 0) - { - failure = default; - return true; - } - - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ScanOperation, - "Cheat Engine returned an invalid AOB result count."); - return false; - } - private static bool TryMaterializeMatches( - StringList list, + IAobMatchList matches, int count, AobScanRequest request, bool hasModuleRange, @@ -119,10 +119,10 @@ private static bool TryMaterializeMatches( { // Do not preallocate to a caller-controlled materialization limit. The limit remains strict below, while // storage grows only for addresses that survived every managed filter. - ImmutableArray
.Builder matches = ImmutableArray.CreateBuilder
(); + ImmutableArray
.Builder materialized = ImmutableArray.CreateBuilder
(); for (int index = 0; index < count; index++) { - if (!TryGetMatchAddress(list, index, out Address address, out failure)) + if (!TryGetMatchAddress(matches, index, out Address address, out failure)) { result = default; return false; @@ -133,37 +133,38 @@ private static bool TryMaterializeMatches( continue; } - if (matches.Count == request.MaximumResults) + if (materialized.Count == request.MaximumResults) { - result = new AobScanResult(matches.ToImmutable(), true); + result = new AobScanResult(materialized.ToImmutable(), true); failure = default; return true; } - matches.Add(address); + materialized.Add(address); } - result = new AobScanResult(matches.ToImmutable(), false); + result = new AobScanResult(materialized.ToImmutable(), false); failure = default; return true; } - private static bool TryGetMatchAddress(StringList list, int index, out Address address, + private static bool TryGetMatchAddress(IAobMatchList matches, int index, out Address address, out CheatEngineFailure failure) { - if (list.TryGetItem(index, out string? text) && Address.TryParse(text, out address)) + if (matches.TryGetItem(index, out string? text) && Address.TryParse(text, out address)) { failure = default; return true; } address = default; - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, ScanOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, $"AOB result {index} was not a hexadecimal address."); return false; } - private static bool IsIncluded(Address address, AobScanRequest request, bool hasModuleRange, ModuleRange moduleRange) + private static bool IsIncluded(Address address, AobScanRequest request, bool hasModuleRange, + ModuleRange moduleRange) { return (!hasModuleRange || moduleRange.Contains(address)) && (!request.Range.HasValue || request.Range.Value.Contains(address)); @@ -173,28 +174,28 @@ internal static bool TryValidateRequest(AobScanRequest request, out CheatEngineF { if (string.IsNullOrWhiteSpace(request.Pattern.Value)) { - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, "An AOB scan requires a normalized, non-empty pattern."); return false; } if (request.MaximumResults <= 0) { - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, "An AOB scan requires a positive materialization limit."); return false; } if (request.Module.HasValue && string.IsNullOrWhiteSpace(request.Module.Value.Value)) { - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, "An AOB module filter must be non-empty."); return false; } if (request.Range.HasValue && request.Range.Value.End < request.Range.Value.Start) { - failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, ScanOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, "An AOB range end address must not precede its start address."); return false; } @@ -203,11 +204,11 @@ internal static bool TryValidateRequest(AobScanRequest request, out CheatEngineF return true; } - private static bool TryGetModuleRange(ModuleName requested, out bool hasRange, out ModuleRange range, + private bool TryGetModuleRange(ModuleName requested, out bool hasRange, out ModuleRange range, out CheatEngineFailure failure) { - ModuleInfo[] modules = new ModuleInfo[MaximumModuleSnapshot]; - InspectionStatus status = EngineInspection.EnumerateModules(modules, out int written); + ModuleInfo[] modules = new ModuleInfo[_maximumModuleSnapshot]; + InspectionStatus status = _scanPort.EnumerateModules(modules, out int written); if (status != InspectionStatus.Success) { hasRange = false; @@ -216,7 +217,7 @@ private static bool TryGetModuleRange(ModuleName requested, out bool hasRange, o status == InspectionStatus.DestinationTooSmall ? CheatEngineFailureKind.ResultLimitExceeded : CheatEngineFailureKind.CapabilityUnavailable, - InModuleOperation, $"Module inspection returned '{status}'."); + _inModuleOperation, $"Module inspection returned '{status}'."); return false; } @@ -233,7 +234,7 @@ private static bool TryGetModuleRange(ModuleName requested, out bool hasRange, o if (found) { hasRange = false; - failure = new CheatEngineFailure(CheatEngineFailureKind.AmbiguousMatch, InModuleOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.AmbiguousMatch, _inModuleOperation, $"More than one module named '{requested.Value}' was present in the selected target."); return false; } @@ -242,14 +243,14 @@ private static bool TryGetModuleRange(ModuleName requested, out bool hasRange, o { hasRange = false; failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, - InModuleOperation, "Cheat Engine did not report the requested module's image size."); + _inModuleOperation, "Cheat Engine did not report the requested module's image size."); return false; } if (module.ImageSize.Value.Value == 0) { hasRange = false; - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, InModuleOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _inModuleOperation, "Cheat Engine reported a zero-length requested module."); return false; } @@ -266,7 +267,7 @@ private static bool TryGetModuleRange(ModuleName requested, out bool hasRange, o } hasRange = false; - failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, InModuleOperation, + failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, _inModuleOperation, $"Module '{requested.Value}' was not present in the selected target."); return false; } diff --git a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs index 9a9234d..ab730cc 100644 --- a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs @@ -113,7 +113,7 @@ public RuntimeCapabilityAvailability GetSdkCapability( CancellationToken cancellationToken = default) { if (TryGetSdkCapability(capability, out RuntimeCapabilityAvailability availability, - out CheatEngineFailure failure, cancellationToken)) + out CheatEngineFailure failure, cancellationToken)) { return availability; } @@ -157,7 +157,7 @@ public ClientCapabilityAvailability GetClientCapability( CancellationToken cancellationToken = default) { if (TryGetClientCapability(capability, out ClientCapabilityAvailability availability, - out CheatEngineFailure failure, cancellationToken)) + out CheatEngineFailure failure, cancellationToken)) { return availability; } @@ -205,7 +205,8 @@ private CheatEngineRuntimeSnapshot Capture() return new CheatEngineRuntimeSnapshot( Epoch, - new CheatEngineRuntimeVersionInfo(observedVersion, CheatEngineVersion.Ce77010621, _clientAssemblyVersion, _sdkAssemblyVersion), + new CheatEngineRuntimeVersionInfo(observedVersion, CheatEngineVersion.Ce77010621, _clientAssemblyVersion, + _sdkAssemblyVersion), new CheatEngineRuntimePlatformInfo( decodedSystemArchitecture, decodedTargetArchitecture, @@ -257,7 +258,7 @@ private static ClientCapabilityAvailability Unknown(ClientCapabilityId capabilit private static CheatEngineArchitecture DecodeSystemArchitecture(ProbeResult probe) { return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int architecture } && - RuntimeInfo.TryDecodeSystemArchitecture(architecture, out CheatEngineArchitecture decoded) + RuntimeInfo.TryDecodeSystemArchitecture(architecture, out CheatEngineArchitecture decoded) ? decoded : CheatEngineArchitecture.Unknown; } @@ -265,7 +266,7 @@ private static CheatEngineArchitecture DecodeSystemArchitecture(ProbeResult private static TargetAbi DecodeTargetAbi(ProbeResult probe) { return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int abi } && - RuntimeInfo.TryDecodeTargetAbi(abi, out TargetAbi decoded) + RuntimeInfo.TryDecodeTargetAbi(abi, out TargetAbi decoded) ? decoded : TargetAbi.Unknown; } @@ -273,7 +274,7 @@ private static TargetAbi DecodeTargetAbi(ProbeResult probe) private static CheatEngineArchitecture DecodeTargetArchitecture(ProbeResult probe, TargetAbi targetAbi) { if (targetAbi != TargetAbi.Windows || - probe is not { State: RuntimeCapabilityAvailabilityState.Available, Value: bool is64Bit }) + probe is not { State: RuntimeCapabilityAvailabilityState.Available, Value: bool is64Bit }) { return CheatEngineArchitecture.Unknown; } diff --git a/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs new file mode 100644 index 0000000..46e0110 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Core.Domains; + +/// Production adapter that copies and releases the SDK-owned AOB list on the CE dispatch thread. +internal sealed class SdkAobScanPort : IAobScanPort +{ + public AobScanHostStatus TryScan(string pattern, AobScanOptions options, + [NotNullWhen(true)] out IAobMatchList? matches) + { + matches = null; + if (!AobScanner.TryScan(pattern, options, out Owned? owner) || owner is null) + { + return AobScanHostStatus.Rejected; + } + + matches = new SdkAobMatchList(owner); + return AobScanHostStatus.Success; + } + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + return EngineInspection.EnumerateModules(destination, out written); + } + + private sealed class SdkAobMatchList(Owned owner) : IAobMatchList + { + private readonly Owned _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + + public bool TryGetCount(out int count) + { + return _owner.Value.TryGetCount(out count); + } + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) + { + return _owner.Value.TryGetItem(index, out value); + } + + public void Dispose() + { + _owner.Dispose(); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs new file mode 100644 index 0000000..37d5552 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs @@ -0,0 +1,60 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// Production adapter that delegates inspection and symbol operations to CheatEngine.SDK. +internal sealed class SdkInspectionPort : IInspectionPort +{ + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + return EngineInspection.EnumerateModules(destination, out written); + } + + public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] destination, out int written) + { + return EngineInspection.EnumerateModules(processId, destination, out written); + } + + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, out int written) + { + return EngineInspection.EnumerateSections(moduleName, destination, out written); + } + + public InspectionStatus EnumerateMemoryRegions(MemoryRegionInfo[] destination, out int written) + { + return EngineInspection.EnumerateMemoryRegions(destination, out written); + } + + public InspectionStatus GetMemoryRegion(Address address, out MemoryRegionInfo region) + { + return EngineInspection.GetMemoryRegionInfo(address, out region); + } + + public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol) + { + return EngineInspection.GetSymbolInfo(expression, out symbol); + } + + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + out Address address) + { + return EngineInspection.ResolveAddress(expression, options, out address); + } + + public bool TryResolveName(nuint address, out string? name) + { + return ClientLuaGlobals.TryGetNameFromAddress(address, out name); + } + + public void RegisterSymbol(string name, nuint address, bool doNotSave) + { + ClientLuaGlobals.RegisterSymbol(name, address, doNotSave); + } + + public void UnregisterSymbol(string name) + { + ClientLuaGlobals.UnregisterSymbol(name); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableClient.cs b/libs/CheatEngine.Client.Core/Domains/TableClient.cs index d4a3b52..e167b6a 100644 --- a/libs/CheatEngine.Client.Core/Domains/TableClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/TableClient.cs @@ -16,7 +16,7 @@ internal sealed class TableClient( CoreClientPolicy policy, ITableRecordMutationPort? recordMutations = null) : ITableClient { - private const string GetHierarchyOperation = "Tables.GetHierarchy"; + private const string _getHierarchyOperation = "Tables.GetHierarchy"; private readonly ICheatEngineDispatcher _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); @@ -30,8 +30,8 @@ public bool TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure AddressTableSnapshot captured = default; bool succeeded = false; if (!_dispatcher.TryInvoke(() => - succeeded = AddressListAccess.TryGetCurrent(out AddressList list) && list.TryGetCount(out int count) && - CaptureTable(count, out captured), out failure, cancellationToken)) + succeeded = AddressListAccess.TryGetCurrent(out AddressList list) && list.TryGetCount(out int count) && + CaptureTable(count, out captured), out failure, cancellationToken)) { table = default; return false; @@ -65,8 +65,8 @@ public bool TryGetSnapshot(MemoryRecordCollectionRequest request, out AddressTab bool succeeded = false; bool exceededLimit = false; if (!_dispatcher.TryInvoke( - () => succeeded = TryCaptureSnapshot(request, out captured, out exceededLimit), - out failure, cancellationToken)) + () => succeeded = TryCaptureSnapshot(request, out captured, out exceededLimit), + out failure, cancellationToken)) { table = default; return false; @@ -130,7 +130,7 @@ public ImmutableArray Find(MemoryRecordSearch search, MemoryRecordCollectionRequest request, CancellationToken cancellationToken = default) { if (TryFind(search, request, out ImmutableArray result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } @@ -221,8 +221,8 @@ public bool TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapsho bool succeeded = false; TableRecordMutationStatus parentMutationStatus = TableRecordMutationStatus.Success; if (!_dispatcher.TryInvoke( - () => succeeded = TryCreateRecord(definition, out captured, out parentMutationStatus), - out failure, cancellationToken)) + () => succeeded = TryCreateRecord(definition, out captured, out parentMutationStatus), + out failure, cancellationToken)) { record = default; return false; @@ -258,7 +258,7 @@ public bool TryUpdate(MemoryRecordUpdate update, out MemoryRecordSnapshot record MemoryRecordSnapshot captured = default; bool succeeded = false; if (!_dispatcher.TryInvoke(() => succeeded = TryUpdateRecord(update, out captured), out failure, - cancellationToken)) + cancellationToken)) { record = default; return false; @@ -324,7 +324,7 @@ public MemoryRecordSnapshot SetActive(MemoryRecordId id, bool isActive, CancellationToken cancellationToken = default) { if (TrySetActive(id, isActive, out MemoryRecordSnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } @@ -346,7 +346,7 @@ record = default; MemoryRecordSnapshot captured = default; TableRecordMutationStatus status = TableRecordMutationStatus.HostRejected; if (!_dispatcher.TryInvoke(() => status = _recordMutations.TrySetParent(childId, parentId, out captured), - out failure, cancellationToken)) + out failure, cancellationToken)) { record = default; return false; @@ -367,7 +367,7 @@ public MemoryRecordSnapshot SetParent(MemoryRecordId childId, MemoryRecordId? pa CancellationToken cancellationToken = default) { if (TrySetParent(childId, parentId, out MemoryRecordSnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } @@ -385,18 +385,18 @@ public bool TryGetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest bool succeeded = false; HierarchyBuildProblem problem = HierarchyBuildProblem.None; if (!_dispatcher.TryInvoke(() => - { - if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(rootId, out MemoryRecord root)) - { - return; - } - - found = true; - HashSet visited = new(); - int materialized = 0; - succeeded = TryBuildHierarchy(root, request, 1, visited, ref materialized, out captured, out problem); - }, out failure, cancellationToken)) + { + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(rootId, out MemoryRecord root)) + { + return; + } + + found = true; + HashSet visited = []; + int materialized = 0; + succeeded = TryBuildHierarchy(root, request, 1, visited, ref materialized, out captured, out problem); + }, out failure, cancellationToken)) { hierarchy = default; return false; @@ -416,7 +416,7 @@ public MemoryRecordHierarchySnapshot GetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, CancellationToken cancellationToken = default) { if (TryGetHierarchy(rootId, request, out MemoryRecordHierarchySnapshot result, out CheatEngineFailure failure, - cancellationToken)) + cancellationToken)) { return result; } @@ -471,15 +471,15 @@ private bool TryRecord(string operation, Func select MemoryRecordSnapshot captured = default; bool succeeded = false; if (!_dispatcher.TryInvoke(() => - { - if (!AddressListAccess.TryGetCurrent(out AddressList list)) - { - return; - } + { + if (!AddressListAccess.TryGetCurrent(out AddressList list)) + { + return; + } - MemoryRecord? value = selector(list); - succeeded = value.HasValue && TrySnapshot(value.Value, out captured); - }, out failure, cancellationToken)) + MemoryRecord? value = selector(list); + succeeded = value.HasValue && TrySnapshot(value.Value, out captured); + }, out failure, cancellationToken)) { record = default; return false; @@ -522,7 +522,7 @@ private static bool TryCaptureRecords(AddressList list, int count, out AddressTa for (int index = 0; index < count; index++) { if (!list.TryGetMemoryRecord(index, out MemoryRecord value) || - !TrySnapshot(value, out MemoryRecordSnapshot snapshot)) + !TrySnapshot(value, out MemoryRecordSnapshot snapshot)) { table = default; return false; @@ -540,13 +540,14 @@ private bool TryCreateRecord(MemoryRecordDefinition definition, out MemoryRecord { record = default; parentMutationStatus = TableRecordMutationStatus.Success; - if (!AddressListAccess.TryGetCurrent(out AddressList list) || !list.TryCreateMemoryRecord(out MemoryRecord value)) + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryCreateMemoryRecord(out MemoryRecord value)) { return false; } bool succeeded = TryInitializeRecord(value, definition) && - TryCompleteRecordCreation(value, definition, out record, out parentMutationStatus); + TryCompleteRecordCreation(value, definition, out record, out parentMutationStatus); if (!succeeded) { _ = value.Handle.TryCallMethod("destroy"u8); @@ -558,9 +559,9 @@ record = default; private static bool TryInitializeRecord(MemoryRecord value, MemoryRecordDefinition definition) { return value.TrySetDescription(definition.Description) && - value.TrySetAddressExpression(definition.AddressExpression) && - value.TrySetVariableType(definition.VariableType) && - value.TrySetValue(definition.Value); + value.TrySetAddressExpression(definition.AddressExpression) && + value.TrySetVariableType(definition.VariableType) && + value.TrySetValue(definition.Value); } private bool TryCompleteRecordCreation(MemoryRecord value, MemoryRecordDefinition definition, @@ -587,7 +588,7 @@ private static bool TryUpdateRecord(MemoryRecordUpdate update, out MemoryRecordS { record = default; if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(update.Id, out MemoryRecord value)) + !list.TryGetMemoryRecordById(update.Id, out MemoryRecord value)) { return false; } @@ -598,9 +599,9 @@ record = default; private static bool TryApplyUpdate(MemoryRecord value, MemoryRecordUpdate update) { return (update.Description is null || value.TrySetDescription(update.Description)) && - (update.AddressExpression is null || value.TrySetAddressExpression(update.AddressExpression)) && - (!update.VariableType.HasValue || value.TrySetVariableType(update.VariableType.Value)) && - (update.Value is null || value.TrySetValue(update.Value)); + (update.AddressExpression is null || value.TrySetAddressExpression(update.AddressExpression)) && + (!update.VariableType.HasValue || value.TrySetVariableType(update.VariableType.Value)) && + (update.Value is null || value.TrySetValue(update.Value)); } private bool TryAuthorize(string path, string operation, out CheatEngineFailure failure) @@ -626,11 +627,11 @@ private bool TryAuthorize(string path, string operation, out CheatEngineFailure internal static bool TrySnapshot(MemoryRecord value, out MemoryRecordSnapshot snapshot) { if (!value.TryGetId(out MemoryRecordId id) || !value.TryGetIndex(out int index) || - !value.TryGetDescription(out string? description) || - !value.TryGetAddressExpression(out string? expression) || - !value.TryGetValue(out string? text) || !value.TryGetVariableType(out VariableType variableType) || - !value.Handle.TryGetProperty("Active"u8, out bool isActive) || - !value.Handle.TryGetProperty("Count"u8, out int childCount) || childCount < 0) + !value.TryGetDescription(out string? description) || + !value.TryGetAddressExpression(out string? expression) || + !value.TryGetValue(out string? text) || !value.TryGetVariableType(out VariableType variableType) || + !value.Handle.TryGetProperty("Active"u8, out bool isActive) || + !value.Handle.TryGetProperty("Count"u8, out int childCount) || childCount < 0) { snapshot = default; return false; @@ -654,16 +655,16 @@ private bool TryMutateRecord(string operation, MemoryRecordId id, bool found = false; bool succeeded = false; if (!_dispatcher.TryInvoke(() => - { - if (!AddressListAccess.TryGetCurrent(out AddressList list) || - !list.TryGetMemoryRecordById(id, out MemoryRecord value)) - { - return; - } + { + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(id, out MemoryRecord value)) + { + return; + } - found = true; - succeeded = mutation(list, value) && TrySnapshot(value, out captured); - }, out failure, cancellationToken)) + found = true; + succeeded = mutation(list, value) && TrySnapshot(value, out captured); + }, out failure, cancellationToken)) { record = default; return false; @@ -734,9 +735,9 @@ private static bool TryBuildHierarchy(MemoryRecord value, MemoryRecordHierarchyR { problem = HierarchyBuildProblem.InvalidShape; if (!value.TryGetChild(index, out MemoryRecord child) || - !TryBuildHierarchy(child, request, depth + 1, visited, ref materialized, - out MemoryRecordHierarchySnapshot childSnapshot, - out problem)) + !TryBuildHierarchy(child, request, depth + 1, visited, ref materialized, + out MemoryRecordHierarchySnapshot childSnapshot, + out problem)) { return false; } @@ -752,17 +753,17 @@ private static bool TryBuildHierarchy(MemoryRecord value, MemoryRecordHierarchyR private static bool Matches(MemoryRecordSearch search, MemoryRecordSnapshot record) { return (search.DescriptionContains is null || record.Description.Contains(search.DescriptionContains, - StringComparison.OrdinalIgnoreCase)) && - (search.AddressExpression is null || string.Equals(record.AddressExpression, search.AddressExpression, - StringComparison.OrdinalIgnoreCase)) && - (!search.VariableType.HasValue || record.VariableType == search.VariableType.Value) && - (!search.IsActive.HasValue || record.IsActive == search.IsActive.Value); + StringComparison.OrdinalIgnoreCase)) && + (search.AddressExpression is null || string.Equals(record.AddressExpression, search.AddressExpression, + StringComparison.OrdinalIgnoreCase)) && + (!search.VariableType.HasValue || record.VariableType == search.VariableType.Value) && + (!search.IsActive.HasValue || record.IsActive == search.IsActive.Value); } private static bool HasPredicate(MemoryRecordSearch search) { return search.DescriptionContains is not null || search.AddressExpression is not null || - search.VariableType.HasValue || search.IsActive.HasValue; + search.VariableType.HasValue || search.IsActive.HasValue; } private static bool CaptureTable(int count, out AddressTableSnapshot table) @@ -788,14 +789,14 @@ private static CheatEngineFailure GetHierarchyFailure(HierarchyBuildProblem prob { return problem switch { - HierarchyBuildProblem.ItemLimit => ResultLimitFailure(GetHierarchyOperation, request.MaximumItems), + HierarchyBuildProblem.ItemLimit => ResultLimitFailure(_getHierarchyOperation, request.MaximumItems), HierarchyBuildProblem.DepthLimit => new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, - GetHierarchyOperation, + _getHierarchyOperation, $"The operation requires a hierarchy depth greater than the explicit limit of {request.MaximumDepth}."), - HierarchyBuildProblem.InvalidShape => HostFailure(GetHierarchyOperation), - _ when !found => new CheatEngineFailure(CheatEngineFailureKind.NotFound, GetHierarchyOperation, + HierarchyBuildProblem.InvalidShape => HostFailure(_getHierarchyOperation), + _ when !found => new CheatEngineFailure(CheatEngineFailureKind.NotFound, _getHierarchyOperation, "The requested Cheat Engine memory record was not found."), - _ => HostFailure(GetHierarchyOperation) + _ => HostFailure(_getHierarchyOperation) }; } diff --git a/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs b/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs index 29af200..cdd3848 100644 --- a/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs +++ b/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs @@ -11,7 +11,7 @@ internal static TableRecordMutationStatus Validate(MemoryRecordId childId, Memor ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumHops); ArgumentNullException.ThrowIfNull(getNext); - HashSet visited = new(); + HashSet visited = []; MemoryRecordId current = candidateParentId; for (int hop = 0; hop < maximumHops; hop++) { diff --git a/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs index ac333fb..d1da5ce 100644 --- a/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs +++ b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs @@ -15,7 +15,7 @@ internal sealed class UnavailableValueScanner : IValueScanner // only through MemoryScanSession.Adopt(Owned, Owned), while Owned has an internal // constructor and the Lua-global generator cannot marshal CEObject results. Bypassing that with reflection or a // hand-rolled destroy owner would make an unverified CE ownership assumption part of the Client contract. - private const string OwnershipGateMessage = + private const string _ownershipGateMessage = "Value scans are disabled: CheatEngine.SDK 1.0.0 has no public ownership factory for createMemScan or " + "createFoundList, and its generated Lua globals cannot return CEObject handles. Enablement requires the " + "Cheat Engine 7.7 ownership and reactivation live gate."; @@ -28,7 +28,7 @@ public bool TryCreateSession(out IValueScanSession? session, out CheatEngineFail ? new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Scans.CreateSession", "The operation was cancelled before Cheat Engine work began.") : new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, "Scans.CreateSession", - OwnershipGateMessage); + _ownershipGateMessage); return false; } diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs index 4a57e0c..8e93cc8 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs @@ -6,7 +6,7 @@ internal sealed class CoreClientPolicy internal CoreClientPolicy(IEnumerable allowedTableRoots, bool enableUnsafeLuaExecution) { ArgumentNullException.ThrowIfNull(allowedTableRoots); - List roots = new(); + List roots = []; foreach (string root in allowedTableRoots) { ArgumentException.ThrowIfNullOrWhiteSpace(root); diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs index 9602554..c0d22f6 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs @@ -7,24 +7,32 @@ namespace CheatEngine.Client.Core.Infrastructure; /// Captures one enabled plugin context and owns all client-created resources for its epoch. internal sealed class CoreLifetime : IDisposable { - private readonly PluginContext _context; + private readonly ICoreLifetimeContext _context; private readonly CoreResourceRegistry _resources = new(); private int _cleanupScopeDepth; private int _disposed; private int _resourcesDrained; private CoreLifetime(PluginContext context) + : this(new PluginContextAdapter(context)) { - _context = context; + } + + internal CoreLifetime(ICoreLifetimeContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); TargetSelection = new TargetSelectionLifetime(ThrowIfInactive); } internal long Epoch => _context.Epoch; - internal CancellationToken Stopping => _context.ShutdownToken; + internal CancellationToken Stopping => _context.Stopping; /// Gets the per-activation target-selection lifetime; this is deliberately independent from . - internal TargetSelectionLifetime TargetSelection { get; } + internal TargetSelectionLifetime TargetSelection + { + get; + } /// Gets whether the captured SDK activation context itself is still current, regardless of admission closure. internal bool IsActivationCurrent => Volatile.Read(ref _disposed) == 0 && _context.IsCurrent; @@ -208,4 +216,17 @@ public void Dispose() owner?.ExitCleanupScope(); } } + + private sealed class PluginContextAdapter(PluginContext context) : ICoreLifetimeContext + { + private readonly PluginContext _context = context ?? throw new ArgumentNullException(nameof(context)); + + public long Epoch => _context.Epoch; + + public CancellationToken Stopping => _context.ShutdownToken; + + public bool IsCurrent => _context.IsCurrent; + + public bool IsMainThread => _context.IsMainThread; + } } diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs b/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs new file mode 100644 index 0000000..56ecb06 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs @@ -0,0 +1,24 @@ +namespace CheatEngine.Client.Core.Infrastructure; + +internal interface ICoreLifetimeContext +{ + public long Epoch + { + get; + } + + public CancellationToken Stopping + { + get; + } + + public bool IsCurrent + { + get; + } + + public bool IsMainThread + { + get; + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs index 8a43708..a817f6d 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs @@ -9,14 +9,14 @@ namespace CheatEngine.Client.Extensions.DependencyInjection; /// public sealed class CheatEngineClientOptions { + /// Gets the default configuration section used by plugin hosting. + public const string ConfigurationSectionName = "CheatEngineClient"; + /// Initializes the default table-file policy, which denies table-file access until roots are configured. public CheatEngineClientOptions() { } - /// 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 diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs index 35e9d25..171567f 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs @@ -40,7 +40,8 @@ public ValidateOptionsResult Validate(string? name, CheatEngineClientOptions opt } catch (Exception exception) when (exception is ArgumentException or NotSupportedException or IOException) { - return ValidateOptionsResult.Fail("AllowedTableRoots must contain paths that can be normalized safely."); + return ValidateOptionsResult.Fail( + "AllowedTableRoots must contain paths that can be normalized safely."); } if (!roots.Add(normalized)) diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs index ced847a..74b4704 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs @@ -79,7 +79,8 @@ private static void AddCoreServices(IServiceCollection services) CheatEngineClientOptions options = serviceProvider.GetRequiredService>().Value; string[] allowedTableRoots = options.AllowedTableRoots - ?? throw new InvalidOperationException("AllowedTableRoots must be validated before the Client policy is created."); + ?? throw new InvalidOperationException( + "AllowedTableRoots must be validated before the Client policy is created."); bool enableUnsafeLuaExecution = serviceProvider .GetService()? .IsEnabled == true; @@ -161,12 +162,3 @@ 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/README.md b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md index cfc0fe7..f5234b4 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md @@ -4,21 +4,32 @@ Explicit, AOT-aware dependency-injection composition for the high-level Cheat En ## 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. +`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. +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. +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. +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. +`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. +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 table-file policy: @@ -29,10 +40,12 @@ not an appsettings option: it can only be enabled with the explicit builder opt- The builder also provides explicit extension points: -- `AddModule()` preserves module registration order and creates modules in the activation scope; Hosting enables +- `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`. +- `EnableUnsafeLuaExecution()` registers the unsafe Lua facade only for the current activation policy. It never exposes + an SDK `LuaState`. ```csharp using CheatEngine.Client.Extensions.DependencyInjection; @@ -46,4 +59,6 @@ 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. +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/UnsafeLuaExecutionRegistration.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs new file mode 100644 index 0000000..db0656e --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs @@ -0,0 +1,10 @@ +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// 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.Fluent/Memory/Memory.cs b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs index 1f07c8e..0c9f4b5 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs @@ -8,8 +8,8 @@ public static class Memory /// Creates an unbound address builder. /// The target address to read or write. /// - /// An immutable address builder that must be bound with Using(memory) - /// before a built-in terminal operation. + /// An immutable address builder that must be bound with Using(memory) + /// before a built-in terminal operation. /// public static MemoryAddressBuilder At(Address address) { diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs index 73b3b63..4ad5b91 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs @@ -96,7 +96,10 @@ 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 . + /// + /// or is + /// . + /// public T ReadWith(IMemoryClient memory, IMemoryCodec codec, CancellationToken cancellationToken = default) { @@ -129,7 +132,10 @@ 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 . + /// + /// or is + /// . + /// public bool TryReadWith(IMemoryClient memory, IMemoryCodec codec, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -158,7 +164,10 @@ 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 . + /// + /// or is + /// . + /// public void WriteWith(IMemoryClient memory, T value, IMemoryCodec codec, CancellationToken cancellationToken = default) { @@ -190,7 +199,10 @@ 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 . + /// + /// or is + /// . + /// public bool TryWriteWith(IMemoryClient memory, T value, IMemoryCodec codec, out CheatEngineFailure failure, CancellationToken cancellationToken = default) diff --git a/libs/CheatEngine.Client.Fluent/README.md b/libs/CheatEngine.Client.Fluent/README.md index aac3503..ae53709 100644 --- a/libs/CheatEngine.Client.Fluent/README.md +++ b/libs/CheatEngine.Client.Fluent/README.md @@ -57,10 +57,10 @@ Abstractions ← Fluent 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` | +| 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` diff --git a/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs index eb7008b..84eafec 100644 --- a/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs +++ b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs @@ -34,7 +34,10 @@ protected CheatEngineClientPlugin() /// Gets the client for the active enable epoch. /// The plugin is not currently enabled. - protected ICheatEngineClient GetRequiredClient() => GetActiveClient(); + protected ICheatEngineClient GetRequiredClient() + { + return GetActiveClient(); + } /// Adds application services, explicit Client modules, codecs, and configuration sources for one activation. /// diff --git a/libs/CheatEngine.Client.Hosting/README.md b/libs/CheatEngine.Client.Hosting/README.md index 9ae2bb2..7722e43 100644 --- a/libs/CheatEngine.Client.Hosting/README.md +++ b/libs/CheatEngine.Client.Hosting/README.md @@ -4,21 +4,32 @@ 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 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. +`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. +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. +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. +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 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. +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: @@ -49,7 +60,10 @@ public sealed class Plugin : CheatEngineClientPlugin } ``` -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. +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 @@ -63,4 +77,5 @@ Plugin projects must reference `CheatEngine.SDK` directly as well as `CheatEngin ``` -`CheatEngine.Client.Templates` contains a complete plugin layout that applies this configuration and includes a bounded AOB, memory, Address List, and Lua-module example. +`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/src/CheatEngine.Client/README.md b/src/CheatEngine.Client/README.md index 4e51357..c7ce726 100644 --- a/src/CheatEngine.Client/README.md +++ b/src/CheatEngine.Client/README.md @@ -46,7 +46,8 @@ ownership types. It brings together: 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) +[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 diff --git a/templates/CheatEngine.Client.Templates/README.md b/templates/CheatEngine.Client.Templates/README.md index 838af70..c26cb16 100644 --- a/templates/CheatEngine.Client.Templates/README.md +++ b/templates/CheatEngine.Client.Templates/README.md @@ -6,9 +6,6 @@ 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: diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs index 93a05a1..7d0b147 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs @@ -1,7 +1,7 @@ using CheatEngine.Client.Inspection; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Inspection; +namespace CheatEngine.Client.Abstractions.Tests.Inspection; public sealed class InspectionContractsTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs index ee1ec46..905ac19 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs @@ -4,7 +4,7 @@ using CheatEngine.Client.Lua; using CheatEngine.Client.Results; -namespace CheatEngine.Client.Tests.Lua; +namespace CheatEngine.Client.Abstractions.Tests.Lua; public sealed class LuaContractTests { @@ -65,27 +65,27 @@ public void InterfaceExecutionInfersTheOperationResultTypeAndForwardsSuccess() int executeResult = client.Execute(operation, TestContext.Current.CancellationToken); bool tryExecuteSucceeded = client.TryExecute( operation, - out var tryExecuteResult, + out int tryExecuteResult, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.Equal(42, executeResult); Assert.True(tryExecuteSucceeded); Assert.Equal(42, tryExecuteResult); - Assert.Equal(default(CheatEngineFailure), failure); + Assert.Equal(default, failure); Assert.Equal(2, operation.ExecutionCount); } private static IEnumerable GetPublicSignatureTypes(Type type) { foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | - BindingFlags.Static)) + BindingFlags.Static)) { yield return property.PropertyType; } foreach (MethodInfo method in - type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) { yield return method.ReturnType; foreach (ParameterInfo parameter in method.GetParameters()) diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs index 47321d6..da7f651 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs @@ -1,7 +1,7 @@ using CheatEngine.Client.Memory; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Memory; +namespace CheatEngine.Client.Abstractions.Tests.Memory; public sealed class MemoryBoundedRequestTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs index 73f51c6..0b0a0ba 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs @@ -2,7 +2,7 @@ using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Runtime; -namespace CheatEngine.Client.Tests.Processes; +namespace CheatEngine.Client.Abstractions.Tests.Processes; public sealed class ProcessSnapshotTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs index 94bf17c..d8b44a3 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs @@ -1,6 +1,6 @@ using CheatEngine.Client.Results; -namespace CheatEngine.Client.Tests.Results; +namespace CheatEngine.Client.Abstractions.Tests.Results; public sealed class CheatEngineFailureTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs index 9124969..2f0dc58 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs @@ -3,7 +3,7 @@ using CheatEngine.Client.Runtime; using CheatEngine.SDK.Engine.Runtime; -namespace CheatEngine.Client.Tests.Runtime; +namespace CheatEngine.Client.Abstractions.Tests.Runtime; public sealed class CheatEngineRuntimeSnapshotTests { @@ -27,7 +27,8 @@ public void SnapshotForwardsVersionAndPlatformComponentsToItsExistingLeafPropert { Version clientAssemblyVersion = new(0, 1, 2, 3); Version sdkAssemblyVersion = new(1, 2, 3, 4); - CheatEngineRuntimeVersionInfo version = new(7.7d, CheatEngineVersion.Ce77010621, clientAssemblyVersion, sdkAssemblyVersion); + CheatEngineRuntimeVersionInfo version = new(7.7d, CheatEngineVersion.Ce77010621, clientAssemblyVersion, + sdkAssemblyVersion); CheatEngineRuntimePlatformInfo platform = new( CheatEngineArchitecture.X64, CheatEngineArchitecture.X86, @@ -66,11 +67,12 @@ public void VersionInfoRejectsInvalidObservedCeVersion(double observedVersion) [Fact] public void VersionInfoRejectsNullAssemblyVersions() { - ArgumentNullException clientVersion = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( - 7.7d, - CheatEngineVersion.Ce77010621, - Null(), - new Version(1, 0, 0))); + ArgumentNullException clientVersion = Assert.Throws(() => + new CheatEngineRuntimeVersionInfo( + 7.7d, + CheatEngineVersion.Ce77010621, + Null(), + new Version(1, 0, 0))); ArgumentNullException sdkVersion = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( 7.7d, CheatEngineVersion.Ce77010621, @@ -105,18 +107,20 @@ public void SnapshotRejectsNegativeActivationEpoch(long epoch) [Fact] public void SnapshotRejectsNullCapabilityCollections() { - ArgumentNullException sdkCapabilities = Assert.Throws(() => new CheatEngineRuntimeSnapshot( - 42, - CreateVersionInfo(), - CreatePlatformInfo(), - Null(), - ClientCapabilities.Empty)); - ArgumentNullException clientCapabilities = Assert.Throws(() => new CheatEngineRuntimeSnapshot( - 42, - CreateVersionInfo(), - CreatePlatformInfo(), - RuntimeCapabilities.Empty, - Null())); + ArgumentNullException sdkCapabilities = Assert.Throws(() => + new CheatEngineRuntimeSnapshot( + 42, + CreateVersionInfo(), + CreatePlatformInfo(), + Null(), + ClientCapabilities.Empty)); + ArgumentNullException clientCapabilities = Assert.Throws(() => + new CheatEngineRuntimeSnapshot( + 42, + CreateVersionInfo(), + CreatePlatformInfo(), + RuntimeCapabilities.Empty, + Null())); Assert.Equal("sdkCapabilities", sdkCapabilities.ParamName); Assert.Equal("clientCapabilities", clientCapabilities.ParamName); @@ -195,5 +199,8 @@ private static CheatEngineRuntimePlatformInfo CreatePlatformInfo() TargetAbi.Windows); } - private static T Null() where T : class => default!; + private static T Null() where T : class + { + return default!; + } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs index f94b68c..9e88d6f 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs @@ -1,6 +1,6 @@ using CheatEngine.Client.Runtime; -namespace CheatEngine.Client.Tests.Runtime; +namespace CheatEngine.Client.Abstractions.Tests.Runtime; public sealed class ClientCapabilitiesTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs index 48cfe0a..124dd1b 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs @@ -3,7 +3,7 @@ using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Scanning.Aob; -namespace CheatEngine.Client.Tests.Scanning; +namespace CheatEngine.Client.Abstractions.Tests.Scanning; public sealed class AobPatternTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs index 1f29218..b98728a 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs @@ -3,7 +3,7 @@ using CheatEngine.Client.Scanning; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Scanning; +namespace CheatEngine.Client.Abstractions.Tests.Scanning; public sealed class ValueScanContractTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs index 393d632..844d985 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs @@ -5,7 +5,7 @@ using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Tables; +namespace CheatEngine.Client.Abstractions.Tests.Tables; public sealed class TableContractTests { @@ -142,11 +142,12 @@ public void ContentSnapshotRejectsNullRequiredValues() "game.exe+20", "100", VariableType.Dword)); - ArgumentNullException addressExpression = Assert.Throws(() => new MemoryRecordContentSnapshot( - "Health", - Null(), - "100", - VariableType.Dword)); + ArgumentNullException addressExpression = Assert.Throws(() => + new MemoryRecordContentSnapshot( + "Health", + Null(), + "100", + VariableType.Dword)); ArgumentNullException value = Assert.Throws(() => new MemoryRecordContentSnapshot( "Health", "game.exe+20", @@ -167,10 +168,7 @@ public void HierarchySnapshotNormalizesDefaultChildrenDuringConstructionAndWithU 0, CreateContent(), CreateState()), default); - MemoryRecordHierarchySnapshot updatedHierarchy = hierarchy with - { - Children = default - }; + MemoryRecordHierarchySnapshot updatedHierarchy = hierarchy with { Children = default }; MemoryRecordHierarchySnapshot uninitializedHierarchy = default; Assert.False(hierarchy.Children.IsDefault); @@ -250,10 +248,14 @@ private static MemoryRecordContentSnapshot CreateContent() return new MemoryRecordContentSnapshot("Health", "game.exe+20", "100", VariableType.Dword); } - private static MemoryRecordStateSnapshot CreateState(Address? currentAddress = null, bool isActive = false, int childCount = 0) + private static MemoryRecordStateSnapshot CreateState(Address? currentAddress = null, bool isActive = false, + int childCount = 0) { return new MemoryRecordStateSnapshot(currentAddress, isActive, childCount); } - private static T Null() where T : class => default!; + private static T Null() where T : class + { + return default!; + } } diff --git a/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs new file mode 100644 index 0000000..3e68585 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs @@ -0,0 +1,59 @@ +using System.Reflection; + +using CheatEngine.Client.Core.Tests.TestSupport; +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; + +namespace CheatEngine.Client.Core.Tests.Composition; + +public sealed class CheatEngineClientTests +{ + [Fact] + public void ExposesTheRuntimeAndDomainServicesFromItsActivationComposition() + { + ICheatEngineRuntime runtime = CreateProxy(); + ICheatEngineDispatcher dispatcher = CreateProxy(); + IProcessClient processes = CreateProxy(); + IMemoryClient memory = CreateProxy(); + IPatternScanner patterns = CreateProxy(); + IValueScanner scans = CreateProxy(); + IInspectionClient inspection = CreateProxy(); + ITableClient tables = CreateProxy(); + ILuaClient lua = CreateProxy(); + + CheatEngineClient client = new( + InertCoreLifetime.Create(), + new CheatEngineClientRuntimeServices(runtime, dispatcher), + new CheatEngineClientDomainServices(processes, memory, patterns, scans, inspection, tables, lua)); + + Assert.Same(runtime, client.Runtime); + Assert.Same(dispatcher, client.Dispatcher); + Assert.Same(processes, client.Processes); + Assert.Same(memory, client.Memory); + Assert.Same(patterns, client.Patterns); + Assert.Same(scans, client.Scans); + Assert.Same(inspection, client.Inspection); + Assert.Same(tables, client.Tables); + Assert.Same(lua, client.Lua); + } + + private static T CreateProxy() + where T : class + { + return DispatchProxy.Create(); + } + + public class ThrowingProxy : DispatchProxy + { + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + throw new NotSupportedException("The façade test must not invoke a composed service."); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs new file mode 100644 index 0000000..043d3bd --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs @@ -0,0 +1,144 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Dispatching; + +public sealed class SdkMainThreadDispatcherBehaviorTests +{ + [Fact] + public void TryInvokeExecutesActionAndGenericCallbacksThroughTheInjectedMainThreadInvoker() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + RecordingMainThreadInvoker invoker = new(); + SdkMainThreadDispatcher dispatcher = new(lifetime, invoker); + bool callbackRan = false; + + bool actionSucceeded = dispatcher.TryInvoke(() => callbackRan = true, out CheatEngineFailure actionFailure, + TestContext.Current.CancellationToken); + bool functionSucceeded = dispatcher.TryInvoke(static () => 42, out int result, + out CheatEngineFailure functionFailure, TestContext.Current.CancellationToken); + + Assert.True(actionSucceeded); + Assert.True(callbackRan); + Assert.Equal(default, actionFailure); + Assert.True(functionSucceeded); + Assert.Equal(42, result); + Assert.Equal(default, functionFailure); + Assert.Equal(1, invoker.ActionCalls); + Assert.Equal(1, invoker.FunctionCalls); + } + + [Fact] + public void CallbackExceptionsAreRethrownWithoutBeingClassifiedAsHostFailures() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, new RecordingMainThreadInvoker()); + + InvalidOperationException actionException = Assert.Throws(() => + dispatcher.TryInvoke(static () => throw new InvalidOperationException("action callback"), + out CheatEngineFailure _, TestContext.Current.CancellationToken)); + InvalidOperationException functionException = Assert.Throws(() => + dispatcher.TryInvoke(static () => throw new InvalidOperationException("function callback"), out _, + out CheatEngineFailure _, TestContext.Current.CancellationToken)); + + Assert.Equal("action callback", actionException.Message); + Assert.Equal("function callback", functionException.Message); + } + + [Fact] + public void TryInvokeMapsMainThreadInfrastructureFailuresToStableClientFailures() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, + new RecordingMainThreadInvoker { HostException = new InvalidOperationException("host queue unavailable") }); + + bool succeeded = dispatcher.TryInvoke(static () => + { + }, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Dispatcher.Invoke", failure.Operation); + Assert.Equal("host queue unavailable", failure.Message); + } + + [Fact] + public void ConstructorRejectsMissingLifecycleOrMainThreadInvoker() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + RecordingMainThreadInvoker invoker = new(); + + ArgumentNullException lifetimeException = Assert.Throws(() => + new SdkMainThreadDispatcher(null!, invoker)); + ArgumentNullException invokerException = Assert.Throws(() => + new SdkMainThreadDispatcher(lifetime, null!)); + + Assert.Equal("lifetime", lifetimeException.ParamName); + Assert.Equal("mainThread", invokerException.ParamName); + } + + private sealed class RecordingMainThreadInvoker : IMainThreadInvoker + { + internal int ActionCalls + { + get; + private set; + } + + internal int FunctionCalls + { + get; + private set; + } + + internal Exception? HostException + { + get; + init; + } + + public Exception? Invoke(Action callback) + { + ActionCalls++; + if (HostException is not null) + { + throw HostException; + } + + try + { + callback(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + public MainThreadInvocationResult Invoke(Func callback) + { + FunctionCalls++; + if (HostException is not null) + { + throw HostException; + } + + try + { + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs new file mode 100644 index 0000000..4bdcee8 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs @@ -0,0 +1,68 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Dispatching; + +public sealed class SdkMainThreadDispatcherTests +{ + [Fact] + public void TryInvokeRejectsNullCallbacksBeforeEnteringThePluginDispatchGate() + { + SdkMainThreadDispatcher dispatcher = new(InertCoreLifetime.Create()); + + ArgumentNullException actionException = Assert.Throws(() => + dispatcher.TryInvoke(null!, out CheatEngineFailure _, TestContext.Current.CancellationToken)); + ArgumentNullException functionException = Assert.Throws(() => + dispatcher.TryInvoke(null!, out _, out CheatEngineFailure _, TestContext.Current.CancellationToken)); + + Assert.Equal("callback", actionException.ParamName); + Assert.Equal("callback", functionException.ParamName); + } + + [Fact] + public void TryInvokeReportsCancellationBeforeCallingTheSdkDispatcher() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = dispatcher.TryInvoke(static () => throw new InvalidOperationException("must not run"), + out CheatEngineFailure failure, cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal("Dispatcher.Invoke", failure.Operation); + } + + [Fact] + public void InvokeConvertsARejectedDispatchToThePublicFailureException() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + CheatEngineOperationException exception = Assert.Throws(() => + dispatcher.Invoke(static () => + { + }, cancellation.Token)); + + Assert.Equal(CheatEngineFailureKind.Cancelled, exception.Failure.Kind); + Assert.Equal("Dispatcher.Invoke", exception.Failure.Operation); + } + + [Fact] + public void MainThreadPropertyRemainsFalseOutsideTheActualSdkMainThread() + { + using ControlledCoreLifetimeContext context = new() { IsMainThread = true }; + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime); + + Assert.False(dispatcher.IsMainThread); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs new file mode 100644 index 0000000..0c9b602 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs @@ -0,0 +1,285 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class InspectionClientBehaviorTests +{ + [Fact] + public void GetModulesCopiesOnlyTheWrittenEntriesAndUsesTheRequestedProcessOverload() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() { ModulesWritten = 1 }; + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryGetModules(new InspectionCollectionRequest(2), out ImmutableArray modules, + out CheatEngineFailure failure, new TargetProcessId(42), TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Single(modules); + Assert.Equal(2, port.LastModuleBufferLength); + Assert.Equal(new TargetProcessId(42), port.LastModuleProcessId); + Assert.Equal(0, port.CurrentProcessModuleCalls); + Assert.Equal(1, port.ExplicitProcessModuleCalls); + } + + [Theory] + [InlineData(InspectionStatus.NotFound, CheatEngineFailureKind.NotFound)] + [InlineData(InspectionStatus.DestinationTooSmall, CheatEngineFailureKind.ResultLimitExceeded)] + [InlineData(InspectionStatus.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(InspectionStatus.LuaFailure, CheatEngineFailureKind.LuaError)] + [InlineData(InspectionStatus.InvalidResult, CheatEngineFailureKind.InvalidHostResult)] + [InlineData((InspectionStatus) 999, CheatEngineFailureKind.Unknown)] + public void InspectionStatusesMapToStableClientFailures(InspectionStatus status, CheatEngineFailureKind expectedKind) + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() { MemoryRegionStatus = status }; + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryGetMemoryRegion(new Address(0x1234), out MemoryRegionInfo region, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, region); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal("Inspection.GetMemoryRegion", failure.Operation); + } + + [Fact] + public void CollectionAndScalarQueriesForwardInputsAndCopyOnlyTheWrittenEntries() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() { SectionsWritten = 1, RegionsWritten = 1, ResolvedAddress = new Address(0xC0FFEE) }; + InspectionClient client = CreateClient(lifetime, port); + ModuleName module = new("fixture.exe"); + SymbolExpression symbol = new("fixture+10"); + AddressResolutionOptions options = new(true, true); + + Assert.True(client.TryGetModuleSections(module, new InspectionCollectionRequest(2), + out ImmutableArray sections, out CheatEngineFailure sectionsFailure, + TestContext.Current.CancellationToken)); + Assert.True(client.TryGetMemoryRegions(new InspectionCollectionRequest(2), + out ImmutableArray regions, out CheatEngineFailure regionsFailure, + TestContext.Current.CancellationToken)); + Assert.True(client.TryGetSymbol(symbol, out SymbolInfo returnedSymbol, out CheatEngineFailure symbolFailure, + TestContext.Current.CancellationToken)); + Assert.True(client.TryResolveAddress(symbol, options, out Address resolved, out CheatEngineFailure addressFailure, + TestContext.Current.CancellationToken)); + + Assert.Single(sections); + Assert.Single(regions); + Assert.Equal(default, sectionsFailure); + Assert.Equal(default, regionsFailure); + Assert.Equal(default, returnedSymbol); + Assert.Equal(default, symbolFailure); + Assert.Equal(new Address(0xC0FFEE), resolved); + Assert.Equal(default, addressFailure); + Assert.Equal(module, port.LastSectionModule); + Assert.Equal(2, port.LastSectionBufferLength); + Assert.Equal(2, port.LastRegionBufferLength); + Assert.Equal(symbol, port.LastSymbolExpression); + Assert.Equal(symbol, port.LastAddressExpression); + Assert.Equal(options, port.LastAddressOptions); + } + + [Theory] + [InlineData(true, "playerHealth", true, CheatEngineFailureKind.Unknown)] + [InlineData(false, null, false, CheatEngineFailureKind.NotFound)] + [InlineData(true, null, false, CheatEngineFailureKind.InvalidHostResult)] + public void ResolveNameDistinguishesANameFromNotFoundAndInvalidHostResults(bool resolveName, string? name, + bool expectedSuccess, CheatEngineFailureKind expectedKind) + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() { ResolveNameResult = resolveName, ResolvedName = name }; + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryResolveName(new Address(0x1234), out string? result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.Equal(expectedSuccess, succeeded); + Assert.Equal(name, result); + Assert.Equal(new nuint(0x1234), port.LastNameAddress); + if (expectedSuccess) + { + Assert.Equal(default, failure); + } + else + { + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal("Inspection.ResolveName", failure.Operation); + } + } + + [Fact] + public void RegisterSymbolReservesOneNameAndReleasesItWithTheLease() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + SymbolRegistration registration = new("fixture-symbol", new Address(0x401000), true); + + Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? lease, + out CheatEngineFailure firstFailure, TestContext.Current.CancellationToken)); + Assert.False(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? duplicate, + out CheatEngineFailure duplicateFailure, TestContext.Current.CancellationToken)); + Assert.NotNull(lease); + Assert.Null(duplicate); + Assert.Equal(default, firstFailure); + Assert.Equal(CheatEngineFailureKind.OperationRejected, duplicateFailure.Kind); + Assert.Equal(1, port.RegisterCalls); + Assert.Equal("fixture-symbol", port.LastRegisteredName); + Assert.Equal(new nuint(0x401000), port.LastRegisteredAddress); + Assert.True(port.LastRegisteredDoNotSave); + + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["fixture-symbol"], port.UnregisteredNames); + Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? retry, + out CheatEngineFailure retryFailure, TestContext.Current.CancellationToken)); + Assert.NotNull(retry); + Assert.Equal(default, retryFailure); + retry.Dispose(); + } + + [Fact] + public void CancelledInspectionDoesNotContactTheSdkPort() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = client.TryGetModules(new InspectionCollectionRequest(1), out ImmutableArray modules, + out CheatEngineFailure failure, cancellationToken: cancellation.Token); + + Assert.False(succeeded); + Assert.Empty(modules); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(0, port.CurrentProcessModuleCalls); + Assert.Equal(0, port.ExplicitProcessModuleCalls); + } + + private static InspectionClient CreateClient(CoreLifetime lifetime, IInspectionPort port) + { + return new InspectionClient(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime, port); + } + + private sealed class FakeInspectionPort : IInspectionPort + { + internal int CurrentProcessModuleCalls { get; private set; } + internal int ExplicitProcessModuleCalls { get; private set; } + internal int LastModuleBufferLength { get; private set; } + internal TargetProcessId? LastModuleProcessId { get; private set; } + internal int SectionsWritten { get; init; } + internal int RegionsWritten { get; init; } + internal int LastSectionBufferLength { get; private set; } + internal int LastRegionBufferLength { get; private set; } + internal ModuleName LastSectionModule { get; private set; } + internal SymbolExpression LastSymbolExpression { get; private set; } + internal SymbolExpression LastAddressExpression { get; private set; } + internal AddressResolutionOptions LastAddressOptions { get; private set; } + internal InspectionStatus ModulesStatus { get; init; } = InspectionStatus.Success; + internal InspectionStatus MemoryRegionStatus { get; init; } = InspectionStatus.Success; + internal Address ResolvedAddress { get; init; } + internal bool ResolveNameResult { get; init; } + internal string? ResolvedName { get; init; } + internal nuint LastNameAddress { get; private set; } + internal int RegisterCalls { get; private set; } + internal string? LastRegisteredName { get; private set; } + internal nuint LastRegisteredAddress { get; private set; } + internal bool LastRegisteredDoNotSave { get; private set; } + internal List UnregisteredNames { get; } = []; + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + CurrentProcessModuleCalls++; + LastModuleBufferLength = destination.Length; + written = ModulesWritten; + return ModulesStatus; + } + + public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] destination, out int written) + { + ExplicitProcessModuleCalls++; + LastModuleProcessId = processId; + LastModuleBufferLength = destination.Length; + written = ModulesWritten; + return ModulesStatus; + } + + public int ModulesWritten { get; init; } + + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, out int written) + { + LastSectionModule = moduleName; + LastSectionBufferLength = destination.Length; + written = SectionsWritten; + return InspectionStatus.Success; + } + + public InspectionStatus EnumerateMemoryRegions(MemoryRegionInfo[] destination, out int written) + { + LastRegionBufferLength = destination.Length; + written = RegionsWritten; + return InspectionStatus.Success; + } + + public InspectionStatus GetMemoryRegion(Address address, out MemoryRegionInfo region) + { + region = default; + return MemoryRegionStatus; + } + + public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol) + { + LastSymbolExpression = expression; + symbol = default; + return InspectionStatus.Success; + } + + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, out Address address) + { + LastAddressExpression = expression; + LastAddressOptions = options; + address = ResolvedAddress; + return InspectionStatus.Success; + } + + public bool TryResolveName(nuint address, out string? name) + { + LastNameAddress = address; + name = ResolvedName; + return ResolveNameResult; + } + + public void RegisterSymbol(string name, nuint address, bool doNotSave) + { + RegisterCalls++; + LastRegisteredName = name; + LastRegisteredAddress = address; + LastRegisteredDoNotSave = doNotSave; + } + + public void UnregisterSymbol(string name) + { + UnregisteredNames.Add(name); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientCoverageTests.cs new file mode 100644 index 0000000..142c531 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientCoverageTests.cs @@ -0,0 +1,28 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class InspectionClientCoverageTests +{ + [Fact] + public void ConstructorRejectsANullDispatcher() + { + ArgumentNullException exception = Assert.Throws(() => + new InspectionClient(null!, InertCoreLifetime.Create())); + + Assert.Equal("dispatcher", exception.ParamName); + } + + [Fact] + public void ConstructorRejectsANullLifetime() + { + SdkMainThreadDispatcher dispatcher = new(InertCoreLifetime.Create()); + + ArgumentNullException exception = Assert.Throws(() => + new InspectionClient(dispatcher, null!)); + + Assert.Equal("lifetime", exception.ParamName); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs new file mode 100644 index 0000000..99835e8 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; + +using CheatEngine.Client.Core.Domains; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class LocalProcessHostTests +{ + [Fact] + public void TryGetLocalProcessCopiesMetadataForTheCurrentManagedProcess() + { + using Process current = Process.GetCurrentProcess(); + LocalProcessHost host = new(); + + bool succeeded = host.TryGetLocalProcess(current.Id, out LocalProcessInfo process); + + Assert.True(succeeded); + Assert.Equal(current.Id, process.Id); + Assert.Equal(current.ProcessName, process.Name); + } + + [Fact] + public void TryGetLocalProcessReturnsFalseForAnInvalidProcessId() + { + LocalProcessHost host = new(); + + bool succeeded = host.TryGetLocalProcess(-1, out LocalProcessInfo process); + + Assert.False(succeeded); + Assert.Equal(default, process); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs new file mode 100644 index 0000000..e2e9a99 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs @@ -0,0 +1,17 @@ +using CheatEngine.Client.Core.Domains; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class LuaRuntimeProbeTests +{ + [Fact] + public void GetCheatEngineVersionRequiresAnEnabledPluginContext() + { + LuaRuntimeProbe probe = new(); + + InvalidOperationException exception = + Assert.Throws(() => probe.GetCheatEngineVersion()); + + Assert.Contains("plugin is not enabled", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs new file mode 100644 index 0000000..456f02e --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs @@ -0,0 +1,295 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class MemoryClientBehaviorCoverageTests +{ + private static readonly Address _address = new(0x405000); + + [Fact] + public void TypedReadForwardsCancellationAndReturnsTheCodecValue() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + RecordingDispatcher dispatcher = new(); + ProbeCodec codec = new() { ReadValue = 1337 }; + MemoryClient client = new(dispatcher); + + bool succeeded = client.TryRead(new MemoryReadRequest(_address, codec), out int value, + out CheatEngineFailure failure, cancellationToken); + + Assert.True(succeeded); + Assert.Equal(1337, value); + Assert.Equal(default, failure); + Assert.Equal(1, dispatcher.ActionInvocationCount); + Assert.Equal(cancellationToken, dispatcher.LastCancellationToken); + Assert.Equal(1, codec.ReadCount); + Assert.Equal(_address, codec.LastReadAddress); + Assert.NotNull(codec.LastReadContext); + } + + [Fact] + public void TypedWriteForwardsCancellationAndPreservesTheValueForTheCodec() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + RecordingDispatcher dispatcher = new(); + ProbeCodec codec = new(); + MemoryClient client = new(dispatcher); + + bool succeeded = client.TryWrite(new MemoryWriteRequest(_address, 42, codec), + out CheatEngineFailure failure, cancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(1, dispatcher.ActionInvocationCount); + Assert.Equal(cancellationToken, dispatcher.LastCancellationToken); + Assert.Equal(1, codec.WriteCount); + Assert.Equal(_address, codec.LastWriteAddress); + Assert.Equal(42, codec.LastWrittenValue); + Assert.NotNull(codec.LastWriteContext); + } + + [Fact] + public void FailedCodecReadUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt() + { + ProbeCodec codec = new() { ReadSucceeds = false }; + MemoryClient client = new(new RecordingDispatcher()); + MemoryReadRequest request = new(_address, codec); + + bool succeeded = client.TryRead(request, out int value, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + CheatEngineOperationException exception = Assert.Throws(() => + client.Read(request, TestContext.Current.CancellationToken)); + + Assert.False(succeeded); + Assert.Equal(default, value); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, failure.Kind); + Assert.Equal("Memory.Read", failure.Operation); + Assert.Equal("The codec for 'Int32' rejected the target-memory read.", failure.Message); + Assert.Equal(failure, exception.Failure); + Assert.Equal(2, codec.ReadCount); + } + + [Fact] + public void FailedCodecWriteUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt() + { + ProbeCodec codec = new() { WriteSucceeds = false }; + MemoryClient client = new(new RecordingDispatcher()); + MemoryWriteRequest request = new(_address, 77, codec); + + bool succeeded = client.TryWrite(request, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + CheatEngineOperationException exception = Assert.Throws(() => + client.Write(request, TestContext.Current.CancellationToken)); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.MemoryWriteFailed, failure.Kind); + Assert.Equal("Memory.Write", failure.Operation); + Assert.Equal("The codec for 'Int32' rejected the target-memory write.", failure.Message); + Assert.Equal(failure, exception.Failure); + Assert.Equal(2, codec.WriteCount); + Assert.Equal(77, codec.LastWrittenValue); + } + + [Fact] + public void CancelledDispatchPreventsCodecExecutionAndPreservesTheCancellationFailure() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + CancellationToken cancellationToken = cancellation.Token; + CancellationAwareDispatcher dispatcher = new(); + ProbeCodec codec = new(); + MemoryClient client = new(dispatcher); + MemoryReadRequest readRequest = new(_address, codec); + MemoryWriteRequest writeRequest = new(_address, 9, codec); + + bool readSucceeded = client.TryRead(readRequest, out int value, out CheatEngineFailure readFailure, + cancellationToken); + bool writeSucceeded = client.TryWrite(writeRequest, out CheatEngineFailure writeFailure, cancellationToken); + + Assert.False(readSucceeded); + Assert.Equal(default, value); + Assert.Equal(CheatEngineFailureKind.Cancelled, readFailure.Kind); + Assert.Equal("Test.Dispatcher", readFailure.Operation); + Assert.False(writeSucceeded); + Assert.Equal(CheatEngineFailureKind.Cancelled, writeFailure.Kind); + Assert.Equal("Test.Dispatcher", writeFailure.Operation); + Assert.Equal(2, dispatcher.ActionInvocationCount); + Assert.Equal(cancellationToken, dispatcher.LastCancellationToken); + Assert.Equal(0, codec.ReadCount); + Assert.Equal(0, codec.WriteCount); + } + + [Fact] + public void ConstructorRejectsANullDispatcher() + { + Assert.Throws(() => new MemoryClient(null!)); + } + + private sealed class ProbeCodec : IMemoryCodec + { + internal int ReadValue + { + get; + init; + } + + internal bool ReadSucceeds + { + get; + init; + } = true; + + internal bool WriteSucceeds + { + get; + init; + } = true; + + internal int ReadCount + { + get; + private set; + } + + internal int WriteCount + { + get; + private set; + } + + internal Address LastReadAddress + { + get; + private set; + } + + internal Address LastWriteAddress + { + get; + private set; + } + + internal int LastWrittenValue + { + get; + private set; + } + + internal IMemoryReadContext? LastReadContext + { + get; + private set; + } + + internal IMemoryWriteContext? LastWriteContext + { + get; + private set; + } + + public bool TryRead(IMemoryReadContext context, Address address, out int value) + { + LastReadContext = context; + LastReadAddress = address; + ReadCount++; + value = ReadValue; + return ReadSucceeds; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + { + LastWriteContext = context; + LastWriteAddress = address; + LastWrittenValue = value; + WriteCount++; + return WriteSucceeds; + } + } + + private class RecordingDispatcher : ICheatEngineDispatcher + { + internal int ActionInvocationCount + { + get; + private set; + } + + internal CancellationToken LastCancellationToken + { + get; + private set; + } + + public bool IsMainThread => true; + + public virtual bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + RecordActionInvocation(cancellationToken); + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + LastCancellationToken = cancellationToken; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + + protected void RecordActionInvocation(CancellationToken cancellationToken) + { + ActionInvocationCount++; + LastCancellationToken = cancellationToken; + } + } + + private sealed class CancellationAwareDispatcher : RecordingDispatcher + { + public override bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + RecordActionInvocation(cancellationToken); + if (!cancellationToken.IsCancellationRequested) + { + callback(); + failure = default; + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs new file mode 100644 index 0000000..0c8fd6f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs @@ -0,0 +1,270 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class MemoryClientDispatchFailureTests +{ + private static readonly Address Address = new(0x1234); + + [Fact] + public void PrimitiveAndCodecReadsPreserveTheDispatcherFailureWithoutExecutingTargetMemory() + { + CheatEngineFailure expected = Failure("Test.Read"); + MemoryClient client = new(new RejectingDispatcher(expected)); + MemoryReadRequest request = new(Address, new NeverUsedCodec()); + + bool primitiveSucceeded = client.TryReadPrimitive(Address, out int primitive, + out CheatEngineFailure primitiveFailure, + TestContext.Current.CancellationToken); + bool codecSucceeded = client.TryRead(request, out int value, out CheatEngineFailure codecFailure, + TestContext.Current.CancellationToken); + + Assert.False(primitiveSucceeded); + Assert.Equal(0, primitive); + Assert.Equal(expected, primitiveFailure); + Assert.False(codecSucceeded); + Assert.Equal(0, value); + Assert.Equal(expected, codecFailure); + } + + [Fact] + public void PrimitiveAndCodecWritesPreserveTheDispatcherFailureWithoutExecutingTargetMemory() + { + CheatEngineFailure expected = Failure("Test.Write"); + MemoryClient client = new(new RejectingDispatcher(expected)); + MemoryWriteRequest request = new(Address, 42, new NeverUsedCodec()); + + bool primitiveSucceeded = client.TryWritePrimitive(Address, 42, out CheatEngineFailure primitiveFailure, + TestContext.Current.CancellationToken); + bool codecSucceeded = client.TryWrite(request, out CheatEngineFailure codecFailure, + TestContext.Current.CancellationToken); + + Assert.False(primitiveSucceeded); + Assert.Equal(expected, primitiveFailure); + Assert.False(codecSucceeded); + Assert.Equal(expected, codecFailure); + } + + [Fact] + public void ByteAndStringOperationsPreserveTheDispatcherFailureAndEmptyReadResults() + { + CheatEngineFailure expected = Failure("Test.Copy"); + MemoryClient client = new(new RejectingDispatcher(expected)); + MemoryBytesReadRequest byteRead = new(Address, 2); + MemoryBytesWriteRequest byteWrite = new(Address, [0x10, 0x20]); + MemoryStringReadRequest stringRead = new(Address, 12, true); + MemoryStringWriteRequest stringWrite = new(Address, "health", true); + + Assert.False(client.TryReadBytes(byteRead, out ImmutableArray bytes, + out CheatEngineFailure byteReadFailure, + TestContext.Current.CancellationToken)); + Assert.True(bytes.IsEmpty); + Assert.Equal(expected, byteReadFailure); + Assert.False(client.TryWriteBytes(byteWrite, out CheatEngineFailure byteWriteFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(expected, byteWriteFailure); + Assert.False(client.TryReadString(stringRead, out string? text, out CheatEngineFailure stringReadFailure, + TestContext.Current.CancellationToken)); + Assert.Null(text); + Assert.Equal(expected, stringReadFailure); + Assert.False(client.TryWriteString(stringWrite, out CheatEngineFailure stringWriteFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(expected, stringWriteFailure); + } + + [Fact] + public void PointerResolutionPreservesTheDispatcherFailureAndDefaultAddress() + { + CheatEngineFailure expected = Failure("Test.Pointer"); + MemoryClient client = new(new RejectingDispatcher(expected)); + PointerChainRequest request = new(Address, [4L, 8L]); + + bool succeeded = client.TryResolvePointerChain(request, out Address actual, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, actual); + Assert.Equal(expected, failure); + } + + [Fact] + public void UnsupportedPrimitiveTypesReturnTheSpecificUnsupportedFailureWithoutAccessingTheHost() + { + MemoryClient client = new(new InlineDispatcher()); + + bool readSucceeded = client.TryReadPrimitive(Address, out DateTime readValue, + out CheatEngineFailure readFailure, TestContext.Current.CancellationToken); + bool writeSucceeded = client.TryWritePrimitive(Address, DateTime.UnixEpoch, out CheatEngineFailure writeFailure, + TestContext.Current.CancellationToken); + + Assert.False(readSucceeded); + Assert.Equal(default, readValue); + Assert.Equal(CheatEngineFailureKind.Unsupported, readFailure.Kind); + Assert.Equal("Memory.ReadPrimitive", readFailure.Operation); + Assert.False(writeSucceeded); + Assert.Equal(CheatEngineFailureKind.Unsupported, writeFailure.Kind); + Assert.Equal("Memory.WritePrimitive", writeFailure.Operation); + } + + [Fact] + public void ReadAndWriteConvenienceMethodsThrowTheClassifiedDispatcherFailure() + { + MemoryClient client = new(new RejectingDispatcher(Failure("Test.Convenience"))); + MemoryReadRequest read = new(Address, new NeverUsedCodec()); + MemoryWriteRequest write = new(Address, 42, new NeverUsedCodec()); + + CheatEngineClientLifecycleException primitiveException = + Assert.Throws(() => + client.ReadPrimitive(Address, TestContext.Current.CancellationToken)); + CheatEngineClientLifecycleException readException = Assert.Throws(() => + client.Read(read, TestContext.Current.CancellationToken)); + CheatEngineClientLifecycleException writeException = Assert.Throws(() => + client.Write(write, TestContext.Current.CancellationToken)); + + Assert.Equal("Test.Convenience", primitiveException.Failure.Operation); + Assert.Equal("Test.Convenience", readException.Failure.Operation); + Assert.Equal("Test.Convenience", writeException.Failure.Operation); + } + + [Theory] + [InlineData("bytes")] + [InlineData("string")] + [InlineData("pointer")] + public void InvalidDefaultRequestCannotReachTheDispatcher(string requestKind) + { + MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch"))); + + switch (requestKind) + { + case "bytes": + Assert.Throws(() => + client.TryReadBytes(default, out _, out _, TestContext.Current.CancellationToken)); + break; + case "string": + Assert.Throws(() => + client.TryReadString(default, out _, out _, TestContext.Current.CancellationToken)); + break; + default: + Assert.Throws(() => + client.TryResolvePointerChain(default, out _, out _, TestContext.Current.CancellationToken)); + break; + } + } + + [Fact] + public void DefaultStringAndByteWritesAreRejectedBeforeDispatch() + { + MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch"))); + + Assert.Throws(() => + client.TryWriteBytes(default, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => + client.TryWriteString(default, out _, TestContext.Current.CancellationToken)); + } + + private static CheatEngineFailure Failure(string operation) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, "The dispatcher rejected work."); + } + + private sealed class NeverUsedCodec : IMemoryCodec + { + public bool TryRead(IMemoryReadContext context, Address address, out int value) + { + throw new InvalidOperationException("The rejecting dispatcher must prevent codec execution."); + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + { + throw new InvalidOperationException("The rejecting dispatcher must prevent codec execution."); + } + } + + private sealed class RejectingDispatcher : ICheatEngineDispatcher + { + private readonly CheatEngineFailure _failure; + + internal RejectingDispatcher(CheatEngineFailure failure) + { + _failure = failure; + } + + public bool IsMainThread => false; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + failure = _failure; + return false; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + result = default; + failure = _failure; + return false; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + callback(); + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + return callback(); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs index 4a3bf96..216d00c 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs @@ -172,7 +172,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul public T Invoke(Func callback, CancellationToken cancellationToken = default) { - if (TryInvoke(callback, out T? result, out var failure, cancellationToken)) + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) { return result; } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs new file mode 100644 index 0000000..624ad8f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs @@ -0,0 +1,51 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class PatternScannerCoverageTests +{ + [Fact] + public void TryScanRejectsAnUninitializedRequestBeforeAccessingThePluginDispatcher() + { + PatternScanner scanner = CreateScanner(); + + bool succeeded = scanner.TryScan(default, out AobScanResult result, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Contains("normalized, non-empty pattern", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void ScanConvertsAnUninitializedRequestFailureToThePublicOperationException() + { + PatternScanner scanner = CreateScanner(); + + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Scan(default, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); + Assert.Equal("Patterns.Scan", exception.Failure.Operation); + Assert.Contains("normalized, non-empty pattern", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ConstructorRejectsANullDispatcher() + { + ArgumentNullException exception = Assert.Throws(() => new PatternScanner(null!)); + + Assert.Equal("dispatcher", exception.ParamName); + } + + private static PatternScanner CreateScanner() + { + return new PatternScanner(new SdkMainThreadDispatcher(InertCoreLifetime.Create())); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs index cf8d4d7..256ae4f 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs @@ -377,7 +377,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul public T Invoke(Func callback, CancellationToken cancellationToken = default) { - if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + if (TryInvoke(callback, out T result, out CheatEngineFailure failure, cancellationToken)) { return result; } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs index 6f30eed..5392fa6 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs @@ -179,6 +179,73 @@ public void GetSnapshotThrowsTheClassifiedFailureWhenTheHostReportsAnInvalidVers Assert.Equal("Dispatcher.Invoke", exception.Failure.Operation); } + [Fact] + public void CapabilityQueriesReturnTheObservedSnapshotEntryOrTheDocumentedUnknownFallback() + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 7); + + bool sdkSucceeded = runtime.TryGetSdkCapability( + RuntimeCapabilityId.CheatEngineVersion, + out RuntimeCapabilityAvailability sdkAvailability, + out CheatEngineFailure sdkFailure, + TestContext.Current.CancellationToken); + bool clientSucceeded = runtime.TryGetClientCapability( + ClientCapabilityId.ProcessSelection, + out ClientCapabilityAvailability clientAvailability, + out CheatEngineFailure clientFailure, + TestContext.Current.CancellationToken); + + Assert.True(sdkSucceeded); + Assert.Equal(default, sdkFailure); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, sdkAvailability.State); + Assert.True(clientSucceeded); + Assert.Equal(default, clientFailure); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, clientAvailability.State); + Assert.Contains("does not probe", clientAvailability.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CapabilityQueriesRejectDefaultIdentifiersBeforeDispatching() + { + InlineDispatcher dispatcher = new(); + RuntimeClient runtime = new(dispatcher, new FakeRuntimeProbe(), static () => 7); + + Assert.Throws(() => runtime.TryGetSdkCapability( + default, out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => runtime.TryGetClientCapability( + default, out _, out _, TestContext.Current.CancellationToken)); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void SnapshotMarksUnavailableAndDetachedHostProbesWithoutInventingRuntimeFacts() + { + RuntimeClient runtime = new( + new InlineDispatcher(), + new FakeRuntimeProbe + { + VersionException = new EngineGlobalUnavailableException("Runtime.Version"), + SystemArchitectureException = + new EngineCapabilityUnavailableException("Runtime.SystemArchitecture"), + TargetAbiException = new EngineGlobalUnavailableException("Runtime.TargetAbi"), + OpenedProcessException = new EngineCapabilityUnavailableException("Runtime.OpenedProcess") + }, + static () => 7); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Null(snapshot.ObservedCheatEngineVersion); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.SystemArchitecture); + Assert.Equal(TargetAbi.Unknown, snapshot.TargetAbi); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, + out RuntimeCapabilityAvailability version)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, version.State); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.SystemArchitecture, + out RuntimeCapabilityAvailability system)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, system.State); + } + private sealed class FakeRuntimeProbe : IRuntimeProbe { internal double ReportedVersion @@ -217,6 +284,30 @@ internal Exception? TargetIs64BitException init; } + internal Exception? VersionException + { + get; + init; + } + + internal Exception? SystemArchitectureException + { + get; + init; + } + + internal Exception? TargetAbiException + { + get; + init; + } + + internal Exception? OpenedProcessException + { + get; + init; + } + internal int TargetIs64BitCallCount { get; @@ -225,21 +316,41 @@ internal int TargetIs64BitCallCount public double GetCheatEngineVersion() { + if (VersionException is not null) + { + throw VersionException; + } + return ReportedVersion; } public int GetSystemArchitecture() { + if (SystemArchitectureException is not null) + { + throw SystemArchitectureException; + } + return SystemArchitectureCode; } public int GetTargetAbi() { + if (TargetAbiException is not null) + { + throw TargetAbiException; + } + return TargetAbiCode; } public long GetOpenedProcessId() { + if (OpenedProcessException is not null) + { + throw OpenedProcessException; + } + return OpenedProcessId; } @@ -325,7 +436,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul public T Invoke(Func callback, CancellationToken cancellationToken = default) { - if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + if (TryInvoke(callback, out T result, out CheatEngineFailure failure, cancellationToken)) { return result; } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs new file mode 100644 index 0000000..193ce53 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs @@ -0,0 +1,26 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class SdkTableRecordMutationPortCoverageTests +{ + [Fact] + public void ParentRelationshipGuardFailsClosedForAnUnknownLinkStatus() + { + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(42), + new MemoryRecordId(12), 1, + static _ => new ParentChainStep((ParentChainStepKind) 99, default)); + + Assert.Equal(TableRecordMutationStatus.HostRejected, status); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ParentRelationshipGuardRequiresAPositiveTraversalBound(int maximumHops) + { + Assert.Throws(() => TableParentRelationshipGuard.Validate(new MemoryRecordId(42), + new MemoryRecordId(12), maximumHops, static _ => ParentChainStep.Root)); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs new file mode 100644 index 0000000..c19a5c8 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs @@ -0,0 +1,20 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class SdkTableRecordMutationPortTests +{ + [Fact] + public void TrySetParentRejectsASelfReferentialRelationshipBeforeAccessingTheAddressList() + { + SdkTableRecordMutationPort port = new(); + MemoryRecordId id = new(42); + + TableRecordMutationStatus status = port.TrySetParent(id, id, out MemoryRecordSnapshot record); + + Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + Assert.Equal(default, record); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs new file mode 100644 index 0000000..ed8a7ab --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs @@ -0,0 +1,81 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class SymbolRegistrationLeaseCleanupCoverageTests +{ + [Fact] + public void DisposeReleasesTheNameAndBecomesTerminalWhenUntrackingFails() + { + List events = []; + InlineDispatcher dispatcher = new(); + SymbolRegistrationLease lease = new( + new SymbolRegistration("fixture-symbol", Address.Zero), + dispatcher, + _ => + { + events.Add("untrack"); + throw new InvalidOperationException("untracking failed"); + }, + name => events.Add("unregister:" + name), + name => events.Add("release-name:" + name)); + + InvalidOperationException exception = Assert.Throws(lease.Dispose); + + Assert.Equal("untracking failed", exception.Message); + Assert.True(lease.IsReleased); + Assert.Equal(["unregister:fixture-symbol", "untrack", "release-name:fixture-symbol"], events); + Assert.Equal(1, dispatcher.InvocationCount); + + lease.Dispose(); + + Assert.Equal(1, dispatcher.InvocationCount); + Assert.Equal(["unregister:fixture-symbol", "untrack", "release-name:fixture-symbol"], events); + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + _ = TryInvoke(callback, out _, cancellationToken); + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + _ = TryInvoke(callback, out TResult result, out _, cancellationToken); + return result; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs index 33cd842..a49fe6d 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs @@ -12,7 +12,7 @@ public sealed class SymbolRegistrationLeaseTests [Fact] public void FailedDispatchKeepsTheLeaseTrackedUntilTheCleanupRetryUnregistersIt() { - List events = new(); + List events = []; CoreResourceRegistry registry = new(); RetriableDispatcher dispatcher = new() { RejectDispatch = true }; SymbolRegistrationLease lease = new( @@ -105,7 +105,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul public TResult Invoke(Func callback, CancellationToken cancellationToken = default) { - if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + if (TryInvoke(callback, out TResult result, out CheatEngineFailure failure, cancellationToken)) { return result; } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs new file mode 100644 index 0000000..3437224 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs @@ -0,0 +1,272 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class TableClientCoverageTests +{ + [Fact] + public void TryFindRejectsAnUninitializedSearchWithoutDispatching() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + + bool succeeded = client.TryFind(default, new MemoryRecordCollectionRequest(8), + out ImmutableArray records, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.True(records.IsEmpty); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Tables.Find", failure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void FindConvertsTheUninitializedSearchRejectionToAnOperationException() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + + CheatEngineOperationException exception = Assert.Throws(() => + client.Find(default, new MemoryRecordCollectionRequest(8), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); + Assert.Equal("Tables.Find", exception.Failure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void NegativeRecordIndexIsRejectedBeforeTheAddressListIsRead() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + + Assert.Throws(() => + client.TryGetRecord(-1, out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void TableImportAndExportFailClosedWhenNoTrustedRootsAreConfigured() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + TrustedTableFile file = new(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", "profile.ct")); + + bool loadSucceeded = client.TryLoadTrustedTable(new TableLoadRequest(file), out CheatEngineFailure loadFailure, + TestContext.Current.CancellationToken); + bool saveSucceeded = client.TrySaveTable(new TableSaveRequest(file), out CheatEngineFailure saveFailure, + TestContext.Current.CancellationToken); + + Assert.False(loadSucceeded); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, loadFailure.Kind); + Assert.Equal("Tables.LoadTrustedTable", loadFailure.Operation); + Assert.False(saveSucceeded); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, saveFailure.Kind); + Assert.Equal("Tables.SaveTable", saveFailure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void ThrowingTableImportAndExportPreserveTheFailClosedPolicyFailure() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + TrustedTableFile file = new(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", "profile.ct")); + + CheatEngineOperationException loadException = Assert.Throws(() => + client.LoadTrustedTable(new TableLoadRequest(file), TestContext.Current.CancellationToken)); + CheatEngineOperationException saveException = Assert.Throws(() => + client.SaveTable(new TableSaveRequest(file), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, loadException.Failure.Kind); + Assert.Equal("Tables.LoadTrustedTable", loadException.Failure.Operation); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, saveException.Failure.Kind); + Assert.Equal("Tables.SaveTable", saveException.Failure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void ReadOperationsPropagateTheExactDispatcherFailureWithoutReadingSdkStatics() + { + CheatEngineFailure expected = Failure(); + RejectingDispatcher dispatcher = new(expected); + TableClient client = CreateClient(dispatcher); + MemoryRecordId id = new(42); + + Assert.False(client.TryGetCurrent(out AddressTableSnapshot current, out CheatEngineFailure currentFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, current); + Assert.Equal(expected, currentFailure); + Assert.False(client.TryGetSnapshot(new MemoryRecordCollectionRequest(8), out AddressTableSnapshot snapshot, + out CheatEngineFailure snapshotFailure, TestContext.Current.CancellationToken)); + Assert.Equal(default, snapshot); + Assert.Equal(expected, snapshotFailure); + Assert.False(client.TryGetRecord(0, out MemoryRecordSnapshot indexedRecord, + out CheatEngineFailure indexedFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, indexedRecord); + Assert.Equal(expected, indexedFailure); + Assert.False(client.TryGetRecord(id, out MemoryRecordSnapshot identifiedRecord, + out CheatEngineFailure identifiedFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, identifiedRecord); + Assert.Equal(expected, identifiedFailure); + Assert.False(client.TryGetSelected(out MemoryRecordSnapshot selectedRecord, + out CheatEngineFailure selectedFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, selectedRecord); + Assert.Equal(expected, selectedFailure); + Assert.Equal(5, dispatcher.InvocationCount); + } + + [Fact] + public void MutatingOperationsPropagateTheExactDispatcherFailureWithoutInvokingTheMutationPort() + { + CheatEngineFailure expected = Failure(); + RejectingDispatcher dispatcher = new(expected); + RecordingMutationPort mutations = new(); + TableClient client = new(dispatcher, CoreClientPolicy.SafeDefaults, mutations); + MemoryRecordId id = new(42); + MemoryRecordDefinition definition = new("Health", "game.exe+10", "100", VariableType.Dword); + MemoryRecordUpdate update = new(id, value: "101"); + + Assert.False(client.TryCreate(definition, out MemoryRecordSnapshot created, + out CheatEngineFailure createFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, created); + Assert.Equal(expected, createFailure); + Assert.False(client.TryUpdate(update, out MemoryRecordSnapshot updated, out CheatEngineFailure updateFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, updated); + Assert.Equal(expected, updateFailure); + Assert.False(client.TryDelete(id, out CheatEngineFailure deleteFailure, TestContext.Current.CancellationToken)); + Assert.Equal(expected, deleteFailure); + Assert.False(client.TrySetActive(id, true, out MemoryRecordSnapshot activeRecord, + out CheatEngineFailure activeFailure, TestContext.Current.CancellationToken)); + Assert.Equal(default, activeRecord); + Assert.Equal(expected, activeFailure); + Assert.False(client.TrySetParent(id, new MemoryRecordId(12), out MemoryRecordSnapshot parentRecord, + out CheatEngineFailure parentFailure, TestContext.Current.CancellationToken)); + Assert.Equal(default, parentRecord); + Assert.Equal(expected, parentFailure); + Assert.Equal(5, dispatcher.InvocationCount); + Assert.Equal(0, mutations.InvocationCount); + } + + [Fact] + public void HierarchyOperationsPropagateTheExactDispatcherFailure() + { + CheatEngineFailure expected = Failure(); + RejectingDispatcher dispatcher = new(expected); + TableClient client = CreateClient(dispatcher); + + Assert.False(client.TryGetHierarchy(new MemoryRecordId(42), new MemoryRecordHierarchyRequest(8, 2), + out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, + TestContext.Current.CancellationToken)); + + Assert.Equal(default, hierarchy); + Assert.Equal(expected, failure); + Assert.Equal(1, dispatcher.InvocationCount); + } + + private static TableClient CreateClient(RejectingDispatcher dispatcher) + { + return new TableClient(dispatcher, CoreClientPolicy.SafeDefaults); + } + + private static CheatEngineFailure Failure() + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Test.TableDispatcher", + "The host dispatcher denied the operation."); + } + + private sealed class RecordingMutationPort : ITableRecordMutationPort + { + internal int InvocationCount + { + get; + private set; + } + + public TableRecordMutationStatus TryDelete(MemoryRecordId id) + { + InvocationCount++; + return TableRecordMutationStatus.Success; + } + + public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + InvocationCount++; + record = default; + return TableRecordMutationStatus.Success; + } + } + + private sealed class RejectingDispatcher : ICheatEngineDispatcher + { + private readonly CheatEngineFailure _failure; + + internal RejectingDispatcher(CheatEngineFailure failure) + { + _failure = failure; + } + + internal int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => false; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + failure = _failure; + return false; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + result = default; + failure = _failure; + return false; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs index daafcdb..70a494a 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs @@ -15,10 +15,7 @@ public sealed class TableClientMutationTests [Fact] public void TryDeleteDispatchesTheRequestedRecordAndReturnsSuccess() { - FakeRecordMutationPort mutations = new() - { - DeleteStatus = TableRecordMutationStatus.Success - }; + FakeRecordMutationPort mutations = new() { DeleteStatus = TableRecordMutationStatus.Success }; TableClient client = CreateClient(mutations); bool succeeded = @@ -68,8 +65,7 @@ public void TrySetParentPassesBothIdsAndReturnsTheCopiedPostMutationSnapshot() MemoryRecordSnapshot expected = Snapshot(41, "Ammo"); FakeRecordMutationPort mutations = new() { - SetParentStatus = TableRecordMutationStatus.Success, - SetParentRecord = expected + SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = expected }; TableClient client = CreateClient(mutations); @@ -89,8 +85,7 @@ public void TrySetParentWithNullParentRequestsTheAddressListRoot() { FakeRecordMutationPort mutations = new() { - SetParentStatus = TableRecordMutationStatus.Success, - SetParentRecord = Snapshot(41, "Ammo") + SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = Snapshot(41, "Ammo") }; TableClient client = CreateClient(mutations); @@ -126,10 +121,7 @@ public void TrySetParentRejectsARecordAsItsOwnParentBeforeDispatching() [Fact] public void TrySetParentMapsAHostRejectedMutationToTheExactHostFailure() { - FakeRecordMutationPort mutations = new() - { - SetParentStatus = TableRecordMutationStatus.HostRejected - }; + FakeRecordMutationPort mutations = new() { SetParentStatus = TableRecordMutationStatus.HostRejected }; TableClient client = CreateClient(mutations); bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(12), @@ -346,7 +338,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul public T Invoke(Func callback, CancellationToken cancellationToken = default) { - if (TryInvoke(callback, out T? result, out var failure, cancellationToken)) + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) { return result; } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs new file mode 100644 index 0000000..f726c38 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs @@ -0,0 +1,24 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class UnavailableValueScannerExceptionCoverageTests +{ + [Fact] + public void CreateSessionThrowsTheCancellationFailureWhenCancellationPrecedesTheCapabilityGate() + { + UnavailableValueScanner scanner = new(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = scanner.CreateSession(cancellation.Token); + }); + + Assert.Equal(CheatEngineFailureKind.Cancelled, exception.Failure.Kind); + Assert.Equal("Scans.CreateSession", exception.Failure.Operation); + Assert.Equal("The operation was cancelled before Cheat Engine work began.", exception.Failure.Message); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyFailureCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyFailureCoverageTests.cs new file mode 100644 index 0000000..1413add --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyFailureCoverageTests.cs @@ -0,0 +1,78 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreClientPolicyFailureCoverageTests +{ + [Fact] + public void TryAuthorizeTableFileRejectsAnEmptyPathBeforeItCanBeNormalized() + { + CoreClientPolicy policy = CoreClientPolicy.SafeDefaults; + + bool allowed = policy.TryAuthorizeTableFile(" ", false, out string reason); + + Assert.False(allowed); + Assert.Equal("The table path must be non-empty and fully qualified.", reason); + } + + [Fact] + public void TryAuthorizeTableFileRejectsAPathThatCannotBeNormalizedSafely() + { + CoreClientPolicy policy = CoreClientPolicy.SafeDefaults; + string pathContainingANullCharacter = new('\0', 1); + + bool allowed = policy.TryAuthorizeTableFile(pathContainingANullCharacter, false, out string reason); + + Assert.False(allowed); + Assert.Equal("The table path cannot be normalized safely.", reason); + } + + [Fact] + public void TryAuthorizeTableFileRejectsAConfiguredRootThatNoLongerExists() + { + string root = Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", Guid.NewGuid().ToString("N")); + CoreClientPolicy policy = new([root], false); + string candidate = Path.Combine(root, "profile.ct"); + + bool allowed = policy.TryAuthorizeTableFile(candidate, false, out string reason); + + Assert.False(allowed); + Assert.Equal("The configured table root does not currently exist and cannot be verified.", reason); + } + + [Fact] + public void TryAuthorizeTableFileRejectsAnExportWhoseParentDirectoryDoesNotExist() + { + using TemporaryDirectory directory = new(); + CoreClientPolicy policy = new([directory.Path], false); + string candidate = Path.Combine(directory.Path, "missing", "profile.ct"); + + bool allowed = policy.TryAuthorizeTableFile(candidate, false, out string reason); + + Assert.False(allowed); + Assert.Equal("A trusted table export destination must have an existing parent directory.", reason); + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "CheatEngine.Client.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + internal string Path + { + get; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs index 575422d..da542e9 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs @@ -1,5 +1,7 @@ using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.Client.Core.Tests.Infrastructure; @@ -28,4 +30,41 @@ public void FromExceptionPreservesDedicatedActivationExpiryClassification() Assert.Equal(exception.Message, failure.Message); Assert.Same(exception, failure.Exception); } + + [Theory] + [InlineData("lifecycle", CheatEngineFailureKind.InvalidState)] + [InlineData("capability", CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData("global", CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData("operation", CheatEngineFailureKind.OperationRejected)] + [InlineData("lua", CheatEngineFailureKind.LuaError)] + [InlineData("binding", CheatEngineFailureKind.BindingError)] + [InlineData("marshalling", CheatEngineFailureKind.InvalidHostResult)] + [InlineData("disposed", CheatEngineFailureKind.InvalidState)] + [InlineData("argument", CheatEngineFailureKind.OperationRejected)] + [InlineData("invalid-operation", CheatEngineFailureKind.OperationRejected)] + [InlineData("unknown", CheatEngineFailureKind.Unknown)] + public void FromExceptionMapsEachStableClientFailureCategory(string scenario, CheatEngineFailureKind expectedKind) + { + Exception exception = scenario switch + { + "lifecycle" => new CheatEngineClientLifecycleException("Client.Test", "Lifecycle stopped."), + "capability" => new EngineCapabilityUnavailableException("Client.Test"), + "global" => new EngineGlobalUnavailableException("Client.Test"), + "operation" => new EngineOperationFailedException("Client.Test", "Host rejected the operation."), + "lua" => new EngineLuaException("Client.Test", LuaStatus.RuntimeError), + "binding" => new EngineBindingException("Client.Test"), + "marshalling" => new EngineMarshallingException("Client.Test", EngineMarshallingDirection.Result, + "a stable result", "an invalid result"), + "disposed" => new ObjectDisposedException("Client.Test"), + "argument" => new ArgumentException("Invalid request.", nameof(scenario)), + "invalid-operation" => new InvalidOperationException("Invalid state."), + _ => new NotSupportedException("Unexpected host failure.") + }; + + CheatEngineFailure failure = CoreFailureFactory.FromException("Client.MapFailure", exception); + + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal("Client.MapFailure", failure.Operation); + Assert.Same(exception, failure.Exception); + } } diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs new file mode 100644 index 0000000..c5d2896 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs @@ -0,0 +1,103 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreLifetimeBehaviorTests +{ + [Fact] + public void ActiveContextExposesItsEpochAndAdmitsOrdinaryWork() + { + using ControlledCoreLifetimeContext context = new() { Epoch = 42 }; + using CoreLifetime lifetime = new(context); + + lifetime.ThrowIfInactive("Test.Work"); + + Assert.Equal(42, lifetime.Epoch); + Assert.True(lifetime.IsActivationCurrent); + Assert.True(lifetime.IsCurrent); + Assert.True(lifetime.CanDispatch); + } + + [Fact] + public void StoppingContextRejectsOrdinaryWorkButAllowsMainThreadCleanupScope() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + context.Stop(); + + CheatEngineClientLifecycleException rejected = Assert.Throws(() => + lifetime.ThrowIfInactive("Test.Work")); + Assert.Equal("Test.Work", rejected.Failure.Operation); + Assert.False(lifetime.CanDispatch); + + using (lifetime.EnterCleanupScope()) + { + Assert.True(lifetime.CanDispatch); + lifetime.ThrowIfDispatchAllowed("Test.Cleanup"); + } + + Assert.False(lifetime.CanDispatch); + } + + [Fact] + public void CleanupScopeRequiresTheCapturedMainThread() + { + using ControlledCoreLifetimeContext context = new() { IsMainThread = false }; + using CoreLifetime lifetime = new(context); + + CheatEngineClientLifecycleException exception = Assert.Throws( + lifetime.EnterCleanupScope); + + Assert.Equal("Client.EnterCleanupScope", exception.Failure.Operation); + Assert.Contains("main thread", exception.Failure.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StaleContextRejectsAllLifecycleAdmissionsWithActivationExpired() + { + using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; + using CoreLifetime lifetime = new(context); + + CheatEngineActivationExpiredException inactive = Assert.Throws(() => + lifetime.ThrowIfInactive("Test.Inactive")); + CheatEngineActivationExpiredException dispatch = Assert.Throws(() => + lifetime.ThrowIfDispatchAllowed("Test.Dispatch")); + + Assert.Equal("Test.Inactive", inactive.Failure.Operation); + Assert.Equal("Test.Dispatch", dispatch.Failure.Operation); + Assert.False(lifetime.IsActivationCurrent); + Assert.False(lifetime.CanDispatch); + } + + [Fact] + public void DisposeDrainsTrackedResourcesOnlyOnceAndClosesTheLifetime() + { + using ControlledCoreLifetimeContext context = new(); + CoreLifetime lifetime = new(context); + RecordingDisposable resource = new(); + lifetime.Track(resource); + + lifetime.Dispose(); + lifetime.Dispose(); + + Assert.Equal(1, resource.DisposeCount); + Assert.False(lifetime.IsActivationCurrent); + Assert.Throws(() => lifetime.ThrowIfInactive("Test.Disposed")); + } + + private sealed class RecordingDisposable : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs new file mode 100644 index 0000000..7ee41a7 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs @@ -0,0 +1,18 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreLifetimeTests +{ + [Fact] + public void CaptureRejectsClientConstructionOutsideAnEnabledPluginEpoch() + { + CheatEngineClientLifecycleException exception = + Assert.Throws(CoreLifetime.Capture); + + Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); + Assert.Equal("Client.Activate", exception.Failure.Operation); + Assert.Contains("has not enabled a plugin context", exception.Failure.Message, StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryClosedCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryClosedCoverageTests.cs new file mode 100644 index 0000000..517b3e6 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryClosedCoverageTests.cs @@ -0,0 +1,33 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreResourceRegistryClosedCoverageTests +{ + [Fact] + public void DisposeTargetSelectionDoesNotRepeatCleanupAfterTheRegistryHasClosed() + { + RecordingDisposable resource = new(); + CoreResourceRegistry registry = new(); + registry.Track(resource, 9); + + registry.Dispose(); + registry.DisposeTargetSelection(9); + + Assert.Equal(1, resource.DisposeCount); + } + + private sealed class RecordingDisposable : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs index 74bd56a..d49bf21 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs @@ -32,7 +32,7 @@ public void DisposeReleasesDistinctResourcesInReverseRegistrationOrderAndIsIdemp [Fact] public void DisposeContinuesInReverseOrderAfterAnEarlierResourceThrows() { - List events = new(); + List events = []; RecordingDisposable first = new("first", events); RecordingDisposable failing = new("failing", events, new InvalidOperationException("expected")); RecordingDisposable last = new("last", events); @@ -53,7 +53,7 @@ public void DisposeContinuesInReverseOrderAfterAnEarlierResourceThrows() [Fact] public void UntrackRemovesOnlyThatResourceAndReportsWhetherItWasTracked() { - List events = new(); + List events = []; RecordingDisposable retained = new("retained", events); RecordingDisposable removed = new("removed", events); RecordingDisposable untracked = new("untracked", events); @@ -82,7 +82,7 @@ public void TrackAfterDisposeRejectsNewResources() [Fact] public void DisposeTargetSelectionReleasesOnlyMatchingResourcesInReverseRegistrationOrder() { - List events = new(); + List events = []; RecordingDisposable firstSelectionResource = new("first-selection", events); RecordingDisposable activationResource = new("activation", events); RecordingDisposable retainedResource = new("retained", events); @@ -111,7 +111,7 @@ public void DisposeTargetSelectionReleasesOnlyMatchingResourcesInReverseRegistra [Fact] public void DisposeTargetSelectionContinuesAfterFailureAndPreservesTheFirstException() { - List events = new(); + List events = []; RecordingDisposable first = new("first", events); RecordingDisposable failing = new("failing", events, new InvalidOperationException("expected")); RecordingDisposable last = new("last", events); diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs index c46753a..a306e83 100644 --- a/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs @@ -10,7 +10,7 @@ public sealed class TargetSelectionLifetimeTests [Fact] public void AdvanceInvalidatesOnlyThePreviousSelectionAndDisposesItsResourcesInLifoOrder() { - List events = new(); + List events = []; TargetSelectionLifetime lifetime = new(static _ => { }); @@ -39,7 +39,7 @@ public void AdvanceInvalidatesOnlyThePreviousSelectionAndDisposesItsResourcesInL [Fact] public void AdvanceContinuesCleanupAfterAResourceFails() { - List events = new(); + List events = []; TargetSelectionLifetime lifetime = new(static _ => { }); @@ -62,7 +62,7 @@ public void AdvanceContinuesCleanupAfterAResourceFails() [Fact] public void DisposeIsIdempotentAndRejectsNewTargetBoundRegistrations() { - List events = new(); + List events = []; TargetSelectionLifetime lifetime = new(static _ => { }); @@ -80,7 +80,7 @@ public void DisposeIsIdempotentAndRejectsNewTargetBoundRegistrations() [Fact] public void UntrackPreventsTheNextSelectionAdvanceFromDisposingTheReleasedResource() { - List events = new(); + List events = []; TargetSelectionLifetime lifetime = new(static _ => { }); diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs index 7c42260..2a6db32 100644 --- a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs @@ -48,7 +48,7 @@ public void TryExecuteNormalizesAnOperationFailureWithoutDetails() { LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); - bool succeeded = client.TryExecute(new FailingOperation(default), out _, + bool succeeded = client.TryExecute(new FailingOperation(default), out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -65,7 +65,7 @@ public void ExecuteTurnsAnExpectedFailureIntoTheClientException() LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); CheatEngineOperationException exception = Assert.Throws(() => - client.Execute(new FailingOperation(expected), + client.Execute(new FailingOperation(expected), TestContext.Current.CancellationToken)); Assert.Equal(expected, exception.Failure); @@ -78,7 +78,7 @@ public void TryExecuteThrowsActivationExpiredWhenTheContextCannotEnterTheCurrent LuaClient client = new(new ImmediateDispatcher(), static () => 7, static () => false); CheatEngineActivationExpiredException exception = Assert.Throws(() => - client.TryExecute(operation, out _, out _, TestContext.Current.CancellationToken)); + client.TryExecute(operation, out _, out _, TestContext.Current.CancellationToken)); Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); Assert.Equal("Lua.OperationContext", exception.Failure.Operation); @@ -94,7 +94,7 @@ public void TryExecuteHonorsCancellationBeforeTheTypedOperationRuns() RetainingOperation operation = new(); LuaClient client = new(dispatcher, static () => 7, static () => true); - bool succeeded = client.TryExecute( + bool succeeded = client.TryExecute( operation, out _, out CheatEngineFailure failure, @@ -188,7 +188,7 @@ public void Invoke(Action callback, CancellationToken cancellationToken = defaul public TResult Invoke(Func callback, CancellationToken cancellationToken = default) { - if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + if (TryInvoke(callback, out TResult result, out CheatEngineFailure failure, cancellationToken)) { return result; } diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs index 28344c1..941ebcf 100644 --- a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs @@ -12,7 +12,7 @@ public sealed class LuaModuleRegistrationTests public void TryRegisterModuleTracksItsLeaseAndDisposesItThroughTheMainThreadDispatcher() { ImmediateDispatcher dispatcher = new(); - List tracked = new(); + List tracked = []; RecordingModule module = new("diagnostics"); LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => { @@ -127,7 +127,7 @@ public void ForgottenModuleLeasesReleaseInReverseRegistrationOrderFromTheActivat { ImmediateDispatcher dispatcher = new(); CoreResourceRegistry registry = new(); - List events = new(); + List events = []; RecordingModule first = new("first", events: events); RecordingModule second = new("second", events: events); LuaClient client = CreateClient( @@ -174,7 +174,7 @@ public void StaleModuleLeaseDoesNotAttemptToDispatchIntoAChangedActivation() public void DispatcherRejectionLeavesTheLeaseTrackedForTheLaterCleanupDispatch() { ImmediateDispatcher dispatcher = new(); - List tracked = new(); + List tracked = []; RecordingModule module = new("diagnostics"); LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => { @@ -204,7 +204,7 @@ public void DispatcherRejectionLeavesTheLeaseTrackedForTheLaterCleanupDispatch() public void FailedLeaseDisposeRemainsTrackedAndCanBeRetriedByTheActivationCleanupPath() { ImmediateDispatcher dispatcher = new(); - List tracked = new(); + List tracked = []; RecordingModule module = new("diagnostics", unregisterFailureCount: 1); LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => { diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/ControlledCoreLifetimeContext.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/ControlledCoreLifetimeContext.cs new file mode 100644 index 0000000..90ac046 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/ControlledCoreLifetimeContext.cs @@ -0,0 +1,44 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +internal sealed class ControlledCoreLifetimeContext : ICoreLifetimeContext, IDisposable +{ + private readonly CancellationTokenSource _stopping = new(); + + internal long Epoch + { + get; + set; + } = 17; + + internal bool IsCurrent + { + get; + set; + } = true; + + internal bool IsMainThread + { + get; + set; + } = true; + + CancellationToken ICoreLifetimeContext.Stopping => _stopping.Token; + + long ICoreLifetimeContext.Epoch => Epoch; + + bool ICoreLifetimeContext.IsCurrent => IsCurrent; + + bool ICoreLifetimeContext.IsMainThread => IsMainThread; + + public void Dispose() + { + _stopping.Dispose(); + } + + internal void Stop() + { + _stopping.Cancel(); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs new file mode 100644 index 0000000..b7a9562 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; + +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +internal static class InertCoreLifetime +{ + internal static CoreLifetime Create() + { + return (CoreLifetime) RuntimeHelpers.GetUninitializedObject(typeof(CoreLifetime)); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/InlineMainThreadInvoker.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/InlineMainThreadInvoker.cs new file mode 100644 index 0000000..e826a9a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/InlineMainThreadInvoker.cs @@ -0,0 +1,31 @@ +using CheatEngine.Client.Core.Dispatching; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +internal sealed class InlineMainThreadInvoker : IMainThreadInvoker +{ + public Exception? Invoke(Action callback) + { + try + { + callback(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + public MainThreadInvocationResult Invoke(Func callback) + { + try + { + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } +} diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs index a349af8..d138fa7 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs @@ -27,8 +27,7 @@ public void AddCheatEngineClientRegistersDescriptorsThatPassProviderValidationWi using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, - ValidateScopes = true + ValidateOnBuild = true, ValidateScopes = true }); Assert.NotNull(provider); @@ -58,7 +57,8 @@ public void EnableUnsafeLuaExecutionAddsOnlyTheExplicitUnsafeLuaDescriptor() Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); - Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(UnsafeLuaExecutionRegistration)); + Assert.Contains(services, + static descriptor => descriptor.ServiceType == typeof(UnsafeLuaExecutionRegistration)); } [Fact] @@ -119,8 +119,7 @@ public void AddModulePreservesExplicitRegistrationOrder() using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, - ValidateScopes = true + ValidateOnBuild = true, ValidateScopes = true }); using IServiceScope scope = provider.CreateScope(); ICheatEngineClientModule[] modules = scope.ServiceProvider.GetServices().ToArray(); @@ -140,8 +139,7 @@ public void AddModuleConstructsAModuleWithScopedDependencyOncePerActivationScope using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { - ValidateOnBuild = true, - ValidateScopes = true + ValidateOnBuild = true, ValidateScopes = true }); using IServiceScope scope = provider.CreateScope(); ScopedDependencyModule first = Assert.IsType( diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs index a5fcfa5..bb6c810 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/DefaultMemoryCodecsTests.cs @@ -20,17 +20,17 @@ public void AddRegistersEveryBuiltInScalarAndPointerCodecExactlyOnce() 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.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec
)); } diff --git a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs index f45d7fe..ce9ff0e 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs @@ -7,7 +7,7 @@ using MemoryFluent = CheatEngine.Client.Memory.Memory; -namespace CheatEngine.Client.Tests.Memory; +namespace CheatEngine.Client.Fluent.Tests.Memory; public sealed class MemoryAddressBuilderTests { @@ -16,8 +16,8 @@ public void AtWithoutServiceRejectsABuiltInTerminalOperation() { MemoryAddressBuilder builder = MemoryFluent.At(0x401000UL); - InvalidOperationException exception = 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); @@ -159,7 +159,7 @@ public void TryReadWithRejectsMissingBoundMemoryAndNullExplicitArguments() Assert.Throws(() => unbound.TryReadWith( codec, out _, out _, TestContext.Current.CancellationToken)); - Assert.Throws(() => unbound.TryReadWith( + Assert.Throws(() => unbound.TryReadWith( null!, codec, out _, out _, TestContext.Current.CancellationToken)); Assert.Throws(() => unbound.TryReadWith( memory, null!, out int _, out _, TestContext.Current.CancellationToken)); @@ -200,8 +200,9 @@ public void WriteWithRejectsMissingBoundMemoryAndNullExplicitArguments() Int32Codec codec = new(); FakeMemoryClient memory = new(0); - Assert.Throws(() => unbound.WriteWith(77, codec, TestContext.Current.CancellationToken)); - Assert.Throws(() => unbound.WriteWith( + 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)); diff --git a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs index dcc6de9..d6a8ab3 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs @@ -13,7 +13,7 @@ using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Scanning; +namespace CheatEngine.Client.Fluent.Tests.Scanning; public sealed class AobFluentBuilderTests { @@ -207,8 +207,8 @@ public void FirstOrNoneExecuteThrowsThePatternScannerFailure() "AOB scanning is unavailable."); FakePatternScanner scanner = new(expectedFailure); - CheatEngineOperationException exception = Assert.Throws( - () => scanner.Aob("90").FirstOrNone().Execute(TestContext.Current.CancellationToken)); + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Aob("90").FirstOrNone().Execute(TestContext.Current.CancellationToken)); Assert.Equal(expectedFailure, exception.Failure); } @@ -244,8 +244,8 @@ public void TakeExecuteThrowsThePatternScannerFailure() "AOB scanning is unavailable."); FakePatternScanner scanner = new(expectedFailure); - CheatEngineOperationException exception = Assert.Throws( - () => scanner.Aob("90").Take(1).Execute(TestContext.Current.CancellationToken)); + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Aob("90").Take(1).Execute(TestContext.Current.CancellationToken)); Assert.Equal(expectedFailure, exception.Failure); } @@ -319,8 +319,8 @@ public void RequireSingleExecuteThrowsWhenNoMatchExists() { FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false)); - CheatEngineOperationException exception = Assert.Throws( - () => scanner.Aob("90").RequireSingle().Execute(TestContext.Current.CancellationToken)); + 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); diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs index bf7d64d..db80a57 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs @@ -23,9 +23,9 @@ public void ProtectedBaseConstructorAllowsAPublicParameterlessConcretePlugin() { ConstructorInfo? baseConstructor = typeof(CheatEngineClientPlugin).GetConstructor( BindingFlags.Instance | BindingFlags.NonPublic, - binder: null, - types: Type.EmptyTypes, - modifiers: null); + null, + Type.EmptyTypes, + null); ConstructorInfo? concreteConstructor = typeof(TestPlugin).GetConstructor(Type.EmptyTypes); Assert.NotNull(baseConstructor); @@ -54,7 +54,8 @@ public void EnableThenDisableUsesOneScopedClientLifecycleAndReleasesCleanupResou List events = []; FakeClient client = new(42); RecordingCleanup cleanup = new(events); - TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => builder.Client.AddModule()); + TestPlugin plugin = CreatePlugin(events, client, cleanup, + static builder => builder.Client.AddModule()); plugin.EnableForTest(); @@ -109,7 +110,9 @@ public void DefaultApplicationCallbacksParticipateInTheActivationLifecycle() List events = []; FakeClient client = new(46); RecordingCleanup cleanup = new(events); - DefaultCallbacksPlugin plugin = new(CreateConfiguration(events, client, cleanup, static _ => { })); + DefaultCallbacksPlugin plugin = new(CreateConfiguration(events, client, cleanup, static _ => + { + })); plugin.EnableForTest(); @@ -125,8 +128,10 @@ 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 _ => { }); + RecordingCleanup cleanup = new(events, new InvalidOperationException("cleanup scope")); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static _ => + { + }); plugin.EnableForTest(); InvalidOperationException exception = Assert.Throws(plugin.DisableForTest); @@ -142,8 +147,9 @@ public void ApplicationEnableFailureAndCleanupFailureAreReportedTogetherAfterRol 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")); + TestPlugin plugin = CreatePlugin(events, client, cleanup, + static builder => builder.Client.AddModule(), + static _ => throw new InvalidOperationException("application enable")); AggregateException exception = Assert.Throws(plugin.EnableForTest); @@ -166,7 +172,8 @@ 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(), + TestPlugin plugin = CreatePlugin(events, client, cleanup, + static builder => builder.Client.AddModule(), onClientDisabling: _ => { events.Add("client.disabling"); @@ -183,7 +190,8 @@ public void DisableAggregatesApplicationModuleAndResourceCleanupFailures() failure => Assert.Equal("drain", failure.Message)); Assert.Equal( [ - "configure", "module.enabled", "client.enabled", "cleanup.enter", "client.disabling", "module.disabling", + "configure", "module.enabled", "client.enabled", "cleanup.enter", "client.disabling", + "module.disabling", "cleanup.drain", "cleanup.exit" ], events); @@ -217,7 +225,8 @@ private static TestPlugin CreatePlugin( Action? onClientEnabled = null, Action? onClientDisabling = null) { - return new TestPlugin(CreateConfiguration(events, client, cleanup, configure), onClientEnabled ?? (currentClient => events.Add("client.enabled")), + return new TestPlugin(CreateConfiguration(events, client, cleanup, configure), + onClientEnabled ?? (currentClient => events.Add("client.enabled")), onClientDisabling ?? (currentClient => events.Add("client.disabling"))); } @@ -240,11 +249,13 @@ private static Action CreateConfiguration( private sealed class TestPlugin : CheatEngineClientPlugin { private readonly Action _configure; - private readonly Action _onClientEnabled; private readonly Action _onClientDisabling; + private readonly Action _onClientEnabled; public TestPlugin() - : this(static _ => { }) + : this(static _ => + { + }) { } @@ -254,8 +265,12 @@ internal TestPlugin( Action? onClientDisabling = null) { _configure = configure; - _onClientEnabled = onClientEnabled ?? (static _ => { }); - _onClientDisabling = onClientDisabling ?? (static _ => { }); + _onClientEnabled = onClientEnabled ?? (static _ => + { + }); + _onClientDisabling = onClientDisabling ?? (static _ => + { + }); } protected override void Configure(CheatEnginePluginBuilder builder) @@ -273,11 +288,20 @@ protected override void OnClientDisabling(ICheatEngineClient client) _onClientDisabling(client); } - internal void EnableForTest() => OnEnable(); + internal void EnableForTest() + { + OnEnable(); + } - internal void DisableForTest() => OnDisable(); + internal void DisableForTest() + { + OnDisable(); + } - internal ICheatEngineClient GetRequiredClientForTest() => GetRequiredClient(); + internal ICheatEngineClient GetRequiredClientForTest() + { + return GetRequiredClient(); + } } private sealed class DefaultCallbacksPlugin(Action configure) : CheatEngineClientPlugin @@ -287,11 +311,20 @@ protected override void Configure(CheatEnginePluginBuilder builder) configure(builder); } - internal void EnableForTest() => OnEnable(); + internal void EnableForTest() + { + OnEnable(); + } - internal void DisableForTest() => OnDisable(); + internal void DisableForTest() + { + OnDisable(); + } - internal ICheatEngineClient GetRequiredClientForTest() => GetRequiredClient(); + internal ICheatEngineClient GetRequiredClientForTest() + { + return GetRequiredClient(); + } } public sealed class RecordingModule(List events) : ICheatEngineClientModule diff --git a/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs index a9ad61f..fdc7f77 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs @@ -15,7 +15,7 @@ public sealed class ClientActivationLifecycleTests [Fact] public void EnableThenCleanupRunsTheApplicationHookAndModulesInTheirSpecifiedOrder() { - List events = new(); + List events = []; RecordingModule first = new("first", events); RecordingModule second = new("second", events); ClientActivationLifecycle lifecycle = CreateLifecycle(events, out ICheatEngineClient client, first, second); @@ -37,7 +37,7 @@ public void EnableThenCleanupRunsTheApplicationHookAndModulesInTheirSpecifiedOrd [Fact] public void FailedModuleEnableStillCompensatesTheFailingModuleThenEarlierModulesInReverseOrder() { - List events = new(); + List events = []; ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, new RecordingModule("first", events), new RecordingModule("second", events, new InvalidOperationException("module enable"))); @@ -54,7 +54,7 @@ public void FailedModuleEnableStillCompensatesTheFailingModuleThenEarlierModules [Fact] public void FailedApplicationEnableStillRunsItsCompensatingHookBeforeModuleCleanup() { - List events = new(); + List events = []; ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, new RecordingModule("module", events)); InvalidOperationException exception = Assert.Throws(() => lifecycle.Enable(_ => @@ -72,7 +72,7 @@ public void FailedApplicationEnableStillRunsItsCompensatingHookBeforeModuleClean [Fact] public void CleanupContinuesAfterFailuresAndIsIdempotent() { - List events = new(); + List events = []; ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, new RecordingModule("first", events, disableFailure: new InvalidOperationException("first disable")), new RecordingModule("second", events, disableFailure: new InvalidOperationException("second disable")));