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..47c1247
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,136 @@
+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) {
+ throw 'Microsoft Testing Platform did not produce a TRX test report.'
+ }
+
+ $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/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
new file mode 100644
index 0000000..5b34005
--- /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@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
+ 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/CheatEngine.Client.slnx b/CheatEngine.Client.slnx
index d872e9d..99628c9 100644
--- a/CheatEngine.Client.slnx
+++ b/CheatEngine.Client.slnx
@@ -11,20 +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.**
+
+[](https://github.com/CheatEngineNet/CheatEngine.Client/actions/workflows/main-ci.yml)
+[](https://www.nuget.org/packages/CheatEngine.Client)
+[](https://dotnet.microsoft.com/download/dotnet/10.0)
+[](#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..75a2d55
--- /dev/null
+++ b/eng/Invoke-PackageSmoke.ps1
@@ -0,0 +1,174 @@
+[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"
+ }
+
+ # 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 {
+ 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/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..776458e
--- /dev/null
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngine.Client.Extensions.DependencyInjection.csproj
@@ -0,0 +1,20 @@
+
+
+
+ {5E8B0B81-9BCB-4505-AF18-6071D762CC91}
+ 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..f8162fe
--- /dev/null
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs
@@ -0,0 +1,113 @@
+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. 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.Scoped());
+ 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.
+ ///
+ /// 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()
+ {
+ 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
new file mode 100644
index 0000000..8a43708
--- /dev/null
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptions.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CheatEngine.Client.Extensions.DependencyInjection;
+
+/// Configuration values used by the high-level Cheat Engine client during one plugin activation.
+///
+/// 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
+{
+ /// 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
+ /// its client scope; relative paths, blank entries, and are rejected.
+ ///
+ [Required]
+ public string[]? AllowedTableRoots
+ {
+ get;
+ set;
+ } = Array.Empty();
+}
diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs
new file mode 100644
index 0000000..35e9d25
--- /dev/null
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs
@@ -0,0 +1,54 @@
+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
+{
+ /// Initializes the semantic validator for the Client table-file policy.
+ public CheatEngineClientOptionsSemanticValidator()
+ {
+ }
+
+ ///
+ 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.");
+ }
+
+ 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 must contain paths that can be normalized safely.");
+ }
+
+ 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..ced847a
--- /dev/null
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs
@@ -0,0 +1,172 @@
+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;
+ string[] allowedTableRoots = options.AllowedTableRoots
+ ?? throw new InvalidOperationException("AllowedTableRoots must be validated before the Client policy is created.");
+ bool enableUnsafeLuaExecution = serviceProvider
+ .GetService()?
+ .IsEnabled == true;
+ return new CoreClientPolicy(allowedTableRoots, 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());
+ }
+}
+
+/// 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/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..01efd9a
--- /dev/null
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Shipped.txt
@@ -0,0 +1,25 @@
+#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.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..cfc0fe7
--- /dev/null
+++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md
@@ -0,0 +1,49 @@
+# 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 table-file policy:
+
+- `AllowedTableRoots` is empty by default, which denies table-file load/save access.
+
+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 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`.
+
+```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..ea347b1
--- /dev/null
+++ b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj
@@ -0,0 +1,23 @@
+
+
+
+ {CF0D14B3-B4CB-4BDD-91AD-4C8B6B8BBCD0}
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs
new file mode 100644
index 0000000..eb7008b
--- /dev/null
+++ b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs
@@ -0,0 +1,324 @@
+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 for construction by a concrete plugin.
+ ///
+ /// 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.
+ ///
+ protected CheatEngineClientPlugin()
+ {
+ }
+
+ /// Gets the client for the active enable epoch.
+ /// The plugin is not currently enabled.
+ protected ICheatEngineClient GetRequiredClient() => 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);
+ }
+
+ /// 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;
+ 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 = [];
+ 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);
+ }
+
+ /// 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 = [];
+ 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..1914df3
--- /dev/null
+++ b/libs/CheatEngine.Client.Hosting/CheatEnginePluginBuilder.cs
@@ -0,0 +1,76 @@
+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
+ });
+ }
+
+ /// 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
new file mode 100644
index 0000000..d416da1
--- /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 = [];
+
+ 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..de1a7b3
--- /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.GetRequiredClient() -> 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..9ae2bb2
--- /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 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.
+
+## 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..4e51357 100644
--- a/src/CheatEngine.Client/README.md
+++ b/src/CheatEngine.Client/README.md
@@ -1,11 +1,56 @@
# 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.
+## Context
+
+`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.
+
+## Why this project exists
+
+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.
+
+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 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).
+- 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/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/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..5039262
--- /dev/null
+++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs
@@ -0,0 +1,128 @@
+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;
+
+ /// Registers the activation-scoped Lua module and demonstrates bounded Client operations.
+ 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());
+
+ int allowedTableRootCount = _options.AllowedTableRoots?.Length ?? 0;
+ LogEnabled(logger, client.Epoch, allowedTableRootCount);
+
+ 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);
+ }
+ }
+
+ ///
+ /// Releases the activation-scoped Lua module so the hosting lifecycle can aggregate any cleanup failure.
+ ///
+ public void OnDisabling(ICheatEngineClient client)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+
+ ILuaModuleLease? lease = _luaModuleLease;
+ _luaModuleLease = null;
+ if (lease is null)
+ {
+ return;
+ }
+
+ 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 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);
+}
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..63a5825
--- /dev/null
+++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaFunctions.cs
@@ -0,0 +1,14 @@
+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
+{
+ /// Returns the activation-local status text for a generated Lua export.
+ [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..acd8651
--- /dev/null
+++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/appsettings.json
@@ -0,0 +1,5 @@
+{
+ "CheatEngineClient": {
+ "AllowedTableRoots": []
+ }
+}
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..ccae8e6
--- /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 .\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.
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..7d9f08b
--- /dev/null
+++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientOptionsSemanticValidatorTests.cs
@@ -0,0 +1,81 @@
+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);
+ }
+
+ [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
new file mode 100644
index 0000000..a349af8
--- /dev/null
+++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs
@@ -0,0 +1,231 @@
+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().EnableUnsafeLuaExecution();
+
+ 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));
+ }
+
+ [Fact]
+ public void SectionOverloadBindsThenProgrammaticConfigurationRunsLast()
+ {
+ using ConfigurationManager configuration = new();
+ 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(options => options.AllowedTableRoots = [overriddenRoot]);
+
+ using ServiceProvider provider = services.BuildServiceProvider();
+ CheatEngineClientOptions options = provider.GetRequiredService>().Value;
+
+ Assert.Equal([overriddenRoot], Assert.IsType(options.AllowedTableRoots));
+ }
+
+ [Fact]
+ public void RootOverloadUsesTheDefaultClientSection()
+ {
+ using ConfigurationManager configuration = new();
+ string allowedRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "allowed"));
+ configuration["CheatEngineClient:AllowedTableRoots:0"] = allowedRoot;
+ ServiceCollection services = new();
+
+ services.AddCheatEngineClient(configuration);
+
+ using ServiceProvider provider = services.BuildServiceProvider();
+ CheatEngineClientOptions options = provider.GetRequiredService>().Value;
+
+ Assert.Equal([allowedRoot], Assert.IsType(options.AllowedTableRoots));
+ }
+
+ [Fact]
+ public void ConfigurationCannotEnableUnsafeLuaExecution()
+ {
+ using ConfigurationManager configuration = new();
+ configuration["CheatEngineClient:EnableUnsafeLuaExecution"] = "true";
+ ServiceCollection services = new();
+ services.AddCheatEngineClient(configuration);
+ using ServiceProvider provider = services.BuildServiceProvider();
+
+ Assert.DoesNotContain(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient));
+ Assert.Empty(provider.GetServices());
+ }
+
+ [Fact]
+ public void AddModulePreservesExplicitRegistrationOrder()
+ {
+ ServiceCollection services = new();
+ services.AddCheatEngineClient()
+ .AddModule()
+ .AddModule()
+ .AddModule();
+
+ 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,
+ module => Assert.IsType(module),
+ 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
+ {
+ public bool TryRead(IMemoryReadContext context, Address address, out CustomValue value)
+ {
+ value = default;
+ return false;
+ }
+
+ public bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value)
+ {
+ return false;
+ }
+ }
+
+ private sealed class SecondCustomCodec : IMemoryCodec
+ {
+ public bool TryRead(IMemoryReadContext context, Address address, out CustomValue value)
+ {
+ value = new CustomValue(2);
+ return true;
+ }
+
+ public bool TryWrite(IMemoryWriteContext context, Address address, in CustomValue value)
+ {
+ return value.Value == 2;
+ }
+ }
+
+ 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)
+ {
+ }
+ }
+
+ 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.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/CheatEngineClientPluginTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs
new file mode 100644
index 0000000..bf7d64d
--- /dev/null
+++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs
@@ -0,0 +1,406 @@
+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;
+
+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);
+ }
+
+ [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
new file mode 100644
index 0000000..bc96221
--- /dev/null
+++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs
@@ -0,0 +1,36 @@
+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();
+ 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([allowedRoot], Assert.IsType(options.AllowedTableRoots));
+ 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