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..5bbd905 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,171 @@ +name: Client validation + +on: + workflow_call: + inputs: + collect-coverage: + description: Emit Visual Studio XML coverage for the SonarQube Cloud analysis workflow. + type: boolean + default: false + +permissions: + contents: read + +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 + env: + COLLECT_COVERAGE: ${{ inputs.collect-coverage }} + run: | + $projects = @(Get-ChildItem -Path tests -Filter '*.Tests.csproj' -Recurse -File | Sort-Object Name) + if ($projects.Count -eq 0) { + throw 'No *.Tests.csproj project found under tests.' + } + + foreach ($project in $projects) { + $results = Join-Path 'artifacts/test-results' $project.BaseName + $options = @( + '--project', $project.FullName, + '--configuration', 'Release', + '--no-build', '--no-restore', + '--report-trx', + '--results-directory', $results, + '--fail-skips', 'on' + ) + if ($env:COLLECT_COVERAGE -eq 'true') { + $options += '--coverage', '--coverage-output-format', 'xml', '--coverage-output', 'coverage.xml' + } + + dotnet test @options + } + + - name: Summarize test results + if: ${{ !cancelled() }} + 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: Upload Sonar coverage + if: ${{ inputs.collect-coverage && !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-client + path: artifacts/test-results/*/coverage.xml + if-no-files-found: error + retention-days: 7 + + - name: Pack and validate public package APIs + run: dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore + + - 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..c820499 --- /dev/null +++ b/.github/workflows/main-ci.yml @@ -0,0 +1,45 @@ +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 + with: + collect-coverage: ${{ github.ref == 'refs/heads/main' }} + + # Main analysis is the SonarQube Cloud baseline for pull-request new-code comparisons. + sonar: + name: Sonar + needs: ci + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/sonar.yml + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml new file mode 100644 index 0000000..fb67677 --- /dev/null +++ b/.github/workflows/pull-request-ci.yml @@ -0,0 +1,79 @@ +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 + with: + collect-coverage: >- + ${{ github.event.pull_request.head.repo.full_name == github.repository + && github.actor != 'dependabot[bot]' }} + + # Fork and Dependabot pull requests receive no repository secret. Keep this condition equal to collect-coverage. + sonar: + name: Sonar + needs: ci + if: >- + github.event.pull_request.head.repo.full_name == github.repository + && github.actor != 'dependabot[bot]' + uses: ./.github/workflows/sonar.yml + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + lint-workflows: + name: Lint workflows + 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..57650ee --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,149 @@ +name: Sonar + +on: + workflow_call: + inputs: + project-key: + description: SonarQube Cloud project key. + type: string + default: CheatEngineNet_CheatEngine.Client + organization: + description: SonarQube Cloud organization key. + type: string + default: cheatenginenet + wait-quality-gate: + description: Fail the analysis job when SonarQube Cloud reports a failing Quality Gate. + type: boolean + default: false + secrets: + SONAR_TOKEN: + description: SonarQube Cloud token. Analysis is skipped when it is unavailable. + required: false + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + MSBUILDDISABLENODEREUSE: true + SONAR_SCANNER_VERSION: 11.3.0 + +jobs: + analyze: + name: Analyze + if: github.event_name != 'merge_group' + runs-on: windows-latest + timeout-minutes: 30 + env: + SONAR_ENABLED: ${{ secrets.SONAR_TOKEN != '' }} + SONAR_PROJECT_KEY: ${{ inputs.project-key }} + SONAR_ORGANIZATION: ${{ inputs.organization }} + SONAR_WAIT_QUALITY_GATE: ${{ inputs.wait-quality-gate }} + + steps: + - name: Skip without token + if: env.SONAR_ENABLED != 'true' + run: >- + Write-Host '::notice title=Sonar analysis skipped::SONAR_TOKEN is empty. Fork and Dependabot pull requests + receive no repository secret; configure SONAR_TOKEN to enable analysis.' + + - name: Check analysis method + if: env.SONAR_ENABLED == 'true' + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + $uri = "https://sonarcloud.io/api/settings/values?component=$env:SONAR_PROJECT_KEY&keys=sonar.autoscan.enabled" + try { + $response = Invoke-RestMethod -Uri $uri -Headers @{ Authorization = "Bearer $env:SONAR_TOKEN" } ` + -TimeoutSec 30 -MaximumRetryCount 2 -RetryIntervalSec 3 + } + catch { + Write-Host "::warning::Could not read the analysis method of $env:SONAR_PROJECT_KEY. $($_.Exception.Message)" + return + } + + if (@($response.settings | Where-Object { $_.key -eq 'sonar.autoscan.enabled' -and $_.value -eq 'true' })) { + Write-Host "::error::Automatic Analysis is enabled on $env:SONAR_PROJECT_KEY, so SonarQube Cloud rejects CI analysis. Turn it off in Administration, Analysis Method." + exit 1 + } + + - name: Checkout + if: env.SONAR_ENABLED == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up JDK 21 + if: env.SONAR_ENABLED == 'true' + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: zulu + java-version: '21' + + - name: Install pinned .NET SDK + if: env.SONAR_ENABLED == 'true' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: | + **/packages.lock.json + Directory.Packages.props + + - name: Download coverage + if: env.SONAR_ENABLED == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage-client + path: ${{ runner.temp }}/coverage + + - name: Install scanner + if: env.SONAR_ENABLED == 'true' + run: >- + dotnet tool install dotnet-sonarscanner --tool-path "$env:RUNNER_TEMP/sonar-scanner" + --version $env:SONAR_SCANNER_VERSION + + - name: Restore locked dependency graph + if: env.SONAR_ENABLED == 'true' + run: dotnet restore CheatEngine.Client.slnx --locked-mode + + - name: Begin analysis + if: env.SONAR_ENABLED == 'true' + env: + SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + run: | + $coverage = "$env:RUNNER_TEMP/coverage" + $reports = @(Get-ChildItem -Path $coverage -Filter *.xml -Recurse -File -ErrorAction SilentlyContinue) + if ($reports.Count -eq 0) { + Write-Host '::error::No coverage report was downloaded. Call ci.yml with collect-coverage set to true.' + exit 1 + } + + Write-Host "Coverage reports: $($reports.Count)" + $arguments = @( + "/k:$env:SONAR_PROJECT_KEY", + "/o:$env:SONAR_ORGANIZATION", + '/d:sonar.exclusions=artifacts/**', + '/d:sonar.coverage.exclusions=tests/**', + "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml", + "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" + ) + & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" begin @arguments + + - name: Build + if: env.SONAR_ENABLED == 'true' + run: >- + dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --no-incremental + --disable-build-servers --warnaserror + + - name: End analysis + if: env.SONAR_ENABLED == 'true' + env: + SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + run: '& "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" end' diff --git a/CheatEngine.Client.slnx b/CheatEngine.Client.slnx index d872e9d..710e369 100644 --- a/CheatEngine.Client.slnx +++ b/CheatEngine.Client.slnx @@ -11,20 +11,31 @@ + - + + + - + + + + + + + diff --git a/README.md b/README.md index 1fe3585..4a6b39e 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,283 @@ +
+ # CheatEngine.Client -A public, fluent, strongly-typed C# 14 / .NET 10 client API, built as a mapper/binder over CheatEngine.SDK. +**High-level, lifecycle-safe C# APIs for modern Cheat Engine plugins.** + +[![CI](https://img.shields.io/github/actions/workflow/status/CheatEngineNet/CheatEngine.Client/main-ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&labelColor=24292f)](https://github.com/CheatEngineNet/CheatEngine.Client/actions/workflows/main-ci.yml) +[![NuGet](https://img.shields.io/nuget/vpre/CheatEngine.Client?style=flat-square&logo=nuget&logoColor=white&labelColor=24292f&color=004880)](https://www.nuget.org/packages/CheatEngine.Client) +[![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4?style=flat-square&logo=dotnet&logoColor=white&labelColor=24292f)](https://dotnet.microsoft.com/download/dotnet/10.0) +[![Windows x64](https://img.shields.io/badge/platform-Windows%20x64-0078D4?style=flat-square&labelColor=24292f)](#requirements) + +[Quick start](#quick-start) · [Lifecycle](#the-plugin-lifecycle) · [Packages](#packages-and-direct-sdk-reference) · [Capabilities](#v010-capability-status) · [Contributing](#build-and-validation) + +
+ +## Context + +`CheatEngine.Client` is an in-process, dependency-injection-first layer for plugins loaded by Cheat Engine. It builds on +[`CheatEngine.SDK`](https://www.nuget.org/packages/CheatEngine.SDK) and turns its low-level host bindings into +bounded, typed, fluent C# operations for the lifetime of one plugin activation. + +The aggregate `ICheatEngineClient` gives an enabled plugin access to runtime facts and capabilities, main-thread +dispatch, process selection, typed memory, AOB scans, inspection, address tables, and protected Lua operations. It +never exposes a `LuaState`, CE object handle, raw native pointer, or SDK ownership wrapper to plugin code. + +## Why this project exists + +`CheatEngine.SDK` deliberately owns the difficult boundary: the generated Cheat Engine entry point, Lua protection, +native bridge, host object model, and compile-time plugin/Lua diagnostics. Those are SDK concerns and +`CheatEngine.Client` does not reimplement them. + +The Client exists for the application layer above that boundary. It makes recurring plugin concerns explicit and +testable: + +| SDK boundary | Client policy above it | +|-------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| Plugin bootstrap and protected Lua calls | One activation-scoped `ICheatEngineClient`; no raw Lua lifetime escapes | +| Host-owned temporary objects and main-thread affinity | Synchronous dispatcher boundary, copied results, and deterministic cleanup | +| Primitive Lua/host operations | Typed memory codecs, bounded strings and pointer chains, immutable AOB builders | +| Plugin construction | One validated DI provider per enable epoch; explicit modules and configuration | +| Host failures and capability differences | `Try...` methods with `CheatEngineFailure`, convenience methods that throw, and runtime capability observations | + +This separation lets a plugin stay ordinary, DI-friendly C# while retaining the SDK as the sole authority for ABI and +Lua safety. It also keeps the high-level surface honest: a contract is not presented as a working Cheat Engine feature +until its ownership, thread-affinity, and lifecycle path are established. + +## How it helps improve Cheat Engine plugin projects + +The Client centralizes lifecycle, ownership, dispatch, options, and capability policy once, rather than requiring each +plugin to reproduce them around low-level SDK calls. This lowers the cost of adding a feature, gives tests a stable +contract boundary, and keeps the generated plugin template focused on application code. It also makes the supported +surface reviewable: high-level APIs remain fluent for consumers while the Core remains the only SDK mapper. + +## Requirements + +| Requirement | Baseline | +|-----------------------------|------------------------------------------------------------------------------------| +| .NET SDK | 10.0.401 or later | +| Target framework / language | `net10.0` / C# 14 | +| Cheat Engine host | 7.7, Windows x64 | +| Plugin form | Framework-dependent managed plugin output folder | +| SDK package | `CheatEngine.SDK` 1.x; the Client publishes a compatible range of `[1.0.0, 2.0.0)` | + +Cheat Engine remains the compatibility authority. The Client is not an IPC client, a remote-process service, or a +standalone executable; v0.1 runs only inside an enabled Cheat Engine plugin. + +## Quick start + +The maintained starting point is the `ceplugin` template. It is both a usable project and the repository's executable +example of the required plugin shape. ```powershell -dotnet build CheatEngine.Client.slnx +dotnet new install CheatEngine.Client.Templates +dotnet new ceplugin --name MyPlugin +cd MyPlugin +dotnet build --configuration Release +``` + +The generated project intentionally retains these direct dependencies: + +```xml + + net10.0 + 14.0 + x64 + true + true + + + + + + + +``` + +`CheatEngine.SDK` must be referenced **directly by the plugin project**. Its build assets generate the Cheat Engine +entry point and provide the native Lua bridge; NuGet transitivity is not sufficient at that host boundary. Setting +`CheatEngineClientPluginProject` opts the project into the Hosting package's `CECLIENT001` guard, which fails the +build if the direct SDK reference is removed. + +Start with the template rather than copying this fragment into an existing plugin: it also demonstrates module +registration, generated Lua exports, validated options, bounded AOB and typed-memory access, and an Address List +snapshot. See the [template guide](templates/CheatEngine.Client.Templates/README.md) and the generated +[plugin README](templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md). + +### Minimal plugin shape + +The SDK still owns the plugin annotation. The Client base owns the activation-scoped composition: + +```csharp +using CheatEngine.Client.Hosting; +using CheatEngine.SDK.Annotations.Plugin; +using Microsoft.Extensions.Configuration; + +namespace MyPlugin; + +[CheatEnginePlugin("My Plugin")] +public sealed class Plugin : CheatEngineClientPlugin +{ + protected override void Configure(CheatEnginePluginBuilder builder) + { + builder.Configuration + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + + builder.Client.AddModule(); + } +} +``` + +An activation module receives the scoped client in `OnEnabled` and `OnDisabling`. Fluent calls remain bounded and +handle-free: + +```csharp +Address address = client.Patterns + .Aob("48 8B ?? ?? ?? 89") + .InModule("game.exe") + .ReadableExecutable() + .RequireSingle() + .Execute(); + +client.Memory.At(address + 0x14).Write(999); ``` -## Layout +Use the `Try...` terminal operations when absence of a process, scan result, or runtime capability is an expected +condition. Do not make a worker wait for the Cheat Engine thread if that worker can call back into the Client. + +## The plugin lifecycle + +The parameterless plugin instance is created by the SDK, but every enable creates new managed state: -The solution folders of `CheatEngine.Client.slnx` mirror the directories below one to one, so a path in Rider's Solution -Explorer is also the path on disk. `/Solution Items/` is the only virtual folder. +```text +OnEnable + -> Configure a new builder (explicit configuration, services, modules, codecs) + -> Build and validate a new provider and scope + -> Resolve options and ICheatEngineClient + -> Enable modules in registration order + -> OnClientEnabled +OnDisable + -> Stop admitting the active client + -> OnClientDisabling + -> Disable modules in reverse order + -> Drain Client-owned CE resources while the SDK context is valid + -> Dispose scope, provider, and configuration ``` -CheatEngine.Client/ -├─ eng/ MSBuild profiles, selected by the top-level folder of a project -├─ libs/ Small layered libraries -│ ├─ CheatEngine.Client.Abstractions/ Public vocabulary and contracts -│ ├─ CheatEngine.Client.Binding/ Mapper/binder onto CheatEngine.SDK -│ └─ CheatEngine.Client.Fluent/ Fluent public API -├─ src/ -│ └─ CheatEngine.Client/ The one project a consumer references -└─ tests/ One .Tests twin per project above + +`ICheatEngineClient.Epoch` and `ICheatEngineClient.Stopping` identify that activation. Never retain the client, a +resource lease, a Lua reference, a cancellation token, or a target-bound value across disable/re-enable. Constructors, +field initializers, and static initialization must not call Cheat Engine; the SDK binding is valid only after enable. + +All Client operations are synchronous. A cancellation token can prevent dispatch or stop Client-managed work between +steps, but it does not claim to interrupt a Lua primitive that has already started. Read +[ADR 0002](docs/adr/0002-plugin-activation-lifecycle.md) before adding a service that touches Cheat Engine. + +## Packages and direct SDK reference + +The recommended package is `CheatEngine.Client`. The delivery graph stays deliberately one-way: + +```text +CheatEngine.Client +├─ CheatEngine.Client.Fluent ────────────────> public contracts +└─ CheatEngine.Client.Hosting + ├─ CheatEngine.Client.Extensions.DependencyInjection + │ ├─ CheatEngine.Client.Core ────────────> CheatEngine.SDK + │ └─ public contracts + └─ CheatEngine.SDK + +public contracts ────────────────────────────> stable SDK value/runtime types only ``` -## Projects - -| Project | Role | References | -|-----------------------------------|----------------------------------------------------------------------------------------|-------------------------------------| -| `CheatEngine.Client.Abstractions` | Strongly-typed public vocabulary, and the contracts between the fluent and the binding | nothing | -| `CheatEngine.Client.Fluent` | Fluent, composable public API | `Abstractions` | -| `CheatEngine.Client.Binding` | Mapper/binder onto CheatEngine.SDK. Internal by default | `Abstractions` | -| `CheatEngine.Client` | Composition root: the single project a consumer references | `Abstractions`, `Fluent`, `Binding` | - -Dependency rules: - -- `Abstractions` is the base. `Fluent` and `Binding` never reference each other, and only `CheatEngine.Client` composes - them. -- `libs/` never references `src/` or `tests/`. A consumer references `CheatEngine.Client` only. -- `Binding` is the only project meant to depend on CheatEngine.SDK (the default rule, to revisit if `Abstractions` reuses CheatEngine.SDK - vocabulary). -- A test project references its subject only. - -## Conventions - -- Folder name, project file name, assembly name and root namespace are the same string. -- Every project has a `README.md` next to its project file. The build fails without it (`CHEATENGINECLIENT9001`). -- Every project in `libs/` and `src/` has a twin `tests/.Tests`, which sees its internals. The `.Tests` suffix is - reserved for test projects. -- The top-level folder of a project selects its profile: `libs/` and `src/` use `eng/Shipping.props`, `tests/` uses - `eng/Tests.props`. A project outside these folders has no target framework and does not build. -- `Directory.Build.props` is the only one of the repository: no nested `Directory.Build.*` files. -- Build output goes to `artifacts/`, never inside a project folder. -- No `Common`, `Utils` or `Helpers` folders. - -## Solution folders - -- One solution folder per directory, with the same path (`/libs/`, `/src/`, `/tests/`, `/eng/`). They stay flat, and - nest only where the disk nests. -- Entries are sorted by path, case-insensitively. Project sources and project READMEs are not listed. `artifacts/`, - `.idea/` and `.claude/` are never listed. -- Adding a project: its folder, its `.csproj`, its `README.md`, one `` line in the matching solution folder, and - its `.Tests` twin. - -## Reserved, not created yet - -A folder exists only when it holds a real file: no empty placeholder directories, no `.gitkeep`. A new top-level folder -that holds projects (`samples/`, `benchmarks/`) needs its own profile in `eng/`, an `` for it in -`Directory.Build.props`, and its own solution folder. `tests/CheatEngine.Client.Tests.Shared/` reuses the `tests/` -profile and the `/tests/` solution folder. `docs/` holds no project, so it only gets a file-only solution folder. - -| Location | Created when | -|------------------------------------------|------------------------------------------------------------------------------------| -| `samples/` | The first public API is worth demonstrating | -| `tests/CheatEngine.Client.Tests.Shared/` | A second test project needs the same fake | -| `benchmarks/` | The first performance-sensitive path exists | -| `docs/` | A document no longer fits in a README (`docs/adr/` with the first decision record) | +| Package | Purpose | Consume directly when | +|-------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|--------------------------------------------------------| +| [`CheatEngine.Client`](src/CheatEngine.Client/README.md) | Umbrella package for the high-level fluent and hosting experience | Building a normal plugin | +| [`CheatEngine.Client.Hosting`](libs/CheatEngine.Client.Hosting/README.md) | `CheatEngineClientPlugin` and one-provider-per-activation host | Integrating the host into an existing composition root | +| [`CheatEngine.Client.Extensions.DependencyInjection`](libs/CheatEngine.Client.Extensions.DependencyInjection/README.md) | Explicit DI registrations, modules, memory codecs, and options | Composing the Client without the plugin base | +| [`CheatEngine.Client.Fluent`](libs/CheatEngine.Client.Fluent/README.md) | Immutable fluent memory and AOB builders | Depending only on fluent request construction | +| [`CheatEngine.Client.Abstractions`](libs/CheatEngine.Client.Abstractions/README.md) | Contracts, requests, failures, and value vocabulary | Referencing contracts without an implementation | +| [`CheatEngine.Client.Core`](libs/CheatEngine.Client.Core/README.md) | SDK-facing implementation | Normally composed through DI, not called directly | +| [`CheatEngine.Client.Templates`](templates/CheatEngine.Client.Templates/README.md) | `dotnet new ceplugin` | Starting a new plugin | + +Package and assembly names describe delivery, not user code. Consumer-facing APIs use functional namespaces such as +`CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, `.Processes`, `.Runtime`, and `.Hosting`. + +## v0.1.0 capability status + +The Client reports runtime capability rather than assuming a particular Cheat Engine global or ownership contract. The +following table is a delivery statement, not a substitute for a live host check. + +| Area | v0.1.0 status | Boundary | +|-----------------------------------------------------|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Lifecycle, dispatch, DI, modules, options | Available | Per-enable provider and scope; modules stop in reverse order | +| Runtime facts and selected process | Available | Snapshot and attachment state are re-read through the active host | +| Typed memory and finite pointer chains | Available | Built-in primitives plus explicitly registered deterministic codecs; strings and byte ranges are bounded | +| Modules, regions, symbols, and custom-symbol leases | Available | Results are copied; leases are activation-scoped | +| AOB scanning | Available | Patterns are normalized; terminals are `FirstOrNone`, `RequireSingle`, or bounded `Take` | +| Address List and memory records | Available | Snapshots and hierarchy materialization are bounded; table file access requires an allowed root | +| Typed protected Lua and explicit Lua modules | Available | No Lua state crosses the public Client contract | +| Value scanning | **Capability-gated** | The public state machine exists, but Client session creation stays unavailable until the internal `MemScan`/`FoundList` ownership path passes its Cheat Engine 7.7 x64 live gate | +| Arbitrary Lua source | Policy-gated and off by default | Requires explicit unsafe opt-in; raw Lua state remains hidden | + +IPC, remote clients, UI/forms, debugger and breakpoints, Auto Assembler, injection, remote allocations, structures, +hotkeys/timers, speedhack, DBVM, Mono/IL2CPP, and advanced ABI hooks are outside v0.1. They have no placeholder +public API. The full current-state rationale is in [ADR 0004](docs/adr/0004-capability-matrix.md). + +## AOT, trimming, and deployment + +Shipping Client projects target `net10.0`, enable nullable analysis, warnings as errors, trim/AOT compatibility +analysis, reference-AOT verification, deterministic builds, XML documentation, Source Link, symbol packages, and +package/API validation. `CheatEngine.Client.AotProbe` publishes the complete Client graph as Native AOT for `win-x64` +to validate those library constraints. + +That is **not** a claim that Cheat Engine can load a Native AOT plugin DLL. The supported deployment remains the +framework-dependent managed plugin output folder. Deploy it as one unit: your plugin assembly, its `.deps.json` and +`.runtimeconfig.json`, Client and SDK assemblies, and the SDK's `cheatengine-sdk-lua-bridge.dll` must remain together. + +The template sets `IsAotCompatible` and `VerifyReferenceAotCompatibility` to protect the application code path, while +leaving the plugin itself in the SDK-supported managed form. See [ADR 0003](docs/adr/0003-package-and-aot-policy.md) +for the package and AOT policy. + +## Build and validation + +The repository pins the .NET SDK in [global.json](global.json), uses Central Package Management, and commits NuGet +lock files. Run the normal Windows validation sequence from the repository root: + +```powershell +dotnet restore CheatEngine.Client.slnx --locked-mode +dotnet build CheatEngine.Client.slnx --configuration Release --no-restore +dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore +dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore +./eng/Invoke-PackageSmoke.ps1 -PackageSource ./artifacts/packages +./eng/Invoke-TemplateSmoke.ps1 -PackageSource ./artifacts/packages +dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj --configuration Release --runtime win-x64 --no-restore --output ./artifacts/aot-probe +./artifacts/aot-probe/CheatEngine.Client.AotProbe.exe +``` + +The [Windows CI workflow](.github/workflows/ci.yml) runs the locked restore, Release build, Microsoft Testing Platform +tests, package API validation, isolated package smoke test, template smoke test, and Native AOT graph probe. The +Cheat Engine 7.7 x64 live suite is opt-in and intentionally excluded from ordinary CI; no CI result should be read as +proof that an untested live-host feature is available. + +## Security and scope + +This project is for local processes you are authorized to inspect or modify. It does not add network control, remote +transport, or a mechanism to bypass Cheat Engine or host protections. + +Table loading can execute Lua in the host. Keep `AllowedTableRoots` empty unless the plugin has an explicit, +trusted import/export location; an empty set disables table file access. Arbitrary Lua source is separately opt-in and +should remain disabled unless the plugin has a deliberate trust boundary. Avoid logging target-memory contents or Lua +source by default. + +## Architecture records + +The decisions that constrain the public surface and delivery model are maintained as short ADRs: + +- [Layered in-process architecture](docs/adr/0001-layered-in-process-architecture.md) +- [One Client activation per plugin enable epoch](docs/adr/0002-plugin-activation-lifecycle.md) +- [Package and AOT policy](docs/adr/0003-package-and-aot-policy.md) +- [Capability delivery matrix](docs/adr/0004-capability-matrix.md) + +For the SDK's bootstrap, generated Lua bindings, native bridge, and host ABI details, start with the +[CheatEngine.SDK README](https://github.com/CheatEngineNet/CheatEngine.SDK#readme). diff --git a/docs/adr/0001-layered-in-process-architecture.md b/docs/adr/0001-layered-in-process-architecture.md new file mode 100644 index 0000000..def67fe --- /dev/null +++ b/docs/adr/0001-layered-in-process-architecture.md @@ -0,0 +1,46 @@ +# ADR 0001: Layered in-process Client architecture + +- Status: Accepted +- Date: 2026-09-20 + +## Context + +`CheatEngine.SDK` exposes a managed route into a live Cheat Engine plugin host. Its Lua state, CE objects, ownership +wrappers, and thread-affinity rules are host-bound implementation details. The Client needs a higher-level, +dependency-injection-friendly API without recreating the SDK ABI or turning those implementation details into public +lifetime obligations. + +## Decision and why + +`CheatEngine.Client` is an in-process, high-level API hosted by a Cheat Engine plugin. It is not an external-process +adapter for `CheatEngine.SDK`, and V1 has no IPC endpoint. + +```text +Plugin assembly + -> CheatEngine.Client.Hosting + -> CheatEngine.Client.Extensions.DependencyInjection + -> CheatEngine.Client.Core -> CheatEngine.SDK -> Cheat Engine Lua/runtime + ^ +CheatEngine.Client.Abstractions <- CheatEngine.Client.Fluent +``` + +`Abstractions` owns public contracts, copied value types, failures, and module boundaries. `Fluent` creates immutable +operation descriptions and has no SDK access. `Core` is the only Client domain layer that maps operations to the SDK. +The DI extension owns explicit registrations and options validation; `Hosting` connects that composition to the plugin +lifecycle. The root `CheatEngine.Client` package is the consumer-facing umbrella. + +The labels in the diagram are package and assembly identities, not consumer namespace prefixes. Public contracts use +functional namespaces such as `CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, `.Runtime`, and `.Processes` +regardless of their delivery package. + +## Consequences and project value + +- Public APIs do not expose `LuaState`, `CEObject`, `Owned`, native pointers, or other host-bound SDK lifetimes. +- Application Lua exports are registered explicitly through `ILuaClient.RegisterModule`; the returned lease owns + activation-scoped unregistration. An `ILuaModule` may encapsulate generated SDK bindings internally but cannot let a + Lua state or SDK handle cross the Client contract. +- Builders remain pure. A complete Cheat Engine operation, including temporary-owner cleanup, crosses the SDK boundary + as one synchronous operation. This keeps fluent composition testable without a live host. +- A future remote bridge is a separate plugin-hosted product with its own protocol, authentication, backpressure, and + epoch-lifetime decision. It cannot introduce retries, transport concerns, or serializable handles into + `ICheatEngineClient` V1. diff --git a/docs/adr/0002-plugin-activation-lifecycle.md b/docs/adr/0002-plugin-activation-lifecycle.md new file mode 100644 index 0000000..069bc82 --- /dev/null +++ b/docs/adr/0002-plugin-activation-lifecycle.md @@ -0,0 +1,43 @@ +# ADR 0002: One Client activation per plugin enable epoch + +- Status: Accepted +- Date: 2026-09-20 + +## Context + +The SDK creates a plugin through a parameterless constructor and attaches the Lua runtime only for the enabled +lifetime. A long-lived provider, service, callback, or CE resource would therefore be able to outlive the host state +that makes it valid. + +## Decision and why + +`CheatEngineClientPlugin` creates a new `CheatEnginePluginBuilder`, service provider, and DI scope from `OnEnable`. +The derived plugin adds its own configuration sources in `Configure`; the Client does not load configuration implicitly. +For file-based configuration, a plugin may explicitly add an optional `appsettings.json` with `reloadOnChange: false`. + +The base validates options after building the provider, resolves the activation-scoped aggregate `ICheatEngineClient`, +enables registered modules in registration order, then invokes `OnClientEnabled`. A failed enable rolls back every +callback that was entered. + +On disable, the base clears the active Client reference first, invokes `OnClientDisabling`, disables enabled modules in +reverse order, drains Client-owned CE resources while the host is still attached, and finally disposes the scope, +provider, and configuration. Cleanup is best-effort and aggregates failures only after every step has been attempted. + +## Invariants + +- SDK calls are forbidden from plugin constructors, field initializers, and static initialization. SDK-dependent + services exist only while the plugin is enabled. +- `Configure` is a one-activation composition hook. It must not build a provider or reconfigure Client options after + provider construction; reloadable runtime configuration would violate the activation boundary. +- A Client scope, cancellation token, SDK handle, Lua reference, or CE-owned resource never crosses an enable/disable + epoch. `ICheatEngineClient.Epoch` and `Stopping` identify the active lifetime. +- Public Client operations are synchronous. They do not retain Lua state across an `await`, and main-thread work enters + the SDK dispatcher as a bounded operation. + +## Consequences and project value + +- Re-enable starts from a new composition rather than a partially disposed singleton graph. +- Reverse-order cleanup and rollback make module ownership explicit and make failure paths unit-testable without + mocking SDK statics. +- Clearing admission before cleanup prevents consumers from acquiring an activation while its resources are being + released. diff --git a/docs/adr/0003-package-and-aot-policy.md b/docs/adr/0003-package-and-aot-policy.md new file mode 100644 index 0000000..7b02a80 --- /dev/null +++ b/docs/adr/0003-package-and-aot-policy.md @@ -0,0 +1,47 @@ +# ADR 0003: Package and AOT policy + +- Status: Accepted +- Date: 2026-09-20 + +## Context + +The SDK's plugin entry-point generator and native Lua bridge are activated by a direct package reference in the plugin +project. Indirect NuGet dependencies do not provide a safe substitute for those build assets. At the same time, Native +AOT compatibility analysis can validate library dependencies without proving that Cheat Engine can load a Native AOT +plugin DLL. + +## Decision and why + +The Client baseline is version `0.1.0`, targets .NET 10 with C# 14, and enables trimming and AOT compatibility analysis +for shipping projects. Central package management pins `CheatEngine.SDK` to `1.0.0` in this repository; SDK-facing +published packages declare the compatible dependency range `[1.0.0, 2.0.0)`. + +The standalone template targets Windows x64 and references the Client, SDK, and JSON configuration provider directly: + +```xml + + + +``` + +The direct `CheatEngine.SDK` reference is deliberate. It activates the generated Cheat Engine plugin entry point and +copies the native Lua bridge. Generated plugin projects set `true`; +the `CheatEngine.Client.Hosting` build target then reports `CECLIENT001` if the project omits that direct SDK reference. + +## Delivery verification + +Windows CI restores in locked mode; builds and tests Release; packs with public API validation; smoke-tests isolated +package consumption and local template installation; and publishes then runs the `win-x64` Native AOT reference probe. +Live Cheat Engine checks are intentionally outside ordinary CI and remain explicit host-validation gates. + +## Consequences and project value + +- The maintained template is the source example for real package consumption, including the two direct references a + plugin needs outside this repository. +- A standalone generated plugin uses explicit local package versions. A future template using central package management + must bring its own `Directory.Packages.props`; it cannot inherit this repository's file. +- `CheatEngine.Client.AotProbe` validates the shipping graph under Native AOT analysis. It does not claim that Cheat + Engine can load a Native AOT plugin; supported deployment remains the managed SDK plugin output together with its + runtime configuration and native bridge. +- A dependency that requires reflection, runtime type discovery, dynamic code, or reflection-based JSON serialization + needs a trimming-safe alternative before entering a shipping Client project. diff --git a/docs/adr/0004-capability-matrix.md b/docs/adr/0004-capability-matrix.md new file mode 100644 index 0000000..35ec06a --- /dev/null +++ b/docs/adr/0004-capability-matrix.md @@ -0,0 +1,57 @@ +# ADR 0004: Capability delivery matrix + +- Status: Accepted +- Date: 2026-09-20 + +## Context + +An interface alone does not establish that a Cheat Engine operation is safe to create, use, dispose, or repeat across +plugin activation. In particular, SDK 1.0.0 does not expose the ownership factory required for the Client to create and +adopt `MemScan` and `FoundList` instances without inventing an unverified handle-lifetime contract. + +## Decision and why + +The Client reports implementation status separately from public vocabulary. A capability is not represented as usable +only because an abstraction can describe it. + +### Status vocabulary + +- **Implemented**: the aggregate `ICheatEngineClient` composes an operational Client implementation for an enable + epoch. The listed host boundary still applies before claiming live-host qualification. +- **Capability-gated**: a public surface exists, but the implementation reports an unsupported capability instead of + assuming an unproven SDK ownership, affinity, or cancellation contract. +- **Deferred**: V1 intentionally provides no operational route through the aggregate Client. + +| Area | Public surface | Current source status | Boundary before live qualification | +|---|---|---|---| +| Plugin lifecycle and DI | `CheatEngineClientPlugin`, `CheatEnginePluginBuilder`, modules, options | Implemented | Exercise enable, rollback, disable, and repeated epoch activation in a CE host. | +| Runtime and capability facts | `ICheatEngineRuntime` | Implemented | Add evidence-backed observations per CE version and architecture as the SDK surface expands. | +| Process, module, and inspection | `IProcessClient`, `IInspectionClient` | Implemented | Map only SDK APIs with verified normal-return and thread contracts. | +| Typed memory | `IMemoryClient`, codecs, and requests | Implemented | Keep reads and writes bounded and classify host failures without leaking Lua state. | +| AOB scan | `IPatternScanner`, `AobScanRequest` | Implemented | Copy returned addresses and release temporary SDK owners in the same CE operation. | +| Value scan | `IValueScanner`, `IValueScanSession`, page records | Capability-gated | Require CE 7.7 x64 evidence for creation, first/next scan, read, ordered destruction, disable, and re-enable. | +| Address tables | `ITableClient`, copied record contracts | Implemented | Validate current-table lifetime, updates, and configured table-root enforcement. | +| Protected and unsafe Lua | `ILuaClient`, `IUnsafeLuaClient` | Protected operations implemented; arbitrary source is policy-gated | Keep arbitrary source opt-in and unavailable by default. | +| IPC or remote Client | None in V1 | Deferred | Define transport, authentication, handle epochs, and backpressure in a separate product decision. | + +## Consequences and project value + +- `IValueScanner.CreateSession` remains unavailable until the internal `createMemScan` and `createFoundList` owner has + passed creation, first and next scans, reading, ordered destruction, disable, and re-enable in Cheat Engine 7.7 x64. + The Client will not bypass the SDK restriction with reflection or a hand-rolled public owner. +- Templates and README examples can expose only guarded operations. They must not imply value scanning, arbitrary Lua, + or host-side behavior that ordinary CI has not established. +- The matrix gives reviewers one place to distinguish a deliberate product gate from a missing implementation, keeping + package release claims honest as SDK evidence changes. + +## Current template boundary + +The template is the maintained source example. It compiles against the hosted plugin shape with explicit JSON +configuration and no reload watcher, demonstrates explicit module DI and validated options, and registers an +application-owned `ILuaModule` through `ILuaClient.RegisterModule`. Its activation-scoped lease owns unregistration of +the generated SDK Lua export. SDK binding calls remain inside that application module; no Lua state or SDK ownership +handle crosses the Client contract. + +The generated project takes an Address List snapshot and performs a guarded AOB and typed-memory probe. It performs no +operation when the required runtime precondition is absent, does not log target-memory contents, and retains the epoch, +ownership, and main-thread constraints defined by these ADRs. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..8f024d2 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,32 @@ +# Architecture Decision Records + +## Context + +These records define the architectural constraints for `CheatEngine.Client`: its public boundaries, plugin lifetime, +package layout, and delivered capability scope. They complement API documentation; they are not a product roadmap or a +substitute for the SDK contract. + +## Why this directory exists + +The Client sits above a host-bound SDK with thread-affinity and ownership rules. Recording the decisions keeps a +convenient public API from silently acquiring unsafe handles, cross-epoch state, indirect SDK build assets, or +unsupported host assumptions. + +## How the records improve the project + +Each ADR is a review boundary. A change that alters a listed decision must update the relevant record or add a new one, +so source code, package behavior, templates, and CI gates remain aligned. + +| Record | Decision | Primary effect | +|---|---|---| +| [0001](0001-layered-in-process-architecture.md) | Keep the Client in-process and isolate SDK domain mapping in Core. | Prevent host handles and transport concerns from leaking through functional Client APIs. | +| [0002](0002-plugin-activation-lifecycle.md) | Create one composition and Client scope for each plugin enable epoch. | Makes cleanup, module rollback, and epoch invalidation deterministic. | +| [0003](0003-package-and-aot-policy.md) | Require a direct SDK reference in plugin projects and distinguish AOT analysis from an AOT plugin binary. | Preserves SDK generators and native bridge assets at the plugin boundary. | +| [0004](0004-capability-matrix.md) | Report implemented, gated, and deferred capabilities separately. | Prevents contracts and templates from implying unverified Cheat Engine behavior. | + +## Delivery alignment + +The Windows CI workflow restores the locked graph, builds and tests Release, packs the public APIs, validates isolated +package and template consumption, then publishes and runs the Native AOT graph probe. Live Cheat Engine validation is +intentionally opt-in and remains a release boundary where an ADR identifies it; ordinary CI does not claim to replace +that host evidence. diff --git a/eng/Invoke-PackageSmoke.ps1 b/eng/Invoke-PackageSmoke.ps1 new file mode 100644 index 0000000..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.Abstractions/README.md b/libs/CheatEngine.Client.Abstractions/README.md index fa5b960..499cbcc 100644 --- a/libs/CheatEngine.Client.Abstractions/README.md +++ b/libs/CheatEngine.Client.Abstractions/README.md @@ -49,16 +49,16 @@ CheatEngine.Client.Abstractions Package and assembly names are not consumer namespaces. Public code belongs to functional namespaces only: -| Namespace | Responsibility | -|---|---| -| `CheatEngine.Client` | `ICheatEngineClient`, the activation-scoped facade | -| `.Dispatching` / `.Runtime` | main-thread dispatch and runtime/capability observations | -| `.Processes` / `.Inspection` | target selection, copied process/module/region/symbol data | -| `.Memory` | bounded primitive, byte, string, codec, and pointer-chain operations | -| `.Scanning` | AOB contracts and the value-scan session contract | -| `.Tables` | copied Address List records and explicitly trusted table I/O requests | -| `.Lua` / `.Modules` | typed protected Lua operations, explicit modules, and leases | -| `.Results` | classified expected failures and lifecycle exceptions | +| Namespace | Responsibility | +|------------------------------|-----------------------------------------------------------------------| +| `CheatEngine.Client` | `ICheatEngineClient`, the activation-scoped facade | +| `.Dispatching` / `.Runtime` | main-thread dispatch and runtime/capability observations | +| `.Processes` / `.Inspection` | target selection, copied process/module/region/symbol data | +| `.Memory` | bounded primitive, byte, string, codec, and pointer-chain operations | +| `.Scanning` | AOB contracts and the value-scan session contract | +| `.Tables` | copied Address List records and explicitly trusted table I/O requests | +| `.Lua` / `.Modules` | typed protected Lua operations, explicit modules, and leases | +| `.Results` | classified expected failures and lifecycle exceptions | No public consumer should use `CheatEngine.Client.Abstractions` as a namespace. diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs index 30e6f6b..0995fee 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeSnapshot.cs @@ -53,52 +53,28 @@ public CheatEngineRuntimePlatformInfo Platform } /// Gets the coarse number returned by CE's getCEVersion global, when it was callable. - public double? ObservedCheatEngineVersion - { - get => Version.ObservedCheatEngineVersion; - } + public double? ObservedCheatEngineVersion => Version.ObservedCheatEngineVersion; /// Gets the complete CE build against which this Client release was qualified. - public CheatEngineVersion QualifiedCheatEngineBaseline - { - get => Version.QualifiedCheatEngineBaseline; - } + public CheatEngineVersion QualifiedCheatEngineBaseline => Version.QualifiedCheatEngineBaseline; /// Gets the assembly version of this Client abstraction assembly. - public Version ClientAssemblyVersion - { - get => Version.ClientAssemblyVersion; - } + public Version ClientAssemblyVersion => Version.ClientAssemblyVersion; /// Gets the assembly version of the SDK runtime-contract assembly. - public Version SdkAssemblyVersion - { - get => Version.SdkAssemblyVersion; - } + public Version SdkAssemblyVersion => Version.SdkAssemblyVersion; /// Gets the CE host architecture observed from CE's system-architecture global. - public CheatEngineArchitecture SystemArchitecture - { - get => Platform.SystemArchitecture; - } + public CheatEngineArchitecture SystemArchitecture => Platform.SystemArchitecture; /// Gets the target architecture observed by a target-specific probe, or unknown. - public CheatEngineArchitecture TargetArchitecture - { - get => Platform.TargetArchitecture; - } + public CheatEngineArchitecture TargetArchitecture => Platform.TargetArchitecture; /// Gets the pointer width implied by the observed target architecture, or unknown. - public PointerSize TargetPointerSize - { - get => Platform.TargetPointerSize; - } + public PointerSize TargetPointerSize => Platform.TargetPointerSize; /// Gets the target ABI observed from CE's ABI global, or unknown. - public TargetAbi TargetAbi - { - get => Platform.TargetAbi; - } + public TargetAbi TargetAbi => Platform.TargetAbi; /// Gets the explicit availability observation for each SDK runtime capability that was probed. public RuntimeCapabilities SdkCapabilities @@ -114,8 +90,8 @@ public ClientCapabilities ClientCapabilities /// Gets whether the observed coarse CE version belongs to the qualified major/minor line. public bool IsOnQualifiedCheatEngineLine => ObservedCheatEngineVersion is { } observed && - observed >= QualifiedCheatEngineBaseline.Major + - QualifiedCheatEngineBaseline.Minor / 10d && - observed < QualifiedCheatEngineBaseline.Major + - (QualifiedCheatEngineBaseline.Minor + 1) / 10d; + observed >= QualifiedCheatEngineBaseline.Major + + QualifiedCheatEngineBaseline.Minor / 10d && + observed < QualifiedCheatEngineBaseline.Major + + (QualifiedCheatEngineBaseline.Minor + 1) / 10d; } diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs index f879e54..66c1791 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/CheatEngineRuntimeVersionInfo.cs @@ -13,7 +13,7 @@ public CheatEngineRuntimeVersionInfo( Version sdkAssemblyVersion) { if (observedCheatEngineVersion is { } observed && - (!double.IsFinite(observed) || observed < 0)) + (!double.IsFinite(observed) || observed < 0)) { throw new ArgumentOutOfRangeException(nameof(observedCheatEngineVersion), observed, "The observed Cheat Engine version must be a finite non-negative number when supplied."); diff --git a/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs b/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs index aa3e46c..c8ac7c8 100644 --- a/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs +++ b/libs/CheatEngine.Client.Abstractions/Scanning/AobPattern.cs @@ -42,10 +42,16 @@ private AobPattern(string normalized, int byteLength) } /// Gets the normalized Cheat Engine pattern text. - public string Value { get; } + public string Value + { + get; + } /// Gets the number of byte positions represented by the pattern. - public int ByteLength { get; } + public int ByteLength + { + get; + } /// Gets whether every byte position is a wildcard. public bool IsWildcardOnly diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs index 7c59637..afced03 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordContentSnapshot.cs @@ -6,7 +6,8 @@ namespace CheatEngine.Client.Tables; public readonly record struct MemoryRecordContentSnapshot { /// Creates copied content fields for a memory-record snapshot. - public MemoryRecordContentSnapshot(string description, string addressExpression, string value, VariableType variableType) + public MemoryRecordContentSnapshot(string description, string addressExpression, string value, + VariableType variableType) { ArgumentNullException.ThrowIfNull(description); ArgumentNullException.ThrowIfNull(addressExpression); diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs index 6e9c5b9..a21e300 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSearch.cs @@ -37,14 +37,26 @@ public MemoryRecordSearch( } /// Gets the case-insensitive description substring predicate. - public string? DescriptionContains { get; } + public string? DescriptionContains + { + get; + } /// Gets the case-insensitive exact address-expression predicate. - public string? AddressExpression { get; } + public string? AddressExpression + { + get; + } /// Gets the optional exact Cheat Engine value-type predicate. - public VariableType? VariableType { get; } + public VariableType? VariableType + { + get; + } /// Gets the optional active/frozen state predicate. - public bool? IsActive { get; } + public bool? IsActive + { + get; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs index 2d0b6f7..e9ca221 100644 --- a/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs +++ b/libs/CheatEngine.Client.Abstractions/Tables/MemoryRecordSnapshot.cs @@ -53,44 +53,23 @@ public MemoryRecordStateSnapshot State } /// Gets the record display description. - public string Description - { - get => Content.Description; - } + public string Description => Content.Description; /// Gets the record's unresolved Cheat Engine address expression. - public string AddressExpression - { - get => Content.AddressExpression; - } + public string AddressExpression => Content.AddressExpression; /// Gets the record's verbatim value text. - public string Value - { - get => Content.Value; - } + public string Value => Content.Value; /// Gets the record's Cheat Engine value type. - public VariableType VariableType - { - get => Content.VariableType; - } + public VariableType VariableType => Content.VariableType; /// Gets the currently resolved target address when it could be obtained. - public Address? CurrentAddress - { - get => State.CurrentAddress; - } + public Address? CurrentAddress => State.CurrentAddress; /// Gets whether Cheat Engine reports this record as active or frozen. - public bool IsActive - { - get => State.IsActive; - } + public bool IsActive => State.IsActive; /// Gets the number of immediate child records reported by Cheat Engine. - public int ChildCount - { - get => State.ChildCount; - } + public int ChildCount => State.ChildCount; } diff --git a/libs/CheatEngine.Client.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.Core/CheatEngine.Client.Core.csproj b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj new file mode 100644 index 0000000..cb7032f --- /dev/null +++ b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj @@ -0,0 +1,19 @@ + + + + true + + false + + + + + + + + + + + + + diff --git a/libs/CheatEngine.Client.Core/CheatEngineClient.cs b/libs/CheatEngine.Client.Core/CheatEngineClient.cs new file mode 100644 index 0000000..4ce4fc2 --- /dev/null +++ b/libs/CheatEngine.Client.Core/CheatEngineClient.cs @@ -0,0 +1,84 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +namespace CheatEngine.Client.Core; + +internal sealed class CheatEngineClient : ICheatEngineClient +{ + private readonly CoreLifetime _lifetime; + + internal CheatEngineClient( + CoreLifetime lifetime, + CheatEngineClientRuntimeServices runtimeServices, + CheatEngineClientDomainServices domainServices) + { + _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + ArgumentNullException.ThrowIfNull(runtimeServices); + ArgumentNullException.ThrowIfNull(domainServices); + + Runtime = runtimeServices.Runtime ?? throw new ArgumentNullException(nameof(runtimeServices)); + Dispatcher = runtimeServices.Dispatcher ?? throw new ArgumentNullException(nameof(runtimeServices)); + Processes = domainServices.Processes ?? throw new ArgumentNullException(nameof(domainServices)); + Memory = domainServices.Memory ?? throw new ArgumentNullException(nameof(domainServices)); + Patterns = domainServices.Patterns ?? throw new ArgumentNullException(nameof(domainServices)); + Scans = domainServices.Scans ?? throw new ArgumentNullException(nameof(domainServices)); + Inspection = domainServices.Inspection ?? throw new ArgumentNullException(nameof(domainServices)); + Tables = domainServices.Tables ?? throw new ArgumentNullException(nameof(domainServices)); + Lua = domainServices.Lua ?? throw new ArgumentNullException(nameof(domainServices)); + } + + public long Epoch => _lifetime.Epoch; + public CancellationToken Stopping => _lifetime.Stopping; + + public ICheatEngineRuntime Runtime + { + get; + } + + public ICheatEngineDispatcher Dispatcher + { + get; + } + + public IProcessClient Processes + { + get; + } + + public IMemoryClient Memory + { + get; + } + + public IPatternScanner Patterns + { + get; + } + + public IValueScanner Scans + { + get; + } + + public IInspectionClient Inspection + { + get; + } + + public ITableClient Tables + { + get; + } + + public ILuaClient Lua + { + get; + } +} diff --git a/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs b/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs new file mode 100644 index 0000000..7ce2156 --- /dev/null +++ b/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs @@ -0,0 +1,18 @@ +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +namespace CheatEngine.Client.Core; + +/// Internal grouping of the independently consumable high-level Client domains. +internal sealed record CheatEngineClientDomainServices( + IProcessClient Processes, + IMemoryClient Memory, + IPatternScanner Patterns, + IValueScanner Scans, + IInspectionClient Inspection, + ITableClient Tables, + ILuaClient Lua); diff --git a/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs b/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs new file mode 100644 index 0000000..18bdb18 --- /dev/null +++ b/libs/CheatEngine.Client.Core/CheatEngineClientRuntimeServices.cs @@ -0,0 +1,9 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Runtime; + +namespace CheatEngine.Client.Core; + +/// Internal grouping of the façade services that describe the active runtime and its dispatch boundary. +internal sealed record CheatEngineClientRuntimeServices( + ICheatEngineRuntime Runtime, + ICheatEngineDispatcher Dispatcher); diff --git a/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs b/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs new file mode 100644 index 0000000..db33040 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs @@ -0,0 +1,8 @@ +namespace CheatEngine.Client.Core.Dispatching; + +internal interface IMainThreadInvoker +{ + public Exception? Invoke(Action callback); + + public MainThreadInvocationResult Invoke(Func callback); +} diff --git a/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs b/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs new file mode 100644 index 0000000..dd31ef8 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Dispatching/MainThreadInvocationResult.cs @@ -0,0 +1,3 @@ +namespace CheatEngine.Client.Core.Dispatching; + +internal readonly record struct MainThreadInvocationResult(T Result, Exception? Exception); diff --git a/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs new file mode 100644 index 0000000..b4395f5 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs @@ -0,0 +1,189 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Hosting.Threading; + +namespace CheatEngine.Client.Core.Dispatching; + +/// Adapts the SDK's synchronous main-thread dispatcher without retaining Lua state. +internal sealed class SdkMainThreadDispatcher : ICheatEngineDispatcher +{ + private const string _invokeOperation = "Dispatcher.Invoke"; + + private readonly CoreLifetime _lifetime; + private readonly IMainThreadInvoker _mainThread; + + internal SdkMainThreadDispatcher(CoreLifetime lifetime) + : this(lifetime, SdkMainThreadInvoker.Instance) + { + } + + internal SdkMainThreadDispatcher(CoreLifetime lifetime, IMainThreadInvoker mainThread) + { + _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + _mainThread = mainThread ?? throw new ArgumentNullException(nameof(mainThread)); + } + + public bool IsMainThread => _lifetime.CanDispatch && MainThread.IsMainThread; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + _lifetime.ThrowIfDispatchAllowed(_invokeOperation); + if (cancellationToken.IsCancellationRequested) + { + failure = CoreFailureFactory.Cancelled(_invokeOperation); + return false; + } + + Exception? callbackException; + try + { + callbackException = _mainThread.Invoke(callback); + } + catch (CheatEngineActivationExpiredException) + { + throw; + } + catch (Exception exception) when (!_lifetime.IsActivationCurrent) + { + throw new CheatEngineActivationExpiredException(_invokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + } + catch (Exception exception) + { + failure = CoreFailureFactory.FromException(_invokeOperation, exception); + return false; + } + + if (callbackException is not null) + { + RethrowCallback(callbackException); + } + + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + _lifetime.ThrowIfDispatchAllowed(_invokeOperation); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = CoreFailureFactory.Cancelled(_invokeOperation); + return false; + } + + MainThreadInvocationResult callbackResult; + try + { + callbackResult = _mainThread.Invoke(callback); + } + catch (CheatEngineActivationExpiredException) + { + throw; + } + catch (Exception exception) when (!_lifetime.IsActivationCurrent) + { + result = default; + throw new CheatEngineActivationExpiredException(_invokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + } + catch (Exception exception) + { + result = default; + failure = CoreFailureFactory.FromException(_invokeOperation, exception); + return false; + } + + if (callbackResult.Exception is not null) + { + RethrowCallback(callbackResult.Exception); + } + + result = callbackResult.Result; + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + _ = ThrowFailure(failure); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + return ThrowFailure(failure); + } + + private static T ThrowFailure(CheatEngineFailure failure) + { + failure.Throw(); + throw new UnreachableException(); + } + + private static void RethrowCallback(Exception callbackException) + { + ExceptionDispatchInfo.Capture(callbackException).Throw(); + throw new UnreachableException(); + } + + private static Exception? InvokeCallback(Action callback) + { + try + { + callback(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static MainThreadInvocationResult InvokeCallback(Func callback) + { + try + { + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } + + private sealed class SdkMainThreadInvoker : IMainThreadInvoker + { + internal static SdkMainThreadInvoker Instance + { + get; + } = new(); + + public Exception? Invoke(Action callback) + { + return MainThread.Invoke(static action => InvokeCallback(action), callback); + } + + public MainThreadInvocationResult Invoke(Func callback) + { + return MainThread.Invoke(static function => InvokeCallback(function), callback); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs b/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs new file mode 100644 index 0000000..ef36dd5 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/AobScanHostStatus.cs @@ -0,0 +1,9 @@ +namespace CheatEngine.Client.Core.Domains; + +/// Classifies the SDK AOB result-list boundary without exposing its ownership handle. +internal enum AobScanHostStatus +{ + Success, + Rejected, + InvalidResult +} diff --git a/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs b/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs new file mode 100644 index 0000000..d0d3992 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IAobMatchList.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal, activation-thread-owned view of an SDK AOB result list. +internal interface IAobMatchList : IDisposable +{ + public bool TryGetCount(out int count); + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs b/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs new file mode 100644 index 0000000..3cf27a5 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IAobScanPort.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal adapter boundary that copies SDK-owned AOB results before Client materializes them. +internal interface IAobScanPort +{ + public AobScanHostStatus TryScan(string pattern, AobScanOptions options, + [NotNullWhen(true)] out IAobMatchList? matches); + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs b/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs new file mode 100644 index 0000000..f40f6ac --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IInspectionPort.cs @@ -0,0 +1,29 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal adapter boundary for the protected SDK inspection primitives. +internal interface IInspectionPort +{ + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written); + + public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] destination, out int written); + + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, out int written); + + public InspectionStatus EnumerateMemoryRegions(MemoryRegionInfo[] destination, out int written); + + public InspectionStatus GetMemoryRegion(Address address, out MemoryRegionInfo region); + + public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol); + + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + out Address address); + + public bool TryResolveName(nuint address, out string? name); + + public void RegisterSymbol(string name, nuint address, bool doNotSave); + + public void UnregisterSymbol(string name); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs b/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs new file mode 100644 index 0000000..2defb5b --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs @@ -0,0 +1,13 @@ +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// Internal adapter boundary for CE process globals and local managed process metadata. +internal interface IProcessHost +{ + public long GetOpenedProcessId(); + public void OpenProcess(long processId); + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process); + public IReadOnlyList FindProcessesByExactName(string processName); + public CheatEngineArchitecture GetTargetArchitecture(); +} diff --git a/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs b/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs new file mode 100644 index 0000000..caab5fa --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/IRuntimeProbe.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Core.Domains; + +/// Internal port for CE runtime globals, so the domain is testable without mocking generated SDK statics. +internal interface IRuntimeProbe +{ + public double GetCheatEngineVersion(); + public int GetSystemArchitecture(); + public int GetTargetAbi(); + public long GetOpenedProcessId(); + public bool TargetIs64Bit(); +} diff --git a/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs b/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs new file mode 100644 index 0000000..2e1c7f1 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ITableRecordMutationPort.cs @@ -0,0 +1,16 @@ +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Separates the static SDK address-list access from mutation classification so the Client's public failure contract +/// remains deterministic and directly testable. +/// +internal interface ITableRecordMutationPort +{ + public TableRecordMutationStatus TryDelete(MemoryRecordId id); + + public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record); +} diff --git a/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs new file mode 100644 index 0000000..d09e9a2 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs @@ -0,0 +1,376 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class InspectionClient( + SdkMainThreadDispatcher dispatcher, + CoreLifetime lifetime, + IInspectionPort? inspection = null) : IInspectionClient +{ + private readonly SdkMainThreadDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly IInspectionPort _inspection = inspection ?? new SdkInspectionPort(); + + private readonly CoreLifetime _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + private readonly HashSet _registeredSymbolNames = new(StringComparer.Ordinal); + private readonly Lock _registeredSymbolNamesLock = new(); + + public bool TryGetModules(InspectionCollectionRequest request, out ImmutableArray modules, + out CheatEngineFailure failure, TargetProcessId? processId = null, + CancellationToken cancellationToken = default) + { + ImmutableArray result = ImmutableArray.Empty; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => + { + ModuleInfo[] buffer = new ModuleInfo[request.MaximumItems]; + status = processId.HasValue + ? _inspection.EnumerateModules(processId.Value, buffer, out int written) + : _inspection.EnumerateModules(buffer, out written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, out failure, cancellationToken)) + { + modules = []; + return false; + } + + modules = result; + return TryMap(status, "Inspection.GetModules", out failure); + } + + public ImmutableArray GetModules(InspectionCollectionRequest request, + TargetProcessId? processId = null, CancellationToken cancellationToken = default) + { + if (TryGetModules(request, out ImmutableArray result, out CheatEngineFailure failure, processId, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return []; + } + + public bool TryGetModuleSections(ModuleName moduleName, InspectionCollectionRequest request, + out ImmutableArray sections, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ImmutableArray result = ImmutableArray.Empty; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => + { + ModuleSectionInfo[] buffer = new ModuleSectionInfo[request.MaximumItems]; + status = _inspection.EnumerateSections(moduleName, buffer, out int written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, out failure, cancellationToken)) + { + sections = []; + return false; + } + + sections = result; + return TryMap(status, "Inspection.GetModuleSections", out failure); + } + + public ImmutableArray GetModuleSections(ModuleName moduleName, + InspectionCollectionRequest request, CancellationToken cancellationToken = default) + { + if (TryGetModuleSections(moduleName, request, out ImmutableArray result, + out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return []; + } + + public bool TryGetMemoryRegions(InspectionCollectionRequest request, + out ImmutableArray regions, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ImmutableArray result = []; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => + { + MemoryRegionInfo[] buffer = new MemoryRegionInfo[request.MaximumItems]; + status = _inspection.EnumerateMemoryRegions(buffer, out int written); + if (status == InspectionStatus.Success) + { + result = ImmutableArray.Create(buffer, 0, written); + } + }, out failure, cancellationToken)) + { + regions = []; + return false; + } + + regions = result; + return TryMap(status, "Inspection.GetMemoryRegions", out failure); + } + + public ImmutableArray GetMemoryRegions(InspectionCollectionRequest request, + CancellationToken cancellationToken = default) + { + if (TryGetMemoryRegions(request, out ImmutableArray result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return []; + } + + public bool TryGetMemoryRegion(Address address, out MemoryRegionInfo region, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + MemoryRegionInfo captured = default; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => status = _inspection.GetMemoryRegion(address, out captured), + out failure, cancellationToken)) + { + region = default; + return false; + } + + region = captured; + return TryMap(status, "Inspection.GetMemoryRegion", out failure); + } + + public MemoryRegionInfo GetMemoryRegion(Address address, CancellationToken cancellationToken = default) + { + if (TryGetMemoryRegion(address, out MemoryRegionInfo result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryGetSymbol(SymbolExpression expression, out SymbolInfo symbol, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + SymbolInfo captured = default; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => status = _inspection.GetSymbol(expression, out captured), + out failure, cancellationToken)) + { + symbol = default; + return false; + } + + symbol = captured; + return TryMap(status, "Inspection.GetSymbol", out failure); + } + + public SymbolInfo GetSymbol(SymbolExpression expression, CancellationToken cancellationToken = default) + { + if (TryGetSymbol(expression, out SymbolInfo result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryResolveName(Address address, [NotNullWhen(true)] out string? name, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + string? captured = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + succeeded = _inspection.TryResolveName(ToNativeAddress(address), out captured), + out failure, + cancellationToken)) + { + name = null; + return false; + } + + if (succeeded && captured is not null) + { + name = captured; + return true; + } + + name = null; + failure = succeeded + ? new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, "Inspection.ResolveName", + "Cheat Engine returned an invalid symbol-name result.") + : new CheatEngineFailure(CheatEngineFailureKind.NotFound, "Inspection.ResolveName", + "Cheat Engine did not return a symbol name for the requested address."); + return false; + } + + public string ResolveName(Address address, CancellationToken cancellationToken = default) + { + if (TryResolveName(address, out string? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return string.Empty; + } + + public bool TryRegisterSymbol(SymbolRegistration registration, + [NotNullWhen(true)] out ISymbolRegistrationLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + lease = null; + if (!TryReserveSymbolName(registration.Name, out failure)) + { + return false; + } + + if (!_dispatcher.TryInvoke(() => _inspection.RegisterSymbol(registration.Name, + ToNativeAddress(registration.Address), registration.DoNotSave), out failure, cancellationToken)) + { + ReleaseSymbolName(registration.Name); + return false; + } + + SymbolRegistrationLease created = new( + registration, + _dispatcher, + lease => _lifetime.Untrack(lease), + _inspection.UnregisterSymbol, + ReleaseSymbolName); + try + { + _lifetime.Track(created); + lease = created; + failure = default; + return true; + } + catch (Exception exception) + { + try + { + created.Dispose(); + } + catch + { + // The original lifecycle failure is the meaningful result. Release the name below only if disposal did not. + } + + if (!created.IsReleased) + { + ReleaseSymbolName(registration.Name); + } + + failure = CoreFailureFactory.FromException("Inspection.RegisterSymbol", exception); + return false; + } + } + + public ISymbolRegistrationLease RegisterSymbol(SymbolRegistration registration, + CancellationToken cancellationToken = default) + { + if (TryRegisterSymbol(registration, out ISymbolRegistrationLease? result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + throw new InvalidOperationException("Unreachable failure flow."); + } + + public bool TryResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + out Address address, out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + Address captured = Address.Zero; + InspectionStatus status = InspectionStatus.InvalidResult; + if (!_dispatcher.TryInvoke(() => status = _inspection.ResolveAddress(expression, options, out captured), + out failure, cancellationToken)) + { + address = default; + return false; + } + + address = captured; + return TryMap(status, "Inspection.ResolveAddress", out failure); + } + + public Address ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + CancellationToken cancellationToken = default) + { + if (TryResolveAddress(expression, options, out Address result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + private static bool TryMap(InspectionStatus status, string operation, out CheatEngineFailure failure) + { + if (status == InspectionStatus.Success) + { + failure = default; + return true; + } + + CheatEngineFailureKind kind = status switch + { + InspectionStatus.NotFound => CheatEngineFailureKind.NotFound, + InspectionStatus.DestinationTooSmall => CheatEngineFailureKind.ResultLimitExceeded, + InspectionStatus.GlobalUnavailable => CheatEngineFailureKind.CapabilityUnavailable, + InspectionStatus.LuaFailure => CheatEngineFailureKind.LuaError, + InspectionStatus.InvalidResult => CheatEngineFailureKind.InvalidHostResult, + _ => CheatEngineFailureKind.Unknown + }; + failure = new CheatEngineFailure(kind, operation, $"Cheat Engine inspection returned '{status}'."); + return false; + } + + private bool TryReserveSymbolName(string name, out CheatEngineFailure failure) + { + lock (_registeredSymbolNamesLock) + { + if (_registeredSymbolNames.Add(name)) + { + failure = default; + return true; + } + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Inspection.RegisterSymbol", + "This client activation already owns a symbol registration with the requested name."); + return false; + } + + private void ReleaseSymbolName(string name) + { + lock (_registeredSymbolNamesLock) + { + _registeredSymbolNames.Remove(name); + } + } + + private static nuint ToNativeAddress(Address address) + { + return checked((nuint) address.Value); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs new file mode 100644 index 0000000..42d79e0 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs @@ -0,0 +1,103 @@ +using System.ComponentModel; +using System.Diagnostics; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class LocalProcessHost : IProcessHost +{ + public long GetOpenedProcessId() + { + return ClientLuaGlobals.GetOpenedProcessId(); + } + + public void OpenProcess(long processId) + { + ClientLuaGlobals.OpenProcess(processId); + } + + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + try + { + using Process managedProcess = Process.GetProcessById(processId); + return TryCapture(managedProcess, out process); + } + catch (ArgumentException) + { + process = default; + return false; + } + } + + public IReadOnlyList FindProcessesByExactName(string processName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(processName); + using ProcessCollection processes = new(Process.GetProcessesByName(processName)); + List matches = new(processes.Count); + for (int index = 0; index < processes.Count; index++) + { + if (TryCapture(processes[index], out LocalProcessInfo process) && + string.Equals(process.Name, processName, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(process); + } + } + + return matches; + } + + public CheatEngineArchitecture GetTargetArchitecture() + { + return ClientLuaGlobals.TargetIs64Bit() + ? CheatEngineArchitecture.X64 + : CheatEngineArchitecture.X86; + } + + private static bool TryCapture(Process process, out LocalProcessInfo captured) + { + try + { + string? executablePath = TryGetExecutablePath(process); + captured = new LocalProcessInfo(process.Id, process.ProcessName, executablePath); + return true; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + captured = default; + return false; + } + } + + private static string? TryGetExecutablePath(Process process) + { + try + { + return process.MainModule?.FileName; + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception + or NotSupportedException) + { + return null; + } + } + + private sealed class ProcessCollection(Process[] processes) : IDisposable + { + private readonly Process[] _processes = processes; + + internal int Count => _processes.Length; + + internal Process this[int index] => _processes[index]; + + public void Dispose() + { + foreach (Process process in _processes) + { + process.Dispose(); + } + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessInfo.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessInfo.cs new file mode 100644 index 0000000..e43d3cb --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LocalProcessInfo.cs @@ -0,0 +1,6 @@ +using System.Diagnostics; + +namespace CheatEngine.Client.Core.Domains; + +/// Copied local process metadata; it does not own a handle. +internal readonly record struct LocalProcessInfo(int Id, string? Name, string? ExecutablePath); diff --git a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs new file mode 100644 index 0000000..047b2ad --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs @@ -0,0 +1,270 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class LuaClient : ILuaClient +{ + private readonly ICheatEngineDispatcher _dispatcher; + private readonly Func _epochProvider; + private readonly Func _isContextCurrent; + private readonly HashSet _registeredModules = new(ReferenceEqualityComparer.Instance); + private readonly Lock _registeredModulesLock = new(); + private readonly Action _trackLease; + private readonly Action _untrackLease; + + internal LuaClient(SdkMainThreadDispatcher dispatcher, CoreLifetime lifetime) + : this(dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)), + CreateProductionInitialization(lifetime)) + { + } + + private LuaClient(ICheatEngineDispatcher dispatcher, LuaClientInitialization initialization) + : this( + dispatcher, + initialization.EpochProvider, + initialization.IsContextCurrent, + initialization.TrackLease, + initialization.UntrackLease) + { + } + + /// Deterministic internal seam for Core tests; production construction uses the SDK dispatcher overload. + internal LuaClient( + ICheatEngineDispatcher dispatcher, + Func epochProvider, + Func isContextCurrent, + Action? trackLease = null, + Action? untrackLease = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _epochProvider = epochProvider ?? throw new ArgumentNullException(nameof(epochProvider)); + _isContextCurrent = isContextCurrent ?? throw new ArgumentNullException(nameof(isContextCurrent)); + _trackLease = trackLease ?? (static _ => + { + }); + _untrackLease = untrackLease ?? (static _ => + { + }); + } + + public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILuaModuleLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(luaModule); + lease = null; + if (cancellationToken.IsCancellationRequested) + { + failure = CoreFailureFactory.Cancelled("Lua.RegisterModule"); + return false; + } + + if (!TryReserveModule(luaModule, out failure)) + { + return false; + } + + bool registered = false; + CheatEngineFailure moduleFailure = default; + if (!_dispatcher.TryInvoke( + () => + { + try + { + luaModule.Register(); + registered = true; + } + catch (Exception exception) + { + moduleFailure = CoreFailureFactory.FromException("Lua.RegisterModule", exception); + } + }, + out failure, + cancellationToken)) + { + ReleaseModule(luaModule); + return false; + } + + if (!registered) + { + ReleaseModule(luaModule); + failure = moduleFailure; + return false; + } + + LuaModuleLease created = new(luaModule, _epochProvider(), _dispatcher, _isContextCurrent, _untrackLease, + ReleaseModule); + try + { + _trackLease(created); + lease = created; + failure = default; + return true; + } + catch (Exception exception) + { + try + { + created.Dispose(); + } + catch + { + // The track failure remains the meaningful result; the module was still removed from this Client scope. + } + + failure = CoreFailureFactory.FromException("Lua.RegisterModule", exception); + return false; + } + } + + public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default) + { + if (TryRegisterModule(luaModule, out ILuaModuleLease? lease, out CheatEngineFailure failure, cancellationToken)) + { + return lease; + } + + failure.Throw(); + throw new InvalidOperationException("Unreachable failure flow."); + } + + public bool TryExecute(ILuaOperation operation, [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = CoreFailureFactory.Cancelled("Lua.Execute"); + return false; + } + + long epoch = _epochProvider(); + LuaOperationResult operationResult = default; + if (!_dispatcher.TryInvoke( + () => operationResult = ExecuteOperation(operation, epoch), + out failure, + cancellationToken)) + { + result = default; + return false; + } + + if (!operationResult.Succeeded) + { + result = default; + failure = IsDefined(operationResult.Failure) + ? operationResult.Failure + : new CheatEngineFailure( + CheatEngineFailureKind.Unknown, + "Lua.Execute", + "The typed Lua operation returned failure without an associated Cheat Engine failure."); + return false; + } + + result = operationResult.Result; + failure = default; + return true; + } + + public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) + { + if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + return ThrowFailure(failure); + } + + public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation + { + return TryExecute(operation, out result, out failure, cancellationToken); + } + + public TResult Execute(TOperation operation, CancellationToken cancellationToken = default) + where TOperation : ILuaOperation + { + return Execute(operation, cancellationToken); + } + + private static T ThrowFailure(CheatEngineFailure failure) + { + failure.Throw(); + throw new UnreachableException(); + } + + private LuaOperationResult ExecuteOperation(ILuaOperation operation, long epoch) + { + LuaOperationContext context = new(epoch, _isContextCurrent); + try + { + context.ThrowIfExpired(); + bool succeeded = operation.TryExecute(context, out TResult? result, out CheatEngineFailure failure); + return new LuaOperationResult(succeeded, result!, failure); + } + finally + { + context.Expire(); + } + } + + private static bool IsDefined(CheatEngineFailure failure) + { + return !string.IsNullOrWhiteSpace(failure.Operation) && !string.IsNullOrWhiteSpace(failure.Message); + } + + private bool TryReserveModule(ILuaModule module, out CheatEngineFailure failure) + { + lock (_registeredModulesLock) + { + if (_registeredModules.Add(module)) + { + failure = default; + return true; + } + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", + "This client activation already owns the supplied Lua module instance."); + return false; + } + + private void ReleaseModule(ILuaModule module) + { + lock (_registeredModulesLock) + { + _registeredModules.Remove(module); + } + } + + private static LuaClientInitialization CreateProductionInitialization(CoreLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(lifetime); + return new LuaClientInitialization( + () => lifetime.Epoch, + () => lifetime.IsActivationCurrent, + lease => lifetime.Track(lease), + lease => lifetime.Untrack(lease)); + } + + private readonly record struct LuaOperationResult( + bool Succeeded, + TResult Result, + CheatEngineFailure Failure); + + private readonly record struct LuaClientInitialization( + Func EpochProvider, + Func IsContextCurrent, + Action TrackLease, + Action UntrackLease); +} diff --git a/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs b/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs new file mode 100644 index 0000000..12d02f1 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaModuleLease.cs @@ -0,0 +1,70 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; + +namespace CheatEngine.Client.Core.Domains; + +/// Activation-owned release path for one explicitly registered application Lua module. +internal sealed class LuaModuleLease( + ILuaModule module, + long epoch, + ICheatEngineDispatcher dispatcher, + Func isActivationCurrent, + Action untrack, + Action releaseModule) : ILuaModuleLease +{ + private readonly ICheatEngineDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly object _disposeLock = new(); + + private readonly Func _isActivationCurrent = + isActivationCurrent ?? throw new ArgumentNullException(nameof(isActivationCurrent)); + + private readonly ILuaModule _module = module ?? throw new ArgumentNullException(nameof(module)); + + private readonly Action _releaseModule = + releaseModule ?? throw new ArgumentNullException(nameof(releaseModule)); + + private readonly Action _untrack = untrack ?? throw new ArgumentNullException(nameof(untrack)); + private int _released; + + public long Epoch + { + get; + } = epoch; + + public bool IsReleased => Volatile.Read(ref _released) != 0; + + public void Dispose() + { + lock (_disposeLock) + { + if (Volatile.Read(ref _released) != 0) + { + return; + } + + // A detached activation has no legal SDK dispatch path left. There is nothing more that this lease can + // safely do, so release managed ownership without attempting to call Cheat Engine. + if (!_isActivationCurrent()) + { + CompleteRelease(); + return; + } + + _dispatcher.Invoke(_module.Unregister); + + // Do not make any ownership transition until the application module confirmed unregistration by returning. + // The write occurs while holding the lock, so another disposer either observes the completed release or + // waits and retries after the original dispatch fault. + CompleteRelease(); + } + } + + private void CompleteRelease() + { + Volatile.Write(ref _released, 1); + _untrack(this); + _releaseModule(_module); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs b/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs new file mode 100644 index 0000000..16e0a12 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaOperationContext.cs @@ -0,0 +1,35 @@ +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains; + +/// Epoch-bound context that is invalidated immediately after a typed Lua operation returns. +internal sealed class LuaOperationContext(long epoch, Func isCurrent) : ILuaExecutionContext +{ + private readonly Func _isCurrent = isCurrent ?? throw new ArgumentNullException(nameof(isCurrent)); + private int _expired; + + public long Epoch + { + get; + } = epoch; + + public bool IsActive => Volatile.Read(ref _expired) == 0 && _isCurrent(); + + public void ThrowIfExpired() + { + if (IsActive) + { + return; + } + + throw new CheatEngineActivationExpiredException( + "Lua.OperationContext", + "The Lua operation context is no longer valid for the current Cheat Engine activation."); + } + + internal void Expire() + { + Volatile.Write(ref _expired, 1); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs b/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs new file mode 100644 index 0000000..52ae412 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/LuaRuntimeProbe.cs @@ -0,0 +1,31 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class LuaRuntimeProbe : IRuntimeProbe +{ + public double GetCheatEngineVersion() + { + return ClientLuaGlobals.GetCheatEngineVersion(); + } + + public int GetSystemArchitecture() + { + return ClientLuaGlobals.GetSystemArchitecture(); + } + + public int GetTargetAbi() + { + return ClientLuaGlobals.GetTargetAbi(); + } + + public long GetOpenedProcessId() + { + return ClientLuaGlobals.GetOpenedProcessId(); + } + + public bool TargetIs64Bit() + { + return ClientLuaGlobals.TargetIs64Bit(); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs new file mode 100644 index 0000000..fa2d872 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs @@ -0,0 +1,609 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class MemoryClient(ICheatEngineDispatcher dispatcher) : IMemoryClient +{ + private readonly ICheatEngineDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + T? captured = default; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryReadKnown(address, out captured, out hostFailure), + out failure, cancellationToken)) + { + value = default; + return false; + } + + if (!succeeded) + { + value = default; + failure = hostFailure is null + ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.ReadPrimitive", + $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") + : new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadPrimitive", hostFailure); + return false; + } + + value = captured!; + failure = default; + return true; + } + + public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) + { + if (TryReadPrimitive(address, out T? value, out CheatEngineFailure failure, cancellationToken)) + { + return value!; + } + + failure.Throw(); + return default!; + } + + public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + string? hostFailure = null; + bool handled = false; + bool succeeded = false; + if (!_dispatcher.TryInvoke( + () => succeeded = TryWriteKnown(address, value, out handled, out hostFailure), + out failure, cancellationToken)) + { + return false; + } + + if (!succeeded) + { + failure = !handled + ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.WritePrimitive", + $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") + : new CheatEngineFailure(CheatEngineFailureKind.MemoryWriteFailed, "Memory.WritePrimitive", + hostFailure ?? "Cheat Engine rejected the target-memory write."); + return false; + } + + failure = default; + return true; + } + + public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default) + { + if (!TryWritePrimitive(address, value, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + T? captured = default; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryReadCore(request, out captured, out hostFailure), + out failure, cancellationToken)) + { + value = default; + return false; + } + + if (!succeeded) + { + value = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.Read", + hostFailure ?? "Cheat Engine rejected the target-memory read."); + return false; + } + + value = captured!; + failure = default; + return true; + } + + public T Read(MemoryReadRequest request, CancellationToken cancellationToken = default) + { + if (TryRead(request, out T? value, out CheatEngineFailure failure, cancellationToken)) + { + return value; + } + + failure.Throw(); + return default!; + } + + public bool TryWrite(MemoryWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryWriteCore(request, out hostFailure), + out failure, cancellationToken)) + { + return false; + } + + if (!succeeded) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.MemoryWriteFailed, "Memory.Write", + hostFailure ?? "Cheat Engine rejected the target-memory write."); + return false; + } + + failure = default; + return true; + } + + public void Write(MemoryWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWrite(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.Length); + ImmutableArray captured = ImmutableArray.Empty; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + byte[] buffer = new byte[request.Length]; + succeeded = TargetMemory.TryReadBytes(request.Address, buffer, out MemoryAccessFailure sdkFailure); + if (succeeded) + { + captured = ImmutableArray.CreateRange(buffer); + } + else + { + hostFailure = sdkFailure.ToString(); + } + }, out failure, cancellationToken)) + { + bytes = []; + return false; + } + + bytes = captured; + return TryMapMemoryFailure(succeeded, false, "Memory.ReadBytes", hostFailure, out failure); + } + + public ImmutableArray ReadBytes(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default) + { + if (TryReadBytes(request, out ImmutableArray bytes, out CheatEngineFailure failure, cancellationToken)) + { + return bytes; + } + + failure.Throw(); + return []; + } + + public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (request.Bytes.IsDefaultOrEmpty) + { + throw new ArgumentException("At least one byte is required.", nameof(request)); + } + + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + succeeded = TargetMemory.TryWriteBytes(request.Address, request.Bytes.AsSpan(), + out MemoryAccessFailure sdkFailure); + if (!succeeded) + { + hostFailure = sdkFailure.ToString(); + } + }, out failure, cancellationToken)) + { + return false; + } + + return TryMapMemoryFailure(succeeded, true, "Memory.WriteBytes", hostFailure, out failure); + } + + public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWriteBytes(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] out string? value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumLength); + string? captured = null; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + succeeded = TargetMemory.TryReadString(request.Address, request.MaximumLength, + request.WideCharacter, out captured, out MemoryAccessFailure sdkFailure); + if (!succeeded) + { + hostFailure = sdkFailure.ToString(); + } + }, out failure, cancellationToken)) + { + value = null; + return false; + } + + value = captured; + return TryMapMemoryFailure(succeeded, false, "Memory.ReadString", hostFailure, out failure); + } + + public string ReadString(MemoryStringReadRequest request, CancellationToken cancellationToken = default) + { + if (TryReadString(request, out string? value, out CheatEngineFailure failure, cancellationToken)) + { + return value; + } + + failure.Throw(); + return string.Empty; + } + + public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request.Value); + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + succeeded = TargetMemory.TryWriteString(request.Address, request.Value.AsSpan(), + request.WideCharacter, out MemoryAccessFailure sdkFailure); + if (!succeeded) + { + hostFailure = sdkFailure.ToString(); + } + }, out failure, cancellationToken)) + { + return false; + } + + return TryMapMemoryFailure(succeeded, true, "Memory.WriteString", hostFailure, out failure); + } + + public void WriteString(MemoryStringWriteRequest request, CancellationToken cancellationToken = default) + { + if (!TryWriteString(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TryResolvePointerChain(PointerChainRequest request, out Address address, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + if (request.Offsets.IsDefaultOrEmpty) + { + throw new ArgumentException("A pointer chain requires at least one offset.", nameof(request)); + } + + if (request.Offsets.Length > 64) + { + throw new ArgumentOutOfRangeException(nameof(request), "A pointer chain is limited to 64 hops."); + } + + Address captured = request.BaseAddress; + string? hostFailure = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + Address current = request.BaseAddress; + foreach (long offset in request.Offsets) + { + if (!TargetMemory.TryReadPointer(current, out Address pointer, out MemoryAccessFailure sdkFailure)) + { + hostFailure = sdkFailure.ToString(); + return; + } + + current = pointer + offset; + } + + captured = current; + succeeded = true; + }, out failure, cancellationToken)) + { + address = default; + return false; + } + + address = captured; + return TryMapMemoryFailure(succeeded, false, "Memory.ResolvePointerChain", hostFailure, out failure); + } + + public Address ResolvePointerChain(PointerChainRequest request, CancellationToken cancellationToken = default) + { + if (TryResolvePointerChain(request, out Address address, out CheatEngineFailure failure, cancellationToken)) + { + return address; + } + + failure.Throw(); + return default; + } + + private static bool TryReadCore(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, + out string? failure) + { + failure = null; + TargetMemoryCodecContext context = TargetMemoryCodecContext.Create(); + if (request.Codec.TryRead(context, request.Address, out value)) + { + return true; + } + + failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory read."; + return false; + } + + private static bool TryWriteCore(MemoryWriteRequest request, out string? failure) + { + failure = null; + TargetMemoryCodecContext context = TargetMemoryCodecContext.Create(); + T value = request.Value; + if (request.Codec.TryWrite(context, request.Address, in value)) + { + return true; + } + + failure = context.Failure ?? $"The codec for '{typeof(T).Name}' rejected the target-memory write."; + return false; + } + + private static bool TryReadKnown(Address address, [MaybeNullWhen(false)] out T value, out string? failure) + { + object? boxed = null; + bool succeeded; + if (typeof(T) == typeof(byte)) + { + succeeded = Read(TargetMemory.TryReadUInt8, address, out boxed, out failure); + } + else if (typeof(T) == typeof(sbyte)) + { + succeeded = Read(TargetMemory.TryReadInt8, address, out boxed, out failure); + } + else if (typeof(T) == typeof(ushort)) + { + succeeded = Read(TargetMemory.TryReadUInt16, address, out boxed, out failure); + } + else if (typeof(T) == typeof(short)) + { + succeeded = Read(TargetMemory.TryReadInt16, address, out boxed, out failure); + } + else if (typeof(T) == typeof(uint)) + { + succeeded = Read(TargetMemory.TryReadUInt32, address, out boxed, out failure); + } + else if (typeof(T) == typeof(int)) + { + succeeded = Read(TargetMemory.TryReadInt32, address, out boxed, out failure); + } + else if (typeof(T) == typeof(ulong)) + { + succeeded = Read(TargetMemory.TryReadUInt64, address, out boxed, out failure); + } + else if (typeof(T) == typeof(long)) + { + succeeded = Read(TargetMemory.TryReadInt64, address, out boxed, out failure); + } + else if (typeof(T) == typeof(float)) + { + succeeded = Read(TargetMemory.TryReadSingle, address, out boxed, out failure); + } + else if (typeof(T) == typeof(double)) + { + succeeded = Read(TargetMemory.TryReadDouble, address, out boxed, out failure); + } + else if (typeof(T) == typeof(Address)) + { + succeeded = Read
(TargetMemory.TryReadPointer, address, out boxed, out failure); + } + else + { + value = default; + failure = null; + return false; + } + + if (succeeded && boxed is T typed) + { + value = typed; + return true; + } + + value = default; + return false; + } + + private static bool TryWriteKnown(Address address, T value, out bool handled, out string? failure) + { + handled = true; + if (typeof(T) == typeof(byte)) + { + return Write(TargetMemory.TryWriteUInt8, address, (byte) (object) value!, out failure); + } + + if (typeof(T) == typeof(sbyte)) + { + return Write(TargetMemory.TryWriteInt8, address, (sbyte) (object) value!, out failure); + } + + if (typeof(T) == typeof(ushort)) + { + return Write(TargetMemory.TryWriteUInt16, address, (ushort) (object) value!, out failure); + } + + if (typeof(T) == typeof(short)) + { + return Write(TargetMemory.TryWriteInt16, address, (short) (object) value!, out failure); + } + + if (typeof(T) == typeof(uint)) + { + return Write(TargetMemory.TryWriteUInt32, address, (uint) (object) value!, out failure); + } + + if (typeof(T) == typeof(int)) + { + return Write(TargetMemory.TryWriteInt32, address, (int) (object) value!, out failure); + } + + if (typeof(T) == typeof(ulong)) + { + return Write(TargetMemory.TryWriteUInt64, address, (ulong) (object) value!, out failure); + } + + if (typeof(T) == typeof(long)) + { + return Write(TargetMemory.TryWriteInt64, address, (long) (object) value!, out failure); + } + + if (typeof(T) == typeof(float)) + { + return Write(TargetMemory.TryWriteSingle, address, (float) (object) value!, out failure); + } + + if (typeof(T) == typeof(double)) + { + return Write(TargetMemory.TryWriteDouble, address, (double) (object) value!, out failure); + } + + if (typeof(T) == typeof(Address)) + { + return Write(TargetMemory.TryWritePointer, address, (Address) (object) value!, out failure); + } + + handled = false; + failure = null; + return false; + } + + private static bool Read(Reader reader, Address address, out object? value, out string? failure) + { + if (reader(address, out T result, out MemoryAccessFailure sdkFailure)) + { + value = result; + failure = null; + return true; + } + + value = null; + failure = sdkFailure.ToString(); + return false; + } + + private static bool Write(Writer writer, Address address, T value, out string? failure) + { + if (writer(address, value, out MemoryAccessFailure sdkFailure)) + { + failure = null; + return true; + } + + failure = sdkFailure.ToString(); + return false; + } + + private static bool TryMapMemoryFailure(bool succeeded, bool isWrite, string operation, string? hostFailure, + out CheatEngineFailure failure) + { + if (succeeded) + { + failure = default; + return true; + } + + failure = new CheatEngineFailure( + isWrite ? CheatEngineFailureKind.MemoryWriteFailed : CheatEngineFailureKind.MemoryReadFailed, + operation, + hostFailure ?? "Cheat Engine rejected the target-memory operation."); + return false; + } + + private delegate bool Reader(Address address, out T value, out MemoryAccessFailure failure); + + private delegate bool Writer(Address address, T value, out MemoryAccessFailure failure); + + private sealed class TargetMemoryCodecContext : IMemoryReadContext, IMemoryWriteContext + { + private int _pointerSize; + + internal string? Failure + { + get; + private set; + } + + public int PointerSize + { + get + { + if (_pointerSize != 0) + { + return _pointerSize; + } + + int pointerSize = ClientLuaGlobals.TargetIs64Bit() ? sizeof(ulong) : sizeof(uint); + _pointerSize = pointerSize; + return pointerSize; + } + } + + public bool TryReadBytes(Address address, Span destination) + { + if (TargetMemory.TryReadBytes(address, destination, out MemoryAccessFailure sdkFailure)) + { + Failure = null; + return true; + } + + Failure = sdkFailure.ToString(); + return false; + } + + public bool TryWriteBytes(Address address, ReadOnlySpan source) + { + if (TargetMemory.TryWriteBytes(address, source, out MemoryAccessFailure sdkFailure)) + { + Failure = null; + return true; + } + + Failure = sdkFailure.ToString(); + return false; + } + + internal static TargetMemoryCodecContext Create() + { + return new TargetMemoryCodecContext(); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs b/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs new file mode 100644 index 0000000..39afede --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ParentChainStep.cs @@ -0,0 +1,16 @@ +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// One inspected link while validating that a record can be reparented without forming a cycle. +internal readonly record struct ParentChainStep(ParentChainStepKind Kind, MemoryRecordId ParentId) +{ + internal static ParentChainStep Root => new(ParentChainStepKind.Root, default); + + internal static ParentChainStep HostRejected => new(ParentChainStepKind.HostRejected, default); + + internal static ParentChainStep Parent(MemoryRecordId parentId) + { + return new ParentChainStep(ParentChainStepKind.Parent, parentId); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs b/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs new file mode 100644 index 0000000..ff53736 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ParentChainStepKind.cs @@ -0,0 +1,9 @@ +namespace CheatEngine.Client.Core.Domains; + +/// Represents the outcome of reading one candidate-parent link. +internal enum ParentChainStepKind +{ + Root, + Parent, + HostRejected +} diff --git a/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs b/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs new file mode 100644 index 0000000..4649dae --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/PatternScanner.cs @@ -0,0 +1,282 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class PatternScanner(SdkMainThreadDispatcher dispatcher, IAobScanPort? scanPort = null) + : IPatternScanner +{ + private const int _maximumModuleSnapshot = 4096; + private const string _inModuleOperation = "Patterns.InModule"; + private const string _scanOperation = "Patterns.Scan"; + + private readonly SdkMainThreadDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly IAobScanPort _scanPort = scanPort ?? new SdkAobScanPort(); + + public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!TryValidateRequest(request, out failure)) + { + result = default; + return false; + } + + AobScanResult captured = default; + CheatEngineFailure hostFailure = default; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryScanCore(request, out captured, out hostFailure), + out failure, cancellationToken)) + { + result = default; + return false; + } + + if (!succeeded) + { + result = default; + failure = hostFailure; + return false; + } + + result = captured; + failure = default; + return true; + } + + public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default) + { + if (TryScan(request, out AobScanResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + private bool TryScanCore(AobScanRequest request, out AobScanResult result, + out CheatEngineFailure failure) + { + AobScanHostStatus status = + _scanPort.TryScan(request.Pattern.Value, request.Options, out IAobMatchList? matchList); + if (status == AobScanHostStatus.Rejected) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, + "Cheat Engine did not return an AOB result list."); + return false; + } + + if (status != AobScanHostStatus.Success || matchList is null) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, + "Cheat Engine returned an invalid AOB result list."); + return false; + } + + using (matchList) + { + if (!matchList.TryGetCount(out int count) || count < 0) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, + "Cheat Engine returned an invalid AOB result count."); + return false; + } + + bool hasModuleRange = false; + ModuleRange moduleRange = default; + if (request.Module.HasValue && + !TryGetModuleRange(request.Module.Value, out hasModuleRange, out moduleRange, + out failure)) + { + result = default; + return false; + } + + return TryMaterializeMatches(matchList, count, request, hasModuleRange, moduleRange, out result, + out failure); + } + } + + private static bool TryMaterializeMatches( + IAobMatchList matches, + int count, + AobScanRequest request, + bool hasModuleRange, + ModuleRange moduleRange, + out AobScanResult result, + out CheatEngineFailure failure) + { + // Do not preallocate to a caller-controlled materialization limit. The limit remains strict below, while + // storage grows only for addresses that survived every managed filter. + ImmutableArray
.Builder materialized = ImmutableArray.CreateBuilder
(); + for (int index = 0; index < count; index++) + { + if (!TryGetMatchAddress(matches, index, out Address address, out failure)) + { + result = default; + return false; + } + + if (!IsIncluded(address, request, hasModuleRange, moduleRange)) + { + continue; + } + + if (materialized.Count == request.MaximumResults) + { + result = new AobScanResult(materialized.ToImmutable(), true); + failure = default; + return true; + } + + materialized.Add(address); + } + + result = new AobScanResult(materialized.ToImmutable(), false); + failure = default; + return true; + } + + private static bool TryGetMatchAddress(IAobMatchList matches, int index, out Address address, + out CheatEngineFailure failure) + { + if (matches.TryGetItem(index, out string? text) && Address.TryParse(text, out address)) + { + failure = default; + return true; + } + + address = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _scanOperation, + $"AOB result {index} was not a hexadecimal address."); + return false; + } + + private static bool IsIncluded(Address address, AobScanRequest request, bool hasModuleRange, + ModuleRange moduleRange) + { + return (!hasModuleRange || moduleRange.Contains(address)) && + (!request.Range.HasValue || request.Range.Value.Contains(address)); + } + + internal static bool TryValidateRequest(AobScanRequest request, out CheatEngineFailure failure) + { + if (string.IsNullOrWhiteSpace(request.Pattern.Value)) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, + "An AOB scan requires a normalized, non-empty pattern."); + return false; + } + + if (request.MaximumResults <= 0) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, + "An AOB scan requires a positive materialization limit."); + return false; + } + + if (request.Module.HasValue && string.IsNullOrWhiteSpace(request.Module.Value.Value)) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, + "An AOB module filter must be non-empty."); + return false; + } + + if (request.Range.HasValue && request.Range.Value.End < request.Range.Value.Start) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, _scanOperation, + "An AOB range end address must not precede its start address."); + return false; + } + + failure = default; + return true; + } + + private bool TryGetModuleRange(ModuleName requested, out bool hasRange, out ModuleRange range, + out CheatEngineFailure failure) + { + ModuleInfo[] modules = new ModuleInfo[_maximumModuleSnapshot]; + InspectionStatus status = _scanPort.EnumerateModules(modules, out int written); + if (status != InspectionStatus.Success) + { + hasRange = false; + range = default; + failure = new CheatEngineFailure( + status == InspectionStatus.DestinationTooSmall + ? CheatEngineFailureKind.ResultLimitExceeded + : CheatEngineFailureKind.CapabilityUnavailable, + _inModuleOperation, $"Module inspection returned '{status}'."); + return false; + } + + bool found = false; + range = default; + for (int index = 0; index < written; index++) + { + ModuleInfo module = modules[index]; + if (!string.Equals(module.Name, requested.Value, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (found) + { + hasRange = false; + failure = new CheatEngineFailure(CheatEngineFailureKind.AmbiguousMatch, _inModuleOperation, + $"More than one module named '{requested.Value}' was present in the selected target."); + return false; + } + + if (!module.ImageSize.HasValue) + { + hasRange = false; + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, + _inModuleOperation, "Cheat Engine did not report the requested module's image size."); + return false; + } + + if (module.ImageSize.Value.Value == 0) + { + hasRange = false; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, _inModuleOperation, + "Cheat Engine reported a zero-length requested module."); + return false; + } + + found = true; + range = new ModuleRange(module.BaseAddress.Value, module.ImageSize.Value.Value); + } + + if (found) + { + hasRange = true; + failure = default; + return true; + } + + hasRange = false; + failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, _inModuleOperation, + $"Module '{requested.Value}' was not present in the selected target."); + return false; + } + + private readonly record struct ModuleRange(ulong Start, ulong Size) + { + internal bool Contains(Address address) + { + return address.Value >= Start && address.Value - Start < Size; + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs b/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs new file mode 100644 index 0000000..c076e2a --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ProbeResult.cs @@ -0,0 +1,29 @@ +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +internal readonly record struct ProbeResult( + RuntimeCapabilityId Capability, + RuntimeCapabilityAvailabilityState State, + T? Value) +{ + internal static ProbeResult Available(RuntimeCapabilityId capability, T value) + { + return new ProbeResult(capability, RuntimeCapabilityAvailabilityState.Available, value); + } + + internal static ProbeResult Unavailable(RuntimeCapabilityId capability) + { + return new ProbeResult(capability, RuntimeCapabilityAvailabilityState.Unavailable, default); + } + + internal static ProbeResult Unknown(RuntimeCapabilityId capability) + { + return new ProbeResult(capability, RuntimeCapabilityAvailabilityState.Unknown, default); + } + + internal RuntimeCapabilityAvailability ToAvailability() + { + return new RuntimeCapabilityAvailability(Capability, State, RuntimeCapabilityContract.Unknown); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs new file mode 100644 index 0000000..a51718d --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs @@ -0,0 +1,292 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// Owns deterministic target selection without taking ownership of Cheat Engine's global process state. +internal sealed class ProcessClient : IProcessClient +{ + private readonly ICheatEngineDispatcher _dispatcher; + private readonly IProcessHost _host; + private readonly Lock _selectionGate = new(); + private readonly TargetSelectionLifetime _selectionLifetime; + private ProcessSelection? _lastSelection; + + internal ProcessClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime) + : this(dispatcher, new LocalProcessHost(), + lifetime?.TargetSelection ?? throw new ArgumentNullException(nameof(lifetime))) + { + } + + internal ProcessClient( + ICheatEngineDispatcher dispatcher, + IProcessHost host, + TargetSelectionLifetime selectionLifetime) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _host = host ?? throw new ArgumentNullException(nameof(host)); + _selectionLifetime = selectionLifetime ?? throw new ArgumentNullException(nameof(selectionLifetime)); + } + + public bool TryGetCurrent( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryReadCurrent("Processes.GetCurrent", out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot GetCurrent(CancellationToken cancellationToken = default) + { + if (TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryRefresh( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryReadCurrent("Processes.Refresh", out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot Refresh(CancellationToken cancellationToken = default) + { + if (TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryAttach( + TargetProcessId processId, + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (processId.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(processId)); + } + + ProcessSnapshot captured = default; + if (!_dispatcher.TryInvoke( + () => + { + _host.OpenProcess(processId.Value); + captured = CaptureCurrent("Processes.Attach"); + if (captured.Id != processId) + { + throw new InvalidOperationException("Cheat Engine did not select the requested process."); + } + }, + out failure, + cancellationToken)) + { + snapshot = default; + return false; + } + + snapshot = captured; + return true; + } + + public ProcessSnapshot Attach(TargetProcessId processId, CancellationToken cancellationToken = default) + { + if (TryAttach(processId, out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryAttachExactName( + string processName, + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + string expectedName = NormalizeExactProcessName(processName); + IReadOnlyList matches = _host.FindProcessesByExactName(expectedName); + if (matches.Count == 0) + { + snapshot = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.NotFound, + "Processes.AttachExactName", + $"No local process named '{processName}' was found."); + return false; + } + + if (matches.Count != 1) + { + snapshot = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.AmbiguousMatch, + "Processes.AttachExactName", + $"Several local processes named '{processName}' were found."); + return false; + } + + return TryAttach(new TargetProcessId(matches[0].Id), out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot AttachExactName(string processName, CancellationToken cancellationToken = default) + { + if (TryAttachExactName(processName, out ProcessSnapshot snapshot, out CheatEngineFailure failure, + cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + private bool TryReadCurrent( + string operation, + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken) + { + ProcessSnapshot captured = default; + if (!_dispatcher.TryInvoke(() => captured = CaptureCurrent(operation), out failure, cancellationToken)) + { + snapshot = default; + if (failure.Kind == CheatEngineFailureKind.OperationRejected) + { + failure = new CheatEngineFailure( + CheatEngineFailureKind.TargetNotAttached, + operation, + failure.Message, + failure.Exception); + } + + return false; + } + + snapshot = captured; + return true; + } + + private ProcessSnapshot CaptureCurrent(string operation) + { + long processId = _host.GetOpenedProcessId(); + if (processId is <= 0 or > int.MaxValue) + { + ClearObservedSelection(operation); + throw new InvalidOperationException("Cheat Engine has no selected local target process."); + } + + TargetProcessId id = new(checked((int) processId)); + if (!_host.TryGetLocalProcess(id.Value, out LocalProcessInfo process)) + { + ClearObservedSelection(operation); + throw new InvalidOperationException("The selected process no longer exists locally."); + } + + if (process.Id != id.Value) + { + throw new EngineMarshallingException( + "Processes.GetCurrent", + EngineMarshallingDirection.Result, + "metadata for the selected process identifier", + $"metadata for process {process.Id}"); + } + + CheatEngineArchitecture architecture = TryGetTargetArchitecture(); + return ObserveSelection(id, process, architecture, operation); + } + + private CheatEngineArchitecture TryGetTargetArchitecture() + { + try + { + return _host.GetTargetArchitecture(); + } + catch (EngineGlobalUnavailableException) + { + return CheatEngineArchitecture.Unknown; + } + catch (EngineCapabilityUnavailableException) + { + return CheatEngineArchitecture.Unknown; + } + } + + private ProcessSnapshot ObserveSelection( + TargetProcessId id, + LocalProcessInfo process, + CheatEngineArchitecture architecture, + string operation) + { + lock (_selectionGate) + { + ProcessSelection selection = new(id, architecture); + if (_lastSelection is { } previous && previous != selection) + { + _selectionLifetime.Advance(operation); + } + + _lastSelection = selection; + return new ProcessSnapshot( + id, + process.Name, + process.ExecutablePath, + architecture, + _selectionLifetime.Epoch); + } + } + + private void ClearObservedSelection(string operation) + { + lock (_selectionGate) + { + if (_lastSelection is null) + { + return; + } + + _lastSelection = null; + _selectionLifetime.Advance(operation); + } + } + + private static string NormalizeExactProcessName(string processName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(processName); + if (processName.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0) + { + throw new ArgumentException("An exact process name must not include a directory path.", + nameof(processName)); + } + + string normalized = processName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? processName[..^4] + : processName; + if (normalized.Length == 0) + { + throw new ArgumentException("An exact process name must include a file name before '.exe'.", + nameof(processName)); + } + + return normalized; + } + + private readonly record struct ProcessSelection(TargetProcessId Id, CheatEngineArchitecture Architecture); +} diff --git a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs new file mode 100644 index 0000000..ab730cc --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs @@ -0,0 +1,319 @@ +using System.Globalization; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Domains; + +/// Captures only independently observed, synchronous runtime facts from the active Cheat Engine host. +internal sealed class RuntimeClient : ICheatEngineRuntime +{ + private readonly Version _clientAssemblyVersion; + private readonly ICheatEngineDispatcher _dispatcher; + private readonly Func _getEpoch; + private readonly CoreClientPolicy _policy; + private readonly IRuntimeProbe _probe; + private readonly Version _sdkAssemblyVersion; + + internal RuntimeClient(ICheatEngineDispatcher dispatcher, CoreLifetime lifetime, CoreClientPolicy policy) + : this( + dispatcher, + new LuaRuntimeProbe(), + () => lifetime.Epoch, + typeof(ICheatEngineRuntime).Assembly.GetName().Version, + typeof(RuntimeInfo).Assembly.GetName().Version, + policy) + { + ArgumentNullException.ThrowIfNull(lifetime); + } + + internal RuntimeClient( + ICheatEngineDispatcher dispatcher, + IRuntimeProbe probe, + Func getEpoch, + Version? clientAssemblyVersion = null, + Version? sdkAssemblyVersion = null, + CoreClientPolicy? policy = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _probe = probe ?? throw new ArgumentNullException(nameof(probe)); + _getEpoch = getEpoch ?? throw new ArgumentNullException(nameof(getEpoch)); + _policy = policy ?? CoreClientPolicy.SafeDefaults; + _clientAssemblyVersion = clientAssemblyVersion ?? typeof(ICheatEngineRuntime).Assembly.GetName().Version ?? + throw new InvalidOperationException("The Client assembly does not declare an assembly version."); + _sdkAssemblyVersion = sdkAssemblyVersion ?? typeof(RuntimeInfo).Assembly.GetName().Version ?? + throw new InvalidOperationException("The SDK runtime assembly does not declare an assembly version."); + } + + public long Epoch => _getEpoch(); + + public bool TryGetSnapshot( + out CheatEngineRuntimeSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + CheatEngineRuntimeSnapshot captured = default; + if (!_dispatcher.TryInvoke(() => captured = Capture(), out failure, cancellationToken)) + { + snapshot = default; + return false; + } + + snapshot = captured; + return true; + } + + public CheatEngineRuntimeSnapshot GetSnapshot(CancellationToken cancellationToken = default) + { + if (TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryGetSdkCapability( + RuntimeCapabilityId capability, + out RuntimeCapabilityAvailability availability, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (capability.IsEmpty) + { + throw new ArgumentException("A runtime capability identifier is required.", nameof(capability)); + } + + if (!TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out failure, cancellationToken)) + { + availability = default; + return false; + } + + if (snapshot.SdkCapabilities.TryGet(capability, out availability)) + { + return true; + } + + availability = new RuntimeCapabilityAvailability( + capability, + RuntimeCapabilityAvailabilityState.Unknown, + RuntimeCapabilityContract.Unknown); + failure = default; + return true; + } + + public RuntimeCapabilityAvailability GetSdkCapability( + RuntimeCapabilityId capability, + CancellationToken cancellationToken = default) + { + if (TryGetSdkCapability(capability, out RuntimeCapabilityAvailability availability, + out CheatEngineFailure failure, cancellationToken)) + { + return availability; + } + + failure.Throw(); + return default; + } + + public bool TryGetClientCapability( + ClientCapabilityId capability, + out ClientCapabilityAvailability availability, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (capability.IsEmpty) + { + throw new ArgumentException("A Client capability identifier is required.", nameof(capability)); + } + + if (!TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out failure, cancellationToken)) + { + availability = default; + return false; + } + + if (snapshot.ClientCapabilities.TryGet(capability, out availability)) + { + return true; + } + + availability = new ClientCapabilityAvailability( + capability, + ClientCapabilityAvailabilityState.Unknown, + "This Client release does not define a probe or policy gate for the requested capability."); + failure = default; + return true; + } + + public ClientCapabilityAvailability GetClientCapability( + ClientCapabilityId capability, + CancellationToken cancellationToken = default) + { + if (TryGetClientCapability(capability, out ClientCapabilityAvailability availability, + out CheatEngineFailure failure, cancellationToken)) + { + return availability; + } + + failure.Throw(); + return default; + } + + private CheatEngineRuntimeSnapshot Capture() + { + ProbeResult version = Probe(RuntimeCapabilityId.CheatEngineVersion, _probe.GetCheatEngineVersion); + ProbeResult systemArchitecture = + Probe(RuntimeCapabilityId.SystemArchitecture, _probe.GetSystemArchitecture); + ProbeResult targetAbi = Probe(RuntimeCapabilityId.TargetAbi, _probe.GetTargetAbi); + + bool hasTarget = TryReadOpenedProcess(out long openedProcessId) && openedProcessId > 0; + ProbeResult targetArchitecture = hasTarget + ? Probe(RuntimeCapabilityId.TargetArchitecture, _probe.TargetIs64Bit) + : ProbeResult.Unknown(RuntimeCapabilityId.TargetArchitecture); + + double? observedVersion = version.State == RuntimeCapabilityAvailabilityState.Available + ? version.Value + : null; + if (observedVersion is { } reported && (!double.IsFinite(reported) || reported < 0)) + { + throw new EngineMarshallingException( + "Runtime.GetCheatEngineVersion", + EngineMarshallingDirection.Result, + "a finite non-negative number", + reported.ToString(CultureInfo.InvariantCulture)); + } + + CheatEngineArchitecture decodedSystemArchitecture = DecodeSystemArchitecture(systemArchitecture); + TargetAbi decodedTargetAbi = DecodeTargetAbi(targetAbi); + CheatEngineArchitecture decodedTargetArchitecture = + DecodeTargetArchitecture(targetArchitecture, decodedTargetAbi); + + RuntimeCapabilityAvailability[] capabilities = + [ + version.ToAvailability(), + systemArchitecture.ToAvailability(), + targetArchitecture.ToAvailability(), + targetAbi.ToAvailability() + ]; + + return new CheatEngineRuntimeSnapshot( + Epoch, + new CheatEngineRuntimeVersionInfo(observedVersion, CheatEngineVersion.Ce77010621, _clientAssemblyVersion, + _sdkAssemblyVersion), + new CheatEngineRuntimePlatformInfo( + decodedSystemArchitecture, + decodedTargetArchitecture, + PointerSize.FromArchitecture(decodedTargetArchitecture), + decodedTargetAbi), + RuntimeCapabilities.Create(capabilities), + CreateClientCapabilities()); + } + + private ClientCapabilities CreateClientCapabilities() + { + ClientCapabilityAvailability[] capabilities = + [ + Unknown(ClientCapabilityId.ProcessSelection, + "The runtime snapshot does not probe every backing process-selection primitive, including openProcess."), + Unknown(ClientCapabilityId.TypedMemory, + "The runtime snapshot does not probe every backing typed-memory primitive."), + Unknown(ClientCapabilityId.PatternScanning, + "The runtime snapshot does not probe every backing AOB scan primitive."), + new( + ClientCapabilityId.ValueScanning, + ClientCapabilityAvailabilityState.Unavailable, + "Value scan sessions remain unavailable until the CE 7.7 live ownership and cleanup gate passes."), + Unknown(ClientCapabilityId.Inspection, + "The runtime snapshot does not probe every backing inspection primitive."), + Unknown(ClientCapabilityId.Tables, + "The runtime snapshot does not probe every backing Address List and table primitive."), + Unknown(ClientCapabilityId.ProtectedLua, + "The runtime snapshot does not probe every backing protected Lua primitive."), + _policy.EnableUnsafeLuaExecution + ? new ClientCapabilityAvailability( + ClientCapabilityId.UnsafeLuaExecution, + ClientCapabilityAvailabilityState.Available, + "Unsafe Lua execution was explicitly enabled for this activation.") + : new ClientCapabilityAvailability( + ClientCapabilityId.UnsafeLuaExecution, + ClientCapabilityAvailabilityState.Unavailable, + "Unsafe Lua execution requires explicit EnableUnsafeLuaExecution opt-in for this activation.") + ]; + + return ClientCapabilities.Create(capabilities); + } + + private static ClientCapabilityAvailability Unknown(ClientCapabilityId capability, string reason) + { + return new ClientCapabilityAvailability(capability, ClientCapabilityAvailabilityState.Unknown, reason); + } + + private static CheatEngineArchitecture DecodeSystemArchitecture(ProbeResult probe) + { + return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int architecture } && + RuntimeInfo.TryDecodeSystemArchitecture(architecture, out CheatEngineArchitecture decoded) + ? decoded + : CheatEngineArchitecture.Unknown; + } + + private static TargetAbi DecodeTargetAbi(ProbeResult probe) + { + return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int abi } && + RuntimeInfo.TryDecodeTargetAbi(abi, out TargetAbi decoded) + ? decoded + : TargetAbi.Unknown; + } + + private static CheatEngineArchitecture DecodeTargetArchitecture(ProbeResult probe, TargetAbi targetAbi) + { + if (targetAbi != TargetAbi.Windows || + probe is not { State: RuntimeCapabilityAvailabilityState.Available, Value: bool is64Bit }) + { + return CheatEngineArchitecture.Unknown; + } + + return is64Bit ? CheatEngineArchitecture.X64 : CheatEngineArchitecture.X86; + } + + private bool TryReadOpenedProcess(out long processId) + { + try + { + processId = _probe.GetOpenedProcessId(); + return true; + } + catch (EngineGlobalUnavailableException) + { + processId = 0; + return false; + } + catch (EngineCapabilityUnavailableException) + { + processId = 0; + return false; + } + } + + private static ProbeResult Probe(RuntimeCapabilityId capability, Func probe) + { + try + { + return ProbeResult.Available(capability, probe()); + } + catch (EngineGlobalUnavailableException) + { + return ProbeResult.Unavailable(capability); + } + catch (EngineCapabilityUnavailableException) + { + return ProbeResult.Unavailable(capability); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs new file mode 100644 index 0000000..46e0110 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Core.Domains; + +/// Production adapter that copies and releases the SDK-owned AOB list on the CE dispatch thread. +internal sealed class SdkAobScanPort : IAobScanPort +{ + public AobScanHostStatus TryScan(string pattern, AobScanOptions options, + [NotNullWhen(true)] out IAobMatchList? matches) + { + matches = null; + if (!AobScanner.TryScan(pattern, options, out Owned? owner) || owner is null) + { + return AobScanHostStatus.Rejected; + } + + matches = new SdkAobMatchList(owner); + return AobScanHostStatus.Success; + } + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + return EngineInspection.EnumerateModules(destination, out written); + } + + private sealed class SdkAobMatchList(Owned owner) : IAobMatchList + { + private readonly Owned _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + + public bool TryGetCount(out int count) + { + return _owner.Value.TryGetCount(out count); + } + + public bool TryGetItem(int index, [NotNullWhen(true)] out string? value) + { + return _owner.Value.TryGetItem(index, out value); + } + + public void Dispose() + { + _owner.Dispose(); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs new file mode 100644 index 0000000..37d5552 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs @@ -0,0 +1,60 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// Production adapter that delegates inspection and symbol operations to CheatEngine.SDK. +internal sealed class SdkInspectionPort : IInspectionPort +{ + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + return EngineInspection.EnumerateModules(destination, out written); + } + + public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] destination, out int written) + { + return EngineInspection.EnumerateModules(processId, destination, out written); + } + + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, out int written) + { + return EngineInspection.EnumerateSections(moduleName, destination, out written); + } + + public InspectionStatus EnumerateMemoryRegions(MemoryRegionInfo[] destination, out int written) + { + return EngineInspection.EnumerateMemoryRegions(destination, out written); + } + + public InspectionStatus GetMemoryRegion(Address address, out MemoryRegionInfo region) + { + return EngineInspection.GetMemoryRegionInfo(address, out region); + } + + public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol) + { + return EngineInspection.GetSymbolInfo(expression, out symbol); + } + + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + out Address address) + { + return EngineInspection.ResolveAddress(expression, options, out address); + } + + public bool TryResolveName(nuint address, out string? name) + { + return ClientLuaGlobals.TryGetNameFromAddress(address, out name); + } + + public void RegisterSymbol(string name, nuint address, bool doNotSave) + { + ClientLuaGlobals.RegisterSymbol(name, address, doNotSave); + } + + public void UnregisterSymbol(string name) + { + ClientLuaGlobals.UnregisterSymbol(name); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs new file mode 100644 index 0000000..435b188 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs @@ -0,0 +1,164 @@ +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.Client.Core.Domains; + +/// Protected SDK implementation of record destruction and parent reassignment. +internal sealed class SdkTableRecordMutationPort : ITableRecordMutationPort +{ + public TableRecordMutationStatus TryDelete(MemoryRecordId id) + { + if (!AddressListAccess.TryGetCurrent(out AddressList list)) + { + return TableRecordMutationStatus.HostRejected; + } + + if (!list.TryGetMemoryRecordById(id, out MemoryRecord record)) + { + return TableRecordMutationStatus.RecordNotFound; + } + + return record.Handle.TryCallMethod("destroy"u8) + ? TableRecordMutationStatus.Success + : TableRecordMutationStatus.HostRejected; + } + + public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + record = default; + if (parentId is { } requestedParentId && requestedParentId == childId) + { + return TableRecordMutationStatus.InvalidRelationship; + } + + TableRecordMutationStatus status = TryResolveMutationContext(childId, parentId, out AddressList list, + out MemoryRecord child, out MemoryRecord parent); + if (status != TableRecordMutationStatus.Success) + { + return status; + } + + if (parentId is { } candidateParentId) + { + status = ValidateParentRelationship(list, childId, candidateParentId, parent); + if (status != TableRecordMutationStatus.Success) + { + return status; + } + } + + return TryAssignParent(child, parent, out record); + } + + private static TableRecordMutationStatus TryResolveMutationContext(MemoryRecordId childId, MemoryRecordId? parentId, + out AddressList list, out MemoryRecord child, out MemoryRecord parent) + { + list = default; + child = default; + parent = MemoryRecord.Null; + if (!AddressListAccess.TryGetCurrent(out list)) + { + return TableRecordMutationStatus.HostRejected; + } + + if (!list.TryGetMemoryRecordById(childId, out child)) + { + return TableRecordMutationStatus.RecordNotFound; + } + + if (parentId is { } parentIdValue && !list.TryGetMemoryRecordById(parentIdValue, out parent)) + { + return TableRecordMutationStatus.ParentNotFound; + } + + return TableRecordMutationStatus.Success; + } + + private static TableRecordMutationStatus ValidateParentRelationship(AddressList list, MemoryRecordId childId, + MemoryRecordId candidateParentId, MemoryRecord parent) + { + if (!list.TryGetCount(out int topLevelCount)) + { + return TableRecordMutationStatus.HostRejected; + } + + MemoryRecord current = parent; + return TableParentRelationshipGuard.Validate(childId, candidateParentId, GetMaximumParentHops(topLevelCount), + _ => GetNextParentChainStep(ref current)); + } + + private static ParentChainStep GetNextParentChainStep(ref MemoryRecord current) + { + ParentReadStatus parentReadStatus = TryReadParent(current, out MemoryRecord next); + if (parentReadStatus == ParentReadStatus.Root) + { + return ParentChainStep.Root; + } + + if (parentReadStatus != ParentReadStatus.Parent) + { + return ParentChainStep.HostRejected; + } + + current = next; + if (!current.TryGetId(out MemoryRecordId nextId)) + { + return ParentChainStep.HostRejected; + } + + return ParentChainStep.Parent(nextId); + } + + private static ParentReadStatus TryReadParent(MemoryRecord current, out MemoryRecord parent) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + if (!current.Handle.TryGetProperty(state, "Parent"u8).IsOk) + { + parent = default; + return ParentReadStatus.HostRejected; + } + + if (state.IsNil(-1)) + { + parent = default; + return ParentReadStatus.Root; + } + + return MemoryRecord.TryRead(state, -1, out parent) + ? ParentReadStatus.Parent + : ParentReadStatus.HostRejected; + } + + private static TableRecordMutationStatus TryAssignParent(MemoryRecord child, MemoryRecord parent, + out MemoryRecordSnapshot record) + { + record = default; + if (!child.Handle.TrySetProperty("Parent"u8, parent) || + !TableClient.TrySnapshot(child, out record)) + { + return TableRecordMutationStatus.HostRejected; + } + + return TableRecordMutationStatus.Success; + } + + private static int GetMaximumParentHops(int topLevelCount) + { + const int traversalSlack = 1024; + return topLevelCount > int.MaxValue - traversalSlack + ? int.MaxValue + : Math.Max(1, topLevelCount) + traversalSlack; + } + + private enum ParentReadStatus + { + Root, + Parent, + HostRejected + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs b/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs new file mode 100644 index 0000000..918fff3 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/SymbolRegistrationLease.cs @@ -0,0 +1,71 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains; + +/// Activation-owned release path for a custom symbol that Client registered in Cheat Engine. +internal sealed class SymbolRegistrationLease( + SymbolRegistration registration, + ICheatEngineDispatcher dispatcher, + Action untrack, + Action unregisterSymbol, + Action releaseName) : ISymbolRegistrationLease +{ + private readonly ICheatEngineDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly Lock _gate = new(); + private readonly Action _releaseName = releaseName ?? throw new ArgumentNullException(nameof(releaseName)); + + private readonly Action _unregisterSymbol = + unregisterSymbol ?? throw new ArgumentNullException(nameof(unregisterSymbol)); + + private readonly Action _untrack = + untrack ?? throw new ArgumentNullException(nameof(untrack)); + + private int _released; + + public string Name + { + get; + } = registration.Name; + + public Address Address + { + get; + } = registration.Address; + + public bool IsReleased => Volatile.Read(ref _released) != 0; + + public void Dispose() + { + lock (_gate) + { + if (Volatile.Read(ref _released) != 0) + { + return; + } + + // Keep the lease active and registered when normal dispatch admission is closed during disable. The hosting + // cleanup scope can then retry this exact disposal on CE's main thread before Lua detaches. + _dispatcher.Invoke(() => _unregisterSymbol(Name)); + + try + { + _untrack(this); + } + finally + { + try + { + _releaseName(Name); + } + finally + { + Volatile.Write(ref _released, 1); + } + } + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableClient.cs b/libs/CheatEngine.Client.Core/Domains/TableClient.cs new file mode 100644 index 0000000..e167b6a --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableClient.cs @@ -0,0 +1,826 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Marshalling; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class TableClient( + ICheatEngineDispatcher dispatcher, + CoreClientPolicy policy, + ITableRecordMutationPort? recordMutations = null) : ITableClient +{ + private const string _getHierarchyOperation = "Tables.GetHierarchy"; + + private readonly ICheatEngineDispatcher _dispatcher = + dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + private readonly CoreClientPolicy _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + private readonly ITableRecordMutationPort _recordMutations = recordMutations ?? new SdkTableRecordMutationPort(); + + public bool TryGetCurrent(out AddressTableSnapshot table, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + AddressTableSnapshot captured = default; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + succeeded = AddressListAccess.TryGetCurrent(out AddressList list) && list.TryGetCount(out int count) && + CaptureTable(count, out captured), out failure, cancellationToken)) + { + table = default; + return false; + } + + table = captured; + if (succeeded) + { + return true; + } + + failure = HostFailure("Tables.GetCurrent"); + return false; + } + + public AddressTableSnapshot GetCurrent(CancellationToken cancellationToken = default) + { + if (TryGetCurrent(out AddressTableSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryGetSnapshot(MemoryRecordCollectionRequest request, out AddressTableSnapshot table, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + AddressTableSnapshot captured = default; + bool succeeded = false; + bool exceededLimit = false; + if (!_dispatcher.TryInvoke( + () => succeeded = TryCaptureSnapshot(request, out captured, out exceededLimit), + out failure, cancellationToken)) + { + table = default; + return false; + } + + table = captured; + if (succeeded) + { + return true; + } + + failure = exceededLimit + ? ResultLimitFailure("Tables.GetSnapshot", request.MaximumItems) + : HostFailure("Tables.GetSnapshot"); + return false; + } + + public AddressTableSnapshot GetSnapshot(MemoryRecordCollectionRequest request, + CancellationToken cancellationToken = default) + { + if (TryGetSnapshot(request, out AddressTableSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryFind(MemoryRecordSearch search, MemoryRecordCollectionRequest request, + out ImmutableArray records, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!HasPredicate(search)) + { + records = []; + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, "Tables.Find", + "A memory-record search must specify at least one predicate."); + return false; + } + + if (!TryGetSnapshot(request, out AddressTableSnapshot snapshot, out failure, cancellationToken)) + { + records = []; + return false; + } + + if (cancellationToken.IsCancellationRequested) + { + records = []; + failure = CoreFailureFactory.Cancelled("Tables.Find"); + return false; + } + + records = snapshot.Records.Where(record => Matches(search, record)).ToImmutableArray(); + failure = default; + return true; + } + + public ImmutableArray Find(MemoryRecordSearch search, + MemoryRecordCollectionRequest request, CancellationToken cancellationToken = default) + { + if (TryFind(search, request, out ImmutableArray result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return []; + } + + public bool TryGetRecord(int index, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + return TryRecord("Tables.GetRecord", + list => list.TryGetMemoryRecord(index, out MemoryRecord value) ? value : null, + out record, out failure, cancellationToken); + } + + public bool TryGetRecord(MemoryRecordId id, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return TryRecord("Tables.GetRecord", + list => list.TryGetMemoryRecordById(id, out MemoryRecord value) ? value : null, + out record, out failure, cancellationToken); + } + + public MemoryRecordSnapshot GetRecord(int index, CancellationToken cancellationToken = default) + { + if (TryGetRecord(index, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public MemoryRecordSnapshot GetRecord(MemoryRecordId id, CancellationToken cancellationToken = default) + { + if (TryGetRecord(id, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryGetSelected(out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryRecord("Tables.GetSelected", list => list.TryGetSelectedRecord(out MemoryRecord value) ? value : null, + out record, out failure, cancellationToken); + } + + public MemoryRecordSnapshot GetSelected(CancellationToken cancellationToken = default) + { + if (TryGetSelected(out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TrySelect(MemoryRecordId id, out MemoryRecordSnapshot record, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryMutateRecord("Tables.Select", id, + static (list, value) => list.TrySetSelectedRecord(value), out record, out failure, cancellationToken); + } + + public MemoryRecordSnapshot SelectRecord(MemoryRecordId id, CancellationToken cancellationToken = default) + { + if (TrySelect(id, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryCreate(MemoryRecordDefinition definition, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + MemoryRecordSnapshot captured = default; + bool succeeded = false; + TableRecordMutationStatus parentMutationStatus = TableRecordMutationStatus.Success; + if (!_dispatcher.TryInvoke( + () => succeeded = TryCreateRecord(definition, out captured, out parentMutationStatus), + out failure, cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (succeeded) + { + return true; + } + + failure = parentMutationStatus == TableRecordMutationStatus.Success + ? HostFailure("Tables.Create") + : MutationFailure("Tables.Create", parentMutationStatus); + return false; + } + + public MemoryRecordSnapshot Create(MemoryRecordDefinition definition, + CancellationToken cancellationToken = default) + { + if (TryCreate(definition, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryUpdate(MemoryRecordUpdate update, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + MemoryRecordSnapshot captured = default; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => succeeded = TryUpdateRecord(update, out captured), out failure, + cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (succeeded) + { + return true; + } + + failure = HostFailure("Tables.Update"); + return false; + } + + public MemoryRecordSnapshot Update(MemoryRecordUpdate update, CancellationToken cancellationToken = default) + { + if (TryUpdate(update, out MemoryRecordSnapshot result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryDelete(MemoryRecordId id, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + TableRecordMutationStatus status = TableRecordMutationStatus.HostRejected; + if (!_dispatcher.TryInvoke(() => status = _recordMutations.TryDelete(id), out failure, cancellationToken)) + { + return false; + } + + if (status == TableRecordMutationStatus.Success) + { + failure = default; + return true; + } + + failure = MutationFailure("Tables.Delete", status); + return false; + } + + public void Delete(MemoryRecordId id, CancellationToken cancellationToken = default) + { + if (!TryDelete(id, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TrySetActive(MemoryRecordId id, bool isActive, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return TryMutateRecord("Tables.SetActive", id, + static (_, value, active) => value.Handle.TrySetProperty("Active"u8, active), + isActive, out record, out failure, cancellationToken); + } + + public MemoryRecordSnapshot SetActive(MemoryRecordId id, bool isActive, + CancellationToken cancellationToken = default) + { + if (TrySetActive(id, isActive, out MemoryRecordSnapshot result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + if (parentId is { } requestedParentId && requestedParentId == childId) + { + record = default; + failure = MutationFailure("Tables.SetParent", TableRecordMutationStatus.InvalidRelationship); + return false; + } + + MemoryRecordSnapshot captured = default; + TableRecordMutationStatus status = TableRecordMutationStatus.HostRejected; + if (!_dispatcher.TryInvoke(() => status = _recordMutations.TrySetParent(childId, parentId, out captured), + out failure, cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (status == TableRecordMutationStatus.Success) + { + failure = default; + return true; + } + + failure = MutationFailure("Tables.SetParent", status); + return false; + } + + public MemoryRecordSnapshot SetParent(MemoryRecordId childId, MemoryRecordId? parentId, + CancellationToken cancellationToken = default) + { + if (TrySetParent(childId, parentId, out MemoryRecordSnapshot result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryGetHierarchy(MemoryRecordId rootId, MemoryRecordHierarchyRequest request, + out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + MemoryRecordHierarchySnapshot captured = default; + bool found = false; + bool succeeded = false; + HierarchyBuildProblem problem = HierarchyBuildProblem.None; + if (!_dispatcher.TryInvoke(() => + { + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(rootId, out MemoryRecord root)) + { + return; + } + + found = true; + HashSet visited = []; + int materialized = 0; + succeeded = TryBuildHierarchy(root, request, 1, visited, ref materialized, out captured, out problem); + }, out failure, cancellationToken)) + { + hierarchy = default; + return false; + } + + hierarchy = captured; + if (succeeded) + { + return true; + } + + failure = GetHierarchyFailure(problem, found, request); + return false; + } + + public MemoryRecordHierarchySnapshot GetHierarchy(MemoryRecordId rootId, + MemoryRecordHierarchyRequest request, CancellationToken cancellationToken = default) + { + if (TryGetHierarchy(rootId, request, out MemoryRecordHierarchySnapshot result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + + public bool TryLoadTrustedTable(TableLoadRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!TryAuthorize(request.File.FullPath, "Tables.LoadTrustedTable", out failure)) + { + return false; + } + + return _dispatcher.TryInvoke(() => ClientLuaGlobals.LoadTable(request.File.FullPath, request.Merge), + out failure, cancellationToken); + } + + public void LoadTrustedTable(TableLoadRequest request, CancellationToken cancellationToken = default) + { + if (!TryLoadTrustedTable(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public bool TrySaveTable(TableSaveRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + if (!TryAuthorize(request.File.FullPath, "Tables.SaveTable", out failure)) + { + return false; + } + + return _dispatcher.TryInvoke(() => ClientLuaGlobals.SaveTable(request.File.FullPath), + out failure, cancellationToken); + } + + public void SaveTable(TableSaveRequest request, CancellationToken cancellationToken = default) + { + if (!TrySaveTable(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + private bool TryRecord(string operation, Func selector, + out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken) + { + MemoryRecordSnapshot captured = default; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + if (!AddressListAccess.TryGetCurrent(out AddressList list)) + { + return; + } + + MemoryRecord? value = selector(list); + succeeded = value.HasValue && TrySnapshot(value.Value, out captured); + }, out failure, cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (succeeded) + { + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.NotFound, operation, + "The requested Cheat Engine memory record was not found or was malformed."); + return false; + } + + private static bool TryCaptureSnapshot(MemoryRecordCollectionRequest request, out AddressTableSnapshot table, + out bool exceededLimit) + { + table = default; + exceededLimit = false; + if (!AddressListAccess.TryGetCurrent(out AddressList list) || !list.TryGetCount(out int count)) + { + return false; + } + + if (count > request.MaximumItems) + { + exceededLimit = true; + return false; + } + + return TryCaptureRecords(list, count, out table); + } + + private static bool TryCaptureRecords(AddressList list, int count, out AddressTableSnapshot table) + { + ImmutableArray.Builder records = + ImmutableArray.CreateBuilder(count); + for (int index = 0; index < count; index++) + { + if (!list.TryGetMemoryRecord(index, out MemoryRecord value) || + !TrySnapshot(value, out MemoryRecordSnapshot snapshot)) + { + table = default; + return false; + } + + records.Add(snapshot); + } + + table = new AddressTableSnapshot(records.MoveToImmutable()); + return true; + } + + private bool TryCreateRecord(MemoryRecordDefinition definition, out MemoryRecordSnapshot record, + out TableRecordMutationStatus parentMutationStatus) + { + record = default; + parentMutationStatus = TableRecordMutationStatus.Success; + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryCreateMemoryRecord(out MemoryRecord value)) + { + return false; + } + + bool succeeded = TryInitializeRecord(value, definition) && + TryCompleteRecordCreation(value, definition, out record, out parentMutationStatus); + if (!succeeded) + { + _ = value.Handle.TryCallMethod("destroy"u8); + } + + return succeeded; + } + + private static bool TryInitializeRecord(MemoryRecord value, MemoryRecordDefinition definition) + { + return value.TrySetDescription(definition.Description) && + value.TrySetAddressExpression(definition.AddressExpression) && + value.TrySetVariableType(definition.VariableType) && + value.TrySetValue(definition.Value); + } + + private bool TryCompleteRecordCreation(MemoryRecord value, MemoryRecordDefinition definition, + out MemoryRecordSnapshot record, out TableRecordMutationStatus parentMutationStatus) + { + record = default; + parentMutationStatus = TableRecordMutationStatus.Success; + if (definition.ParentId is not { } parentId) + { + return TrySnapshot(value, out record); + } + + if (!value.TryGetId(out MemoryRecordId createdId)) + { + parentMutationStatus = TableRecordMutationStatus.HostRejected; + return false; + } + + parentMutationStatus = _recordMutations.TrySetParent(createdId, parentId, out record); + return parentMutationStatus == TableRecordMutationStatus.Success; + } + + private static bool TryUpdateRecord(MemoryRecordUpdate update, out MemoryRecordSnapshot record) + { + record = default; + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(update.Id, out MemoryRecord value)) + { + return false; + } + + return TryApplyUpdate(value, update) && TrySnapshot(value, out record); + } + + private static bool TryApplyUpdate(MemoryRecord value, MemoryRecordUpdate update) + { + return (update.Description is null || value.TrySetDescription(update.Description)) && + (update.AddressExpression is null || value.TrySetAddressExpression(update.AddressExpression)) && + (!update.VariableType.HasValue || value.TrySetVariableType(update.VariableType.Value)) && + (update.Value is null || value.TrySetValue(update.Value)); + } + + private bool TryAuthorize(string path, string operation, out CheatEngineFailure failure) + { + if (_policy.AllowedTableRoots.Count == 0) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, operation, + "Table import and export are disabled because no allowed root is configured."); + return false; + } + + if (_policy.TryAuthorizeTableFile(path, operation == "Tables.LoadTrustedTable", out string reason)) + { + failure = default; + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.OperationRejected, operation, + reason); + return false; + } + + internal static bool TrySnapshot(MemoryRecord value, out MemoryRecordSnapshot snapshot) + { + if (!value.TryGetId(out MemoryRecordId id) || !value.TryGetIndex(out int index) || + !value.TryGetDescription(out string? description) || + !value.TryGetAddressExpression(out string? expression) || + !value.TryGetValue(out string? text) || !value.TryGetVariableType(out VariableType variableType) || + !value.Handle.TryGetProperty("Active"u8, out bool isActive) || + !value.Handle.TryGetProperty("Count"u8, out int childCount) || childCount < 0) + { + snapshot = default; + return false; + } + + Address? currentAddress = value.TryGetCurrentAddress(out Address address) ? address : null; + snapshot = new MemoryRecordSnapshot( + id, + index, + new MemoryRecordContentSnapshot(description, expression, text, variableType), + new MemoryRecordStateSnapshot(currentAddress, isActive, childCount)); + return true; + } + + private bool TryMutateRecord(string operation, MemoryRecordId id, + Func mutation, out MemoryRecordSnapshot record, + out CheatEngineFailure failure, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(mutation); + MemoryRecordSnapshot captured = default; + bool found = false; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + if (!AddressListAccess.TryGetCurrent(out AddressList list) || + !list.TryGetMemoryRecordById(id, out MemoryRecord value)) + { + return; + } + + found = true; + succeeded = mutation(list, value) && TrySnapshot(value, out captured); + }, out failure, cancellationToken)) + { + record = default; + return false; + } + + record = captured; + if (succeeded) + { + return true; + } + + failure = found + ? HostFailure(operation) + : new CheatEngineFailure(CheatEngineFailureKind.NotFound, operation, + "The requested Cheat Engine memory record was not found."); + return false; + } + + private bool TryMutateRecord(string operation, MemoryRecordId id, + Func mutation, TArgument argument, + out MemoryRecordSnapshot record, out CheatEngineFailure failure, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(mutation); + return TryMutateRecord(operation, id, (list, value) => mutation(list, value, argument), out record, + out failure, cancellationToken); + } + + private static bool TryBuildHierarchy(MemoryRecord value, MemoryRecordHierarchyRequest request, int depth, + HashSet visited, ref int materialized, out MemoryRecordHierarchySnapshot hierarchy, + out HierarchyBuildProblem problem) + { + hierarchy = default; + if (materialized >= request.MaximumItems) + { + problem = HierarchyBuildProblem.ItemLimit; + return false; + } + + if (!TrySnapshot(value, out MemoryRecordSnapshot snapshot) || !visited.Add(snapshot.Id)) + { + problem = HierarchyBuildProblem.InvalidShape; + return false; + } + + materialized++; + if (snapshot.ChildCount == 0) + { + hierarchy = new MemoryRecordHierarchySnapshot(snapshot, []); + problem = HierarchyBuildProblem.None; + return true; + } + + if (depth >= request.MaximumDepth) + { + problem = HierarchyBuildProblem.DepthLimit; + return false; + } + + if (snapshot.ChildCount > request.MaximumItems - materialized) + { + problem = HierarchyBuildProblem.ItemLimit; + return false; + } + + ImmutableArray.Builder children = + ImmutableArray.CreateBuilder(snapshot.ChildCount); + for (int index = 0; index < snapshot.ChildCount; index++) + { + problem = HierarchyBuildProblem.InvalidShape; + if (!value.TryGetChild(index, out MemoryRecord child) || + !TryBuildHierarchy(child, request, depth + 1, visited, ref materialized, + out MemoryRecordHierarchySnapshot childSnapshot, + out problem)) + { + return false; + } + + children.Add(childSnapshot); + } + + hierarchy = new MemoryRecordHierarchySnapshot(snapshot, children.MoveToImmutable()); + problem = HierarchyBuildProblem.None; + return true; + } + + private static bool Matches(MemoryRecordSearch search, MemoryRecordSnapshot record) + { + return (search.DescriptionContains is null || record.Description.Contains(search.DescriptionContains, + StringComparison.OrdinalIgnoreCase)) && + (search.AddressExpression is null || string.Equals(record.AddressExpression, search.AddressExpression, + StringComparison.OrdinalIgnoreCase)) && + (!search.VariableType.HasValue || record.VariableType == search.VariableType.Value) && + (!search.IsActive.HasValue || record.IsActive == search.IsActive.Value); + } + + private static bool HasPredicate(MemoryRecordSearch search) + { + return search.DescriptionContains is not null || search.AddressExpression is not null || + search.VariableType.HasValue || search.IsActive.HasValue; + } + + private static bool CaptureTable(int count, out AddressTableSnapshot table) + { + table = new AddressTableSnapshot(count); + return true; + } + + private static CheatEngineFailure HostFailure(string operation) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidHostResult, operation, + "Cheat Engine did not return the expected Address List contract."); + } + + private static CheatEngineFailure ResultLimitFailure(string operation, int maximumItems) + { + return new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, operation, + $"The operation requires more records than the explicit limit of {maximumItems}."); + } + + private static CheatEngineFailure GetHierarchyFailure(HierarchyBuildProblem problem, bool found, + MemoryRecordHierarchyRequest request) + { + return problem switch + { + HierarchyBuildProblem.ItemLimit => ResultLimitFailure(_getHierarchyOperation, request.MaximumItems), + HierarchyBuildProblem.DepthLimit => new CheatEngineFailure(CheatEngineFailureKind.ResultLimitExceeded, + _getHierarchyOperation, + $"The operation requires a hierarchy depth greater than the explicit limit of {request.MaximumDepth}."), + HierarchyBuildProblem.InvalidShape => HostFailure(_getHierarchyOperation), + _ when !found => new CheatEngineFailure(CheatEngineFailureKind.NotFound, _getHierarchyOperation, + "The requested Cheat Engine memory record was not found."), + _ => HostFailure(_getHierarchyOperation) + }; + } + + private static CheatEngineFailure MutationFailure(string operation, TableRecordMutationStatus status) + { + return status switch + { + TableRecordMutationStatus.RecordNotFound => new CheatEngineFailure(CheatEngineFailureKind.NotFound, + operation, "The requested Cheat Engine memory record was not found."), + TableRecordMutationStatus.ParentNotFound => new CheatEngineFailure(CheatEngineFailureKind.NotFound, + operation, "The requested parent Cheat Engine memory record was not found."), + TableRecordMutationStatus.InvalidRelationship => new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, operation, + "The requested parent relationship is invalid: it is self-referential, cyclic, or exceeds the " + + "supported hierarchy depth."), + _ => HostFailure(operation) + }; + } + + private enum HierarchyBuildProblem + { + None, + ItemLimit, + DepthLimit, + InvalidShape + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs b/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs new file mode 100644 index 0000000..cdd3848 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableParentRelationshipGuard.cs @@ -0,0 +1,40 @@ +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Domains; + +/// Bounded, handle-free validation of a parent chain before Cheat Engine is asked to mutate it. +internal static class TableParentRelationshipGuard +{ + internal static TableRecordMutationStatus Validate(MemoryRecordId childId, MemoryRecordId candidateParentId, + int maximumHops, Func getNext) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumHops); + ArgumentNullException.ThrowIfNull(getNext); + + HashSet visited = []; + MemoryRecordId current = candidateParentId; + for (int hop = 0; hop < maximumHops; hop++) + { + if (current == childId || !visited.Add(current)) + { + return TableRecordMutationStatus.InvalidRelationship; + } + + ParentChainStep step = getNext(current); + switch (step.Kind) + { + case ParentChainStepKind.Root: + return TableRecordMutationStatus.Success; + case ParentChainStepKind.Parent: + current = step.ParentId; + break; + case ParentChainStepKind.HostRejected: + return TableRecordMutationStatus.HostRejected; + default: + return TableRecordMutationStatus.HostRejected; + } + } + + return TableRecordMutationStatus.InvalidRelationship; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs b/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs new file mode 100644 index 0000000..e0b4b5e --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/TableRecordMutationStatus.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Core.Domains; + +/// Outcome of one protected address-list record mutation. +internal enum TableRecordMutationStatus +{ + Success, + RecordNotFound, + ParentNotFound, + InvalidRelationship, + HostRejected +} diff --git a/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs new file mode 100644 index 0000000..d1da5ce --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/UnavailableValueScanner.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; + +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Keeps the public capability honest until the owned MemScan/FoundList lifecycle passes the required live CE 7.7 +/// gate. +/// +internal sealed class UnavailableValueScanner : IValueScanner +{ + // This is a deliberate product gate, not a transient host-capability probe. SDK 1.0.0 exposes the state machine + // only through MemoryScanSession.Adopt(Owned, Owned), while Owned has an internal + // constructor and the Lua-global generator cannot marshal CEObject results. Bypassing that with reflection or a + // hand-rolled destroy owner would make an unverified CE ownership assumption part of the Client contract. + private const string _ownershipGateMessage = + "Value scans are disabled: CheatEngine.SDK 1.0.0 has no public ownership factory for createMemScan or " + + "createFoundList, and its generated Lua globals cannot return CEObject handles. Enablement requires the " + + "Cheat Engine 7.7 ownership and reactivation live gate."; + + public bool TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + session = null; + failure = cancellationToken.IsCancellationRequested + ? new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Scans.CreateSession", + "The operation was cancelled before Cheat Engine work began.") + : new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, "Scans.CreateSession", + _ownershipGateMessage); + return false; + } + + public IValueScanSession CreateSession(CancellationToken cancellationToken = default) + { + _ = TryCreateSession(out _, out CheatEngineFailure failure, cancellationToken); + return ThrowFailure(failure); + } + + private static T ThrowFailure(CheatEngineFailure failure) + { + failure.Throw(); + throw new UnreachableException(); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs b/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs new file mode 100644 index 0000000..c75ecbe --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/UnsafeLuaClient.cs @@ -0,0 +1,90 @@ +using System.Text; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.Client.Core.Domains; + +internal sealed class UnsafeLuaClient : IUnsafeLuaClient +{ + private readonly ICheatEngineDispatcher _dispatcher; + private readonly CoreClientPolicy _policy; + + internal UnsafeLuaClient(SdkMainThreadDispatcher dispatcher, CoreClientPolicy policy) + : this((ICheatEngineDispatcher) (dispatcher ?? throw new ArgumentNullException(nameof(dispatcher))), policy) + { + } + + /// Deterministic internal seam for policy tests; production construction uses the SDK dispatcher overload. + internal UnsafeLuaClient(ICheatEngineDispatcher dispatcher, CoreClientPolicy policy) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + } + + public bool TryExecute(LuaScript script, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(script.Source); + if (script.ChunkName is { Length: 0 }) + { + throw new ArgumentException("A Lua chunk name must be null or non-empty.", nameof(script)); + } + + if (!_policy.EnableUnsafeLuaExecution) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, "Lua.ExecuteUnsafe", + "Arbitrary Lua execution was not enabled for this activation."); + return false; + } + + string luaStatus = "unknown"; + string? luaMessage = null; + bool succeeded = false; + if (!_dispatcher.TryInvoke(() => + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + byte[] source = Encoding.UTF8.GetBytes(script.Source); + ReadOnlySpan name = script.ChunkName is null + ? ReadOnlySpan.Empty + : Encoding.UTF8.GetBytes(script.ChunkName); + LuaStatus status = state.TryExecute(source, 0, name); + succeeded = status.IsOk; + luaStatus = status.ToString(); + if (!succeeded) + { + luaMessage = LuaError.FromStack(state, status).Message; + } + }, out failure, cancellationToken)) + { + return false; + } + + if (succeeded) + { + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.LuaError, "Lua.ExecuteUnsafe", + luaMessage is { Length: > 0 } + ? $"The protected Lua call failed with status '{luaStatus}': {luaMessage}" + : $"The protected Lua call failed with status '{luaStatus}'."); + return false; + } + + public void Execute(LuaScript script, CancellationToken cancellationToken = default) + { + if (!TryExecute(script, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs b/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs new file mode 100644 index 0000000..c52e62b --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/ValueScanSessionStateMachine.cs @@ -0,0 +1,85 @@ +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Domains; + +/// +/// Keeps the client-visible value-scan lifecycle conservative and independent of SDK object handles. +/// +/// +/// This state machine is deliberately usable without a live Cheat Engine host. The eventual CE-backed session must +/// call +/// or immediately before work begins, then either +/// after wait-and-initialize succeeds or when a protected Lua +/// call +/// leaves the safe continuation state unknown. +/// +internal sealed class ValueScanSessionStateMachine +{ + public ValueScanSessionState State + { + get; + private set; + } = ValueScanSessionState.Created; + + internal void BeginFirstScan() + { + Require(ValueScanSessionState.Created, "a first scan"); + State = ValueScanSessionState.Scanning; + } + + internal void BeginNextScan() + { + Require(ValueScanSessionState.ResultsReady, "a subsequent scan"); + State = ValueScanSessionState.Scanning; + } + + internal void CompleteScan() + { + Require(ValueScanSessionState.Scanning, "scan completion"); + State = ValueScanSessionState.ResultsReady; + } + + internal void Invalidate() + { + if (State != ValueScanSessionState.Disposed) + { + State = ValueScanSessionState.Invalidated; + } + } + + internal void Reset() + { + if (State == ValueScanSessionState.Created) + { + return; + } + + if (State is ValueScanSessionState.Scanning or ValueScanSessionState.Disposed) + { + ThrowInvalidTransition("a reset"); + } + + State = ValueScanSessionState.Created; + } + + internal void MarkDisposed() + { + State = ValueScanSessionState.Disposed; + } + + private void Require(ValueScanSessionState expected, string operation) + { + if (State == expected) + { + return; + } + + ThrowInvalidTransition(operation); + } + + private void ThrowInvalidTransition(string operation) + { + throw new InvalidOperationException( + $"The value-scan session is {State} and cannot begin {operation}."); + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs b/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs new file mode 100644 index 0000000..4839b35 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/ClientLuaGlobals.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Annotations.Lua; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Internal generated bindings for CE globals that the SDK does not expose as high-level services. +internal static partial class ClientLuaGlobals +{ + [LuaGlobal("getOpenedProcessID")] + internal static partial long GetOpenedProcessId(); + + [LuaGlobal("openProcess")] + internal static partial void OpenProcess(long processId); + + [LuaGlobal("getCEVersion")] + internal static partial double GetCheatEngineVersion(); + + [LuaGlobal("getSystemArchitecture")] + internal static partial int GetSystemArchitecture(); + + [LuaGlobal("getABI")] + internal static partial int GetTargetAbi(); + + [LuaGlobal("targetIs64Bit")] + internal static partial bool TargetIs64Bit(); + + [LuaGlobal("loadTable")] + internal static partial void LoadTable(string path, bool merge); + + [LuaGlobal("saveTable")] + internal static partial void SaveTable(string path); + + [LuaGlobal("getNameFromAddress")] + internal static partial bool TryGetNameFromAddress(nuint address, [MaybeNullWhen(false)] out string? name); + + [LuaGlobal("registerSymbol")] + internal static partial void RegisterSymbol(string name, nuint address, bool doNotSave); + + [LuaGlobal("unregisterSymbol")] + internal static partial void UnregisterSymbol(string name); +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs new file mode 100644 index 0000000..8e93cc8 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreClientPolicy.cs @@ -0,0 +1,210 @@ +namespace CheatEngine.Client.Core.Infrastructure; + +/// Immutable activation policy supplied by the DI integration. +internal sealed class CoreClientPolicy +{ + internal CoreClientPolicy(IEnumerable allowedTableRoots, bool enableUnsafeLuaExecution) + { + ArgumentNullException.ThrowIfNull(allowedTableRoots); + List roots = []; + foreach (string root in allowedTableRoots) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + roots.Add(NormalizeDirectory(root)); + } + + AllowedTableRoots = roots.ToArray(); + EnableUnsafeLuaExecution = enableUnsafeLuaExecution; + } + + internal static CoreClientPolicy SafeDefaults + { + get; + } = new([], false); + + internal IReadOnlyList AllowedTableRoots + { + get; + } + + internal bool EnableUnsafeLuaExecution + { + get; + } + + /// + /// Validates a table path against the activation's explicit roots without following any observed link or junction. + /// + /// + /// Cheat Engine's native table functions accept only a path, not a managed file handle. A full handle-based + /// TOCTOU-proof authorization is therefore impossible at this boundary. This policy deliberately rejects every + /// path with an observed reparse point instead of resolving and trusting a link target; callers must keep the + /// approved root tree non-reparse-pointed until the native load or save completes. + /// + internal bool TryAuthorizeTableFile(string path, bool forLoad, out string reason) + { + if (string.IsNullOrWhiteSpace(path)) + { + reason = "The table path must be non-empty and fully qualified."; + return false; + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(path); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + reason = "The table path cannot be normalized safely."; + return false; + } + + foreach (string root in AllowedTableRoots) + { + if (!IsContainedBy(root, fullPath)) + { + continue; + } + + if (!TryVerifySafePath(root, fullPath, forLoad, out reason)) + { + return false; + } + + reason = string.Empty; + return true; + } + + reason = "The normalized table path is outside every configured allowed root."; + return false; + } + + private static string NormalizeDirectory(string path) + { + string fullPath = Path.GetFullPath(path); + return Path.EndsInDirectorySeparator(fullPath) ? fullPath : fullPath + Path.DirectorySeparatorChar; + } + + private static bool IsContainedBy(string normalizedRoot, string fullPath) + { + string root = Path.TrimEndingDirectorySeparator(normalizedRoot); + string relative = Path.GetRelativePath(root, fullPath); + return !Path.IsPathFullyQualified(relative) && + !string.Equals(relative, "..", StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal); + } + + private static bool TryVerifySafePath(string normalizedRoot, string fullPath, bool forLoad, out string reason) + { + string root = Path.TrimEndingDirectorySeparator(normalizedRoot); + if (!Directory.Exists(root)) + { + reason = "The configured table root does not currently exist and cannot be verified."; + return false; + } + + if (!TryVerifyDirectoryChain(root, out reason)) + { + return false; + } + + if (forLoad) + { + if (!File.Exists(fullPath)) + { + reason = "A trusted table import source must be an existing regular file."; + return false; + } + + if (!TryVerifyDirectoryChain(Path.GetDirectoryName(fullPath)!, out reason)) + { + return false; + } + + return TryVerifyRegularFile(fullPath, out reason); + } + + string? parent = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(parent) || !Directory.Exists(parent)) + { + reason = "A trusted table export destination must have an existing parent directory."; + return false; + } + + if (!TryVerifyDirectoryChain(parent, out reason)) + { + return false; + } + + return !File.Exists(fullPath) || TryVerifyRegularFile(fullPath, out reason); + } + + private static bool TryVerifyDirectoryChain(string directoryPath, out string reason) + { + string fullPath = Path.GetFullPath(directoryPath); + string? root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + { + reason = "The table path does not have a verifiable filesystem root."; + return false; + } + + string current = Path.TrimEndingDirectorySeparator(root); + if (current.Length == 2 && current[1] == ':') + { + current += Path.DirectorySeparatorChar; + } + + if (!TryVerifyNotReparsePoint(new DirectoryInfo(current), out reason)) + { + return false; + } + + string relative = Path.GetRelativePath(current, fullPath); + if (relative == ".") + { + return true; + } + + foreach (string segment in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if (!TryVerifyNotReparsePoint(new DirectoryInfo(current), out reason)) + { + return false; + } + } + + return true; + } + + private static bool TryVerifyRegularFile(string fullPath, out string reason) + { + return TryVerifyNotReparsePoint(new FileInfo(fullPath), out reason); + } + + private static bool TryVerifyNotReparsePoint(FileSystemInfo item, out string reason) + { + try + { + item.Refresh(); + if ((item.Attributes & FileAttributes.ReparsePoint) != 0 || item.LinkTarget is not null) + { + reason = "The trusted table path contains a symbolic link, junction, or another reparse point."; + return false; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or NotSupportedException) + { + reason = "The trusted table path cannot be verified without following a filesystem link."; + return false; + } + + reason = string.Empty; + return true; + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs new file mode 100644 index 0000000..29612fb --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreFailureFactory.cs @@ -0,0 +1,50 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Maps implementation exceptions to the stable public client result vocabulary. +internal static class CoreFailureFactory +{ + internal static CheatEngineFailure Cancelled(string operation) + { + return new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + operation, + "The operation was cancelled before Cheat Engine work began."); + } + + internal static CheatEngineFailure FromException(string operation, Exception exception) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + ArgumentNullException.ThrowIfNull(exception); + + return new CheatEngineFailure(GetKind(exception), operation, exception.Message, exception); + } + + internal static CheatEngineFailure Lifecycle(string operation, string message, Exception? exception = null) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, message, exception); + } + + private static CheatEngineFailureKind GetKind(Exception exception) + { + return exception switch + { + CheatEngineActivationExpiredException => CheatEngineFailureKind.ActivationExpired, + CheatEngineClientLifecycleException => CheatEngineFailureKind.InvalidState, + EngineCapabilityUnavailableException => CheatEngineFailureKind.CapabilityUnavailable, + EngineGlobalUnavailableException => CheatEngineFailureKind.CapabilityUnavailable, + EngineOperationFailedException => CheatEngineFailureKind.OperationRejected, + EngineLuaException => CheatEngineFailureKind.LuaError, + LuaException => CheatEngineFailureKind.LuaError, + EngineBindingException => CheatEngineFailureKind.BindingError, + EngineMarshallingException => CheatEngineFailureKind.InvalidHostResult, + ObjectDisposedException => CheatEngineFailureKind.InvalidState, + ArgumentException => CheatEngineFailureKind.OperationRejected, + InvalidOperationException => CheatEngineFailureKind.OperationRejected, + _ => CheatEngineFailureKind.Unknown + }; + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs new file mode 100644 index 0000000..c0d22f6 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreLifetime.cs @@ -0,0 +1,232 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Context; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// Captures one enabled plugin context and owns all client-created resources for its epoch. +internal sealed class CoreLifetime : IDisposable +{ + private readonly ICoreLifetimeContext _context; + private readonly CoreResourceRegistry _resources = new(); + private int _cleanupScopeDepth; + private int _disposed; + private int _resourcesDrained; + + private CoreLifetime(PluginContext context) + : this(new PluginContextAdapter(context)) + { + } + + internal CoreLifetime(ICoreLifetimeContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + TargetSelection = new TargetSelectionLifetime(ThrowIfInactive); + } + + internal long Epoch => _context.Epoch; + + internal CancellationToken Stopping => _context.Stopping; + + /// Gets the per-activation target-selection lifetime; this is deliberately independent from . + internal TargetSelectionLifetime TargetSelection + { + get; + } + + /// Gets whether the captured SDK activation context itself is still current, regardless of admission closure. + internal bool IsActivationCurrent => Volatile.Read(ref _disposed) == 0 && _context.IsCurrent; + + internal bool IsCurrent => + IsActivationCurrent && !Stopping.IsCancellationRequested; + + /// + /// Gets whether synchronous dispatch is currently legal. During disable, only code running inside the explicit + /// main-thread cleanup scope may dispatch; no worker admission is reopened. + /// + internal bool CanDispatch => IsActivationCurrent && + (!Stopping.IsCancellationRequested || IsInCleanupScopeOnMainThread); + + private bool IsInCleanupScopeOnMainThread => Volatile.Read(ref _cleanupScopeDepth) != 0 && + IsActivationCurrent && + _context.IsMainThread; + + /// Releases client-owned resources while the hosting plugin still owns SDK detach sequencing. + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (Interlocked.Exchange(ref _resourcesDrained, 1) != 0) + { + return; + } + + DisposeOwnedResources(); + } + + /// + /// Opens the hosting-only cleanup path on Cheat Engine's main thread. It permits direct, synchronous cleanup + /// dispatch after SDK shutdown cancellation, but never permits a worker to enter the SDK work-admission gate. + /// + internal IDisposable EnterCleanupScope() + { + ThrowIfActivationCurrent("Client.EnterCleanupScope"); + if (!_context.IsMainThread) + { + throw new CheatEngineClientLifecycleException("Client.EnterCleanupScope", + "Cheat Engine cleanup must run on the plugin main thread."); + } + + int cleanupScopeDepth = Interlocked.Increment(ref _cleanupScopeDepth); + if (cleanupScopeDepth <= 0) + { + Interlocked.Decrement(ref _cleanupScopeDepth); + throw new InvalidOperationException("The Cheat Engine cleanup scope depth overflowed."); + } + + return new CleanupScope(this); + } + + /// Drains client-owned CE resources while the SDK context remains attached and the cleanup scope is active. + internal void DrainOwnedResourcesForDisable() + { + if (!IsInCleanupScopeOnMainThread) + { + throw new CheatEngineClientLifecycleException("Client.DrainResources", + "Client-owned Cheat Engine resources can only be drained by the active main-thread cleanup scope."); + } + + if (Interlocked.Exchange(ref _resourcesDrained, 1) != 0) + { + return; + } + + DisposeOwnedResources(); + } + + private void DisposeOwnedResources() + { + Exception? firstFailure = null; + try + { + TargetSelection.Dispose(); + } + catch (Exception exception) + { + firstFailure = exception; + } + + try + { + _resources.Dispose(); + } + catch (Exception exception) + { + firstFailure ??= exception; + } + + if (firstFailure is not null) + { + throw firstFailure; + } + } + + internal static CoreLifetime Capture() + { + PluginContext? context = PluginHost.Context; + if (context is null || !context.IsCurrent) + { + throw new CheatEngineClientLifecycleException( + "Client.Activate", + "Cheat Engine has not enabled a plugin context for this client scope."); + } + + return new CoreLifetime(context); + } + + internal T Track(T resource) + where T : class, IDisposable + { + ThrowIfInactive("Client.TrackResource"); + return _resources.Track(resource); + } + + internal bool Untrack(IDisposable resource) + { + return _resources.Untrack(resource); + } + + internal void ThrowIfInactive(string operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + ThrowIfActivationCurrent(operation); + + if (Stopping.IsCancellationRequested) + { + throw new CheatEngineClientLifecycleException(operation, + "The Cheat Engine plugin lifecycle is stopping and no new client work is admitted."); + } + } + + /// Rejects ordinary dispatch once admission closes, except for the current main-thread cleanup scope. + internal void ThrowIfDispatchAllowed(string operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + ThrowIfActivationCurrent(operation); + + if (!Stopping.IsCancellationRequested || IsInCleanupScopeOnMainThread) + { + return; + } + + throw new CheatEngineClientLifecycleException(operation, + "The Cheat Engine plugin lifecycle is stopping and no new client work is admitted."); + } + + private void ThrowIfActivationCurrent(string operation) + { + if (!IsActivationCurrent) + { + throw new CheatEngineActivationExpiredException(operation, + "The Cheat Engine plugin lifecycle changed, so this client epoch is stale."); + } + } + + private void ExitCleanupScope() + { + if (Interlocked.Decrement(ref _cleanupScopeDepth) >= 0) + { + return; + } + + Interlocked.Exchange(ref _cleanupScopeDepth, 0); + throw new InvalidOperationException("The Cheat Engine cleanup scope was released more than once."); + } + + private sealed class CleanupScope(CoreLifetime lifetime) : IDisposable + { + private CoreLifetime? _lifetime = lifetime; + + public void Dispose() + { + CoreLifetime? owner = Interlocked.Exchange(ref _lifetime, null); + owner?.ExitCleanupScope(); + } + } + + private sealed class PluginContextAdapter(PluginContext context) : ICoreLifetimeContext + { + private readonly PluginContext _context = context ?? throw new ArgumentNullException(nameof(context)); + + public long Epoch => _context.Epoch; + + public CancellationToken Stopping => _context.ShutdownToken; + + public bool IsCurrent => _context.IsCurrent; + + public bool IsMainThread => _context.IsMainThread; + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs b/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs new file mode 100644 index 0000000..f0a05e3 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/CoreResourceRegistry.cs @@ -0,0 +1,177 @@ +namespace CheatEngine.Client.Core.Infrastructure; + +/// Owns client-created resources for one active plugin epoch and releases them in reverse creation order. +internal sealed class CoreResourceRegistry : IDisposable +{ + private readonly List _resources = []; + private bool _disposed; + + /// Releases every tracked resource even when an earlier cleanup fails. + public void Dispose() + { + DisposeDetached(DetachAll()); + } + + internal T Track(T resource) + where T : class, IDisposable + { + return TrackCore(resource, null); + } + + /// Tracks a resource that must be released when its target selection expires. + internal T Track(T resource, long targetSelectionEpoch) + where T : class, IDisposable + { + return TrackCore(resource, targetSelectionEpoch); + } + + /// Detaches and releases resources created for one target selection in reverse creation order. + internal void DisposeTargetSelection(long targetSelectionEpoch) + { + DisposeDetached(DetachTargetSelection(targetSelectionEpoch)); + } + + /// Detaches every registration and marks the registry closed. + internal IDisposable[] DetachAll() + { + lock (_resources) + { + if (_disposed) + { + return []; + } + + _disposed = true; + IDisposable[] resources = new IDisposable[_resources.Count]; + for (int index = 0; index < resources.Length; index++) + { + resources[index] = _resources[index].Resource; + } + + _resources.Clear(); + return resources; + } + } + + /// Detaches the resources registered to one selection without closing the registry. + internal IDisposable[] DetachTargetSelection(long targetSelectionEpoch) + { + lock (_resources) + { + if (_disposed) + { + return []; + } + + List? detached = null; + for (int index = 0; index < _resources.Count; index++) + { + if (_resources[index].TargetSelectionEpoch != targetSelectionEpoch) + { + continue; + } + + (detached ??= []).Add(_resources[index].Resource); + } + + if (detached is null) + { + return []; + } + + for (int index = _resources.Count - 1; index >= 0; index--) + { + if (_resources[index].TargetSelectionEpoch == targetSelectionEpoch) + { + _resources.RemoveAt(index); + } + } + + return [.. detached]; + } + } + + /// Disposes an insertion-ordered resource snapshot in reverse order, preserving the first fault. + internal static void DisposeDetached(IReadOnlyList resources) + { + Exception? firstFailure = null; + for (int index = resources.Count - 1; index >= 0; index--) + { + try + { + resources[index].Dispose(); + } + catch (Exception exception) + { + firstFailure ??= exception; + } + } + + if (firstFailure is not null) + { + throw firstFailure; + } + } + + private T TrackCore(T resource, long? targetSelectionEpoch) + where T : class, IDisposable + { + ArgumentNullException.ThrowIfNull(resource); + + lock (_resources) + { + ThrowIfDisposed(); + if (!ContainsReference(resource)) + { + _resources.Add(new ResourceEntry(resource, targetSelectionEpoch)); + } + } + + return resource; + } + + internal bool Untrack(IDisposable resource) + { + ArgumentNullException.ThrowIfNull(resource); + + lock (_resources) + { + for (int index = _resources.Count - 1; index >= 0; index--) + { + if (!ReferenceEquals(_resources[index].Resource, resource)) + { + continue; + } + + _resources.RemoveAt(index); + return true; + } + + return false; + } + } + + private bool ContainsReference(IDisposable resource) + { + for (int index = 0; index < _resources.Count; index++) + { + if (ReferenceEquals(_resources[index].Resource, resource)) + { + return true; + } + } + + return false; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(CoreResourceRegistry), + "Resources cannot be registered after the Cheat Engine client lifecycle has stopped."); + } + } + + private readonly record struct ResourceEntry(IDisposable Resource, long? TargetSelectionEpoch); +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs b/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs new file mode 100644 index 0000000..56ecb06 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/ICoreLifetimeContext.cs @@ -0,0 +1,24 @@ +namespace CheatEngine.Client.Core.Infrastructure; + +internal interface ICoreLifetimeContext +{ + public long Epoch + { + get; + } + + public CancellationToken Stopping + { + get; + } + + public bool IsCurrent + { + get; + } + + public bool IsMainThread + { + get; + } +} diff --git a/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs b/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs new file mode 100644 index 0000000..0ecb6a2 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Infrastructure/TargetSelectionLifetime.cs @@ -0,0 +1,128 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Infrastructure; + +/// +/// Owns the independently advancing target-selection epoch for one plugin activation. It deliberately does not +/// change the SDK activation epoch: selecting another process invalidates target-bound leases, not the client. +/// +internal sealed class TargetSelectionLifetime(Action activationGuard) : IDisposable +{ + private readonly Action _activationGuard = + activationGuard ?? throw new ArgumentNullException(nameof(activationGuard)); + + private readonly Lock _gate = new(); + private readonly CoreResourceRegistry _resources = new(); + private bool _disposed; + private long _epoch; + + /// Gets the current target-selection epoch, starting at zero for each activation. + internal long Epoch => Volatile.Read(ref _epoch); + + /// Releases every target-bound resource that remains at activation shutdown. + public void Dispose() + { + IDisposable[] resources; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + resources = _resources.DetachAll(); + } + + CoreResourceRegistry.DisposeDetached(resources); + } + + /// + /// Advances the target selection after a PID or architecture change and releases the old selection's + /// client-owned leases in reverse creation order. + /// + internal long Advance(string operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + + IDisposable[] staleResources; + long nextEpoch; + lock (_gate) + { + _activationGuard(operation); + ThrowIfDisposed(); + long staleEpoch = _epoch; + nextEpoch = checked(staleEpoch + 1); + Volatile.Write(ref _epoch, nextEpoch); + staleResources = _resources.DetachTargetSelection(staleEpoch); + } + + CoreResourceRegistry.DisposeDetached(staleResources); + return nextEpoch; + } + + /// Throws when a target-bound lease belongs to an earlier target selection. + internal void ThrowIfExpired(long capturedEpoch, string operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + + lock (_gate) + { + _activationGuard(operation); + ThrowIfDisposed(); + if (capturedEpoch == _epoch) + { + return; + } + + throw new CheatEngineClientLifecycleException(operation, + "The target process selection changed, so this resource is no longer valid."); + } + } + + /// Registers a disposable resource against the current target selection. + internal T Track(T resource, long capturedEpoch) + where T : class, IDisposable + { + ArgumentNullException.ThrowIfNull(resource); + + lock (_gate) + { + ThrowIfExpiredCore(capturedEpoch, "TargetSelection.Track"); + return _resources.Track(resource, capturedEpoch); + } + } + + /// Removes a resource after its owner released it explicitly. + internal bool Untrack(IDisposable resource) + { + ArgumentNullException.ThrowIfNull(resource); + + lock (_gate) + { + return _resources.Untrack(resource); + } + } + + private void ThrowIfExpiredCore(long capturedEpoch, string operation) + { + _activationGuard(operation); + ThrowIfDisposed(); + if (capturedEpoch == _epoch) + { + return; + } + + throw new CheatEngineClientLifecycleException(operation, + "The target process selection changed, so this resource is no longer valid."); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(TargetSelectionLifetime), + "Target-bound resources cannot be registered after the Cheat Engine client lifecycle has stopped."); + } + } +} diff --git a/libs/CheatEngine.Client.Core/PublicAPI.Shipped.txt b/libs/CheatEngine.Client.Core/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/libs/CheatEngine.Client.Core/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.Client.Core/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Core/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/libs/CheatEngine.Client.Core/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.Client.Core/README.md b/libs/CheatEngine.Client.Core/README.md new file mode 100644 index 0000000..0dc9ee5 --- /dev/null +++ b/libs/CheatEngine.Client.Core/README.md @@ -0,0 +1,97 @@ +# CheatEngine.Client.Core + +## Context + +`CheatEngine.Client.Core` is the SDK-facing implementation layer of `CheatEngine.Client`. It maps +the public contracts from `CheatEngine.Client.Abstractions` onto `CheatEngine.SDK` 1.x while a +Cheat Engine plugin activation is current. + +This package contains the main-thread dispatcher adapter, runtime probes, target-selection +tracking, typed memory and AOB adapters, copied inspection/table operations, protected Lua +operations, and lifecycle-owned cleanup infrastructure. It is an in-process layer for local, +authorized targets; it is not an IPC client or a standalone Cheat Engine host. + +## Why This Project Exists + +The Client must use the SDK directly for its real runtime work, but its SDK-specific ownership and +Lua details must not become application-level implementation concerns. Core provides that boundary: +it translates stable contracts to SDK calls, normalizes expected failures, and owns the rules that +make an enable/disable/re-enable cycle safe. + +Core references `CheatEngine.Client.Abstractions` and the full SDK package. It never references +`CheatEngine.Client.Fluent`. The Dependency Injection and Hosting packages compose Core into the +activation-scoped `ICheatEngineClient`; applications should obtain that facade through hosting or +DI instead of constructing Core services. + +```text +Abstractions ← Core ← DependencyInjection ← Hosting + ↑ + CheatEngine.SDK + +Abstractions ← Fluent +``` + +## How It Improves CheatEngine.Client + +- Sends every Cheat Engine operation through the synchronous SDK main-thread dispatcher. +- Captures an SDK activation epoch and rejects stale work instead of letting a resource survive a + disable/re-enable boundary. +- Closes ordinary work admission during disable, then drains Client-owned resources on the CE main + thread in reverse creation order while the SDK context is still valid. +- Separates the plugin epoch from the selected-target epoch so target changes invalidate only + target-bound resources. +- Copies SDK-owned scan/list data into managed values before releasing the owner; the public API + never leaks SDK Lua state, CE objects, or `Owned` wrappers. +- Maps predictable host failures to `CheatEngineFailure` and preserves ordinary .NET exceptions + for programming errors. + +## Ownership and Delivery Boundary + +Core is a delivery package, not a second public facade. Its concrete service implementations and +infrastructure are internal; the supported public contracts remain in functional namespaces from +the Abstractions package such as `CheatEngine.Client.Memory`, `.Scanning`, `.Tables`, `.Lua`, and +`.Runtime`. Do not add consumer namespaces such as `CheatEngine.Client.Core`. + +The package enables neither an SDK plugin entry point nor dynamic loading on its own. The final +plugin project must directly reference both `CheatEngine.Client` and `CheatEngine.SDK` so that the +SDK generator, build assets, native Lua bridge, and host bootstrap execute at the actual plugin +boundary. + +## Capability Status and Limits + +Core implements the currently qualified Client mappings for runtime facts and capabilities, +process selection, typed memory, bounded pointer chains and strings, AOB scans, copied inspection, +Address List operations, trusted table paths, and protected typed Lua work. All CE calls remain +synchronous; a cancellation token is observed before dispatch or between Client-managed steps, not +as an interruption of an already-running Lua primitive. + +Value scan creation is deliberately unavailable. Although the public session contract and state +machine are present, CheatEngine.SDK 1.0.0 does not expose a public owner factory for the +`MemScan` and `FoundList` instances that Client would need. Core reports a capability failure +rather than making an unverified ownership assumption. It must not be documented or released as +a live value-scanning implementation until the Cheat Engine 7.7 x64 creation, destruction, +disable, and reactivation gate passes. + +UI/forms, debugger and breakpoints, Auto Assembler, injection, public remote allocations, +structures, hotkeys/timers, speedhack, DBVM, Mono/IL2CPP, and ABI hooks are outside this layer's +current delivered capability. + +## Contribution and Validation + +Treat lifetime, dispatch, and disposal changes as host-safety changes. Keep SDK handles internal, +route new CE work through the dispatcher, add a capability observation for optional bindings, and +test target/activation invalidation and reverse-order cleanup in +`tests/CheatEngine.Client.Core.Tests`. + +Core's direct public baseline is intentionally empty; changes that create a public type require an +explicit product-surface decision and corresponding `PublicAPI.Unshipped.txt` update. Validate the +repository from its root: + +```powershell +dotnet restore CheatEngine.Client.slnx --locked-mode +dotnet build CheatEngine.Client.slnx --configuration Release --no-restore +dotnet test --solution CheatEngine.Client.slnx --configuration Release --no-build --no-restore +``` + +The ordinary test suite uses SDK-facing ports/fakes and does not replace the opt-in live Cheat +Engine qualification gate. diff --git a/libs/CheatEngine.Client.Core/packages.lock.json b/libs/CheatEngine.Client.Core/packages.lock.json new file mode 100644 index 0000000..48b14cb --- /dev/null +++ b/libs/CheatEngine.Client.Core/packages.lock.json @@ -0,0 +1,60 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "CheatEngine.SDK": { + "type": "Direct", + "requested": "[1.0.0, 2.0.0)", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + } + } + } +} diff --git a/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..a817f6d --- /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 +{ + /// Gets the default configuration section used by plugin hosting. + public const string ConfigurationSectionName = "CheatEngineClient"; + + /// Initializes the default table-file policy, which denies table-file access until roots are configured. + public CheatEngineClientOptions() + { + } + + /// Gets 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..171567f --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientOptionsSemanticValidator.cs @@ -0,0 +1,55 @@ +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..74b4704 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs @@ -0,0 +1,164 @@ +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()); + } +} 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..f5234b4 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/README.md @@ -0,0 +1,64 @@ +# 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/UnsafeLuaExecutionRegistration.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs new file mode 100644 index 0000000..db0656e --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/UnsafeLuaExecutionRegistration.cs @@ -0,0 +1,10 @@ +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Records the builder-only opt-in required to enable unsafe Lua for an activation. +internal sealed class UnsafeLuaExecutionRegistration +{ + internal bool IsEnabled + { + get; + } = true; +} diff --git a/libs/CheatEngine.Client.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..0c9f4b5 --- /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..4ad5b91 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs @@ -0,0 +1,221 @@ +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..ae53709 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..84eafec --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/CheatEngineClientPlugin.cs @@ -0,0 +1,327 @@ +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() + { + return 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..7722e43 --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/README.md @@ -0,0 +1,81 @@ +# 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..c7ce726 100644 --- a/src/CheatEngine.Client/README.md +++ b/src/CheatEngine.Client/README.md @@ -1,11 +1,57 @@ # CheatEngine.Client -The single project a consumer references. It is the composition root of CheatEngine.Client: it wires -`CheatEngine.Client.Fluent` and `CheatEngine.Client.Binding` behind the `CheatEngine.Client.Abstractions` contracts and -holds no logic of its own. +## 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..c26cb16 --- /dev/null +++ b/templates/CheatEngine.Client.Templates/README.md @@ -0,0 +1,58 @@ +# 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`. + +## 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.Abstractions.Tests/Inspection/InspectionContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs index 93a05a1..7d0b147 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Inspection/InspectionContractsTests.cs @@ -1,7 +1,7 @@ using CheatEngine.Client.Inspection; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Inspection; +namespace CheatEngine.Client.Abstractions.Tests.Inspection; public sealed class InspectionContractsTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs index ee1ec46..905ac19 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs @@ -4,7 +4,7 @@ using CheatEngine.Client.Lua; using CheatEngine.Client.Results; -namespace CheatEngine.Client.Tests.Lua; +namespace CheatEngine.Client.Abstractions.Tests.Lua; public sealed class LuaContractTests { @@ -65,27 +65,27 @@ public void InterfaceExecutionInfersTheOperationResultTypeAndForwardsSuccess() int executeResult = client.Execute(operation, TestContext.Current.CancellationToken); bool tryExecuteSucceeded = client.TryExecute( operation, - out var tryExecuteResult, + out int tryExecuteResult, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.Equal(42, executeResult); Assert.True(tryExecuteSucceeded); Assert.Equal(42, tryExecuteResult); - Assert.Equal(default(CheatEngineFailure), failure); + Assert.Equal(default, failure); Assert.Equal(2, operation.ExecutionCount); } private static IEnumerable GetPublicSignatureTypes(Type type) { foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | - BindingFlags.Static)) + BindingFlags.Static)) { yield return property.PropertyType; } foreach (MethodInfo method in - type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) { yield return method.ReturnType; foreach (ParameterInfo parameter in method.GetParameters()) diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs index 47321d6..da7f651 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs @@ -1,7 +1,7 @@ using CheatEngine.Client.Memory; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Memory; +namespace CheatEngine.Client.Abstractions.Tests.Memory; public sealed class MemoryBoundedRequestTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs index 73f51c6..0b0a0ba 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs @@ -2,7 +2,7 @@ using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Runtime; -namespace CheatEngine.Client.Tests.Processes; +namespace CheatEngine.Client.Abstractions.Tests.Processes; public sealed class ProcessSnapshotTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs index 94bf17c..d8b44a3 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Results/CheatEngineFailureTests.cs @@ -1,6 +1,6 @@ using CheatEngine.Client.Results; -namespace CheatEngine.Client.Tests.Results; +namespace CheatEngine.Client.Abstractions.Tests.Results; public sealed class CheatEngineFailureTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs index 9124969..2f0dc58 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/CheatEngineRuntimeSnapshotTests.cs @@ -3,7 +3,7 @@ using CheatEngine.Client.Runtime; using CheatEngine.SDK.Engine.Runtime; -namespace CheatEngine.Client.Tests.Runtime; +namespace CheatEngine.Client.Abstractions.Tests.Runtime; public sealed class CheatEngineRuntimeSnapshotTests { @@ -27,7 +27,8 @@ public void SnapshotForwardsVersionAndPlatformComponentsToItsExistingLeafPropert { Version clientAssemblyVersion = new(0, 1, 2, 3); Version sdkAssemblyVersion = new(1, 2, 3, 4); - CheatEngineRuntimeVersionInfo version = new(7.7d, CheatEngineVersion.Ce77010621, clientAssemblyVersion, sdkAssemblyVersion); + CheatEngineRuntimeVersionInfo version = new(7.7d, CheatEngineVersion.Ce77010621, clientAssemblyVersion, + sdkAssemblyVersion); CheatEngineRuntimePlatformInfo platform = new( CheatEngineArchitecture.X64, CheatEngineArchitecture.X86, @@ -66,11 +67,12 @@ public void VersionInfoRejectsInvalidObservedCeVersion(double observedVersion) [Fact] public void VersionInfoRejectsNullAssemblyVersions() { - ArgumentNullException clientVersion = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( - 7.7d, - CheatEngineVersion.Ce77010621, - Null(), - new Version(1, 0, 0))); + ArgumentNullException clientVersion = Assert.Throws(() => + new CheatEngineRuntimeVersionInfo( + 7.7d, + CheatEngineVersion.Ce77010621, + Null(), + new Version(1, 0, 0))); ArgumentNullException sdkVersion = Assert.Throws(() => new CheatEngineRuntimeVersionInfo( 7.7d, CheatEngineVersion.Ce77010621, @@ -105,18 +107,20 @@ public void SnapshotRejectsNegativeActivationEpoch(long epoch) [Fact] public void SnapshotRejectsNullCapabilityCollections() { - ArgumentNullException sdkCapabilities = Assert.Throws(() => new CheatEngineRuntimeSnapshot( - 42, - CreateVersionInfo(), - CreatePlatformInfo(), - Null(), - ClientCapabilities.Empty)); - ArgumentNullException clientCapabilities = Assert.Throws(() => new CheatEngineRuntimeSnapshot( - 42, - CreateVersionInfo(), - CreatePlatformInfo(), - RuntimeCapabilities.Empty, - Null())); + ArgumentNullException sdkCapabilities = Assert.Throws(() => + new CheatEngineRuntimeSnapshot( + 42, + CreateVersionInfo(), + CreatePlatformInfo(), + Null(), + ClientCapabilities.Empty)); + ArgumentNullException clientCapabilities = Assert.Throws(() => + new CheatEngineRuntimeSnapshot( + 42, + CreateVersionInfo(), + CreatePlatformInfo(), + RuntimeCapabilities.Empty, + Null())); Assert.Equal("sdkCapabilities", sdkCapabilities.ParamName); Assert.Equal("clientCapabilities", clientCapabilities.ParamName); @@ -195,5 +199,8 @@ private static CheatEngineRuntimePlatformInfo CreatePlatformInfo() TargetAbi.Windows); } - private static T Null() where T : class => default!; + private static T Null() where T : class + { + return default!; + } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs index f94b68c..9e88d6f 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs @@ -1,6 +1,6 @@ using CheatEngine.Client.Runtime; -namespace CheatEngine.Client.Tests.Runtime; +namespace CheatEngine.Client.Abstractions.Tests.Runtime; public sealed class ClientCapabilitiesTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs index 48cfe0a..124dd1b 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/AobPatternTests.cs @@ -3,7 +3,7 @@ using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Scanning.Aob; -namespace CheatEngine.Client.Tests.Scanning; +namespace CheatEngine.Client.Abstractions.Tests.Scanning; public sealed class AobPatternTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs index 1f29218..b98728a 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Scanning/ValueScanContractTests.cs @@ -3,7 +3,7 @@ using CheatEngine.Client.Scanning; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Scanning; +namespace CheatEngine.Client.Abstractions.Tests.Scanning; public sealed class ValueScanContractTests { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs index 393d632..844d985 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Tables/TableContractTests.cs @@ -5,7 +5,7 @@ using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Values; -namespace CheatEngine.Client.Tests.Tables; +namespace CheatEngine.Client.Abstractions.Tests.Tables; public sealed class TableContractTests { @@ -142,11 +142,12 @@ public void ContentSnapshotRejectsNullRequiredValues() "game.exe+20", "100", VariableType.Dword)); - ArgumentNullException addressExpression = Assert.Throws(() => new MemoryRecordContentSnapshot( - "Health", - Null(), - "100", - VariableType.Dword)); + ArgumentNullException addressExpression = Assert.Throws(() => + new MemoryRecordContentSnapshot( + "Health", + Null(), + "100", + VariableType.Dword)); ArgumentNullException value = Assert.Throws(() => new MemoryRecordContentSnapshot( "Health", "game.exe+20", @@ -167,10 +168,7 @@ public void HierarchySnapshotNormalizesDefaultChildrenDuringConstructionAndWithU 0, CreateContent(), CreateState()), default); - MemoryRecordHierarchySnapshot updatedHierarchy = hierarchy with - { - Children = default - }; + MemoryRecordHierarchySnapshot updatedHierarchy = hierarchy with { Children = default }; MemoryRecordHierarchySnapshot uninitializedHierarchy = default; Assert.False(hierarchy.Children.IsDefault); @@ -250,10 +248,14 @@ private static MemoryRecordContentSnapshot CreateContent() return new MemoryRecordContentSnapshot("Health", "game.exe+20", "100", VariableType.Dword); } - private static MemoryRecordStateSnapshot CreateState(Address? currentAddress = null, bool isActive = false, int childCount = 0) + private static MemoryRecordStateSnapshot CreateState(Address? currentAddress = null, bool isActive = false, + int childCount = 0) { return new MemoryRecordStateSnapshot(currentAddress, isActive, childCount); } - private static T Null() where T : class => default!; + private static T Null() where T : class + { + return default!; + } } diff --git a/tests/CheatEngine.Client.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.Core.Tests/CheatEngine.Client.Core.Tests.csproj b/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj new file mode 100644 index 0000000..7d2fc0f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/CheatEngine.Client.Core.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs new file mode 100644 index 0000000..3e68585 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs @@ -0,0 +1,59 @@ +using System.Reflection; + +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +namespace CheatEngine.Client.Core.Tests.Composition; + +public sealed class CheatEngineClientTests +{ + [Fact] + public void ExposesTheRuntimeAndDomainServicesFromItsActivationComposition() + { + ICheatEngineRuntime runtime = CreateProxy(); + ICheatEngineDispatcher dispatcher = CreateProxy(); + IProcessClient processes = CreateProxy(); + IMemoryClient memory = CreateProxy(); + IPatternScanner patterns = CreateProxy(); + IValueScanner scans = CreateProxy(); + IInspectionClient inspection = CreateProxy(); + ITableClient tables = CreateProxy(); + ILuaClient lua = CreateProxy(); + + CheatEngineClient client = new( + InertCoreLifetime.Create(), + new CheatEngineClientRuntimeServices(runtime, dispatcher), + new CheatEngineClientDomainServices(processes, memory, patterns, scans, inspection, tables, lua)); + + Assert.Same(runtime, client.Runtime); + Assert.Same(dispatcher, client.Dispatcher); + Assert.Same(processes, client.Processes); + Assert.Same(memory, client.Memory); + Assert.Same(patterns, client.Patterns); + Assert.Same(scans, client.Scans); + Assert.Same(inspection, client.Inspection); + Assert.Same(tables, client.Tables); + Assert.Same(lua, client.Lua); + } + + private static T CreateProxy() + where T : class + { + return DispatchProxy.Create(); + } + + public class ThrowingProxy : DispatchProxy + { + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + throw new NotSupportedException("The façade test must not invoke a composed service."); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs new file mode 100644 index 0000000..043d3bd --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs @@ -0,0 +1,144 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Dispatching; + +public sealed class SdkMainThreadDispatcherBehaviorTests +{ + [Fact] + public void TryInvokeExecutesActionAndGenericCallbacksThroughTheInjectedMainThreadInvoker() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + RecordingMainThreadInvoker invoker = new(); + SdkMainThreadDispatcher dispatcher = new(lifetime, invoker); + bool callbackRan = false; + + bool actionSucceeded = dispatcher.TryInvoke(() => callbackRan = true, out CheatEngineFailure actionFailure, + TestContext.Current.CancellationToken); + bool functionSucceeded = dispatcher.TryInvoke(static () => 42, out int result, + out CheatEngineFailure functionFailure, TestContext.Current.CancellationToken); + + Assert.True(actionSucceeded); + Assert.True(callbackRan); + Assert.Equal(default, actionFailure); + Assert.True(functionSucceeded); + Assert.Equal(42, result); + Assert.Equal(default, functionFailure); + Assert.Equal(1, invoker.ActionCalls); + Assert.Equal(1, invoker.FunctionCalls); + } + + [Fact] + public void CallbackExceptionsAreRethrownWithoutBeingClassifiedAsHostFailures() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, new RecordingMainThreadInvoker()); + + InvalidOperationException actionException = Assert.Throws(() => + dispatcher.TryInvoke(static () => throw new InvalidOperationException("action callback"), + out CheatEngineFailure _, TestContext.Current.CancellationToken)); + InvalidOperationException functionException = Assert.Throws(() => + dispatcher.TryInvoke(static () => throw new InvalidOperationException("function callback"), out _, + out CheatEngineFailure _, TestContext.Current.CancellationToken)); + + Assert.Equal("action callback", actionException.Message); + Assert.Equal("function callback", functionException.Message); + } + + [Fact] + public void TryInvokeMapsMainThreadInfrastructureFailuresToStableClientFailures() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime, + new RecordingMainThreadInvoker { HostException = new InvalidOperationException("host queue unavailable") }); + + bool succeeded = dispatcher.TryInvoke(static () => + { + }, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Dispatcher.Invoke", failure.Operation); + Assert.Equal("host queue unavailable", failure.Message); + } + + [Fact] + public void ConstructorRejectsMissingLifecycleOrMainThreadInvoker() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + RecordingMainThreadInvoker invoker = new(); + + ArgumentNullException lifetimeException = Assert.Throws(() => + new SdkMainThreadDispatcher(null!, invoker)); + ArgumentNullException invokerException = Assert.Throws(() => + new SdkMainThreadDispatcher(lifetime, null!)); + + Assert.Equal("lifetime", lifetimeException.ParamName); + Assert.Equal("mainThread", invokerException.ParamName); + } + + private sealed class RecordingMainThreadInvoker : IMainThreadInvoker + { + internal int ActionCalls + { + get; + private set; + } + + internal int FunctionCalls + { + get; + private set; + } + + internal Exception? HostException + { + get; + init; + } + + public Exception? Invoke(Action callback) + { + ActionCalls++; + if (HostException is not null) + { + throw HostException; + } + + try + { + callback(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + public MainThreadInvocationResult Invoke(Func callback) + { + FunctionCalls++; + if (HostException is not null) + { + throw HostException; + } + + try + { + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs new file mode 100644 index 0000000..4bdcee8 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherTests.cs @@ -0,0 +1,68 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Dispatching; + +public sealed class SdkMainThreadDispatcherTests +{ + [Fact] + public void TryInvokeRejectsNullCallbacksBeforeEnteringThePluginDispatchGate() + { + SdkMainThreadDispatcher dispatcher = new(InertCoreLifetime.Create()); + + ArgumentNullException actionException = Assert.Throws(() => + dispatcher.TryInvoke(null!, out CheatEngineFailure _, TestContext.Current.CancellationToken)); + ArgumentNullException functionException = Assert.Throws(() => + dispatcher.TryInvoke(null!, out _, out CheatEngineFailure _, TestContext.Current.CancellationToken)); + + Assert.Equal("callback", actionException.ParamName); + Assert.Equal("callback", functionException.ParamName); + } + + [Fact] + public void TryInvokeReportsCancellationBeforeCallingTheSdkDispatcher() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = dispatcher.TryInvoke(static () => throw new InvalidOperationException("must not run"), + out CheatEngineFailure failure, cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal("Dispatcher.Invoke", failure.Operation); + } + + [Fact] + public void InvokeConvertsARejectedDispatchToThePublicFailureException() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + CheatEngineOperationException exception = Assert.Throws(() => + dispatcher.Invoke(static () => + { + }, cancellation.Token)); + + Assert.Equal(CheatEngineFailureKind.Cancelled, exception.Failure.Kind); + Assert.Equal("Dispatcher.Invoke", exception.Failure.Operation); + } + + [Fact] + public void MainThreadPropertyRemainsFalseOutsideTheActualSdkMainThread() + { + using ControlledCoreLifetimeContext context = new() { IsMainThread = true }; + using CoreLifetime lifetime = new(context); + SdkMainThreadDispatcher dispatcher = new(lifetime); + + Assert.False(dispatcher.IsMainThread); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs new file mode 100644 index 0000000..0c9b602 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs @@ -0,0 +1,285 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class InspectionClientBehaviorTests +{ + [Fact] + public void GetModulesCopiesOnlyTheWrittenEntriesAndUsesTheRequestedProcessOverload() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() { ModulesWritten = 1 }; + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryGetModules(new InspectionCollectionRequest(2), out ImmutableArray modules, + out CheatEngineFailure failure, new TargetProcessId(42), TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Single(modules); + Assert.Equal(2, port.LastModuleBufferLength); + Assert.Equal(new TargetProcessId(42), port.LastModuleProcessId); + Assert.Equal(0, port.CurrentProcessModuleCalls); + Assert.Equal(1, port.ExplicitProcessModuleCalls); + } + + [Theory] + [InlineData(InspectionStatus.NotFound, CheatEngineFailureKind.NotFound)] + [InlineData(InspectionStatus.DestinationTooSmall, CheatEngineFailureKind.ResultLimitExceeded)] + [InlineData(InspectionStatus.GlobalUnavailable, CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData(InspectionStatus.LuaFailure, CheatEngineFailureKind.LuaError)] + [InlineData(InspectionStatus.InvalidResult, CheatEngineFailureKind.InvalidHostResult)] + [InlineData((InspectionStatus) 999, CheatEngineFailureKind.Unknown)] + public void InspectionStatusesMapToStableClientFailures(InspectionStatus status, CheatEngineFailureKind expectedKind) + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() { MemoryRegionStatus = status }; + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryGetMemoryRegion(new Address(0x1234), out MemoryRegionInfo region, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, region); + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal("Inspection.GetMemoryRegion", failure.Operation); + } + + [Fact] + public void CollectionAndScalarQueriesForwardInputsAndCopyOnlyTheWrittenEntries() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() { SectionsWritten = 1, RegionsWritten = 1, ResolvedAddress = new Address(0xC0FFEE) }; + InspectionClient client = CreateClient(lifetime, port); + ModuleName module = new("fixture.exe"); + SymbolExpression symbol = new("fixture+10"); + AddressResolutionOptions options = new(true, true); + + Assert.True(client.TryGetModuleSections(module, new InspectionCollectionRequest(2), + out ImmutableArray sections, out CheatEngineFailure sectionsFailure, + TestContext.Current.CancellationToken)); + Assert.True(client.TryGetMemoryRegions(new InspectionCollectionRequest(2), + out ImmutableArray regions, out CheatEngineFailure regionsFailure, + TestContext.Current.CancellationToken)); + Assert.True(client.TryGetSymbol(symbol, out SymbolInfo returnedSymbol, out CheatEngineFailure symbolFailure, + TestContext.Current.CancellationToken)); + Assert.True(client.TryResolveAddress(symbol, options, out Address resolved, out CheatEngineFailure addressFailure, + TestContext.Current.CancellationToken)); + + Assert.Single(sections); + Assert.Single(regions); + Assert.Equal(default, sectionsFailure); + Assert.Equal(default, regionsFailure); + Assert.Equal(default, returnedSymbol); + Assert.Equal(default, symbolFailure); + Assert.Equal(new Address(0xC0FFEE), resolved); + Assert.Equal(default, addressFailure); + Assert.Equal(module, port.LastSectionModule); + Assert.Equal(2, port.LastSectionBufferLength); + Assert.Equal(2, port.LastRegionBufferLength); + Assert.Equal(symbol, port.LastSymbolExpression); + Assert.Equal(symbol, port.LastAddressExpression); + Assert.Equal(options, port.LastAddressOptions); + } + + [Theory] + [InlineData(true, "playerHealth", true, CheatEngineFailureKind.Unknown)] + [InlineData(false, null, false, CheatEngineFailureKind.NotFound)] + [InlineData(true, null, false, CheatEngineFailureKind.InvalidHostResult)] + public void ResolveNameDistinguishesANameFromNotFoundAndInvalidHostResults(bool resolveName, string? name, + bool expectedSuccess, CheatEngineFailureKind expectedKind) + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new() { ResolveNameResult = resolveName, ResolvedName = name }; + InspectionClient client = CreateClient(lifetime, port); + + bool succeeded = client.TryResolveName(new Address(0x1234), out string? result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.Equal(expectedSuccess, succeeded); + Assert.Equal(name, result); + Assert.Equal(new nuint(0x1234), port.LastNameAddress); + if (expectedSuccess) + { + Assert.Equal(default, failure); + } + else + { + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal("Inspection.ResolveName", failure.Operation); + } + } + + [Fact] + public void RegisterSymbolReservesOneNameAndReleasesItWithTheLease() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + SymbolRegistration registration = new("fixture-symbol", new Address(0x401000), true); + + Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? lease, + out CheatEngineFailure firstFailure, TestContext.Current.CancellationToken)); + Assert.False(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? duplicate, + out CheatEngineFailure duplicateFailure, TestContext.Current.CancellationToken)); + Assert.NotNull(lease); + Assert.Null(duplicate); + Assert.Equal(default, firstFailure); + Assert.Equal(CheatEngineFailureKind.OperationRejected, duplicateFailure.Kind); + Assert.Equal(1, port.RegisterCalls); + Assert.Equal("fixture-symbol", port.LastRegisteredName); + Assert.Equal(new nuint(0x401000), port.LastRegisteredAddress); + Assert.True(port.LastRegisteredDoNotSave); + + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["fixture-symbol"], port.UnregisteredNames); + Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? retry, + out CheatEngineFailure retryFailure, TestContext.Current.CancellationToken)); + Assert.NotNull(retry); + Assert.Equal(default, retryFailure); + retry.Dispose(); + } + + [Fact] + public void CancelledInspectionDoesNotContactTheSdkPort() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + FakeInspectionPort port = new(); + InspectionClient client = CreateClient(lifetime, port); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = client.TryGetModules(new InspectionCollectionRequest(1), out ImmutableArray modules, + out CheatEngineFailure failure, cancellationToken: cancellation.Token); + + Assert.False(succeeded); + Assert.Empty(modules); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(0, port.CurrentProcessModuleCalls); + Assert.Equal(0, port.ExplicitProcessModuleCalls); + } + + private static InspectionClient CreateClient(CoreLifetime lifetime, IInspectionPort port) + { + return new InspectionClient(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime, port); + } + + private sealed class FakeInspectionPort : IInspectionPort + { + internal int CurrentProcessModuleCalls { get; private set; } + internal int ExplicitProcessModuleCalls { get; private set; } + internal int LastModuleBufferLength { get; private set; } + internal TargetProcessId? LastModuleProcessId { get; private set; } + internal int SectionsWritten { get; init; } + internal int RegionsWritten { get; init; } + internal int LastSectionBufferLength { get; private set; } + internal int LastRegionBufferLength { get; private set; } + internal ModuleName LastSectionModule { get; private set; } + internal SymbolExpression LastSymbolExpression { get; private set; } + internal SymbolExpression LastAddressExpression { get; private set; } + internal AddressResolutionOptions LastAddressOptions { get; private set; } + internal InspectionStatus ModulesStatus { get; init; } = InspectionStatus.Success; + internal InspectionStatus MemoryRegionStatus { get; init; } = InspectionStatus.Success; + internal Address ResolvedAddress { get; init; } + internal bool ResolveNameResult { get; init; } + internal string? ResolvedName { get; init; } + internal nuint LastNameAddress { get; private set; } + internal int RegisterCalls { get; private set; } + internal string? LastRegisteredName { get; private set; } + internal nuint LastRegisteredAddress { get; private set; } + internal bool LastRegisteredDoNotSave { get; private set; } + internal List UnregisteredNames { get; } = []; + + public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) + { + CurrentProcessModuleCalls++; + LastModuleBufferLength = destination.Length; + written = ModulesWritten; + return ModulesStatus; + } + + public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] destination, out int written) + { + ExplicitProcessModuleCalls++; + LastModuleProcessId = processId; + LastModuleBufferLength = destination.Length; + written = ModulesWritten; + return ModulesStatus; + } + + public int ModulesWritten { get; init; } + + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, out int written) + { + LastSectionModule = moduleName; + LastSectionBufferLength = destination.Length; + written = SectionsWritten; + return InspectionStatus.Success; + } + + public InspectionStatus EnumerateMemoryRegions(MemoryRegionInfo[] destination, out int written) + { + LastRegionBufferLength = destination.Length; + written = RegionsWritten; + return InspectionStatus.Success; + } + + public InspectionStatus GetMemoryRegion(Address address, out MemoryRegionInfo region) + { + region = default; + return MemoryRegionStatus; + } + + public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo symbol) + { + LastSymbolExpression = expression; + symbol = default; + return InspectionStatus.Success; + } + + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, out Address address) + { + LastAddressExpression = expression; + LastAddressOptions = options; + address = ResolvedAddress; + return InspectionStatus.Success; + } + + public bool TryResolveName(nuint address, out string? name) + { + LastNameAddress = address; + name = ResolvedName; + return ResolveNameResult; + } + + public void RegisterSymbol(string name, nuint address, bool doNotSave) + { + RegisterCalls++; + LastRegisteredName = name; + LastRegisteredAddress = address; + LastRegisteredDoNotSave = doNotSave; + } + + public void UnregisterSymbol(string name) + { + UnregisteredNames.Add(name); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientCoverageTests.cs new file mode 100644 index 0000000..142c531 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientCoverageTests.cs @@ -0,0 +1,28 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class InspectionClientCoverageTests +{ + [Fact] + public void ConstructorRejectsANullDispatcher() + { + ArgumentNullException exception = Assert.Throws(() => + new InspectionClient(null!, InertCoreLifetime.Create())); + + Assert.Equal("dispatcher", exception.ParamName); + } + + [Fact] + public void ConstructorRejectsANullLifetime() + { + SdkMainThreadDispatcher dispatcher = new(InertCoreLifetime.Create()); + + ArgumentNullException exception = Assert.Throws(() => + new InspectionClient(dispatcher, null!)); + + Assert.Equal("lifetime", exception.ParamName); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs new file mode 100644 index 0000000..99835e8 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; + +using CheatEngine.Client.Core.Domains; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class LocalProcessHostTests +{ + [Fact] + public void TryGetLocalProcessCopiesMetadataForTheCurrentManagedProcess() + { + using Process current = Process.GetCurrentProcess(); + LocalProcessHost host = new(); + + bool succeeded = host.TryGetLocalProcess(current.Id, out LocalProcessInfo process); + + Assert.True(succeeded); + Assert.Equal(current.Id, process.Id); + Assert.Equal(current.ProcessName, process.Name); + } + + [Fact] + public void TryGetLocalProcessReturnsFalseForAnInvalidProcessId() + { + LocalProcessHost host = new(); + + bool succeeded = host.TryGetLocalProcess(-1, out LocalProcessInfo process); + + Assert.False(succeeded); + Assert.Equal(default, process); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs new file mode 100644 index 0000000..e2e9a99 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/LuaRuntimeProbeTests.cs @@ -0,0 +1,17 @@ +using CheatEngine.Client.Core.Domains; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class LuaRuntimeProbeTests +{ + [Fact] + public void GetCheatEngineVersionRequiresAnEnabledPluginContext() + { + LuaRuntimeProbe probe = new(); + + InvalidOperationException exception = + Assert.Throws(() => probe.GetCheatEngineVersion()); + + Assert.Contains("plugin is not enabled", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs new file mode 100644 index 0000000..456f02e --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientBehaviorCoverageTests.cs @@ -0,0 +1,295 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class MemoryClientBehaviorCoverageTests +{ + private static readonly Address _address = new(0x405000); + + [Fact] + public void TypedReadForwardsCancellationAndReturnsTheCodecValue() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + RecordingDispatcher dispatcher = new(); + ProbeCodec codec = new() { ReadValue = 1337 }; + MemoryClient client = new(dispatcher); + + bool succeeded = client.TryRead(new MemoryReadRequest(_address, codec), out int value, + out CheatEngineFailure failure, cancellationToken); + + Assert.True(succeeded); + Assert.Equal(1337, value); + Assert.Equal(default, failure); + Assert.Equal(1, dispatcher.ActionInvocationCount); + Assert.Equal(cancellationToken, dispatcher.LastCancellationToken); + Assert.Equal(1, codec.ReadCount); + Assert.Equal(_address, codec.LastReadAddress); + Assert.NotNull(codec.LastReadContext); + } + + [Fact] + public void TypedWriteForwardsCancellationAndPreservesTheValueForTheCodec() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + RecordingDispatcher dispatcher = new(); + ProbeCodec codec = new(); + MemoryClient client = new(dispatcher); + + bool succeeded = client.TryWrite(new MemoryWriteRequest(_address, 42, codec), + out CheatEngineFailure failure, cancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(1, dispatcher.ActionInvocationCount); + Assert.Equal(cancellationToken, dispatcher.LastCancellationToken); + Assert.Equal(1, codec.WriteCount); + Assert.Equal(_address, codec.LastWriteAddress); + Assert.Equal(42, codec.LastWrittenValue); + Assert.NotNull(codec.LastWriteContext); + } + + [Fact] + public void FailedCodecReadUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt() + { + ProbeCodec codec = new() { ReadSucceeds = false }; + MemoryClient client = new(new RecordingDispatcher()); + MemoryReadRequest request = new(_address, codec); + + bool succeeded = client.TryRead(request, out int value, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + CheatEngineOperationException exception = Assert.Throws(() => + client.Read(request, TestContext.Current.CancellationToken)); + + Assert.False(succeeded); + Assert.Equal(default, value); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, failure.Kind); + Assert.Equal("Memory.Read", failure.Operation); + Assert.Equal("The codec for 'Int32' rejected the target-memory read.", failure.Message); + Assert.Equal(failure, exception.Failure); + Assert.Equal(2, codec.ReadCount); + } + + [Fact] + public void FailedCodecWriteUsesTheFallbackFailureAndTheConvenienceMethodThrowsIt() + { + ProbeCodec codec = new() { WriteSucceeds = false }; + MemoryClient client = new(new RecordingDispatcher()); + MemoryWriteRequest request = new(_address, 77, codec); + + bool succeeded = client.TryWrite(request, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + CheatEngineOperationException exception = Assert.Throws(() => + client.Write(request, TestContext.Current.CancellationToken)); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.MemoryWriteFailed, failure.Kind); + Assert.Equal("Memory.Write", failure.Operation); + Assert.Equal("The codec for 'Int32' rejected the target-memory write.", failure.Message); + Assert.Equal(failure, exception.Failure); + Assert.Equal(2, codec.WriteCount); + Assert.Equal(77, codec.LastWrittenValue); + } + + [Fact] + public void CancelledDispatchPreventsCodecExecutionAndPreservesTheCancellationFailure() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + CancellationToken cancellationToken = cancellation.Token; + CancellationAwareDispatcher dispatcher = new(); + ProbeCodec codec = new(); + MemoryClient client = new(dispatcher); + MemoryReadRequest readRequest = new(_address, codec); + MemoryWriteRequest writeRequest = new(_address, 9, codec); + + bool readSucceeded = client.TryRead(readRequest, out int value, out CheatEngineFailure readFailure, + cancellationToken); + bool writeSucceeded = client.TryWrite(writeRequest, out CheatEngineFailure writeFailure, cancellationToken); + + Assert.False(readSucceeded); + Assert.Equal(default, value); + Assert.Equal(CheatEngineFailureKind.Cancelled, readFailure.Kind); + Assert.Equal("Test.Dispatcher", readFailure.Operation); + Assert.False(writeSucceeded); + Assert.Equal(CheatEngineFailureKind.Cancelled, writeFailure.Kind); + Assert.Equal("Test.Dispatcher", writeFailure.Operation); + Assert.Equal(2, dispatcher.ActionInvocationCount); + Assert.Equal(cancellationToken, dispatcher.LastCancellationToken); + Assert.Equal(0, codec.ReadCount); + Assert.Equal(0, codec.WriteCount); + } + + [Fact] + public void ConstructorRejectsANullDispatcher() + { + Assert.Throws(() => new MemoryClient(null!)); + } + + private sealed class ProbeCodec : IMemoryCodec + { + internal int ReadValue + { + get; + init; + } + + internal bool ReadSucceeds + { + get; + init; + } = true; + + internal bool WriteSucceeds + { + get; + init; + } = true; + + internal int ReadCount + { + get; + private set; + } + + internal int WriteCount + { + get; + private set; + } + + internal Address LastReadAddress + { + get; + private set; + } + + internal Address LastWriteAddress + { + get; + private set; + } + + internal int LastWrittenValue + { + get; + private set; + } + + internal IMemoryReadContext? LastReadContext + { + get; + private set; + } + + internal IMemoryWriteContext? LastWriteContext + { + get; + private set; + } + + public bool TryRead(IMemoryReadContext context, Address address, out int value) + { + LastReadContext = context; + LastReadAddress = address; + ReadCount++; + value = ReadValue; + return ReadSucceeds; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + { + LastWriteContext = context; + LastWriteAddress = address; + LastWrittenValue = value; + WriteCount++; + return WriteSucceeds; + } + } + + private class RecordingDispatcher : ICheatEngineDispatcher + { + internal int ActionInvocationCount + { + get; + private set; + } + + internal CancellationToken LastCancellationToken + { + get; + private set; + } + + public bool IsMainThread => true; + + public virtual bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + RecordActionInvocation(cancellationToken); + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + LastCancellationToken = cancellationToken; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + + protected void RecordActionInvocation(CancellationToken cancellationToken) + { + ActionInvocationCount++; + LastCancellationToken = cancellationToken; + } + } + + private sealed class CancellationAwareDispatcher : RecordingDispatcher + { + public override bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + RecordActionInvocation(cancellationToken); + if (!cancellationToken.IsCancellationRequested) + { + callback(); + failure = default; + return true; + } + + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs new file mode 100644 index 0000000..0c8fd6f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs @@ -0,0 +1,270 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class MemoryClientDispatchFailureTests +{ + private static readonly Address Address = new(0x1234); + + [Fact] + public void PrimitiveAndCodecReadsPreserveTheDispatcherFailureWithoutExecutingTargetMemory() + { + CheatEngineFailure expected = Failure("Test.Read"); + MemoryClient client = new(new RejectingDispatcher(expected)); + MemoryReadRequest request = new(Address, new NeverUsedCodec()); + + bool primitiveSucceeded = client.TryReadPrimitive(Address, out int primitive, + out CheatEngineFailure primitiveFailure, + TestContext.Current.CancellationToken); + bool codecSucceeded = client.TryRead(request, out int value, out CheatEngineFailure codecFailure, + TestContext.Current.CancellationToken); + + Assert.False(primitiveSucceeded); + Assert.Equal(0, primitive); + Assert.Equal(expected, primitiveFailure); + Assert.False(codecSucceeded); + Assert.Equal(0, value); + Assert.Equal(expected, codecFailure); + } + + [Fact] + public void PrimitiveAndCodecWritesPreserveTheDispatcherFailureWithoutExecutingTargetMemory() + { + CheatEngineFailure expected = Failure("Test.Write"); + MemoryClient client = new(new RejectingDispatcher(expected)); + MemoryWriteRequest request = new(Address, 42, new NeverUsedCodec()); + + bool primitiveSucceeded = client.TryWritePrimitive(Address, 42, out CheatEngineFailure primitiveFailure, + TestContext.Current.CancellationToken); + bool codecSucceeded = client.TryWrite(request, out CheatEngineFailure codecFailure, + TestContext.Current.CancellationToken); + + Assert.False(primitiveSucceeded); + Assert.Equal(expected, primitiveFailure); + Assert.False(codecSucceeded); + Assert.Equal(expected, codecFailure); + } + + [Fact] + public void ByteAndStringOperationsPreserveTheDispatcherFailureAndEmptyReadResults() + { + CheatEngineFailure expected = Failure("Test.Copy"); + MemoryClient client = new(new RejectingDispatcher(expected)); + MemoryBytesReadRequest byteRead = new(Address, 2); + MemoryBytesWriteRequest byteWrite = new(Address, [0x10, 0x20]); + MemoryStringReadRequest stringRead = new(Address, 12, true); + MemoryStringWriteRequest stringWrite = new(Address, "health", true); + + Assert.False(client.TryReadBytes(byteRead, out ImmutableArray bytes, + out CheatEngineFailure byteReadFailure, + TestContext.Current.CancellationToken)); + Assert.True(bytes.IsEmpty); + Assert.Equal(expected, byteReadFailure); + Assert.False(client.TryWriteBytes(byteWrite, out CheatEngineFailure byteWriteFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(expected, byteWriteFailure); + Assert.False(client.TryReadString(stringRead, out string? text, out CheatEngineFailure stringReadFailure, + TestContext.Current.CancellationToken)); + Assert.Null(text); + Assert.Equal(expected, stringReadFailure); + Assert.False(client.TryWriteString(stringWrite, out CheatEngineFailure stringWriteFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(expected, stringWriteFailure); + } + + [Fact] + public void PointerResolutionPreservesTheDispatcherFailureAndDefaultAddress() + { + CheatEngineFailure expected = Failure("Test.Pointer"); + MemoryClient client = new(new RejectingDispatcher(expected)); + PointerChainRequest request = new(Address, [4L, 8L]); + + bool succeeded = client.TryResolvePointerChain(request, out Address actual, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, actual); + Assert.Equal(expected, failure); + } + + [Fact] + public void UnsupportedPrimitiveTypesReturnTheSpecificUnsupportedFailureWithoutAccessingTheHost() + { + MemoryClient client = new(new InlineDispatcher()); + + bool readSucceeded = client.TryReadPrimitive(Address, out DateTime readValue, + out CheatEngineFailure readFailure, TestContext.Current.CancellationToken); + bool writeSucceeded = client.TryWritePrimitive(Address, DateTime.UnixEpoch, out CheatEngineFailure writeFailure, + TestContext.Current.CancellationToken); + + Assert.False(readSucceeded); + Assert.Equal(default, readValue); + Assert.Equal(CheatEngineFailureKind.Unsupported, readFailure.Kind); + Assert.Equal("Memory.ReadPrimitive", readFailure.Operation); + Assert.False(writeSucceeded); + Assert.Equal(CheatEngineFailureKind.Unsupported, writeFailure.Kind); + Assert.Equal("Memory.WritePrimitive", writeFailure.Operation); + } + + [Fact] + public void ReadAndWriteConvenienceMethodsThrowTheClassifiedDispatcherFailure() + { + MemoryClient client = new(new RejectingDispatcher(Failure("Test.Convenience"))); + MemoryReadRequest read = new(Address, new NeverUsedCodec()); + MemoryWriteRequest write = new(Address, 42, new NeverUsedCodec()); + + CheatEngineClientLifecycleException primitiveException = + Assert.Throws(() => + client.ReadPrimitive(Address, TestContext.Current.CancellationToken)); + CheatEngineClientLifecycleException readException = Assert.Throws(() => + client.Read(read, TestContext.Current.CancellationToken)); + CheatEngineClientLifecycleException writeException = Assert.Throws(() => + client.Write(write, TestContext.Current.CancellationToken)); + + Assert.Equal("Test.Convenience", primitiveException.Failure.Operation); + Assert.Equal("Test.Convenience", readException.Failure.Operation); + Assert.Equal("Test.Convenience", writeException.Failure.Operation); + } + + [Theory] + [InlineData("bytes")] + [InlineData("string")] + [InlineData("pointer")] + public void InvalidDefaultRequestCannotReachTheDispatcher(string requestKind) + { + MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch"))); + + switch (requestKind) + { + case "bytes": + Assert.Throws(() => + client.TryReadBytes(default, out _, out _, TestContext.Current.CancellationToken)); + break; + case "string": + Assert.Throws(() => + client.TryReadString(default, out _, out _, TestContext.Current.CancellationToken)); + break; + default: + Assert.Throws(() => + client.TryResolvePointerChain(default, out _, out _, TestContext.Current.CancellationToken)); + break; + } + } + + [Fact] + public void DefaultStringAndByteWritesAreRejectedBeforeDispatch() + { + MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch"))); + + Assert.Throws(() => + client.TryWriteBytes(default, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => + client.TryWriteString(default, out _, TestContext.Current.CancellationToken)); + } + + private static CheatEngineFailure Failure(string operation) + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, operation, "The dispatcher rejected work."); + } + + private sealed class NeverUsedCodec : IMemoryCodec + { + public bool TryRead(IMemoryReadContext context, Address address, out int value) + { + throw new InvalidOperationException("The rejecting dispatcher must prevent codec execution."); + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + { + throw new InvalidOperationException("The rejecting dispatcher must prevent codec execution."); + } + } + + private sealed class RejectingDispatcher : ICheatEngineDispatcher + { + private readonly CheatEngineFailure _failure; + + internal RejectingDispatcher(CheatEngineFailure failure) + { + _failure = failure; + } + + public bool IsMainThread => false; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + failure = _failure; + return false; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + result = default; + failure = _failure; + return false; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + callback(); + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + return callback(); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs new file mode 100644 index 0000000..216d00c --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientTests.cs @@ -0,0 +1,184 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class MemoryClientTests +{ + [Fact] + public void TypedPrimitiveReadUsesTheCallerSuppliedCodec() + { + RecordingInt32Codec codec = new() { ReadValue = 1234 }; + MemoryClient client = new(new InlineDispatcher()); + Address address = 0x401000; + + bool succeeded = client.TryRead(new MemoryReadRequest(address, codec), out int value, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(1234, value); + Assert.Equal(default, failure); + Assert.Equal(1, codec.ReadCount); + Assert.Equal(address, codec.LastReadAddress); + } + + [Fact] + public void TypedPrimitiveWriteUsesTheCallerSuppliedCodec() + { + RecordingInt32Codec codec = new(); + MemoryClient client = new(new InlineDispatcher()); + Address address = 0x402000; + + bool succeeded = client.TryWrite(new MemoryWriteRequest(address, 77, codec), + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(1, codec.WriteCount); + Assert.Equal(address, codec.LastWriteAddress); + Assert.Equal(77, codec.LastWrittenValue); + } + + [Fact] + public void TypedCodecFailureBecomesAClassifiedMemoryReadFailure() + { + RecordingInt32Codec codec = new() { ReadSucceeds = false }; + MemoryClient client = new(new InlineDispatcher()); + + bool succeeded = client.TryRead(new MemoryReadRequest(0x403000, codec), out int value, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, value); + Assert.Equal(CheatEngineFailureKind.MemoryReadFailed, failure.Kind); + Assert.Equal("Memory.Read", failure.Operation); + Assert.Contains("codec", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, codec.ReadCount); + } + + private sealed class RecordingInt32Codec : IMemoryCodec + { + internal int ReadValue + { + get; + init; + } + + internal bool ReadSucceeds + { + get; + init; + } = true; + + internal int ReadCount + { + get; + private set; + } + + internal int WriteCount + { + get; + private set; + } + + internal Address LastReadAddress + { + get; + private set; + } + + internal Address LastWriteAddress + { + get; + private set; + } + + internal int LastWrittenValue + { + get; + private set; + } + + public bool TryRead(IMemoryReadContext context, Address address, out int value) + { + Assert.NotNull(context); + ReadCount++; + LastReadAddress = address; + value = ReadValue; + return ReadSucceeds; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + { + Assert.NotNull(context); + WriteCount++; + LastWriteAddress = address; + LastWrittenValue = value; + return true; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs new file mode 100644 index 0000000..624ad8f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerCoverageTests.cs @@ -0,0 +1,51 @@ +using CheatEngine.Client.Core.Dispatching; +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class PatternScannerCoverageTests +{ + [Fact] + public void TryScanRejectsAnUninitializedRequestBeforeAccessingThePluginDispatcher() + { + PatternScanner scanner = CreateScanner(); + + bool succeeded = scanner.TryScan(default, out AobScanResult result, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + Assert.Contains("normalized, non-empty pattern", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void ScanConvertsAnUninitializedRequestFailureToThePublicOperationException() + { + PatternScanner scanner = CreateScanner(); + + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Scan(default, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); + Assert.Equal("Patterns.Scan", exception.Failure.Operation); + Assert.Contains("normalized, non-empty pattern", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ConstructorRejectsANullDispatcher() + { + ArgumentNullException exception = Assert.Throws(() => new PatternScanner(null!)); + + Assert.Equal("dispatcher", exception.ParamName); + } + + private static PatternScanner CreateScanner() + { + return new PatternScanner(new SdkMainThreadDispatcher(InertCoreLifetime.Create())); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs new file mode 100644 index 0000000..adea745 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/PatternScannerTests.cs @@ -0,0 +1,30 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class PatternScannerTests +{ + [Fact] + public void TryValidateRequestRejectsTheDefaultStructBeforeAnyDispatcherOrSdkOperation() + { + bool valid = PatternScanner.TryValidateRequest(default, out CheatEngineFailure failure); + + Assert.False(valid); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Patterns.Scan", failure.Operation); + } + + [Fact] + public void TryValidateRequestAcceptsAConstructedBoundedRequest() + { + AobScanRequest request = new(new AobPattern("90"), AobScanOptions.Default, 1); + + bool valid = PatternScanner.TryValidateRequest(request, out CheatEngineFailure failure); + + Assert.True(valid); + Assert.Equal(default, failure); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs new file mode 100644 index 0000000..256ae4f --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs @@ -0,0 +1,389 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class ProcessClientTests +{ + [Fact] + public void RefreshKeepsTheSelectionEpochForTheSamePidAndArchitecture() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(initial.Id, refreshed.Id); + Assert.Equal(initial.TargetArchitecture, refreshed.TargetArchitecture); + Assert.Equal(0, initial.SelectionEpoch); + Assert.Equal(initial.SelectionEpoch, refreshed.SelectionEpoch); + } + + [Fact] + public void RefreshChangesPidAdvancesSelectionEpochAndDisposesTheOldTargetLease() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); + host.OpenedProcessId = 43; + + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(new TargetProcessId(43), refreshed.Id); + Assert.Equal(1, refreshed.SelectionEpoch); + Assert.Equal(1, lease.DisposeCount); + Assert.Throws(() => + selectionLifetime.ThrowIfExpired(initial.SelectionEpoch, "Test.TargetLease")); + } + + [Fact] + public void RefreshChangesArchitectureForTheSamePidAndAdvancesSelectionEpoch() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X86); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + host.TargetArchitecture = CheatEngineArchitecture.X64; + + ProcessSnapshot refreshed = client.Refresh(TestContext.Current.CancellationToken); + + Assert.Equal(new TargetProcessId(42), refreshed.Id); + Assert.Equal(CheatEngineArchitecture.X64, refreshed.TargetArchitecture); + Assert.Equal(1, refreshed.SelectionEpoch); + Assert.NotEqual(initial.SelectionEpoch, refreshed.SelectionEpoch); + } + + [Fact] + public void TryGetCurrentReportsTargetNotAttachedAndInvalidatesTheKnownSelectionWhenCeDetaches() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + host.OpenedProcessId = 0; + + bool succeeded = client.TryGetCurrent( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.TargetNotAttached, failure.Kind); + Assert.Equal(1, selectionLifetime.Epoch); + Assert.Throws(() => + selectionLifetime.ThrowIfExpired(initial.SelectionEpoch, "Test.TargetLease")); + + CheatEngineOperationException exception = Assert.Throws(() => + client.GetCurrent(TestContext.Current.CancellationToken)); + Assert.Equal(CheatEngineFailureKind.TargetNotAttached, exception.Failure.Kind); + } + + [Fact] + public void AttachVerifiesThePidSelectedByCheatEngineBeforeReturningTheSnapshot() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); + host.SelectedAfterOpenOverride = 42; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + bool succeeded = client.TryAttach( + new TargetProcessId(43), + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal([43L], host.OpenProcessCalls); + } + + [Fact] + public void TryAttachSelectsTheRequestedPidAndAdvancesAnAlreadyObservedSelection() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture-b", "C:\\fixtures\\fixture-b.exe"); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + RecordingDisposable lease = new(); + selectionLifetime.Track(lease, initial.SelectionEpoch); + + bool succeeded = client.TryAttach( + new TargetProcessId(43), + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(new TargetProcessId(43), snapshot.Id); + Assert.Equal(1, snapshot.SelectionEpoch); + Assert.Equal(1, lease.DisposeCount); + Assert.Equal([43L], host.OpenProcessCalls); + } + + [Fact] + public void TryAttachExactNameRejectsZeroAndMultipleCandidatesWithoutOpeningAnyProcess() + { + FakeProcessHost noMatchHost = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime noMatchLifetime = CreateSelectionLifetime(); + ProcessClient noMatchClient = new(new InlineDispatcher(), noMatchHost, noMatchLifetime); + + bool noMatchSucceeded = noMatchClient.TryAttachExactName( + "absent.exe", + out ProcessSnapshot noMatchSnapshot, + out CheatEngineFailure noMatchFailure, + TestContext.Current.CancellationToken); + + Assert.False(noMatchSucceeded); + Assert.Equal(default, noMatchSnapshot); + Assert.Equal(CheatEngineFailureKind.NotFound, noMatchFailure.Kind); + Assert.Empty(noMatchHost.OpenProcessCalls); + + FakeProcessHost ambiguousHost = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + ambiguousHost.NameMatches["fixture"] = + [ + new LocalProcessInfo(43, "fixture", "C:\\fixtures\\one.exe"), + new LocalProcessInfo(44, "fixture", "C:\\fixtures\\two.exe") + ]; + using TargetSelectionLifetime ambiguousLifetime = CreateSelectionLifetime(); + ProcessClient ambiguousClient = new(new InlineDispatcher(), ambiguousHost, ambiguousLifetime); + + bool ambiguousSucceeded = ambiguousClient.TryAttachExactName( + "fixture.exe", + out ProcessSnapshot ambiguousSnapshot, + out CheatEngineFailure ambiguousFailure, + TestContext.Current.CancellationToken); + + Assert.False(ambiguousSucceeded); + Assert.Equal(default, ambiguousSnapshot); + Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, ambiguousFailure.Kind); + Assert.Empty(ambiguousHost.OpenProcessCalls); + } + + [Fact] + public void AttachExactNameUsesTheSingleExactCandidateAndReturnsCopiedMetadata() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[43] = new LocalProcessInfo(43, "fixture", "C:\\fixtures\\fixture.exe"); + host.NameMatches["fixture"] = [host.LocalProcesses[43]]; + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + ProcessSnapshot snapshot = client.AttachExactName("fixture.exe", TestContext.Current.CancellationToken); + + Assert.Equal(new TargetProcessId(43), snapshot.Id); + Assert.Equal("fixture", snapshot.Name); + Assert.Equal("C:\\fixtures\\fixture.exe", snapshot.ExecutablePath); + Assert.Equal([43L], host.OpenProcessCalls); + } + + [Theory] + [InlineData("C:\\fixtures\\fixture.exe")] + [InlineData(".exe")] + [InlineData(" ")] + public void ExactNameAttachmentRejectsPathsAndInvalidNamesBeforeProcessDiscovery(string name) + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + Assert.Throws(() => + client.TryAttachExactName(name, out _, out _, TestContext.Current.CancellationToken)); + Assert.Empty(host.OpenProcessCalls); + } + + private static TargetSelectionLifetime CreateSelectionLifetime() + { + return new TargetSelectionLifetime(static _ => + { + }); + } + + private sealed class RecordingDisposable : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + } + } + + private sealed class FakeProcessHost : IProcessHost + { + internal Dictionary LocalProcesses + { + get; + } = []; + + internal Dictionary> NameMatches + { + get; + } = + new(StringComparer.OrdinalIgnoreCase); + + internal List OpenProcessCalls + { + get; + } = []; + + internal long OpenedProcessId + { + get; + set; + } + + internal long? SelectedAfterOpenOverride + { + get; + set; + } + + internal CheatEngineArchitecture TargetArchitecture + { + get; + set; + } + + public long GetOpenedProcessId() + { + return OpenedProcessId; + } + + public void OpenProcess(long processId) + { + OpenProcessCalls.Add(processId); + OpenedProcessId = SelectedAfterOpenOverride ?? processId; + } + + public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) + { + return LocalProcesses.TryGetValue(processId, out process); + } + + public IReadOnlyList FindProcessesByExactName(string processName) + { + return NameMatches.TryGetValue(processName, out IReadOnlyList? matches) ? matches : []; + } + + public CheatEngineArchitecture GetTargetArchitecture() + { + return TargetArchitecture; + } + + internal static FakeProcessHost CreateSelected(int processId, CheatEngineArchitecture architecture) + { + FakeProcessHost host = new() { OpenedProcessId = processId, TargetArchitecture = architecture }; + host.LocalProcesses[processId] = new LocalProcessInfo( + processId, + "fixture", + "C:\\fixtures\\fixture.exe"); + return host; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + "Dispatcher.Invoke", + "Cancelled before dispatch."); + return false; + } + + try + { + callback(); + failure = default; + return true; + } + catch (Exception exception) + { + failure = new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, + "Dispatcher.Invoke", + exception.Message, + exception); + return false; + } + } + + public bool TryInvoke(Func callback, out T result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default!; + failure = new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + "Dispatcher.Invoke", + "Cancelled before dispatch."); + return false; + } + + try + { + result = callback(); + failure = default; + return true; + } + catch (Exception exception) + { + result = default!; + failure = new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, + "Dispatcher.Invoke", + exception.Message, + exception); + return false; + } + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs new file mode 100644 index 0000000..5392fa6 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs @@ -0,0 +1,467 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class RuntimeClientTests +{ + [Fact] + public void SnapshotReportsOnlyTheObservedCeLineAndIndependentCapabilities() + { + InlineDispatcher dispatcher = new(); + FakeRuntimeProbe probe = new() + { + ReportedVersion = 7.7d, + SystemArchitectureCode = 1, + TargetAbiCode = 0, + OpenedProcessId = 42, + TargetIs64BitValue = true + }; + RuntimeClient runtime = new(dispatcher, probe, static () => 84, new Version(0, 1, 0), new Version(1, 0, 0)); + + bool succeeded = + runtime.TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(84, snapshot.Epoch); + Assert.Equal(7.7d, snapshot.ObservedCheatEngineVersion); + Assert.Equal(new CheatEngineVersion(7, 7, 0, 10621), snapshot.QualifiedCheatEngineBaseline); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.SystemArchitecture); + Assert.Equal(CheatEngineArchitecture.X64, snapshot.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, snapshot.TargetPointerSize); + Assert.Equal(TargetAbi.Windows, snapshot.TargetAbi); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, + out RuntimeCapabilityAvailability versionCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, versionCapability.State); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, + out RuntimeCapabilityAvailability targetCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, targetCapability.State); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void SnapshotMarksOnlyTheUnavailableProbeUnavailableWithoutInventingItsValue() + { + FakeRuntimeProbe probe = new() + { + ReportedVersion = 7.7d, + SystemArchitectureCode = 1, + TargetAbiCode = 0, + OpenedProcessId = 42, + TargetIs64BitException = new EngineGlobalUnavailableException("Runtime.TargetArchitecture") + }; + RuntimeClient runtime = new(new InlineDispatcher(), probe, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, snapshot.TargetPointerSize); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, + out RuntimeCapabilityAvailability targetCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, targetCapability.State); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.SystemArchitecture, + out RuntimeCapabilityAvailability systemCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, systemCapability.State); + } + + [Fact] + public void SnapshotLeavesTargetArchitectureUnknownWhenNoTargetIsSelectedWithoutCallingTargetProbe() + { + FakeRuntimeProbe probe = new() + { + ReportedVersion = 7.7d, SystemArchitectureCode = 1, TargetAbiCode = 0, OpenedProcessId = 0 + }; + RuntimeClient runtime = new(new InlineDispatcher(), probe, static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, + out RuntimeCapabilityAvailability targetCapability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, targetCapability.State); + Assert.Equal(0, probe.TargetIs64BitCallCount); + } + + [Fact] + public void TryGetSnapshotObservesCancellationBeforeDispatchingAProbe() + { + InlineDispatcher dispatcher = new(); + RuntimeClient runtime = new(dispatcher, new FakeRuntimeProbe(), static () => 1); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = runtime.TryGetSnapshot(out CheatEngineRuntimeSnapshot snapshot, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void TryGetSdkCapabilityReturnsUnknownForAnIdentifierThatWasNotProbed() + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 1); + RuntimeCapabilityId customCapability = new("Client.Custom"); + + bool succeeded = runtime.TryGetSdkCapability( + customCapability, + out RuntimeCapabilityAvailability availability, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(customCapability, availability.Capability); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, availability.State); + } + + [Fact] + public void SnapshotReportsClientGatesWithoutClaimingUnprobedDomainsAreAvailable() + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 1); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.ValueScanning, + out ClientCapabilityAvailability valueScanning)); + Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, valueScanning.State); + Assert.Contains("live ownership", valueScanning.Reason, StringComparison.OrdinalIgnoreCase); + Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.TypedMemory, + out ClientCapabilityAvailability typedMemory)); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, typedMemory.State); + Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.UnsafeLuaExecution, + out ClientCapabilityAvailability unsafeLua)); + Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, unsafeLua.State); + } + + [Fact] + public void SnapshotReportsUnsafeLuaAvailableOnlyForTheExplicitActivationOptIn() + { + RuntimeClient runtime = new( + new InlineDispatcher(), + new FakeRuntimeProbe(), + static () => 1, + policy: new CoreClientPolicy([], true)); + + bool succeeded = runtime.TryGetClientCapability( + ClientCapabilityId.UnsafeLuaExecution, + out ClientCapabilityAvailability availability, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(ClientCapabilityAvailabilityState.Available, availability.State); + Assert.True(availability.IsAvailable); + } + + [Fact] + public void GetSnapshotThrowsTheClassifiedFailureWhenTheHostReportsAnInvalidVersion() + { + RuntimeClient runtime = new( + new InlineDispatcher(), + new FakeRuntimeProbe { ReportedVersion = double.NaN }, + static () => 1); + + CheatEngineOperationException exception = Assert.Throws(() => + runtime.GetSnapshot(TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, exception.Failure.Kind); + Assert.Equal("Dispatcher.Invoke", exception.Failure.Operation); + } + + [Fact] + public void CapabilityQueriesReturnTheObservedSnapshotEntryOrTheDocumentedUnknownFallback() + { + RuntimeClient runtime = new(new InlineDispatcher(), new FakeRuntimeProbe(), static () => 7); + + bool sdkSucceeded = runtime.TryGetSdkCapability( + RuntimeCapabilityId.CheatEngineVersion, + out RuntimeCapabilityAvailability sdkAvailability, + out CheatEngineFailure sdkFailure, + TestContext.Current.CancellationToken); + bool clientSucceeded = runtime.TryGetClientCapability( + ClientCapabilityId.ProcessSelection, + out ClientCapabilityAvailability clientAvailability, + out CheatEngineFailure clientFailure, + TestContext.Current.CancellationToken); + + Assert.True(sdkSucceeded); + Assert.Equal(default, sdkFailure); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, sdkAvailability.State); + Assert.True(clientSucceeded); + Assert.Equal(default, clientFailure); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, clientAvailability.State); + Assert.Contains("does not probe", clientAvailability.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CapabilityQueriesRejectDefaultIdentifiersBeforeDispatching() + { + InlineDispatcher dispatcher = new(); + RuntimeClient runtime = new(dispatcher, new FakeRuntimeProbe(), static () => 7); + + Assert.Throws(() => runtime.TryGetSdkCapability( + default, out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => runtime.TryGetClientCapability( + default, out _, out _, TestContext.Current.CancellationToken)); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void SnapshotMarksUnavailableAndDetachedHostProbesWithoutInventingRuntimeFacts() + { + RuntimeClient runtime = new( + new InlineDispatcher(), + new FakeRuntimeProbe + { + VersionException = new EngineGlobalUnavailableException("Runtime.Version"), + SystemArchitectureException = + new EngineCapabilityUnavailableException("Runtime.SystemArchitecture"), + TargetAbiException = new EngineGlobalUnavailableException("Runtime.TargetAbi"), + OpenedProcessException = new EngineCapabilityUnavailableException("Runtime.OpenedProcess") + }, + static () => 7); + + CheatEngineRuntimeSnapshot snapshot = runtime.GetSnapshot(TestContext.Current.CancellationToken); + + Assert.Null(snapshot.ObservedCheatEngineVersion); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.SystemArchitecture); + Assert.Equal(TargetAbi.Unknown, snapshot.TargetAbi); + Assert.Equal(CheatEngineArchitecture.Unknown, snapshot.TargetArchitecture); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.CheatEngineVersion, + out RuntimeCapabilityAvailability version)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, version.State); + Assert.True(snapshot.SdkCapabilities.TryGet(RuntimeCapabilityId.SystemArchitecture, + out RuntimeCapabilityAvailability system)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, system.State); + } + + private sealed class FakeRuntimeProbe : IRuntimeProbe + { + internal double ReportedVersion + { + get; + init; + } = 7.7d; + + internal int SystemArchitectureCode + { + get; + init; + } = 1; + + internal int TargetAbiCode + { + get; + init; + } + + internal long OpenedProcessId + { + get; + init; + } + + internal bool TargetIs64BitValue + { + get; + init; + } + + internal Exception? TargetIs64BitException + { + get; + init; + } + + internal Exception? VersionException + { + get; + init; + } + + internal Exception? SystemArchitectureException + { + get; + init; + } + + internal Exception? TargetAbiException + { + get; + init; + } + + internal Exception? OpenedProcessException + { + get; + init; + } + + internal int TargetIs64BitCallCount + { + get; + private set; + } + + public double GetCheatEngineVersion() + { + if (VersionException is not null) + { + throw VersionException; + } + + return ReportedVersion; + } + + public int GetSystemArchitecture() + { + if (SystemArchitectureException is not null) + { + throw SystemArchitectureException; + } + + return SystemArchitectureCode; + } + + public int GetTargetAbi() + { + if (TargetAbiException is not null) + { + throw TargetAbiException; + } + + return TargetAbiCode; + } + + public long GetOpenedProcessId() + { + if (OpenedProcessException is not null) + { + throw OpenedProcessException; + } + + return OpenedProcessId; + } + + public bool TargetIs64Bit() + { + TargetIs64BitCallCount++; + if (TargetIs64BitException is not null) + { + throw TargetIs64BitException; + } + + return TargetIs64BitValue; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + internal int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = Cancelled(); + return false; + } + + InvocationCount++; + try + { + callback(); + failure = default; + return true; + } + catch (Exception exception) + { + failure = FromException(exception); + return false; + } + } + + public bool TryInvoke(Func callback, out T result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default!; + failure = Cancelled(); + return false; + } + + InvocationCount++; + try + { + result = callback(); + failure = default; + return true; + } + catch (Exception exception) + { + result = default!; + failure = FromException(exception); + return false; + } + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + + private static CheatEngineFailure Cancelled() + { + return new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + "Dispatcher.Invoke", + "Cancelled before dispatch."); + } + + private static CheatEngineFailure FromException(Exception exception) + { + return new CheatEngineFailure( + exception is EngineMarshallingException + ? CheatEngineFailureKind.InvalidHostResult + : CheatEngineFailureKind.OperationRejected, + "Dispatcher.Invoke", + exception.Message, + exception); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs new file mode 100644 index 0000000..193ce53 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortCoverageTests.cs @@ -0,0 +1,26 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class SdkTableRecordMutationPortCoverageTests +{ + [Fact] + public void ParentRelationshipGuardFailsClosedForAnUnknownLinkStatus() + { + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(42), + new MemoryRecordId(12), 1, + static _ => new ParentChainStep((ParentChainStepKind) 99, default)); + + Assert.Equal(TableRecordMutationStatus.HostRejected, status); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ParentRelationshipGuardRequiresAPositiveTraversalBound(int maximumHops) + { + Assert.Throws(() => TableParentRelationshipGuard.Validate(new MemoryRecordId(42), + new MemoryRecordId(12), maximumHops, static _ => ParentChainStep.Root)); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs new file mode 100644 index 0000000..c19a5c8 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SdkTableRecordMutationPortTests.cs @@ -0,0 +1,20 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class SdkTableRecordMutationPortTests +{ + [Fact] + public void TrySetParentRejectsASelfReferentialRelationshipBeforeAccessingTheAddressList() + { + SdkTableRecordMutationPort port = new(); + MemoryRecordId id = new(42); + + TableRecordMutationStatus status = port.TrySetParent(id, id, out MemoryRecordSnapshot record); + + Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + Assert.Equal(default, record); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs new file mode 100644 index 0000000..ed8a7ab --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseCleanupCoverageTests.cs @@ -0,0 +1,81 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class SymbolRegistrationLeaseCleanupCoverageTests +{ + [Fact] + public void DisposeReleasesTheNameAndBecomesTerminalWhenUntrackingFails() + { + List events = []; + InlineDispatcher dispatcher = new(); + SymbolRegistrationLease lease = new( + new SymbolRegistration("fixture-symbol", Address.Zero), + dispatcher, + _ => + { + events.Add("untrack"); + throw new InvalidOperationException("untracking failed"); + }, + name => events.Add("unregister:" + name), + name => events.Add("release-name:" + name)); + + InvalidOperationException exception = Assert.Throws(lease.Dispose); + + Assert.Equal("untracking failed", exception.Message); + Assert.True(lease.IsReleased); + Assert.Equal(["unregister:fixture-symbol", "untrack", "release-name:fixture-symbol"], events); + Assert.Equal(1, dispatcher.InvocationCount); + + lease.Dispose(); + + Assert.Equal(1, dispatcher.InvocationCount); + Assert.Equal(["unregister:fixture-symbol", "untrack", "release-name:fixture-symbol"], events); + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + _ = TryInvoke(callback, out _, cancellationToken); + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + _ = TryInvoke(callback, out TResult result, out _, cancellationToken); + return result; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs new file mode 100644 index 0000000..a49fe6d --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/SymbolRegistrationLeaseTests.cs @@ -0,0 +1,117 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class SymbolRegistrationLeaseTests +{ + [Fact] + public void FailedDispatchKeepsTheLeaseTrackedUntilTheCleanupRetryUnregistersIt() + { + List events = []; + CoreResourceRegistry registry = new(); + RetriableDispatcher dispatcher = new() { RejectDispatch = true }; + SymbolRegistrationLease lease = new( + new SymbolRegistration("fixture-symbol", Address.Zero), + dispatcher, + registeredLease => + { + registry.Untrack(registeredLease); + events.Add("untrack"); + }, + _ => events.Add("unregister"), + _ => events.Add("release-name")); + registry.Track(lease); + + Assert.Throws(lease.Dispose); + + Assert.False(lease.IsReleased); + Assert.Empty(events); + + dispatcher.RejectDispatch = false; + registry.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["unregister", "untrack", "release-name"], events); + Assert.Equal(2, dispatcher.InvocationCount); + } + + private sealed class RetriableDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + public bool RejectDispatch + { + get; + set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + if (RejectDispatch) + { + failure = new CheatEngineFailure( + CheatEngineFailureKind.InvalidState, + "Test.Dispatch", + "Dispatch admission is closed."); + return false; + } + + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + if (RejectDispatch) + { + result = default!; + failure = new CheatEngineFailure( + CheatEngineFailureKind.InvalidState, + "Test.Dispatch", + "Dispatch admission is closed."); + return false; + } + + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out TResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs new file mode 100644 index 0000000..3437224 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientCoverageTests.cs @@ -0,0 +1,272 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class TableClientCoverageTests +{ + [Fact] + public void TryFindRejectsAnUninitializedSearchWithoutDispatching() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + + bool succeeded = client.TryFind(default, new MemoryRecordCollectionRequest(8), + out ImmutableArray records, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.True(records.IsEmpty); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Tables.Find", failure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void FindConvertsTheUninitializedSearchRejectionToAnOperationException() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + + CheatEngineOperationException exception = Assert.Throws(() => + client.Find(default, new MemoryRecordCollectionRequest(8), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); + Assert.Equal("Tables.Find", exception.Failure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void NegativeRecordIndexIsRejectedBeforeTheAddressListIsRead() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + + Assert.Throws(() => + client.TryGetRecord(-1, out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void TableImportAndExportFailClosedWhenNoTrustedRootsAreConfigured() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + TrustedTableFile file = new(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", "profile.ct")); + + bool loadSucceeded = client.TryLoadTrustedTable(new TableLoadRequest(file), out CheatEngineFailure loadFailure, + TestContext.Current.CancellationToken); + bool saveSucceeded = client.TrySaveTable(new TableSaveRequest(file), out CheatEngineFailure saveFailure, + TestContext.Current.CancellationToken); + + Assert.False(loadSucceeded); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, loadFailure.Kind); + Assert.Equal("Tables.LoadTrustedTable", loadFailure.Operation); + Assert.False(saveSucceeded); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, saveFailure.Kind); + Assert.Equal("Tables.SaveTable", saveFailure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void ThrowingTableImportAndExportPreserveTheFailClosedPolicyFailure() + { + RejectingDispatcher dispatcher = new(Failure()); + TableClient client = CreateClient(dispatcher); + TrustedTableFile file = new(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", "profile.ct")); + + CheatEngineOperationException loadException = Assert.Throws(() => + client.LoadTrustedTable(new TableLoadRequest(file), TestContext.Current.CancellationToken)); + CheatEngineOperationException saveException = Assert.Throws(() => + client.SaveTable(new TableSaveRequest(file), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, loadException.Failure.Kind); + Assert.Equal("Tables.LoadTrustedTable", loadException.Failure.Operation); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, saveException.Failure.Kind); + Assert.Equal("Tables.SaveTable", saveException.Failure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void ReadOperationsPropagateTheExactDispatcherFailureWithoutReadingSdkStatics() + { + CheatEngineFailure expected = Failure(); + RejectingDispatcher dispatcher = new(expected); + TableClient client = CreateClient(dispatcher); + MemoryRecordId id = new(42); + + Assert.False(client.TryGetCurrent(out AddressTableSnapshot current, out CheatEngineFailure currentFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, current); + Assert.Equal(expected, currentFailure); + Assert.False(client.TryGetSnapshot(new MemoryRecordCollectionRequest(8), out AddressTableSnapshot snapshot, + out CheatEngineFailure snapshotFailure, TestContext.Current.CancellationToken)); + Assert.Equal(default, snapshot); + Assert.Equal(expected, snapshotFailure); + Assert.False(client.TryGetRecord(0, out MemoryRecordSnapshot indexedRecord, + out CheatEngineFailure indexedFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, indexedRecord); + Assert.Equal(expected, indexedFailure); + Assert.False(client.TryGetRecord(id, out MemoryRecordSnapshot identifiedRecord, + out CheatEngineFailure identifiedFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, identifiedRecord); + Assert.Equal(expected, identifiedFailure); + Assert.False(client.TryGetSelected(out MemoryRecordSnapshot selectedRecord, + out CheatEngineFailure selectedFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, selectedRecord); + Assert.Equal(expected, selectedFailure); + Assert.Equal(5, dispatcher.InvocationCount); + } + + [Fact] + public void MutatingOperationsPropagateTheExactDispatcherFailureWithoutInvokingTheMutationPort() + { + CheatEngineFailure expected = Failure(); + RejectingDispatcher dispatcher = new(expected); + RecordingMutationPort mutations = new(); + TableClient client = new(dispatcher, CoreClientPolicy.SafeDefaults, mutations); + MemoryRecordId id = new(42); + MemoryRecordDefinition definition = new("Health", "game.exe+10", "100", VariableType.Dword); + MemoryRecordUpdate update = new(id, value: "101"); + + Assert.False(client.TryCreate(definition, out MemoryRecordSnapshot created, + out CheatEngineFailure createFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, created); + Assert.Equal(expected, createFailure); + Assert.False(client.TryUpdate(update, out MemoryRecordSnapshot updated, out CheatEngineFailure updateFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, updated); + Assert.Equal(expected, updateFailure); + Assert.False(client.TryDelete(id, out CheatEngineFailure deleteFailure, TestContext.Current.CancellationToken)); + Assert.Equal(expected, deleteFailure); + Assert.False(client.TrySetActive(id, true, out MemoryRecordSnapshot activeRecord, + out CheatEngineFailure activeFailure, TestContext.Current.CancellationToken)); + Assert.Equal(default, activeRecord); + Assert.Equal(expected, activeFailure); + Assert.False(client.TrySetParent(id, new MemoryRecordId(12), out MemoryRecordSnapshot parentRecord, + out CheatEngineFailure parentFailure, TestContext.Current.CancellationToken)); + Assert.Equal(default, parentRecord); + Assert.Equal(expected, parentFailure); + Assert.Equal(5, dispatcher.InvocationCount); + Assert.Equal(0, mutations.InvocationCount); + } + + [Fact] + public void HierarchyOperationsPropagateTheExactDispatcherFailure() + { + CheatEngineFailure expected = Failure(); + RejectingDispatcher dispatcher = new(expected); + TableClient client = CreateClient(dispatcher); + + Assert.False(client.TryGetHierarchy(new MemoryRecordId(42), new MemoryRecordHierarchyRequest(8, 2), + out MemoryRecordHierarchySnapshot hierarchy, out CheatEngineFailure failure, + TestContext.Current.CancellationToken)); + + Assert.Equal(default, hierarchy); + Assert.Equal(expected, failure); + Assert.Equal(1, dispatcher.InvocationCount); + } + + private static TableClient CreateClient(RejectingDispatcher dispatcher) + { + return new TableClient(dispatcher, CoreClientPolicy.SafeDefaults); + } + + private static CheatEngineFailure Failure() + { + return new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Test.TableDispatcher", + "The host dispatcher denied the operation."); + } + + private sealed class RecordingMutationPort : ITableRecordMutationPort + { + internal int InvocationCount + { + get; + private set; + } + + public TableRecordMutationStatus TryDelete(MemoryRecordId id) + { + InvocationCount++; + return TableRecordMutationStatus.Success; + } + + public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + InvocationCount++; + record = default; + return TableRecordMutationStatus.Success; + } + } + + private sealed class RejectingDispatcher : ICheatEngineDispatcher + { + private readonly CheatEngineFailure _failure; + + internal RejectingDispatcher(CheatEngineFailure failure) + { + _failure = failure; + } + + internal int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => false; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + failure = _failure; + return false; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + result = default; + failure = _failure; + return false; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs new file mode 100644 index 0000000..70a494a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/TableClientMutationTests.cs @@ -0,0 +1,350 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Enums; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class TableClientMutationTests +{ + [Fact] + public void TryDeleteDispatchesTheRequestedRecordAndReturnsSuccess() + { + FakeRecordMutationPort mutations = new() { DeleteStatus = TableRecordMutationStatus.Success }; + TableClient client = CreateClient(mutations); + + bool succeeded = + client.TryDelete(new MemoryRecordId(41), out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(new MemoryRecordId(41), mutations.LastDeletedId); + Assert.Equal(default, failure); + } + + [Fact] + public void TryDeleteClassifiesAnAbsentRecord() + { + TableClient client = CreateClient(new FakeRecordMutationPort + { + DeleteStatus = TableRecordMutationStatus.RecordNotFound + }); + + bool succeeded = + client.TryDelete(new MemoryRecordId(41), out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); + Assert.Equal("Tables.Delete", failure.Operation); + } + + [Fact] + public void DeleteThrowsTheClassifiedHostFailure() + { + TableClient client = CreateClient(new FakeRecordMutationPort + { + DeleteStatus = TableRecordMutationStatus.HostRejected + }); + + CheatEngineOperationException exception = Assert.Throws(() => + client.Delete(new MemoryRecordId(41), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, exception.Failure.Kind); + Assert.Equal("Tables.Delete", exception.Failure.Operation); + } + + [Fact] + public void TrySetParentPassesBothIdsAndReturnsTheCopiedPostMutationSnapshot() + { + MemoryRecordSnapshot expected = Snapshot(41, "Ammo"); + FakeRecordMutationPort mutations = new() + { + SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = expected + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(12), + out MemoryRecordSnapshot actual, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(new MemoryRecordId(41), mutations.LastChildId); + Assert.Equal(new MemoryRecordId(12), mutations.LastParentId); + Assert.Equal(expected, actual); + Assert.Equal(default, failure); + } + + [Fact] + public void TrySetParentWithNullParentRequestsTheAddressListRoot() + { + FakeRecordMutationPort mutations = new() + { + SetParentStatus = TableRecordMutationStatus.Success, SetParentRecord = Snapshot(41, "Ammo") + }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), null, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Null(mutations.LastParentId); + Assert.Equal(default, failure); + } + + [Fact] + public void TrySetParentRejectsARecordAsItsOwnParentBeforeDispatching() + { + FakeRecordMutationPort mutations = new(); + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(41), + out MemoryRecordSnapshot record, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Tables.SetParent", failure.Operation); + Assert.Equal( + "The requested parent relationship is invalid: it is self-referential, cyclic, or exceeds the " + + "supported hierarchy depth.", + failure.Message); + Assert.Equal(0, mutations.SetParentCallCount); + } + + [Fact] + public void TrySetParentMapsAHostRejectedMutationToTheExactHostFailure() + { + FakeRecordMutationPort mutations = new() { SetParentStatus = TableRecordMutationStatus.HostRejected }; + TableClient client = CreateClient(mutations); + + bool succeeded = client.TrySetParent(new MemoryRecordId(41), new MemoryRecordId(12), + out MemoryRecordSnapshot record, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, record); + Assert.Equal(new MemoryRecordId(41), mutations.LastChildId); + Assert.Equal(new MemoryRecordId(12), mutations.LastParentId); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Tables.SetParent", failure.Operation); + Assert.Equal("Cheat Engine did not return the expected Address List contract.", failure.Message); + } + + [Fact] + public void SetParentThrowsAClassifiedMissingParentFailure() + { + TableClient client = CreateClient(new FakeRecordMutationPort + { + SetParentStatus = TableRecordMutationStatus.ParentNotFound + }); + + CheatEngineOperationException exception = Assert.Throws(() => + client.SetParent(new MemoryRecordId(41), new MemoryRecordId(12), TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.NotFound, exception.Failure.Kind); + Assert.Equal("Tables.SetParent", exception.Failure.Operation); + Assert.Contains("parent", exception.Failure.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ParentRelationshipGuardPermitsARootTerminatedCandidateChain() + { + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 4, + static id => id == new MemoryRecordId(12) ? ParentChainStep.Root : ParentChainStep.HostRejected); + + Assert.Equal(TableRecordMutationStatus.Success, status); + } + + [Fact] + public void ParentRelationshipGuardPropagatesARejectedHostRead() + { + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 4, static _ => ParentChainStep.HostRejected); + + Assert.Equal(TableRecordMutationStatus.HostRejected, status); + } + + [Fact] + public void ParentRelationshipGuardRejectsAnIndirectCycleBackToTheChild() + { + Dictionary links = new() + { + [new MemoryRecordId(12)] = ParentChainStep.Parent(new MemoryRecordId(24)), + [new MemoryRecordId(24)] = ParentChainStep.Parent(new MemoryRecordId(41)) + }; + + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 4, + id => links[id]); + + Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + } + + [Fact] + public void ParentRelationshipGuardRejectsAnExistingParentLoop() + { + Dictionary links = new() + { + [new MemoryRecordId(12)] = ParentChainStep.Parent(new MemoryRecordId(24)), + [new MemoryRecordId(24)] = ParentChainStep.Parent(new MemoryRecordId(12)) + }; + + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 4, + id => links[id]); + + Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + } + + [Fact] + public void ParentRelationshipGuardRejectsAChainThatExceedsItsBound() + { + TableRecordMutationStatus status = TableParentRelationshipGuard.Validate(new MemoryRecordId(41), + new MemoryRecordId(12), 2, + static id => id switch + { + { Value: 12 } => ParentChainStep.Parent(new MemoryRecordId(24)), + { Value: 24 } => ParentChainStep.Parent(new MemoryRecordId(36)), + _ => ParentChainStep.Root + }); + + Assert.Equal(TableRecordMutationStatus.InvalidRelationship, status); + } + + private static TableClient CreateClient(FakeRecordMutationPort mutations) + { + return new TableClient(new InlineDispatcher(), CoreClientPolicy.SafeDefaults, mutations); + } + + private static MemoryRecordSnapshot Snapshot(int id, string description) + { + return new MemoryRecordSnapshot( + new MemoryRecordId(id), + 0, + new MemoryRecordContentSnapshot(description, "game.exe+24", "50", VariableType.Dword), + new MemoryRecordStateSnapshot(null)); + } + + private sealed class FakeRecordMutationPort : ITableRecordMutationPort + { + internal TableRecordMutationStatus DeleteStatus + { + get; + init; + } = TableRecordMutationStatus.Success; + + internal TableRecordMutationStatus SetParentStatus + { + get; + init; + } = TableRecordMutationStatus.Success; + + internal MemoryRecordSnapshot SetParentRecord + { + get; + init; + } + + internal MemoryRecordId LastDeletedId + { + get; + private set; + } + + internal MemoryRecordId LastChildId + { + get; + private set; + } + + internal MemoryRecordId? LastParentId + { + get; + private set; + } + + internal int SetParentCallCount + { + get; + private set; + } + + public TableRecordMutationStatus TryDelete(MemoryRecordId id) + { + LastDeletedId = id; + return DeleteStatus; + } + + public TableRecordMutationStatus TrySetParent(MemoryRecordId childId, MemoryRecordId? parentId, + out MemoryRecordSnapshot record) + { + SetParentCallCount++; + LastChildId = childId; + LastParentId = parentId; + record = SetParentRecord; + return SetParentStatus; + } + } + + private sealed class InlineDispatcher : ICheatEngineDispatcher + { + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, [MaybeNullWhen(false)] out T result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatcher", "Cancelled."); + return false; + } + + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public T Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out T? result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs new file mode 100644 index 0000000..f726c38 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerExceptionCoverageTests.cs @@ -0,0 +1,24 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class UnavailableValueScannerExceptionCoverageTests +{ + [Fact] + public void CreateSessionThrowsTheCancellationFailureWhenCancellationPrecedesTheCapabilityGate() + { + UnavailableValueScanner scanner = new(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = scanner.CreateSession(cancellation.Token); + }); + + Assert.Equal(CheatEngineFailureKind.Cancelled, exception.Failure.Kind); + Assert.Equal("Scans.CreateSession", exception.Failure.Operation); + Assert.Equal("The operation was cancelled before Cheat Engine work began.", exception.Failure.Message); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs new file mode 100644 index 0000000..52a590a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableValueScannerTests.cs @@ -0,0 +1,52 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Results; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class UnavailableValueScannerTests +{ + [Fact] + public void TryCreateSessionReportsCapabilityUnavailableBeforeTheLiveOwnershipGate() + { + UnavailableValueScanner scanner = new(); + + bool succeeded = scanner.TryCreateSession( + out IValueScanSession? session, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(session); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal((string?) "Scans.CreateSession", (string?) failure.Operation); + Assert.Contains("no public ownership factory", failure.Message, StringComparison.Ordinal); + Assert.Contains("cannot return CEObject", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void TryCreateSessionReportsCancellationWithoutAttemptingHostWork() + { + UnavailableValueScanner scanner = new(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = scanner.TryCreateSession(out IValueScanSession? session, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Null(session); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + } + + [Fact] + public void CreateSessionThrowsTheClassifiedUnavailableFailure() + { + UnavailableValueScanner scanner = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = scanner.CreateSession(TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs new file mode 100644 index 0000000..812faf9 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ValueScanSessionStateMachineTests.cs @@ -0,0 +1,64 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Scanning; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class ValueScanSessionStateMachineTests +{ + [Fact] + public void FirstAndNextScansFollowTheManagedHappyPath() + { + ValueScanSessionStateMachine state = new(); + + state.BeginFirstScan(); + Assert.Equal(ValueScanSessionState.Scanning, state.State); + + state.CompleteScan(); + Assert.Equal(ValueScanSessionState.ResultsReady, state.State); + + state.BeginNextScan(); + Assert.Equal(ValueScanSessionState.Scanning, state.State); + + state.CompleteScan(); + Assert.Equal(ValueScanSessionState.ResultsReady, state.State); + } + + [Fact] + public void FailedStartedOperationInvalidatesUntilReset() + { + ValueScanSessionStateMachine state = new(); + state.BeginFirstScan(); + + state.Invalidate(); + + Assert.Equal(ValueScanSessionState.Invalidated, state.State); + Assert.Throws(state.BeginFirstScan); + Assert.Throws(state.BeginNextScan); + + state.Reset(); + Assert.Equal(ValueScanSessionState.Created, state.State); + } + + [Fact] + public void ResetDoesNotInterruptScanning() + { + ValueScanSessionStateMachine state = new(); + state.BeginFirstScan(); + + Assert.Throws(state.Reset); + Assert.Equal(ValueScanSessionState.Scanning, state.State); + } + + [Fact] + public void DisposalIsIdempotentAndTerminal() + { + ValueScanSessionStateMachine state = new(); + + state.MarkDisposed(); + state.MarkDisposed(); + + Assert.Equal(ValueScanSessionState.Disposed, state.State); + Assert.Throws(state.Reset); + Assert.Throws(state.BeginFirstScan); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyFailureCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyFailureCoverageTests.cs new file mode 100644 index 0000000..1413add --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyFailureCoverageTests.cs @@ -0,0 +1,78 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreClientPolicyFailureCoverageTests +{ + [Fact] + public void TryAuthorizeTableFileRejectsAnEmptyPathBeforeItCanBeNormalized() + { + CoreClientPolicy policy = CoreClientPolicy.SafeDefaults; + + bool allowed = policy.TryAuthorizeTableFile(" ", false, out string reason); + + Assert.False(allowed); + Assert.Equal("The table path must be non-empty and fully qualified.", reason); + } + + [Fact] + public void TryAuthorizeTableFileRejectsAPathThatCannotBeNormalizedSafely() + { + CoreClientPolicy policy = CoreClientPolicy.SafeDefaults; + string pathContainingANullCharacter = new('\0', 1); + + bool allowed = policy.TryAuthorizeTableFile(pathContainingANullCharacter, false, out string reason); + + Assert.False(allowed); + Assert.Equal("The table path cannot be normalized safely.", reason); + } + + [Fact] + public void TryAuthorizeTableFileRejectsAConfiguredRootThatNoLongerExists() + { + string root = Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Tests", Guid.NewGuid().ToString("N")); + CoreClientPolicy policy = new([root], false); + string candidate = Path.Combine(root, "profile.ct"); + + bool allowed = policy.TryAuthorizeTableFile(candidate, false, out string reason); + + Assert.False(allowed); + Assert.Equal("The configured table root does not currently exist and cannot be verified.", reason); + } + + [Fact] + public void TryAuthorizeTableFileRejectsAnExportWhoseParentDirectoryDoesNotExist() + { + using TemporaryDirectory directory = new(); + CoreClientPolicy policy = new([directory.Path], false); + string candidate = Path.Combine(directory.Path, "missing", "profile.ct"); + + bool allowed = policy.TryAuthorizeTableFile(candidate, false, out string reason); + + Assert.False(allowed); + Assert.Equal("A trusted table export destination must have an existing parent directory.", reason); + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "CheatEngine.Client.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + internal string Path + { + get; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyTests.cs new file mode 100644 index 0000000..3afd660 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreClientPolicyTests.cs @@ -0,0 +1,77 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreClientPolicyTests +{ + [Fact] + public void TryAuthorizeTableFileAllowsARegularExistingImportInsideItsConfiguredRoot() + { + using TemporaryDirectory directory = new(); + string source = Path.Combine(directory.Path, "profile.ct"); + File.WriteAllText(source, ""); + CoreClientPolicy policy = new([directory.Path], false); + + bool allowed = policy.TryAuthorizeTableFile(source, true, out string reason); + + Assert.True(allowed); + Assert.Equal(string.Empty, reason); + } + + [Fact] + public void TryAuthorizeTableFileRejectsAPathWithOnlyTheSameTextPrefixAsTheConfiguredRoot() + { + using TemporaryDirectory parent = new(); + string approved = Path.Combine(parent.Path, "tables"); + string similarlyNamed = Path.Combine(parent.Path, "tables-escape"); + Directory.CreateDirectory(approved); + Directory.CreateDirectory(similarlyNamed); + string candidate = Path.Combine(similarlyNamed, "profile.ct"); + File.WriteAllText(candidate, ""); + CoreClientPolicy policy = new([approved], false); + + bool allowed = policy.TryAuthorizeTableFile(candidate, true, out string reason); + + Assert.False(allowed); + Assert.Contains("outside", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryAuthorizeTableFileRejectsMissingImportSourceButAllowsANewExportInAnExistingDirectory() + { + using TemporaryDirectory directory = new(); + string candidate = Path.Combine(directory.Path, "new-profile.ct"); + CoreClientPolicy policy = new([directory.Path], false); + + bool importAllowed = policy.TryAuthorizeTableFile(candidate, true, out string importReason); + bool exportAllowed = policy.TryAuthorizeTableFile(candidate, false, out string exportReason); + + Assert.False(importAllowed); + Assert.Contains("existing regular file", importReason, StringComparison.OrdinalIgnoreCase); + Assert.True(exportAllowed); + Assert.Equal(string.Empty, exportReason); + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "CheatEngine.Client.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + internal string Path + { + get; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs new file mode 100644 index 0000000..da542e9 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreFailureFactoryTests.cs @@ -0,0 +1,70 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreFailureFactoryTests +{ + [Fact] + public void LifecycleCreatesTheStableInvalidStateFailure() + { + CheatEngineFailure failure = CoreFailureFactory.Lifecycle("Client.DrainResources", "The client is stopping."); + + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal("Client.DrainResources", failure.Operation); + Assert.Equal("The client is stopping.", failure.Message); + Assert.Null(failure.Exception); + } + + [Fact] + public void FromExceptionPreservesDedicatedActivationExpiryClassification() + { + CheatEngineActivationExpiredException exception = new("Memory.Read", "The epoch changed."); + + CheatEngineFailure failure = CoreFailureFactory.FromException("Dispatcher.Invoke", exception); + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, failure.Kind); + Assert.Equal("Dispatcher.Invoke", failure.Operation); + Assert.Equal(exception.Message, failure.Message); + Assert.Same(exception, failure.Exception); + } + + [Theory] + [InlineData("lifecycle", CheatEngineFailureKind.InvalidState)] + [InlineData("capability", CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData("global", CheatEngineFailureKind.CapabilityUnavailable)] + [InlineData("operation", CheatEngineFailureKind.OperationRejected)] + [InlineData("lua", CheatEngineFailureKind.LuaError)] + [InlineData("binding", CheatEngineFailureKind.BindingError)] + [InlineData("marshalling", CheatEngineFailureKind.InvalidHostResult)] + [InlineData("disposed", CheatEngineFailureKind.InvalidState)] + [InlineData("argument", CheatEngineFailureKind.OperationRejected)] + [InlineData("invalid-operation", CheatEngineFailureKind.OperationRejected)] + [InlineData("unknown", CheatEngineFailureKind.Unknown)] + public void FromExceptionMapsEachStableClientFailureCategory(string scenario, CheatEngineFailureKind expectedKind) + { + Exception exception = scenario switch + { + "lifecycle" => new CheatEngineClientLifecycleException("Client.Test", "Lifecycle stopped."), + "capability" => new EngineCapabilityUnavailableException("Client.Test"), + "global" => new EngineGlobalUnavailableException("Client.Test"), + "operation" => new EngineOperationFailedException("Client.Test", "Host rejected the operation."), + "lua" => new EngineLuaException("Client.Test", LuaStatus.RuntimeError), + "binding" => new EngineBindingException("Client.Test"), + "marshalling" => new EngineMarshallingException("Client.Test", EngineMarshallingDirection.Result, + "a stable result", "an invalid result"), + "disposed" => new ObjectDisposedException("Client.Test"), + "argument" => new ArgumentException("Invalid request.", nameof(scenario)), + "invalid-operation" => new InvalidOperationException("Invalid state."), + _ => new NotSupportedException("Unexpected host failure.") + }; + + CheatEngineFailure failure = CoreFailureFactory.FromException("Client.MapFailure", exception); + + Assert.Equal(expectedKind, failure.Kind); + Assert.Equal("Client.MapFailure", failure.Operation); + Assert.Same(exception, failure.Exception); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs new file mode 100644 index 0000000..c5d2896 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeBehaviorTests.cs @@ -0,0 +1,103 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreLifetimeBehaviorTests +{ + [Fact] + public void ActiveContextExposesItsEpochAndAdmitsOrdinaryWork() + { + using ControlledCoreLifetimeContext context = new() { Epoch = 42 }; + using CoreLifetime lifetime = new(context); + + lifetime.ThrowIfInactive("Test.Work"); + + Assert.Equal(42, lifetime.Epoch); + Assert.True(lifetime.IsActivationCurrent); + Assert.True(lifetime.IsCurrent); + Assert.True(lifetime.CanDispatch); + } + + [Fact] + public void StoppingContextRejectsOrdinaryWorkButAllowsMainThreadCleanupScope() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + context.Stop(); + + CheatEngineClientLifecycleException rejected = Assert.Throws(() => + lifetime.ThrowIfInactive("Test.Work")); + Assert.Equal("Test.Work", rejected.Failure.Operation); + Assert.False(lifetime.CanDispatch); + + using (lifetime.EnterCleanupScope()) + { + Assert.True(lifetime.CanDispatch); + lifetime.ThrowIfDispatchAllowed("Test.Cleanup"); + } + + Assert.False(lifetime.CanDispatch); + } + + [Fact] + public void CleanupScopeRequiresTheCapturedMainThread() + { + using ControlledCoreLifetimeContext context = new() { IsMainThread = false }; + using CoreLifetime lifetime = new(context); + + CheatEngineClientLifecycleException exception = Assert.Throws( + lifetime.EnterCleanupScope); + + Assert.Equal("Client.EnterCleanupScope", exception.Failure.Operation); + Assert.Contains("main thread", exception.Failure.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StaleContextRejectsAllLifecycleAdmissionsWithActivationExpired() + { + using ControlledCoreLifetimeContext context = new() { IsCurrent = false }; + using CoreLifetime lifetime = new(context); + + CheatEngineActivationExpiredException inactive = Assert.Throws(() => + lifetime.ThrowIfInactive("Test.Inactive")); + CheatEngineActivationExpiredException dispatch = Assert.Throws(() => + lifetime.ThrowIfDispatchAllowed("Test.Dispatch")); + + Assert.Equal("Test.Inactive", inactive.Failure.Operation); + Assert.Equal("Test.Dispatch", dispatch.Failure.Operation); + Assert.False(lifetime.IsActivationCurrent); + Assert.False(lifetime.CanDispatch); + } + + [Fact] + public void DisposeDrainsTrackedResourcesOnlyOnceAndClosesTheLifetime() + { + using ControlledCoreLifetimeContext context = new(); + CoreLifetime lifetime = new(context); + RecordingDisposable resource = new(); + lifetime.Track(resource); + + lifetime.Dispose(); + lifetime.Dispose(); + + Assert.Equal(1, resource.DisposeCount); + Assert.False(lifetime.IsActivationCurrent); + Assert.Throws(() => lifetime.ThrowIfInactive("Test.Disposed")); + } + + private sealed class RecordingDisposable : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs new file mode 100644 index 0000000..7ee41a7 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreLifetimeTests.cs @@ -0,0 +1,18 @@ +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreLifetimeTests +{ + [Fact] + public void CaptureRejectsClientConstructionOutsideAnEnabledPluginEpoch() + { + CheatEngineClientLifecycleException exception = + Assert.Throws(CoreLifetime.Capture); + + Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); + Assert.Equal("Client.Activate", exception.Failure.Operation); + Assert.Contains("has not enabled a plugin context", exception.Failure.Message, StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryClosedCoverageTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryClosedCoverageTests.cs new file mode 100644 index 0000000..517b3e6 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryClosedCoverageTests.cs @@ -0,0 +1,33 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreResourceRegistryClosedCoverageTests +{ + [Fact] + public void DisposeTargetSelectionDoesNotRepeatCleanupAfterTheRegistryHasClosed() + { + RecordingDisposable resource = new(); + CoreResourceRegistry registry = new(); + registry.Track(resource, 9); + + registry.Dispose(); + registry.DisposeTargetSelection(9); + + Assert.Equal(1, resource.DisposeCount); + } + + private sealed class RecordingDisposable : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs new file mode 100644 index 0000000..d49bf21 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/CoreResourceRegistryTests.cs @@ -0,0 +1,152 @@ +using System.Runtime.ExceptionServices; + +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class CoreResourceRegistryTests +{ + [Fact] + public void DisposeReleasesDistinctResourcesInReverseRegistrationOrderAndIsIdempotent() + { + List events = new(); + RecordingDisposable first = new("first", events); + RecordingDisposable second = new("second", events); + RecordingDisposable third = new("third", events); + CoreResourceRegistry registry = new(); + + Assert.Same(first, registry.Track(first)); + registry.Track(second); + registry.Track(third); + registry.Track(second); + + registry.Dispose(); + registry.Dispose(); + + Assert.Equal(["third", "second", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, second.DisposeCount); + Assert.Equal(1, third.DisposeCount); + } + + [Fact] + public void DisposeContinuesInReverseOrderAfterAnEarlierResourceThrows() + { + List events = []; + RecordingDisposable first = new("first", events); + RecordingDisposable failing = new("failing", events, new InvalidOperationException("expected")); + RecordingDisposable last = new("last", events); + CoreResourceRegistry registry = new(); + registry.Track(first); + registry.Track(failing); + registry.Track(last); + + InvalidOperationException exception = Assert.Throws(registry.Dispose); + + Assert.Equal("expected", exception.Message); + Assert.Equal(["last", "failing", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, failing.DisposeCount); + Assert.Equal(1, last.DisposeCount); + } + + [Fact] + public void UntrackRemovesOnlyThatResourceAndReportsWhetherItWasTracked() + { + List events = []; + RecordingDisposable retained = new("retained", events); + RecordingDisposable removed = new("removed", events); + RecordingDisposable untracked = new("untracked", events); + CoreResourceRegistry registry = new(); + registry.Track(retained); + registry.Track(removed); + + Assert.True(registry.Untrack(removed)); + Assert.False(registry.Untrack(untracked)); + + registry.Dispose(); + + Assert.Equal(["retained"], events); + Assert.Equal(0, removed.DisposeCount); + } + + [Fact] + public void TrackAfterDisposeRejectsNewResources() + { + CoreResourceRegistry registry = new(); + registry.Dispose(); + + Assert.Throws(() => registry.Track(new RecordingDisposable("late", []))); + } + + [Fact] + public void DisposeTargetSelectionReleasesOnlyMatchingResourcesInReverseRegistrationOrder() + { + List events = []; + RecordingDisposable firstSelectionResource = new("first-selection", events); + RecordingDisposable activationResource = new("activation", events); + RecordingDisposable retainedResource = new("retained", events); + RecordingDisposable lastSelectionResource = new("last-selection", events); + CoreResourceRegistry registry = new(); + registry.Track(firstSelectionResource, 4); + registry.Track(activationResource); + registry.Track(retainedResource, 5); + registry.Track(lastSelectionResource, 4); + + registry.DisposeTargetSelection(4); + + Assert.Equal(["last-selection", "first-selection"], events); + Assert.Equal(1, firstSelectionResource.DisposeCount); + Assert.Equal(0, activationResource.DisposeCount); + Assert.Equal(0, retainedResource.DisposeCount); + Assert.Equal(1, lastSelectionResource.DisposeCount); + + registry.Dispose(); + + Assert.Equal(["last-selection", "first-selection", "retained", "activation"], events); + Assert.Equal(1, activationResource.DisposeCount); + Assert.Equal(1, retainedResource.DisposeCount); + } + + [Fact] + public void DisposeTargetSelectionContinuesAfterFailureAndPreservesTheFirstException() + { + List events = []; + RecordingDisposable first = new("first", events); + RecordingDisposable failing = new("failing", events, new InvalidOperationException("expected")); + RecordingDisposable last = new("last", events); + CoreResourceRegistry registry = new(); + registry.Track(first, 9); + registry.Track(failing, 9); + registry.Track(last, 9); + + InvalidOperationException exception = + Assert.Throws(() => registry.DisposeTargetSelection(9)); + + Assert.Equal("expected", exception.Message); + Assert.Equal(["last", "failing", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, failing.DisposeCount); + Assert.Equal(1, last.DisposeCount); + } + + private sealed class RecordingDisposable(string name, List events, Exception? exception = null) + : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + events.Add(name); + if (exception is not null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs new file mode 100644 index 0000000..a306e83 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Infrastructure/TargetSelectionLifetimeTests.cs @@ -0,0 +1,133 @@ +using System.Runtime.ExceptionServices; + +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Infrastructure; + +public sealed class TargetSelectionLifetimeTests +{ + [Fact] + public void AdvanceInvalidatesOnlyThePreviousSelectionAndDisposesItsResourcesInLifoOrder() + { + List events = []; + TargetSelectionLifetime lifetime = new(static _ => + { + }); + long firstEpoch = lifetime.Epoch; + RecordingDisposable first = new("first", events); + RecordingDisposable second = new("second", events); + lifetime.Track(first, firstEpoch); + lifetime.Track(second, firstEpoch); + + long nextEpoch = lifetime.Advance("Process.Refresh"); + + Assert.Equal(firstEpoch + 1, nextEpoch); + Assert.Equal(["second", "first"], events); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, second.DisposeCount); + + CheatEngineClientLifecycleException exception = Assert.Throws(() => + lifetime.ThrowIfExpired(firstEpoch, "Memory.Read")); + + Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); + Assert.Equal("Memory.Read", exception.Failure.Operation); + + lifetime.ThrowIfExpired(nextEpoch, "Memory.Read"); + } + + [Fact] + public void AdvanceContinuesCleanupAfterAResourceFails() + { + List events = []; + TargetSelectionLifetime lifetime = new(static _ => + { + }); + long epoch = lifetime.Epoch; + RecordingDisposable first = new("first", events); + RecordingDisposable failing = new("failing", events, new InvalidOperationException("expected")); + RecordingDisposable last = new("last", events); + lifetime.Track(first, epoch); + lifetime.Track(failing, epoch); + lifetime.Track(last, epoch); + + InvalidOperationException exception = + Assert.Throws(() => lifetime.Advance("Process.Refresh")); + + Assert.Equal("expected", exception.Message); + Assert.Equal(["last", "failing", "first"], events); + Assert.Equal(epoch + 1, lifetime.Epoch); + } + + [Fact] + public void DisposeIsIdempotentAndRejectsNewTargetBoundRegistrations() + { + List events = []; + TargetSelectionLifetime lifetime = new(static _ => + { + }); + RecordingDisposable resource = new("resource", events); + lifetime.Track(resource, lifetime.Epoch); + + lifetime.Dispose(); + lifetime.Dispose(); + + Assert.Equal(["resource"], events); + Assert.Equal(1, resource.DisposeCount); + Assert.Throws(() => lifetime.Track(new RecordingDisposable("late", events), 0)); + } + + [Fact] + public void UntrackPreventsTheNextSelectionAdvanceFromDisposingTheReleasedResource() + { + List events = []; + TargetSelectionLifetime lifetime = new(static _ => + { + }); + RecordingDisposable resource = new("resource", events); + lifetime.Track(resource, lifetime.Epoch); + + Assert.True(lifetime.Untrack(resource)); + Assert.False(lifetime.Untrack(resource)); + + lifetime.Advance("Process.Refresh"); + + Assert.Empty(events); + Assert.Equal(0, resource.DisposeCount); + } + + [Fact] + public void ActivationGuardRejectsAnAdvanceWithoutChangingTheTargetSelectionEpoch() + { + CheatEngineActivationExpiredException expected = new("Process.Refresh", "The plugin epoch changed."); + TargetSelectionLifetime lifetime = new(static operation => + throw new CheatEngineActivationExpiredException(operation, "The plugin epoch changed.")); + + CheatEngineActivationExpiredException exception = + Assert.Throws(() => lifetime.Advance("Process.Refresh")); + + Assert.Equal(expected.Failure.Kind, exception.Failure.Kind); + Assert.Equal(expected.Failure.Operation, exception.Failure.Operation); + Assert.Equal(0, lifetime.Epoch); + } + + private sealed class RecordingDisposable(string name, List events, Exception? exception = null) + : IDisposable + { + internal int DisposeCount + { + get; + private set; + } + + public void Dispose() + { + DisposeCount++; + events.Add(name); + if (exception is not null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs new file mode 100644 index 0000000..2a6db32 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs @@ -0,0 +1,200 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +public sealed class LuaClientTests +{ + [Fact] + public void TryExecuteProvidesTheCapturedEpochOnlyDuringItsSynchronousOperation() + { + ImmediateDispatcher dispatcher = new(); + RetainingOperation operation = new(); + LuaClient client = new(dispatcher, static () => 42, static () => true); + + bool succeeded = client.TryExecute(operation, out int result, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(42, result); + Assert.Equal(default, failure); + Assert.Equal(1, dispatcher.InvocationCount); + ILuaExecutionContext? context = operation.Context; + Assert.NotNull(context); + Assert.Equal(42, context.Epoch); + Assert.False(context.IsActive); + Assert.Throws(context.ThrowIfExpired); + } + + [Fact] + public void TryExecutePreservesAnExpectedOperationFailure() + { + CheatEngineFailure expected = new(CheatEngineFailureKind.LuaError, "Lua.Custom", "The Lua global failed."); + LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); + + bool succeeded = client.TryExecute(new FailingOperation(expected), out int result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(0, result); + Assert.Equal(expected, failure); + } + + [Fact] + public void TryExecuteNormalizesAnOperationFailureWithoutDetails() + { + LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); + + bool succeeded = client.TryExecute(new FailingOperation(default), out _, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.Unknown, failure.Kind); + Assert.Equal("Lua.Execute", failure.Operation); + Assert.Contains("without an associated", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void ExecuteTurnsAnExpectedFailureIntoTheClientException() + { + CheatEngineFailure expected = new(CheatEngineFailureKind.LuaError, "Lua.Custom", "The Lua global failed."); + LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); + + CheatEngineOperationException exception = Assert.Throws(() => + client.Execute(new FailingOperation(expected), + TestContext.Current.CancellationToken)); + + Assert.Equal(expected, exception.Failure); + } + + [Fact] + public void TryExecuteThrowsActivationExpiredWhenTheContextCannotEnterTheCurrentActivation() + { + RetainingOperation operation = new(); + LuaClient client = new(new ImmediateDispatcher(), static () => 7, static () => false); + + CheatEngineActivationExpiredException exception = Assert.Throws(() => + client.TryExecute(operation, out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); + Assert.Equal("Lua.OperationContext", exception.Failure.Operation); + Assert.Null(operation.Context); + } + + [Fact] + public void TryExecuteHonorsCancellationBeforeTheTypedOperationRuns() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + ImmediateDispatcher dispatcher = new(); + RetainingOperation operation = new(); + LuaClient client = new(dispatcher, static () => 7, static () => true); + + bool succeeded = client.TryExecute( + operation, + out _, + out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(0, dispatcher.InvocationCount); + Assert.Null(operation.Context); + } + + private sealed class RetainingOperation : ILuaOperation + { + internal ILuaExecutionContext? Context + { + get; + private set; + } + + public bool TryExecute(ILuaExecutionContext context, out int result, out CheatEngineFailure failure) + { + context.ThrowIfExpired(); + Context = context; + result = checked((int) context.Epoch); + failure = default; + return true; + } + } + + private sealed class FailingOperation(CheatEngineFailure expectedFailure) : ILuaOperation + { + public bool TryExecute(ILuaExecutionContext context, out int result, out CheatEngineFailure failure) + { + context.ThrowIfExpired(); + result = default; + failure = expectedFailure; + return false; + } + } + + private sealed class ImmediateDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatch", "Cancelled."); + return false; + } + + InvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default!; + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatch", "Cancelled."); + return false; + } + + InvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out TResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs new file mode 100644 index 0000000..941ebcf --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs @@ -0,0 +1,346 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +public sealed class LuaModuleRegistrationTests +{ + [Fact] + public void TryRegisterModuleTracksItsLeaseAndDisposesItThroughTheMainThreadDispatcher() + { + ImmediateDispatcher dispatcher = new(); + List tracked = []; + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => + { + tracked.Remove(lease); + }); + + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.NotNull(lease); + Assert.Equal(81, lease.Epoch); + Assert.False(lease.IsReleased); + Assert.Equal(["diagnostics.register"], module.Events); + Assert.Equal(1, dispatcher.InvocationCount); + Assert.Single(tracked, lease); + + lease.Dispose(); + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + Assert.Equal(2, dispatcher.InvocationCount); + Assert.Empty(tracked); + } + + [Fact] + public void TryRegisterModuleRejectsTheSameModuleInstanceUntilItsLeaseIsReleased() + { + ImmediateDispatcher dispatcher = new(); + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, static () => true); + + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? firstLease, out _, + TestContext.Current.CancellationToken)); + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? duplicateLease, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(duplicateLease); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal("Lua.RegisterModule", failure.Operation); + Assert.Equal(["diagnostics.register"], module.Events); + + firstLease.Dispose(); + + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + } + + [Fact] + public void RegisterModuleReturnsTheLeaseAndThrowsTheMappedFailureForARejectedRegistration() + { + ImmediateDispatcher dispatcher = new(); + LuaClient client = CreateClient(dispatcher, static () => true); + RecordingModule accepted = new("accepted"); + RecordingModule rejected = new("rejected", new InvalidOperationException("generated registration failed")); + + using ILuaModuleLease lease = client.RegisterModule(accepted, TestContext.Current.CancellationToken); + CheatEngineOperationException exception = Assert.Throws(() => + client.RegisterModule(rejected, TestContext.Current.CancellationToken)); + + Assert.Equal(81, lease.Epoch); + Assert.Equal(CheatEngineFailureKind.OperationRejected, exception.Failure.Kind); + Assert.Equal("Lua.RegisterModule", exception.Failure.Operation); + Assert.Equal(["accepted.register"], accepted.Events); + Assert.Equal(["rejected.register"], rejected.Events); + } + + [Fact] + public void TryRegisterModuleMapsAnApplicationModuleFailureAndDoesNotCreateALease() + { + ImmediateDispatcher dispatcher = new(); + RecordingModule module = new("diagnostics", new InvalidOperationException("generated registration failed")); + LuaClient client = CreateClient(dispatcher, static () => true); + + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Lua.RegisterModule", failure.Operation); + Assert.Equal("generated registration failed", failure.Message); + Assert.Equal(["diagnostics.register"], module.Events); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void TryRegisterModuleRollsBackTheGeneratedRegistrationWhenTheActivationCannotTrackItsLease() + { + ImmediateDispatcher dispatcher = new(); + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient( + dispatcher, + static () => true, + static _ => throw new InvalidOperationException("activation is closed")); + + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + Assert.Equal(2, dispatcher.InvocationCount); + } + + [Fact] + public void ForgottenModuleLeasesReleaseInReverseRegistrationOrderFromTheActivationResourceRegistry() + { + ImmediateDispatcher dispatcher = new(); + CoreResourceRegistry registry = new(); + List events = []; + RecordingModule first = new("first", events: events); + RecordingModule second = new("second", events: events); + LuaClient client = CreateClient( + dispatcher, + static () => true, + lease => + { + registry.Track(lease); + }, + lease => + { + registry.Untrack(lease); + }); + + Assert.True(client.TryRegisterModule(first, out _, out _, TestContext.Current.CancellationToken)); + Assert.True(client.TryRegisterModule(second, out _, out _, TestContext.Current.CancellationToken)); + + registry.Dispose(); + + Assert.Equal(["first.register", "second.register", "second.unregister", "first.unregister"], events); + Assert.Equal(4, dispatcher.InvocationCount); + } + + [Fact] + public void StaleModuleLeaseDoesNotAttemptToDispatchIntoAChangedActivation() + { + ImmediateDispatcher dispatcher = new(); + bool activationIsCurrent = true; + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, () => activationIsCurrent); + + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + activationIsCurrent = false; + + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["diagnostics.register"], module.Events); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void DispatcherRejectionLeavesTheLeaseTrackedForTheLaterCleanupDispatch() + { + ImmediateDispatcher dispatcher = new(); + List tracked = []; + RecordingModule module = new("diagnostics"); + LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => + { + tracked.Remove(lease); + }); + + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + dispatcher.InvokeException = + new CheatEngineClientLifecycleException("Dispatcher.Invoke", "Activation stopping."); + + Assert.Throws(lease.Dispose); + + Assert.False(lease.IsReleased); + Assert.Single(tracked, lease); + Assert.Equal(["diagnostics.register"], module.Events); + + dispatcher.InvokeException = null; + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Empty(tracked); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + } + + [Fact] + public void FailedLeaseDisposeRemainsTrackedAndCanBeRetriedByTheActivationCleanupPath() + { + ImmediateDispatcher dispatcher = new(); + List tracked = []; + RecordingModule module = new("diagnostics", unregisterFailureCount: 1); + LuaClient client = CreateClient(dispatcher, static () => true, tracked.Add, lease => + { + tracked.Remove(lease); + }); + + Assert.True(client.TryRegisterModule(module, out ILuaModuleLease? lease, out _, + TestContext.Current.CancellationToken)); + + InvalidOperationException exception = Assert.Throws(lease.Dispose); + + Assert.Equal("generated unregistration failed", exception.Message); + Assert.False(lease.IsReleased); + Assert.Single(tracked, lease); + Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); + + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Empty(tracked); + Assert.Equal(["diagnostics.register", "diagnostics.unregister", "diagnostics.unregister"], module.Events); + Assert.Equal(3, dispatcher.InvocationCount); + } + + private static LuaClient CreateClient( + ICheatEngineDispatcher dispatcher, + Func isActivationCurrent, + Action? trackLease = null, + Action? untrackLease = null) + { + return new LuaClient(dispatcher, static () => 81, isActivationCurrent, trackLease, untrackLease); + } + + private sealed class RecordingModule( + string name, + Exception? registerException = null, + List? events = null, + int unregisterFailureCount = 0) : ILuaModule + { + private int _remainingUnregisterFailures = unregisterFailureCount; + + internal List Events + { + get; + } = events ?? []; + + public void Register() + { + Events.Add(name + ".register"); + if (registerException is not null) + { + throw registerException; + } + } + + public void Unregister() + { + Events.Add(name + ".unregister"); + if (_remainingUnregisterFailures-- > 0) + { + throw new InvalidOperationException("generated unregistration failed"); + } + } + } + + private sealed class ImmediateDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + internal Exception? InvokeException + { + get; + set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatch", "Cancelled."); + return false; + } + + InvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + if (cancellationToken.IsCancellationRequested) + { + result = default!; + failure = new CheatEngineFailure(CheatEngineFailureKind.Cancelled, "Test.Dispatch", "Cancelled."); + return false; + } + + InvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (InvokeException is not null) + { + throw InvokeException; + } + + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out var result, out var failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaOperationContextTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaOperationContextTests.cs new file mode 100644 index 0000000..38a6360 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaOperationContextTests.cs @@ -0,0 +1,38 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +public sealed class LuaOperationContextTests +{ + [Fact] + public void ContextIsActiveUntilItIsExplicitlyExpired() + { + LuaOperationContext context = new(12, static () => true); + + Assert.Equal(12, context.Epoch); + Assert.True(context.IsActive); + context.ThrowIfExpired(); + + context.Expire(); + + Assert.False(context.IsActive); + CheatEngineActivationExpiredException exception = + Assert.Throws(context.ThrowIfExpired); + Assert.Equal(CheatEngineFailureKind.ActivationExpired, exception.Failure.Kind); + } + + [Fact] + public void ContextRejectsAStaleActivationWithoutNeedingExplicitExpiration() + { + bool isCurrent = false; + LuaOperationContext context = new(12, () => isCurrent); + + Assert.False(context.IsActive); + Assert.Throws(context.ThrowIfExpired); + + isCurrent = true; + + Assert.True(context.IsActive); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs new file mode 100644 index 0000000..8c1c020 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Lua/UnsafeLuaClientTests.cs @@ -0,0 +1,75 @@ +using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Tests.Lua; + +public sealed class UnsafeLuaClientTests +{ + [Fact] + public void TryExecuteRejectsUnsafeLuaBeforeDispatchWhenTheActivationDidNotOptIn() + { + RecordingDispatcher dispatcher = new(); + UnsafeLuaClient client = new(dispatcher, new CoreClientPolicy([], false)); + + bool succeeded = client.TryExecute(new LuaScript("return 42"), out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal("Lua.ExecuteUnsafe", failure.Operation); + Assert.Equal(0, dispatcher.InvocationCount); + } + + [Fact] + public void TryExecuteRejectsTheDefaultLuaScriptBeforeCheckingOrDispatchingUnsafeExecution() + { + RecordingDispatcher dispatcher = new(); + UnsafeLuaClient client = new(dispatcher, new CoreClientPolicy([], true)); + + Assert.Throws(() => + client.TryExecute(default, out _, TestContext.Current.CancellationToken)); + Assert.Equal(0, dispatcher.InvocationCount); + } + + private sealed class RecordingDispatcher : ICheatEngineDispatcher + { + public int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + InvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + InvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + callback(); + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + return callback(); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/README.md b/tests/CheatEngine.Client.Core.Tests/README.md new file mode 100644 index 0000000..5dd0f0a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/README.md @@ -0,0 +1,32 @@ +# CheatEngine.Client.Core.Tests + +## Context + +This is the unit-test suite for Core's internal adapters and activation-bound orchestration. It uses internal ports and +fakes where a real Cheat Engine host would otherwise be required. + +## Why this project exists + +Core owns the behavior that is easy to get wrong at the SDK boundary: dispatch admission, epoch expiration, target +selection changes, LIFO resource cleanup, failure mapping, protected Lua calls and module leases, memory codecs, AOB +result ownership, process/runtime adapters, table mutations, and symbol-registration cleanup. + +It also verifies the conservative value-scan gate and its managed state machine. It does not fabricate SDK-owned scan +handles or present an unvalidated Cheat Engine 7.7 x64 value-scan lifecycle as supported. + +## How it helps improve CheatEngine.Client + +The suite separates deterministic policy tests from live-host validation. That lets Core prove cleanup ordering, +rollback behavior, bounded materialization, and stale-resource rejection without mocking SDK statics or requiring a +user process. Regressions in lifecycle code fail quickly before they can leak into a plugin activation. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Core.Tests\CheatEngine.Client.Core.Tests.csproj --configuration Release +``` + +Run the opt-in Cheat Engine live suite separately when it is available and a controlled local fixture has been +authorized; this project is not a substitute for that host-level gate. diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/ControlledCoreLifetimeContext.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/ControlledCoreLifetimeContext.cs new file mode 100644 index 0000000..90ac046 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/ControlledCoreLifetimeContext.cs @@ -0,0 +1,44 @@ +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +internal sealed class ControlledCoreLifetimeContext : ICoreLifetimeContext, IDisposable +{ + private readonly CancellationTokenSource _stopping = new(); + + internal long Epoch + { + get; + set; + } = 17; + + internal bool IsCurrent + { + get; + set; + } = true; + + internal bool IsMainThread + { + get; + set; + } = true; + + CancellationToken ICoreLifetimeContext.Stopping => _stopping.Token; + + long ICoreLifetimeContext.Epoch => Epoch; + + bool ICoreLifetimeContext.IsCurrent => IsCurrent; + + bool ICoreLifetimeContext.IsMainThread => IsMainThread; + + public void Dispose() + { + _stopping.Dispose(); + } + + internal void Stop() + { + _stopping.Cancel(); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs new file mode 100644 index 0000000..b7a9562 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/InertCoreLifetime.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; + +using CheatEngine.Client.Core.Infrastructure; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +internal static class InertCoreLifetime +{ + internal static CoreLifetime Create() + { + return (CoreLifetime) RuntimeHelpers.GetUninitializedObject(typeof(CoreLifetime)); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/TestSupport/InlineMainThreadInvoker.cs b/tests/CheatEngine.Client.Core.Tests/TestSupport/InlineMainThreadInvoker.cs new file mode 100644 index 0000000..e826a9a --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/TestSupport/InlineMainThreadInvoker.cs @@ -0,0 +1,31 @@ +using CheatEngine.Client.Core.Dispatching; + +namespace CheatEngine.Client.Core.Tests.TestSupport; + +internal sealed class InlineMainThreadInvoker : IMainThreadInvoker +{ + public Exception? Invoke(Action callback) + { + try + { + callback(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + public MainThreadInvocationResult Invoke(Func callback) + { + try + { + return new MainThreadInvocationResult(callback(), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/packages.lock.json b/tests/CheatEngine.Client.Core.Tests/packages.lock.json new file mode 100644 index 0000000..13e5d02 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/packages.lock.json @@ -0,0 +1,205 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Direct", + "requested": "[18.11.2, )", + "resolved": "18.11.2", + "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.2.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Testing.Platform": "2.4.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.2.10", + "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + } + } + } +} 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..d138fa7 --- /dev/null +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs @@ -0,0 +1,229 @@ +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..bb6c810 --- /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.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.IsType>(provider.GetRequiredService>(), false); + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); + Assert.Single(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec
)); + } + + [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..ce9ff0e --- /dev/null +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs @@ -0,0 +1,416 @@ +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.Fluent.Tests.Memory; + +public sealed class MemoryAddressBuilderTests +{ + [Fact] + public void AtWithoutServiceRejectsABuiltInTerminalOperation() + { + MemoryAddressBuilder builder = MemoryFluent.At(0x401000UL); + + InvalidOperationException exception = + Assert.Throws(() => builder.Read(TestContext.Current.CancellationToken)); + + Assert.Contains("Memory.At(memory, address)", exception.Message); + Assert.Contains("memory.At(address)", exception.Message); + Assert.Contains("Using(memory)", exception.Message); + Assert.DoesNotContain("pass the service to Read/Write", exception.Message); + } + + [Fact] + public void UsingCreatesABoundBuilderAndForwardsBuiltInReads() + { + Address address = 0x401000; + FakeMemoryClient memory = new(1337); + MemoryAddressBuilder unbound = MemoryFluent.At(address); + + int value = unbound.Using(memory).Read(TestContext.Current.CancellationToken); + + Assert.Equal(1337, value); + Assert.Equal(address, memory.LastPrimitiveReadAddress); + Assert.Equal(typeof(int), memory.LastPrimitiveReadType); + Assert.Throws(() => unbound.Read(TestContext.Current.CancellationToken)); + } + + [Fact] + public void MemoryExtensionForwardsBuiltInWritesWithoutACodec() + { + Address address = 0x402000; + FakeMemoryClient memory = new(0); + + bool succeeded = memory.At(address) + .TryWrite(77, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastPrimitiveWriteAddress); + Assert.Equal(typeof(int), memory.LastPrimitiveWriteType); + Assert.Equal(77, memory.LastPrimitiveWriteValue); + } + + [Fact] + public void ReadWithForwardsTheExplicitCustomCodecWithoutReplacingIt() + { + Address address = 0x403000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(42); + + int value = MemoryFluent.At(address).Using(memory).ReadWith(codec, TestContext.Current.CancellationToken); + + Assert.Equal(42, value); + Assert.Equal(address, memory.LastCustomReadAddress); + Assert.Same(codec, memory.LastCustomReadCodec); + } + + [Fact] + public void TryWriteWithForwardsValueAddressAndTheExplicitCustomCodec() + { + Address address = 0x404000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + bool succeeded = MemoryFluent.At(address).Using(memory).TryWriteWith( + 77, codec, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastCustomWriteAddress); + Assert.Equal(77, memory.LastCustomWriteValue); + Assert.Same(codec, memory.LastCustomWriteCodec); + } + + [Fact] + public void TryReadForwardsBuiltInReadToTheBoundService() + { + Address address = 0x405000; + FakeMemoryClient memory = new(1337); + + bool succeeded = MemoryFluent.At(address).Using(memory).TryRead( + out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(1337, value); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastPrimitiveReadAddress); + Assert.Equal(typeof(int), memory.LastPrimitiveReadType); + } + + [Fact] + public void WriteForwardsBuiltInWriteToTheBoundService() + { + Address address = 0x406000; + FakeMemoryClient memory = new(0); + + MemoryFluent.At(address).Using(memory).Write(77, TestContext.Current.CancellationToken); + + Assert.Equal(address, memory.LastPrimitiveWriteAddress); + Assert.Equal(typeof(int), memory.LastPrimitiveWriteType); + Assert.Equal(77, memory.LastPrimitiveWriteValue); + } + + [Fact] + public void TryReadWithForwardsTheBoundCustomCodecWithoutReplacingIt() + { + Address address = 0x407000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(42); + + bool succeeded = MemoryFluent.At(address).Using(memory).TryReadWith( + codec, out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(42, value); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastCustomReadAddress); + Assert.Same(codec, memory.LastCustomReadCodec); + } + + [Fact] + public void TryReadWithForwardsTheExplicitMemoryServiceAndCustomCodec() + { + Address address = 0x408000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(42); + + bool succeeded = MemoryFluent.At(address).TryReadWith( + memory, codec, out int value, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(42, value); + Assert.Equal(default, failure); + Assert.Equal(address, memory.LastCustomReadAddress); + Assert.Same(codec, memory.LastCustomReadCodec); + } + + [Fact] + public void TryReadWithRejectsMissingBoundMemoryAndNullExplicitArguments() + { + MemoryAddressBuilder unbound = MemoryFluent.At(0x409000UL); + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + Assert.Throws(() => unbound.TryReadWith( + codec, out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => unbound.TryReadWith( + null!, codec, out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => unbound.TryReadWith( + memory, null!, out int _, out _, TestContext.Current.CancellationToken)); + } + + [Fact] + public void WriteWithForwardsTheBoundCustomCodecWithoutReplacingIt() + { + Address address = 0x40A000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + MemoryFluent.At(address).Using(memory).WriteWith(77, codec, TestContext.Current.CancellationToken); + + Assert.Equal(address, memory.LastCustomWriteAddress); + Assert.Equal(77, memory.LastCustomWriteValue); + Assert.Same(codec, memory.LastCustomWriteCodec); + } + + [Fact] + public void WriteWithForwardsTheExplicitMemoryServiceAndCustomCodec() + { + Address address = 0x40B000; + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + MemoryFluent.At(address).WriteWith(memory, 77, codec, TestContext.Current.CancellationToken); + + Assert.Equal(address, memory.LastCustomWriteAddress); + Assert.Equal(77, memory.LastCustomWriteValue); + Assert.Same(codec, memory.LastCustomWriteCodec); + } + + [Fact] + public void WriteWithRejectsMissingBoundMemoryAndNullExplicitArguments() + { + MemoryAddressBuilder unbound = MemoryFluent.At(0x40C000UL); + Int32Codec codec = new(); + FakeMemoryClient memory = new(0); + + Assert.Throws(() => + unbound.WriteWith(77, codec, TestContext.Current.CancellationToken)); + Assert.Throws(() => unbound.WriteWith( + null!, 77, codec, TestContext.Current.CancellationToken)); + Assert.Throws(() => unbound.WriteWith( + memory, 77, null!, TestContext.Current.CancellationToken)); + } + + private sealed class Int32Codec : IMemoryCodec + { + public bool TryRead(IMemoryReadContext context, Address address, out int value) + { + value = default; + return false; + } + + public bool TryWrite(IMemoryWriteContext context, Address address, in int value) + { + return false; + } + } + + private sealed class FakeMemoryClient(int primitiveReadValue) : IMemoryClient + { + internal Address LastPrimitiveReadAddress + { + get; + private set; + } + + internal Type? LastPrimitiveReadType + { + get; + private set; + } + + internal Address LastPrimitiveWriteAddress + { + get; + private set; + } + + internal Type? LastPrimitiveWriteType + { + get; + private set; + } + + internal object? LastPrimitiveWriteValue + { + get; + private set; + } + + internal Address LastCustomReadAddress + { + get; + private set; + } + + internal object? LastCustomReadCodec + { + get; + private set; + } + + internal Address LastCustomWriteAddress + { + get; + private set; + } + + internal object? LastCustomWriteCodec + { + get; + private set; + } + + internal object? LastCustomWriteValue + { + get; + private set; + } + + public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + LastPrimitiveReadAddress = address; + LastPrimitiveReadType = typeof(T); + value = (T) (object) primitiveReadValue; + failure = default; + return true; + } + + public T ReadPrimitive(Address address, CancellationToken cancellationToken = default) + { + _ = TryReadPrimitive(address, out T? value, out _, cancellationToken); + return value!; + } + + public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + LastPrimitiveWriteAddress = address; + LastPrimitiveWriteType = typeof(T); + LastPrimitiveWriteValue = value; + failure = default; + return true; + } + + public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default) + { + _ = TryWritePrimitive(address, value, out _, cancellationToken); + } + + public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + bytes = []; + failure = default; + return true; + } + + public ImmutableArray ReadBytes(MemoryBytesReadRequest request, + CancellationToken cancellationToken = default) + { + _ = TryReadBytes(request, out ImmutableArray bytes, out _, cancellationToken); + return bytes; + } + + public bool TryWriteBytes(MemoryBytesWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + failure = default; + return true; + } + + public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancellationToken = default) + { + _ = TryWriteBytes(request, out _, cancellationToken); + } + + public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] out string? value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + value = string.Empty; + failure = default; + return true; + } + + public string ReadString(MemoryStringReadRequest request, CancellationToken cancellationToken = default) + { + _ = TryReadString(request, out string? value, out _, cancellationToken); + return value!; + } + + public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + failure = default; + return true; + } + + public void WriteString(MemoryStringWriteRequest request, CancellationToken cancellationToken = default) + { + _ = TryWriteString(request, out _, cancellationToken); + } + + public bool TryResolvePointerChain(PointerChainRequest request, out Address address, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + address = request.BaseAddress; + failure = default; + return true; + } + + public Address ResolvePointerChain(PointerChainRequest request, CancellationToken cancellationToken = default) + { + _ = TryResolvePointerChain(request, out Address address, out _, cancellationToken); + return address; + } + + public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + LastCustomReadAddress = request.Address; + LastCustomReadCodec = request.Codec; + value = (T) (object) primitiveReadValue; + failure = default; + return true; + } + + public T Read(MemoryReadRequest request, CancellationToken cancellationToken = default) + { + _ = TryRead(request, out T? value, out _, cancellationToken); + return value!; + } + + public bool TryWrite(MemoryWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + LastCustomWriteAddress = request.Address; + LastCustomWriteValue = request.Value; + LastCustomWriteCodec = request.Codec; + failure = default; + return true; + } + + public void Write(MemoryWriteRequest request, CancellationToken cancellationToken = default) + { + _ = TryWrite(request, out _, cancellationToken); + } + } +} diff --git a/tests/CheatEngine.Client.Fluent.Tests/README.md b/tests/CheatEngine.Client.Fluent.Tests/README.md index 11323cf..f9d7e76 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/README.md +++ b/tests/CheatEngine.Client.Fluent.Tests/README.md @@ -1,3 +1,26 @@ # CheatEngine.Client.Fluent.Tests -Tests of [`CheatEngine.Client.Fluent`](../../libs/CheatEngine.Client.Fluent/README.md). References its subject only. +## Context + +This project tests the public fluent builders for AOB scanning and typed memory access. The builders are pure managed +values; they do not retain a Cheat Engine object, Lua state, or SDK ownership handle. + +## Why this project exists + +The fluent layer is the consumer-facing expression of limits and intent. It must preserve the caller's pattern, +module/range/protection/alignment filters, codec choice, and bounded terminal behavior until execution is delegated to +the Client facade. + +## How it helps improve CheatEngine.Client + +The tests prove builder immutability, correct forwarding, invalid-argument rejection, and the bounded semantics of +`FirstOrNone`, `RequireSingle`, and `Take`. This keeps ergonomic calls such as `client.Patterns.Aob(...).Take(...)` +predictable without making a live scan part of a unit test. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Fluent.Tests\CheatEngine.Client.Fluent.Tests.csproj --configuration Release +``` diff --git a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs new file mode 100644 index 0000000..d6a8ab3 --- /dev/null +++ b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs @@ -0,0 +1,432 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Fluent.Tests.Scanning; + +public sealed class AobFluentBuilderTests +{ + [Fact] + public void ConfigurationMethodsReturnNewBuilderWithoutChangingTheOriginal() + { + FakePatternScanner scanner = new(); + AobScanBuilder original = scanner.Aob("48 8B ?? 89"); + + AobScanBuilder configured = original.InModule("game.exe").InRange(0x400000, 0x4FFFFF).ReadableExecutable(); + + Assert.Null(original.Module); + Assert.Null(original.Range); + Assert.Null(original.Options.ProtectionFlags); + Assert.Equal("game.exe", configured.Module!.Value.Value); + Assert.Equal(new AobScanRange(0x400000, 0x4FFFFF), configured.Range); + Assert.Equal("+X-C-W", configured.Options.ProtectionFlags); + Assert.Equal("48 8B ?? 89", configured.Pattern.Value); + } + + [Fact] + public void RequireSingleUsesTwoResultLimitAndReturnsTheOnlyMatch() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], false)); + + Address actual = scanner.Aob("90 90").InModule("game.exe").RequireSingle() + .Execute(TestContext.Current.CancellationToken); + + Assert.Equal(expected, actual); + AobScanRequest? request = scanner.LastRequest; + Assert.NotNull(request); + Assert.Equal(2, request.Value.MaximumResults); + Assert.Equal("game.exe", request.Value.Module!.Value.Value); + } + + [Fact] + public void FluentFiltersNormalizeAndForwardProtectionAlignmentAndRange() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], false)); + + Address actual = scanner.Aob("90") + .WithProtectionFlags("-w+x-c") + .WithAlignment(FastScanMethod.LastDigits, "f0") + .InRange(0x400000, 0x4FFFFF) + .RequireSingle() + .Execute(TestContext.Current.CancellationToken); + + Assert.Equal(expected, actual); + AobScanRequest request = Assert.IsType(scanner.LastRequest); + Assert.Equal("+X-C-W", request.Options.ProtectionFlags); + Assert.Equal(FastScanMethod.LastDigits, request.Options.AlignmentMethod); + Assert.Equal("F0", request.Options.AlignmentParameter); + Assert.Equal(new AobScanRange(0x400000, 0x4FFFFF), request.Range); + } + + [Theory] + [InlineData("+X+X")] + [InlineData("X")] + public void WithProtectionFlagsRejectsMalformedExpressionsBeforeTerminalSelection(string protection) + { + FakePatternScanner scanner = new(); + + Assert.Throws(() => scanner.Aob("90").WithProtectionFlags(protection)); + Assert.Null(scanner.LastRequest); + } + + [Fact] + public void WithAlignmentRejectsAnInvalidDivisorBeforeTerminalSelection() + { + FakePatternScanner scanner = new(); + + Assert.Throws(() => scanner.Aob("90").WithAlignment(FastScanMethod.Aligned, "0")); + Assert.Null(scanner.LastRequest); + } + + [Fact] + public void RequireSingleReportsAmbiguousMatchWhenTheBoundedResultIsTruncated() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], true)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, failure.Kind); + Assert.Equal("Aob.RequireSingle", failure.Operation); + } + + [Fact] + public void FirstOrNoneUsesOneResultLimitAndTreatsNoMatchAsSuccess() + { + FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false)); + + bool succeeded = scanner.Aob("90").FirstOrNone().TryExecute( + out Address? address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Null(address); + Assert.Equal(default, failure); + AobScanRequest? request = scanner.LastRequest; + Assert.NotNull(request); + Assert.Equal(1, request.Value.MaximumResults); + } + + [Fact] + public void FirstOrNoneRejectsAHostResponseBeyondItsOneMatchLimit() + { + FakePatternScanner scanner = new(new AobScanResult([0x401000, 0x402000], false)); + + bool succeeded = scanner.Aob("90").FirstOrNone().TryExecute( + out Address? address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(address); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Aob.FirstOrNone", failure.Operation); + } + + [Fact] + public void RequireSingleRejectsAHostResponseBeyondItsTwoMatchLimit() + { + FakePatternScanner scanner = new(new AobScanResult([0x401000, 0x402000, 0x403000], false)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Aob.RequireSingle", failure.Operation); + } + + [Fact] + public void TakeRejectsAHostResponseBeyondTheRequestedLimit() + { + Address first = 0x401000; + Address second = 0x402000; + FakePatternScanner scanner = new(new AobScanResult([first, second], false)); + + bool succeeded = scanner.Aob("90").Take(1).TryExecute( + out AobScanResult result, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.InvalidHostResult, failure.Kind); + Assert.Equal("Aob.Take", failure.Operation); + AobScanRequest? request = scanner.LastRequest; + Assert.NotNull(request); + Assert.Equal(1, request.Value.MaximumResults); + } + + [Fact] + public void TryExecutePreservesFailureReturnedByThePatternScanner() + { + CheatEngineFailure expectedFailure = new( + CheatEngineFailureKind.CapabilityUnavailable, + "Patterns.Scan", + "AOB scanning is unavailable."); + FakePatternScanner scanner = new(expectedFailure); + + bool succeeded = scanner.Aob("90").Take(1).TryExecute( + out _, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(expectedFailure, failure); + } + + [Fact] + public void FirstOrNoneExecuteReturnsTheFirstCopiedMatch() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], false)); + + Address? actual = scanner.Aob("90").FirstOrNone().Execute(TestContext.Current.CancellationToken); + + Assert.NotNull(actual); + Assert.Equal(expected, actual.Value); + Assert.Equal(1, Assert.IsType(scanner.LastRequest).MaximumResults); + } + + [Fact] + public void FirstOrNoneExecuteThrowsThePatternScannerFailure() + { + CheatEngineFailure expectedFailure = new( + CheatEngineFailureKind.CapabilityUnavailable, + "Patterns.Scan", + "AOB scanning is unavailable."); + FakePatternScanner scanner = new(expectedFailure); + + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Aob("90").FirstOrNone().Execute(TestContext.Current.CancellationToken)); + + Assert.Equal(expectedFailure, exception.Failure); + } + + [Fact] + public void DefaultFirstOrNoneBuilderRejectsExecution() + { + AobFirstMatchBuilder builder = default; + + Assert.Throws(() => builder.Execute(TestContext.Current.CancellationToken)); + } + + [Fact] + public void TakeExecuteReturnsTheBoundedCopiedResult() + { + Address first = 0x401000; + Address second = 0x402000; + FakePatternScanner scanner = new(new AobScanResult([first, second], true)); + + AobScanResult actual = scanner.Aob("90").Take(2).Execute(TestContext.Current.CancellationToken); + + Assert.Equal([first, second], actual.Matches); + Assert.True(actual.IsTruncated); + Assert.Equal(2, Assert.IsType(scanner.LastRequest).MaximumResults); + } + + [Fact] + public void TakeExecuteThrowsThePatternScannerFailure() + { + CheatEngineFailure expectedFailure = new( + CheatEngineFailureKind.CapabilityUnavailable, + "Patterns.Scan", + "AOB scanning is unavailable."); + FakePatternScanner scanner = new(expectedFailure); + + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Aob("90").Take(1).Execute(TestContext.Current.CancellationToken)); + + Assert.Equal(expectedFailure, exception.Failure); + } + + [Fact] + public void DefaultManyMatchBuilderRejectsExecution() + { + AobManyMatchBuilder builder = default; + + Assert.Throws(() => builder.Execute(TestContext.Current.CancellationToken)); + } + + [Fact] + public void AobRejectsWhitespaceOnlyPatterns() + { + FakePatternScanner scanner = new(); + + Assert.Throws(() => scanner.Aob(" \t\r\n ")); + } + + [Fact] + public void DefaultAobScanBuilderRejectsTerminalSelection() + { + AobScanBuilder builder = default; + + Assert.Throws(() => builder.RequireSingle()); + } + + [Fact] + public void InModuleRejectsTheDefaultModuleNameBeforeTerminalSelection() + { + FakePatternScanner scanner = new(); + + Assert.Throws(() => scanner.Aob("90").InModule(default(ModuleName))); + Assert.Null(scanner.LastRequest); + } + + [Fact] + public void RequireSingleReportsNotFoundForNoMatches() + { + FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.NotFound, failure.Kind); + Assert.Equal("Aob.RequireSingle", failure.Operation); + } + + [Fact] + public void RequireSinglePreservesThePatternScannerFailure() + { + CheatEngineFailure expectedFailure = new( + CheatEngineFailureKind.CapabilityUnavailable, + "Patterns.Scan", + "AOB scanning is unavailable."); + FakePatternScanner scanner = new(expectedFailure); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(expectedFailure, failure); + } + + [Fact] + public void RequireSingleExecuteThrowsWhenNoMatchExists() + { + FakePatternScanner scanner = new(new AobScanResult(ImmutableArray
.Empty, false)); + + CheatEngineOperationException exception = Assert.Throws(() => + scanner.Aob("90").RequireSingle().Execute(TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.NotFound, exception.Failure.Kind); + Assert.Equal("Aob.RequireSingle", exception.Failure.Operation); + } + + [Fact] + public void RequireSingleReportsAmbiguousMatchForTwoNonTruncatedResults() + { + FakePatternScanner scanner = new(new AobScanResult([0x401000, 0x402000], false)); + + bool succeeded = scanner.Aob("90").RequireSingle().TryExecute( + out Address address, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, address); + Assert.Equal(CheatEngineFailureKind.AmbiguousMatch, failure.Kind); + } + + [Fact] + public void DefaultSingleMatchBuilderRejectsExecution() + { + AobSingleMatchBuilder builder = default; + + Assert.Throws(() => builder.Execute(TestContext.Current.CancellationToken)); + } + + [Fact] + public void ClientAobExtensionUsesTheClientPatternScanner() + { + Address expected = 0x401000; + FakePatternScanner scanner = new(new AobScanResult([expected], false)); + ICheatEngineClient client = new FakeCheatEngineClient(scanner); + + Address actual = client.Aob("90").RequireSingle().Execute(TestContext.Current.CancellationToken); + + Assert.Equal(expected, actual); + Assert.Equal(2, Assert.IsType(scanner.LastRequest).MaximumResults); + } + + private sealed class FakePatternScanner : IPatternScanner + { + private readonly CheatEngineFailure _failure; + private readonly AobScanResult _result; + private readonly bool _succeeds; + + internal FakePatternScanner() + : this(new AobScanResult(ImmutableArray
.Empty, false)) + { + } + + internal FakePatternScanner(AobScanResult result) + { + _result = result; + _succeeds = true; + } + + internal FakePatternScanner(CheatEngineFailure failure) + { + _failure = failure; + } + + internal AobScanRequest? LastRequest + { + get; + private set; + } + + public bool TryScan(AobScanRequest request, out AobScanResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + LastRequest = request; + result = _result; + failure = _failure; + return _succeeds; + } + + public AobScanResult Scan(AobScanRequest request, CancellationToken cancellationToken = default) + { + if (TryScan(request, out AobScanResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + } + + private sealed class FakeCheatEngineClient(IPatternScanner patterns) : ICheatEngineClient + { + public long Epoch => 0; + public CancellationToken Stopping => CancellationToken.None; + public ICheatEngineRuntime Runtime => NotUsed(); + public ICheatEngineDispatcher Dispatcher => NotUsed(); + public IProcessClient Processes => NotUsed(); + public IMemoryClient Memory => NotUsed(); + public IPatternScanner Patterns => patterns; + public IValueScanner Scans => NotUsed(); + public IInspectionClient Inspection => NotUsed(); + public ITableClient Tables => NotUsed(); + public ILuaClient Lua => NotUsed(); + + private static T NotUsed() + where T : class + { + throw new InvalidOperationException("This test client only provides a pattern scanner."); + } + } +} diff --git a/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json b/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json new file mode 100644 index 0000000..be25ffb --- /dev/null +++ b/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json @@ -0,0 +1,204 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Direct", + "requested": "[18.11.2, )", + "resolved": "18.11.2", + "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.2.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Testing.Platform": "2.4.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.2.10", + "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.fluent": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + } + } + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj b/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj new file mode 100644 index 0000000..7673ddb --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngine.Client.Hosting.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs new file mode 100644 index 0000000..db80a57 --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs @@ -0,0 +1,439 @@ +using System.Reflection; + +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +using Microsoft.Extensions.DependencyInjection; + +namespace CheatEngine.Client.Hosting.Tests; + +public sealed class CheatEngineClientPluginTests +{ + [Fact] + public void ProtectedBaseConstructorAllowsAPublicParameterlessConcretePlugin() + { + ConstructorInfo? baseConstructor = typeof(CheatEngineClientPlugin).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, + null, + Type.EmptyTypes, + null); + ConstructorInfo? concreteConstructor = typeof(TestPlugin).GetConstructor(Type.EmptyTypes); + + Assert.NotNull(baseConstructor); + Assert.True(baseConstructor.IsFamily); + Assert.NotNull(concreteConstructor); + Assert.True(concreteConstructor.IsPublic); + Assert.NotNull(new TestPlugin()); + } + + [Fact] + public void GetRequiredClientWithoutAnActiveEnableEpochThrowsLifecycleException() + { + TestPlugin plugin = new(); + + CheatEngineClientLifecycleException exception = + Assert.Throws(plugin.GetRequiredClientForTest); + + Assert.Equal(CheatEngineFailureKind.InvalidState, exception.Failure.Kind); + Assert.Equal("GetClient", exception.Failure.Operation); + Assert.Contains("only while the plugin is enabled", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void EnableThenDisableUsesOneScopedClientLifecycleAndReleasesCleanupResourcesInOrder() + { + List events = []; + FakeClient client = new(42); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, + static builder => builder.Client.AddModule()); + + plugin.EnableForTest(); + + Assert.Same(client, plugin.GetRequiredClientForTest()); + CheatEngineClientLifecycleException duplicateEnable = + Assert.Throws(plugin.EnableForTest); + Assert.Equal("EnableClient", duplicateEnable.Failure.Operation); + + plugin.DisableForTest(); + + Assert.Equal( + [ + "configure", "module.enabled", "client.enabled", "cleanup.enter", "client.disabling", + "module.disabling", "cleanup.drain", "cleanup.exit" + ], + events); + Assert.Equal(1, cleanup.DrainCount); + Assert.Equal(1, cleanup.ScopeDisposeCount); + Assert.Throws(plugin.GetRequiredClientForTest); + } + + [Fact] + public void FailedModuleEnableRollsBackAllEnteredModulesAndLeavesThePluginInactive() + { + List events = []; + FakeClient client = new(43); + RecordingCleanup cleanup = new(events); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static builder => + { + builder.Client.AddModule().AddModule(); + }); + + InvalidOperationException exception = Assert.Throws(plugin.EnableForTest); + + Assert.Equal("module enable", exception.Message); + Assert.Equal( + [ + "configure", "module.enabled", "module.enable-failed", "cleanup.enter", "module.disable-failed", + "module.disabling", "cleanup.drain", "cleanup.exit" + ], + events); + Assert.Throws(plugin.GetRequiredClientForTest); + + plugin.DisableForTest(); + + Assert.Equal(1, cleanup.DrainCount); + } + + [Fact] + public void DefaultApplicationCallbacksParticipateInTheActivationLifecycle() + { + List events = []; + FakeClient client = new(46); + RecordingCleanup cleanup = new(events); + DefaultCallbacksPlugin plugin = new(CreateConfiguration(events, client, cleanup, static _ => + { + })); + + plugin.EnableForTest(); + + Assert.Same(client, plugin.GetRequiredClientForTest()); + + plugin.DisableForTest(); + + Assert.Equal(["configure", "cleanup.enter", "cleanup.drain", "cleanup.exit"], events); + } + + [Fact] + public void DisableRethrowsOneCleanupScopeFailureAfterClosingTheActivation() + { + List events = []; + FakeClient client = new(47); + RecordingCleanup cleanup = new(events, new InvalidOperationException("cleanup scope")); + TestPlugin plugin = CreatePlugin(events, client, cleanup, static _ => + { + }); + plugin.EnableForTest(); + + InvalidOperationException exception = Assert.Throws(plugin.DisableForTest); + + Assert.Equal("cleanup scope", exception.Message); + Assert.Equal(["configure", "client.enabled", "cleanup.enter"], events); + Assert.Throws(plugin.GetRequiredClientForTest); + } + + [Fact] + public void ApplicationEnableFailureAndCleanupFailureAreReportedTogetherAfterRollback() + { + List events = []; + FakeClient client = new(44); + RecordingCleanup cleanup = new(events, drainFailure: new InvalidOperationException("drain")); + TestPlugin plugin = CreatePlugin(events, client, cleanup, + static builder => builder.Client.AddModule(), + static _ => throw new InvalidOperationException("application enable")); + + AggregateException exception = Assert.Throws(plugin.EnableForTest); + + Assert.Collection( + exception.InnerExceptions, + failure => Assert.Equal("application enable", failure.Message), + failure => Assert.Equal("drain", failure.Message)); + Assert.Equal( + [ + "configure", "module.enabled", "cleanup.enter", "client.disabling", "module.disabling", "cleanup.drain", + "cleanup.exit" + ], + events); + Assert.Equal(1, cleanup.ScopeDisposeCount); + } + + [Fact] + public void DisableAggregatesApplicationModuleAndResourceCleanupFailures() + { + List events = []; + FakeClient client = new(45); + RecordingCleanup cleanup = new(events, drainFailure: new InvalidOperationException("drain")); + TestPlugin plugin = CreatePlugin(events, client, cleanup, + static builder => builder.Client.AddModule(), + onClientDisabling: _ => + { + events.Add("client.disabling"); + throw new InvalidOperationException("application disable"); + }); + plugin.EnableForTest(); + + AggregateException exception = Assert.Throws(plugin.DisableForTest); + + Assert.Collection( + exception.InnerExceptions, + failure => Assert.Equal("application disable", failure.Message), + failure => Assert.Equal("module disable", failure.Message), + failure => Assert.Equal("drain", failure.Message)); + Assert.Equal( + [ + "configure", "module.enabled", "client.enabled", "cleanup.enter", "client.disabling", + "module.disabling", + "cleanup.drain", "cleanup.exit" + ], + events); + Assert.Equal(1, cleanup.DrainCount); + } + + [Fact] + public void ConfigureFailureDoesNotPublishAnActivation() + { + int configureCalls = 0; + TestPlugin plugin = new(_ => + { + configureCalls++; + throw new InvalidOperationException("configuration"); + }); + + InvalidOperationException exception = Assert.Throws(plugin.EnableForTest); + + Assert.Equal("configuration", exception.Message); + Assert.Equal(1, configureCalls); + Assert.Throws(plugin.GetRequiredClientForTest); + + plugin.DisableForTest(); + } + + private static TestPlugin CreatePlugin( + List events, + FakeClient client, + RecordingCleanup cleanup, + Action configure, + Action? onClientEnabled = null, + Action? onClientDisabling = null) + { + return new TestPlugin(CreateConfiguration(events, client, cleanup, configure), + onClientEnabled ?? (currentClient => events.Add("client.enabled")), + onClientDisabling ?? (currentClient => events.Add("client.disabling"))); + } + + private static Action CreateConfiguration( + List events, + FakeClient client, + RecordingCleanup cleanup, + Action configure) + { + return builder => + { + events.Add("configure"); + builder.Services.AddSingleton(events); + builder.Services.AddSingleton(client); + builder.Services.AddSingleton(cleanup); + configure(builder); + }; + } + + private sealed class TestPlugin : CheatEngineClientPlugin + { + private readonly Action _configure; + private readonly Action _onClientDisabling; + private readonly Action _onClientEnabled; + + public TestPlugin() + : this(static _ => + { + }) + { + } + + internal TestPlugin( + Action configure, + Action? onClientEnabled = null, + Action? onClientDisabling = null) + { + _configure = configure; + _onClientEnabled = onClientEnabled ?? (static _ => + { + }); + _onClientDisabling = onClientDisabling ?? (static _ => + { + }); + } + + protected override void Configure(CheatEnginePluginBuilder builder) + { + _configure(builder); + } + + protected override void OnClientEnabled(ICheatEngineClient client) + { + _onClientEnabled(client); + } + + protected override void OnClientDisabling(ICheatEngineClient client) + { + _onClientDisabling(client); + } + + internal void EnableForTest() + { + OnEnable(); + } + + internal void DisableForTest() + { + OnDisable(); + } + + internal ICheatEngineClient GetRequiredClientForTest() + { + return GetRequiredClient(); + } + } + + private sealed class DefaultCallbacksPlugin(Action configure) : CheatEngineClientPlugin + { + protected override void Configure(CheatEnginePluginBuilder builder) + { + configure(builder); + } + + internal void EnableForTest() + { + OnEnable(); + } + + internal void DisableForTest() + { + OnDisable(); + } + + internal ICheatEngineClient GetRequiredClientForTest() + { + return GetRequiredClient(); + } + } + + public sealed class RecordingModule(List events) : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + events.Add("module.enabled"); + } + + public void OnDisabling(ICheatEngineClient client) + { + events.Add("module.disabling"); + } + } + + public sealed class FailingEnableModule(List events) : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + events.Add("module.enable-failed"); + throw new InvalidOperationException("module enable"); + } + + public void OnDisabling(ICheatEngineClient client) + { + events.Add("module.disable-failed"); + } + } + + public sealed class FailingDisableModule(List events) : ICheatEngineClientModule + { + public void OnEnabled(ICheatEngineClient client) + { + events.Add("module.enabled"); + } + + public void OnDisabling(ICheatEngineClient client) + { + events.Add("module.disabling"); + throw new InvalidOperationException("module disable"); + } + } + + private sealed class RecordingCleanup( + List events, + Exception? enterFailure = null, + Exception? drainFailure = null) + : ICheatEngineClientActivationCleanup + { + internal int DrainCount + { + get; + private set; + } + + internal int ScopeDisposeCount + { + get; + private set; + } + + public IDisposable EnterCleanupScope() + { + events.Add("cleanup.enter"); + if (enterFailure is not null) + { + throw enterFailure; + } + + return new CallbackDisposable(() => + { + ScopeDisposeCount++; + events.Add("cleanup.exit"); + }); + } + + public void DrainOwnedResourcesForDisable() + { + DrainCount++; + events.Add("cleanup.drain"); + if (drainFailure is not null) + { + throw drainFailure; + } + } + } + + private sealed class CallbackDisposable(Action dispose) : IDisposable + { + private Action? _dispose = dispose; + + public void Dispose() + { + Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } + } + + private sealed class FakeClient(long epoch) : ICheatEngineClient + { + public long Epoch => epoch; + public CancellationToken Stopping => CancellationToken.None; + public ICheatEngineRuntime Runtime => null!; + public ICheatEngineDispatcher Dispatcher => null!; + public IProcessClient Processes => null!; + public IMemoryClient Memory => null!; + public IPatternScanner Patterns => null!; + public IValueScanner Scans => null!; + public IInspectionClient Inspection => null!; + public ITableClient Tables => null!; + public ILuaClient Lua => null!; + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs new file mode 100644 index 0000000..bc96221 --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEnginePluginBuilderTests.cs @@ -0,0 +1,36 @@ +using CheatEngine.Client.Extensions.DependencyInjection; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CheatEngine.Client.Hosting.Tests; + +public sealed class CheatEnginePluginBuilderTests +{ + [Fact] + public void BuildServiceProviderValidatesServicesAndBindsTheActivationConfiguration() + { + CheatEnginePluginBuilder builder = new(); + string allowedRoot = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "CheatEngine.Client.Hosting.Tests")); + builder.Configuration["CheatEngineClient:AllowedTableRoots:0"] = allowedRoot; + + using ServiceProvider provider = builder.BuildServiceProvider(); + CheatEngineClientOptions options = provider.GetRequiredService>().Value; + + Assert.Equal([allowedRoot], Assert.IsType(options.AllowedTableRoots)); + Assert.Same(builder.Configuration, provider.GetRequiredService()); + Assert.Same(builder.Configuration, provider.GetRequiredService()); + } + + [Fact] + public void BuildServiceProviderIsSingleUse() + { + CheatEnginePluginBuilder builder = new(); + using ServiceProvider provider = builder.BuildServiceProvider(); + + InvalidOperationException exception = Assert.Throws(builder.BuildServiceProvider); + + Assert.Contains("only one provider", exception.Message, StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs new file mode 100644 index 0000000..fdc7f77 --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs @@ -0,0 +1,159 @@ +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Modules; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; + +namespace CheatEngine.Client.Hosting.Tests; + +public sealed class ClientActivationLifecycleTests +{ + [Fact] + public void EnableThenCleanupRunsTheApplicationHookAndModulesInTheirSpecifiedOrder() + { + List events = []; + RecordingModule first = new("first", events); + RecordingModule second = new("second", events); + ClientActivationLifecycle lifecycle = CreateLifecycle(events, out ICheatEngineClient client, first, second); + + lifecycle.Enable(_ => events.Add("application.enabled")); + List failures = lifecycle.Cleanup(_ => events.Add("application.disabling")); + + Assert.Empty(failures); + Assert.Equal( + [ + "first.enabled", "second.enabled", "application.enabled", "application.disabling", "second.disabling", + "first.disabling" + ], + events); + Assert.Same(client, first.LastClient); + Assert.Same(client, second.LastClient); + } + + [Fact] + public void FailedModuleEnableStillCompensatesTheFailingModuleThenEarlierModulesInReverseOrder() + { + List events = []; + ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, + new RecordingModule("first", events), + new RecordingModule("second", events, new InvalidOperationException("module enable"))); + + InvalidOperationException exception = + Assert.Throws(() => lifecycle.Enable(_ => events.Add("application.enabled"))); + List failures = lifecycle.Cleanup(_ => events.Add("application.disabling")); + + Assert.Equal("module enable", exception.Message); + Assert.Empty(failures); + Assert.Equal(["first.enabled", "second.enabled", "second.disabling", "first.disabling"], events); + } + + [Fact] + public void FailedApplicationEnableStillRunsItsCompensatingHookBeforeModuleCleanup() + { + List events = []; + ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, new RecordingModule("module", events)); + + InvalidOperationException exception = Assert.Throws(() => lifecycle.Enable(_ => + { + events.Add("application.enabled"); + throw new InvalidOperationException("application enable"); + })); + List failures = lifecycle.Cleanup(_ => events.Add("application.disabling")); + + Assert.Equal("application enable", exception.Message); + Assert.Empty(failures); + Assert.Equal(["module.enabled", "application.enabled", "application.disabling", "module.disabling"], events); + } + + [Fact] + public void CleanupContinuesAfterFailuresAndIsIdempotent() + { + List events = []; + ClientActivationLifecycle lifecycle = CreateLifecycle(events, out _, + new RecordingModule("first", events, disableFailure: new InvalidOperationException("first disable")), + new RecordingModule("second", events, disableFailure: new InvalidOperationException("second disable"))); + lifecycle.Enable(_ => events.Add("application.enabled")); + + List failures = lifecycle.Cleanup(_ => + { + events.Add("application.disabling"); + throw new InvalidOperationException("application disable"); + }); + List repeatedFailures = lifecycle.Cleanup(_ => events.Add("unexpected")); + + Assert.Collection( + failures, + failure => Assert.Equal("application disable", failure.Message), + failure => Assert.Equal("second disable", failure.Message), + failure => Assert.Equal("first disable", failure.Message)); + Assert.Empty(repeatedFailures); + Assert.Equal( + [ + "first.enabled", "second.enabled", "application.enabled", "application.disabling", "second.disabling", + "first.disabling" + ], + events); + } + + private static ClientActivationLifecycle CreateLifecycle( + List events, + out ICheatEngineClient client, + params ICheatEngineClientModule[] modules) + { + ArgumentNullException.ThrowIfNull(events); + client = new FakeClient(); + return new ClientActivationLifecycle(client, modules); + } + + private sealed class RecordingModule( + string name, + List events, + Exception? enableFailure = null, + Exception? disableFailure = null) : ICheatEngineClientModule + { + internal ICheatEngineClient? LastClient + { + get; + private set; + } + + public void OnEnabled(ICheatEngineClient client) + { + LastClient = client; + events.Add($"{name}.enabled"); + if (enableFailure is not null) + { + throw enableFailure; + } + } + + public void OnDisabling(ICheatEngineClient client) + { + LastClient = client; + events.Add($"{name}.disabling"); + if (disableFailure is not null) + { + throw disableFailure; + } + } + } + + private sealed class FakeClient : ICheatEngineClient + { + public long Epoch => 42; + public CancellationToken Stopping => CancellationToken.None; + public ICheatEngineRuntime Runtime => null!; + public ICheatEngineDispatcher Dispatcher => null!; + public IProcessClient Processes => null!; + public IMemoryClient Memory => null!; + public IPatternScanner Patterns => null!; + public IValueScanner Scans => null!; + public IInspectionClient Inspection => null!; + public ITableClient Tables => null!; + public ILuaClient Lua => null!; + } +} diff --git a/tests/CheatEngine.Client.Hosting.Tests/README.md b/tests/CheatEngine.Client.Hosting.Tests/README.md new file mode 100644 index 0000000..642649f --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/README.md @@ -0,0 +1,25 @@ +# CheatEngine.Client.Hosting.Tests + +## Context + +This project tests the plugin host built on `CheatEngineClientPlugin` and `CheatEnginePluginBuilder`. + +## Why this project exists + +Hosting translates Cheat Engine enable/disable callbacks into an ephemeral DI provider and a single Client activation. +It is responsible for configuration ordering, validation-before-use, module startup order, reverse shutdown, rollback, +and cleanup while the SDK context is still valid. + +## How it helps improve CheatEngine.Client + +The suite makes the lifecycle contract executable: a new activation is created per enable, stale work is rejected after +disable, modules observe a deterministic order, and cleanup is performed in reverse order. These tests guard the +boundary where a long-lived plugin host meets activation-scoped Client services. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Hosting.Tests\CheatEngine.Client.Hosting.Tests.csproj --configuration Release +``` diff --git a/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json b/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json new file mode 100644 index 0000000..df232a5 --- /dev/null +++ b/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json @@ -0,0 +1,326 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Direct", + "requested": "[18.11.2, )", + "resolved": "18.11.2", + "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.2.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Testing.Platform": "2.4.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.2.10", + "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.Client.Core": "[0.1.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.Binder": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} diff --git a/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs b/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs new file mode 100644 index 0000000..35f4ba9 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/FacadeGraphSmokeTests.cs @@ -0,0 +1,133 @@ +using System.Reflection; + +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Tables; +using CheatEngine.SDK.Engine.Values; + +using MemoryFluent = CheatEngine.Client.Memory.Memory; + +namespace CheatEngine.Client.Tests; + +public sealed class FacadeGraphSmokeTests +{ + [Fact] + public void MetaPackageReferenceProvidesTheFunctionalClientSurface() + { + Address address = 0x401000; + MemoryAddressBuilder memoryOperation = MemoryFluent.At(address); + AobPattern pattern = new("48 8B ?? 89"); + + Dictionary entryPoints = new() + { + [typeof(ICheatEngineClient)] = "CheatEngine.Client", + [typeof(ICheatEngineDispatcher)] = "CheatEngine.Client.Dispatching", + [typeof(IProcessClient)] = "CheatEngine.Client.Processes", + [typeof(IMemoryClient)] = "CheatEngine.Client.Memory", + [typeof(IPatternScanner)] = "CheatEngine.Client.Scanning", + [typeof(IValueScanner)] = "CheatEngine.Client.Scanning", + [typeof(IInspectionClient)] = "CheatEngine.Client.Inspection", + [typeof(ITableClient)] = "CheatEngine.Client.Tables", + [typeof(ILuaClient)] = "CheatEngine.Client.Lua", + [typeof(IUnsafeLuaClient)] = "CheatEngine.Client.Lua", + [typeof(ICheatEngineRuntime)] = "CheatEngine.Client.Runtime", + [typeof(MemoryAddressBuilder)] = "CheatEngine.Client.Memory", + [typeof(AobScanBuilder)] = "CheatEngine.Client.Scanning", + [typeof(CheatEngineClientBuilder)] = "CheatEngine.Client.Extensions.DependencyInjection", + [typeof(CheatEngineClientPlugin)] = "CheatEngine.Client.Hosting" + }; + + Assert.Equal(address, memoryOperation.Address); + Assert.Equal("48 8B ?? 89", pattern.Value); + Assert.All(entryPoints, static entryPoint => + Assert.Equal(entryPoint.Value, entryPoint.Key.Namespace)); + } + + [Fact] + public void PublicClientContractsDoNotExposeSdkOwnershipOrLuaHandles() + { + Type[] clientContracts = + [ + typeof(ICheatEngineClient), + typeof(ICheatEngineDispatcher), + typeof(IProcessClient), + typeof(IMemoryClient), + typeof(IPatternScanner), + typeof(IValueScanner), + typeof(IValueScanSession), + typeof(IInspectionClient), + typeof(ITableClient), + typeof(ILuaClient), + typeof(IUnsafeLuaClient), + typeof(ICheatEngineRuntime), + typeof(IMemoryCodec<>), + typeof(IMemoryReadContext), + typeof(IMemoryWriteContext), + typeof(MemoryAddressBuilder), + typeof(CheatEngineMemoryFluentExtensions), + typeof(MemoryFluent), + typeof(AobScanBuilder), + typeof(AobFirstMatchBuilder), + typeof(AobSingleMatchBuilder), + typeof(AobManyMatchBuilder), + typeof(CheatEngineAobFluentExtensions), + typeof(CheatEngineClientBuilder), + typeof(CheatEngineClientServiceCollectionExtensions), + typeof(CheatEngineClientPlugin) + ]; + + IEnumerable signatures = clientContracts.SelectMany(GetPublicSignatureTypes); + + Assert.DoesNotContain(signatures, IsForbiddenSdkHandle); + } + + private static IEnumerable GetPublicSignatureTypes(Type contract) + { + foreach (ConstructorInfo constructor in contract.GetConstructors()) + { + foreach (ParameterInfo parameter in constructor.GetParameters()) + { + yield return parameter.ParameterType; + } + } + + foreach (PropertyInfo property in contract.GetProperties(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static)) + { + yield return property.PropertyType; + } + + foreach (MethodInfo method in contract.GetMethods(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static)) + { + yield return method.ReturnType; + foreach (ParameterInfo parameter in method.GetParameters()) + { + yield return parameter.ParameterType; + } + } + } + + private static bool IsForbiddenSdkHandle(Type type) + { + while (type.HasElementType) + { + type = type.GetElementType()!; + } + + if (type.IsGenericType && type.GetGenericArguments().Any(IsForbiddenSdkHandle)) + { + return true; + } + + return type.Name is "LuaState" or "LuaRef" or "CEObject" or "MemScan" or "FoundList" + || type.Name.StartsWith("Owned`", StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.Tests/README.md b/tests/CheatEngine.Client.Tests/README.md index de26846..781ea62 100644 --- a/tests/CheatEngine.Client.Tests/README.md +++ b/tests/CheatEngine.Client.Tests/README.md @@ -1,4 +1,28 @@ # CheatEngine.Client.Tests -Tests of [`CheatEngine.Client`](../../src/CheatEngine.Client/README.md): the composition, and the public API as a -consumer sees it. References its subject only. +## Context + +This is the consumer-facing smoke suite for the `CheatEngine.Client` meta-package project. It validates the public +graph that a plugin author receives through the recommended top-level package. + +## Why this project exists + +The meta-package must expose functional Client namespaces and fluent entry points without forcing consumers to know +the internal package layout. It must also keep SDK ownership wrappers and raw Lua handles behind the Client boundary. + +## How it helps improve CheatEngine.Client + +The smoke tests compile against the assembled consumer graph and inspect selected public contracts for prohibited +handle types. They catch accidental dependency omissions, namespace regressions, and public leakage of `LuaState`, +`LuaRef`, `CEObject`, `Owned`, `MemScan`, or `FoundList` before package smoke tests run. + +The suite is activation-independent. It does not replace Core lifecycle tests, template/package smoke tests, or the +opt-in live validation required for host-dependent capabilities such as value scans. + +## Run + +From the repository root: + +```powershell +dotnet test --project .\tests\CheatEngine.Client.Tests\CheatEngine.Client.Tests.csproj --configuration Release +``` diff --git a/tests/CheatEngine.Client.Tests/packages.lock.json b/tests/CheatEngine.Client.Tests/packages.lock.json new file mode 100644 index 0000000..ae8ee85 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/packages.lock.json @@ -0,0 +1,339 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Direct", + "requested": "[18.11.2, )", + "resolved": "18.11.2", + "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.2.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Testing.Platform": "2.4.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.2.10", + "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "cheatengine.client": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Fluent": "[0.1.0, )", + "CheatEngine.Client.Hosting": "[0.1.0, )" + } + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.Client.Core": "[0.1.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.fluent": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )" + } + }, + "cheatengine.client.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.Binder": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +}