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.Fluent/CheatEngine.Client.Fluent.csproj b/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj
index 9a9b672..12f8df2 100644
--- a/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj
+++ b/libs/CheatEngine.Client.Fluent/CheatEngine.Client.Fluent.csproj
@@ -1,5 +1,9 @@
+
+ CheatEngine.Client
+
+
diff --git a/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs
new file mode 100644
index 0000000..3cbd0b6
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs
@@ -0,0 +1,17 @@
+using CheatEngine.SDK.Engine.Values;
+
+namespace CheatEngine.Client.Memory;
+
+/// Fluent entry points for the scoped target-memory contract.
+public static class CheatEngineMemoryFluentExtensions
+{
+ /// Starts a fluent, immutable operation against .
+ /// The scoped target-memory service used by terminal operations.
+ /// The target address to read or write.
+ /// An immutable address builder bound to .
+ /// is .
+ public static MemoryAddressBuilder At(this IMemoryClient memory, Address address)
+ {
+ return Memory.At(memory, address);
+ }
+}
diff --git a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs
new file mode 100644
index 0000000..1f07c8e
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs
@@ -0,0 +1,29 @@
+using CheatEngine.SDK.Engine.Values;
+
+namespace CheatEngine.Client.Memory;
+
+/// Starts a fluent, handle-free operation against one target-memory address.
+public static class Memory
+{
+ /// Creates an unbound address builder.
+ /// The target address to read or write.
+ ///
+ /// An immutable address builder that must be bound with Using(memory)
+ /// before a built-in terminal operation.
+ ///
+ public static MemoryAddressBuilder At(Address address)
+ {
+ return new MemoryAddressBuilder(address, null);
+ }
+
+ /// Creates an address builder bound to the supplied memory service.
+ /// The scoped target-memory service used by terminal operations.
+ /// The target address to read or write.
+ /// An immutable address builder.
+ /// is .
+ public static MemoryAddressBuilder At(IMemoryClient memory, Address address)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ return new MemoryAddressBuilder(address, memory);
+ }
+}
diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs
new file mode 100644
index 0000000..73b3b63
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs
@@ -0,0 +1,209 @@
+using System.Diagnostics.CodeAnalysis;
+
+using CheatEngine.Client.Results;
+using CheatEngine.SDK.Engine.Values;
+
+namespace CheatEngine.Client.Memory;
+
+/// An immutable, handle-free builder for one target-memory address.
+public readonly record struct MemoryAddressBuilder
+{
+ private readonly IMemoryClient? _memory;
+
+ internal MemoryAddressBuilder(Address address, IMemoryClient? memory)
+ {
+ Address = address;
+ _memory = memory;
+ }
+
+ /// Gets the target address used by terminal operations.
+ public Address Address
+ {
+ get;
+ }
+
+ /// Returns an equivalent builder bound to a scoped target-memory service.
+ /// The scoped target-memory service used by terminal operations.
+ /// A new immutable builder.
+ /// is .
+ public MemoryAddressBuilder Using(IMemoryClient memory)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ return new MemoryAddressBuilder(Address, memory);
+ }
+
+ /// Reads one built-in scalar or pointer type through the bound memory service.
+ /// The built-in scalar or pointer type to read.
+ /// Cancels before the operation reaches Cheat Engine.
+ /// The value read from .
+ /// No memory service has been bound to this builder.
+ public T Read(CancellationToken cancellationToken = default)
+ {
+ return RequireMemory().ReadPrimitive(Address, cancellationToken);
+ }
+
+ /// Tries to read one built-in scalar or pointer type through the bound memory service.
+ /// The built-in scalar or pointer type to read.
+ /// The value read when the method returns .
+ /// The classified operation failure when the method returns .
+ /// Cancels before the operation reaches Cheat Engine.
+ /// when a value was read.
+ /// No memory service has been bound to this builder.
+ public bool TryRead([MaybeNullWhen(false)] out T value, out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ return RequireMemory().TryReadPrimitive(Address, out value, out failure, cancellationToken);
+ }
+
+ /// Writes one built-in scalar or pointer type through the bound memory service.
+ /// The built-in scalar or pointer type to write.
+ /// The value to write to .
+ /// Cancels before the operation reaches Cheat Engine.
+ /// No memory service has been bound to this builder.
+ public void Write(T value, CancellationToken cancellationToken = default)
+ {
+ RequireMemory().WritePrimitive(Address, value, cancellationToken);
+ }
+
+ /// Tries to write one built-in scalar or pointer type through the bound memory service.
+ /// The built-in scalar or pointer type to write.
+ /// The value to write to .
+ /// The classified operation failure when the method returns .
+ /// Cancels before the operation reaches Cheat Engine.
+ /// when Cheat Engine accepted the write.
+ /// No memory service has been bound to this builder.
+ public bool TryWrite(T value, out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ return RequireMemory().TryWritePrimitive(Address, value, out failure, cancellationToken);
+ }
+
+ /// Reads one typed value through the service bound to this builder.
+ /// The managed value type represented by .
+ /// The deterministic codec that maps to Cheat Engine memory.
+ /// Cancels before the operation reaches Cheat Engine.
+ /// The managed value returned by Cheat Engine.
+ /// No memory service has been bound to this builder.
+ /// is .
+ public T ReadWith(IMemoryCodec codec, CancellationToken cancellationToken = default)
+ {
+ return ReadWith(RequireMemory(), codec, cancellationToken);
+ }
+
+ /// Reads one typed value through an explicit target-memory service.
+ /// The managed value type represented by .
+ /// The scoped target-memory service used for this operation.
+ /// The deterministic codec that maps to Cheat Engine memory.
+ /// Cancels before the operation reaches Cheat Engine.
+ /// The managed value returned by Cheat Engine.
+ /// or is .
+ public T ReadWith(IMemoryClient memory, IMemoryCodec codec,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ ArgumentNullException.ThrowIfNull(codec);
+ return memory.Read(new MemoryReadRequest(Address, codec), cancellationToken);
+ }
+
+ /// Tries to read one typed value through the service bound to this builder.
+ /// The managed value type represented by .
+ /// The deterministic codec that maps to Cheat Engine memory.
+ /// The managed value when the method returns .
+ /// The classified operation failure when the method returns .
+ /// Cancels before the operation reaches Cheat Engine.
+ /// when a value was read.
+ /// No memory service has been bound to this builder.
+ /// is .
+ public bool TryReadWith(IMemoryCodec codec, [MaybeNullWhen(false)] out T value,
+ out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ return TryReadWith(RequireMemory(), codec, out value, out failure, cancellationToken);
+ }
+
+ /// Tries to read one typed value through an explicit target-memory service.
+ /// The managed value type represented by .
+ /// The scoped target-memory service used for this operation.
+ /// The deterministic codec that maps to Cheat Engine memory.
+ /// The managed value when the method returns .
+ /// The classified operation failure when the method returns .
+ /// Cancels before the operation reaches Cheat Engine.
+ /// when a value was read.
+ /// or is .
+ public bool TryReadWith(IMemoryClient memory, IMemoryCodec codec, [MaybeNullWhen(false)] out T value,
+ out CheatEngineFailure failure, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ ArgumentNullException.ThrowIfNull(codec);
+ return memory.TryRead(new MemoryReadRequest(Address, codec), out value, out failure, cancellationToken);
+ }
+
+ /// Writes one typed value through the service bound to this builder.
+ /// The managed value type represented by .
+ /// The managed value to write.
+ /// The deterministic codec that maps to Cheat Engine memory.
+ /// Cancels before the operation reaches Cheat Engine.
+ /// Nothing when Cheat Engine accepted the write.
+ /// No memory service has been bound to this builder.
+ /// is .
+ public void WriteWith(T value, IMemoryCodec codec, CancellationToken cancellationToken = default)
+ {
+ WriteWith(RequireMemory(), value, codec, cancellationToken);
+ }
+
+ /// Writes one typed value through an explicit target-memory service.
+ /// The managed value type represented by .
+ /// The scoped target-memory service used for this operation.
+ /// The managed value to write.
+ /// The deterministic codec that maps to Cheat Engine memory.
+ /// Cancels before the operation reaches Cheat Engine.
+ /// Nothing when Cheat Engine accepted the write.
+ /// or is .
+ public void WriteWith(IMemoryClient memory, T value, IMemoryCodec codec,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ ArgumentNullException.ThrowIfNull(codec);
+ memory.Write(new MemoryWriteRequest(Address, value, codec), cancellationToken);
+ }
+
+ /// Tries to write one typed value through the service bound to this builder.
+ /// The managed value type represented by .
+ /// The managed value to write.
+ /// The deterministic codec that maps to Cheat Engine memory.
+ /// The classified operation failure when the method returns .
+ /// Cancels before the operation reaches Cheat Engine.
+ /// when Cheat Engine accepted the write.
+ /// No memory service has been bound to this builder.
+ /// is .
+ public bool TryWriteWith(T value, IMemoryCodec codec, out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ return TryWriteWith(RequireMemory(), value, codec, out failure, cancellationToken);
+ }
+
+ /// Tries to write one typed value through an explicit target-memory service.
+ /// The managed value type represented by .
+ /// The scoped target-memory service used for this operation.
+ /// The managed value to write.
+ /// The deterministic codec that maps to Cheat Engine memory.
+ /// The classified operation failure when the method returns .
+ /// Cancels before the operation reaches Cheat Engine.
+ /// when Cheat Engine accepted the write.
+ /// or is .
+ public bool TryWriteWith(IMemoryClient memory, T value, IMemoryCodec codec,
+ out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ ArgumentNullException.ThrowIfNull(codec);
+ return memory.TryWrite(new MemoryWriteRequest(Address, value, codec), out failure, cancellationToken);
+ }
+
+ private IMemoryClient RequireMemory()
+ {
+ return _memory ?? throw new InvalidOperationException(
+ "This memory builder has no bound target-memory service. Use Memory.At(memory, address), " +
+ "memory.At(address), or bind the builder with Using(memory) before a terminal operation.");
+ }
+}
diff --git a/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt
new file mode 100644
index 0000000..fd41b59
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/PublicAPI.Shipped.txt
@@ -0,0 +1,82 @@
+#nullable enable
+~override CheatEngine.Client.Memory.MemoryAddressBuilder.Equals(object obj) -> bool
+~override CheatEngine.Client.Memory.MemoryAddressBuilder.ToString() -> string
+~override CheatEngine.Client.Scanning.AobFirstMatchBuilder.Equals(object obj) -> bool
+~override CheatEngine.Client.Scanning.AobFirstMatchBuilder.ToString() -> string
+~override CheatEngine.Client.Scanning.AobManyMatchBuilder.Equals(object obj) -> bool
+~override CheatEngine.Client.Scanning.AobManyMatchBuilder.ToString() -> string
+~override CheatEngine.Client.Scanning.AobScanBuilder.Equals(object obj) -> bool
+~override CheatEngine.Client.Scanning.AobScanBuilder.ToString() -> string
+~override CheatEngine.Client.Scanning.AobSingleMatchBuilder.Equals(object obj) -> bool
+~override CheatEngine.Client.Scanning.AobSingleMatchBuilder.ToString() -> string
+CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions
+CheatEngine.Client.Memory.Memory
+CheatEngine.Client.Memory.MemoryAddressBuilder
+CheatEngine.Client.Memory.MemoryAddressBuilder.Address.get -> CheatEngine.SDK.Engine.Values.Address
+CheatEngine.Client.Memory.MemoryAddressBuilder.Equals(CheatEngine.Client.Memory.MemoryAddressBuilder other) -> bool
+CheatEngine.Client.Memory.MemoryAddressBuilder.MemoryAddressBuilder() -> void
+CheatEngine.Client.Memory.MemoryAddressBuilder.Read(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T
+CheatEngine.Client.Memory.MemoryAddressBuilder.ReadWith(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T
+CheatEngine.Client.Memory.MemoryAddressBuilder.ReadWith(CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> T
+CheatEngine.Client.Memory.MemoryAddressBuilder.TryRead(out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadWith(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.Client.Memory.IMemoryCodec! codec, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadWith(CheatEngine.Client.Memory.IMemoryCodec! codec, out T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Memory.MemoryAddressBuilder.TryWrite(T value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteWith(CheatEngine.Client.Memory.IMemoryClient! memory, T value, CheatEngine.Client.Memory.IMemoryCodec! codec, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteWith(T value, CheatEngine.Client.Memory.IMemoryCodec! codec, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Memory.MemoryAddressBuilder.Using(CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryAddressBuilder
+CheatEngine.Client.Memory.MemoryAddressBuilder.Write(T value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void
+CheatEngine.Client.Memory.MemoryAddressBuilder.WriteWith(CheatEngine.Client.Memory.IMemoryClient! memory, T value, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void
+CheatEngine.Client.Memory.MemoryAddressBuilder.WriteWith(T value, CheatEngine.Client.Memory.IMemoryCodec! codec, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void
+CheatEngine.Client.Scanning.AobFirstMatchBuilder
+CheatEngine.Client.Scanning.AobFirstMatchBuilder.AobFirstMatchBuilder() -> void
+CheatEngine.Client.Scanning.AobFirstMatchBuilder.Equals(CheatEngine.Client.Scanning.AobFirstMatchBuilder other) -> bool
+CheatEngine.Client.Scanning.AobFirstMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address?
+CheatEngine.Client.Scanning.AobFirstMatchBuilder.TryExecute(out CheatEngine.SDK.Engine.Values.Address? address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Scanning.AobManyMatchBuilder
+CheatEngine.Client.Scanning.AobManyMatchBuilder.AobManyMatchBuilder() -> void
+CheatEngine.Client.Scanning.AobManyMatchBuilder.Equals(CheatEngine.Client.Scanning.AobManyMatchBuilder other) -> bool
+CheatEngine.Client.Scanning.AobManyMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Scanning.AobScanResult
+CheatEngine.Client.Scanning.AobManyMatchBuilder.TryExecute(out CheatEngine.Client.Scanning.AobScanResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Scanning.AobScanBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.AobScanBuilder() -> void
+CheatEngine.Client.Scanning.AobScanBuilder.Equals(CheatEngine.Client.Scanning.AobScanBuilder other) -> bool
+CheatEngine.Client.Scanning.AobScanBuilder.FirstOrNone() -> CheatEngine.Client.Scanning.AobFirstMatchBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.InModule(CheatEngine.SDK.Engine.Inspection.ModuleName module) -> CheatEngine.Client.Scanning.AobScanBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.InModule(string! moduleName) -> CheatEngine.Client.Scanning.AobScanBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.InRange(CheatEngine.SDK.Engine.Values.Address start, CheatEngine.SDK.Engine.Values.Address end) -> CheatEngine.Client.Scanning.AobScanBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.Module.get -> CheatEngine.SDK.Engine.Inspection.ModuleName?
+CheatEngine.Client.Scanning.AobScanBuilder.Options.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions
+CheatEngine.Client.Scanning.AobScanBuilder.Pattern.get -> CheatEngine.Client.Scanning.AobPattern
+CheatEngine.Client.Scanning.AobScanBuilder.Range.get -> CheatEngine.Client.Scanning.AobScanRange?
+CheatEngine.Client.Scanning.AobScanBuilder.ReadableExecutable() -> CheatEngine.Client.Scanning.AobScanBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.RequireSingle() -> CheatEngine.Client.Scanning.AobSingleMatchBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.Take(int maximumResults) -> CheatEngine.Client.Scanning.AobManyMatchBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.WithAlignment(CheatEngine.SDK.Engine.Enums.FastScanMethod method, string? parameter) -> CheatEngine.Client.Scanning.AobScanBuilder
+CheatEngine.Client.Scanning.AobScanBuilder.WithProtectionFlags(string? protectionFlags) -> CheatEngine.Client.Scanning.AobScanBuilder
+CheatEngine.Client.Scanning.AobSingleMatchBuilder
+CheatEngine.Client.Scanning.AobSingleMatchBuilder.AobSingleMatchBuilder() -> void
+CheatEngine.Client.Scanning.AobSingleMatchBuilder.Equals(CheatEngine.Client.Scanning.AobSingleMatchBuilder other) -> bool
+CheatEngine.Client.Scanning.AobSingleMatchBuilder.Execute(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address
+CheatEngine.Client.Scanning.AobSingleMatchBuilder.TryExecute(out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool
+CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions
+override CheatEngine.Client.Memory.MemoryAddressBuilder.GetHashCode() -> int
+override CheatEngine.Client.Scanning.AobFirstMatchBuilder.GetHashCode() -> int
+override CheatEngine.Client.Scanning.AobManyMatchBuilder.GetHashCode() -> int
+override CheatEngine.Client.Scanning.AobScanBuilder.GetHashCode() -> int
+override CheatEngine.Client.Scanning.AobSingleMatchBuilder.GetHashCode() -> int
+static CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions.At(this CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder
+static CheatEngine.Client.Memory.Memory.At(CheatEngine.Client.Memory.IMemoryClient! memory, CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder
+static CheatEngine.Client.Memory.Memory.At(CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.Client.Memory.MemoryAddressBuilder
+static CheatEngine.Client.Memory.MemoryAddressBuilder.operator !=(CheatEngine.Client.Memory.MemoryAddressBuilder left, CheatEngine.Client.Memory.MemoryAddressBuilder right) -> bool
+static CheatEngine.Client.Memory.MemoryAddressBuilder.operator ==(CheatEngine.Client.Memory.MemoryAddressBuilder left, CheatEngine.Client.Memory.MemoryAddressBuilder right) -> bool
+static CheatEngine.Client.Scanning.AobFirstMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobFirstMatchBuilder left, CheatEngine.Client.Scanning.AobFirstMatchBuilder right) -> bool
+static CheatEngine.Client.Scanning.AobFirstMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobFirstMatchBuilder left, CheatEngine.Client.Scanning.AobFirstMatchBuilder right) -> bool
+static CheatEngine.Client.Scanning.AobManyMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobManyMatchBuilder left, CheatEngine.Client.Scanning.AobManyMatchBuilder right) -> bool
+static CheatEngine.Client.Scanning.AobManyMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobManyMatchBuilder left, CheatEngine.Client.Scanning.AobManyMatchBuilder right) -> bool
+static CheatEngine.Client.Scanning.AobScanBuilder.operator !=(CheatEngine.Client.Scanning.AobScanBuilder left, CheatEngine.Client.Scanning.AobScanBuilder right) -> bool
+static CheatEngine.Client.Scanning.AobScanBuilder.operator ==(CheatEngine.Client.Scanning.AobScanBuilder left, CheatEngine.Client.Scanning.AobScanBuilder right) -> bool
+static CheatEngine.Client.Scanning.AobSingleMatchBuilder.operator !=(CheatEngine.Client.Scanning.AobSingleMatchBuilder left, CheatEngine.Client.Scanning.AobSingleMatchBuilder right) -> bool
+static CheatEngine.Client.Scanning.AobSingleMatchBuilder.operator ==(CheatEngine.Client.Scanning.AobSingleMatchBuilder left, CheatEngine.Client.Scanning.AobSingleMatchBuilder right) -> bool
+static CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions.Aob(this CheatEngine.Client.ICheatEngineClient! client, string! pattern) -> CheatEngine.Client.Scanning.AobScanBuilder
+static CheatEngine.Client.Scanning.CheatEngineAobFluentExtensions.Aob(this CheatEngine.Client.Scanning.IPatternScanner! scanner, string! pattern) -> CheatEngine.Client.Scanning.AobScanBuilder
diff --git a/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt
new file mode 100644
index 0000000..7dc5c58
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/libs/CheatEngine.Client.Fluent/README.md b/libs/CheatEngine.Client.Fluent/README.md
index b224307..aac3503 100644
--- a/libs/CheatEngine.Client.Fluent/README.md
+++ b/libs/CheatEngine.Client.Fluent/README.md
@@ -1,10 +1,88 @@
# CheatEngine.Client.Fluent
-The fluent, composable public API of CheatEngine.Client: builders and entry points that express operations against the
-`CheatEngine.Client.Abstractions` contracts.
+## Context
-## Rules
+`CheatEngine.Client.Fluent` supplies the immutable, handle-free syntax for common Client
+operations. It enriches the contracts in `CheatEngine.Client.Abstractions`; it does not execute
+Cheat Engine calls by itself.
-- References `CheatEngine.Client.Abstractions` only. It never references `CheatEngine.Client.Binding`, so it can be
- tested against fakes of the contracts.
-- Public API.
+The package currently provides AOB request builders and typed target-memory address builders. A
+terminal builder delegates work to a caller-supplied `IPatternScanner` or `IMemoryClient`, usually
+the services available from an activation-scoped `ICheatEngineClient`.
+
+```csharp
+using CheatEngine.Client.Memory;
+using CheatEngine.Client.Scanning;
+using CheatEngine.SDK.Engine.Values;
+
+Address address = client.Aob("48 8B ?? ?? ?? 89")
+ .InModule("game.exe")
+ .ReadableExecutable()
+ .RequireSingle()
+ .Execute();
+
+client.Memory.At(address + 0x14).Write(999);
+```
+
+## Why This Project Exists
+
+The public API needs expressive construction of bounded requests without coupling application code
+to Core, service location, or SDK ownership. Fluent keeps that syntax as a small pure layer over
+interfaces, so builders can be inspected and tested without Cheat Engine.
+
+It references `CheatEngine.Client.Abstractions` only. It has no project reference to Core and no
+direct `CheatEngine.SDK` package reference. Core remains the only Client layer that maps a terminal
+operation to Cheat Engine; Dependency Injection and Hosting own the concrete implementation.
+
+```text
+Abstractions ← Fluent
+ ↑
+ Core ← DependencyInjection ← Hosting
+```
+
+## How It Improves CheatEngine.Client
+
+- Represents operation configuration as immutable `readonly record struct` values rather than CE
+ handles or mutable builders.
+- Validates and normalizes an AOB pattern and its options before a terminal operation is selected.
+- Forces explicit result cardinality: `RequireSingle()`, `FirstOrNone()`, or `Take(maximumResults)`.
+- Preserves bounded materialization rules; callers can inspect `AobScanResult.IsTruncated` when a
+ bounded scan is intentionally incomplete.
+- Provides `Memory.At(...)` and `memory.At(...)` builders for primitive and codec-based reads and
+ writes without retaining a live target handle.
+- Uses the normal `Try...` plus `CheatEngineFailure` pattern and leaves the actual lifecycle,
+ dispatch, and SDK translation to the supplied contract implementation.
+
+## Public Namespaces and Boundaries
+
+The package publishes functional namespaces only:
+
+| Namespace | Entry points |
+|---|---|
+| `CheatEngine.Client.Scanning` | `Aob(...)`, AOB filters, and bounded terminal builders |
+| `CheatEngine.Client.Memory` | `Memory.At(...)`, `IMemoryClient.At(...)`, and `MemoryAddressBuilder` |
+
+`CheatEngine.Client.Fluent` is a package/assembly name, never a consumer namespace. The builders
+may expose stable SDK value types already present in the Abstractions vocabulary, notably `Address`
+and documented scan/inspection option types; they never expose Lua states, CE objects, or SDK
+ownership wrappers.
+
+Fluent does not make a capability available. For example, it has no value-scan builder and cannot
+turn the currently gated `IValueScanner` contract into a live scan. A builder remains valid as a
+managed value, but executing it through a stale scoped service still follows the implementation's
+activation and target-epoch rules.
+
+## Contribution and Validation
+
+Add a fluent surface only when it preserves an existing explicit contract and has a bounded terminal
+operation. Do not store CE resources in a builder, add Core dependencies, or introduce
+assembly-derived namespaces. Update `PublicAPI.Unshipped.txt` and add focused behavior tests in
+`tests/CheatEngine.Client.Fluent.Tests` for every public member or terminal-condition change.
+
+Validate the complete graph from the repository root:
+
+```powershell
+dotnet restore CheatEngine.Client.slnx --locked-mode
+dotnet build CheatEngine.Client.slnx --configuration Release --no-restore
+dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore
+```
diff --git a/libs/CheatEngine.Client.Fluent/Scanning/AobFirstMatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Scanning/AobFirstMatchBuilder.cs
new file mode 100644
index 0000000..d14f695
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/Scanning/AobFirstMatchBuilder.cs
@@ -0,0 +1,64 @@
+using CheatEngine.Client.Results;
+using CheatEngine.SDK.Engine.Values;
+
+namespace CheatEngine.Client.Scanning;
+
+/// An immutable terminal builder for an AOB scan that returns its first match, if any.
+public readonly record struct AobFirstMatchBuilder
+{
+ private readonly AobScanRequest _request;
+ private readonly IPatternScanner? _scanner;
+
+ internal AobFirstMatchBuilder(IPatternScanner scanner, AobScanRequest request)
+ {
+ _scanner = scanner;
+ _request = request;
+ }
+
+ /// Runs the scan and returns its first match, or when no match exists.
+ /// Cancels before the scan reaches Cheat Engine.
+ /// The first copied target address, or .
+ /// The scan operation failed.
+ public Address? Execute(CancellationToken cancellationToken = default)
+ {
+ if (TryExecute(out Address? address, out CheatEngineFailure failure, cancellationToken))
+ {
+ return address;
+ }
+
+ throw new CheatEngineOperationException(failure);
+ }
+
+ /// Runs the scan and attempts to return its first match.
+ /// The first target address, or when no match exists.
+ /// The scan failure when the method returns .
+ /// Cancels before the scan reaches Cheat Engine.
+ /// when the scan ran successfully, including a no-match result.
+ public bool TryExecute(out Address? address, out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ if (!RequireScanner().TryScan(_request, out AobScanResult result, out failure, cancellationToken))
+ {
+ address = null;
+ return false;
+ }
+
+ if (result.Matches.Length > _request.MaximumResults)
+ {
+ address = null;
+ failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Aob.FirstOrNone",
+ "The pattern scanner returned more matches than the first-match materialization limit.");
+ return false;
+ }
+
+ address = result.Matches.Length == 0 ? null : result.Matches[0];
+ failure = default;
+ return true;
+ }
+
+ private IPatternScanner RequireScanner()
+ {
+ return _scanner ?? throw new InvalidOperationException(
+ "This AOB terminal builder has no bound pattern scanner. Create it through Aob(pattern).");
+ }
+}
diff --git a/libs/CheatEngine.Client.Fluent/Scanning/AobManyMatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Scanning/AobManyMatchBuilder.cs
new file mode 100644
index 0000000..dd785bb
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/Scanning/AobManyMatchBuilder.cs
@@ -0,0 +1,63 @@
+using CheatEngine.Client.Results;
+
+namespace CheatEngine.Client.Scanning;
+
+/// An immutable terminal builder for a bounded, copied AOB result set.
+public readonly record struct AobManyMatchBuilder
+{
+ private readonly AobScanRequest _request;
+ private readonly IPatternScanner? _scanner;
+
+ internal AobManyMatchBuilder(IPatternScanner scanner, AobScanRequest request)
+ {
+ _scanner = scanner;
+ _request = request;
+ }
+
+ /// Runs the bounded scan and returns its copied result set.
+ /// Cancels before the scan reaches Cheat Engine.
+ ///
+ /// The bounded copied result set; inspect before treating it as
+ /// complete.
+ ///
+ /// The scan operation failed or violated its materialization limit.
+ public AobScanResult Execute(CancellationToken cancellationToken = default)
+ {
+ if (TryExecute(out AobScanResult result, out CheatEngineFailure failure, cancellationToken))
+ {
+ return result;
+ }
+
+ throw new CheatEngineOperationException(failure);
+ }
+
+ /// Runs the bounded scan and attempts to return its copied result set.
+ /// The bounded copied result set when the method returns .
+ /// The scan or materialization failure when the method returns .
+ /// Cancels before the scan reaches Cheat Engine.
+ /// when the bounded result set was returned.
+ public bool TryExecute(out AobScanResult result, out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ if (!RequireScanner().TryScan(_request, out result, out failure, cancellationToken))
+ {
+ return false;
+ }
+
+ if (result.Matches.Length <= _request.MaximumResults)
+ {
+ return true;
+ }
+
+ result = default;
+ failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Aob.Take",
+ "The pattern scanner returned more matches than the request's materialization limit.");
+ return false;
+ }
+
+ private IPatternScanner RequireScanner()
+ {
+ return _scanner ?? throw new InvalidOperationException(
+ "This AOB terminal builder has no bound pattern scanner. Create it through Aob(pattern).");
+ }
+}
diff --git a/libs/CheatEngine.Client.Fluent/Scanning/AobScanBuilder.cs b/libs/CheatEngine.Client.Fluent/Scanning/AobScanBuilder.cs
new file mode 100644
index 0000000..e4e96e2
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/Scanning/AobScanBuilder.cs
@@ -0,0 +1,153 @@
+using CheatEngine.SDK.Engine.Enums;
+using CheatEngine.SDK.Engine.Inspection;
+using CheatEngine.SDK.Engine.Scanning.Aob;
+using CheatEngine.SDK.Engine.Values;
+
+namespace CheatEngine.Client.Scanning;
+
+/// An immutable, handle-free AOB scan builder before its result cardinality is selected.
+public readonly record struct AobScanBuilder
+{
+ private readonly IPatternScanner? _scanner;
+
+ internal AobScanBuilder(IPatternScanner? scanner, AobPattern pattern, AobScanOptions options, ModuleName? module,
+ AobScanRange? range)
+ {
+ _scanner = scanner;
+ Pattern = pattern;
+ Options = options;
+ Module = module;
+ Range = range;
+ }
+
+ /// Gets the normalized pattern that the builder will submit to Cheat Engine.
+ public AobPattern Pattern
+ {
+ get;
+ }
+
+ /// Gets the current evidence-backed Cheat Engine scan options.
+ public AobScanOptions Options
+ {
+ get;
+ }
+
+ /// Gets the optional module-range filter that Core resolves before materializing matches.
+ public ModuleName? Module
+ {
+ get;
+ }
+
+ /// Gets the optional inclusive copied-address range filter.
+ public AobScanRange? Range
+ {
+ get;
+ }
+
+ /// Returns an equivalent builder that restricts results to one named target module.
+ /// The non-empty module name understood by Cheat Engine's symbol handler.
+ /// A new immutable builder.
+ public AobScanBuilder InModule(string moduleName)
+ {
+ return InModule(new ModuleName(moduleName));
+ }
+
+ /// Returns an equivalent builder that restricts results to one target module.
+ /// The module-range filter resolved by Core before result materialization.
+ /// A new immutable builder.
+ public AobScanBuilder InModule(ModuleName module)
+ {
+ if (string.IsNullOrWhiteSpace(module.Value))
+ {
+ throw new ArgumentException("An AOB module filter must be non-empty.", nameof(module));
+ }
+
+ return new AobScanBuilder(_scanner, Pattern, Options, module, Range);
+ }
+
+ /// Returns an equivalent builder that retains only match addresses in an inclusive target-address range.
+ /// The first included target address.
+ /// The last included target address.
+ /// A new immutable builder.
+ ///
+ /// The SDK's string-form AOBScan binding has no start/end arguments. Core applies this range while
+ /// copying the owned result list, before the requested materialization limit is counted.
+ ///
+ public AobScanBuilder InRange(Address start, Address end)
+ {
+ return new AobScanBuilder(_scanner, Pattern, Options, Module, new AobScanRange(start, end));
+ }
+
+ /// Returns an equivalent builder that searches read-only executable memory.
+ ///
+ /// Cheat Engine's documented protection grammar does not expose a readable bit. +X-C-W therefore means
+ /// executable, not copy-on-write, and not writable memory.
+ ///
+ /// A new immutable builder.
+ public AobScanBuilder ReadableExecutable()
+ {
+ return WithOptions(new AobScanOptions("+X-C-W", Options.AlignmentMethod, Options.AlignmentParameter));
+ }
+
+ /// Returns an equivalent builder with the exact Cheat Engine protection expression.
+ ///
+ /// The protection expression accepted by Cheat Engine, or to omit
+ /// it.
+ ///
+ /// A new immutable builder.
+ public AobScanBuilder WithProtectionFlags(string? protectionFlags)
+ {
+ return WithOptions(new AobScanOptions(protectionFlags, Options.AlignmentMethod, Options.AlignmentParameter));
+ }
+
+ /// Returns an equivalent builder with an explicit Cheat Engine alignment rule.
+ /// The documented Cheat Engine alignment method.
+ /// The divisor or hexadecimal trailing-digit expression required by .
+ /// A new immutable builder.
+ public AobScanBuilder WithAlignment(FastScanMethod method, string? parameter)
+ {
+ return WithOptions(new AobScanOptions(Options.ProtectionFlags, method, parameter));
+ }
+
+ /// Selects an operation that succeeds only when exactly one AOB match exists.
+ /// An immutable single-match terminal builder.
+ public AobSingleMatchBuilder RequireSingle()
+ {
+ return new AobSingleMatchBuilder(RequireScanner(), BuildRequest(2));
+ }
+
+ /// Selects an operation that returns the first match or when there is no match.
+ /// An immutable first-match terminal builder.
+ public AobFirstMatchBuilder FirstOrNone()
+ {
+ return new AobFirstMatchBuilder(RequireScanner(), BuildRequest(1));
+ }
+
+ /// Selects an operation that materializes no more than the requested number of matches.
+ /// The positive maximum number of copied addresses to materialize.
+ /// An immutable bounded-result terminal builder.
+ /// is zero or negative.
+ public AobManyMatchBuilder Take(int maximumResults)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumResults);
+ return new AobManyMatchBuilder(RequireScanner(), BuildRequest(maximumResults));
+ }
+
+ private IPatternScanner RequireScanner()
+ {
+ return _scanner ?? throw new InvalidOperationException(
+ "This AOB builder has no bound pattern scanner. Start the operation with client.Aob(pattern) or scanner.Aob(pattern).");
+ }
+
+ private AobScanRequest BuildRequest(int maximumResults)
+ {
+ return new AobScanRequest(Pattern, Options, maximumResults, Module, Range);
+ }
+
+ private AobScanBuilder WithOptions(AobScanOptions options)
+ {
+ // Validate and normalize at configuration time, not after the caller selected a terminal operation.
+ AobScanRequest request = new(Pattern, options, 1, Module, Range);
+ return new AobScanBuilder(_scanner, Pattern, request.Options, Module, Range);
+ }
+}
diff --git a/libs/CheatEngine.Client.Fluent/Scanning/AobSingleMatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Scanning/AobSingleMatchBuilder.cs
new file mode 100644
index 0000000..5842926
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/Scanning/AobSingleMatchBuilder.cs
@@ -0,0 +1,80 @@
+using CheatEngine.Client.Results;
+using CheatEngine.SDK.Engine.Values;
+
+namespace CheatEngine.Client.Scanning;
+
+/// An immutable terminal builder for an AOB scan that must have exactly one match.
+public readonly record struct AobSingleMatchBuilder
+{
+ private readonly AobScanRequest _request;
+ private readonly IPatternScanner? _scanner;
+
+ internal AobSingleMatchBuilder(IPatternScanner scanner, AobScanRequest request)
+ {
+ _scanner = scanner;
+ _request = request;
+ }
+
+ /// Runs the scan and returns its sole match.
+ /// Cancels before the scan reaches Cheat Engine.
+ /// The sole target address.
+ /// The scan failed, had no match, or had several matches.
+ public Address Execute(CancellationToken cancellationToken = default)
+ {
+ if (TryExecute(out Address address, out CheatEngineFailure failure, cancellationToken))
+ {
+ return address;
+ }
+
+ throw new CheatEngineOperationException(failure);
+ }
+
+ /// Runs the scan and attempts to return its sole match.
+ /// The sole target address when the method returns .
+ /// The scan or cardinality failure when the method returns .
+ /// Cancels before the scan reaches Cheat Engine.
+ /// when exactly one match exists.
+ public bool TryExecute(out Address address, out CheatEngineFailure failure,
+ CancellationToken cancellationToken = default)
+ {
+ if (!RequireScanner().TryScan(_request, out AobScanResult result, out failure, cancellationToken))
+ {
+ address = default;
+ return false;
+ }
+
+ if (result.Matches.Length > _request.MaximumResults)
+ {
+ address = default;
+ failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Aob.RequireSingle",
+ "The pattern scanner returned more matches than the single-match materialization limit.");
+ return false;
+ }
+
+ if (result.Matches.Length == 0)
+ {
+ address = default;
+ failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, "Aob.RequireSingle",
+ "The AOB scan did not find a match.");
+ return false;
+ }
+
+ if (result.Matches.Length != 1 || result.IsTruncated)
+ {
+ address = default;
+ failure = new CheatEngineFailure(CheatEngineFailureKind.AmbiguousMatch, "Aob.RequireSingle",
+ "The AOB scan found more than one match.");
+ return false;
+ }
+
+ address = result.Matches[0];
+ failure = default;
+ return true;
+ }
+
+ private IPatternScanner RequireScanner()
+ {
+ return _scanner ?? throw new InvalidOperationException(
+ "This AOB terminal builder has no bound pattern scanner. Create it through Aob(pattern).");
+ }
+}
diff --git a/libs/CheatEngine.Client.Fluent/Scanning/CheatEngineAobFluentExtensions.cs b/libs/CheatEngine.Client.Fluent/Scanning/CheatEngineAobFluentExtensions.cs
new file mode 100644
index 0000000..d9c1f70
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/Scanning/CheatEngineAobFluentExtensions.cs
@@ -0,0 +1,27 @@
+using CheatEngine.SDK.Engine.Scanning.Aob;
+
+namespace CheatEngine.Client.Scanning;
+
+/// Starts immutable, handle-free AOB scans from a scoped pattern-scanner contract.
+public static class CheatEngineAobFluentExtensions
+{
+ /// Starts an AOB scan bound to the supplied scoped scanner.
+ /// The scoped scanner used by terminal operations.
+ /// The AOB pattern to validate and normalize.
+ /// An immutable AOB scan builder.
+ public static AobScanBuilder Aob(this IPatternScanner scanner, string pattern)
+ {
+ ArgumentNullException.ThrowIfNull(scanner);
+ return new AobScanBuilder(scanner, new AobPattern(pattern), AobScanOptions.Default, null, null);
+ }
+
+ /// Starts an AOB scan using the pattern scanner of a scoped Cheat Engine client.
+ /// The scoped Cheat Engine client.
+ /// The AOB pattern to validate and normalize.
+ /// An immutable AOB scan builder.
+ public static AobScanBuilder Aob(this ICheatEngineClient client, string pattern)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ return client.Patterns.Aob(pattern);
+ }
+}
diff --git a/libs/CheatEngine.Client.Fluent/packages.lock.json b/libs/CheatEngine.Client.Fluent/packages.lock.json
new file mode 100644
index 0000000..e5efdf4
--- /dev/null
+++ b/libs/CheatEngine.Client.Fluent/packages.lock.json
@@ -0,0 +1,60 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.CodeAnalysis.PublicApiAnalyzers": {
+ "type": "Direct",
+ "requested": "[5.6.0, )",
+ "resolved": "5.6.0",
+ "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ=="
+ },
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.12, )",
+ "resolved": "10.0.12",
+ "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
+ },
+ "Microsoft.SourceLink.GitHub": {
+ "type": "Direct",
+ "requested": "[10.0.401, )",
+ "resolved": "10.0.401",
+ "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==",
+ "dependencies": {
+ "Microsoft.Build.Tasks.Git": "10.0.401",
+ "Microsoft.SourceLink.Common": "10.0.401",
+ "System.IO.Hashing": "10.0.12"
+ }
+ },
+ "Microsoft.Build.Tasks.Git": {
+ "type": "Transitive",
+ "resolved": "10.0.401",
+ "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==",
+ "dependencies": {
+ "System.IO.Hashing": "10.0.12"
+ }
+ },
+ "Microsoft.SourceLink.Common": {
+ "type": "Transitive",
+ "resolved": "10.0.401",
+ "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA=="
+ },
+ "System.IO.Hashing": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg=="
+ },
+ "cheatengine.client.abstractions": {
+ "type": "Project",
+ "dependencies": {
+ "CheatEngine.SDK": "[1.0.0, 2.0.0)"
+ }
+ },
+ "CheatEngine.SDK": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.0, )",
+ "resolved": "1.0.0",
+ "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA=="
+ }
+ }
+ }
+}
diff --git a/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.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs
new file mode 100644
index 0000000..f45d7fe
--- /dev/null
+++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs
@@ -0,0 +1,415 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+
+using CheatEngine.Client.Memory;
+using CheatEngine.Client.Results;
+using CheatEngine.SDK.Engine.Values;
+
+using MemoryFluent = CheatEngine.Client.Memory.Memory;
+
+namespace CheatEngine.Client.Tests.Memory;
+
+public sealed class MemoryAddressBuilderTests
+{
+ [Fact]
+ public void AtWithoutServiceRejectsABuiltInTerminalOperation()
+ {
+ MemoryAddressBuilder builder = MemoryFluent.At(0x401000UL);
+
+ InvalidOperationException exception = Assert.Throws(
+ () => builder.Read