diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 9c37eff4..2768f407 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -5,7 +5,7 @@ early_access: false reviews: # Assertive and advisory: CodeRabbit reviews thoroughly but never approves, requests changes or sets a required - # status. The only merge requirement is the CI / Gate check. + # status. The merge requirement is the CI / Gate check; there is no merge queue. profile: assertive request_changes_workflow: false review_details: false @@ -39,11 +39,12 @@ reviews: mode: warning requirements: >- Pull requests are squash-merged and the title becomes the commit subject: a short imperative sentence such as - "Fix CI validation findings", at most 72 characters, no trailing period. + "Fix CI validation findings", at most 72 characters, no trailing period, no Conventional Commit or area prefix, + starting with an uppercase letter. description: mode: warning issue_assessment: - mode: 'off' # issues are disabled on this repository + mode: 'off' # pull requests are not required to link an issue docstrings: mode: 'off' # CS1591 already fails the build for undocumented public APIs custom_checks: @@ -68,6 +69,23 @@ reviews: path (conversion failure, callback exception, protected-call error) and that registry references and callbacks are released. Fail when such code changes without those assertions, or when a raw Lua call that can raise is added outside a protected boundary. + - name: Lua concurrency contract + mode: warning + instructions: >- + Applies only when the pull request changes libs/CheatEngine.SDK.Lua/Runtime/**; otherwise pass. Fail when + worker-thread Lua admission is widened, exposed or defaulted to on without staying behind + `[Experimental("CESDK5001")]`, when a comment or doc claims worker-thread admission is qualified before the + Q19 C3 and C4 receipts both pass, or when new code assumes the SDK serializes two plugins or two SDK copies + against each other (ADR-07: admission protects only this copy's attach/reset/detach transitions, never the + shared Lua heap). Otherwise pass. + - name: Native host emulator evidence + mode: warning + instructions: >- + Applies only when the pull request changes tests/native-host-emulator/** or + tests/CheatEngine.SDK.Hosting.Tests/Coexistence/**; otherwise pass. Fail when a description, comment or test + name presents the emulator's facts as live Cheat Engine host behavior or as a C4 (two-copy) qualification + receipt, rather than C2 hostfxr component-route evidence; or when an absolute path can reach the emitted + `--facts` output. Otherwise pass. - name: Public API documentation and changelog mode: warning instructions: >- @@ -88,12 +106,28 @@ reviews: mode: warning instructions: >- Applies only to changes under .github/; otherwise pass. Fail when an action is referenced by tag or branch - instead of a full 40-character commit SHA with a version comment; pull_request_target is used; - actions/checkout omits persist-credentials: false; a permission is widened without a comment giving the - reason; a PowerShell step runs a native command (dotnet, xmake, git, gh, tar, actionlint) without checking - $LASTEXITCODE; SONAR_TOKEN or NUGET_USER can reach fork or Dependabot runs; a job added to ci.yml is missing - from the Gate's needs; or NuGet/login moves out of the release.yml publish job that uses environment nuget. - Otherwise pass. + instead of a full 40-character commit SHA with a version comment; pull_request_target or a merge_group + trigger is used; a job runs on a -latest label or on any label other than windows-2025 or ubuntu-24.04, or + has no timeout-minutes; actions/checkout omits persist-credentials: false; a permission is widened without a + comment giving the reason; a PowerShell step runs a native command (dotnet, xmake, git, gh, tar, actionlint) + without checking $LASTEXITCODE; a restore is not locked, or a job of ci.yml, sonar.yml, codeql.yml or + release.yml enables a package cache; SONAR_TOKEN or NUGET_USER can reach fork or Dependabot runs; a job added + to ci.yml is missing from the Gate's needs (WorkflowContractTests also fails; only the advisory client-canary + job may stay out); a zizmor finding is ignored in .github/zizmor.yml without a reason; or NuGet/login moves + out of the release.yml publish job that uses environment nuget. scorecard.yml is exempt from the pwsh + defaults and run-step rules, because Scorecard's publication verifier rejects them. The advisory workflows + (codeql, scorecard, zizmor-online, dependency-submission) stay outside the Gate. Otherwise pass. + - name: Public API and compatibility + mode: warning + instructions: >- + Applies when the pull request changes public or protected API under libs/ or src/, a PublicAPI.Shipped.txt + or PublicAPI.Unshipped.txt file, src/CheatEngine.SDK/CompatibilitySuppressions.xml, or a packages.lock.json + file; otherwise pass. Pass when every public API change is recorded in the owning library's + PublicAPI.Unshipped.txt (a changed or removed shipped declaration also gets a *REMOVED* line); + PublicAPI.Shipped.txt changes only in a release pull request that moves Unshipped into it; + CompatibilitySuppressions.xml changes only together with an intentional break that the description and the + CHANGELOG declare; and every packages.lock.json change comes from a CLI restore (never a hand edit) + together with the package, project or global.json change that caused it. Fail otherwise. finishing_touches: docstrings: enabled: false @@ -157,29 +191,51 @@ reviews: - path: 'tests/**' instructions: >- Tests use xUnit v3 with Microsoft.Testing.Platform. CI runs the whole solution once in Debug and once in - Release with --fail-skips on, so a skipped test fails both. Distinguish fixture, package and NativeAOT probes - from actual live Cheat Engine host qualification. + Release with --fail-skips on, so a skipped test fails both. Debug excludes the packaging tests by the trait + Category=Packaging (never by Skip); Release runs them against the exact nupkg it packed + (CESDK_PACKAGED_UMBRELLA_NUPKG). A test that evidences a qualification scenario carries + [Trait("Qualification", "Qxx")] naming the C0-C4 level it reaches; a C1/C2 success is never presented as + host-qualified. Distinguish fixture, package and NativeAOT probes from actual live Cheat Engine host + qualification. - path: 'CHANGELOG.md' instructions: >- Keep a Changelog 1.1.0. The release workflow publishes the body of the "## [X.Y.Z]" section (or [Unreleased] for a prerelease) as the GitHub release notes, so keep version headings exact and link references at the end. - path: '.github/**' instructions: >- - pull-request-ci.yml, main-ci.yml and release.yml are thin callers of the reusable ci.yml (native, build-test - matrix Debug/Release, aot, sonar through sonar.yml, lint, gate) and must stay thin. Jobs exchange artifacts - instead of redoing work: lua-protection-bridge and classic-abi-fixture-facts from native; nuget-package, - coverage and test-results- from build-test; release-notes from the release verify job. Do not - reintroduce per-project test matrices, a second test run of the same configuration, or rebuilds of what an - upstream job produced. NativeBridgePeAuditTests asserts the native job text. The Gate evaluates toJSON(needs) - and only sonar may be skipped. NuGet/login stays in release.yml with environment nuget (trusted publishing - binding). Require SHA-pinned actions, least privilege, persist-credentials: false and $LASTEXITCODE checks. - actionlint already runs in CI; do not ask for a duplicate CodeRabbit actionlint run. Never use - pull_request_target. Sonar secrets must stay unavailable to forks and Dependabot. + pull-request-ci.yml and main-ci.yml are thin callers of the reusable ci.yml (native, build-test matrix + Debug/Release, aot, sonar through sonar.yml, lint, format, dependency-review, lock-files, gate) and must stay + thin. Jobs exchange artifacts instead of redoing work: lua-protection-bridge and classic-abi-fixture-facts from + native; nuget-package, coverage and test-results- from build-test. + The Release leg packs before it tests, and the packaging tests consume that exact nupkg. Do not reintroduce + per-project test matrices, a second test run of the same configuration, or rebuilds of what an upstream job + produced. NativeBridgePeAuditTests asserts the native job text, and WorkflowContractTests freezes job ids and + names, gate.needs, runners, pins, locked restores and the reserved artifact names. Every job runs on + windows-2025 or ubuntu-24.04 (never -latest) with a timeout, restores with --locked-mode, and no job of ci.yml, + sonar.yml, codeql.yml or release.yml uses a package cache. The Gate evaluates toJSON(needs): every job must + succeed, and sonar must match SONAR_EXPECTED (it runs exactly when expected and is skipped only for forks, + Dependabot, the dormant merge_group clause and release runs); no other job may be skipped. The required check + is CI / Gate. CodeQL, Scorecard, zizmor-online and dependency-submission are advisory and stay out of the + Gate; scorecard.yml keeps no defaults, env or run steps. release.yml is draft-first (verify, ci, attest, + draft-release, publish, verify-publication, finalize-release); publication jobs run only for v* tags of this + repository, never upload to a published release, and keep NuGet/login in the publish job with environment + nuget (trusted publishing binding). Require SHA-pinned actions, least privilege, persist-credentials: false, + user-controlled values only through env, and $LASTEXITCODE checks. actionlint and zizmor already run in the + lint job; do not ask for a duplicate CodeRabbit run. Never use pull_request_target. Sonar secrets must stay + unavailable to forks and Dependabot. No custom PowerShell/Python/JSON governance or evidence tooling under + eng/: a genuine build necessity currently wrapped in a bespoke script gets inlined as direct run: lines using + standard actions and CLI calls instead. + - path: '**/*.md' + instructions: >- + Never restore or link the retired documentations/ tree. Narrative documentation (qualification protocol, + catalogues, migration guides) lives outside both repositories, under the maintainer's local docs/ tree, not + in a repository docs/ folder: flag a pull request that recreates one. Relative links keep the exact case of + the target; READMEs packed into the NuGet package use absolute links only. tools: # CodeRabbit is used as the GitHub App; pipeline failures are surfaced through its GitHub Checks integration. github-checks: enabled: true - # The ci.yml lint job is the deterministic actionlint owner. + # actionlint and zizmor run in the lint job of ci.yml, the deterministic owner of both. actionlint: enabled: false diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..330fcffd --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-coverage": { + "version": "18.11.2", + "commands": [ + "dotnet-coverage" + ], + "rollForward": true + } + } +} diff --git a/.editorconfig b/.editorconfig index 22262b1b..fe741e41 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,8 @@ indent_size = 4 tab_width = 4 max_line_length = 120 trim_trailing_whitespace = true +# Matches .gitattributes (eol=crlf): dotnet format and the IDE0055 build check agree on CRLF. +end_of_line = crlf [*.{csproj,props,targets,slnx,config,json,yml,yaml}] # YAML cannot use tabs for indentation. Keep project and configuration files @@ -75,24 +77,22 @@ csharp_style_prefer_top_level_statements = false # blockers because Directory.Build.props treats warnings as errors. dotnet_diagnostic.IDE0079.severity = error -# Migration rules: surface the historical backlog in every Roslyn-aware editor -# and participate in code cleanup without breaking the currently dirty tree. -# Promote these to warning/error in a dedicated cleanup change once the existing -# source has been reformatted and explicit types have been introduced. +# Style rules enforced since the repository-wide reformat of the audit remediation branch +# (see .git-blame-ignore-revs). EnforceCodeStyleInBuild plus TreatWarningsAsErrors turns them into build errors, +# and CI also runs `dotnet format --verify-no-changes`. dotnet_diagnostic.IDE0007.severity = none -dotnet_diagnostic.IDE0008.severity = suggestion -dotnet_diagnostic.IDE0011.severity = suggestion +dotnet_diagnostic.IDE0008.severity = warning +dotnet_diagnostic.IDE0011.severity = warning dotnet_diagnostic.IDE0033.severity = suggestion -dotnet_diagnostic.IDE0040.severity = suggestion -dotnet_diagnostic.IDE0044.severity = suggestion -# The formatter still follows the tab contract above. Do not flood every open -# legacy file with a formatting diagnostic before the dedicated reformat pass. -dotnet_diagnostic.IDE0055.severity = none +dotnet_diagnostic.IDE0040.severity = warning +dotnet_diagnostic.IDE0044.severity = warning +dotnet_diagnostic.IDE0055.severity = warning dotnet_diagnostic.IDE0005.severity = suggestion -dotnet_diagnostic.IDE0065.severity = suggestion -dotnet_diagnostic.IDE0090.severity = suggestion +dotnet_diagnostic.IDE0065.severity = warning +dotnet_diagnostic.IDE0090.severity = warning dotnet_diagnostic.IDE0160.severity = none -dotnet_diagnostic.IDE0161.severity = suggestion +dotnet_diagnostic.IDE0161.severity = warning +# Naming fixes rename symbols, which dotnet format cannot apply safely; keep them as editor guidance. dotnet_diagnostic.IDE1006.severity = suggestion # Rider derives its own "var or explicit type" inspections from the shared @@ -148,3 +148,7 @@ dotnet_diagnostic.IDE0005.severity = error [src/**.cs] dotnet_diagnostic.IDE0005.severity = error + +# The native bridge embeds the SHA-256 of these two files; .gitattributes pins them to LF. +[native/cheatengine-sdk-lua-bridge/{cheatengine_sdk_lua_bridge.c,xmake.lua}] +end_of_line = lf diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..7f8c8a6a --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Commits that only reformat code. Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs +# Apply repository formatting +0a03dac0cae53d399e89dfb1494ac80843a3ab9a +# Format the projects that live outside the solution (AotProbe, LiveProbe) +fc6016532f5f1d963d1cf267a1750bbbfa64cc49 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..1416134e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,16 @@ +# Informational only: the "Protect main" ruleset does not require a code-owner review (single active maintainer; a +# required review would block every maintainer and Dependabot pull request). GitHub still requests a review from the +# owners below. Owners must have write access; confirm co-owners with the maintainer before adding them. +# Syntax: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# The last matching pattern wins, so the catch-all comes first. +* @AriusII + +# Native authority: the protection bridge, the ABI layouts and the build assets consumers import. +/native/ @AriusII +/libs/CheatEngine.SDK.Abi/ @AriusII +/src/CheatEngine.SDK/build/ @AriusII + +# Build, release and governance: workflows, the PR policy code and repository settings. +/.github/ @AriusII +/eng/ @AriusII +/SECURITY.md @AriusII diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..e6c8f69f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,62 @@ +# Schema: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema +name: Bug report +description: An SDK API, analyzer, generator or build asset behaves differently from its documentation. +title: "[Bug] " +labels: [ bug ] +body: + - type: markdown + attributes: + value: | + Use this form for a defect in the SDK itself. When a plugin does not load, or works with one Cheat Engine, + runtime or package combination and not another, use the **Compatibility report** form instead: it records the + exact tuple the problem depends on. Security problems go to private reporting (see SECURITY.md). + + - type: input + id: sdk-version + attributes: + label: CheatEngine.SDK version + description: The `resolved` version of `CheatEngine.SDK` in the plugin's `packages.lock.json`, or the commit when you build from source. + placeholder: "1.0.0" + validations: + required: true + + - type: input + id: ce-version + attributes: + label: Cheat Engine build + description: The exact file version, or `not involved` for an analyzer, generator or build problem. + placeholder: "7.7.0.10621" + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: A minimal plugin or code snippet and the actions that trigger the problem. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Observed result + description: What happened instead, with the exact error text or diagnostic id. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs (optional) + description: Relevant, redacted build output or log lines. Remove user names and private paths first. + render: text + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/compatibility.yml b/.github/ISSUE_TEMPLATE/compatibility.yml new file mode 100644 index 00000000..c20dfd2e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility.yml @@ -0,0 +1,216 @@ +# A compatibility problem is only actionable when it names the exact tuple that was run (audit ch.21 "the tuple to +# qualify", ch.22 Checkpoint F exit): package and content hash, bridge, Cheat Engine build and executable, the Lua DLL +# actually bound, runtime configuration, load profile, build options and the level of evidence. +# Schema: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema +name: Compatibility report +description: A plugin built with CheatEngine.SDK does not load or behave as expected with a specific Cheat Engine, runtime or package combination. +title: "[Compatibility] " +labels: [ compatibility ] +body: + - type: markdown + attributes: + value: | + A compatibility report is tied to one exact combination of package, bridge, Cheat Engine build and runtime. + Fill in every hash from the files that were actually used; a version name alone does not identify a build. + + Collect a SHA-256 with PowerShell: + + ```powershell + Get-FileHash -Algorithm SHA256 "$env:ProgramFiles\Cheat Engine\cheatengine-x86_64.exe" + Get-FileHash -Algorithm SHA256 "$env:ProgramFiles\Cheat Engine\lua53-64.dll" + Get-FileHash -Algorithm SHA256 "$env:ProgramFiles\Cheat Engine\ce.runtimeconfig.json" + Get-FileHash -Algorithm SHA256 .\bin\Release\net10.0\cheatengine-sdk-lua-bridge.dll + ``` + + Read the package identity from the plugin's `packages.lock.json` (never recompute it): + + ```powershell + (Get-Content packages.lock.json -Raw | ConvertFrom-Json).dependencies.'net10.0'.'CheatEngine.SDK' | Select-Object resolved, contentHash + ``` + + Remove user names and private paths before posting. Never attach Cheat Engine or target binaries. + Security problems go to private reporting instead (see SECURITY.md). + + - type: input + id: sdk-version + attributes: + label: CheatEngine.SDK version + description: The `resolved` version of `CheatEngine.SDK` in the plugin's `packages.lock.json`. + placeholder: "1.0.0" + validations: + required: true + + - type: input + id: sdk-content-hash + attributes: + label: CheatEngine.SDK contentHash (from packages.lock.json) + description: The `contentHash` value of `CheatEngine.SDK` exactly as the lock file records it (base64 SHA-512). Copy it; do not recompute it from a downloaded file. + placeholder: "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + validations: + required: true + + - type: input + id: bridge-sha256 + attributes: + label: SHA-256 of cheatengine-sdk-lua-bridge.dll + description: The bridge next to the plugin in the folder Cheat Engine loads it from. + validations: + required: true + + - type: input + id: bridge-fingerprint + attributes: + label: Bridge source fingerprint (optional) + description: The `:` fingerprint, if the SDK printed an identification line in the Cheat Engine log. + validations: + required: false + + - type: input + id: client-version + attributes: + label: CheatEngine.Client version (optional) + description: Only when the plugin uses CheatEngine.Client. + validations: + required: false + + - type: input + id: ce-version + attributes: + label: Cheat Engine build + description: The exact file version of the executable you started (Properties, Details, File version). + placeholder: "7.7.0.10621" + validations: + required: true + + - type: input + id: ce-exe-sha256 + attributes: + label: SHA-256 of the Cheat Engine executable + description: Usually `cheatengine-x86_64.exe`. The `-SSE4-AVX2` variant and the `Cheat Engine.exe` launcher are different files; name the one you started. + validations: + required: true + + - type: input + id: lua-dll-sha256 + attributes: + label: SHA-256 of lua53-64.dll + description: The `lua53-64.dll` in the Cheat Engine folder, which is the Lua a plugin actually binds. + validations: + required: true + + - type: dropdown + id: load-profile + attributes: + label: Load profile + description: How Cheat Engine loaded the plugin. + options: + - Managed plugin through hostfxr (profile ce-7.7.0.10621-x64-managed-hostfxr) + - Historical CLR loader + - NativeAOT plugin (not supported) + - Other or unknown + validations: + required: true + + - type: input + id: runtimeconfig-sha256 + attributes: + label: SHA-256 of ce.runtimeconfig.json + description: The file in the Cheat Engine folder, as found, for the managed route. Write `none` when the file does not exist. Report it exactly as it is; this form never asks you to alter Cheat Engine's runtime configuration. + validations: + required: true + + - type: dropdown + id: runtimeconfig-origin + attributes: + label: Origin of ce.runtimeconfig.json + description: Whether the file is the one the Cheat Engine installer wrote. A locally altered file affects every managed plugin, so it changes the profile under test. + options: + - Installer file, never altered + - Altered on this machine + - Unknown + - No such file + validations: + required: true + + - type: dropdown + id: target-architecture + attributes: + label: Target process architecture + options: + - x64 target + - x86 target (32-bit process) + - No target process opened + - Other + validations: + required: true + + - type: dropdown + id: evidence-level + attributes: + label: What was actually run + description: Choose the highest level you ran yourself. Package or fixture tests are not a Cheat Engine host result, and a build that succeeds is not a plugin that loads. + options: + - Plugin loaded in Cheat Engine (C3) + - Two plugins or a target switch (C4) + - Package or fixture tests only (C1/C2) + - Build or analyzer output only (C0) + validations: + required: true + + - type: textarea + id: build-options + attributes: + label: Build options + description: The relevant project properties, for example `TargetFramework`, `PlatformTarget`, `PublishAot`, `SelfContained`, `EnableDynamicLoading`, and how the plugin was deployed. + render: xml + validations: + required: true + + - type: input + id: os + attributes: + label: Windows version + placeholder: "Windows 11 24H2 (26100.1)" + validations: + required: true + + - type: textarea + id: dotnet + attributes: + label: .NET host and runtimes + description: The "Host" and ".NET runtimes installed" sections of `dotnet --info`. + render: text + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: From a clean start of Cheat Engine, including the target and the plugin actions. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Observed result + description: What happened instead, with the exact error text. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs (optional) + description: Relevant, redacted log lines. Remove user names and private paths first. + render: text + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..21cf20e9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,12 @@ +# Issue forms: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/CheatEngineNet/CheatEngine.SDK/security/advisories/new + about: Report privately through GitHub private vulnerability reporting, never in a public issue (see SECURITY.md). + - name: Questions and ideas + url: https://github.com/CheatEngineNet/CheatEngine.SDK/discussions + about: Ask how to use the SDK or propose an idea in GitHub Discussions. + - name: Problems in Cheat Engine itself + url: https://github.com/cheat-engine/cheat-engine/issues + about: Behaviour of Cheat Engine that also happens without a CheatEngine.SDK plugin belongs upstream. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d7cffe44..74b65d15 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,7 @@ ## Summary - + ## Scope and ownership @@ -9,12 +10,18 @@ The SDK owns Cheat Engine integration; CheatEngine.Client owns workflows and pol ## Validation -| Check | Evidence (command, run link or artifact) | Result | -|------------------------|--------------------------------------------------------------|--------------| -| CI / Gate | | Pending | -| Local build and tests | | Not executed | -| Packed package | | Not executed | -| Live Cheat Engine host | | Not executed | +| Check | Evidence (command, run link or artifact) | Result | +|--------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|--------------| +| CI / Gate | | Pending | +| Local build and tests | | Not executed | +| Packed package | | Not executed | +| Public API files / CompatibilitySuppressions | | Not executed | +| Qualification level (C0 static, C1 managed, C2 native fixture, C3 CE exact, C4 multi-plugin) and Q-IDs | | Not executed | +| Live Cheat Engine host | | Not executed | +| Release manifest impact | | Not executed | + + ## Compatibility and release impact @@ -24,6 +31,10 @@ The SDK owns Cheat Engine integration; CheatEngine.Client owns workflows and pol - [ ] The change is focused and follows CONTRIBUTING.md. - [ ] Tests cover the change; no skipped test hides a failure. -- [ ] Consumer-visible changes are recorded under `[Unreleased]` in CHANGELOG.md. +- [ ] Consumer-visible changes are recorded under `[Unreleased]`, or the description contains `` with a reason +- [ ] Public API changes are recorded in `PublicAPI.Unshipped.txt`; every intentional break is declared in + `CompatibilitySuppressions.xml` and the CHANGELOG. +- [ ] Lock files were regenerated with `dotnet restore --force-evaluate`, never edited by hand. +- [ ] Evidence tests carry `[Trait("Qualification", "Qxx")]` naming the qualification level reached. - [ ] Affected READMEs and documentation are updated in this pull request. - [ ] The claims above match the actual CI and local results; live-host limitations are stated. diff --git a/.github/actions/setup-dotnet/action.yml b/.github/actions/setup-dotnet/action.yml index 082d977a..6b417244 100644 --- a/.github/actions/setup-dotnet/action.yml +++ b/.github/actions/setup-dotnet/action.yml @@ -1,28 +1,53 @@ name: Setup .NET -description: Installs the SDK pinned by global.json, quiets the CLI and optionally restores a solution or project. +description: >- + Installs the exact SDK pinned by global.json, quiets the CLI and optionally restores solutions or projects in NuGet + locked mode. inputs: restore: - description: Solution or project to restore. Empty skips the restore. + description: >- + Newline-separated solutions or projects to restore, each with --locked-mode against its committed + packages.lock.json. Empty skips the restore. default: '' + cache: + description: >- + Cache the NuGet package folder, keyed on every packages.lock.json. Keep 'false' in every job reachable from a + release, Sonar or CodeQL run (ci.yml, sonar.yml, codeql.yml, release.yml): a cache written by a pull request run + must never feed a published build. WorkflowContractTests enforces it. + default: 'false' runs: using: composite steps: + # global.json pins the SDK with rollForward: disable, and runner images do not ship that exact feature band, so + # every job that runs dotnet installs it here. - name: Install SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json + cache: ${{ inputs.cache }} + cache-dependency-path: '**/packages.lock.json' - name: Configure CLI shell: pwsh - run: | + run: | # zizmor: ignore[github-env] writes three constant literals only; no expression or user input reaches GITHUB_ENV 'DOTNET_NOLOGO=1', 'DOTNET_CLI_TELEMETRY_OPTOUT=1', 'MSBUILDDISABLENODEREUSE=1' | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + # Locked mode fails with NU1004 when a committed lock file no longer matches the projects, instead of silently + # resolving different packages than the ones reviewed. Never combined with --force-evaluate (NU1005). + # https://learn.microsoft.com/nuget/consume-packages/package-references-in-project-files#locking-dependencies - name: Restore if: inputs.restore != '' shell: pwsh env: - RESTORE_TARGET: ${{ inputs.restore }} - run: dotnet restore $env:RESTORE_TARGET + RESTORE_TARGETS: ${{ inputs.restore }} + run: | + $ErrorActionPreference = 'Stop' + $targets = @($env:RESTORE_TARGETS -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + foreach ($target in $targets) { + dotnet restore $target --locked-mode + if ($LASTEXITCODE -ne 0) { + throw "Locked restore of '$target' failed with exit code $LASTEXITCODE. If a dependency, global.json or a project changed, regenerate its lock file with a forced re-evaluation restore (never --locked-mode together with it, NU1005) and commit it." + } + } diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 960cc7bd..2697ffb1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,68 @@ +# Dependabot version updates (security updates are enabled in the repository settings and ignore every limit below). +# Options: https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference +# - No commit-message prefix: the repository convention is an imperative title with no prefix ("deps: ..." is not). +# - Every ecosystem waits at least 7 days after a release (zizmor dependabot-cooldown; a cooldown never delays a +# security update). +# - Dependabot does not regenerate the SDK-implicit entries of packages.lock.json: check out its branch, run +# `dotnet restore --force-evaluate` for each project and push the result. +# - A dependency joins the first group it matches, so the specific groups come before the catch-all. version: 2 updates: - package-ecosystem: nuget directory: / schedule: interval: monthly + open-pull-requests-limit: 5 + labels: [ dependencies, .NET ] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 7 + semver-patch-days: 7 groups: + security-updates: + applies-to: security-updates + patterns: [ '*' ] + test-infrastructure: + patterns: + - 'xunit.v3*' + - 'Microsoft.Testing.*' + - 'Microsoft.CodeAnalysis.*.Testing' + - 'BenchmarkDotNet' + - 'YamlDotNet' + update-types: [ minor, patch ] + analyzers: + patterns: + - 'Meziantou.Analyzer' + - 'Microsoft.CodeAnalysis.BannedApiAnalyzers' + - 'Microsoft.CodeAnalysis.PublicApiAnalyzers' + update-types: [ minor, patch ] minor-and-patch: + patterns: [ '*' ] update-types: [ minor, patch ] ignore: - # The Roslyn pin moves together with RoslynComponentFloor (build error CESDK9002). + # The Roslyn pin moves together with RoslynComponentFloor (build error CESDK9002) and the SDK-bundled compiler: + # a component built against a newer Roslyn does not load in an older consumer compiler (CS9057). - dependency-name: Microsoft.CodeAnalysis.CSharp - dependency-name: Microsoft.CodeAnalysis.CSharp.Workspaces + - dependency-name: Microsoft.CodeAnalysis.Analyzers + # SDK-implicit packages recorded in the lock files move only with global.json. + - dependency-name: Microsoft.NET.ILLink.Tasks + - dependency-name: Microsoft.DotNet.ILCompiler + - dependency-name: 'runtime.*.Microsoft.DotNet.ILCompiler' + + - package-ecosystem: dotnet-sdk + directory: / + schedule: + interval: monthly + labels: [ dependencies, .NET ] + cooldown: + default-days: 7 + semver-major-days: 30 + ignore: + # A new .NET major is a deliberate migration (TargetFramework, AnalysisLevel pin, lock files), not a bump. + - dependency-name: '*' + update-types: [ 'version-update:semver-major' ] - package-ecosystem: github-actions directories: @@ -18,6 +70,10 @@ updates: - /.github/actions/* schedule: interval: monthly + labels: [ dependencies, ci ] + cooldown: + # GitHub Actions supports default-days only (no SemVer-bump days). + default-days: 7 groups: actions: update-types: [ minor, patch ] diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml new file mode 100644 index 00000000..6cc59f70 --- /dev/null +++ b/.github/dependency-review-config.yml @@ -0,0 +1,37 @@ +# Configuration of actions/dependency-review-action v5, run by the dependency-review job of ci.yml on pull requests. +# The job passes config-file, comment-summary-in-pr: never (no pull-requests: write, so it works the same on forks) and +# retry-on-snapshot-warnings: true inline, as shared contract 1.6 freezes them; every other option lives here. +# Option names: https://github.com/actions/dependency-review-action#configuration-options + +# A pull request fails when it adds a dependency with a known vulnerability of this severity or higher, in any scope: +# test and build tooling runs in CI with the repository checkout, so development dependencies count too. +fail-on-severity: moderate +fail-on-scopes: + - runtime + - development + +# Licenses a new or updated dependency may carry (SPDX identifiers). Every direct reference was MIT or Apache-2.0 when +# this list was written (nuget.org nuspecs, 2026-09-23). +allow-licenses: + - MIT + - Apache-2.0 + - BSD-2-Clause + - BSD-3-Clause + - ISC + - 0BSD + - Unlicense + - MS-PL + +# Packages whose nuspec ships the license as a file (license type="file") instead of an SPDX expression, so the +# dependency graph has no identifier to compare. Both are Microsoft's code coverage tooling, used only by test runs and +# never shipped in the CheatEngine.SDK package. Add a package here only with the same verification and a reason. +allow-dependencies-licenses: + - pkg:nuget/Microsoft.Testing.Extensions.CodeCoverage + - pkg:nuget/dotnet-coverage + +# Without automatic dependency submission the head snapshot of a pull request can lag: wait up to two minutes for it +# (retry-on-snapshot-warnings itself is set in the workflow) instead of reviewing a partial graph. +retry-on-snapshot-warnings-timeout: 120 + +show-openssf-scorecard: true +warn-on-openssf-scorecard-level: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebfac757..7629b120 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,30 @@ name: CI -# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. +# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. Its gate job is the required check +# "CI / Gate"; the caller job id `ci` (name CI) and the job name Gate are frozen. # -# native ─► build-test (Debug, Release) ─► sonar ─┐ -# └► aot ─────────────────────────────────┼─► gate (the only required check: "CI / Gate") -# lint ──────────────────────────────────────────┘ +# native ─┬─► build-test (Debug, Release) ─► sonar ─┐ +# ├─► aot ───────────────────────────────────┤ +# └─► native-host-emulator (Debug build-test leg only) ─┤ +# lint, format, dependency-review, lock-files ───────────────────┴─► gate # -# Jobs never rebuild what an upstream job produced; they exchange artifacts: +# Artifacts (the only names this workflow may upload; WorkflowContractTests holds the list): # lua-protection-bridge, classic-abi-fixture-facts native → build-test, aot +# nuget-package build-test (Release) → sonar, release.yml, reviewers # coverage build-test (Debug) → sonar -# nuget-package build-test (Release) → sonar, release publish, reviewers -# test-results- build-test → humans +# test-results- build-test → reviewers +# test-dumps-, binlogs-[-] on failure only +# +# No job restores or saves a NuGet cache: every job is reachable from the tag release run. Every restore is locked +# against the committed packages.lock.json files (.github/actions/setup-dotnet). on: workflow_call: inputs: sonar: - description: Run the SonarQube Cloud analysis. Merge-queue runs, fork pull requests and Dependabot always skip it. + description: >- + Run the SonarQube Cloud analysis. The gate then requires it (SONAR_EXPECTED), except for fork pull requests + and Dependabot, which receive no secrets. type: boolean default: false package-version: @@ -42,8 +50,16 @@ defaults: jobs: native: name: Build native bridge - runs-on: windows-latest - timeout-minutes: 10 + runs-on: windows-2025 + timeout-minutes: 15 + env: + # The bridge toolchain pins. 14.44 is the default toolset of the VS 2022 image and a side-by-side component of the + # VS 2026 image, so the pin holds whichever image windows-2025 maps to. A bump changes the shipped DLL bytes: + # change both values in one reviewed commit (NativeBridgePeAuditTests reads them). + BRIDGE_VS_TOOLSET: '14.44' + BRIDGE_VS_SDKVER: '10.0.26100.0' + outputs: + bridge-sha256: ${{ steps.bridge.outputs.bridge-sha256 }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -55,63 +71,27 @@ jobs: with: xmake-version: '3.0.9' + # Configure and build the bridge with the pinned MSVC toolset and Windows SDK. Byte-for-byte drift from the + # checked-in DLL is checked by the next step (a notice, never a failure); this step only has to produce the DLL + # every later job downloads as the lua-protection-bridge artifact. - name: Build bridge + id: bridge run: | $ErrorActionPreference = 'Stop' - # xmake 3.0.9 mis-parses an absolute Windows -o path when the target - # uses $(builddir); keep both xmake paths relative and resolve them - # only for PowerShell validation and cleanup. - $repositoryRoot = [IO.Path]::GetFullPath($PWD.Path) - $primaryOutput = 'artifacts/native/cheatengine-sdk-lua-bridge' - $reproducibilityOutput = 'artifacts/native/cheatengine-sdk-lua-bridge-repro' - $primaryOutputPath = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $primaryOutput)) - $reproducibilityOutputPath = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $reproducibilityOutput)) - if ([string]::Equals($primaryOutputPath, $reproducibilityOutputPath, [StringComparison]::OrdinalIgnoreCase)) { - throw 'The primary and reproducibility bridge output directories must be distinct.' - } - - $workspacePrefix = "$repositoryRoot$([IO.Path]::DirectorySeparatorChar)" - foreach ($resolvedOutput in @($primaryOutputPath, $reproducibilityOutputPath)) { - if (-not $resolvedOutput.StartsWith($workspacePrefix, [StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to clean native bridge output outside the checkout: '$resolvedOutput'." - } - if (Test-Path -LiteralPath $resolvedOutput) { - Remove-Item -LiteralPath $resolvedOutput -Recurse -Force - } - New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null - } - - xmake f -P native/cheatengine-sdk-lua-bridge -o $primaryOutput -p windows -a x64 -m release -y + xmake f -P native/cheatengine-sdk-lua-bridge -o artifacts/native/cheatengine-sdk-lua-bridge -p windows -a x64 -m release -y --ccache=n "--vs_toolset=$env:BRIDGE_VS_TOOLSET" "--vs_sdkver=$env:BRIDGE_VS_SDKVER" if ($LASTEXITCODE -ne 0) { - throw "xmake configuration for the primary bridge output failed with exit code $LASTEXITCODE." + throw "xmake configuration for the native bridge failed with exit code $LASTEXITCODE." } xmake -P native/cheatengine-sdk-lua-bridge -y if ($LASTEXITCODE -ne 0) { - throw "xmake build for the primary bridge output failed with exit code $LASTEXITCODE." + throw "xmake build of the native bridge failed with exit code $LASTEXITCODE." } - $primaryBridge = Join-Path $primaryOutputPath 'cheatengine-sdk-lua-bridge.dll' - if (-not (Test-Path -LiteralPath $primaryBridge -PathType Leaf)) { - throw "xmake did not produce '$primaryBridge'." - } - - xmake f -P native/cheatengine-sdk-lua-bridge -o $reproducibilityOutput -p windows -a x64 -m release -y - if ($LASTEXITCODE -ne 0) { - throw "xmake configuration for the reproducibility bridge output failed with exit code $LASTEXITCODE." - } - xmake -P native/cheatengine-sdk-lua-bridge -y - if ($LASTEXITCODE -ne 0) { - throw "xmake build for the reproducibility bridge output failed with exit code $LASTEXITCODE." - } - $reproducibilityBridge = Join-Path $reproducibilityOutputPath 'cheatengine-sdk-lua-bridge.dll' - if (-not (Test-Path -LiteralPath $reproducibilityBridge -PathType Leaf)) { - throw "xmake did not produce '$reproducibilityBridge'." - } - - $primaryHash = (Get-FileHash -LiteralPath $primaryBridge -Algorithm SHA256).Hash - $reproducibilityHash = (Get-FileHash -LiteralPath $reproducibilityBridge -Algorithm SHA256).Hash - if (-not [string]::Equals($primaryHash, $reproducibilityHash, [StringComparison]::OrdinalIgnoreCase)) { - throw "The native bridge is not reproducible: '$primaryBridge' SHA-256 is $primaryHash but '$reproducibilityBridge' is $reproducibilityHash." + $dll = 'artifacts/native/cheatengine-sdk-lua-bridge/cheatengine-sdk-lua-bridge.dll' + if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) { + throw "xmake did not produce '$dll'." } + $hash = (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant() + "bridge-sha256=$hash" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Upload bridge uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -121,7 +101,8 @@ jobs: if-no-files-found: error retention-days: 14 - # Run after upload so a failed drift check still leaves the corrected DLL available to download. + # Run after upload so a failed check still leaves the corrected DLL available to download. Byte drift between the + # CI-built and the checked-in DLL is only a notice (previous step); a checked-in DLL built from other sources fails. - name: Verify checked-in bridge matches its source run: | $sourceHash = (Get-FileHash -Algorithm SHA256 native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c).Hash.ToLowerInvariant() @@ -161,9 +142,9 @@ jobs: build-test: name: Build and test (${{ matrix.configuration }}) - needs: native - runs-on: windows-latest - timeout-minutes: 30 + needs: [ native, native-host-emulator ] + runs-on: windows-2025 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -175,7 +156,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 # MinVer and the packaging tests need tags and full history + fetch-depth: 0 # MinVer computes the package version from tags and history persist-credentials: false - name: Setup .NET @@ -196,58 +177,131 @@ jobs: name: classic-abi-fixture-facts path: artifacts/native-abi-fixture + - name: Use native host emulator + if: matrix.configuration == 'Debug' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: native-host-emulator + path: artifacts/native-host-emulator + + # One build of the whole solution, which also compiles every harness it lists (LivePlugin, Coexistence, probes). - name: Build run: | - dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore + dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore "-bl:artifacts/logs/build-test-$env:CONFIGURATION.binlog" if ($LASTEXITCODE -ne 0) { throw "$env:CONFIGURATION solution build failed with exit code $LASTEXITCODE." } - # One parallel run over every tests/**/*.Tests project of the solution, which also proves that the solution - # discovers every test module. A skip fails both configurations: NativeLua tests skip when the bundled Lua DLL - # does not bind, and a green check must not hide that. In Debug, the facts the native job built make the managed - # ABI comparison mandatory, and every module writes its own GUID-named coverage report. + # Pack before testing: the packaging tests below consume this exact file, which is then uploaded unchanged. The + # pack is incremental after the build and recompiles nothing. + - name: Pack + id: pack + if: matrix.configuration == 'Release' + env: + PACKAGE_VERSION: ${{ inputs.package-version }} + BRIDGE_SHA256: ${{ needs.native.outputs.bridge-sha256 }} + run: | + $ErrorActionPreference = 'Stop' + dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog + if ($LASTEXITCODE -ne 0) { + throw "SDK package creation failed with exit code $LASTEXITCODE." + } + + # Pre-publish sanity check: exactly one CheatEngine.SDK..nupkg, matching nuspec identity, the + # embedded SPDX SBOM (Microsoft.Sbom.Targets, CESDK9008) and the CI-built native bridge. + $files = @(Get-ChildItem -LiteralPath artifacts/nuget -Recurse -File) + if ($files.Count -ne 1 -or $files[0].Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.nupkg$') { + throw "Expected exactly one CheatEngine.SDK..nupkg in 'artifacts/nuget', found: $(($files | ForEach-Object Name) -join ', ')." + } + $package = $files[0] + $fileVersion = $Matches['version'] + if ($env:PACKAGE_VERSION -and $package.Name -cne "CheatEngine.SDK.$($env:PACKAGE_VERSION).nupkg") { + throw "Packed $($package.Name), but the release requires CheatEngine.SDK.$($env:PACKAGE_VERSION).nupkg." + } + $archive = [IO.Compression.ZipFile]::OpenRead($package.FullName) + try { + $nuspecEntry = $archive.GetEntry('CheatEngine.SDK.nuspec') + if ($null -eq $nuspecEntry) { + throw "$($package.Name) has no CheatEngine.SDK.nuspec at its root." + } + $reader = [IO.StreamReader]::new($nuspecEntry.Open()) + try { [xml] $nuspec = $reader.ReadToEnd() } finally { $reader.Dispose() } + if ($nuspec.package.metadata.id -cne 'CheatEngine.SDK' -or $nuspec.package.metadata.version -cne $fileVersion) { + throw "$($package.Name) declares id '$($nuspec.package.metadata.id)' version '$($nuspec.package.metadata.version)' in its nuspec." + } + if ($null -eq $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json')) { + throw "$($package.Name) does not embed the SPDX SBOM at _manifest/spdx_2.2/manifest.spdx.json (Microsoft.Sbom.Targets, CESDK9008)." + } + $bridge = $archive.GetEntry('build/native/cheatengine-sdk-lua-bridge.dll') + if ($null -eq $bridge) { + throw "$($package.Name) does not carry the native bridge at build/native/cheatengine-sdk-lua-bridge.dll." + } + $bridgeStream = $bridge.Open() + try { + $bridgeSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bridgeStream)).ToLowerInvariant() + } finally { + $bridgeStream.Dispose() + } + if ($bridgeSha256 -cne $env:BRIDGE_SHA256.ToLowerInvariant()) { + throw "$($package.Name) packs a bridge with SHA-256 $bridgeSha256, but the native job built $env:BRIDGE_SHA256." + } + } finally { + $archive.Dispose() + } + $sha256 = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + @("nupkg=$($package.FullName)", "sha256=$sha256", "version=$fileVersion") | + Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "Packed $($package.Name) (SHA-256 $sha256)." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + # One run over every tests/**/*.Tests module of the solution; MTP options go straight to dotnet test (SDK 10). + # A skip fails the run (--fail-skips on): NativeLua tests skip when the bundled Lua DLL does not bind, and a green + # check must not hide that. Hang and crash dumps need the extensions every module gets from eng/Tests.props. + # Debug: the native job's ABI facts make the managed ABI comparison mandatory, every module writes a coverage + # report, and the packaging tests are filtered out by trait (never skipped): they run in the Release leg against + # the packed file (CESDK_PACKAGED_UMBRELLA_NUPKG). - name: Test + id: test + env: + CESDK_PACKAGED_UMBRELLA_NUPKG: ${{ matrix.configuration == 'Release' && steps.pack.outputs.nupkg || '' }} + PACKED_NUPKG_SHA256: ${{ steps.pack.outputs.sha256 }} run: | + $ErrorActionPreference = 'Stop' $options = @( '--solution', 'CheatEngine.SDK.slnx', '-c', $env:CONFIGURATION, '--no-build', '--results-directory', $env:RESULTS, - '--fail-skips', 'on', '--report-trx', '--report-gh', '--report-gh-groups', 'off' + '--fail-skips', 'on', '--report-trx', '--report-gh', '--report-gh-groups', 'off', + '--hangdump', '--hangdump-timeout', '15m', '--crashdump' ) if ($env:CONFIGURATION -eq 'Debug') { $env:CE77_NATIVE_ABI_FACTS_PATH = Join-Path $env:GITHUB_WORKSPACE 'artifacts/native-abi-fixture/ce77-native-abi-facts.txt' $env:CE77_NATIVE_ABI_REQUIRED = 'true' - $options += '--coverage', '--coverage-output-format', 'xml' + $env:CESDK_NATIVE_HOST_EMULATOR_DIR = Join-Path $env:GITHUB_WORKSPACE 'artifacts/native-host-emulator' + $env:CESDK_NATIVE_HOST_EMULATOR_REQUIRED = 'true' + $options += '--coverage', '--coverage-output-format', 'xml', '--filter-not-trait', 'Category=Packaging' + } + else { + $consumed = (Get-FileHash -LiteralPath $env:CESDK_PACKAGED_UMBRELLA_NUPKG -Algorithm SHA256).Hash.ToLowerInvariant() + if ($consumed -ne $env:PACKED_NUPKG_SHA256) { + throw "The package changed between Pack ($env:PACKED_NUPKG_SHA256) and Test ($consumed)." + } + "Packaging tests consume ``$(Split-Path -Leaf $env:CESDK_PACKAGED_UMBRELLA_NUPKG)`` (SHA-256 ``$consumed``)." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 } dotnet test @options if ($LASTEXITCODE -ne 0) { throw "$env:CONFIGURATION tests failed with exit code $LASTEXITCODE." } - if ($env:CONFIGURATION -eq 'Debug') { - $modules = @(Get-ChildItem -LiteralPath $env:RESULTS -Filter *.trx -File).Count - $reports = @(Get-ChildItem -LiteralPath $env:RESULTS -Filter *.xml -File).Count - if ($reports -eq 0 -or $reports -ne $modules) { - throw "Expected one coverage report per test module ($modules), found $reports." - } - } - # The shipped package comes from the build the tests just ran against. The pack is incremental: nothing recompiles. - - name: Pack + # The uploaded file is the file the tests consumed. + - name: Verify the tested package is unchanged if: matrix.configuration == 'Release' env: - PACKAGE_VERSION: ${{ inputs.package-version }} + PACKAGE: ${{ steps.pack.outputs.nupkg }} + PACKED_NUPKG_SHA256: ${{ steps.pack.outputs.sha256 }} run: | - dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget - if ($LASTEXITCODE -ne 0) { - throw "SDK package creation failed with exit code $LASTEXITCODE." + $actual = (Get-FileHash -LiteralPath $env:PACKAGE -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:PACKED_NUPKG_SHA256) { + throw "The package changed during the tests: packed $env:PACKED_NUPKG_SHA256, now $actual." } - $packages = @(Get-ChildItem -Path artifacts/nuget -Filter *.nupkg -File) - if ($packages.Count -ne 1) { - throw "Expected one package in artifacts/nuget, found $($packages.Count)." - } - if ($env:PACKAGE_VERSION -and $packages[0].Name -ne "CheatEngine.SDK.$env:PACKAGE_VERSION.nupkg") { - throw "Packed $($packages[0].Name), but the release requires CheatEngine.SDK.$env:PACKAGE_VERSION.nupkg." - } - "Packed ``$($packages[0].Name)``." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - name: Upload package if: matrix.configuration == 'Release' @@ -276,11 +330,34 @@ jobs: if-no-files-found: warn retention-days: 7 + - name: Upload test dumps + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-dumps-${{ matrix.configuration }} + # Hang and crash dumps, and the crash sequence file listing the tests that were running. + path: | + ${{ env.RESULTS }}/**/*.dmp + ${{ env.RESULTS }}/**/*sequence* + if-no-files-found: ignore + retention-days: 5 + + # Binary logs only on failure: they record the environment the build saw. + # https://learn.microsoft.com/visualstudio/ide/msbuild-logs#provide-msbuild-binary-logs-for-investigation + - name: Upload binary logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binlogs-build-test-${{ matrix.configuration }} + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + aot: name: Native AOT publication probe needs: native - runs-on: windows-latest - timeout-minutes: 20 + runs-on: windows-2025 + timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -288,22 +365,14 @@ jobs: fetch-depth: 0 # MinVer computes the version from tags and history persist-credentials: false + # The three projects live outside the solution's test run; their lock files carry the win-x64 ILCompiler packages. - name: Setup .NET uses: ./.github/actions/setup-dotnet with: - restore: tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj - - - name: Restore Native AOT library loader harness - run: | - $ErrorActionPreference = 'Stop' - dotnet restore tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj - if ($LASTEXITCODE -ne 0) { - throw "Native AOT library probe restore failed with exit code $LASTEXITCODE." - } - dotnet restore tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj - if ($LASTEXITCODE -ne 0) { - throw "Native AOT loader harness restore failed with exit code $LASTEXITCODE." - } + restore: | + tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj + tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj + tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj - name: Use CI-built native bridge uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -316,7 +385,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' $output = Join-Path $PWD 'artifacts/aot-probe' - dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore -o $output + dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore -o $output -bl:artifacts/logs/aot-probe.binlog if ($LASTEXITCODE -ne 0) { throw "Native AOT probe publish failed with exit code $LASTEXITCODE." } @@ -336,11 +405,11 @@ jobs: $profileOutput = Join-Path $PWD 'artifacts/nativeaot-loader-profile' $libraryProject = 'tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj' $harnessProject = 'tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj' - dotnet publish $libraryProject -c Release --no-restore -o $profileOutput + dotnet publish $libraryProject -c Release --no-restore -o $profileOutput -bl:artifacts/logs/aot-library-probe.binlog if ($LASTEXITCODE -ne 0) { throw "Native AOT shared-library publish failed with exit code $LASTEXITCODE." } - dotnet publish $harnessProject -c Release --no-restore -o $profileOutput + dotnet publish $harnessProject -c Release --no-restore -o $profileOutput -bl:artifacts/logs/aot-loader-harness.binlog if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness publish failed with exit code $LASTEXITCODE." } @@ -357,34 +426,79 @@ jobs: & $harness --load --acknowledge-process-resident-load if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness load exited with code $LASTEXITCODE." } - # Secrets never reach fork or Dependabot runs, and a merge-queue branch is analysed again once it lands on main. - # Pull requests fail on the quality gate; main only reports it. + - name: Upload binary logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binlogs-aot + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + + # C2 evidence only: a hostfxr component-route emulator that exercises the same managed entry point Cheat Engine's + # loader would call, never Cheat Engine itself. The Debug build-test leg downloads its output and runs + # NativeHostEmulatorTests against it (tests/native-host-emulator/README.md). + native-host-emulator: + name: Native host emulator + needs: native + runs-on: windows-2025 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Needed only to locate the installed Microsoft.NETCore.App.Host.win-x64 pack's nethost headers/lib/dll; no + # project is restored here. + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Build native host emulator + run: | + $ErrorActionPreference = 'Stop' + ./tests/native-host-emulator/build.ps1 -OutputDirectory artifacts/native-host-emulator + if ($LASTEXITCODE -ne 0) { + throw "Native host emulator build failed with exit code $LASTEXITCODE." + } + + - name: Upload native host emulator + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-host-emulator + path: artifacts/native-host-emulator + if-no-files-found: error + retention-days: 14 + + # SONAR_EXPECTED: the same expression is the gate's SONAR_EXPECTED variable (env is not available in jobs..if; + # WorkflowContractTests keeps the two texts equal). Secrets never reach fork or Dependabot runs, the dormant merge_group + # clause stays as defense in depth, and release.yml never passes sonar. github.actor is deliberate: a maintainer who + # pushes onto a Dependabot branch has the token and must be analysed. Outside push events the quality gate is awaited. sonar: name: Sonar needs: build-test if: >- - inputs.sonar - && github.event_name != 'merge_group' - && github.actor != 'dependabot[bot]' - && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + ${{ inputs.sonar + && github.event_name != 'merge_group' + && github.actor != 'dependabot[bot]' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} uses: ./.github/workflows/sonar.yml with: - wait-quality-gate: ${{ github.event_name == 'pull_request' }} + wait-quality-gate: ${{ github.event_name != 'push' }} secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} lint: - name: Lint workflows - runs-on: ubuntu-latest - timeout-minutes: 5 + name: Lint + runs-on: ubuntu-24.04 + timeout-minutes: 10 env: ACTIONLINT_VERSION: 1.7.12 ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 # linux_amd64, official checksums file steps: - - name: Checkout workflow definitions + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - sparse-checkout: .github persist-credentials: false - name: Run actionlint @@ -406,32 +520,150 @@ jobs: throw "actionlint failed with exit code $LASTEXITCODE." } + # Offline audits only: the gate must not depend on the GitHub API. The online audits run in an advisory workflow. + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + online-audits: false + advanced-security: false + annotations: true + config: .github/zizmor.yml + + # Whitespace formatting of every C# file, including projects outside the solution. Style rules are enforced by the + # build itself (EnforceCodeStyleInBuild with warnings as errors). No restore: --folder needs no MSBuild workspace. + format: + name: Format + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Verify whitespace formatting + run: | + dotnet format whitespace . --folder --verify-no-changes --exclude artifacts + if ($LASTEXITCODE -ne 0) { + throw "Whitespace formatting differs (exit code $LASTEXITCODE). Run 'dotnet format whitespace . --folder --exclude artifacts' and commit the result." + } + + # Always runs, so the gate never sees it skipped: pull requests are reviewed, every other event records a notice. + dependency-review: + name: Dependency review + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + if: github.event_name == 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Review dependency changes + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + config-file: ./.github/dependency-review-config.yml + comment-summary-in-pr: never + retry-on-snapshot-warnings: true + + - name: Nothing to review + if: github.event_name != 'pull_request' + env: + EVENT: ${{ github.event_name }} + run: Write-Host "::notice title=Dependency review::A $env:EVENT run has no base to compare; dependency review runs on pull requests." + + # The committed packages.lock.json files must be exactly what a fresh restore produces. Windows only: the Native AOT + # lock sections record the host-RID ILCompiler packages. + lock-files: + name: Lock files + runs-on: windows-2025 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The composite action's own locked-mode restore is the verification: it fails with NU1004 the moment a + # committed packages.lock.json no longer matches a fresh restore. CheatEngine.SDK.AotProbe is the only project + # outside the solution (Solution/SolutionInventoryTests.cs). + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + with: + restore: | + CheatEngine.SDK.slnx + tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj + gate: name: Gate # always(): a failed or cancelled job must turn the required check red instead of skipping it. if: always() - needs: [ native, build-test, aot, sonar, lint ] - runs-on: ubuntu-latest + needs: [ native, build-test, aot, native-host-emulator, sonar, lint, format, dependency-review, lock-files ] + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: { } steps: - name: Check results env: NEEDS: ${{ toJSON(needs) }} + SONAR_EXPECTED: >- + ${{ inputs.sonar + && github.event_name != 'merge_group' + && github.actor != 'dependabot[bot]' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + EVENT: ${{ github.event_name }} run: | - # Every job must succeed. Only sonar may be skipped: its own condition turns it off for releases, the merge - # queue, forks and Dependabot. A sonar skipped because build-test failed is caught by build-test's result. - $mayBeSkipped = @('sonar') + # Every job must succeed, except sonar, which must match SONAR_EXPECTED: success when it is true, skipped when it + # is false (fork, Dependabot, merge_group or release run). A sonar run that was not expected means the job + # condition and this expression drifted apart, which fails the gate too. No other job may ever be skipped. + $ErrorActionPreference = 'Stop' + if ($env:SONAR_EXPECTED -notin @('true', 'false')) { + Write-Host "::error title=Gate::SONAR_EXPECTED must be 'true' or 'false', got '$env:SONAR_EXPECTED'." + exit 1 + } + $sonarExpected = $env:SONAR_EXPECTED -eq 'true' $needs = $env:NEEDS | ConvertFrom-Json -AsHashtable - $failed = [Collections.Generic.List[string]]::new() - $rows = foreach ($job in @($needs.Keys | Sort-Object)) { - $result = $needs[$job].result - $allowed = if ($job -in $mayBeSkipped) { 'success', 'skipped' } else { 'success' } - if ($result -notin $allowed) { $failed.Add("$job ($result)") } - "| $job | $result |" - } - @('| Job | Result |', '| --- | --- |') + $rows | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - if ($failed.Count -gt 0) { - Write-Host "::error::Not successful: $($failed -join ', ')." + if ($needs.Count -eq 0) { + Write-Host '::error title=Gate::The gate received no job results.' + exit 1 + } + $mismatches = [Collections.Generic.List[string]]::new() + $rows = [Collections.Generic.List[string]]::new() + foreach ($job in @($needs.Keys | Sort-Object)) { + $result = [string] $needs[$job].result + $required = 'success' + $reason = 'every job must succeed' + if ($job -eq 'sonar') { + if ($sonarExpected) { + $reason = "SONAR_EXPECTED=true on ${env:EVENT}: the analysis must run and pass" + if ($result -eq 'skipped') { + $upstream = [string] $needs['build-test'].result + $reason += ($upstream -ne 'success') ? "; skipped because build-test was $upstream" : '; skipped although expected: the sonar condition drifted' + } + } + else { + $required = 'skipped' + $reason = "SONAR_EXPECTED=false on ${env:EVENT} (fork, Dependabot, merge_group or release run)" + if ($result -ne 'skipped') { + $reason += '; it ran although not expected: the sonar condition drifted' + } + } + } + if ($result -ne $required) { + $mismatches.Add("$job is $result, required $required") + } + $rows.Add("| $job | $result | $required | $reason |") + } + @('### Gate', '', '| Job | Result | Required | Reason |', '| --- | --- | --- | --- |') + $rows | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($mismatches.Count -gt 0) { + Write-Host "::error title=Gate::$($mismatches -join '; ')." exit 1 } + Write-Host "All $($needs.Count) jobs have their required result." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..acc99a5a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,147 @@ +# Advisory CodeQL code scanning (audit register PR-CQ-22). Not part of CI / Gate: findings go to the Security tab and +# appear as pull request annotations. +# - C#: a manual, traced Release build of the shipped product graph (src/CheatEngine.SDK builds the six libraries, the +# analyzers and every source generator it packs), so generated code is analysed; build-mode none would skip it. +# - C/C++: the native bridge and the ABI fixture sources, without a build (build-mode none), on Windows so the +# extractor sees the Windows SDK headers. +# - GitHub Actions: the workflows and the composite action. +# The repository's code-scanning default setup must stay OFF: GitHub rejects advanced-setup uploads while it is on. +# No NuGet, dependency or TRAP cache: no cache on any path reachable by codeql (shared-contracts 1.5). +# A fork pull request gets a read-only token, so its SARIF upload can fail; the workflow is advisory and never moves to +# pull_request_target. +name: CodeQL + +on: + push: + branches: [ main ] + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + schedule: + - cron: '17 3 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + csharp: + name: Analyze (csharp) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: windows-2025 + timeout-minutes: 45 + permissions: + contents: read + security-events: write # upload CodeQL SARIF + actions: read # CodeQL reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # MinVer computes the real version, so no version-dependent guard sees a fallback + persist-credentials: false + + - name: Set up .NET and restore the product graph + uses: ./.github/actions/setup-dotnet + with: + restore: src/CheatEngine.SDK/CheatEngine.SDK.csproj + + - name: Initialize CodeQL + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + languages: csharp + build-mode: manual + queries: security-extended + dependency-caching: false + trap-caching: false + + # The compiler must run inside the traced build: no incremental skip, no build server, no compiler server (the + # tracer only sees newly created csc processes). CodeQL injects EmitCompilerGeneratedFiles itself. + # https://learn.microsoft.com/dotnet/core/tools/dotnet-build + # https://docs.github.com/en/code-security/reference/code-scanning/codeql/build-options-for-compiled-languages + - name: Build the shipped product graph + run: | + $ErrorActionPreference = 'Stop' + dotnet build src/CheatEngine.SDK/CheatEngine.SDK.csproj -c Release --no-restore --no-incremental --disable-build-servers -p:UseSharedCompilation=false -bl:artifacts/logs/codeql-csharp.binlog + if ($LASTEXITCODE -ne 0) { + throw "CodeQL traced build failed with exit code $LASTEXITCODE." + } + + - name: Analyze + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + category: /language:csharp + + - name: Upload binary log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binlogs-codeql + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + + cpp: + name: Analyze (c-cpp) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: windows-2025 + timeout-minutes: 20 + permissions: + contents: read + security-events: write # upload CodeQL SARIF + actions: read # CodeQL reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + languages: c-cpp + build-mode: none + queries: security-extended + dependency-caching: false + trap-caching: false + + - name: Analyze + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + category: /language:c-cpp + + actions: + name: Analyze (actions) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + security-events: write # upload CodeQL SARIF + actions: read # CodeQL reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + languages: actions + build-mode: none + queries: security-extended + dependency-caching: false + trap-caching: false + + - name: Analyze + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + category: /language:actions diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml new file mode 100644 index 00000000..189ba912 --- /dev/null +++ b/.github/workflows/dependency-submission.yml @@ -0,0 +1,263 @@ +# Advisory CI-side dependency submission (audit register PR-CQ-45). Automatic NuGet submission needs an +# organization-level setting that cannot be enabled here, and the dependency graph resolves no version from the Central +# Package Management layout on its own (it lists "Microsoft.CodeAnalysis.CSharp >= 0"). +# - detect: read-only token. A pinned, hash-verified Component Detection binary scans the locked restore. The official +# submission action is not used: it downloads the latest Component Detection release at run time and executes it +# next to a contents: write token. +# - submit: the only job with contents: write. It executes no third-party code (download-artifact and gh api only) and +# refuses a snapshot whose commit, ref or correlator is not this run's, because detect ran repository code. +# Pushes to main give dependency review its base snapshot; same-repository pull requests (Dependabot included) submit +# a head snapshot for the pull request head commit. Fork pull requests and drafts never run it. +# detect restores in locked mode, like CI: on a Dependabot pull request whose lock files still miss entries that only +# 'dotnet restore --force-evaluate' regenerates (the SDK-implicit packages), it fails until the maintainer regenerates +# them on the branch and pushes. It is advisory, never a required check. +name: Dependency submission + +on: + push: + branches: [ main ] + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + workflow_dispatch: + +# Only the newest snapshot of a pull request matters, so a pull request run cancels the previous one. Runs for main are +# serialized and a running one is never cancelled, so snapshots reach the graph in push order. GitHub keeps at most one +# pending run per group, though: after three quick pushes the middle commit's pending run is replaced by the newest one +# and that commit gets no snapshot. Accepted: the newest commit of main always gets one; dependency review of a pull +# request based on a commit without a snapshot compares against the manifest-only graph and reports snapshot warnings. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + detect: + name: Detect NuGet dependencies + if: >- + github.event_name != 'pull_request' + || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) + # Windows: the lock files of the Native AOT projects hold win-x64 ILCompiler sections, so a locked restore needs it. + runs-on: windows-2025 + timeout-minutes: 30 + steps: + # The snapshot describes the commit it names: the pull request head, not the merge commit. + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + # The step restores itself (locked, with binary logs for the detector). + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + + # GitHub's dependency graph cannot read the Central Package Management layout (it lists NuGet packages as + # ">= 0"), and automatic dependency submission needs an organization-level setting, so the snapshot + # (https://docs.github.com/en/rest/dependency-graph/dependency-submission) is built here: a pinned Component + # Detection binary scans a locked restore of every project, and its manifest becomes one GitHub manifest per + # project (repository-relative path), "direct" for the packages a project references explicitly, "development" + # scope for the detector's development dependencies, and the dependency edges of each project's graph. + - name: Run Component Detection and build the snapshot + env: + SNAPSHOT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SNAPSHOT_REF: ${{ github.ref }} + SNAPSHOT_CORRELATOR: sdk-nuget + SNAPSHOT_JOB_ID: ${{ github.run_id }} + SNAPSHOT_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + $ErrorActionPreference = 'Stop' + + # Pinned detector. Bump the three values together after reading the release and its asset digest: + # gh api repos/microsoft/component-detection/releases/tags/v --jq '.assets[] | select(.name == "component-detection-win-x64.exe") | .digest' + $detectorVersion = '8.0.1' + $detectorAsset = 'component-detection-win-x64.exe' + $detectorSha256 = '9539f792cd2ae7d719922db45df763ec4454cc380fbfcbe685da9a90c1b39cf2' + $minimumPackageCount = 40 + $sentinelPackage = 'Microsoft.CodeAnalysis.CSharp' + $solutionFile = 'CheatEngine.SDK.slnx' + + function ConvertTo-RepositoryPath($Location) { + $absolute = if ([IO.Path]::IsPathRooted($Location)) { $Location } else { Join-Path $PWD.Path $Location } + $relative = [IO.Path]::GetRelativePath($PWD.Path, $absolute).Replace('\', '/') + if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or [IO.Path]::IsPathRooted($relative)) { return $null } + return $relative + } + + function Get-PackageUrl($Component) { + # Package URL spec: the NuGet type keeps the name's case; '@' in a name must be percent-encoded. + return "pkg:nuget/$($Component.Name.Replace('@', '%40'))@$($Component.Version)" + } + + function ConvertTo-OrdinalSet($Item) { + $values = [string[]] @($Item | Where-Object { $null -ne $_ }) + $set = [Collections.Generic.HashSet[string]]::new($values, [StringComparer]::Ordinal) + return , $set # the unary comma keeps the set whole: PowerShell would otherwise enumerate it. + } + + $work = Join-Path ([IO.Path]::GetTempPath()) "cheatengine-sdk-snapshot-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $work | Out-Null + try { + $detector = Join-Path $work $detectorAsset + $scanManifest = Join-Path $work 'component-detection-manifest.json' + + Invoke-WebRequest -Uri "https://github.com/microsoft/component-detection/releases/download/v$detectorVersion/$detectorAsset" -OutFile $detector -MaximumRetryCount 3 -RetryIntervalSec 5 + $actualSha256 = (Get-FileHash -LiteralPath $detector -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha256 -cne $detectorSha256) { + throw "$detectorAsset v$detectorVersion has SHA-256 $actualSha256, expected $detectorSha256. Refusing to run it." + } + + [xml] $solution = Get-Content -Raw -LiteralPath $solutionFile + $inSolution = @($solution.SelectNodes('//Project') | ForEach-Object { $_.GetAttribute('Path').Replace('\', '/') }) + $outOfSolution = @(git ls-files -- '*.csproj') + if ($LASTEXITCODE -ne 0) { + throw "git ls-files failed with exit code $LASTEXITCODE." + } + $outOfSolution = @($outOfSolution | Where-Object { $_ -notin $inSolution } | Sort-Object) + $targets = @($solutionFile) + $outOfSolution + New-Item -ItemType Directory -Force -Path artifacts/logs | Out-Null + foreach ($target in $targets) { + $binaryLog = "artifacts/logs/dependency-restore-$([IO.Path]::GetFileNameWithoutExtension($target)).binlog" + dotnet restore $target --locked-mode "-bl:$binaryLog" + if ($LASTEXITCODE -ne 0) { + throw "Locked restore of '$target' failed with exit code $LASTEXITCODE." + } + } + + & $detector scan --SourceDirectory $PWD.Path --ManifestFile $scanManifest --DetectorCategories NuGet --Output $work --LogLevel Warning + if ($LASTEXITCODE -ne 0) { + throw "Component Detection failed with exit code $LASTEXITCODE." + } + + $scan = Get-Content -LiteralPath $scanManifest -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable + $components = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($found in @($scan['componentsFound'])) { + $component = $found['component'] + if ($component['type'] -ceq 'NuGet') { + $components[[string] $component['id']] = [pscustomobject]@{ Name = [string] $component['name']; Version = [string] $component['version'] } + } + } + + $manifests = [ordered]@{} + foreach ($location in @($scan['dependencyGraphs'].Keys | Sort-Object)) { + $relative = ConvertTo-RepositoryPath $location + if ($null -eq $relative) { continue } + + $graph = $scan['dependencyGraphs'][$location] + $explicit = ConvertTo-OrdinalSet $graph['explicitlyReferencedComponentIds'] + $development = ConvertTo-OrdinalSet $graph['developmentDependencies'] + $resolved = [ordered]@{} + foreach ($id in @($graph['graph'].Keys | Sort-Object)) { + if (-not $components.ContainsKey($id)) { continue } + + $children = @(@($graph['graph'][$id]) | Where-Object { $_ -and $components.ContainsKey($_) } | + ForEach-Object { Get-PackageUrl $components[$_] } | Sort-Object -Unique) + $packageUrl = Get-PackageUrl $components[$id] + $resolved[$packageUrl] = [ordered]@{ + package_url = $packageUrl + relationship = if ($explicit.Contains($id)) { 'direct' } else { 'indirect' } + scope = if ($development.Contains($id)) { 'development' } else { 'runtime' } + dependencies = [string[]] $children + } + } + + if ($resolved.Count -gt 0) { + $manifests[$relative] = [ordered]@{ name = $relative; file = [ordered]@{ source_location = $relative }; resolved = $resolved } + } + } + } + finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + } + + $job = [ordered]@{ correlator = $env:SNAPSHOT_CORRELATOR; id = $env:SNAPSHOT_JOB_ID; html_url = $env:SNAPSHOT_JOB_URL } + $snapshot = [ordered]@{ + version = 0 + sha = $env:SNAPSHOT_SHA + ref = $env:SNAPSHOT_REF + job = $job + detector = [ordered]@{ name = 'Microsoft Component Detection'; version = $detectorVersion; url = 'https://github.com/microsoft/component-detection' } + scanned = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) + manifests = $manifests + } + + $packageUrls = @($manifests.Values | ForEach-Object { $_['resolved'].Keys } | Sort-Object -Unique) + if ($packageUrls.Count -lt $minimumPackageCount) { + throw "Only $($packageUrls.Count) NuGet packages detected (at least $minimumPackageCount expected): the scan missed the restore." + } + + [xml] $packageVersions = Get-Content -Raw -LiteralPath Directory.Packages.props + $sentinelVersion = @($packageVersions.SelectNodes('//PackageVersion') | + Where-Object { $_.GetAttribute('Include') -ceq $sentinelPackage } | Select-Object -First 1).GetAttribute('Version') + $sentinelUrl = "pkg:nuget/$sentinelPackage@$sentinelVersion" + if ($packageUrls -cnotcontains $sentinelUrl) { + throw "The snapshot lacks $sentinelUrl, the Roslyn pin of Directory.Packages.props: the scan missed the analyzers and generators." + } + + New-Item -ItemType Directory -Force -Path artifacts/dependency-snapshot | Out-Null + [IO.File]::WriteAllText('artifacts/dependency-snapshot/snapshot.json', ($snapshot | ConvertTo-Json -Depth 20), [Text.UTF8Encoding]::new($false)) + "Detected $($packageUrls.Count) distinct NuGet packages across $($manifests.Count) manifests." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + - name: Upload snapshot + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dependency-snapshot + path: artifacts/dependency-snapshot/snapshot.json + if-no-files-found: error + retention-days: 5 + + submit: + name: Submit NuGet dependency graph + needs: detect + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write # POST /repos/{owner}/{repo}/dependency-graph/snapshots requires write + steps: + - name: Download snapshot + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dependency-snapshot + path: snapshot + + # The artifact came from a job that ran repository code: submit it only with the snapshot shape and this run's + # commit, ref and correlator. + - name: Submit + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SNAPSHOT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SNAPSHOT_REF: ${{ github.ref }} + SNAPSHOT_CORRELATOR: sdk-nuget + run: | + $ErrorActionPreference = 'Stop' + $snapshot = Get-Content -LiteralPath snapshot/snapshot.json -Raw | ConvertFrom-Json -AsHashtable + $properties = @($snapshot.Keys | Sort-Object) -join ',' + if ($properties -cne 'detector,job,manifests,ref,scanned,sha,version') { + throw "The snapshot artifact has unexpected top-level properties ($properties); nothing was submitted." + } + $job = $snapshot['job'] + $checks = [ordered]@{ + version = $snapshot['version'] -is [long] -and $snapshot['version'] -eq 0 + sha = $snapshot['sha'] -ceq $env:SNAPSHOT_SHA + ref = $snapshot['ref'] -ceq $env:SNAPSHOT_REF + correlator = $job -is [System.Collections.IDictionary] -and $job['correlator'] -ceq $env:SNAPSHOT_CORRELATOR + detector = $snapshot['detector'] -is [System.Collections.IDictionary] + manifests = $snapshot['manifests'] -is [System.Collections.IDictionary] -and $snapshot['manifests'].Count -gt 0 + } + $mismatches = @($checks.Keys | Where-Object { -not $checks[$_] }) + if ($mismatches.Count -gt 0) { + throw "The snapshot artifact does not match this run ($($mismatches -join ', ')); nothing was submitted." + } + gh api --method POST "repos/$env:REPOSITORY/dependency-graph/snapshots" --input snapshot/snapshot.json + if ($LASTEXITCODE -ne 0) { + throw "Dependency snapshot submission failed with exit code $LASTEXITCODE." + } + "Submitted $($snapshot['manifests'].Count) manifests for ``$env:SNAPSHOT_SHA`` ($env:SNAPSHOT_REF)." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index baaadf1f..2027ff23 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -1,22 +1,26 @@ name: Main CI +# Every commit that lands on main, and manual runs on any branch. There is no merge queue, so there is no merge_group +# trigger: an event path that never runs must not live in the required check. on: push: branches: [ main ] - merge_group: - types: [ checks_requested ] workflow_dispatch: -# No concurrency group: every main commit and merge group keeps its own complete run. +# No concurrency group: every main commit keeps its own complete run. permissions: contents: read +defaults: + run: + shell: pwsh + jobs: ci: name: CI uses: ./.github/workflows/ci.yml with: - sonar: true # ci.yml skips it for the merge queue and only reports the quality gate outside pull requests + sonar: true # a push to main reports the quality gate; a manual run waits for it secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index c3502c32..16862436 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -1,5 +1,6 @@ name: Pull request CI +# No path filter: the required check "CI / Gate" must report on every pull request. on: pull_request: types: [ opened, synchronize, reopened, ready_for_review ] @@ -12,6 +13,10 @@ concurrency: permissions: contents: read +defaults: + run: + shell: pwsh + jobs: ci: name: CI @@ -19,6 +24,6 @@ jobs: if: ${{ !github.event.pull_request.draft }} uses: ./.github/workflows/ci.yml with: - sonar: true # ci.yml still skips Sonar for fork and Dependabot pull requests, which receive no secrets + sonar: true # the gate still expects no Sonar run for fork and Dependabot pull requests, which receive no secrets secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c0a2acd..fb842471 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,14 @@ name: Release run-name: Release ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' (dry run)' || '' }} -# verify ─► ci (build and test the tag, pack the tested build) ─► publish (manual approval) ─► github-release -# The nuget-package artifact built and tested by ci is the exact file pushed to nuget.org and attached to the release. +# verify ─► ci ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release +# +# ci builds and tests the tag and packs the tested build; its nuget-package artifact is the one file that the packaging +# tests consume, that is attested, attached to the release and pushed to nuget.org. The release is created as a draft +# that already carries every asset (an immutable release locks its assets and tag once published) and is published +# only after nuget.org serves the repository-signed package. A workflow_dispatch dry run stops after attest: it builds +# the SBOM and SHA256SUMS, creates no attestation and no release, and publishes nothing. +# See RELEASING.md. on: push: @@ -25,11 +31,13 @@ defaults: jobs: verify: name: Verify tag - runs-on: windows-latest - timeout-minutes: 5 + runs-on: ubuntu-24.04 + timeout-minutes: 10 outputs: version: ${{ steps.tag.outputs.version }} prerelease: ${{ steps.tag.outputs.prerelease }} + tag: ${{ steps.tag.outputs.tag }} + tree: ${{ steps.tag.outputs.tree }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -45,6 +53,11 @@ jobs: $ErrorActionPreference = 'Stop' if ($env:GITHUB_REF_TYPE -ne 'tag') { + $tree = git rev-parse --verify 'HEAD^{tree}' + if ($LASTEXITCODE -ne 0) { + throw "Resolving the tree of the dry-run commit failed with exit code $LASTEXITCODE." + } + "tree=$tree" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 Write-Host "::notice::Dry run on branch $env:GITHUB_REF_NAME, so there is no tag to verify." exit 0 } @@ -70,6 +83,10 @@ jobs: Write-Host "::error::Tag $env:GITHUB_REF_NAME points to $commit, which did not land on main. Tag the merged head of main." exit 1 } + $tree = git rev-parse --verify "${commit}^{tree}" + if ($LASTEXITCODE -ne 0) { + throw "Resolving the tree of $commit failed with exit code $LASTEXITCODE." + } # A full re-run after a successful publish would rebuild a different file for an immutable version. Re-run # only the failed jobs of the original run: they reuse its artifacts. @@ -86,8 +103,8 @@ jobs: } $prerelease = if ($version.Contains('-')) { 'true' } else { 'false' } - "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - "prerelease=$prerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + @("version=$version", "prerelease=$prerelease", "tag=$env:GITHUB_REF_NAME", "tree=$tree") | + Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 # The GitHub release notes are the CHANGELOG section of the version. A prerelease may ship before its entries # leave [Unreleased]; a final release may not. @@ -137,32 +154,334 @@ jobs: package-version: ${{ needs.verify.outputs.version }} package-retention-days: 90 + # Attestations are created before publication because the draft must already carry their bundles. On a dry run the + # attest/verify steps below are skipped (their own 'if' guards the exact tag push condition). + attest: + name: Attest and assemble release assets + needs: [ verify, ci ] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write # the Sigstore certificate that signs the attestations is bound to this workflow's OIDC identity + attestations: write # store the provenance and SBOM attestations of the package in this repository + env: + VERSION: ${{ needs.verify.outputs.version }} + TAG: ${{ needs.verify.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nuget-package + path: artifacts/nuget + + - name: Stage package and SBOM + id: stage + run: | + $ErrorActionPreference = 'Stop' + $packages = @(Get-ChildItem -LiteralPath artifacts/nuget -Filter 'CheatEngine.SDK.*.nupkg' -File) + if ($packages.Count -ne 1) { + throw "nuget-package must hold exactly one CheatEngine.SDK package, found $($packages.Count)." + } + $name = $packages[0].Name + if ($env:VERSION -and $name -cne "CheatEngine.SDK.$env:VERSION.nupkg") { + throw "nuget-package holds $name, but the tag requires CheatEngine.SDK.$env:VERSION.nupkg." + } + $version = $name.Substring('CheatEngine.SDK.'.Length, $name.Length - 'CheatEngine.SDK.'.Length - '.nupkg'.Length) + $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' + New-Item -ItemType Directory -Path $release -Force | Out-Null + Copy-Item -LiteralPath $packages[0].FullName -Destination $release + $package = Join-Path $release $name + + # Microsoft.Sbom.Targets embeds the SPDX 2.2 SBOM at pack time; the release attests that exact document + # (sbom-path derives predicate https://spdx.dev/Document/v2.2 from its spdxVersion), so the bytes are + # extracted, never re-serialized. + $sbomEntry = '_manifest/spdx_2.2/manifest.spdx.json' + $sbom = Join-Path $release "CheatEngine.SDK.$version.spdx.json" + $archive = [IO.Compression.ZipFile]::OpenRead($package) + try { + $entry = $archive.GetEntry($sbomEntry) + if ($null -eq $entry) { + throw "$name embeds no SPDX 2.2 SBOM at $sbomEntry." + } + $stream = $entry.Open() + try { + $document = [IO.MemoryStream]::new() + $stream.CopyTo($document) + $bytes = $document.ToArray() + } finally { + $stream.Dispose() + } + } finally { + $archive.Dispose() + } + $spdxVersion = ([Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json).spdxVersion + if ($spdxVersion -cne 'SPDX-2.2') { + throw "The SBOM of $name declares spdxVersion '$spdxVersion', expected SPDX-2.2." + } + [IO.File]::WriteAllBytes($sbom, $bytes) + @("version=$version", "package=$package", "sbom=$sbom") | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Attest provenance + id: provenance + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.stage.outputs.package }} + + # The predicate type https://spdx.dev/Document/v2.2 is derived from the SBOM's spdxVersion. + - name: Attest SBOM + id: sbom + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.stage.outputs.package }} + sbom-path: ${{ steps.stage.outputs.sbom }} + + - name: Verify attestations + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + env: + GH_TOKEN: ${{ github.token }} + PACKAGE: ${{ steps.stage.outputs.package }} + STAGED_VERSION: ${{ steps.stage.outputs.version }} + PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }} + SBOM_BUNDLE: ${{ steps.sbom.outputs.bundle-path }} + run: | + $ErrorActionPreference = 'Stop' + $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' + $provenance = Join-Path $release "CheatEngine.SDK.$env:STAGED_VERSION.provenance.sigstore.json" + $sbom = Join-Path $release "CheatEngine.SDK.$env:STAGED_VERSION.sbom.sigstore.json" + Copy-Item -LiteralPath $env:PROVENANCE_BUNDLE -Destination $provenance + Copy-Item -LiteralPath $env:SBOM_BUNDLE -Destination $sbom + $identity = @( + '-R', 'CheatEngineNet/CheatEngine.SDK', + '--signer-workflow', 'CheatEngineNet/CheatEngine.SDK/.github/workflows/release.yml', + '--source-ref', "refs/tags/$env:TAG", + '--deny-self-hosted-runners' + ) + $checks = @( + @('--bundle', $provenance), + @('--bundle', $sbom, '--predicate-type', 'https://spdx.dev/Document/v2.2'), + @(), + @('--predicate-type', 'https://spdx.dev/Document/v2.2') + ) + foreach ($check in $checks) { + gh attestation verify $env:PACKAGE @identity @check + if ($LASTEXITCODE -ne 0) { + throw "gh attestation verify $($check -join ' ') failed with exit code $LASTEXITCODE." + } + } + + # One line per asset: 64 lowercase hex digits, two spaces, the file name (the 'sha256sum -c' / 'shasum -a 256 -c' + # form), sorted ordinally by name. + - name: Write checksums + env: + STAGED_VERSION: ${{ steps.stage.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' + $prefix = "CheatEngine.SDK.$env:STAGED_VERSION" + $names = @("$prefix.nupkg", "$prefix.spdx.json") + if (Test-Path -LiteralPath (Join-Path $release "$prefix.provenance.sigstore.json")) { + $names += "$prefix.provenance.sigstore.json", "$prefix.sbom.sigstore.json" + } + $names = @($names | Sort-Object) + $lines = foreach ($name in $names) { + $hash = (Get-FileHash -LiteralPath (Join-Path $release $name) -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $name" + } + [IO.File]::WriteAllText((Join-Path $release 'SHA256SUMS'), (($lines -join "`n") + "`n"), [Text.UTF8Encoding]::new($false)) + + - name: Upload release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: attestation-bundles + path: | + artifacts/release/* + !artifacts/release/*.nupkg + if-no-files-found: error + retention-days: 90 + + # Never uploads to a published release (its assets are immutable once published): a draft receives its missing + # assets, and a published release that already carries the same assets is left untouched. + draft-release: + name: Create draft release + needs: [ verify, attest ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write # create the draft release and attach its assets + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.verify.outputs.tag }} + VERSION: ${{ needs.verify.outputs.version }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nuget-package + path: artifacts/release + + - name: Download release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: attestation-bundles + path: artifacts/release + + - name: Download release notes + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-notes + path: artifacts + + # No release yet: create a draft with every local asset. A draft release: upload whatever it is still missing. + # A published release (immutable once published): every local asset must already be there with the same + # SHA-256, since nothing may ever be uploaded to it; any other difference needs a maintainer. + - name: Create or complete the draft release + run: | + $ErrorActionPreference = 'Stop' + $release = 'artifacts/release' + $local = [ordered]@{} + foreach ($file in @(Get-ChildItem -LiteralPath $release -File)) { + $local[$file.Name] = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + # Drafts are listed for this token (contents: write), so an absent tag means no release at all. + $releases = gh release list --limit 1000 --json tagName + if ($LASTEXITCODE -ne 0) { + throw "Listing the releases failed with exit code $LASTEXITCODE." + } + $existing = $null + if (@($releases | ConvertFrom-Json | Where-Object { $_.tagName -ceq $env:TAG }).Count -gt 0) { + $view = gh release view $env:TAG --json isDraft,assets + if ($LASTEXITCODE -ne 0) { + throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." + } + $existing = $view | ConvertFrom-Json + } + + $names = [string[]]@($local.Keys) + [Array]::Sort($names, [StringComparer]::Ordinal) + $upload = [Collections.Generic.List[string]]::new() + $problems = [Collections.Generic.List[string]]::new() + $action = 'Create' + if ($null -ne $existing) { + $remote = @{} + foreach ($asset in @($existing.assets)) { + $digest = [string] $asset.digest + $remote[[string] $asset.name] = if ($digest -cmatch '^sha256:[0-9a-f]{64}$') { $digest.Substring(7) } else { '' } + } + foreach ($name in $names) { + if (-not $remote.ContainsKey($name)) { + if ($existing.isDraft) { $upload.Add($name) } + else { $problems.Add("the published release has no '$name'") } + continue + } + if ($remote[$name] -cne $local[$name]) { + $problems.Add("'$name' on the release has SHA-256 $($remote[$name]), this run produced $($local[$name])") + } + } + $action = if ($problems.Count -gt 0) { 'Refuse' } elseif ($existing.isDraft) { 'Complete' } else { 'AlreadyPublished' } + } + else { + $upload.AddRange($names) + } + + switch ($action) { + 'Create' { + $options = @('--draft', '--verify-tag', '--title', $env:TAG, '--notes-file', 'artifacts/release-notes.md') + if ($env:PRERELEASE -eq 'true') { $options += '--prerelease' } + $files = @($upload | ForEach-Object { Join-Path $release $_ }) + gh release create $env:TAG @options @files + if ($LASTEXITCODE -ne 0) { + throw "Creating the draft release failed with exit code $LASTEXITCODE." + } + } + 'Complete' { + foreach ($name in $upload) { + gh release upload $env:TAG (Join-Path $release $name) + if ($LASTEXITCODE -ne 0) { + throw "Uploading $name to the draft failed with exit code $LASTEXITCODE." + } + } + } + 'AlreadyPublished' { + Write-Host "::notice::Release $env:TAG is already published with these assets; nothing is uploaded." + } + default { + Write-Host "::error::Release $env:TAG needs a maintainer: $($problems -join '; '). A draft that was never published can be deleted and this job re-run." + exit 1 + } + } + "Draft release ``$env:TAG``: $action ($($local.Count) assets)." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + # NuGet trusted publishing is bound to this file and the nuget environment, so login and push stay in this job. publish: name: Publish to NuGet - needs: [ verify, ci ] + needs: [ verify, draft-release ] if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' - runs-on: windows-latest + runs-on: windows-2025 timeout-minutes: 15 environment: - name: nuget # required reviewers approve the publication; the environment only admits v*.*.* tags + name: nuget # the maintainer configures its deployment approval and v*.*.* tag policy (RELEASING.md) url: https://www.nuget.org/packages/CheatEngine.SDK/${{ needs.verify.outputs.version }} permissions: contents: read - id-token: write # NuGet trusted publishing and the provenance signature - attestations: write # store the provenance attestation + id-token: write # exchange the OIDC token for a short-lived NuGet API key (trusted publishing) env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - PACKAGE: artifacts/nuget/CheatEngine.SDK.${{ needs.verify.outputs.version }}.nupkg + VERSION: ${{ needs.verify.outputs.version }} steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + - name: Download package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: nuget-package - path: artifacts/nuget + path: artifacts/release + + - name: Download release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: attestation-bundles + path: artifacts/release - # The user is the nuget.org profile name that registered the trusted publishing policy, not an email address. + - name: Check the package against SHA256SUMS + run: | + $ErrorActionPreference = 'Stop' + $name = "CheatEngine.SDK.$env:VERSION.nupkg" + $sums = @{} + foreach ($line in (Get-Content -LiteralPath 'artifacts/release/SHA256SUMS')) { + if ($line -cmatch '^(?[0-9a-f]{64}) (?\S+)$') { + $sums[$Matches['name']] = $Matches['hash'] + } + } + $actual = (Get-FileHash -LiteralPath "artifacts/release/$name" -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not $sums.ContainsKey($name) -or $sums[$name] -cne $actual) { + throw "$name has SHA-256 $actual, which SHA256SUMS of the draft release does not list." + } + Write-Host "Publishing $name with SHA-256 $actual." + + # The temporary API key lives one hour, so the login stays immediately before the push. The user is the + # nuget.org profile name that registered the trusted publishing policy, not an email address. - name: NuGet login id: login uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 @@ -174,58 +493,235 @@ jobs: env: NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} run: | - dotnet nuget push $env:PACKAGE --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push "artifacts/release/CheatEngine.SDK.$env:VERSION.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate if ($LASTEXITCODE -ne 0) { throw "NuGet push failed with exit code $LASTEXITCODE." } - # After the push, so a failed push never leaves an attestation for a package that was not published. - - name: Attest provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ env.PACKAGE }} - - github-release: - name: Create GitHub release + verify-publication: + name: Verify nuget.org publication needs: [ verify, publish ] - runs-on: windows-latest - timeout-minutes: 10 - permissions: - contents: write # create the release and upload the package + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + runs-on: windows-2025 + timeout-minutes: 45 env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} VERSION: ${{ needs.verify.outputs.version }} - PRERELEASE: ${{ needs.verify.outputs.prerelease }} steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + - name: Download package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: nuget-package - path: artifacts/nuget + path: artifacts/release - - name: Download release notes - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + # Resolves PackageBaseAddress/3.0.0 from the service index (never a hard-coded base URL), polls the flat + # container until the version is listed (nuget.org validates and indexes a push for several minutes), downloads + # the repository-signed file, runs 'dotnet nuget verify --all' on it, and compares every entry of the two + # archives byte for byte: the signed copy must hold exactly the attested entries plus '.signature.p7s'. + - name: Verify the nuget.org package + run: | + $ErrorActionPreference = 'Stop' + $package = Join-Path $env:GITHUB_WORKSPACE "artifacts/release/CheatEngine.SDK.$env:VERSION.nupkg" + $lowerId = 'cheatengine.sdk' + $lowerVersion = $env:VERSION.Split('+')[0].ToLowerInvariant() + $deadline = [DateTime]::UtcNow.AddMinutes(35) + + $index = Invoke-RestMethod -Uri 'https://api.nuget.org/v3/index.json' -MaximumRetryCount 5 -RetryIntervalSec 10 + $resource = @($index.resources | Where-Object { $_.'@type' -ceq 'PackageBaseAddress/3.0.0' }) | Select-Object -First 1 + if ($null -eq $resource) { + throw "nuget.org's service index has no PackageBaseAddress/3.0.0 resource." + } + $base = ([string] $resource.'@id').TrimEnd('/') + '/' + + $listed = $false + while (-not $listed) { + try { + $listed = @((Invoke-RestMethod -Uri "$base$lowerId/index.json").versions) -ccontains $lowerVersion + } + catch { + $listed = $false + } + if (-not $listed) { + if ([DateTime]::UtcNow -ge $deadline) { + throw "CheatEngine.SDK $env:VERSION is not listed at $base after 35 minutes. nuget.org may still be validating it; re-run this job." + } + Start-Sleep -Seconds 30 + } + } + + $download = Join-Path $env:RUNNER_TEMP 'nuget-org' + New-Item -ItemType Directory -Path $download -Force | Out-Null + $signed = Join-Path $download "$lowerId.$lowerVersion.nupkg" + Invoke-WebRequest -Uri "$base$lowerId/$lowerVersion/$lowerId.$lowerVersion.nupkg" -OutFile $signed + + $verifyOutput = (& dotnet nuget verify --all -v normal $signed 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { + throw "'dotnet nuget verify --all' rejected the nuget.org package (exit code $LASTEXITCODE): $verifyOutput" + } + Write-Host $verifyOutput + if (-not $verifyOutput.Contains('https://api.nuget.org/v3/index.json', [StringComparison]::OrdinalIgnoreCase)) { + throw "'dotnet nuget verify' did not report a repository signature from https://api.nuget.org/v3/index.json." + } + + $attested = [Collections.Generic.Dictionary[string, byte[]]]::new() + $attestedArchive = [IO.Compression.ZipFile]::OpenRead($package) + try { + foreach ($entry in $attestedArchive.Entries) { + $stream = $entry.Open() + try { + $memory = [IO.MemoryStream]::new() + $stream.CopyTo($memory) + $attested[$entry.FullName] = $memory.ToArray() + } finally { + $stream.Dispose() + } + } + } finally { + $attestedArchive.Dispose() + } + + $signedArchive = [IO.Compression.ZipFile]::OpenRead($signed) + try { + $signedNames = [Collections.Generic.HashSet[string]]::new([string[]] ($signedArchive.Entries | ForEach-Object FullName)) + foreach ($name in $attested.Keys) { + if (-not $signedNames.Contains($name)) { + throw "The nuget.org package is missing the attested entry '$name'." + } + $entry = $signedArchive.GetEntry($name) + $stream = $entry.Open() + try { + $memory = [IO.MemoryStream]::new() + $stream.CopyTo($memory) + if (-not [Linq.Enumerable]::SequenceEqual($memory.ToArray(), $attested[$name])) { + throw "The nuget.org entry '$name' differs from the attested package." + } + } finally { + $stream.Dispose() + } + } + if (-not $signedNames.Contains('.signature.p7s')) { + throw "The nuget.org package has no repository signature (.signature.p7s)." + } + } finally { + $signedArchive.Dispose() + } + + $signedSha256 = (Get-FileHash -LiteralPath $signed -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Host "nuget.org serves CheatEngine.SDK $env:VERSION repository-signed (SHA-256 $signedSha256), identical to the attested package plus its signature." + + finalize-release: + name: Publish GitHub release + needs: [ verify, attest, verify-publication ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write # publish the release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.verify.outputs.tag }} + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - name: release-notes - path: artifacts + persist-credentials: false - - name: Create release + - name: Read the release state + id: state run: | - $name = "CheatEngine.SDK.$env:VERSION.nupkg" - $package = "artifacts/nuget/$name" - $attached = gh release view $env:TAG --json assets --jq '.assets[].name' 2>$null + $ErrorActionPreference = 'Stop' + $view = gh release view $env:TAG --json isDraft if ($LASTEXITCODE -ne 0) { - $options = @('--verify-tag', '--title', $env:TAG, '--notes-file', 'artifacts/release-notes.md') - if ($env:PRERELEASE -eq 'true') { $options += '--prerelease' } - gh release create $env:TAG $package @options - } elseif ($attached -notcontains $name) { - gh release upload $env:TAG $package - } else { - Write-Host "::notice::Release $env:TAG already carries $name." - exit 0 + throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." } + $isDraft = [bool]($view | ConvertFrom-Json).isDraft + if (-not $isDraft) { + Write-Host "::notice::Release $env:TAG is already published." + } + "draft=$($isDraft.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Publish the release + if: steps.state.outputs.draft == 'true' + run: | + gh release edit $env:TAG --draft=false if ($LASTEXITCODE -ne 0) { - throw "Creating the GitHub release failed with exit code $LASTEXITCODE." + throw "Publishing the release failed with exit code $LASTEXITCODE." + } + + # Verifies what consumers download, not the local artifacts. + - name: Verify the published release + run: | + $ErrorActionPreference = 'Stop' + $state = gh release view $env:TAG --json isDraft,isImmutable + if ($LASTEXITCODE -ne 0) { + throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." + } + $state = $state | ConvertFrom-Json + if ($state.isDraft) { + throw "Release $env:TAG is still a draft." + } + $published = Join-Path $env:RUNNER_TEMP 'published-release' + gh release download $env:TAG --dir $published --clobber + if ($LASTEXITCODE -ne 0) { + throw "Downloading the assets of $env:TAG failed with exit code $LASTEXITCODE." + } + $sums = @{} + foreach ($line in (Get-Content -LiteralPath (Join-Path $published 'SHA256SUMS'))) { + if ($line -cmatch '^(?[0-9a-f]{64}) (?\S+)$') { + $sums[$Matches['name']] = $Matches['hash'] + } + } + foreach ($name in $sums.Keys) { + $actual = (Get-FileHash -LiteralPath (Join-Path $published $name) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -cne $sums[$name]) { + throw "The published $name has SHA-256 $actual, SHA256SUMS lists $($sums[$name])." + } + } + $package = Join-Path $published "CheatEngine.SDK.$env:VERSION.nupkg" + if ($state.isImmutable) { + gh release verify $env:TAG + if ($LASTEXITCODE -ne 0) { + throw "gh release verify failed with exit code $LASTEXITCODE." + } + gh release verify-asset $env:TAG $package + if ($LASTEXITCODE -ne 0) { + throw "gh release verify-asset failed with exit code $LASTEXITCODE." + } + } + else { + Write-Host "::warning::Immutable releases are not enabled, so $env:TAG has no release attestation. Enable them once this workflow is on main (RELEASING.md)." + } + $identity = @( + '-R', 'CheatEngineNet/CheatEngine.SDK', + '--signer-workflow', 'CheatEngineNet/CheatEngine.SDK/.github/workflows/release.yml', + '--source-ref', "refs/tags/$env:TAG", + '--deny-self-hosted-runners' + ) + $checks = @( + @($package), + @($package, '--predicate-type', 'https://spdx.dev/Document/v2.2') + ) + foreach ($check in $checks) { + gh attestation verify @check @identity + if ($LASTEXITCODE -ne 0) { + throw "gh attestation verify $([IO.Path]::GetFileName($check[0])) failed with exit code $LASTEXITCODE." + } } + @( + "### Release $env:TAG", + '', + "Immutable release: $($state.isImmutable)", + '', + '| Asset | SHA-256 |', + '| --- | --- |' + ) + @($sums.Keys | Sort-Object | ForEach-Object { "| ``$_`` | ``$($sums[$_])`` |" }) | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..15670683 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,52 @@ +# Advisory OpenSSF Scorecard (audit register PR-CQ-24; https://github.com/ossf/scorecard-action). Not part of CI / Gate, +# no score target. With publish_results: true the Scorecard API verifies this file and rejects: workflow-level `env` +# or `defaults`, workflow-level write permissions, `id-token` in any other job, job-level `env` or `defaults`, +# containers and services, any step without `uses:` (so no `run:` step), actions outside its allowlist, and runners +# other than ubuntu-latest or ubuntu-NN.NN (NN.NN >= 22.04). This file therefore deliberately does NOT follow the +# repository's `defaults: run: shell: pwsh` convention. +# Publication works from the default branch only: the first run happens after merge. Expected low checks, explained +# rather than chased: Binary-Artifacts (the Lua fixture and the bridge, see SECURITY.md), Code-Review (single +# maintainer), Branch-Protection (until the maintainer configures it in the repository's GitHub settings). +name: Scorecard + +on: + branch_protection_rule: + push: + branches: [ main ] + schedule: + - cron: '23 4 * * 1' + +permissions: + contents: read + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + security-events: write # upload the SARIF results to code scanning + id-token: write # publish_results: sign the upload to the OpenSSF Scorecard API + actions: read # the Scorecard checks read workflow runs + issues: read # the Scorecard checks read issues + pull-requests: read # the Code-Review check reads pull requests + checks: read # the CI-Tests check reads check runs + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # No repo_token: the default token reads the rulesets of a public repository. + - name: Run Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 8c7d4c2c..35b44fc6 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,7 +1,9 @@ name: Sonar -# Reusable SonarQube Cloud analysis, called by ci.yml after build-test. It reuses the coverage and nuget-package -# artifacts of the same run; only the instrumented build is repeated, because the scanner must observe a compilation. +# Reusable SonarQube Cloud analysis, called by ci.yml after build-test when SONAR_EXPECTED is true; the gate then +# requires it to succeed. It reuses the coverage and nuget-package artifacts of the same run; only the instrumented build +# is repeated, because the scanner must observe a compilation. No binary log is kept here: it would record the scanner +# environment. New Code of non-main branches is measured against the reference branch main (a SonarQube Cloud setting). on: workflow_call: @@ -15,7 +17,7 @@ on: type: string default: cheatenginenet wait-quality-gate: - description: Fail the job when the quality gate fails. Pull requests wait for it; main only reports it. + description: Fail the job when the quality gate fails. Every event but push waits for it; main pushes only report it. type: boolean default: true secrets: @@ -36,7 +38,7 @@ defaults: jobs: analyze: name: Analyze - runs-on: windows-latest + runs-on: windows-2025 timeout-minutes: 30 env: SONAR_PROJECT_KEY: ${{ inputs.project-key }} @@ -68,7 +70,7 @@ jobs: distribution: zulu java-version: '21' - # SDK and CLI settings only: the restore below must not use the checked-out nuget.config. + # SDK and CLI settings only: the restore below must not use the checked-out nuget.config. No package cache. - name: Setup .NET uses: ./.github/actions/setup-dotnet @@ -103,14 +105,15 @@ jobs: "path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 # Restore before scanner begin: the subsequent analysis build is --no-restore, so no PR-supplied source can - # participate while scanner credentials are configured. + # participate while scanner credentials are configured. Locked mode: exactly the packages of the committed lock + # files, from nuget.org only. - name: Restore from NuGet.org env: NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} run: | - dotnet restore CheatEngine.SDK.slnx --configfile $env:NUGET_CONFIG + dotnet restore CheatEngine.SDK.slnx --configfile $env:NUGET_CONFIG --locked-mode if ($LASTEXITCODE -ne 0) { - throw "Sonar restore failed with exit code $LASTEXITCODE." + throw "Sonar restore failed with exit code $LASTEXITCODE. If a dependency changed, regenerate CheatEngine.SDK.slnx's lock file with a forced re-evaluation restore." } - name: Install scanner @@ -134,8 +137,8 @@ jobs: Write-Host '::error::The coverage artifact holds no report.' exit 1 } - # The new-code period follows sonar.projectVersion. The core version of the package built in this run (1.0.1 - # for 1.0.1-alpha.0.37) changes only at a release, so new code on main means changes since the last release. + # sonar.projectVersion is the core version of the package built in this run (2.0.0 for 2.0.0-alpha.0.37), + # which changes only at a release. $packages = @(Get-ChildItem -Path "$env:RUNNER_TEMP/package" -Filter 'CheatEngine.SDK.*.nupkg' -File) if ($packages.Count -ne 1 -or $packages[0].Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+)') { Write-Host '::error::The nuget-package artifact must hold exactly one CheatEngine.SDK package.' @@ -178,10 +181,12 @@ jobs: "/k:$env:SONAR_PROJECT_KEY" "/o:$env:SONAR_ORGANIZATION" "/v:$version" - '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.SDK.Benchmarks/**,tests/CheatEngine.SDK.LivePlugin/**' + # Not analysed: build output, harnesses that are not product or test code, catalogue tooling and docs. + # Tests stay analysed (the tests/** issue ignores above exist for them) but never count for coverage. + '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.SDK.Benchmarks/**,tests/CheatEngine.SDK.LivePlugin/**,tests/CheatEngine.SDK.QualificationTarget/**,tests/native-host-emulator/**,eng/tools/**,docs/**' # CI publishes managed coverage only. Keep build-time tooling and test-only sources out of the product # coverage metric instead of presenting an incomplete report as if it covered those paths. - '/d:sonar.coverage.exclusions=tests/**,eng/**' + '/d:sonar.coverage.exclusions=tests/**,eng/**,docs/**' "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml" "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" @@ -212,3 +217,15 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "SonarScanner end failed with exit code $LASTEXITCODE." } + + # Also after a failed quality gate: the summary links the analysis of this pull request or branch. + - name: Link the analysis + if: ${{ !cancelled() }} + env: + PULL_REQUEST: ${{ github.event.pull_request.number }} + BRANCH: ${{ github.ref_name }} + run: | + $scope = if ($env:PULL_REQUEST) { "pullRequest=$env:PULL_REQUEST" } else { "branch=$([Uri]::EscapeDataString($env:BRANCH))" } + $link = "https://sonarcloud.io/summary/new_code?id=$([Uri]::EscapeDataString($env:SONAR_PROJECT_KEY))&$scope" + @('### Sonar', '', "[SonarQube Cloud analysis of $env:SONAR_PROJECT_KEY]($link)") | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/zizmor-online.yml b/.github/workflows/zizmor-online.yml new file mode 100644 index 00000000..9c91a118 --- /dev/null +++ b/.github/workflows/zizmor-online.yml @@ -0,0 +1,56 @@ +# Advisory online workflow audits (audit register PR-CQ-19): the audits that need the GitHub API +# (known-vulnerable-actions, impostor-commit, ref-confusion, ref-version-mismatch, stale-action-refs), uploaded to code +# scanning under the "zizmor" category. Not part of CI / Gate: in SARIF mode zizmor exits 0 with findings +# (https://docs.zizmor.sh/usage/). The blocking, offline zizmor run lives in ci.yml (job lint) and pins the same +# version. zizmor discovers .github/zizmor.yml by itself (https://docs.zizmor.sh/configuration/). +# The schedule matters: the vulnerable-actions data changes without any commit. +name: zizmor online audits + +on: + push: + branches: [ main ] + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + schedule: + - cron: '41 5 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + zizmor: + name: zizmor (online) + # Drafts wait; fork pull requests get a read-only token and cannot upload SARIF. + if: >- + github.event_name != 'pull_request' + || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-24.04 # the action runs zizmor in a container + timeout-minutes: 10 + permissions: + contents: read + security-events: write # upload zizmor SARIF + actions: read # the SARIF upload reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Inputs: https://github.com/zizmorcore/zizmor-action (action.yml at v0.6.4). The tool version is pinned next to + # the action, so a new zizmor release cannot change the findings without a commit. + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + online-audits: true + advanced-security: true + persona: regular diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000..a4c3b38a --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,11 @@ +# zizmor configuration: the lint job of ci.yml passes it explicitly (offline audits, pinned zizmor version), and a +# local `zizmor --offline .github` discovers it. Every entry below ignores or disables one audit, with the reason in +# the comment directly above it; WorkflowContractTests fails on an entry without one. Findings in composite actions +# cannot be ignored here: they carry an inline `# zizmor: ignore[] ` comment instead. +# https://docs.zizmor.sh/configuration/ +rules: + # zizmor suggests GitHub's `uses: $/` self-repository syntax for local actions and reusable workflows, but the + # pinned actionlint 1.7.12 rejects it ("invalid format because ref is missing"). Keep the documented `./` form + # until the pinned actionlint accepts the new syntax. + self-repository: + disable: true diff --git a/.gitignore b/.gitignore index 10c83311..2dd1a77c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,6 @@ TestResults/ BenchmarkDotNet.Artifacts/ *.binlog -__pycache__/ -*.py[cod] - .idea/ .vs/ .xmake/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b211a54..c0d0fbf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,17 +10,118 @@ under [1.0.0](#100---2026-09-20) before upgrading. ## [Unreleased] -- Removed the obsolete host-symbol compatibility flag from `AddressResolutionOptions`; use - `EngineInspection.ResolveHostAddress` for host-symbol resolution. +The next release line is 2.0.0. Every pack is validated against the published 1.0.0 package, and each intentional break +is declared in `src/CheatEngine.SDK/CompatibilitySuppressions.xml` and marked **Breaking** below. + +### Added + +- The package embeds an SPDX 2.2 software bill of materials at `_manifest/spdx_2.2/manifest.spdx.json`, and its nuspec + keeps the repository URL and commit. +- A security policy with private vulnerability reporting, and a compatibility issue form that captures the exact + package, bridge, Cheat Engine and runtime tuple. +- `LuaOptional` lets a generated Lua or Engine API binding distinguish an omitted trailing argument (`LUA_TNONE`) + from an explicit Lua `nil`, and lets a result read the factual count Lua returned instead of assuming the declared + arity; the Engine API grammar gains matching `opt:`, `opt-result:` and `rest:` declarations, and every curated spec + must now declare the `contract: ce77` header. +- `AobScanner.TryScanWithinBounds` runs an exhaustive or a deadline-bounded, cooperatively terminable AOB scan over an + explicit target range, and `TryFindFirstFoundWithinBounds` stops at the first match; both accept byte-array + patterns and explicit range bounds. The deadline/termination overloads and the first-found scan are marked + `[Experimental]` (`CESDK5010`, `CESDK5011`, build errors unless acknowledged) until their exact-host evidence lands. +- `TargetReleaseStatus.RefusedRuntimeChanged` reports that a target-bound owner was consumed without any Cheat Engine + call because the Lua runtime identity that created it (attach epoch or state generation) is no longer current. +- `RuntimeInfo.TryDeriveTargetArchitecture` derives the target CPU architecture from Cheat Engine's own instruction- + family and bitness facts. +- Diagnostics `CESDK9102` (a direct plugin-library consumer sets `PublishAot=true`) and `CESDK0006` (a method exports + a classic `CEPlugin_*` native entry point) flag the two plugin shapes CheatEngine.SDK does not support; `CESDK1020` + flags a `PointerSize` built from the plugin process width instead of a Cheat Engine observation. +- `LuaRuntime.AdmitWorkerThreads()` (`[Experimental("CESDK5001")]`) and `AdmitMainThreadOnly()` switch the Lua thread + admission policy read by the new `LuaRuntime.ThreadAdmission` property; `TryAcquireOperationWithOutcome` reports the + factual `LuaAdmissionStatus` (`Admitted`, `Detached`, `ThreadNotAdmitted`, `ExternalStateReset`, …) of an admission + attempt instead of only a boolean or an exception. +- `LuaRuntime.ExternalStateResetDetected` reports that Lua was reset outside the SDK's own `resetLuaState` path. +- `HostLog.IdentifyOnEnable` and the `CHEATENGINE_SDK_IDENTIFY_ON_ENABLE` environment variable opt into one bounded, + path-free `CheatEngineSdkIdentification: …` log entry per enable attempt (SDK version and commit, bridge and Lua + module fingerprints, host and runtime facts). +- A C2 native hostfxr host emulator (`tests/native-host-emulator`) measures plugin coexistence (separate and shared + loader folders, the default and component ALC routes) in CI without contacting a live Cheat Engine host. + +### Changed + +- Untagged builds are versioned `2.0.0-alpha.0.N` with `AssemblyVersion` 2.0.0.0, so the `[GeneratedCode]` attribute + that the generators emit carries 2.0.0.0. +- Building this repository requires .NET SDK 10.0.401 exactly (`global.json` `rollForward: disable`), and every + project restores against a committed NuGet lock file. Plugin projects that consume the package are not affected. +- The packaged native Lua protection bridge is built by CI with a pinned MSVC toolset (14.44) and Windows SDK + (10.0.26100.0), and a second build and a build from a copy in another directory must produce the same bytes; the + toolchain facts are published with every CI run. +- `native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json` now describes the committed bridge DLL and its sources, + and a test checks it against the committed file. +- Releases are created as drafts with the SPDX 2.2 SBOM, provenance and SBOM attestations, `SHA256SUMS`, sigstore + bundles and a release tuple, and are verified on nuget.org before they are published; the packaging tests run + against the exact package that is published. +- Documentation no longer links to the retired `documentations/` tree, and no narrative documentation is recreated + under a repository `docs/` folder; a repository test rejects dead links, local paths and relative links in the + packed README. +- The CE 7.7 live probe is now compiled with the solution. +- A target that reports the x86 instruction family together with the 64-bit flag — how Cheat Engine reports every x64 + target — now resolves to the `X64` architecture instead of being refused as a contradictory profile. +- Integer, address and pointer marshallers refuse a Lua float at or above 2^53 instead of narrowing it silently. +- A zero-match `AOBScan` is modeled as the factual `AobScanStatus.NoResult`, matching how Cheat Engine 7.7 reports it + (no value, read as `nil`); it is never presented as a "no matches" classification. +- `PointerSize.FromArchitecture` is obsolete (`CESDK7001`); read the target bitness or configured pointer size from + Cheat Engine instead. +- **Breaking, not reported by ApiCompat:** every SDK-owned outcome enum added since 1.0.0 now starts at `Unknown = 0` + instead of reading a default value as success: `LuaOperationStatusKind`, `LuaGlobalPushStatus`, + `InstructionOperationStatus`, `ProcessOperationStatusKind` and `AobScanStatus`. None of these enums existed in the + published 1.0.0 package, so ApiCompat does not see the renumbering. +- **Breaking:** the `Callback` fields of `AddressListPluginInit` and `DisassemblerContextPluginInit` changed from a + typed function pointer to `void*`. +- **Breaking:** `MemoryAccessFailure.DestinationTooSmall`, `WriteFailed` and `InvalidResult` were renumbered from 4, 5 + and 6 to 5, 8 and 9. +- **Breaking, not reported by ApiCompat:** `LuaClassAttribute` and `LuaPropertyAttribute` no longer set + `Inherited = false`, and `MemoryScanSession.Scanner` and `MemoryScanSession.Results` are now marked + `[RequiresPluginEnabled]`, so calling them from plugin startup code reports `CESDK1001`. +- **Breaking, behavioral, not reported by ApiCompat (ADR-07, F04):** Lua operations are admitted only on the + plugin's captured main thread, or inside a callback the host itself invoked, by default; a worker thread that was + previously admitted is now refused before Cheat Engine's Lua state provider is ever called. `MainThread.Invoke`'s + worker-side `synchronize` hand-off is unaffected: it is the one documented default exception. Admitting arbitrary + worker threads is the new `[Experimental("CESDK5001")]` opt-in above, held unqualified until Q19 passes at both a + local and a two-copy qualification run. +- Every remaining unqualified "one Lua binding per plugin" or "one load context hosts one plugin" claim in source and + README text was reworded to what the SDK can actually observe: one binding per loaded `CheatEngine.SDK.Lua` + assembly instance. +- The generated plugin name is still copied through the process ANSI code page; a non-ASCII name's exact behavior is + documented as unqualified pending a future host-based validation run (no local Cheat Engine qualification is + available for this release). + +### Removed + +- **Breaking:** removed the obsolete host-symbol compatibility flag `UseHostSymbolTable` from `AddressResolutionOptions` + (its constructor, property accessors and `Deconstruct` change); use `EngineInspection.ResolveHostAddress` for + host-symbol resolution. + +### Fixed + +- A symbol registration whose release never reached Cheat Engine is no longer read as a successful release. +- An AOB result list is destroyed once, even when the call that would hand it to its caller cannot proceed. +- A scan is stopped before its session is released, a disposed session refuses further scan members, and a release + requested from inside the session's own Cheat Engine wait is deferred instead of re-entering it; a bounded scan's + session is released if staging its allocation fails. +- A log sink that writes back into `HostLog` from its own callback, or that re-enters a plugin lifecycle transition, + is contained instead of recursing or deadlocking; a registration-rejection log no longer runs while the + registration lock is held. +- An `resetLuaState()` call the SDK was not told about is now detected on the next admitted Lua operation: old + callback and subscription owners are refused deterministically, and nothing is released into the replacement Lua + registry by number. There is still no public SDK reset API. ## [1.0.0] - 2026-09-20 > [!WARNING] > **Major breaking change: 1.0.0 is a new SDK, not an update of `CESDK` 0.2.1.** The new architecture replaces the -target -> framework, the plugin model and the whole public API, so nothing written against 0.1.0 to 0.2.1 works the same way. -> There is no compatibility layer and no deprecation period. A plugin has to be rewritten against the new API, -> starting from the [quick start](README.md#quick-start). A plugin that stays on `CESDK` 0.2.1 keeps building as before. +> target framework, the plugin model and the whole public API, so nothing written against 0.1.0 to 0.2.1 works the +> same way. There is no compatibility layer and no deprecation period. A plugin has to be rewritten against the new +> API, starting from the [quick start](README.md#quick-start). A plugin that stays on `CESDK` 0.2.1 keeps building as +> before. ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..c4a994b5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,108 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for +everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity +and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, +color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take +appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, +issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing +the community in public spaces. Examples of representing our community include using an official e-mail address, posting +via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible +for enforcement through GitHub private reporting: open a private report at + and start its title with "Code of +Conduct"; only you and the maintainers can read it. All complaints will be reviewed and investigated promptly and +fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem +in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the +community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation +and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including +unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding +interactions in community spaces as well as external channels like social media. Violating these terms may lead to a +temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified +period of time. No public or private interaction with the people involved, including unsolicited interaction with those +enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate +behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae4836dc..39bea0b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,11 @@ focused, preserve existing patterns, and update documentation when behavior or p ## Prerequisites - Windows x64 -- .NET SDK 10.0.401, as pinned in [`global.json`](global.json) +- .NET SDK 10.0.401 exactly. [`global.json`](global.json) sets `rollForward: disable`, because the NuGet lock files + record the packages that the SDK adds implicitly. Install it with + `winget install Microsoft.DotNet.SDK.10 --version 10.0.401`. +- PowerShell 7 (`pwsh`): every workflow step runs in it (`defaults.run.shell: pwsh`); useful for rehearsing a CI step + locally. - Git Ordinary managed work uses the checked-in Lua bridge and does not need a C toolchain. If you change [ @@ -18,55 +22,192 @@ C toolchain. Run these commands from the repository root: ```powershell -dotnet restore CheatEngine.SDK.slnx +dotnet restore CheatEngine.SDK.slnx --locked-mode dotnet build CheatEngine.SDK.slnx -c Debug --no-restore dotnet test --solution CheatEngine.SDK.slnx -c Debug --fail-skips on dotnet test --solution CheatEngine.SDK.slnx -c Release --fail-skips on ``` +A locked restore fails when a lock file no longer matches the projects; see +[Lock files and Dependabot](#lock-files-and-dependabot). Locally, the packaging tests pack the SDK themselves; in CI +they consume the exact package of the run. + To create the package locally: ```powershell dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget ``` +The pack validates the API against the published CheatEngine.SDK 1.0.0 package, so it needs nuget.org once, and it +embeds the SPDX SBOM. + For manual host validation, use the [live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md). ## Continuous integration Pull requests run `Pull request CI`, pushes to `main` run `Main CI`, and version tags run `Release`. All three call the -reusable [`ci.yml`](.github/workflows/ci.yml), which builds each thing once and passes it on as an artifact: - -1. `native` rebuilds the Lua bridge twice to prove it is reproducible, checks the checked-in DLL against its source, - and builds the classic ABI fixture facts. -2. `build-test` builds the solution once per configuration (Debug and Release) and runs every - `tests/**/*.Tests` project in a single `dotnet test --solution` run. A skipped test fails both configurations. - Debug also collects coverage and compares the ABI fixture facts with the managed layouts; Release packs the tested - build as the `nuget-package` artifact, which you can download from the run. -3. `aot` publishes and runs the Native AOT probes. -4. `sonar` analyzes the code with SonarQube Cloud from the Debug coverage. It runs for branches of this repository only; - fork and Dependabot pull requests skip it. The quality gate fails pull requests and is only reported on `main`. -5. `lint` runs actionlint on the workflows. - -`CI / Gate` is the only required check: it fails when any job fails, and only `sonar` may be skipped. Drafts do not -run CI until they are marked ready for review. CodeRabbit reviews every pull request, but its findings and pre-merge -checks are advisory. +reusable [`ci.yml`](.github/workflows/ci.yml), which builds each thing once and passes it on as an artifact. Every job +runs on a pinned runner label (`windows-2025` or `ubuntu-24.04`), every restore is locked against the committed lock +files, and no job uses a NuGet cache. Every step is a direct `dotnet`/`xmake`/`gh` CLI call or a standard action, never +a bespoke script: run the commands in a job's steps locally to rehearse it. + +| Job | What it checks | +|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `native` | Builds the Lua protection bridge with the pinned xmake, MSVC toolset and Windows SDK; checks that the checked-in DLL was built from the checked-in sources; builds the classic ABI fixture facts. A CI-built DLL that differs from the checked-in one is a notice, not a failure. | +| `build-test` (Debug, Release) | Builds the solution once per configuration. Release packs first and checks the single nupkg, its nuspec identity, its SBOM and its bridge; the packaging tests then consume that exact file through `CESDK_PACKAGED_UMBRELLA_NUPKG`, and the same file is uploaded as `nuget-package`. Debug excludes the packaging tests by trait (`--filter-not-trait "Category=Packaging"`), compares the ABI fixture facts and collects coverage. | +| `aot` | Publishes and runs the Native AOT probes. | +| `native-host-emulator` | Builds the C2 hostfxr host emulator (`tests/native-host-emulator`) under the pinned MSVC toolset; the Debug `build-test` leg downloads it and runs `NativeHostEmulatorTests` against it. It never contacts or qualifies a live Cheat Engine host. | +| `sonar` | Analyzes the code with SonarQube Cloud from the Debug coverage. | +| `lint` | Runs actionlint and zizmor with offline audits, each pinned by version (and, for actionlint, checksum). | +| `format` | Verifies the whitespace formatting of every C# file, including projects outside the solution. | +| `dependency-review` | Reviews dependency changes of pull requests against `.github/dependency-review-config.yml`; other events record a notice. | +| `lock-files` | Restores the solution and every out-of-solution project with `--locked-mode`: the committed lock files must equal a fresh restore. | +| `gate` | Produces `CI / Gate` from the results of every job above. | + +Both `build-test` legs run every `tests/**/*.Tests` module in a single `dotnet test --solution` call with +`--fail-skips on`, and hang and crash dumps. A skipped test fails the run, and the packaging tests are excluded from +Debug by trait, never skipped. + +### Required checks + +`CI / Gate` is the only required check. There is no merge queue. + +`CI / Gate` requires every job to succeed, with one exception: `sonar` must run and pass exactly when +`SONAR_EXPECTED` is true, and must be skipped otherwise. It is false for fork and Dependabot pull requests, which +receive no secrets, for release runs, which never request Sonar, and for the dormant `merge_group` clause. A Sonar run +that was not expected fails the gate too, and no other job may be skipped. The quality gate fails pull requests and is +only reported on `main`. Drafts do not run CI until they are marked ready for review. + +Give the pull request an imperative title of at most 72 characters (no trailing period, no Conventional Commit or area +prefix): pull requests are squash-merged, and the title becomes the commit subject on `main`. Record consumer-visible +changes under `[Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md) in the same pull request. + +Advisory workflows run outside the gate: CodeQL (C#, C/C++ and the workflows), OpenSSF Scorecard, the online zizmor +audits, and the NuGet dependency graph submission. CodeRabbit reviews every pull request, but its findings and +pre-merge checks are advisory. + +### Workflow changes + +- `WorkflowContractTests` in the + [`tests/CheatEngine.SDK.Repository.Tests`](tests/CheatEngine.SDK.Repository.Tests/README.md) project freeze the + pipeline: job ids and names, `gate.needs` (a job missing from it fails), SHA-pinned actions, pinned + runners, timeouts, locked restores and the reserved artifact names. Change a workflow and its tests in the same pull + request. +- Dependabot does not update `runs-on`. Move to a new runner image in one pull request that changes every workflow + together with the label lists of the Repository tests. + +### Flaky tests + +Required runs never retry a test. With `--fail-skips on` a skipped test fails the run, so a flaky test cannot be hidden +with `Skip`: fix it or delete it in the pull request that finds it. + +### Run the checks locally + +```powershell +dotnet format whitespace . --folder --verify-no-changes --exclude artifacts # the format job +actionlint # 1.7.12, from the repository root +zizmor --offline .github # 1.30.1, reads .github/zizmor.yml +dotnet restore CheatEngine.SDK.slnx --locked-mode # the lock-files job +``` + +### Running the native host emulator locally + +```powershell +./tests/native-host-emulator/build.ps1 -OutputDirectory artifacts/native-host-emulator +$env:CESDK_NATIVE_HOST_EMULATOR_DIR = (Resolve-Path artifacts/native-host-emulator).Path +$env:CESDK_NATIVE_HOST_EMULATOR_REQUIRED = 'true' +dotnet test tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj -c Debug --filter-class "*NativeHostEmulatorTests" +``` + +Without the environment variables, the module is green through the same required-mode opt-out pattern as the classic +ABI fixture; with `CESDK_NATIVE_HOST_EMULATOR_REQUIRED` unset or `false`, an absent emulator directory is skipped, not +required. + +## Lua concurrency contract + +`LuaRuntime` admits Lua work only on the plugin's captured main thread by default (ADR-07), with one documented +exception: the worker-side half of `MainThread.Invoke`'s `synchronize` hand-off. Admitting arbitrary worker threads is +an explicit, `[Experimental("CESDK5001")]` opt-in (`LuaRuntime.AdmitWorkerThreads()`), held unqualified until Q19 +passes at both C3 (local qualification) and C4 (two-copy qualification) — see +[`libs/CheatEngine.SDK.Hosting/README.md`](libs/CheatEngine.SDK.Hosting/README.md) for the full contract text and +[`analyzers/docs/CESDK5001.md`](analyzers/docs/CESDK5001.md) for the diagnostic. An external `resetLuaState()` call the +SDK was not told about is detected deterministically and refuses old owners; there is no public SDK reset API. ## Style and analyzers - Follow [`.editorconfig`](.editorconfig): UTF-8, tab-based C# indentation (spaces only for continuation alignment), and two-space configuration/project-file indentation. [`.gitattributes`](.gitattributes) owns working-tree line-ending - normalization. + normalization: CRLF, except the LF-pinned bridge inputs `cheatengine_sdk_lua_bridge.c` and `xmake.lua`, whose hashes + are embedded in the bridge DLL. +- Formatting is part of the build: `EnforceCodeStyleInBuild` turns the style rules, including IDE0055 formatting, into + errors. Run `dotnet format CheatEngine.SDK.slnx --no-restore` before committing; the `format` job also checks the + projects outside the solution. - Use file-scoped namespaces, explicit accessibility, PascalCase public members, and the local naming patterns already present. - Public APIs require XML documentation. Builds treat compiler and analyzer diagnostics as errors, except for configured - exceptions. + exceptions. The analysis level is pinned (`10.0-recommended`) and moves only with the SDK. - Do not use LINQ, including query expressions or `System.Linq` operators. Prefer explicit loops and collection APIs. - Preserve native Lua identifiers and ABI layouts. Avoid unrelated refactors. The package includes analyzers and code fixes. See the [diagnostic reference](analyzers/docs/README.md) for the `CESDK` rules and their fixes. +## Public API and compatibility + +- The six shipping libraries under `libs/` track their public API in `PublicAPI.Shipped.txt`, the surface of the + published 1.0.0 package, and `PublicAPI.Unshipped.txt`, the delta. A public API change updates + `PublicAPI.Unshipped.txt` in the same commit (RS0016 and RS0017 fail the build otherwise). `PublicAPI.Shipped.txt` + changes only at a release. +- Every pack is validated against CheatEngine.SDK 1.0.0. An intentional break is declared in + `src/CheatEngine.SDK/CompatibilitySuppressions.xml`, with its `*REMOVED*` line and a CHANGELOG entry. The file is + regenerated locally by the maintainer who integrates the change, never by CI. +- An enum added since 1.0.0 that reports a status or an outcome starts with a neutral zero member (`Unknown`); enums + that mirror Cheat Engine constants keep their 1.0.0 members. + +`CompatibilitySuppressions.xml` is regenerated only by the integrator, locally, with +`dotnet pack src/CheatEngine.SDK -c Release -p:ApiCompatGenerateSuppressionFile=true`; CI never passes that property. + +## Lock files and Dependabot + +- Every project restores against a committed `packages.lock.json`. Regenerate one with + `dotnet restore --force-evaluate` (never combined with `--locked-mode`, NU1005), on Windows with the pinned + SDK, after any change to `Directory.Packages.props`, a package reference, a project file or `global.json`, and commit + the result on its own (`Regenerate lock files after `). Never edit a lock file by hand; on a merge conflict, + take either side and restore again. +- Dependabot NuGet pull requests do not regenerate the SDK-implicit entries: check out the branch + (`gh pr checkout `), restore each changed project with `--force-evaluate`, commit and push. +- For a Dependabot `dotnet-sdk` pull request, install the new SDK, restore every project with `--force-evaluate`, + make the `global.json` `errorMessage` name the new version, and update the documentation that names the SDK. + +## NuGet audit + +Restore audits every direct and transitive package. High and critical advisories (NU1903, NU1904) fail every build; +lower severities are warnings. An advisory suppression is a last resort: only in `Directory.Build.props`, with a +justification and an expiry date, and never in a stable release. + +## Repository guards + +The MSBuild guards `CESDK9003` to `CESDK9009` fail the build when the supply-chain policy is weakened: the PublicAPI +files, the analysis-level pin, lock files and Central Package Management, package validation, the major version that +declared breaks require, the SBOM, and the NuGet audit. `Directory.Build.targets` and `Directory.Build.props` give the +condition and the fix of each one, next to the guard itself. + +## Qualification evidence + +Evidence has five levels: C0 static contract, C1 managed tests, C2 native fixture, C3 the exact Cheat Engine host with +the plugin loaded, and C4 several components. A C1 or C2 result is never presented as host-qualified, and no document +states a global percentage. A test that evidences a scenario carries `[Trait("Qualification", "Qxx")]` naming the Qxx +scenario of the audit's register (Q01-Q48) and the level it reaches. There is no local exact-host qualification +runner or committed evidence tree in this repository; C3/C4 evidence, when gathered, is described in the pull request +and release notes. + +## Documentation + +Keep relative Markdown links exact-case and resolvable, and never restore or link the retired `documentations/` tree. +Narrative documentation (qualification protocol, catalogues, migration guides) is not recreated under a repository +`docs/` folder; it lives outside both repositories, under the maintainer's own notes. A README packed into the NuGet +package uses absolute `https://` links only. + ## Branches and pull requests 1. Update your local `main`, then create a focused branch from it. @@ -75,19 +216,29 @@ rules and their fixes. 4. Open a pull request against `main`; do not push directly to the protected branch. 5. Record consumer-visible changes under `[Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). -Fill in the pull request template: state the problem, resulting behavior, validation commands and results, and any -remaining live-host limitations. Include documentation changes that the work requires. Pull requests are -squash-merged once `CI / Gate` passes, so the pull request title becomes the commit subject on `main`. +Fill in the pull request template: state the problem, resulting behavior, validation commands and results, the +qualification level of the evidence, public API and release impact, and any remaining live-host limitations. Include +documentation changes that the work requires. Pull requests are squash-merged once `CI / Gate` passes, so the pull +request title becomes the commit subject on `main`. ## Commits -Use focused commits with short, imperative subjects, such as `Fix CI validation findings`. Conventional Commit prefixes -are not required. Do not add `Co-authored-by` trailers. +Use focused commits with an imperative subject of at most 72 characters, without a Conventional Commit prefix or a +trailing period, such as `Fix CI validation findings`. The body explains why. Regenerated lock files go in their own +commit. Do not add `Co-authored-by` trailers. + +## Security + +Report vulnerabilities privately, as [`SECURITY.md`](SECURITY.md) describes, never in a public issue, discussion or pull +request. ## Releases -Versions are derived by MinVer from the nearest `v*` tag; the current minimum major/minor line is `1.0`, as configured +Versions are derived by MinVer from the nearest `v*` tag; the current minimum major/minor line is `2.0`, as configured in [`Directory.Build.props`](Directory.Build.props). Pushing a valid `v..` tag (an optional SemVer -prerelease is allowed) starts the release workflow. It builds and tests the tag, waits for manual approval on the -`nuget` environment, publishes the tested package to NuGet, and creates a GitHub release whose notes are the -`CHANGELOG.md` section of that version. See [`RELEASING.md`](RELEASING.md). +prerelease is allowed) starts the draft-first release workflow. It builds and tests the tag, attests the package +(provenance and SPDX 2.2 SBOM), and creates a draft release with the package, the SBOM, `SHA256SUMS` and the sigstore +bundles. After manual approval on the `nuget` environment it publishes that exact package, verifies it on nuget.org, +and only then publishes the release, whose notes are the `CHANGELOG.md` section of that version. See +[`RELEASING.md`](RELEASING.md), including its [qualification](RELEASING.md#qualification) expectations for a stable +release. diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 5948c8b7..ccca6943 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -1,189 +1,205 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + - + + + + + - + + + + + + - - - - - + + + + + + + + + - - - - + + + + + - - + + - - + + - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - - - + + + + + - - + + - - + + - + - - + + - + - + + + - + + - + + + + - - + + - - + + + - - - - - - - - - + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/Directory.Build.props b/Directory.Build.props index cb319278..8ec23754 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,13 +14,56 @@ true - latest-recommended + + <_CheatEngineSdkPinnedAnalysisLevel>10.0-recommended + $(_CheatEngineSdkPinnedAnalysisLevel) true true + + + + + true all - - $(WarningsNotAsErrors);NU1900;NU1905 + low + <_CheatEngineSdkNuGetAuditCodes>NU1900;NU1901;NU1902;NU1903;NU1904;NU1905 + <_CheatEngineSdkNuGetAuditBlockingCodes>NU1903;NU1904 + $(WarningsAsErrors);$(_CheatEngineSdkNuGetAuditBlockingCodes) + $(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1905 + $(WarningsAsErrors);$(_CheatEngineSdkNuGetAuditCodes) + + + + + + + true @@ -36,8 +79,13 @@ false v - - 1.0 + + 2.0 diff --git a/Directory.Build.targets b/Directory.Build.targets index 6d2c069b..e4171d7e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -34,4 +34,173 @@ Code="CESDK9002" Text="Directory.Packages.props pins Microsoft.CodeAnalysis.CSharp '$(_CheatEngineSdkRoslynPin)' and Workspaces '$(_CheatEngineSdkWorkspacesPin)', expected '$(RoslynComponentFloor)'. Restore the pin, or raise it together with RoslynComponentFloor in eng/RoslynComponent.props."/> + + + + + + + + + + + + + + + + + + + + <_CheatEngineSdkAuditRequiredErrors>$(_CheatEngineSdkNuGetAuditBlockingCodes) + <_CheatEngineSdkAuditRequiredErrors Condition="'$(AuditPipeline)' == 'true'">$(_CheatEngineSdkNuGetAuditCodes) + + + <_CheatEngineSdkAuditRequiredError Include="$([MSBuild]::Unescape($(_CheatEngineSdkAuditRequiredErrors.ToUpperInvariant())))"/> + <_CheatEngineSdkAuditWeakened Include="$([MSBuild]::Unescape($([System.Text.RegularExpressions.Regex]::Replace('$(NoWarn);$(WarningsNotAsErrors)', '[\s,;]+', ';').ToUpperInvariant())))"/> + <_CheatEngineSdkAuditAsError Include="$([MSBuild]::Unescape($([System.Text.RegularExpressions.Regex]::Replace('$(WarningsAsErrors)', '[\s,;]+', ';').ToUpperInvariant())))"/> + + <_CheatEngineSdkAuditWeakenedOther Include="@(_CheatEngineSdkAuditWeakened)" Exclude="@(_CheatEngineSdkAuditRequiredError)"/> + <_CheatEngineSdkAuditWeakenedRequired Include="@(_CheatEngineSdkAuditWeakened)" Exclude="@(_CheatEngineSdkAuditWeakenedOther)"/> + + <_CheatEngineSdkAuditMissingError Include="@(_CheatEngineSdkAuditRequiredError)" Exclude="@(_CheatEngineSdkAuditAsError)"/> + + + + + + + + + + <_CheatEngineSdkAuditSuppressionsFile>$([MSBuild]::NormalizePath('$(RepoRoot)', 'Directory.Build.props')) + <_CheatEngineSdkUtcToday>$([System.DateTime]::UtcNow.ToString('yyyyMMdd')) + + + + + + + + + + <_CheatEngineSdkPackNoWarnCode Include="$([MSBuild]::Unescape($([System.Text.RegularExpressions.Regex]::Replace('$(NoWarn)', '[\s,;]+', ';').ToUpperInvariant())))"/> + + <_CheatEngineSdkSilencedCompatCode Include="@(_CheatEngineSdkPackNoWarnCode)" + Condition="$([System.String]::Copy('%(Identity)').StartsWith('CP')) or $([System.String]::Copy('%(Identity)').StartsWith('PKV'))"/> + + + + + + + + + + <_CheatEngineSdkSuppressionFile>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', 'CompatibilitySuppressions.xml')) + <_CheatEngineSdkDeclaresBreaks>false + <_CheatEngineSdkDeclaresBreaks Condition="Exists('$(_CheatEngineSdkSuppressionFile)') and $([System.IO.File]::ReadAllText('$(_CheatEngineSdkSuppressionFile)').Contains('<IsBaselineSuppression>true</IsBaselineSuppression>'))">true + <_CheatEngineSdkPackageMajor>$(PackageVersion.Split('-')[0].Split('.')[0]) + <_CheatEngineSdkBaselineMajor>$(PackageValidationBaselineVersion.Split('-')[0].Split('.')[0]) + + + + + + + + + + + + + $(PackageVersion) + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index f07b0ced..94f88314 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,10 @@ + + + + @@ -27,11 +31,16 @@ + + + + + - + diff --git a/Directory.Solution.targets b/Directory.Solution.targets new file mode 100644 index 00000000..dd5871d7 --- /dev/null +++ b/Directory.Solution.targets @@ -0,0 +1,18 @@ + + + + + + + diff --git a/README.md b/README.md index 85bd110b..04477705 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ **An unofficial .NET SDK for Cheat Engine plugins.** -[![Build](https://img.shields.io/github/actions/workflow/status/CheatEngineNet/CheatEngine.SDK/main-ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&label=build)](https://github.com/CheatEngineNet/CheatEngine.SDK/actions/workflows/main-ci.yml) +[![CI](https://img.shields.io/github/actions/workflow/status/CheatEngineNet/CheatEngine.SDK/main-ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&label=CI)](https://github.com/CheatEngineNet/CheatEngine.SDK/actions/workflows/main-ci.yml) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/CheatEngineNet/CheatEngine.SDK/badge)](https://scorecard.dev/viewer/?uri=github.com/CheatEngineNet/CheatEngine.SDK) [![NuGet](https://img.shields.io/nuget/vpre/CheatEngine.SDK?style=flat-square&logo=nuget&logoColor=white&label=NuGet)](https://www.nuget.org/packages/CheatEngine.SDK) [![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4?style=flat-square&logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/download/dotnet/10.0) [![Windows x64](https://img.shields.io/badge/platform-Windows%20x64-0078D4?style=flat-square)](#requirements) [![MIT license](https://img.shields.io/badge/license-MIT-6e7781?style=flat-square)](LICENSE) -[Get started](#quick-start) · [Examples](exemples/README.md) · [API guide](exemples/api/README.md) · [Diagnostics](analyzers/docs/README.md) · [Contributing](CONTRIBUTING.md) +[Get started](#quick-start) · [Examples](exemples/README.md) · [API guide](exemples/api/README.md) · [Diagnostics](analyzers/docs/README.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) @@ -20,16 +21,22 @@ an independent project and is not affiliated with Cheat Engine. ## Requirements -| Requirement | Supported version | -|---------------|-------------------------------------------------------------------------------------------------| -| .NET SDK | 10.0.401 (or a later SDK selected through `latestFeature`) | -| .NET runtimes | .NET 10 `Microsoft.NETCore.App`, `Microsoft.WindowsDesktop.App`, and `Microsoft.AspNetCore.App` | -| Cheat Engine | 7.7 | -| Platform | Windows x64 | +| Requirement | Supported version | +|---------------|------------------------------------------------------------------------------------------------------------------------| +| .NET SDK | 10.0.401 or later to build a plugin; exactly 10.0.401 to build this repository (`global.json`, `rollForward: disable`) | +| .NET runtimes | .NET 10 `Microsoft.NETCore.App`, `Microsoft.WindowsDesktop.App`, and `Microsoft.AspNetCore.App` | +| Cheat Engine | 7.7 | +| Platform | Windows x64 | -Cheat Engine must be configured to run on .NET 10. -The [live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md#run-it-in-cheat-engine) explains the required -`ce.runtimeconfig.json` changes. +The qualifiable host profile is `ce-7.7.0.10621-x64-managed-hostfxr`: Cheat Engine 7.7.0.10621 x64 loading managed +plugins through `hostfxr`. No exact-host scenario (C3/C4) has been executed on it yet. The .NET 10 +`ce.runtimeconfig.json` observed on the maintainer's host is a local modification, not an installer default, and it +affects every managed plugin of that host: record your host's runtime policy and do not edit an installed Cheat Engine +configuration just to run a plugin. See the +[live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md#run-it-in-cheat-engine). + +A NativeAOT plugin DLL is not a supported profile: Cheat Engine unloads a plugin with `FreeLibrary`, which .NET does +not support for a NativeAOT library. See [load profiles and limits](src/CheatEngine.SDK/README.md#load-profiles-and-limits). ## Install @@ -100,10 +107,10 @@ start with [Example 01](exemples/01-first-plugin/README.md). The repository pins the .NET SDK in [`global.json`](global.json). From the repository root: ```powershell -dotnet restore CheatEngine.SDK.slnx +dotnet restore CheatEngine.SDK.slnx --locked-mode dotnet build CheatEngine.SDK.slnx -c Debug --no-restore dotnet test --solution CheatEngine.SDK.slnx -c Debug --fail-skips on -dotnet test --solution CheatEngine.SDK.slnx -c Release +dotnet test --solution CheatEngine.SDK.slnx -c Release --fail-skips on dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget ``` @@ -113,16 +120,16 @@ C toolchain to rebuild it. ## Project layout -| Path | Purpose | -|------------------------------------------------|----------------------------------------------------------------------------| -| [`libs/`](libs/) | Layered annotations, ABI, Lua, engine, and hosting libraries. | -| [`src/CheatEngine.SDK/`](src/CheatEngine.SDK/) | The `CheatEngine.SDK` NuGet package and consumer build properties. | -| [`source-generators/`](source-generators/) | Generated plugin entry-point and Lua-binding components. | -| [`analyzers/`](analyzers/) | Diagnostics, code fixes, and their documentation. | -| [`native/`](native/) | The Lua test fixture and bundled Windows x64 Lua protection bridge. | -| [`tests/`](tests/) | Unit tests, benchmarks, shared fixtures, and the live-plugin sample. | -| [`exemples/`](exemples/) | Guides, recipes, and API documentation. The directory name is intentional. | -| [`eng/`](eng/) | Shared build configuration. | +| Path | Purpose | +|------------------------------------------------|-----------------------------------------------------------------------------| +| [`libs/`](libs/) | Layered annotations, ABI, Lua, engine, and hosting libraries. | +| [`src/CheatEngine.SDK/`](src/CheatEngine.SDK/) | The `CheatEngine.SDK` NuGet package and consumer build properties. | +| [`source-generators/`](source-generators/) | Generated plugin entry-point and Lua-binding components. | +| [`analyzers/`](analyzers/) | Diagnostics, code fixes, and their documentation. | +| [`native/`](native/) | The Lua test fixture and bundled Windows x64 Lua protection bridge. | +| [`tests/`](tests/) | Unit tests, benchmarks, shared fixtures, and the live-plugin sample. | +| [`exemples/`](exemples/) | Guides, recipes, and API documentation. The directory name is intentional. | +| [`eng/`](eng/) | Shared MSBuild configuration (`Directory.Build.*`, central package management). | ## Documentation diff --git a/RELEASING.md b/RELEASING.md index 978a97fe..766d8522 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,16 +1,68 @@ # Releasing CheatEngine.SDK -NuGet releases are produced by `.github/workflows/release.yml` from version tags: +NuGet releases are produced by `.github/workflows/release.yml` from version tags. This document describes the chain, +how to prepare and publish a release, and how anyone can verify one afterwards. + +> **Current state.** The last published version is 1.0.0, released before this chain existed: its GitHub release carries +> the nupkg only (no SBOM or checksums) and is not immutable. The 2.0 line has not been released. There is no automated +> qualification gate: before tagging a stable release, the maintainer confirms against the audit's Q02-Q10, Q40 and Q41 +> register (analyses/20 of the audit dossier) that the release is not claiming host qualification it has not earned, and +> records any open row as a known limitation in the release notes. + +## Overview ```text -verify ─► ci (build and test the tag, pack the tested build) ─► publish (manual approval) ─► github-release +verify ─► ci ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release + │ │ │ │ │ │ + │ │ │ │ │ └ Release published and verified + │ │ │ │ └ nuget.org serves the attested package, repository-signed + │ │ │ └ push to nuget.org after approval of the nuget environment + │ │ └ draft release that already carries every asset + │ └ SBOM and its attestation, provenance attestation, SHA256SUMS + └ build and test the tag in Debug and Release, pack once, test that exact nupkg ``` -The `nuget-package` artifact that the `ci` job builds and tests is the exact file pushed to nuget.org, attested, and -attached to the GitHub release. Do not upload a locally built package manually: nuget.org versions are immutable, and -the workflow artifact is the release artifact. +| Job | What it does | +|---|---| +| `verify` | Checks the SemVer tag, that it points to the first-parent history of `main`, that the version is not on nuget.org yet, and extracts the release notes from `CHANGELOG.md`. | +| `ci` | Runs `ci.yml` on the tag. The Release leg packs `CheatEngine.SDK..nupkg` (the file name must match the tag), runs the packaging tests on that exact file (`CESDK_PACKAGED_UMBRELLA_NUPKG`), and uploads it as `nuget-package`. | +| `attest` | Extracts the SPDX 2.2 SBOM embedded in the nupkg, creates the SLSA provenance attestation and the SBOM attestation of the nupkg (predicate `https://spdx.dev/Document/v2.2`), verifies both, and writes `SHA256SUMS`. | +| `draft-release` | Creates a **draft** release that already carries every asset, or completes an existing draft. It never uploads to a published release. | +| `publish` | Waits for approval of the `nuget` environment, checks the nupkg against `SHA256SUMS`, logs in through NuGet trusted publishing and pushes. | +| `verify-publication` | Waits until nuget.org lists the version, downloads the repository-signed copy and checks it: `dotnet nuget verify --all`, and every zip entry byte-identical to the attested package except the added `.signature.p7s`. | +| `finalize-release` | Publishes the release, then verifies what a consumer downloads (`SHA256SUMS`, `gh release verify`, `gh attestation verify`). | + +The `nuget-package` artifact is the release artifact: the file the packaging tests consumed is the file that is +attested, attached to the release and pushed to nuget.org. Never upload a locally built package: nuget.org versions +are immutable. + +**Why draft-first.** Once an +[immutable release](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases) +is published, its tag cannot move and its assets cannot be added, changed or deleted, so every asset must be attached +to the draft. That is why the attestations are created before publication: the draft must carry their bundles. +`finalize-release` only flips the draft to published once nuget.org has served the attested package. + +## Package identities -## One-time trusted publishing setup +One release has three package identities: the attested asset, its NuGet content hash, and the nuget.org signed copy. +`verify-publication` checks all three exist and agree; none of them is written to a separate manifest. The published +1.0.0 is the worked example (verified on 2026-09-23): + +| Identity | What it is | 1.0.0 | +|---|---|---| +| Attested asset SHA-256 | SHA-256 of the unsigned nupkg CI packed: the GitHub release asset and the subject of the attestations. | `99bf90101cd13e0183c94759e43badc6a1e719ffc3e2c9fd0b93490abdac0632` | +| NuGet content hash | SHA-512 (base64) of that same unsigned file. It is the `contentHash` consumer lock files store and validate on restore ([NU1403](https://learn.microsoft.com/nuget/reference/errors-and-warnings/nu1403)). | `n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==` | +| nuget.org signed file | nuget.org [repository-signs every package](https://learn.microsoft.com/nuget/reference/signed-packages-reference), so the file it serves has different bytes: the same entries plus `.signature.p7s`. | SHA-256 `3e8c98583ac71af25a5bd7053e7583fbafcd196139fae7c0b04bae9b40a7cd33`, SHA-512 `1a2B/E6reX5e636hfdb+Zdj3kT6817DuNES1RWvprhRyuyztE/56Zk2iHOMQIKpGH+O2Va8rYJxXXXTVq5aN9Q==` | + +The 1.0.0 signed copy has 28 entries and the asset 27; every common entry is byte-identical, and +`dotnet nuget verify --all` on the signed copy reports the content hash above. The native bridge inside 1.0.0 +(`build/native/cheatengine-sdk-lua-bridge.dll`) has SHA-256 +`da08c2ba03019da3a8c432ef061d5d6133fd2169ba3a6a8e9ac903353856d994` and source fingerprint +`8a63e00c7dd941212e7ef8c13d8c97f73142c5154bfbe5dbc5459e7131bb789b:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8`. + +## One-time setup + +### Trusted publishing Create an organization-owned trusted publishing policy under the `CheatEngine` organization at with these exact values: @@ -28,37 +80,82 @@ Create an organization-owned trusted publishing policy under the `CheatEngine` o The GitHub `nuget` environment must contain an environment secret named `NUGET_USER`. Its value must be the exact nuget.org username of the administrator who created the policy, currently `AriusII`, not the organization name and not an email address. Organization membership alone does not make another username valid for that policy; if a different -administrator recreates it, update `NUGET_USER` to that policy creator. Restrict the environment to deployment tags -matching `v*.*.*` and add the maintainers who approve publications as required reviewers. The workflow exchanges -GitHub's OIDC token for a one-use, short-lived NuGet API key through `NuGet/login`; it must not store a long-lived NuGet -API key. Because the policy names `release.yml` and `nuget`, the login and push steps must stay in the `publish` job of -that file. +administrator recreates it, update `NUGET_USER` to that policy creator. The workflow exchanges GitHub's OIDC token for a +short-lived NuGet API key through `NuGet/login` +([trusted publishing](https://learn.microsoft.com/nuget/nuget-org/trusted-publishing)); it never stores a long-lived +NuGet API key. Because the policy names `release.yml` and `nuget`, the login and push steps must stay in the `publish` +job of that file, and the login runs right before the push because the temporary key lives one hour. + +### Repository settings + +The maintainer applies these settings directly in the repository's GitHub settings: + +- Environment `nuget`: the maintainers who approve publications as required reviewers, with self-review allowed + (`prevent_self_review: false`) while a single maintainer releases; no administrator bypass; deployments limited to + tags matching `v*.*.*`; the `NUGET_USER` secret. +- A tag ruleset on `v*`: release tags cannot be moved or deleted. +- Immutable releases: enable them **only after this `release.yml` is on `main`**. The previous workflow uploaded onto an + existing published release, which an immutable release refuses. Until they are enabled, `finalize-release` warns that + the release has no release attestation and skips `gh release verify`. + +As observed on 2026-09-23, the `nuget` environment had a branch policy only (no required reviewer) and immutable +releases were disabled. ## Prepare a release -1. Move the completed entries from `Unreleased` to a `## [X.Y.Z] - YYYY-MM-DD` section in `CHANGELOG.md` and add its - link reference. The body of that section becomes the GitHub release notes: a stable tag fails without it. A - prerelease tag uses its own `## [X.Y.Z-rc.N]` section when present, otherwise the `[Unreleased]` section. -2. Update version-specific examples and analyzer release tracking when the public baseline changes. -3. Set `MinVerMinimumMajorMinor` in `Directory.Build.props` to the release line. The exact version still comes from the - `v..` tag. -4. Run the local validation from the repository root: +1. **Changelog.** Move the completed entries from `[Unreleased]` to a `## [X.Y.Z] - YYYY-MM-DD` section of + `CHANGELOG.md` and add its link reference. The body of that section becomes the GitHub release notes: a stable tag + fails without it; a prerelease tag uses its own `## [X.Y.Z-rc.N]` section when present, otherwise `[Unreleased]`. + Keep the four kinds of change the audit distinguishes apart, because each one breaks consumers differently: + - `### Added`: extensions (a new wrapper, option or API); + - `### Changed`: semantic corrections (the meaning of a result or boolean, a Lua arity or index convention, the + allowed thread, ownership conditions, an error category); + - `### Security`: refusal hardening (an operation that used to proceed and is now refused); + - `### Deployment`: package, native bridge, runtime policy or load-profile changes. + + `### Removed` and `### Deprecated` are used as Keep a Changelog defines them. Add a `### Qualification waivers` + section when the [qualification gate](#qualification-gate) needs one. +2. **Version line.** The 2.0 line is set by `MinVerMinimumMajorMinor` in `Directory.Build.props` (`2.0`); the exact + version comes from the `v..` tag. +3. **API compatibility.** Every pack validates `lib/net10.0` against the published 1.0.0 package, and + `src/CheatEngine.SDK/CompatibilitySuppressions.xml` lists exactly the intentional breaks; nothing else may differ. + Every suppressed break has a `### Changed`, `### Removed` or `### Security` entry in the version's section. +4. **Local validation** from the repository root, with .NET SDK 10.0.401: ```powershell - dotnet restore CheatEngine.SDK.slnx + dotnet restore CheatEngine.SDK.slnx --locked-mode dotnet build CheatEngine.SDK.slnx -c Debug --no-restore - dotnet test --solution CheatEngine.SDK.slnx -c Debug --fail-skips on - dotnet test --solution CheatEngine.SDK.slnx -c Release --fail-skips on - dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget -p:MinVerVersionOverride=1.0.0 --no-restore + dotnet test --solution CheatEngine.SDK.slnx -c Debug --no-build --fail-skips on --filter-not-trait "Category=Packaging" + dotnet build-server shutdown + dotnet build CheatEngine.SDK.slnx -c Release --no-restore + dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget -p:MinVerVersionOverride=2.0.0 --no-restore + $env:CESDK_PACKAGED_UMBRELLA_NUPKG = (Resolve-Path artifacts/nuget/CheatEngine.SDK.2.0.0.nupkg).Path + dotnet test --solution CheatEngine.SDK.slnx -c Release --no-build --fail-skips on + Remove-Item Env:CESDK_PACKAGED_UMBRELLA_NUPKG ``` - Replace `1.0.0` only in the local pack command when rehearsing another release. Inspect the resulting `.nupkg` as a - ZIP archive and confirm its ID, version, README, license, assemblies, analyzers, build assets, and native bridge. -5. Merge the release pull request (squash) once `CI / Gate` passes. + Replace `2.0.0` with the version you rehearse; the pack needs nuget.org once for package validation. The Release test + run consumes exactly the packed file, as CI's Release leg does + ([exact-package run](tests/CheatEngine.SDK.Tests/README.md#run-the-tests)). This local package is a rehearsal: never + upload or publish it. +5. **Merge** the release pull request (squash) once `CI / Gate` passes. + +## Qualification + +A stable release follows Checkpoint F of the audit: a user must know what they can load, with which version and which +limits. There is no automated gate for this: before tagging a stable release, the maintainer checks the audit's Q02-Q10, +Q40 and Q41 register (analyses/20 of the audit dossier) against what this tree actually qualifies, and adds a +`### Qualification waivers` section to the version's CHANGELOG entry for any row that stays open, as `- Qxx: `. +The waivers are part of the release notes, so every consumer sees them. -To rehearse the pipeline without publishing, start `Release` manually from a branch (**Actions → Release → Run -workflow**, or `gh workflow run release.yml --ref `). The dry run executes `verify` and the full `ci` job, then -skips `publish` and `github-release`. +C1 and C2 results are never presented as host qualification: a C1 cell never stands in for a required C3 level. + +## Dry run + +Start `Release` manually from a branch (**Actions → Release → Run workflow**, or +`gh workflow run release.yml --ref `). The dry run executes `verify` (a notice instead of the tag checks), the +full `ci`, and `attest` without attestations. It uploads `nuget-package` and `attestation-bundles` (SBOM and +`SHA256SUMS`), creates no draft and publishes nothing. ## Publish @@ -66,29 +163,90 @@ The release commit must first land on the first-parent history of `main`; the wo commit. From an up-to-date `main` checkout: ```powershell -git tag -a v1.0.0 -m "Release 1.0.0" -git push origin v1.0.0 +git tag -a v2.0.0 -m "Release 2.0.0" +git push origin v2.0.0 ``` -The tag starts the `Release` workflow: +The tag starts the chain of the [overview](#overview). When `draft-release` finishes, the draft release already shows +the nupkg, the SBOM, both attestation bundles and `SHA256SUMS`. `publish` then waits for a required reviewer to +approve the `nuget` deployment on the run page. After the push, nuget.org validates and indexes the package, which can +take several minutes; `verify-publication` polls for up to 35 minutes. `finalize-release` publishes the GitHub release +only after nuget.org serves the attested package, and its step summary shows the immutability status and the asset +checksums. + +## Verify a release -1. `verify` checks the SemVer tag, that it points to `main`, that the version is not already on nuget.org, and extracts - the release notes from `CHANGELOG.md`. -2. `ci` builds and tests the tag in Debug and Release and packs `CheatEngine.SDK..nupkg` from the tested - Release build. The Release leg fails if the file name does not match the tag. -3. `publish` waits for a required reviewer to approve the `nuget` deployment in the run page, then pushes the package - and attests its provenance. -4. `github-release` creates the GitHub release from the extracted notes, attaches the package, and marks prereleases. +Anyone can check a release without trusting this repository's word: -Verify both the [NuGet package](https://www.nuget.org/packages/CheatEngine.SDK) and the GitHub release afterwards. -NuGet validation and search indexing can take several minutes. +```powershell +$v = '2.0.0' +gh release download "v$v" -R CheatEngineNet/CheatEngine.SDK -D release +Set-Location release + +# Immutable release: the release attestation covers the tag, the commit and every asset. +gh release verify "v$v" -R CheatEngineNet/CheatEngine.SDK +gh release verify-asset "v$v" "CheatEngine.SDK.$v.nupkg" -R CheatEngineNet/CheatEngine.SDK + +# Build provenance and SBOM of the package, signed by this workflow for this tag. +$identity = @('-R', 'CheatEngineNet/CheatEngine.SDK', + '--signer-workflow', 'CheatEngineNet/CheatEngine.SDK/.github/workflows/release.yml', + '--source-ref', "refs/tags/v$v", '--deny-self-hosted-runners') +gh attestation verify "CheatEngine.SDK.$v.nupkg" @identity +gh attestation verify "CheatEngine.SDK.$v.nupkg" @identity --predicate-type https://spdx.dev/Document/v2.2 + +# Checksums (sha256sum -c SHA256SUMS on Linux and macOS). +Get-Content SHA256SUMS | ForEach-Object { + $hash, $name = $_ -split ' ', 2 + if ((Get-FileHash $name -Algorithm SHA256).Hash -ne $hash) { throw "$name does not match SHA256SUMS." } +} +``` -After a successful release, raise `MinVerMinimumMajorMinor` to the next development line and commit that change on -`main`. For example, after `v1.0.0`, use `1.1` so subsequent untagged builds become `1.1.0-alpha.0.N`. +- The attestation bundles attached to the release (`*.provenance.sigstore.json`, `*.sbom.sigstore.json`) let + `gh attestation verify` check an asset against a bundle file with `--bundle ` instead of fetching the + attestation from GitHub ([`gh attestation verify`](https://cli.github.com/manual/gh_attestation_verify), + [verifying a release](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/verifying-the-integrity-of-a-release)). +- A consumer lock file holds the NuGet content hash: the `contentHash` of `CheatEngine.SDK` in `packages.lock.json` + equals the base64 SHA-512 of the release asset + (`[Convert]::ToBase64String([Security.Cryptography.SHA512]::HashData([IO.File]::ReadAllBytes("CheatEngine.SDK.$v.nupkg")))`). +- `dotnet nuget verify --all` on the file downloaded from nuget.org reports a nuget.org repository signature and the same + content hash ([`dotnet nuget verify`](https://learn.microsoft.com/dotnet/core/tools/dotnet-nuget-verify)). Never run it + on the GitHub asset: that file is unsigned by design and fails with + [NU3004](https://learn.microsoft.com/nuget/reference/errors-and-warnings/nu3004). +- The `verify-publication` job automates these nuget.org checks directly in the workflow. + +**Reproducibility.** The promise is at the level of the DLLs and the native bridge: MinVer stamps the tag version, +`ContinuousIntegrationBuild` normalizes paths, and the source fingerprint embedded in the bridge names its C source and +build script. The nupkg itself is not byte-reproducible, because the SBOM it embeds has a generated document namespace +and creation time. The attested nupkg is therefore the identity of a release; a rebuild from the tag reproduces its +assemblies and bridge, not the nupkg bytes. A packed bridge that differs from the committed, audited one +(`native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json`) is reported as drift, never hidden and never a +failure: CI rebuilds the bridge with its pinned toolset. + +## After a release + +On `main`, in one pull request after the release: + +1. Raise `MinVerMinimumMajorMinor` in `Directory.Build.props` to the next development line (after `v2.0.0`: `2.1`), so + untagged builds become `2.1.0-alpha.0.N`. +2. Set `PackageValidationBaselineVersion` in `src/CheatEngine.SDK/CheatEngine.SDK.csproj` to the released version. +3. Delete `src/CheatEngine.SDK/CompatibilitySuppressions.xml`: the new baseline already contains those changes. +4. For each shipping library, apply the `*REMOVED*` lines of `PublicAPI.Unshipped.txt` to `PublicAPI.Shipped.txt`, move + the other lines into `PublicAPI.Shipped.txt` (ordinally sorted), and leave `PublicAPI.Unshipped.txt` with its + `#nullable enable` header only. +5. Open a new empty `[Unreleased]` section in `CHANGELOG.md`. ## Re-running a release Use **Re-run failed jobs** only. Completed jobs are not repeated and the re-run reuses the artifacts of the original -attempt, so the pushed package, its attestation, and the release asset stay the same file. A push of an existing -version is skipped as a duplicate, and an existing GitHub release only receives a missing asset. **Re-run all jobs** -after a successful publish stops in `verify`, because the version is already on nuget.org. +attempt, so the tested, attested, attached and pushed package stays the same file. Artifacts are kept 90 days: re-run +within that window. + +- A draft that misses assets receives them. +- A push of an existing version is skipped as a duplicate. +- A release that is already published is never uploaded to: when it carries the same assets, `draft-release` reports it + and succeeds, and `finalize-release` only verifies it. +- **Re-run all jobs** rebuilds a new nupkg (its SBOM differs), so `draft-release` refuses a draft that holds the earlier + one. If that draft was never published, delete it and re-run. Once the version is on nuget.org, a full re-run stops + in `verify`. +- If `verify-publication` times out while nuget.org is still validating, re-run it. If it reports a different package, + stop: do not publish the draft, and investigate the push. diff --git a/ROADMAP.md b/ROADMAP.md index ec8e5e50..62546016 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,9 @@ **Planning baseline: September 21, 2026.** This is an outcome-based plan, not a delivery-date commitment. The initial preparation session was denied GitHub writes; this branch is the later reviewable import. The roadmap describes planned -outcomes, never implementation, package, fixture, or live-host completion. Read the live issue and Project state -separately from this versioned plan. +outcomes, never implementation, package, fixture, or live-host completion. + +The `SDK-0xx` work-item pages were retired on 2026-09-22 and are not restored; the identifiers below are plain text. ## Operating boundary @@ -28,81 +29,81 @@ research. Source merged, package shipped, fixture passed and host qualified are Maintain the source-indexed execution baseline without inventing audit coverage. -| Work item | Priority | Prerequisites | -|-----------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------------| -| [SDK-001](documentations/engineering/work-items/SDK-001.md) — Establish source, artifact, and capability provenance | P1 | Refinement and evidence; no declared issue blocker | -| [SDK-002](documentations/engineering/work-items/SDK-002.md) — Inventory the public CE extension surface and host profiles | P1 | SDK-001 | -| [SDK-003](documentations/engineering/work-items/SDK-003.md) — Adopt engineering governance and validate the bootstrap graph | P1 | Refinement and evidence; no declared issue blocker | +| Work item | Priority | Prerequisites | +|-------------------------------------------------------------------------|----------|----------------------------------------------------| +| SDK-001 — Establish source, artifact, and capability provenance | P1 | Refinement and evidence; no declared issue blocker | +| SDK-002 — Inventory the public CE extension surface and host profiles | P1 | SDK-001 | +| SDK-003 — Adopt engineering governance and validate the bootstrap graph | P1 | Refinement and evidence; no declared issue blocker | ### SDK-E02 — ABI, hosting, and deployment qualification Qualify exact host contracts and preserve the supported managed deployment. -| Work item | Priority | Prerequisites | -|--------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| -| [SDK-004](documentations/engineering/work-items/SDK-004.md) — Qualify classic ABI layouts and conflicting signatures | P1 | SDK-002 | -| [SDK-005](documentations/engineering/work-items/SDK-005.md) — Qualify activation admission, shutdown, and plugin coexistence | P1 | SDK-002 | -| [SDK-006](documentations/engineering/work-items/SDK-006.md) — Decide the NativeAOT plugin loader profile without weakening managed support | P2 | SDK-005 | +| Work item | Priority | Prerequisites | +|----------------------------------------------------------------------------------------|----------|---------------| +| SDK-004 — Qualify classic ABI layouts and conflicting signatures | P1 | SDK-002 | +| SDK-005 — Qualify activation admission, shutdown, and plugin coexistence | P1 | SDK-002 | +| SDK-006 — Decide the NativeAOT plugin loader profile without weakening managed support | P2 | SDK-005 | ### SDK-E03 — Authoritative semantic outcomes and Lua registration Expose reusable CE semantics without depending on Client policy. -| Work item | Priority | Prerequisites | -|------------------------------------------------------------------------------------------------------------------------------|----------|------------------| -| [SDK-007](documentations/engineering/work-items/SDK-007.md) — Preserve structured outcomes across Lua and Engine primitives | P1 | SDK-001 | -| [SDK-008](documentations/engineering/work-items/SDK-008.md) — Own built-in runtime, process, symbol, and table Lua contracts | P1 | SDK-007, SDK-002 | -| [SDK-009](documentations/engineering/work-items/SDK-009.md) — Return ownership-aware Lua registration leases | P1 | SDK-007, SDK-012 | +| Work item | Priority | Prerequisites | +|--------------------------------------------------------------------------|----------|------------------| +| SDK-007 — Preserve structured outcomes across Lua and Engine primitives | P1 | SDK-001 | +| SDK-008 — Own built-in runtime, process, symbol, and table Lua contracts | P1 | SDK-007, SDK-002 | +| SDK-009 — Return ownership-aware Lua registration leases | P1 | SDK-007, SDK-012 | ### SDK-E04 — Runtime identity and resource ownership Make runtime/target authority and exception-safe cleanup reusable for every SDK consumer. -| Work item | Priority | Prerequisites | -|-----------------------------------------------------------------------------------------------------------------------------------|----------|------------------| -| [SDK-010](documentations/engineering/work-items/SDK-010.md) — Establish authoritative target identity for effectful operations | P1 | SDK-007, SDK-002 | -| [SDK-011](documentations/engineering/work-items/SDK-011.md) — Make resource ownership handoff and cleanup exception-safe | P1 | SDK-010, SDK-007 | -| [SDK-012](documentations/engineering/work-items/SDK-012.md) — Qualify shared Lua state, reset, and protected operation boundaries | P1 | SDK-002, SDK-005 | +| Work item | Priority | Prerequisites | +|-------------------------------------------------------------------------------|----------|------------------| +| SDK-010 — Establish authoritative target identity for effectful operations | P1 | SDK-007, SDK-002 | +| SDK-011 — Make resource ownership handoff and cleanup exception-safe | P1 | SDK-010, SDK-007 | +| SDK-012 — Qualify shared Lua state, reset, and protected operation boundaries | P1 | SDK-002, SDK-005 | ### SDK-E05 — Memory and scanning primitives Provide qualified target reads, AOB outcomes, and scan-session ownership. -| Work item | Priority | Prerequisites | -|----------------------------------------------------------------------------------------------------------------------------------|----------|------------------| -| [SDK-013](documentations/engineering/work-items/SDK-013.md) — Qualify target memory, pointer width, and bounded buffer contracts | P1 | SDK-007, SDK-010 | -| [SDK-014](documentations/engineering/work-items/SDK-014.md) — Separate AOB absence, errors, and execution bounds | P1 | SDK-007, SDK-013 | -| [SDK-015](documentations/engineering/work-items/SDK-015.md) — Qualify the existing value-scan session factory | P2 | SDK-011, SDK-013 | +| Work item | Priority | Prerequisites | +|------------------------------------------------------------------------------|----------|------------------| +| SDK-013 — Qualify target memory, pointer width, and bounded buffer contracts | P1 | SDK-007, SDK-010 | +| SDK-014 — Separate AOB absence, errors, and execution bounds | P1 | SDK-007, SDK-013 | +| SDK-015 — Qualify the existing value-scan session factory | P2 | SDK-011, SDK-013 | ### SDK-E06 — Instruction, patch, and debugger contracts Separate instruction processing, target mutation and immediate callback decisions. -| Work item | Priority | Prerequisites | -|---------------------------------------------------------------------------------------------------------------------------------|----------|---------------------------| -| [SDK-016](documentations/engineering/work-items/SDK-016.md) — Qualify assembly and disassembly contracts by instruction profile | P2 | SDK-004, SDK-013 | -| [SDK-017](documentations/engineering/work-items/SDK-017.md) — Qualify Auto Assembler patch application and disable ownership | P1 | SDK-011, SDK-012 | -| [SDK-018](documentations/engineering/work-items/SDK-018.md) — Define synchronous debugger callback and continuation ownership | P2 | SDK-004, SDK-005, SDK-010 | +| Work item | Priority | Prerequisites | +|-----------------------------------------------------------------------------|----------|---------------------------| +| SDK-016 — Qualify assembly and disassembly contracts by instruction profile | P2 | SDK-004, SDK-013 | +| SDK-017 — Qualify Auto Assembler patch application and disable ownership | P1 | SDK-011, SDK-012 | +| SDK-018 — Define synchronous debugger callback and continuation ownership | P2 | SDK-004, SDK-005, SDK-010 | ### SDK-E07 — Record commands and optional capability families Deliver exact record semantics early and keep unrelated advanced families independently gated. -| Work item | Priority | Prerequisites | -|----------------------------------------------------------------------------------------------------------------------------------------|----------|------------------| -| [SDK-019](documentations/engineering/work-items/SDK-019.md) — Qualify timer and hotkey subscription ownership | P2 | SDK-005, SDK-012 | -| [SDK-020](documentations/engineering/work-items/SDK-020.md) — Partition advanced capability research into independently gated families | P3 | SDK-002, SDK-010 | -| [SDK-021](documentations/engineering/work-items/SDK-021.md) — Expose typed record and symbol mutation ownership | P1 | SDK-007, SDK-012 | +| Work item | Priority | Prerequisites | +|------------------------------------------------------------------------------------|----------|------------------| +| SDK-019 — Qualify timer and hotkey subscription ownership | P2 | SDK-005, SDK-012 | +| SDK-020 — Partition advanced capability research into independently gated families | P3 | SDK-002, SDK-010 | +| SDK-021 — Expose typed record and symbol mutation ownership | P1 | SDK-007, SDK-012 | ### SDK-E08 — Generation, artifacts, and ecosystem conformance Make published artifacts and generated consumers match the qualified source contracts. -| Work item | Priority | Prerequisites | -|---------------------------------------------------------------------------------------------------------------------------------|----------|------------------------------------------------------| -| [SDK-022](documentations/engineering/work-items/SDK-022.md) — Validate generated bindings and marshalling in packed consumers | P1 | SDK-007, SDK-009 | -| [SDK-023](documentations/engineering/work-items/SDK-023.md) — Publish a traceable minimum contract artifact for Client adoption | P1 | SDK-008, SDK-009, SDK-010, SDK-011, SDK-021, SDK-022 | -| [SDK-024](documentations/engineering/work-items/SDK-024.md) — Establish release qualification and performance evidence | P2 | SDK-023, SDK-005 | +| Work item | Priority | Prerequisites | +|-----------------------------------------------------------------------------|----------|------------------------------------------------------| +| SDK-022 — Validate generated bindings and marshalling in packed consumers | P1 | SDK-007, SDK-009 | +| SDK-023 — Publish a traceable minimum contract artifact for Client adoption | P1 | SDK-008, SDK-009, SDK-010, SDK-011, SDK-021, SDK-022 | +| SDK-024 — Establish release qualification and performance evidence | P2 | SDK-023, SDK-005 | ## Execution notes diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..287b0891 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,92 @@ +# Security policy + +CheatEngine.SDK is a Windows x64 SDK for Cheat Engine 7.7 plugins: a NuGet package with managed libraries, Roslyn +analyzers and generators, MSBuild assets and a native protection bridge. This page says which versions receive security +fixes, how to report a vulnerability privately, and how to check that a package came from this repository. + +## Supported versions + +| Version | Package | Security fixes | +|---|---|---| +| 1.0.x, the latest published release line | `CheatEngine.SDK` on nuget.org | Yes, as a new 1.0.x patch release | +| 2.0.0 prereleases built from `main` | CI artifacts and prerelease packages | On `main` only; a prerelease is never patched in place | +| 0.1.0 to 0.2.1 | the former `CESDK` package ID | No; move to `CheatEngine.SDK` (see [CHANGELOG.md](CHANGELOG.md)) | + +When a new release line ships, the previous line stops receiving fixes; the table is updated in the same pull request. + +## Reporting a vulnerability + +Report vulnerabilities privately through GitHub private vulnerability reporting: +. + +Never report a vulnerability in a public issue, discussion or pull request. + +A report can be triaged fastest when it identifies the exact combination that was run: + +- the `CheatEngine.SDK` version and its `contentHash`, both read from the plugin's `packages.lock.json` (never + recomputed); +- the SHA-256 of `cheatengine-sdk-lua-bridge.dll` in the plugin output folder; +- the Cheat Engine build (for example `7.7.0.10621`) and the SHA-256 of the executable you started, usually + `cheatengine-x86_64.exe`; +- the plugin load profile (managed plugin through hostfxr, historical CLR loader, or Native AOT) and the SHA-256 of + `ce.runtimeconfig.json` when the managed route is used; +- the Windows version and the output of `dotnet --info`; +- the impact (what an attacker controls and what they gain) and a minimal reproduction. + +Hashes can be read with PowerShell, for example +`Get-FileHash -Algorithm SHA256 "$env:ProgramFiles\Cheat Engine\cheatengine-x86_64.exe"`. Remove user names and private +paths from logs. Never attach Cheat Engine binaries, target binaries or raw debugger dumps; describe them by name, +version and hash instead. + +## Scope + +In scope: + +- the `CheatEngine.SDK` package: its libraries, analyzers, source generators, MSBuild build assets and the bundled + native protection bridge `cheatengine-sdk-lua-bridge.dll`; +- the bridge sources under `native/cheatengine-sdk-lua-bridge`; +- this repository's build, test and release workflows, including the provenance of published packages. + +Out of scope: + +- vulnerabilities in Cheat Engine itself: report them upstream at ; +- vulnerabilities in CheatEngine.Client: report them at + ; +- use of Cheat Engine or of a plugin against software or processes you are not authorized to inspect or modify. + +## Response + +- An acknowledgement within 7 days and a triage decision within 14 days, on a best-effort basis: the project has a + single active maintainer. +- Fixes are coordinated through a GitHub Security Advisory on this repository, which credits the reporter unless they + ask otherwise. +- A fix ships as a new package version with a `Security` entry in [CHANGELOG.md](CHANGELOG.md). The advisory is + published once the fixed package is available on nuget.org. + +## Verifying releases + +Packages are built, tested, packed and published by the `release.yml` workflow of this repository from a version tag, +through NuGet trusted publishing (no long-lived API key), and the published package gets a GitHub build provenance +attestation. [RELEASING.md](RELEASING.md) describes the process. + +A package therefore has two files with different hashes: + +- the package attached to the GitHub release, which is the attested file: + `gh attestation verify CheatEngine.SDK..nupkg --repo CheatEngineNet/CheatEngine.SDK`; +- the package served by nuget.org, which nuget.org re-signs with its repository signature, so its SHA-256 differs from + the GitHub asset: `dotnet nuget verify --all CheatEngine.SDK..nupkg`. + +The `contentHash` that NuGet writes into a consumer's `packages.lock.json` is computed without the repository +signature, so it identifies the package content independently of where it was downloaded. + +## Binary files in this repository + +Two binaries are committed on purpose; everything else is built from source. + +- `native/cheat-engine/lua53-64.dll` is the unmodified 64-bit Lua library of a Cheat Engine 7.7 installation, SHA-256 + `C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2` ([its README](native/cheat-engine/README.md)). + Tests and benchmarks bind it so that they exercise the Lua a plugin binds in production; it is never packed. +- `native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll` is the native protection + bridge that the package ships. CI rebuilds it from its C source and `xmake.lua` on every run, checks that two builds + are identical, and checks the committed DLL against the source fingerprint it exports. The weekly scheduled health + workflow also rebuilds the bridge of the latest release tag and compares it with the released DLL. diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs index 20d04ff8..e0e532e4 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs @@ -57,8 +57,8 @@ public static ConstructorDeclarationSyntax MakePublic(ConstructorDeclarationSynt // 'private protected' and 'protected internal' are two keywords: drop the second one, but not what the // author wrote around it. Its comments move behind the modifier in front of it, which always exists. for (int next = IndexOfAccessibility(modifiers, first + 1); - next >= 0; - next = IndexOfAccessibility(modifiers, next)) + next >= 0; + next = IndexOfAccessibility(modifiers, next)) { SyntaxToken dropped = modifiers[next]; SyntaxToken previous = modifiers[next - 1]; @@ -79,7 +79,7 @@ private static SyntaxTriviaList FromFirstComment(SyntaxTriviaList trivia) foreach (SyntaxTrivia item in trivia) { if (kept.Count > 0 || - !(item.IsKind(SyntaxKind.WhitespaceTrivia) || item.IsKind(SyntaxKind.EndOfLineTrivia))) + !(item.IsKind(SyntaxKind.WhitespaceTrivia) || item.IsKind(SyntaxKind.EndOfLineTrivia))) { kept = kept.Add(item); } @@ -111,7 +111,7 @@ private static int IndexOfAccessibility(SyntaxTokenList modifiers, int start) for (int index = start; index < modifiers.Count; index++) { if (modifiers[index].Kind() is SyntaxKind.PublicKeyword or SyntaxKind.InternalKeyword - or SyntaxKind.ProtectedKeyword or SyntaxKind.PrivateKeyword) + or SyntaxKind.ProtectedKeyword or SyntaxKind.PrivateKeyword) { return index; } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs index 4926f9a2..589468a5 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs @@ -80,10 +80,10 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) foreach (Diagnostic diagnostic in context.Diagnostics) { if (!diagnostic.Properties.TryGetValue(DiagnosticProperties.PluginClassProblem, out string? problemName) - || !Enum.TryParse(problemName, out PluginShapeIssues problem) - || root.FindToken(diagnostic.Location.SourceSpan.Start).Parent - ?.FirstAncestorOrSelf() is not { } declaration - || semanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not { } type) + || !Enum.TryParse(problemName, out PluginShapeIssues problem) + || root.FindToken(diagnostic.Location.SourceSpan.Start).Parent + ?.FirstAncestorOrSelf() is not { } declaration + || semanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not { } type) { continue; } @@ -117,7 +117,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) { if (reference.GetSyntax(cancellationToken) is not TypeDeclarationSyntax part - || solution.GetDocument(reference.SyntaxTree) is not { } document) + || solution.GetDocument(reference.SyntaxTree) is not { } document) { continue; } @@ -243,11 +243,11 @@ private static bool CanOmitEveryArgument(IMethodSymbol constructor) foreach (IMethodSymbol constructor in type.InstanceConstructors) { if (!constructor.Parameters.IsEmpty - || constructor.IsImplicitlyDeclared - || constructor.DeclaringSyntaxReferences.IsEmpty - || constructor.DeclaringSyntaxReferences[0] is not { } reference - || reference.GetSyntax(cancellationToken) is not ConstructorDeclarationSyntax syntax - || solution.GetDocument(reference.SyntaxTree) is not { } document) + || constructor.IsImplicitlyDeclared + || constructor.DeclaringSyntaxReferences.IsEmpty + || constructor.DeclaringSyntaxReferences[0] is not { } reference + || reference.GetSyntax(cancellationToken) is not ConstructorDeclarationSyntax syntax + || solution.GetDocument(reference.SyntaxTree) is not { } document) { continue; } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs index 345a5f11..b71d2474 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs @@ -57,7 +57,7 @@ public static BlockSyntax Guard(ArrowExpressionClauseSyntax expressionBody, Synt int statementPart = IndexAfterLastComment(afterSemicolon); SyntaxTriviaList withStatement = Slice(afterSemicolon, 0, statementPart); if (statementPart > 0 && afterSemicolon[statementPart - 1].IsKind(SyntaxKind.SingleLineCommentTrivia)) - // The closing brace of the try block follows: it must not end up inside the comment. + // The closing brace of the try block follows: it must not end up inside the comment. { withStatement = withStatement.Add(endOfLine); } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs index b95c46bb..46d12ee0 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs @@ -64,8 +64,8 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) { MethodDeclarationSyntax? declaration = FindDeclaration(root, diagnostic.Location.SourceSpan); if (declaration is null - || semanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not IMethodSymbol method - || !HasKnownFailureConvention(method)) + || semanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not IMethodSymbol method + || !HasKnownFailureConvention(method)) { continue; } @@ -107,8 +107,8 @@ private static async Task WrapAsync(Document document, MethodDeclarati SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); if (root is null || - semanticModel?.GetDeclaredSymbol(declaration, cancellationToken) is not IMethodSymbol method - || !HasKnownFailureConvention(method)) + semanticModel?.GetDeclaredSymbol(declaration, cancellationToken) is not IMethodSymbol method + || !HasKnownFailureConvention(method)) { return document; } @@ -136,25 +136,25 @@ private static async Task WrapAsync(Document document, MethodDeclarati private static bool HasKnownFailureConvention(IMethodSymbol method) { if (!method.IsStatic - || method.IsGenericMethod - || method.DeclaredAccessibility != Accessibility.Public - || !string.Equals(method.Name, "CEPluginInitialize", StringComparison.Ordinal) - || method.ReturnsByRef - || method.ReturnsByRefReadonly - || method.ReturnType.SpecialType != SpecialType.System_Int32 - || method.Parameters.Length != 2 - || method.Parameters[0].RefKind != RefKind.None - || method.Parameters[0].Type.SpecialType != SpecialType.System_IntPtr - || method.Parameters[1].RefKind != RefKind.None - || method.Parameters[1].Type.SpecialType != SpecialType.System_Int32) + || method.IsGenericMethod + || method.DeclaredAccessibility != Accessibility.Public + || !string.Equals(method.Name, "CEPluginInitialize", StringComparison.Ordinal) + || method.ReturnsByRef + || method.ReturnsByRefReadonly + || method.ReturnType.SpecialType != SpecialType.System_Int32 + || method.Parameters.Length != 2 + || method.Parameters[0].RefKind != RefKind.None + || method.Parameters[0].Type.SpecialType != SpecialType.System_IntPtr + || method.Parameters[1].RefKind != RefKind.None + || method.Parameters[1].Type.SpecialType != SpecialType.System_Int32) { return false; } INamedTypeSymbol containingType = method.ContainingType; return string.Equals(containingType.Name, "CESDK", StringComparison.Ordinal) - && containingType.ContainingType is null - && string.Equals(containingType.ContainingNamespace.ToDisplayString(), "CESDK", - StringComparison.Ordinal); + && containingType.ContainingType is null + && string.Equals(containingType.ContainingNamespace.ToDisplayString(), "CESDK", + StringComparison.Ordinal); } } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/packages.lock.json b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/packages.lock.json new file mode 100644 index 00000000..e2c319fb --- /dev/null +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/packages.lock.json @@ -0,0 +1,273 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "D2zqK/k16fto0yMz0hcXMTkzOxEwMDJyA1mu/KXF9Befwz4zub3MpHQD8FeRxJtVSSsC3dQFYBw7zu7r/pfO7g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Bcl.AsyncInterfaces": "10.0.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.CSharp": "[5.9.0]", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Composition": "10.0.1", + "System.IO.Pipelines": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Channels": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.16.0, )", + "resolved": "1.16.0", + "contentHash": "3kdIIceBPumwjw279FuiVMfVENT2cGASXJgcigdySsbX2dJB8ofUgG6i47yqF/k1qu6fvNR3csrSekZPviR6kQ==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "E1HSLkPHXEO30JEij2pWbOuzz1Z5ND4a5l7IP1T2RgQuE0a0NzEIvtO64RNy3Otn6PFezbT80cfm3M/Cgt70PA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "1A6jz50NG4nOEW8tX5+h+MyHqjWL0mPGwrUdwu+OlTfyknLo0GfxSqj4zEks8uVUdHdo9v8Ir9dHxACf8iYNEA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Bcl.AsyncInterfaces": "10.0.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Composition": "10.0.1", + "System.IO.Pipelines": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Channels": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DJkqaWQfho/ReTzKcZD3zJJ6K4GcS154k+T0UCPMBNIOZ2U/lNpyiiWZ6Etw0onWyTH1K+yhICsdmwA5xy2aPQ==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Convention": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1", + "System.Composition.TypedParts": "10.0.1" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "mRxYvpCVPAeuLEk0c0kxWJVjbW1/HUoxCgYotOj9eDeQiYcTDOMdCQApsTrHYMN3pHBA8WoF00KGolG632Etaw==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "dYynUByfVBzYDheNPGxS8UN8AvG/4tXf/coSs1odHOyoh4etv1kad/FrLWLMq4f8NO49NV20Xu+0/y613woTUA==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4UGmyBdKWEN1nkqspJlji/nV7XIVm6KGlOC2So0mtM/gKvaNgLz+tUkcbY+6Zpr7dr6ohX1S5yl0RLID5otRHw==", + "dependencies": { + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "TVfys1gnUIhmXuYfFzyez0fOkDyELe9UwlxYeVlq6FmqmWmt1ouF0OQJ+6ozkHbkaop7uBUaXw7Qb+/o0m+nMg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "koSfjkdQZfgQr3SyiSIBboDn+GiR0vZ3x9Uek9FJbXK0w5AiATV8KrnMEP8B8OAlO+Y3zQf0CPCNzwH+VIYDKg==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "26LbFXHKd7PmRnWlkjnYgmjd5B6HYVG+1MpTO25BdxTJnx6D0O16JPAC/S4YBqjtt4YpfGj1QO/Ss6SPMGEGQw==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", + "dependencies": { + "System.Collections.Immutable": "10.0.1" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "cheatengine.sdk.analyzers": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.SourceGenerators.Shared": "[1.0.0, )" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Unshipped.md b/analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Unshipped.md index 72ee30cb..9e3003c8 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Unshipped.md +++ b/analyzers/CheatEngine.SDK.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,9 +7,15 @@ -----------|----------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------- CESDK0003 | CheatEngine.SDK.Plugin | Error | CheatEnginePluginAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK0003.md) CESDK0005 | CheatEngine.SDK.Plugin | Error | CheatEnginePluginAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK0005.md) + CESDK0006 | CheatEngine.SDK.Plugin | Warning | ClassicNativeExportAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK0006.md) CESDK1001 | CheatEngine.SDK.Usage | Error | PluginLifecycleAndOwnershipAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK1001.md) CESDK1003 | CheatEngine.SDK.Usage | Error | PluginLifecycleAndOwnershipAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK1003.md) CESDK1005 | CheatEngine.SDK.Usage | Error | PluginLifecycleAndOwnershipAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK1005.md) + CESDK1020 | CheatEngine.SDK.Usage | Warning | HostWidthPointerSizeAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK1020.md) CESDK2005 | CheatEngine.SDK.Generation | Error | LuaBindingAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK2005.md) CESDK2006 | CheatEngine.SDK.Generation | Error | LuaBindingAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK2006.md) CESDK2007 | CheatEngine.SDK.Generation | Error | LuaBindingAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK2007.md) + CESDK2010 | CheatEngine.SDK.Generation | Error | LuaBindingAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK2010.md) + CESDK2011 | CheatEngine.SDK.Generation | Error | LuaBindingAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK2011.md) + CESDK2012 | CheatEngine.SDK.Generation | Error | LuaBindingAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK2012.md) + CESDK2013 | CheatEngine.SDK.Generation | Error | LuaBindingAnalyzer, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK2013.md) diff --git a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticDescriptors.cs b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticDescriptors.cs index 5a908203..0b43d85f 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticDescriptors.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticDescriptors.cs @@ -84,6 +84,21 @@ internal static class DiagnosticDescriptors HelpLinkBase + DiagnosticIds.GeneratedEntryPointCollision + ".md", WellKnownDiagnosticTags.CompilationEnd); + /// CESDK0006. Message arguments: the method name, then the exported entry-point name. + public static readonly DiagnosticDescriptor ClassicNativePluginExport = new( + DiagnosticIds.ClassicNativePluginExport, + "Method exports a classic Cheat Engine native plugin entry point", + "'{0}' exports '{1}', a classic Cheat Engine native plugin entry point; CheatEngine.SDK supports only the managed hostfxr profile and a NativeAOT plugin DLL cannot be unloaded by Cheat Engine", + DiagnosticCategories.Plugin, + DiagnosticSeverity.Warning, + true, + "An [UnmanagedCallersOnly] method whose EntryPoint starts with 'CEPlugin_' becomes a native export of the assembly when it is published with NativeAOT, " + + "which is how a classic native Cheat Engine plugin DLL is recognised. That is not a supported CheatEngine.SDK profile: Cheat Engine unloads plugins with FreeLibrary, " + + "and .NET does not support unloading a NativeAOT library, so no residence model exists for such a plugin. CheatEngine.SDK plugins are framework-dependent assemblies " + + "loaded through the managed hostfxr profile with the generated CESDK.CESDK.CEPluginInitialize entry point. Remove the export, " + + "or suppress the rule only if the assembly is deliberately not a CheatEngine.SDK plugin.", + HelpLinkBase + DiagnosticIds.ClassicNativePluginExport + ".md"); + /// CESDK1001. Message argument: the enabled-only member called too early. public static readonly DiagnosticDescriptor RequiresPluginEnabledTooEarly = new( DiagnosticIds.RequiresPluginEnabledTooEarly, @@ -137,6 +152,20 @@ internal static class DiagnosticDescriptors + "host-owned, explicitly tracked operation only when the API actually supports asynchronous waiting.", HelpLinkBase + DiagnosticIds.AsyncPluginLifecycle + ".md"); + /// CESDK1020. Message argument: the host-width expression. + public static readonly DiagnosticDescriptor HostWidthPointerSize = new( + DiagnosticIds.HostWidthPointerSize, + "PointerSize built from the plugin process width", + "This PointerSize comes from '{0}', the width of the plugin process, not of the Cheat Engine target", + DiagnosticCategories.Usage, + DiagnosticSeverity.Warning, + true, + "A plugin always runs inside the 64-bit Cheat Engine process, so IntPtr.Size, nint.Size, sizeof(nint), Unsafe.SizeOf(), " + + "Marshal.SizeOf() and Environment.Is64BitProcess describe the plugin, never the target: an x86 target has 4-byte pointers, " + + "and Cheat Engine's configured pointer size is a separate setting. Read the target bitness or the configured pointer size from " + + "Cheat Engine instead (TargetArchitectureObservation.Bitness or ConfiguredPointerSize).", + HelpLinkBase + DiagnosticIds.HostWidthPointerSize + ".md"); + /// CESDK2001. Message argument: the member name. public static readonly DiagnosticDescriptor UnsafeBlocksRequired = new( DiagnosticIds.UnsafeBlocksRequired, @@ -231,4 +260,57 @@ internal static class DiagnosticDescriptors + "handle members. A source declaration with the same identity prevents compilation. This rule identifies the user " + "declaration before generated code is emitted.", HelpLinkBase + DiagnosticIds.GeneratedLuaIdentityCollision + ".md"); + + /// CESDK2010. Message arguments: the method name, then the sentence fragment describing the problem. + public static readonly DiagnosticDescriptor NonTrailingOptionalLuaArgument = new( + DiagnosticIds.NonTrailingOptionalLuaArgument, + "Optional Lua argument is not in a trailing run", + "Lua binding '{0}' {1}", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "A LuaOptional argument of a [LuaGlobal] or [LuaFunction] binding can be omitted, and Lua cannot receive an argument " + + "after an absent one. Every required argument therefore comes before the first optional one. The generator emits " + + "nothing for a declaration that breaks this rule.", + HelpLinkBase + DiagnosticIds.NonTrailingOptionalLuaArgument + ".md"); + + /// CESDK2011. Message arguments: the method name, then the sentence fragment describing the problem. + public static readonly DiagnosticDescriptor InvalidOptionalOrVariadicLuaResult = new( + DiagnosticIds.InvalidOptionalOrVariadicLuaResult, + "Optional or variadic Lua result shape is invalid", + "Lua global binding '{0}' {1}", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "The results of a [LuaGlobal] binding are read in order: required results, then 'out LuaOptional' results, then at " + + "most one variadic 'Span values, out int count' pair of int, long, float, double, bool or nuint, declared last " + + "and only on the form that returns LuaOperationStatus. The generator emits nothing for another result order or shape.", + HelpLinkBase + DiagnosticIds.InvalidOptionalOrVariadicLuaResult + ".md"); + + /// CESDK2012. Message arguments: the member name, then the sentence fragment describing the problem. + public static readonly DiagnosticDescriptor LookAlikeLuaContractType = new( + DiagnosticIds.LookAlikeLuaContractType, + "Type impersonates an SDK Lua contract type", + "Lua binding '{0}' {1}", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "LuaOptional and LuaOperationStatus are contracts of the CheatEngine.SDK.Lua assembly. A type with the same namespace, " + + "name and arity declared in source or in another assembly is not that contract, so the generator never selects the " + + "optional or outcome shape for it and emits nothing for the declaration.", + HelpLinkBase + DiagnosticIds.LookAlikeLuaContractType + ".md"); + + /// CESDK2013. Message arguments: the member name, then the sentence fragment describing the problem. + public static readonly DiagnosticDescriptor UnsupportedLuaOptionalPosition = new( + DiagnosticIds.UnsupportedLuaOptionalPosition, + "LuaOptional is not supported in this position", + "Lua binding '{0}' {1}", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "LuaOptional is supported for a [LuaGlobal] argument or 'out' result and for a [LuaFunction] parameter, with T one of " + + "int, long, float, double, bool, nuint or string. It is not supported as a return value, on [LuaMethod] or " + + "[LuaProperty] members, with string?, a custom-marshalled or nested type argument, or together with [LuaMarshaller]. " + + "The generator emits nothing for such a member.", + HelpLinkBase + DiagnosticIds.UnsupportedLuaOptionalPosition + ".md"); } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs index d9f73ff3..b294efa2 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs @@ -29,6 +29,12 @@ public static class DiagnosticIds /// A source declaration collides with the CESDK.CESDK type generated for an enabled plugin. public const string GeneratedEntryPointCollision = "CESDK0005"; + /// + /// A method's [UnmanagedCallersOnly] attribute exports a classic Cheat Engine native plugin entry point + /// (CEPlugin_*), the NativeAOT plugin DLL route CheatEngine.SDK does not support. + /// + public const string ClassicNativePluginExport = "CESDK0006"; + /// A plugin constructor or initializer directly calls an API which requires an enabled plugin. public const string RequiresPluginEnabledTooEarly = "CESDK1001"; @@ -41,6 +47,9 @@ public static class DiagnosticIds /// An OnEnable or OnDisable implementation is async void. public const string AsyncPluginLifecycle = "CESDK1005"; + /// A PointerSize is built from the plugin process width instead of a Cheat Engine observation. + public const string HostWidthPointerSize = "CESDK1020"; + /// A [LuaFunction] or [LuaGlobal] binding exists but the compilation does not allow unsafe code. public const string UnsafeBlocksRequired = "CESDK2001"; @@ -61,4 +70,16 @@ public static class DiagnosticIds /// A user declaration collides with a member that a Lua binding generator must emit. public const string GeneratedLuaIdentityCollision = "CESDK2007"; + + /// A LuaOptional<T> argument of a Lua binding is followed by a required argument. + public const string NonTrailingOptionalLuaArgument = "CESDK2010"; + + /// The optional or variadic results of a [LuaGlobal] binding do not have a valid shape. + public const string InvalidOptionalOrVariadicLuaResult = "CESDK2011"; + + /// A binding names a type that has the metadata name of an SDK Lua contract type but is not that type. + public const string LookAlikeLuaContractType = "CESDK2012"; + + /// LuaOptional<T> is used in a position or with a type argument that is not supported. + public const string UnsupportedLuaOptionalPosition = "CESDK2013"; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingAnalyzer.cs index c8cdf8af..66d90b45 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingAnalyzer.cs @@ -18,7 +18,10 @@ namespace CheatEngine.SDK.Analyzers.Generation; /// [LuaGlobal] member exists but the compilation does not allow unsafe code. CESDK2002: the type that /// declares such a member cannot receive a generated part. CESDK2003: a [LuaFunction] method cannot be /// exported by a generated thunk. CESDK2004: a [LuaGlobal] method cannot receive a generated body. -/// CESDK2005: two otherwise valid functions of one binding type export the same Lua name. +/// CESDK2005: two otherwise valid functions of one binding type export the same Lua name. CESDK2010 to CESDK2013: +/// an optional argument outside the trailing run, an invalid optional or variadic result shape, a look-alike of an +/// SDK Lua contract type, and LuaOptional<T> where it is not supported (including on +/// [LuaMethod] and [LuaProperty] members, which do not support it yet). /// /// /// @@ -53,7 +56,11 @@ public override ImmutableArray SupportedDiagnostics DiagnosticDescriptors.InvalidLuaBindingContainingType, DiagnosticDescriptors.InvalidLuaFunction, DiagnosticDescriptors.InvalidLuaGlobal, - DiagnosticDescriptors.DuplicateLuaName + DiagnosticDescriptors.DuplicateLuaName, + DiagnosticDescriptors.NonTrailingOptionalLuaArgument, + DiagnosticDescriptors.InvalidOptionalOrVariadicLuaResult, + DiagnosticDescriptors.LookAlikeLuaContractType, + DiagnosticDescriptors.UnsupportedLuaOptionalPosition ]; /// @@ -83,7 +90,9 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaClassAttribute), SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMethodAttribute), SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaPropertyAttribute), - SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaState)); + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaState), + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaOptional), + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaOperationStatus)); // Mirrors CheatEngine.SDK.SourceGenerators.LuaBindings.Model.CompilationFacts.From: a non-C# compilation (never seen // here, the analyzer is C#-only) would read as "unsafe not allowed" too. @@ -92,9 +101,52 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) LuaFunctionDuplicateState duplicateNames = new(); context.RegisterSymbolAction( symbolContext => AnalyzeMethod(symbolContext, symbols, allowsUnsafe, duplicateNames), SymbolKind.Method); + if (symbols.LuaMethodAttribute is not null || symbols.LuaPropertyAttribute is not null) + { + context.RegisterSymbolAction(symbolContext => AnalyzeObjectMember(symbolContext, symbols), + SymbolKind.Method, SymbolKind.Property); + } + context.RegisterCompilationEndAction(duplicateNames.Report); } + // [LuaMethod] and [LuaProperty] members do not support LuaOptional yet: CESDK2013 names the reason instead of the + // generic CESDK2006 "unsupported type" (the object-binding analyzer leaves such a position to this rule). + private static void AnalyzeObjectMember(SymbolAnalysisContext context, LuaBindingContractSymbols symbols) + { + if (symbols.LuaOptional is null) + { + return; + } + + bool usesOptional; + switch (context.Symbol) + { + case IMethodSymbol method when symbols.LuaMethodAttribute is not null + && FindAttribute(method, symbols.LuaMethodAttribute) is not null: + usesOptional = LuaContractTypes.Is(method.ReturnType, symbols.LuaOptional); + foreach (IParameterSymbol parameter in method.Parameters) + { + usesOptional |= LuaContractTypes.Is(parameter.Type, symbols.LuaOptional); + } + + break; + case IPropertySymbol property when symbols.LuaPropertyAttribute is not null + && FindAttribute(property, symbols.LuaPropertyAttribute) is not null: + usesOptional = LuaContractTypes.Is(property.Type, symbols.LuaOptional); + break; + default: + return; + } + + if (usesOptional) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsupportedLuaOptionalPosition, + FirstLocation(context.Symbol), context.Symbol.Name, + "must not use LuaOptional: [LuaMethod] and [LuaProperty] members do not support optional values yet")); + } + } + private static void AnalyzeMethod(SymbolAnalysisContext context, LuaBindingContractSymbols symbols, bool allowsUnsafe, LuaFunctionDuplicateState duplicateNames) { @@ -154,7 +206,7 @@ private static void AnalyzeLuaFunction( string? name = ReadName(attribute); LuaFunctionShapeIssues issues = LuaFunctionShape.Inspect(context.Compilation, method, symbols.LuaState, symbols.LuaMarshallerAttribute, - symbols.LuaMarshallerContract, out _); + symbols.LuaMarshallerContract, symbols.LuaOptional, out _); if (!LuaNames.IsValidName(name)) { issues |= LuaFunctionShapeIssues.InvalidName; @@ -168,7 +220,7 @@ private static void AnalyzeLuaFunction( } context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.InvalidLuaFunction, location, method.Name, + LuaFunctionProblemText.DescriptorFor(problem), location, method.Name, LuaFunctionProblemText.Describe(problem))); } @@ -186,7 +238,7 @@ private static void AnalyzeLuaGlobal(SymbolAnalysisContext context, IMethodSymbo { LuaGlobalShapeIssues issues = LuaGlobalShape.Inspect(context.Compilation, method, symbols.LuaState, symbols.LuaMarshallerAttribute, - symbols.LuaMarshallerContract, out _); + symbols.LuaMarshallerContract, symbols.LuaOptional, symbols.LuaOperationStatus, out _); if (!LuaNames.IsValidName(ReadName(attribute))) { issues |= LuaGlobalShapeIssues.InvalidName; @@ -200,7 +252,8 @@ private static void AnalyzeLuaGlobal(SymbolAnalysisContext context, IMethodSymbo } context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.InvalidLuaGlobal, location, method.Name, LuaGlobalProblemText.Describe(problem))); + LuaGlobalProblemText.DescriptorFor(problem), location, method.Name, + LuaGlobalProblemText.Describe(problem))); } } @@ -215,9 +268,9 @@ private static void AnalyzeLuaGlobal(SymbolAnalysisContext context, IMethodSymbo : null; } - private static AttributeData? FindAttribute(IMethodSymbol method, INamedTypeSymbol attributeClass) + private static AttributeData? FindAttribute(ISymbol symbol, INamedTypeSymbol attributeClass) { - foreach (AttributeData attribute in method.GetAttributes()) + foreach (AttributeData attribute in symbol.GetAttributes()) { if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) { @@ -228,8 +281,8 @@ private static void AnalyzeLuaGlobal(SymbolAnalysisContext context, IMethodSymbo return null; } - private static Location FirstLocation(IMethodSymbol method) + private static Location FirstLocation(ISymbol symbol) { - return method.Locations.IsEmpty ? Location.None : method.Locations[0]; + return symbol.Locations.IsEmpty ? Location.None : symbol.Locations[0]; } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingContractSymbols.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingContractSymbols.cs index 0ec8b34b..c14b0c3e 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingContractSymbols.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaBindingContractSymbols.cs @@ -23,6 +23,8 @@ namespace CheatEngine.SDK.Analyzers.Generation; /// The resolved LuaMethod marker, or . /// The resolved LuaProperty marker, or . /// The resolved SDK LuaState symbol, or . +/// The resolved SDK LuaOptional<T>, or . +/// The resolved SDK LuaOperationStatus, or . internal sealed class LuaBindingContractSymbols( INamedTypeSymbol? luaFunctionAttribute, INamedTypeSymbol? luaGlobalAttribute, @@ -31,7 +33,9 @@ internal sealed class LuaBindingContractSymbols( INamedTypeSymbol? luaClassAttribute, INamedTypeSymbol? luaMethodAttribute, INamedTypeSymbol? luaPropertyAttribute, - INamedTypeSymbol? luaState) + INamedTypeSymbol? luaState, + INamedTypeSymbol? luaOptional, + INamedTypeSymbol? luaOperationStatus) { /// The marker attribute of an exported Lua function. public INamedTypeSymbol? LuaFunctionAttribute @@ -80,4 +84,16 @@ public INamedTypeSymbol? LuaState { get; } = luaState; + + /// The real SDK optional value, recognised by identity only. + public INamedTypeSymbol? LuaOptional + { + get; + } = luaOptional; + + /// The real SDK outcome value that selects the Outcome form, recognised by identity only. + public INamedTypeSymbol? LuaOperationStatus + { + get; + } = luaOperationStatus; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs index 5b73fe87..09327583 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs @@ -65,7 +65,7 @@ public void Report(CompilationAnalysisContext context) private string LuaNameFor(ConcurrentQueue<(string MethodName, Location Location)> members) { foreach (KeyValuePair<(string ContainingType, string LuaName), - ConcurrentQueue<(string MethodName, Location Location)>> pair in _candidates) + ConcurrentQueue<(string MethodName, Location Location)>> pair in _candidates) { if (ReferenceEquals(pair.Value, members)) { diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionProblemText.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionProblemText.cs index 4fb100b5..8fb68a52 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionProblemText.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionProblemText.cs @@ -1,7 +1,10 @@ using System.Collections.Immutable; +using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; +using Microsoft.CodeAnalysis; + namespace CheatEngine.SDK.Analyzers.Generation; /// @@ -29,9 +32,27 @@ internal static class LuaFunctionProblemText LuaFunctionShapeIssues.StateParameterNotFirst, LuaFunctionShapeIssues.UnsupportedParameterType, LuaFunctionShapeIssues.UnsupportedReturnType, - LuaFunctionShapeIssues.DuplicateName + LuaFunctionShapeIssues.DuplicateName, + LuaFunctionShapeIssues.OptionalArgumentNotTrailing, + LuaFunctionShapeIssues.LookAlikeContractType, + LuaFunctionShapeIssues.OptionalNotSupportedHere ]; + /// + /// The rule that reports for one method: CESDK2010, CESDK2012 or CESDK2013 for the + /// optional and contract-identity flags, CESDK2003 for every other local flag. + /// + public static DiagnosticDescriptor DescriptorFor(LuaFunctionShapeIssues problem) + { + return problem switch + { + LuaFunctionShapeIssues.OptionalArgumentNotTrailing => DiagnosticDescriptors.NonTrailingOptionalLuaArgument, + LuaFunctionShapeIssues.LookAlikeContractType => DiagnosticDescriptors.LookAlikeLuaContractType, + LuaFunctionShapeIssues.OptionalNotSupportedHere => DiagnosticDescriptors.UnsupportedLuaOptionalPosition, + _ => DiagnosticDescriptors.InvalidLuaFunction + }; + } + /// Returns the message fragment of a single flag. public static string Describe(LuaFunctionShapeIssues problem) { @@ -59,6 +80,12 @@ public static string Describe(LuaFunctionShapeIssues problem) "must return void or a type a marshaller pushes: int, long, float, double, bool, nuint, ReadOnlySpan or string", LuaFunctionShapeIssues.DuplicateName => "must not share its Lua name with another [LuaFunction] of the same containing type: one registration table cannot bind a name twice", + LuaFunctionShapeIssues.OptionalArgumentNotTrailing => + "must declare every LuaOptional parameter after the required ones: only trailing Lua arguments can be absent", + LuaFunctionShapeIssues.LookAlikeContractType => + "must use the LuaOptional type of CheatEngine.SDK.Lua, not a same-named type from source or another assembly", + LuaFunctionShapeIssues.OptionalNotSupportedHere => + "must use LuaOptional only for a parameter, with T one of int, long, float, double, bool, nuint or string (never string?, a custom-marshalled or nested type, or with [LuaMarshaller]); a thunk cannot return one", _ => "cannot be exported by a generated thunk" }; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaGlobalProblemText.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaGlobalProblemText.cs index f4ca4cef..e8ddd956 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaGlobalProblemText.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaGlobalProblemText.cs @@ -1,11 +1,15 @@ using System.Collections.Immutable; +using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Model; +using Microsoft.CodeAnalysis; + namespace CheatEngine.SDK.Analyzers.Generation; /// -/// The sentence fragment that completes "Lua global binding 'X' ..." in the CESDK2004 message, one per +/// The sentence fragment that completes "Lua global binding 'X' ..." in the CESDK2004 message (or the CESDK2010 to +/// CESDK2013 message of an optional, variadic or contract-identity problem), one per /// flag (from CheatEngine.SDK.SourceGenerators.Shared). /// internal static class LuaGlobalProblemText @@ -29,9 +33,35 @@ internal static class LuaGlobalProblemText LuaGlobalShapeIssues.UnsupportedResultType, LuaGlobalShapeIssues.SpanResult, LuaGlobalShapeIssues.UnsupportedReturnType, - LuaGlobalShapeIssues.TryFormReturnNotBool + LuaGlobalShapeIssues.TryFormReturnNotBool, + LuaGlobalShapeIssues.OptionalArgumentNotTrailing, + LuaGlobalShapeIssues.OptionalResultNotTrailing, + LuaGlobalShapeIssues.VariadicResultNotLast, + LuaGlobalShapeIssues.VariadicResultOutsideOutcome, + LuaGlobalShapeIssues.UnsupportedVariadicElement, + LuaGlobalShapeIssues.MultipleVariadicResults, + LuaGlobalShapeIssues.LookAlikeContractType, + LuaGlobalShapeIssues.OptionalNotSupportedHere ]; + /// + /// The rule that reports : CESDK2010 to CESDK2013 for the optional, variadic and + /// contract-identity flags, CESDK2004 for every other flag. + /// + public static DiagnosticDescriptor DescriptorFor(LuaGlobalShapeIssues problem) + { + return problem switch + { + LuaGlobalShapeIssues.OptionalArgumentNotTrailing => DiagnosticDescriptors.NonTrailingOptionalLuaArgument, + LuaGlobalShapeIssues.OptionalResultNotTrailing or LuaGlobalShapeIssues.VariadicResultNotLast + or LuaGlobalShapeIssues.VariadicResultOutsideOutcome or LuaGlobalShapeIssues.UnsupportedVariadicElement + or LuaGlobalShapeIssues.MultipleVariadicResults => DiagnosticDescriptors.InvalidOptionalOrVariadicLuaResult, + LuaGlobalShapeIssues.LookAlikeContractType => DiagnosticDescriptors.LookAlikeLuaContractType, + LuaGlobalShapeIssues.OptionalNotSupportedHere => DiagnosticDescriptors.UnsupportedLuaOptionalPosition, + _ => DiagnosticDescriptors.InvalidLuaGlobal + }; + } + /// Returns the message fragment of a single flag. public static string Describe(LuaGlobalShapeIssues problem) { @@ -66,6 +96,22 @@ public static string Describe(LuaGlobalShapeIssues problem) LuaGlobalShapeIssues.UnsupportedReturnType => "must return void, bool or a type a marshaller reads", LuaGlobalShapeIssues.TryFormReturnNotBool => "must return bool or LuaOperationStatus when it declares 'out' results: the throwing form has no 'out' parameter", + LuaGlobalShapeIssues.OptionalArgumentNotTrailing => + "must declare every LuaOptional argument after the required ones: Lua cannot receive an argument after an absent one", + LuaGlobalShapeIssues.OptionalResultNotTrailing => + "must declare every 'out LuaOptional' result after the required results", + LuaGlobalShapeIssues.VariadicResultNotLast => + "must declare the variadic 'Span values, out int count' pair after every other result", + LuaGlobalShapeIssues.VariadicResultOutsideOutcome => + "must return LuaOperationStatus to declare a variadic 'Span values, out int count' pair: only that form reports a capacity or element failure", + LuaGlobalShapeIssues.UnsupportedVariadicElement => + "must use int, long, float, double, bool or nuint as the element type of a variadic 'Span values, out int count' pair", + LuaGlobalShapeIssues.MultipleVariadicResults => + "must declare at most one variadic 'Span values, out int count' pair", + LuaGlobalShapeIssues.LookAlikeContractType => + "must use the LuaOptional and LuaOperationStatus types of CheatEngine.SDK.Lua, not a same-named type from source or another assembly", + LuaGlobalShapeIssues.OptionalNotSupportedHere => + "must use LuaOptional only for an argument or an 'out' result, with T one of int, long, float, double, bool, nuint or string (never string?, a custom-marshalled or nested type, or with [LuaMarshaller]); the throwing form cannot return one", _ => "cannot receive a generated body" }; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs index 7afb0b68..219c9730 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs @@ -59,6 +59,8 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) SdkSymbolResolver.Annotation(context.Compilation, WellKnownTypeNames.LuaMarshallerAttribute), SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.ILuaMarshaller), SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaState), + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaOptional), + SdkSymbolResolver.Lua(context.Compilation, WellKnownTypeNames.LuaOperationStatus), context.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1"), context.Compilation.GetTypeByMetadataName("CheatEngine.SDK.Engine.Objects.CEObject")); if (!symbols.HasAnyLuaObjectAnnotation) @@ -75,7 +77,7 @@ private static void AnalyzeType(SymbolAnalysisContext context, LuaObjectContract { INamedTypeSymbol type = (INamedTypeSymbol) context.Symbol; if (symbols.LuaClassAttribute is not null - && FindAttribute(type, symbols.LuaClassAttribute) is { } luaClassAttribute) + && FindAttribute(type, symbols.LuaClassAttribute) is { } luaClassAttribute) { string problem = LuaClassProblem(type, luaClassAttribute, context.CancellationToken); if (problem.Length > 0) @@ -98,7 +100,7 @@ private static void AnalyzeMethod(SymbolAnalysisContext context, LuaObjectContra { IMethodSymbol method = (IMethodSymbol) context.Symbol; if (symbols.LuaMethodAttribute is not null - && FindAttribute(method, symbols.LuaMethodAttribute) is { } luaMethodAttribute) + && FindAttribute(method, symbols.LuaMethodAttribute) is { } luaMethodAttribute) { string problem = LuaMethodProblem(method, luaMethodAttribute, symbols, context.CancellationToken); if (problem.Length > 0) @@ -112,7 +114,7 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, LuaObjectCont { IPropertySymbol property = (IPropertySymbol) context.Symbol; if (symbols.LuaPropertyAttribute is null - || FindAttribute(property, symbols.LuaPropertyAttribute) is not { } luaPropertyAttribute) + || FindAttribute(property, symbols.LuaPropertyAttribute) is not { } luaPropertyAttribute) { return; } @@ -148,12 +150,12 @@ private static void ReportLuaClassIdentityCollisions(SymbolAnalysisContext conte foreach (ISymbol member in type.GetMembers()) { if (member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator } method - && method.Name is "op_Equality" or "op_Inequality") + && method.Name is "op_Equality" or "op_Inequality") { ReportCollision(context, member, "operator " + - (string.Equals(method.Name, "op_Equality", StringComparison.Ordinal) - ? "==" - : "!=")); + (string.Equals(method.Name, "op_Equality", StringComparison.Ordinal) + ? "==" + : "!=")); } } @@ -165,8 +167,8 @@ private static void ReportLuaClassIdentityCollisions(SymbolAnalysisContext conte foreach (IMethodSymbol constructor in type.InstanceConstructors) { if (constructor.Parameters.Length == 1 - && constructor.Parameters[0].RefKind == RefKind.None - && SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, ceObject)) + && constructor.Parameters[0].RefKind == RefKind.None + && SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, ceObject)) { ReportCollision(context, constructor, type.Name + "(CEObject)"); } @@ -201,7 +203,7 @@ private static void ReportLuaFunctionIdentityCollisions(SymbolAnalysisContext co foreach (ISymbol member in type.GetMembers()) { if (member is not IMethodSymbol method - || FindAttribute(method, symbols.LuaFunctionAttribute) is not { } attribute) + || FindAttribute(method, symbols.LuaFunctionAttribute) is not { } attribute) { continue; } @@ -209,7 +211,7 @@ private static void ReportLuaFunctionIdentityCollisions(SymbolAnalysisContext co string? name = ReadName(attribute); LuaFunctionShapeIssues issues = LuaFunctionShape.Inspect(context.Compilation, method, symbols.LuaState, symbols.LuaMarshallerAttribute, - symbols.LuaMarshallerContract, out _); + symbols.LuaMarshallerContract, symbols.LuaOptional, out _); if (!LuaNames.IsValidName(name)) { issues |= LuaFunctionShapeIssues.InvalidName; @@ -250,7 +252,7 @@ private static void ReportLuaGlobalIdentityCollisions(SymbolAnalysisContext cont foreach (ISymbol member in type.GetMembers()) { if (member is not IMethodSymbol method - || FindAttribute(method, symbols.LuaGlobalAttribute) is not { } attribute) + || FindAttribute(method, symbols.LuaGlobalAttribute) is not { } attribute) { continue; } @@ -258,7 +260,7 @@ private static void ReportLuaGlobalIdentityCollisions(SymbolAnalysisContext cont string? name = ReadName(attribute); LuaGlobalShapeIssues issues = LuaGlobalShape.Inspect(context.Compilation, method, symbols.LuaState, symbols.LuaMarshallerAttribute, - symbols.LuaMarshallerContract, out _); + symbols.LuaMarshallerContract, symbols.LuaOptional, symbols.LuaOperationStatus, out _); if (!LuaNames.IsValidName(name)) { issues |= LuaGlobalShapeIssues.InvalidName; @@ -279,7 +281,7 @@ private static void ReportLuaGlobalLocalCollisions(SymbolAnalysisContext context { foreach (IParameterSymbol parameter in method.Parameters) { - if (IsLuaGlobalGeneratedLocalName(parameter.Name)) + if (LuaGlobalCallEmitter.IsReservedLocal(parameter.Name)) { ReportCollision(context, parameter, "generated local " + parameter.Name); } @@ -411,7 +413,9 @@ private static string LuaMethodProblem(IMethodSymbol method, AttributeData attri return parameterProblem; } - return LuaMethodReturnProblem(method, hasOutResult, symbols.ReadOnlySpan); + return LuaContractTypes.Is(method.ReturnType, symbols.LuaOptional) + ? string.Empty + : LuaMethodReturnProblem(method, hasOutResult, symbols.ReadOnlySpan); } private static string LuaMethodParameterProblem(IMethodSymbol method, LuaObjectContractSymbols symbols, @@ -429,7 +433,8 @@ private static string LuaMethodParameterProblem(IMethodSymbol method, LuaObjectC if (parameter.RefKind == RefKind.Out) { hasOutResult = true; - if (!IsScalar(parameter.Type, false, symbols.ReadOnlySpan)) + if (!LuaContractTypes.Is(parameter.Type, symbols.LuaOptional) + && !IsScalar(parameter.Type, false, symbols.ReadOnlySpan)) { return "out results must be supported scalar values"; } @@ -457,7 +462,9 @@ private static string LuaMethodParameterProblem(IMethodSymbol method, LuaObjectC return "LuaMethod does not take a LuaState parameter"; } - if (!IsScalar(parameter.Type, true, symbols.ReadOnlySpan)) + // LuaOptional on an object member is CESDK2013 (LuaBindingAnalyzer), not a generic unsupported type. + if (!LuaContractTypes.Is(parameter.Type, symbols.LuaOptional) + && !IsScalar(parameter.Type, true, symbols.ReadOnlySpan)) { return "parameters must be supported scalar values"; } @@ -506,7 +513,8 @@ private static string LuaPropertyProblem(IPropertySymbol property, AttributeData return "ref and ref readonly properties are not supported"; } - if (!IsScalar(property.Type, false, symbols.ReadOnlySpan)) + if (!LuaContractTypes.Is(property.Type, symbols.LuaOptional) + && !IsScalar(property.Type, false, symbols.ReadOnlySpan)) { return "the property type must be a supported scalar value"; } @@ -523,7 +531,7 @@ private static string LuaClassProblemForMember(INamedTypeSymbol containingType, CancellationToken cancellationToken) { if (symbols.LuaClassAttribute is null - || FindAttribute(containingType, symbols.LuaClassAttribute) is not { } luaClassAttribute) + || FindAttribute(containingType, symbols.LuaClassAttribute) is not { } luaClassAttribute) { return "the containing type must carry [LuaClass]"; } @@ -585,7 +593,7 @@ private static bool IsBodylessPartialProperty(IPropertySymbol property, Cancella } if (!hasPartialModifier || declaration.AccessorList is null || - declaration.AccessorList.Accessors.Count == 0) + declaration.AccessorList.Accessors.Count == 0) { return false; } @@ -598,8 +606,8 @@ private static bool IsBodylessPartialProperty(IPropertySymbol property, Cancella foreach (AccessorDeclarationSyntax accessor in declaration.AccessorList.Accessors) { if (accessor.Kind() is not SyntaxKind.GetAccessorDeclaration and not SyntaxKind.SetAccessorDeclaration - || accessor.Body is not null || accessor.ExpressionBody is not null - || !HasSupportedAccessorModifiers(accessor)) + || accessor.Body is not null || accessor.ExpressionBody is not null + || !HasSupportedAccessorModifiers(accessor)) { return false; } @@ -614,7 +622,7 @@ private static bool HasSupportedAccessorModifiers(AccessorDeclarationSyntax acce foreach (SyntaxToken modifier in accessor.Modifiers) { if (modifier.Kind() is not (SyntaxKind.PublicKeyword or SyntaxKind.PrivateKeyword - or SyntaxKind.ProtectedKeyword or SyntaxKind.InternalKeyword)) + or SyntaxKind.ProtectedKeyword or SyntaxKind.InternalKeyword)) { return false; } @@ -626,15 +634,15 @@ private static bool HasSupportedAccessorModifiers(AccessorDeclarationSyntax acce private static bool IsScalar(ITypeSymbol type, bool allowReadOnlySpan, INamedTypeSymbol? readOnlySpan) { if (type.SpecialType is SpecialType.System_Int32 or SpecialType.System_Int64 or SpecialType.System_Single - or SpecialType.System_Double or SpecialType.System_Boolean or SpecialType.System_UIntPtr - or SpecialType.System_String) + or SpecialType.System_Double or SpecialType.System_Boolean or SpecialType.System_UIntPtr + or SpecialType.System_String) { return true; } if (!allowReadOnlySpan || readOnlySpan is null || type is not INamedTypeSymbol { IsGenericType: true } named - || !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, readOnlySpan) - || named.TypeArguments.Length != 1) + || !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, readOnlySpan) + || named.TypeArguments.Length != 1) { return false; } @@ -647,11 +655,6 @@ private static bool IsGeneratedLocalName(string name) return name is "__ceState" or "__ceOperation" or "__ceTop" or "__ceStatus" or "__ceResult"; } - private static bool IsLuaGlobalGeneratedLocalName(string name) - { - return name is "__L" or "__operation" or "__top" or "__ok" or "__status" or "__result"; - } - private static AttributeData? FindAttribute(ISymbol symbol, INamedTypeSymbol attributeClass) { foreach (AttributeData attribute in symbol.GetAttributes()) @@ -692,6 +695,8 @@ private sealed class LuaObjectContractSymbols( INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, INamedTypeSymbol? luaState, + INamedTypeSymbol? luaOptional, + INamedTypeSymbol? luaOperationStatus, INamedTypeSymbol? readOnlySpan, INamedTypeSymbol? ceObject) { @@ -735,6 +740,16 @@ public INamedTypeSymbol? LuaState get; } = luaState; + public INamedTypeSymbol? LuaOptional + { + get; + } = luaOptional; + + public INamedTypeSymbol? LuaOperationStatus + { + get; + } = luaOperationStatus; + public INamedTypeSymbol? ReadOnlySpan { get; @@ -746,8 +761,8 @@ public INamedTypeSymbol? CEObject } = ceObject; public bool HasAnyLuaObjectAnnotation => LuaClassAttribute is not null || LuaMethodAttribute is not null - || LuaPropertyAttribute is not null || - LuaFunctionAttribute is not null - || LuaGlobalAttribute is not null; + || LuaPropertyAttribute is not null || + LuaFunctionAttribute is not null + || LuaGlobalAttribute is not null; } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs index f7e998f0..65242eab 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs @@ -86,8 +86,8 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) // CESDK.CESDK to the author, which CESDK0003 validates at compilation end. bool? entryPointIsGenerated = null; if (context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue(GenerateEntryPointKey, - out string? raw) - && bool.TryParse(raw, out bool generate)) + out string? raw) + && bool.TryParse(raw, out bool generate)) { entryPointIsGenerated = generate; } @@ -128,7 +128,7 @@ private static void AnalyzeNamedType( // The attribute targets classes only: on anything else the compiler already reports CS0592. if (type.TypeKind != TypeKind.Class || - FindAttribute(type, symbols.PluginAttribute) is not { } attribute) + FindAttribute(type, symbols.PluginAttribute) is not { } attribute) { return; } @@ -215,20 +215,20 @@ private static bool IsManualBootstrap(INamedTypeSymbol type) foreach (ISymbol member in type.GetMembers("CEPluginInitialize")) { if (member is not IMethodSymbol - { - MethodKind: MethodKind.Ordinary, - IsStatic: true, - IsGenericMethod: false, - DeclaredAccessibility: Accessibility.Public, - ReturnsByRef: false, - ReturnsByRefReadonly: false, - ReturnType.SpecialType: SpecialType.System_Int32, - Parameters: - [ - { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_IntPtr }, - { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_Int32 } - ] - }) + { + MethodKind: MethodKind.Ordinary, + IsStatic: true, + IsGenericMethod: false, + DeclaredAccessibility: Accessibility.Public, + ReturnsByRef: false, + ReturnsByRefReadonly: false, + ReturnType.SpecialType: SpecialType.System_Int32, + Parameters: + [ + { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_IntPtr }, + { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_Int32 } + ] + }) { continue; } @@ -265,9 +265,9 @@ private static void AnalyzeNamespaceDeclaration(SyntaxNodeAnalysisContext contex // A nested declaration is under 'CESDK' exactly when its outermost declaration is: one report per outermost one. if (declaration.Parent is not CompilationUnitSyntax - || context.SemanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not INamespaceSymbol - declared - || !IsUnderReservedRoot(declared, context.CancellationToken)) + || context.SemanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not INamespaceSymbol + declared + || !IsUnderReservedRoot(declared, context.CancellationToken)) { return; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/ClassicNativeExportAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/ClassicNativeExportAnalyzer.cs new file mode 100644 index 00000000..ca0723b6 --- /dev/null +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/ClassicNativeExportAnalyzer.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; + +using CheatEngine.SDK.Analyzers.Diagnostics; +using CheatEngine.SDK.Analyzers.WellKnown; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace CheatEngine.SDK.Analyzers.Plugin; + +/// +/// CESDK0006: a method or local function whose [UnmanagedCallersOnly] attribute exports a constant +/// EntryPoint starting with CEPlugin_, the prefix of the three classic Cheat Engine native plugin +/// exports (CEPlugin_GetVersion, CEPlugin_InitializePlugin, CEPlugin_DisablePlugin). +/// +/// +/// +/// Only a NativeAOT publication of the consumer's own assembly turns such a method into a DLL export +/// (https://learn.microsoft.com/dotnet/core/deploying/native-aot/interop#native-exports); the SDK package itself +/// never adds one. A NativeAOT plugin DLL is not a supported CheatEngine.SDK profile: Cheat Engine unloads plugins +/// with FreeLibrary, which .NET does not support for NativeAOT libraries, and CheatEngine.SDK plugins load +/// through the managed hostfxr profile only (audit F02, ADR-02). +/// +/// +/// Only the CEPlugin_ prefix is recognised (ordinal, case-sensitive, like the host's export lookup). The +/// historical unprefixed names the host also tries are deliberately not flagged: they are ordinary words +/// (GetVersion, InitializePlugin) that would produce false positives. Like the other plugin rules +/// it registers nothing in a compilation that does not reference CheatEngine.SDK. Stateless, no I/O, safe for +/// concurrent execution; generated code is not analysed. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ClassicNativeExportAnalyzer : DiagnosticAnalyzer +{ + /// The prefix of every classic native plugin export name. + internal const string ClassicExportPrefix = "CEPlugin_"; + + private const string EntryPointArgument = "EntryPoint"; + + /// + public override ImmutableArray SupportedDiagnostics + { + get; + } = + [ + DiagnosticDescriptors.ClassicNativePluginExport + ]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + Compilation compilation = context.Compilation; + bool referencesCheatEngineSdk = + compilation.GetTypeByMetadataName(WellKnownTypeNames.CheatEnginePluginAttribute) is not null + || compilation.GetTypeByMetadataName(WellKnownTypeNames.CheatEnginePluginBase) is not null; + INamedTypeSymbol? unmanagedCallersOnly = + compilation.GetTypeByMetadataName(WellKnownTypeNames.UnmanagedCallersOnlyAttribute); + if (!referencesCheatEngineSdk || unmanagedCallersOnly is null) + { + return; + } + + context.RegisterSymbolAction( + symbolContext => Analyze(symbolContext.ReportDiagnostic, (IMethodSymbol) symbolContext.Symbol, + unmanagedCallersOnly, symbolContext.CancellationToken), + SymbolKind.Method); + context.RegisterOperationAction( + operationContext => Analyze(operationContext.ReportDiagnostic, + ((ILocalFunctionOperation) operationContext.Operation).Symbol, unmanagedCallersOnly, + operationContext.CancellationToken), + OperationKind.LocalFunction); + } + + private static void Analyze(Action report, IMethodSymbol method, INamedTypeSymbol unmanagedCallersOnly, + CancellationToken cancellationToken) + { + foreach (AttributeData attribute in method.GetAttributes()) + { + if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, unmanagedCallersOnly)) + { + continue; + } + + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (!string.Equals(argument.Key, EntryPointArgument, StringComparison.Ordinal) || + argument.Value.Value is not string entryPoint || + !entryPoint.StartsWith(ClassicExportPrefix, StringComparison.Ordinal)) + { + continue; + } + + Location location = attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken).GetLocation() + ?? (method.Locations.IsEmpty ? Location.None : method.Locations[0]); + report(Diagnostic.Create(DiagnosticDescriptors.ClassicNativePluginExport, location, method.Name, + entryPoint)); + } + } + } +} diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs index 146e92da..13260a17 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs @@ -57,7 +57,7 @@ public void Report(CompilationAnalysisContext context) { int pluginClassCount = _pluginClasses.Count; if (pluginClassCount == 0) - // Not a plugin assembly (the SDK's own libraries, a helper library): both rules are about plugins. + // Not a plugin assembly (the SDK's own libraries, a helper library): both rules are about plugins. { return; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/README.md b/analyzers/CheatEngine.SDK.Analyzers/README.md index 48ca8473..d3b27b2f 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/README.md +++ b/analyzers/CheatEngine.SDK.Analyzers/README.md @@ -18,13 +18,14 @@ diagnostic that names the cause and links to a page with the fix. The assembly ships inside the `CheatEngine.SDK` package under `analyzers/dotnet/cs`, never as a package of its own. Its public -surface is the `DiagnosticIds` constants (`CheatEngine.SDK.Analyzers.Diagnostics`) and five analyzers. Everything else +surface is the `DiagnosticIds` constants (`CheatEngine.SDK.Analyzers.Diagnostics`) and six analyzers. Everything else is internal. | Analyzer | Rules | Subject | |-----------------------------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------------------------------------------| | `CheatEngine.SDK.Analyzers.Plugin.CheatEnginePluginAnalyzer` | `CESDK0001`–`CESDK0005` except unassigned IDs | Generated or explicitly manual bootstrap shape and identity | +| `CheatEngine.SDK.Analyzers.Plugin.ClassicNativeExportAnalyzer` | `CESDK0006` | `[UnmanagedCallersOnly]` exports named `CEPlugin_*` (unsupported NativeAOT plugin DLL profile) | | `CheatEngine.SDK.Analyzers.Usage.UnmanagedCallersOnlyGuardAnalyzer` | `CESDK1004` | Methods and local functions marked `[UnmanagedCallersOnly]` | | `CheatEngine.SDK.Analyzers.Usage.PluginLifecycleAndOwnershipAnalyzer` | `CESDK1001`, `CESDK1003`, `CESDK1005` | Enabled-only startup calls, direct disposal of borrowed values, and `async void` lifecycle callbacks | | `CheatEngine.SDK.Analyzers.Generation.LuaBindingAnalyzer` | `CESDK2001`–`CESDK2005` | `[LuaFunction]` and `[LuaGlobal]` method forms and duplicate export names | diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs index 4837f102..b9db1a10 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs @@ -113,16 +113,16 @@ private static bool IsGuardTry(ITryOperation tryOperation, INamedTypeSymbol exce } return hasCatchAll - && (tryOperation.Finally is null - || !ContainsThrow(tryOperation.Finally, doesNotReturnAttribute, environmentType)); + && (tryOperation.Finally is null + || !ContainsThrow(tryOperation.Finally, doesNotReturnAttribute, environmentType)); } // 'catch { }' has the exception type System.Object; 'catch (Exception)' names the root of the hierarchy. private static bool IsCatchAll(ICatchClauseOperation catchClause, INamedTypeSymbol exceptionType) { return catchClause.Filter is null - && (catchClause.ExceptionType.SpecialType == SpecialType.System_Object - || SymbolEqualityComparer.Default.Equals(catchClause.ExceptionType, exceptionType)); + && (catchClause.ExceptionType.SpecialType == SpecialType.System_Object + || SymbolEqualityComparer.Default.Equals(catchClause.ExceptionType, exceptionType)); } private static bool ContainsThrow(IOperation block, INamedTypeSymbol? doesNotReturnAttribute, @@ -131,8 +131,8 @@ private static bool ContainsThrow(IOperation block, INamedTypeSymbol? doesNotRet foreach (IOperation descendant in block.Descendants()) { if (descendant.Kind == OperationKind.Throw - || (descendant is IInvocationOperation invocation - && NeverReturnsByThrowing(invocation.TargetMethod, doesNotReturnAttribute, environmentType))) + || (descendant is IInvocationOperation invocation + && NeverReturnsByThrowing(invocation.TargetMethod, doesNotReturnAttribute, environmentType))) { return true; } @@ -148,7 +148,7 @@ private static bool NeverReturnsByThrowing(IMethodSymbol method, INamedTypeSymbo INamedTypeSymbol? environmentType) { if (doesNotReturnAttribute is null - || SymbolEqualityComparer.Default.Equals(method.ContainingType, environmentType)) + || SymbolEqualityComparer.Default.Equals(method.ContainingType, environmentType)) { return false; } @@ -167,11 +167,13 @@ private static bool NeverReturnsByThrowing(IMethodSymbol method, INamedTypeSymbo private static bool AreTrivialDeclarations(IVariableDeclarationGroupOperation group) { foreach (IVariableDeclarationOperation declaration in group.Declarations) - foreach (IVariableDeclaratorOperation declarator in declaration.Declarators) { - if (declarator.Initializer is { } initializer && !IsTriviallyNonThrowing(initializer.Value)) + foreach (IVariableDeclaratorOperation declarator in declaration.Declarators) { - return false; + if (declarator.Initializer is { } initializer && !IsTriviallyNonThrowing(initializer.Value)) + { + return false; + } } } @@ -197,7 +199,7 @@ private static bool IsTriviallyNonThrowing(IOperation value) } => true, IConversionOperation conversion => IsNonThrowingConversion(conversion) && - IsTriviallyNonThrowing(conversion.Operand), + IsTriviallyNonThrowing(conversion.Operand), // '-x', '+x', '~x', '!x' outside a checked context. 'dynamic' and 'decimal' operands run code, and so does // the fifth built-in unary operator: '^x' constructs a System.Index, which rejects negative values. @@ -227,7 +229,7 @@ private static bool IsNonThrowingConversion(IConversionOperation operation) { Conversion conversion = operation.GetConversion(); if (!conversion.Exists || conversion.IsUserDefined || conversion.MethodSymbol is not null || - conversion.IsDynamic) + conversion.IsDynamic) { return false; } @@ -244,15 +246,15 @@ private static bool IsNonThrowingConversion(IConversionOperation operation) if (conversion.IsImplicit) { return conversion.IsNumeric - || conversion.IsReference - || conversion.IsPointer - || (conversion.IsNullable && IsNullableWrapping(source, target)); + || conversion.IsReference + || conversion.IsPointer + || (conversion.IsNullable && IsNullableWrapping(source, target)); } return !operation.IsChecked - && (conversion.IsNumeric || conversion.IsEnumeration || conversion.IsPointer) - && IsPrimitiveEnumOrPointer(source) - && IsPrimitiveEnumOrPointer(target); + && (conversion.IsNumeric || conversion.IsEnumeration || conversion.IsPointer) + && IsPrimitiveEnumOrPointer(source) + && IsPrimitiveEnumOrPointer(target); } // 'int -> int?', 'int -> long?', 'int? -> long?', 'Guid -> Guid?'. An implicit nullable conversion can also @@ -263,9 +265,9 @@ private static bool IsNullableWrapping(ITypeSymbol? source, ITypeSymbol? target) ITypeSymbol? from = UnwrapNullable(source); ITypeSymbol? to = UnwrapNullable(target); return from is not null - && to is not null - && (SymbolEqualityComparer.Default.Equals(from, to) || - (IsPrimitiveOrEnum(from) && IsPrimitiveOrEnum(to))); + && to is not null + && (SymbolEqualityComparer.Default.Equals(from, to) || + (IsPrimitiveOrEnum(from) && IsPrimitiveOrEnum(to))); } private static ITypeSymbol? UnwrapNullable(ITypeSymbol? type) @@ -285,12 +287,12 @@ private static bool IsPrimitiveEnumOrPointer(ITypeSymbol? type) private static bool IsPrimitiveOrEnum(ITypeSymbol? type) { return type is { TypeKind: TypeKind.Enum } - || type?.SpecialType is SpecialType.System_Boolean or SpecialType.System_Char - or SpecialType.System_SByte or SpecialType.System_Byte - or SpecialType.System_Int16 or SpecialType.System_UInt16 - or SpecialType.System_Int32 or SpecialType.System_UInt32 - or SpecialType.System_Int64 or SpecialType.System_UInt64 - or SpecialType.System_IntPtr or SpecialType.System_UIntPtr - or SpecialType.System_Single or SpecialType.System_Double; + || type?.SpecialType is SpecialType.System_Boolean or SpecialType.System_Char + or SpecialType.System_SByte or SpecialType.System_Byte + or SpecialType.System_Int16 or SpecialType.System_UInt16 + or SpecialType.System_Int32 or SpecialType.System_UInt32 + or SpecialType.System_Int64 or SpecialType.System_UInt64 + or SpecialType.System_IntPtr or SpecialType.System_UIntPtr + or SpecialType.System_Single or SpecialType.System_Double; } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/HostWidthPointerSizeAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/HostWidthPointerSizeAnalyzer.cs new file mode 100644 index 00000000..7a6f9a61 --- /dev/null +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/HostWidthPointerSizeAnalyzer.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Immutable; + +using CheatEngine.SDK.Analyzers.Diagnostics; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace CheatEngine.SDK.Analyzers.Usage; + +/// +/// CESDK1020: a CheatEngine.SDK.Engine.Runtime.PointerSize built from the width of the plugin process +/// (IntPtr.Size, sizeof(nint), Environment.Is64BitProcess, ...) instead of a Cheat Engine +/// observation of the target. +/// +/// +/// +/// The plugin always runs inside the 64-bit Cheat Engine process, so its own pointer width says nothing about +/// the target: an x86 target has 4-byte pointers, and Cheat Engine's configured pointer size can be any value +/// (audit A07-03). The rule reports two direct shapes only: a new PointerSize(x) whose argument, after +/// conversions, is a host-width source, and a conditional expression that selects PointerSize.Bit64 or +/// PointerSize.Bit32 on a condition that reads a host-width source. It does no dataflow: a width copied +/// into a local first is not followed. +/// +/// +/// PointerSize is resolved by metadata name and by its defining assembly CheatEngine.SDK.Engine; a +/// project-local lookalike is ignored, and a project that does not reference the Engine assembly registers +/// nothing. Host-address formatting and every other use of IntPtr.Size stay silent. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class HostWidthPointerSizeAnalyzer : DiagnosticAnalyzer +{ + private const string EngineAssemblyName = "CheatEngine.SDK.Engine"; + private const string PointerSizeMetadataName = "CheatEngine.SDK.Engine.Runtime.PointerSize"; + + /// + public override ImmutableArray SupportedDiagnostics + { + get; + } = [DiagnosticDescriptors.HostWidthPointerSize]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + INamedTypeSymbol? pointerSize = ResolvePointerSize(context.Compilation); + if (pointerSize is null) + { + return; + } + + HostWidthSymbols symbols = new( + pointerSize, + context.Compilation.GetTypeByMetadataName("System.Environment"), + context.Compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.Unsafe"), + context.Compilation.GetTypeByMetadataName("System.Runtime.InteropServices.Marshal")); + context.RegisterOperationAction(operationContext => AnalyzeObjectCreation(operationContext, symbols), + OperationKind.ObjectCreation); + context.RegisterOperationAction(operationContext => AnalyzeConditional(operationContext, symbols), + OperationKind.Conditional); + } + + private static INamedTypeSymbol? ResolvePointerSize(Compilation compilation) + { + foreach (MetadataReference reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly + && string.Equals(assembly.Identity.Name, EngineAssemblyName, StringComparison.Ordinal)) + { + return assembly.GetTypeByMetadataName(PointerSizeMetadataName); + } + } + + return null; + } + + private static void AnalyzeObjectCreation(OperationAnalysisContext context, HostWidthSymbols symbols) + { + IObjectCreationOperation creation = (IObjectCreationOperation) context.Operation; + if (creation.Constructor is null + || !SymbolEqualityComparer.Default.Equals(creation.Constructor.ContainingType, symbols.PointerSize) + || creation.Arguments.Length != 1) + { + return; + } + + IOperation argument = SkipConversions(creation.Arguments[0].Value); + if (!IsHostWidthSource(argument, symbols)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.HostWidthPointerSize, + creation.Syntax.GetLocation(), argument.Syntax.ToString())); + } + + private static void AnalyzeConditional(OperationAnalysisContext context, HostWidthSymbols symbols) + { + IConditionalOperation conditional = (IConditionalOperation) context.Operation; + if (conditional.WhenFalse is null + || !SymbolEqualityComparer.Default.Equals(conditional.Type, symbols.PointerSize) + || !IsWidthConstant(conditional.WhenTrue, symbols) + || !IsWidthConstant(conditional.WhenFalse, symbols)) + { + return; + } + + IOperation? source = FindHostWidthSource(conditional.Condition, symbols); + if (source is null) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.HostWidthPointerSize, + conditional.Syntax.GetLocation(), source.Syntax.ToString())); + } + + // PointerSize.Bit32 or PointerSize.Bit64, the two widths a host-width conditional chooses between. + private static bool IsWidthConstant(IOperation operation, HostWidthSymbols symbols) + { + return SkipConversions(operation) is IPropertyReferenceOperation { Instance: null } property + && SymbolEqualityComparer.Default.Equals(property.Property.ContainingType, symbols.PointerSize) + && property.Property.Name is "Bit32" or "Bit64"; + } + + private static IOperation? FindHostWidthSource(IOperation condition, HostWidthSymbols symbols) + { + if (IsHostWidthSource(condition, symbols) || IsIs64BitProcess(condition, symbols)) + { + return condition; + } + + foreach (IOperation descendant in condition.Descendants()) + { + if (IsHostWidthSource(descendant, symbols) || IsIs64BitProcess(descendant, symbols)) + { + return descendant; + } + } + + return null; + } + + private static bool IsHostWidthSource(IOperation operation, HostWidthSymbols symbols) + { + switch (operation) + { + case IPropertyReferenceOperation { Instance: null } property: + // IntPtr.Size and UIntPtr.Size; nint.Size and nuint.Size bind to the same properties. + return string.Equals(property.Property.Name, "Size", StringComparison.Ordinal) + && IsNativeWidthType(property.Property.ContainingType); + case ISizeOfOperation sizeOf: + return IsNativeWidthType(sizeOf.TypeOperand) || sizeOf.TypeOperand is IPointerTypeSymbol; + case IInvocationOperation invocation: + return IsGenericSizeOf(invocation, symbols); + default: + return false; + } + } + + // Unsafe.SizeOf() and Marshal.SizeOf() (and their unsigned and pointer forms). + private static bool IsGenericSizeOf(IInvocationOperation invocation, HostWidthSymbols symbols) + { + IMethodSymbol method = invocation.TargetMethod; + if (!string.Equals(method.Name, "SizeOf", StringComparison.Ordinal) || method.TypeArguments.Length != 1 + || invocation.Arguments.Length != 0) + { + return false; + } + + INamedTypeSymbol declaring = method.ContainingType; + bool knownHelper = SymbolEqualityComparer.Default.Equals(declaring, symbols.Unsafe) + || SymbolEqualityComparer.Default.Equals(declaring, symbols.Marshal); + ITypeSymbol argument = method.TypeArguments[0]; + return knownHelper && (IsNativeWidthType(argument) || argument is IPointerTypeSymbol); + } + + private static bool IsIs64BitProcess(IOperation operation, HostWidthSymbols symbols) + { + return operation is IPropertyReferenceOperation { Instance: null } property + && string.Equals(property.Property.Name, "Is64BitProcess", StringComparison.Ordinal) + && SymbolEqualityComparer.Default.Equals(property.Property.ContainingType, symbols.Environment); + } + + // System.IntPtr and System.UIntPtr, including their nint and nuint spellings. + private static bool IsNativeWidthType(ITypeSymbol? type) + { + return type?.SpecialType is SpecialType.System_IntPtr or SpecialType.System_UIntPtr; + } + + private static IOperation SkipConversions(IOperation operation) + { + while (operation is IConversionOperation conversion) + { + operation = conversion.Operand; + } + + return operation; + } + + private sealed class HostWidthSymbols + { + public HostWidthSymbols(INamedTypeSymbol pointerSize, INamedTypeSymbol? environment, + INamedTypeSymbol? unsafeType, INamedTypeSymbol? marshal) + { + PointerSize = pointerSize; + Environment = environment; + Unsafe = unsafeType; + Marshal = marshal; + } + + public INamedTypeSymbol PointerSize + { + get; + } + + public INamedTypeSymbol? Environment + { + get; + } + + public INamedTypeSymbol? Unsafe + { + get; + } + + public INamedTypeSymbol? Marshal + { + get; + } + } +} diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs index 2d1dc3d5..6d60546a 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs @@ -77,8 +77,8 @@ private static void AnalyzeInvocation(OperationAnalysisContext context, PluginLi { IInvocationOperation invocation = (IInvocationOperation) context.Operation; if (symbols.RequiresPluginEnabled is not null && IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute) - && RequiresEnabled(invocation.TargetMethod, - symbols.RequiresPluginEnabled)) + && RequiresEnabled(invocation.TargetMethod, + symbols.RequiresPluginEnabled)) { context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiresPluginEnabledTooEarly, invocation.Syntax.GetLocation(), DisplayName(invocation.TargetMethod))); @@ -102,7 +102,7 @@ private static void AnalyzePropertyReference(OperationAnalysisContext context, PluginLifecycleContractSymbols symbols) { if (symbols.RequiresPluginEnabled is null || - !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) + !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) { return; } @@ -120,14 +120,14 @@ private static void AnalyzePropertyReference(OperationAnalysisContext context, private static void AnalyzeObjectCreation(OperationAnalysisContext context, PluginLifecycleContractSymbols symbols) { if (symbols.RequiresPluginEnabled is null || - !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) + !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) { return; } IObjectCreationOperation creation = (IObjectCreationOperation) context.Operation; if (creation.Constructor is null || - !RequiresEnabled(creation.Constructor, symbols.RequiresPluginEnabled)) + !RequiresEnabled(creation.Constructor, symbols.RequiresPluginEnabled)) { return; } @@ -140,7 +140,7 @@ private static void AnalyzeMethod(SymbolAnalysisContext context, PluginLifecycle { IMethodSymbol method = (IMethodSymbol) context.Symbol; if (!method.IsAsync || !method.ReturnsVoid || - !IsPluginClass(method.ContainingType, symbols.PluginAttribute)) + !IsPluginClass(method.ContainingType, symbols.PluginAttribute)) { return; } @@ -183,8 +183,8 @@ private static bool IsLifecycleOverride(IMethodSymbol method, INamedTypeSymbol p } for (IMethodSymbol? overridden = method.OverriddenMethod; - overridden is not null; - overridden = overridden.OverriddenMethod) + overridden is not null; + overridden = overridden.OverriddenMethod) { if (SymbolEqualityComparer.Default.Equals(overridden.ContainingType, pluginBase)) { diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs index 1f037769..f7e1589f 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs @@ -82,7 +82,7 @@ private static void AnalyzeMethodBody(OperationAnalysisContext context, INamedTy { IMethodBodyOperation body = (IMethodBodyOperation) context.Operation; if (context.ContainingSymbol is not IMethodSymbol method || - !IsUnmanagedCallersOnly(method, unmanagedCallersOnly)) + !IsUnmanagedCallersOnly(method, unmanagedCallersOnly)) { return; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs index 83b760c2..6c21f93d 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs @@ -38,7 +38,7 @@ internal static class SdkSymbolResolver foreach (MetadataReference reference in compilation.References) { if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly - || !string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) + || !string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) { continue; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/WellKnownTypeNames.cs b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/WellKnownTypeNames.cs index 33dcb863..6e0b7fd0 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/WellKnownTypeNames.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/WellKnownTypeNames.cs @@ -1,4 +1,5 @@ using CheatEngine.SDK.SourceGenerators.Shared; +using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; namespace CheatEngine.SDK.Analyzers.WellKnown; @@ -71,4 +72,10 @@ internal static class WellKnownTypeNames /// The real Lua state symbol that LuaMethod does not accept as an argument. public const string LuaState = "CheatEngine.SDK.Lua.State.LuaState"; + + /// The explicit optional value of a generated binding (CESDK2010 to CESDK2013). + public const string LuaOptional = LuaContractTypes.LuaOptionalMetadataName; + + /// The outcome value that selects the Outcome form of a [LuaGlobal] binding (CESDK2012). + public const string LuaOperationStatus = LuaContractTypes.LuaOperationStatusMetadataName; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/packages.lock.json b/analyzers/CheatEngine.SDK.Analyzers/packages.lock.json new file mode 100644 index 00000000..64d57ef4 --- /dev/null +++ b/analyzers/CheatEngine.SDK.Analyzers/packages.lock.json @@ -0,0 +1,141 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.16.0, )", + "resolved": "1.16.0", + "contentHash": "3kdIIceBPumwjw279FuiVMfVENT2cGASXJgcigdySsbX2dJB8ofUgG6i47yqF/k1qu6fvNR3csrSekZPviR6kQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", + "dependencies": { + "System.Collections.Immutable": "10.0.1" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/analyzers/docs/CESDK0006.md b/analyzers/docs/CESDK0006.md new file mode 100644 index 00000000..a39aa44d --- /dev/null +++ b/analyzers/docs/CESDK0006.md @@ -0,0 +1,67 @@ +# CESDK0006: Method exports a classic Cheat Engine native plugin entry point + +| | | +|--------------------|------------------------------------------------| +| Category | `CheatEngine.SDK.Plugin` | +| Default severity | Warning | +| Enabled by default | Yes | +| Code fix | No | +| Reported | While typing and in build output | + +## Cause + +A method or local function of a project that references CheatEngine.SDK carries `[UnmanagedCallersOnly]` with a +constant `EntryPoint` that starts with `CEPlugin_`, for example: + +```csharp +[UnmanagedCallersOnly(EntryPoint = "CEPlugin_GetVersion", CallConvs = [typeof(CallConvStdcall)])] +private static int GetVersion(nint version, int size) { ... } + +[UnmanagedCallersOnly(EntryPoint = NativeExportNames.InitializePlugin)] +private static int Initialize(nint exports, int pluginId) { ... } +``` + +## Why + +`CEPlugin_GetVersion`, `CEPlugin_InitializePlugin` and `CEPlugin_DisablePlugin` are the three exports by which Cheat +Engine recognises a **classic native** plugin DLL. A .NET assembly gets such exports only when it is published with +NativeAOT: `UnmanagedCallersOnly` methods with an `EntryPoint` of the published assembly become native exports +([Native AOT interop, native exports](https://learn.microsoft.com/dotnet/core/deploying/native-aot/interop#native-exports)). +The CheatEngine.SDK package can never add them for you; only your own source can. + +That route is not a supported CheatEngine.SDK profile: + +- Cheat Engine unloads a plugin with `FreeLibrary`, and .NET does not support unloading a NativeAOT library + ([Native AOT libraries](https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries)). No residence model + exists that would make such a plugin removable (audit F02, scenario Q42 recorded `NotApplicable`). +- CheatEngine.SDK plugins are framework-dependent assemblies loaded through the managed hostfxr profile, with the + generated `CESDK.CESDK.CEPluginInitialize` entry point and the 48-byte managed exports table. The classic 159-slot + table a `CEPlugin_InitializePlugin` export receives has no managed route and no SDK facade. +- Replacing the managed bootstrap by three native exports is not a complete solution: it changes the load profile, the + table the plugin receives and the unload contract all at once. + +See the [NativeAOT plugin profile](../../libs/CheatEngine.SDK.Abi/README.md) page for the profile table. + +## What is checked + +- Methods and local functions with `[UnmanagedCallersOnly]` from `System.Runtime.InteropServices`, in a compilation that + references CheatEngine.SDK (the plugin attribute or the plugin base class resolves). +- The named argument `EntryPoint`, whose value is always a compile-time constant (a literal or a constant such as + `NativeExportNames.GetVersion`). It is reported when the value starts with `CEPlugin_` (ordinal, case-sensitive). One + diagnostic per attribute, located on the attribute. +- Not flagged: other entry-point names, `[UnmanagedCallersOnly]` without `EntryPoint`, and the historical unprefixed + names (`GetVersion`, `InitializePlugin`, `DisablePlugin`) that the host also tries: they are ordinary words and would + produce false positives. This is a documented limitation. +- Generated code is not analysed. + +## How to fix + +Remove the `CEPlugin_*` exports and write a managed plugin: a class marked `[CheatEnginePlugin]` deriving from +`CheatEnginePlugin`, loaded through the generated entry point. Keep `PublishAot` off for the plugin (see +[CESDK9102](CESDK9102.md)). + +## When to suppress + +Only when the assembly is deliberately not a CheatEngine.SDK plugin, for example a research harness that inspects its +own exports without ever being loaded by Cheat Engine, and you accept that nothing in CheatEngine.SDK supports that +route. diff --git a/analyzers/docs/CESDK1020.md b/analyzers/docs/CESDK1020.md new file mode 100644 index 00000000..ab3985e9 --- /dev/null +++ b/analyzers/docs/CESDK1020.md @@ -0,0 +1,76 @@ +# CESDK1020: PointerSize built from the plugin process width + +| | | +|--------------------|----------------------------------------------------| +| Category | `CheatEngine.SDK.Usage` | +| Default severity | Warning | +| Enabled by default | Yes | +| Code fix | None | +| Reported | While typing and in build | + +In short: the width of your plugin process is not the width of the Cheat Engine target. Ask Cheat Engine: +`RuntimeProcessOperations.ObserveTargetArchitecture` returns the target bitness and Cheat Engine's configured pointer +size. + +## Cause + +A `CheatEngine.SDK.Engine.Runtime.PointerSize` is built from the width of the process the plugin runs in, in one of two +direct shapes: + +- `new PointerSize(x)` where `x`, after implicit or explicit conversions, is `IntPtr.Size`, `UIntPtr.Size`, `nint.Size`, + `nuint.Size`, `sizeof(nint)`, `sizeof(nuint)`, `sizeof(IntPtr)`, `sizeof(UIntPtr)`, `sizeof(void*)` (any pointer type), + `Unsafe.SizeOf()` or `Marshal.SizeOf()` (and their unsigned and pointer forms); +- a conditional expression `condition ? PointerSize.Bit64 : PointerSize.Bit32` (either order) whose condition reads one + of those values or `Environment.Is64BitProcess`. + +## Why + +A Cheat Engine plugin always runs inside the 64-bit Cheat Engine process, so every one of those values is 8 (or `true`) +whatever the target is. The target is a separate process: an x86 target has 4-byte pointers, and Cheat Engine keeps +its configured pointer size (`getPointerSize`) as yet another fact that `setPointerSize` can change. A pointer width +taken from the plugin process silently reads or writes the wrong number of bytes on an x86 target (audit A07-03, F08). + +## The exact definition + +The rule runs only in a project that references the `CheatEngine.SDK.Engine` assembly, and it matches `PointerSize` by +metadata name and by that defining assembly, so a project-local type with the same name is ignored. It reports the two +shapes above and nothing else: + +| Code | Verdict | +|-----------------------------------------------------------------------------|----------| +| `new PointerSize(IntPtr.Size)`, `new PointerSize((int) nuint.Size)` | reported | +| `new PointerSize(sizeof(void*))`, `new PointerSize(Unsafe.SizeOf())` | reported | +| `Environment.Is64BitProcess ? PointerSize.Bit64 : PointerSize.Bit32` | reported | +| `IntPtr.Size == 8 ? PointerSize.Bit64 : PointerSize.Bit32` | reported | +| `new PointerSize(4)`, `PointerSize.Bit64` | silent | +| `int width = IntPtr.Size; new PointerSize(width)` | silent: no dataflow | +| `IntPtr.Size` used to format a host address or size a host buffer | silent | +| `observation.Bitness`, `observation.ConfiguredPointerSize` | silent | + +The rule does not follow values through locals, fields or method calls; it catches the direct shapes that make the +mistake easy to write. Generated code is not analyzed. + +## Fix + +Read the fact you need from Cheat Engine: + +```csharp +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; + +ProcessOperationStatus status = RuntimeProcessOperations.ObserveTargetArchitecture( + out TargetArchitectureObservation target); +if (status.IsSuccess) +{ + PointerSize bitness = target.Bitness; // what CE's readPointer follows + PointerSize configured = target.ConfiguredPointerSize; // CE's getPointerSize, unknown unless 4 or 8 +} +``` + +`CurrentProcessObservation.PointerSize` (from `RuntimeProcessOperations.ObserveCurrent`) is also the target bitness. + +## When to suppress + +When the value really describes the plugin or the Cheat Engine host process rather than the target, for example a host +buffer that is passed to a native Cheat Engine export. Prefer `HostAddress` and host-side types for such values; a +`PointerSize` in the SDK always describes a target. diff --git a/analyzers/docs/CESDK2010.md b/analyzers/docs/CESDK2010.md new file mode 100644 index 00000000..28ab73ca --- /dev/null +++ b/analyzers/docs/CESDK2010.md @@ -0,0 +1,63 @@ +# CESDK2010: Optional Lua argument is not in a trailing run + +| | | +|--------------------|------------------------------| +| Category | `CheatEngine.SDK.Generation` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | While typing and in build | + +## Cause + +A `[LuaGlobal]` method or a `[LuaFunction]` method declares a +`CheatEngine.SDK.Lua.Marshalling.LuaOptional` argument followed by a required argument. + +## Why + +`LuaOptional` states that an argument can be omitted: a `[LuaGlobal]` wrapper does not push an omitted argument, and +a `[LuaFunction]` thunk reads a position the caller did not pass as omitted. Lua arguments are positional, so only a +trailing argument can be absent. An omitted argument followed by a present one cannot be expressed: Lua would receive the +later value in the earlier position. Cheat Engine functions can behave differently for an omitted argument and for an +explicit `nil`, so the generator never pushes `nil` in place of an omitted argument. + +The generator emits nothing for the declaration. + +## What is checked + +Arguments are read left to right, after an optional leading `LuaState`. Once a `LuaOptional` argument has been seen, +every later argument must also be a `LuaOptional`. Results (`out` parameters and span pairs) are not arguments and may +follow the optional run. + +## Example + +```csharp +using CheatEngine.SDK.Annotations.Lua; +using CheatEngine.SDK.Lua.Marshalling; + +namespace MyPlugin; + +public static partial class Tables +{ + [LuaGlobal("loadTable")] + public static partial void LoadTable(LuaOptional merge, string path); // CESDK2010 +} +``` + +Compliant: the required argument first, the optional one last. + +```csharp +public static partial class Tables +{ + [LuaGlobal("loadTable")] + public static partial void LoadTable(string path, LuaOptional merge); +} +``` + +A caller writes `default` (or `LuaOptional.Omitted()`) to omit `merge`, `LuaOptional.Nil()` to pass `nil`, +and `LuaOptional.Of(true)` to pass a value. When several optional arguments are declared, omitting one while passing a +later one throws `ArgumentException` before the wrapper touches Lua. + +## When to suppress + +Do not suppress it: the diagnostic means the binding has no generated body or thunk. Reorder the parameters. diff --git a/analyzers/docs/CESDK2011.md b/analyzers/docs/CESDK2011.md new file mode 100644 index 00000000..44decdcc --- /dev/null +++ b/analyzers/docs/CESDK2011.md @@ -0,0 +1,69 @@ +# CESDK2011: Optional or variadic Lua result shape is invalid + +| | | +|--------------------|------------------------------| +| Category | `CheatEngine.SDK.Generation` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | While typing and in build | + +## Cause + +The results of a `[LuaGlobal]` method do not follow the order the generated body reads them in, or a variadic result pair +is declared where it cannot be generated. + +## Why + +A binding with an `out LuaOptional` result or a variadic `Span values, out int count` pair calls Lua with +`LUA_MULTRET` and reads the factual number of values the global returned. The results are then read in a fixed order: + +1. required results (`out` values and `Span destination, out int written` copy-out pairs): fewer values than these + is `LuaOperationStatusKind.MissingResult`, never `NilResult`; +2. `out LuaOptional` results: a position Lua did not return is `Omitted`, a `nil` is `Nil`; +3. at most one variadic `Span values, out int count` pair, last: every remaining value is copied, or the call reports + `ResultCapacityExceeded` with the needed count. + +Only the form that returns `LuaOperationStatus` can report a capacity or element failure of the variadic tail, so the +variadic pair is refused on the `bool` Try form. The generator emits nothing for a declaration that breaks these rules. + +## What is checked + +| Problem | Requirement | +|--------------------------------|-------------------------------------------------------------------------------------------------------| +| `OptionalResultNotTrailing` | No required result follows an `out LuaOptional` result. | +| `VariadicResultNotLast` | No result follows the variadic pair. | +| `VariadicResultOutsideOutcome` | A variadic pair is declared only by a method that returns `LuaOperationStatus`. | +| `UnsupportedVariadicElement` | The span element is `int`, `long`, `float`, `double`, `bool` or `nuint`. `Span` stays copy-out. | +| `MultipleVariadicResults` | At most one variadic pair is declared. | + +## Example + +```csharp +using System; +using CheatEngine.SDK.Annotations.Lua; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Marshalling; + +namespace MyPlugin; + +public static partial class Memory +{ + [LuaGlobal("readBytes")] + public static partial bool TryReadBytes(nuint address, int count, Span values, out int read); // CESDK2011 +} +``` + +Compliant: the Outcome form, which reports `ResultCapacityExceeded`, `NilResult` or `InvalidResult` for the tail. + +```csharp +public static partial class Memory +{ + [LuaGlobal("readBytes")] + public static partial LuaOperationStatus ReadBytes(nuint address, int count, Span values, out int read); +} +``` + +## When to suppress + +Do not suppress it: the diagnostic means the binding has no generated body. Reorder or reshape the results. diff --git a/analyzers/docs/CESDK2012.md b/analyzers/docs/CESDK2012.md new file mode 100644 index 00000000..6f31fcf6 --- /dev/null +++ b/analyzers/docs/CESDK2012.md @@ -0,0 +1,58 @@ +# CESDK2012: Type impersonates an SDK Lua contract type + +| | | +|--------------------|------------------------------| +| Category | `CheatEngine.SDK.Generation` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | While typing and in build | + +## Cause + +A `[LuaGlobal]` or `[LuaFunction]` method uses a type named `CheatEngine.SDK.Lua.Marshalling.LuaOptional` or +`CheatEngine.SDK.Lua.Calls.LuaOperationStatus` that is not defined by the `CheatEngine.SDK.Lua` assembly: a type +declared in the project's own source, or in another referenced assembly. + +## Why + +The generators select the optional argument or result shape, and the `LuaOperationStatus` Outcome form, from these two +types. They recognise them by symbol identity: the type the `CheatEngine.SDK.Lua` assembly defines, found among every +type with that metadata name. A namespace and a name are only a lookup key. Accepting a same-named type would let the +generated body return or read a different type than the declaration names, or silently turn an Outcome declaration into +something else. The generator emits nothing for such a declaration and this rule names the reason, instead of a generic +"unsupported type" message. + +## What is checked + +Every argument, result and return type whose namespace, name and arity equal `LuaOptional`1` or `LuaOperationStatus` must +be the `CheatEngine.SDK.Lua` type. When the project does not reference `CheatEngine.SDK.Lua` at all, every such type is a +look-alike. + +## Example + +```csharp +namespace CheatEngine.SDK.Lua.Calls +{ + public readonly struct LuaOperationStatus { } // a source copy of the SDK type +} + +namespace MyPlugin +{ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + + public static partial class Memory + { + [LuaGlobal("readInteger")] + public static partial LuaOperationStatus TryReadInt32(nuint address, out int value); // CESDK2012 + } +} +``` + +Compliant: remove the source copy and reference the `CheatEngine.SDK` package, which brings the real +`CheatEngine.SDK.Lua` assembly. + +## When to suppress + +Do not suppress it: the diagnostic means the binding has no generated code and names a type the SDK does not define. diff --git a/analyzers/docs/CESDK2013.md b/analyzers/docs/CESDK2013.md new file mode 100644 index 00000000..fd9c4598 --- /dev/null +++ b/analyzers/docs/CESDK2013.md @@ -0,0 +1,72 @@ +# CESDK2013: LuaOptional is not supported in this position + +| | | +|--------------------|------------------------------| +| Category | `CheatEngine.SDK.Generation` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | While typing and in build | + +## Cause + +A binding uses `CheatEngine.SDK.Lua.Marshalling.LuaOptional` where the generators do not support it, or with a type +argument they cannot marshal. + +## Why + +`LuaOptional` keeps three states apart: omitted (no value at all), an explicit Lua `nil`, and a value. The generators +support it where each state has a defined Lua meaning: + +- a `[LuaGlobal]` argument (omitted is not pushed, `Nil` is pushed as `nil`) and a `[LuaFunction]` parameter (an absent + position is omitted); +- a `[LuaGlobal]` `out` result (a position the global did not return is omitted). + +A return value has no such meaning: the throwing form returns exactly one value or throws, and a thunk returns one value +or none. `[LuaMethod]` and `[LuaProperty]` members do not support optional values yet. The type argument must be a +built-in marshalled kind: `int`, `long`, `float`, `double`, `bool`, `nuint` or `string`. `string?` is refused because +`nil` is the `Nil` state, never a `null` string; a custom-marshalled or nested type, or an explicit `[LuaMarshaller]` on +the parameter, is refused because the optional states would have no defined meaning for its marshaller. The generator +emits nothing for the member. + +## What is checked + +| Position | Supported | +|---------------------------------------------------|---------------------------------------------| +| `[LuaGlobal]` argument, `[LuaFunction]` parameter | Yes, in a trailing run (see CESDK2010) | +| `[LuaGlobal]` `out` result | Yes, after the required results (CESDK2011) | +| `[LuaGlobal]` throwing-form return | No | +| `[LuaFunction]` return | No | +| `[LuaMethod]` parameter, result or return | Not yet | +| `[LuaProperty]` type | Not yet | + +`T` must be `int`, `long`, `float`, `double`, `bool`, `nuint` or `string`, and the parameter carries no `[LuaMarshaller]`. + +## Example + +```csharp +using CheatEngine.SDK.Annotations.Lua; +using CheatEngine.SDK.Lua.Marshalling; + +namespace MyPlugin; + +public static partial class Symbols +{ + [LuaGlobal("getAddressSafe")] + public static partial LuaOptional ResolveAddress(string expression); // CESDK2013: optional return +} +``` + +Compliant: an `out LuaOptional` result of the Try or Outcome form. + +```csharp +public static partial class Symbols +{ + [LuaGlobal("getAddressSafe")] + public static partial bool TryResolveAddress(string expression, out LuaOptional address); +} +``` + +## When to suppress + +Do not suppress it: the diagnostic means the member has no generated code. diff --git a/analyzers/docs/CESDK3001.md b/analyzers/docs/CESDK3001.md new file mode 100644 index 00000000..3242a40f --- /dev/null +++ b/analyzers/docs/CESDK3001.md @@ -0,0 +1,41 @@ +# CESDK3001: Engine API specification is invalid + +| | | +|--------------------|-----------------------------| +| Category | `CheatEngine.SDK.EngineApi` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | In build, by the generator | + +## Cause + +A curated spec file (`*.cheatengine-sdk-api.txt`) has a malformed line, an unknown or duplicated key, an invalid +namespace, type, Lua global or C# method name, an unknown kind, a missing required key, or a value outside the +`contract: ce77` vocabulary (provenance status, minimum version, architecture, thread, ownership, `nil`). + +## Why + +A spec states the contract of every generated wrapper. A malformed entry cannot be generated, and silently skipping it +would remove an API from `CheatEngine.SDK.Engine` without anyone noticing. The generator drops only the invalid entry +(or the file, for a broken header) and reports why; a valid sibling entry still generates. + +## What is checked + +The grammar in [`source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/README.md`](../../source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/README.md#spec-file-format): +`key: value` lines, blocks separated by blank lines, the header keys, the entry keys and their counts, the value kinds, +reserved generated locals, and generated-member collisions inside one file. + +## How to fix + +Correct the key or value at the reported line and column. The message names the key, value or rule. + +## When to suppress + +Never: the diagnostic means an entry generates nothing. + +## Who sees it + +Only this repository: the EngineApi generator does not ship in the `CheatEngine.SDK` package. It runs while +`CheatEngine.SDK.Engine` builds and reads the spec files that project passes as `AdditionalFiles`. The diagnostic is +located on the spec file (line and column of the offending key or value), not on C# code. diff --git a/analyzers/docs/CESDK3002.md b/analyzers/docs/CESDK3002.md new file mode 100644 index 00000000..3c18168f --- /dev/null +++ b/analyzers/docs/CESDK3002.md @@ -0,0 +1,38 @@ +# CESDK3002: Engine API specification has a generated-identity conflict + +| | | +|--------------------|-----------------------------| +| Category | `CheatEngine.SDK.EngineApi` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | In build, by the generator | + +## Cause + +Two spec files declare the same generated type, or would generate the same wrapper member or `LuaRef` cache field. + +## Why + +A spec file exclusively owns its generated type. Two files contributing to one type would depend on partial-type +ordering and could collide silently. Every participating file is reported at the exact field, and none of the +conflicting files is generated. + +## What is checked + +The `namespace` + `type` identity across all spec files of one compilation, and the wrapper and cache-field identities +of their entries. + +## How to fix + +Move the entries into one spec file, or give one file another `type`. + +## When to suppress + +Never: the diagnostic means the conflicting files generate nothing. + +## Who sees it + +Only this repository: the EngineApi generator does not ship in the `CheatEngine.SDK` package. It runs while +`CheatEngine.SDK.Engine` builds and reads the spec files that project passes as `AdditionalFiles`. The diagnostic is +located on the spec file (line and column of the offending key or value), not on C# code. diff --git a/analyzers/docs/CESDK3003.md b/analyzers/docs/CESDK3003.md new file mode 100644 index 00000000..1035d20d --- /dev/null +++ b/analyzers/docs/CESDK3003.md @@ -0,0 +1,55 @@ +# CESDK3003: Engine API specification does not declare the ce77 contract + +| | | +|--------------------|-----------------------------| +| Category | `CheatEngine.SDK.EngineApi` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | In build, by the generator | + +## Cause + +A spec file declares at least one entry, but its header has no `contract: ce77` key. + +## Why + +Every generated wrapper carries machine-validated Cheat Engine 7.7 evidence: provenance (a proof status and the pinned +`celua.txt` SHA-256), minimum CE version, architecture, thread affinity (`unknown` unless proven), ownership, and a +`nil` contract per entry. A wrapper without that evidence would present an unqualified contract as a reviewed one +(audit F10, AX06-18). Header-only reservation files stay readable without the contract, because they generate nothing. + +## What is checked + +A file with one or more entries must have `contract: ce77` in its header; the other contract keys and the per-entry +`nil` key are then required and validated (CESDK3001). The file generates nothing until the contract is present. + +## How to fix + +```text +namespace: CheatEngine.SDK.Engine.Generated +type: Example +contract: ce77 +provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 <64 hex digits> +minimum-ce: 7.7.0.10621 +architecture: x64 +thread: unknown +ownership: none + +global: getCEVersion +method: GetCheatEngineVersion +form: throwing +return: double +nil: none +doc: Gets the version value. +``` + +## When to suppress + +Never. + +## Who sees it + +Only this repository: the EngineApi generator does not ship in the `CheatEngine.SDK` package. It runs while +`CheatEngine.SDK.Engine` builds and reads the spec files that project passes as `AdditionalFiles`. The diagnostic is +located on the spec file (line and column of the offending key or value), not on C# code. diff --git a/analyzers/docs/CESDK3004.md b/analyzers/docs/CESDK3004.md new file mode 100644 index 00000000..18478a18 --- /dev/null +++ b/analyzers/docs/CESDK3004.md @@ -0,0 +1,42 @@ +# CESDK3004: Engine API optional argument is invalid + +| | | +|--------------------|-----------------------------| +| Category | `CheatEngine.SDK.EngineApi` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | In build, by the generator | + +## Cause + +An entry declares an `opt:` argument followed by an `arg:` or `fixed:` argument, or an `opt:` argument of kind `utf8` +or `string?`. + +## Why + +An `opt:` argument becomes a `LuaOptional` parameter: omitted (not pushed), `Nil` (pushed as `nil`) or a value. +Cheat Engine functions can behave differently for an omitted argument and an explicit `nil`, so the generated wrapper +never pushes `nil` in place of an omitted argument. Lua arguments are positional, so only a trailing run of arguments +can be omitted. `LuaOptional` is an ordinary struct, so a `utf8` span cannot be optional, and `nil` is the `Nil` +state rather than a `null` string. + +## What is checked + +`arg:`, `fixed:` and `opt:` keep their textual order, which is the push order. After the first `opt:`, only `opt:` +may follow. An `opt:` kind is `int32`, `int64`, `single`, `double`, `boolean`, `address` or `string`; an optional +`address` is exposed as `LuaOptional
` and converted by the facade without losing its state. + +## How to fix + +Move the required and fixed arguments before the optional ones, or use `string` for optional text. + +## When to suppress + +Never: the diagnostic means the entry generates nothing. + +## Who sees it + +Only this repository: the EngineApi generator does not ship in the `CheatEngine.SDK` package. It runs while +`CheatEngine.SDK.Engine` builds and reads the spec files that project passes as `AdditionalFiles`. The diagnostic is +located on the spec file (line and column of the offending key or value), not on C# code. diff --git a/analyzers/docs/CESDK3005.md b/analyzers/docs/CESDK3005.md new file mode 100644 index 00000000..25334194 --- /dev/null +++ b/analyzers/docs/CESDK3005.md @@ -0,0 +1,41 @@ +# CESDK3005: Engine API optional or variadic result is invalid + +| | | +|--------------------|-----------------------------| +| Category | `CheatEngine.SDK.EngineApi` | +| Default severity | Error | +| Enabled by default | Yes | +| Code fix | No | +| Reported | In build, by the generator | + +## Cause + +An entry declares a `result:` after an `opt-result:`, anything after a `rest:`, a `rest:` on a form other than +`outcome`, an `opt-result:` of kind `string?`, or a `rest:` of a kind other than `int32`, `int64`, `single`, `double` +or `boolean`. + +## Why + +An entry with `opt-result:` or `rest:` calls Lua with `LUA_MULTRET` and reads the factual number of values. Required +results come first (fewer values than them is `MissingResult`, never `nil`), then optional results (a position Lua did +not return is omitted), then at most one variadic tail. Only the `outcome` form, which returns `LuaOperationStatus`, +can report `ResultCapacityExceeded`, `NilResult` or `InvalidResult` for the tail. + +## What is checked + +The order `result:`, `opt-result:`, `rest:`; `rest:` only with `form: outcome` and only once; the kinds above. A +`rest: values:int64` entry generates `Span values, out int valuesCount`. + +## How to fix + +Reorder the results, or use `form: outcome` for a `rest:` result. + +## When to suppress + +Never: the diagnostic means the entry generates nothing. + +## Who sees it + +Only this repository: the EngineApi generator does not ship in the `CheatEngine.SDK` package. It runs while +`CheatEngine.SDK.Engine` builds and reads the spec files that project passes as `AdditionalFiles`. The diagnostic is +located on the spec file (line and column of the offending key or value), not on C# code. diff --git a/analyzers/docs/CESDK5001.md b/analyzers/docs/CESDK5001.md new file mode 100644 index 00000000..f58d830e --- /dev/null +++ b/analyzers/docs/CESDK5001.md @@ -0,0 +1,74 @@ +# CESDK5001: Worker-thread Lua admission is experimental + +| | | +|--------------------|---------------------------------------------------------------------------------| +| Kind | `[Experimental]` API gate (reported by the C# compiler, not by an SDK analyzer) | +| Default severity | Error (compiler) | +| Enabled by default | Yes | +| Code fix | None | +| Reported | While typing and in build, at every use of a gated member | + +In short: opting a worker thread into Lua admission is unqualified. Handle every `LuaAdmissionStatus` your code can +now receive, and do not assume the shared Lua heap is safe to touch from two threads at once. + +## Cause + +Your code calls `CheatEngine.SDK.Lua.Runtime.LuaRuntime.AdmitWorkerThreads()`, which carries +`[Experimental("CESDK5001")]`. + +## Why it is experimental + +The SDK 2.0 default (`LuaThreadAdmission.MainThreadOnly`, ADR-07, F04) starts no Lua work on a thread the host is not +already running Lua on: a worker's `LuaRuntime.AcquireOperation()`-family call is refused with +`LuaAdmissionStatus.ThreadNotAdmitted` before the host's state provider ever runs. This is the conservative default +because Cheat Engine hands out one Lua thread (coroutine) per OS thread from one shared virtual machine, heap and +registry — admission only makes this SDK copy's own attach/reset/detach transitions exclusive, never the shared heap. +Whether it is actually safe to run Lua concurrently from a first-admitted worker thread, on the pinned Cheat Engine +host, is exactly what qualification scenario Q19 measures. + +`AdmitWorkerThreads()` lifts the admission refusal only; it does not by itself prove, or provide, any serialization of +the shared Lua heap across threads. It stays gated until Q19 passes at **both**: + +- **C3** (exact host, one plugin, first worker call observed and measured), and +- **C4** (exact host, two SDK copies, simultaneous first worker calls observed and measured). + +No local Cheat Engine qualification run is currently available for either level, so both remain open. The +`[Experimental]` gate is the honest reflection of that: the SDK is telling you it has not seen this behaviour on the +real host yet. + +## How to opt in + +Suppress the diagnostic where you accept the gate, as narrowly as possible, and call it again from every `OnEnable` +(every `Attach`/`Detach` reverts to the conservative default): + +```csharp +#pragma warning disable CESDK5001 // Accepted: single-threaded worker pool, no concurrent Lua calls across threads. +CheatEngine.SDK.Lua.Runtime.LuaRuntime.AdmitWorkerThreads(); +#pragma warning restore CESDK5001 +``` + +or for a whole project: + +```xml + + $(NoWarn);CESDK5001 + +``` + +Never suppress it project-wide from an entry point that is not itself calling `AdmitWorkerThreads()`; keep the +suppression next to the call site so a reviewer sees exactly what accepted the gate. + +## What is still unqualified + +- Q19 at C4 (two SDK copies, both opted in, simultaneous first worker calls). +- The worker-side `synchronize` hand-off's heap safety under real concurrent Lua traffic (its admission itself is not + gated: it is the SDK's single documented default exception, not this opt-in). +- Multi-plugin concurrency in general (see the Hosting README, "Plugin identity and coexistence"). +- Whether two worker threads racing `AdmitWorkerThreads()`-opted-in calls at the same time observe a consistent Lua + stack: `LuaThreadAdmissionTests` proves only that both are refused by the *default*, not that both succeed safely + once opted in. + +## When the gate is removed + +A Q19 C3 receipt (exact host, one plugin) **and** a Q19 C4 receipt (exact host, two SDK copies) that both record a +successful, observed first worker Lua call retire this identifier; it is never reused. diff --git a/analyzers/docs/CESDK5010.md b/analyzers/docs/CESDK5010.md new file mode 100644 index 00000000..1aa1fc81 --- /dev/null +++ b/analyzers/docs/CESDK5010.md @@ -0,0 +1,70 @@ +# CESDK5010: Scan deadline and cooperative termination are experimental + +| | | +|--------------------|---------------------------------------------------------------------------------| +| Kind | `[Experimental]` API gate (reported by the C# compiler, not by an SDK analyzer) | +| Default severity | Error (compiler) | +| Enabled by default | Yes | +| Code fix | None | +| Reported | While typing and in build, at every use of a gated member | + +In short: these members call two Cheat Engine 7.7 behaviours that have not yet been observed on the pinned host. Opt in +only if you accept that, and handle every status they can return. + +## Cause + +Your code calls one of these `CheatEngine.SDK.Engine` members, which carry +`[Experimental("CESDK5010")]`: + +| Member | Relies on | +|------------------------------------------------------------------|-------------------------------------------------------------------| +| `MemoryScanSession.TryWaitForCompletion(TimeSpan)` | the `false` (timed-out) result of `MemScan.waitTillDone(timeout)` | +| `MemoryScanSession.TryTerminateScan(TimeSpan)` | `MemScan.terminateScan(false)` and the bounded wait that follows | +| `AobScanner.TryScanWithinBounds(..., TimeSpan waitTimeout, ...)` | both, when the call deadline expires before the scan completes | + +## Why it is experimental + +Cheat Engine 7.7.0.10621 documents an optional timeout for `MemScan.waitTillDone` that returns a boolean (`celua.txt` +line 2649) and a cooperative `MemScan.terminateScan` (line 2566). The C3 spike of 2026-09-22 (Lua-only, on the pinned +`ce-7.7.0.10621-x64-managed-hostfxr` profile, decision D4.7) observed `waitTillDone(timeout)` returning `true`, but could +not produce the `false` path: a whole-address-space scan finished before a one-millisecond deadline expired. It did not +exercise `terminateScan` at all. Everything these members do after a deadline expires is therefore covered by fixture +tests (C1) only, not by the host. + +What the SDK does guarantee, and tests at C1: it never forces termination (a forced stop can kill CE's scan thread and +open a modal dialog on CE's main thread), requests the cooperative stop at most once, never retries it, and reports an +unconfirmed stop instead of claiming one. CE's waits can run queued main-thread work that calls back into the session; +while one of these members is inside a CE call, the session refuses its other members and defers a release until that +call has returned, so it never destroys the scanner under its own wait. How long CE's stop and destroy really block, and +whether they behave as the fixture assumes, is exactly what the host has not shown yet. + +The bounded scan without a deadline, +`AobScanner.TryScanWithinBounds(string, AobScanBounds, AobScanOptions, Span
, CancellationToken)`, is not +gated: the spike settled its range semantics, completeness and cost. + +## How to opt in + +Suppress the diagnostic where you accept the gate, as narrowly as possible: + +```csharp +#pragma warning disable CESDK5010 // Deadline-bounded scan: every MemoryScanWaitStatus is handled below. +MemoryScanWaitStatus status = session.TryWaitForCompletion(TimeSpan.FromSeconds(2)); +#pragma warning restore CESDK5010 +``` + +or for a whole project: + +```xml + + $(NoWarn);CESDK5010 + +``` + +Handle `MemoryScanWaitStatus.TimedOut` (the scan may still be running: wait again, stop it, or release the session) and +every `MemoryScanTerminationStatus` other than `Confirmed` (the stop is unconfirmed: the session cannot be reset, only +released or abandoned). + +## When the gate is removed + +A Q29 C3 receipt on the pinned profile that records a deadline returning `TimedOut` and a termination returning +`Confirmed` retires this identifier; it is never reused. diff --git a/analyzers/docs/CESDK5011.md b/analyzers/docs/CESDK5011.md new file mode 100644 index 00000000..21bf73a8 --- /dev/null +++ b/analyzers/docs/CESDK5011.md @@ -0,0 +1,62 @@ +# CESDK5011: First-found AOB scan is experimental + +| | | +|--------------------|---------------------------------------------------------------------------------| +| Kind | `[Experimental]` API gate (reported by the C# compiler, not by an SDK analyzer) | +| Default severity | Error (compiler) | +| Enabled by default | Yes | +| Code fix | None | +| Reported | While typing and in build, at every use of the gated member | + +In short: this scan returns whichever match Cheat Engine happens to find first. It is not the only match, not the +lowest match, and not a proof that the pattern is unique. Use it only when any single match will do. + +## Cause + +Your code calls `AobScanner.TryFindFirstFoundWithinBounds(string, AobScanBounds, AobScanOptions, CancellationToken)`, +which carries `[Experimental("CESDK5011")]`. + +## Why it is experimental + +The member switches on the one-result mode of Cheat Engine's `MemScan` (`setOnlyOneResult(true)`) and reads the address +with `getOnlyResult()` (`celua.txt` lines 2656-2657). Cheat Engine documents the address as the first result found and +documents no order. The C3 spike of 2026-09-22 (Lua-only, on the pinned `ce-7.7.0.10621-x64-managed-hostfxr` profile, +decision D4.6) saw the lowest in-module address in three runs out of three, which is an observation, not a contract. It +did not observe the no-match path of `getOnlyResult`, nor the SDK's session sequence for this mode (a found list created +before the scan and never initialized). Two further semantics make the result easy to misuse: + +- the result is *some* match: using it for "require a single match", "first by address" or a range query silently + drops every other match (audit F07: an exhaustive scan must never be replaced by an arbitrary first result); +- Cheat Engine's start bound is not byte-exact, so a match that begins just before the range can be reported; the SDK + reports it as `AobFirstFoundOutcomeKind.FoundOutsideBounds`, which says nothing about whether an in-bounds match + exists. + +For exhaustive range and module scans, and for uniqueness, use the ungated +`AobScanner.TryScanWithinBounds(string, AobScanBounds, AobScanOptions, Span
, CancellationToken)`: uniqueness +needs an exhausted domain (`InBoundsCountIsExact`) or a second in-bounds match. + +## How to opt in + +Suppress the diagnostic where any single match is acceptable, as narrowly as possible: + +```csharp +#pragma warning disable CESDK5011 // Any match of this signature will do; uniqueness is not required. +AobFirstFoundResult first = AobScanner.TryFindFirstFoundWithinBounds(pattern, bounds, AobScanOptions.Default, + cancellationToken); +#pragma warning restore CESDK5011 +``` + +or for a whole project: + +```xml + + $(NoWarn);CESDK5011 + +``` + +Handle `Found`, `NotFound` and the indeterminate `FoundOutsideBounds` separately. + +## When the gate is removed + +A Q29 C3 receipt on the pinned profile that records this member's call sequence and its no-match path retires this +identifier; it is never reused. Even then the semantics stay "first found, order unspecified". diff --git a/analyzers/docs/CESDK7001.md b/analyzers/docs/CESDK7001.md new file mode 100644 index 00000000..bbac4460 --- /dev/null +++ b/analyzers/docs/CESDK7001.md @@ -0,0 +1,70 @@ +# CESDK7001: PointerSize.FromArchitecture is obsolete + +| | | +|--------------------|------------------------------------------------------------------------------| +| Kind | `[Obsolete]` diagnostic of the C# compiler, raised with this identifier | +| Obsoleted API | `CheatEngine.SDK.Engine.Runtime.PointerSize.FromArchitecture` | +| Default severity | Warning (an error under `TreatWarningsAsErrors`) | +| Obsoleted in | CheatEngine.SDK 2.0 | +| Removal | Not before the next major version after 2.0; the method body is unchanged | +| Replacement | `TargetArchitectureObservation.ConfiguredPointerSize` or `.Bitness` | + +In short: do not turn an architecture into a pointer size. Ask Cheat Engine for the fact you need: +`RuntimeProcessOperations.ObserveTargetArchitecture` returns the target bitness and CE's configured pointer size as two +separate facts. + +## Cause + +Code calls `PointerSize.FromArchitecture(CheatEngineArchitecture)`. + +## Why + +`FromArchitecture` returns the natural width of an instruction set: 4 bytes for x86 and ARM32, 8 bytes for x64 and +ARM64. Its name suggests more than that, and callers used it as the width of Cheat Engine's pointers. Cheat Engine keeps +three facts that this method silently merges: + +| Fact | Cheat Engine source | SDK member | +|--------------------------------|---------------------|--------------------------------------------------------------| +| Target ISA family | `targetIsX86`, `targetIsArm` | `TargetArchitectureObservation.IsX86Family`, `IsArmFamily`, `Architecture` | +| Target bitness | `targetIs64Bit` | `TargetArchitectureObservation.Bitness` | +| CE's configured pointer size | `getPointerSize` | `TargetArchitectureObservation.ConfiguredPointerSize`, `RuntimeInfo.PointerSize` | + +The configured pointer size is per-attachment state that `setPointerSize` sets to any integer. On Cheat Engine +7.7.0.10621 x64 with an x64 target, `setPointerSize(4)` made `getPointerSize()` return 4 while `targetIs64Bit()` stayed +true, and `readPointer` kept reading 8 bytes. `setPointerSize(2)` was accepted, and selecting the target again reset +the value. That observation is the spike C3 D3 design input (a Lua-only run on the pinned host, not a qualification of +SDK code). An architecture therefore determines neither the configured size nor the bitness, and the plugin's own +`IntPtr.Size` determines neither (audit F08, A07-03, A07-04). + +## The exact definition + +The compiler reports `CESDK7001` for every reference to `PointerSize.FromArchitecture` outside an +`#pragma warning disable CESDK7001` region, because the method carries +`[Obsolete(..., DiagnosticId = "CESDK7001")]`. The ordinary obsoletion warning `CS0618` is not reported for it, so a +`CS0618` suppression does not silence this one. + +## Fix + +Read the fact you need from Cheat Engine instead of deriving it: + +```csharp +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; + +ProcessOperationStatus status = RuntimeProcessOperations.ObserveTargetArchitecture( + out TargetArchitectureObservation target); +if (status.IsSuccess) +{ + PointerSize bitness = target.Bitness; // what CE's readPointer follows + PointerSize configured = target.ConfiguredPointerSize; // CE's getPointerSize, unknown unless 4 or 8 +} +``` + +`RuntimeProcessOperations.TryGetConfiguredPointerSize` reads only the configured size and keeps any raw integer. +`RuntimeObservations.TryObserveRuntimeInfo` produces a `RuntimeInfo` whose `PointerSize` is the configured size and +whose `Target.Bitness` is the bitness. + +## When to suppress + +Only in code that needs the natural instruction width of an architecture value it already holds, for example a test +that pins the 1.0.0 behaviour. Suppress it locally with `#pragma warning disable CESDK7001` and a comment saying why. diff --git a/analyzers/docs/CESDK9102.md b/analyzers/docs/CESDK9102.md new file mode 100644 index 00000000..48a98e07 --- /dev/null +++ b/analyzers/docs/CESDK9102.md @@ -0,0 +1,62 @@ +# CESDK9102: A CheatEngine.SDK plugin library sets PublishAot + +| | | +|--------------------|--------------------------------------------------------------------------| +| Kind | MSBuild warning of the packaged build target (not a Roslyn analyzer) | +| Reported by | Target `CheatEngineSdkWarnNativeAotPluginProfile`, `build/CheatEngine.SDK.targets` | +| Default severity | Warning | +| Applies to | Projects with a **direct** `PackageReference` to CheatEngine.SDK | +| Reported | Before `BeforeBuild`, on every build (not only on publish) | + +## Cause + +A project that references the `CheatEngine.SDK` package directly sets `PublishAot=true` and is not an executable +(`OutputType` is neither `Exe` nor `WinExe`), with or without `NativeLib=Shared` or `NativeLib=Static`: + +```xml + + true + Shared + +``` + +## Why + +A library published with NativeAOT is a native DLL. Cheat Engine would load it through its classic native plugin path, +and removes a plugin with `FreeLibrary`; .NET does not support unloading a NativeAOT library +([Native AOT libraries](https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries)). No residence model +exists for such a plugin in CheatEngine.SDK 2.0, so that profile is not supported (audit F02). + +CheatEngine.SDK plugins load through the **managed hostfxr profile**: a framework-dependent plugin folder (the plugin +assembly, the SDK assemblies, `*.deps.json`, `*.runtimeconfig.json` and the Lua protection bridge) that Cheat Engine's +.NET host loads and calls through the generated `CESDK.CESDK.CEPluginInitialize` entry point. `PublishAot` changes none +of that for the better and produces an artefact Cheat Engine cannot unload. + +"The shipping libraries are AOT-compatible" is a statement about trimming and AOT analysis of the libraries, verified by +a probe executable; it is not a promise that a NativeAOT plugin DLL can be loaded or removed. See the +[NativeAOT plugin profile](../../libs/CheatEngine.SDK.Abi/README.md) page. + +## What is checked + +- `'$(PublishAot)' == 'true'` and `'$(OutputType)'` is not `Exe` or `WinExe` (case-insensitive, like every MSBuild + condition). An executable that publishes with NativeAOT (a tool, a probe) is a different artefact and is not warned. +- Only direct package consumers: the target is a `build/` asset, which NuGet imports for a direct reference and never + for an indirect (transitive) one. +- The message names the project, the `NativeLib` value, and links here. + +## How to fix + +Remove `PublishAot` (and `NativeLib`) from the plugin project. Build or publish the plugin as a normal framework-dependent +class library. + +## When to suppress + +When the library is not a Cheat Engine plugin but still references CheatEngine.SDK directly, and you publish it with +NativeAOT for another host. In SDK-style projects `NoWarn` demotes this MSBuild warning to a message +([suppress tool warnings](https://learn.microsoft.com/visualstudio/ide/how-to-suppress-compiler-warnings#suppress-tool-warnings)): + +```xml + + $(NoWarn);CESDK9102 + +``` diff --git a/analyzers/docs/README.md b/analyzers/docs/README.md index cc9ee916..e3c92b6f 100644 --- a/analyzers/docs/README.md +++ b/analyzers/docs/README.md @@ -8,7 +8,9 @@ written for the plugin author who just saw the message: cause, reason, exact def Identifier ranges: `CESDK0xxx` plugin shape and bootstrap (category `CheatEngine.SDK.Plugin`), `CESDK1xxx` runtime-safety usage (`CheatEngine.SDK.Usage`), `CESDK2xxx` Lua generator input (`CheatEngine.SDK.Generation`). The separate EngineApi generator owns -`CESDK3xxx` for curated-spec grammar and generated-identity diagnostics. Identifiers are never renumbered or reused. +`CESDK3xxx` for curated-spec grammar and generated-identity diagnostics. `CESDK5xxx` marks compiler-enforced +`[Experimental]` API gates, held unqualified until their audit criteria pass. `CESDK7xxx` marks members obsoleted after +1.0.0 (`[Obsolete(DiagnosticId = …)]`). Identifiers are never renumbered or reused. | Id | Title | Severity | Code fix | |---------------------------|------------------------------------------------------------------|----------|----------------------------------------------------------------------------------------------| @@ -17,10 +19,12 @@ separate EngineApi generator owns | [CESDK0003](CESDK0003.md) | Manual Cheat Engine bootstrap is missing or malformed | Error | Add the exact `CESDK.CESDK.CEPluginInitialize(IntPtr, int)` contract | | [CESDK0004](CESDK0004.md) | Plugin assembly declares a namespace under 'CESDK' | Warning | None | | [CESDK0005](CESDK0005.md) | Source type collides with the generated Cheat Engine entry point | Error | Rename it, or explicitly own the complete manual bootstrap | +| [CESDK0006](CESDK0006.md) | Method exports a classic Cheat Engine native plugin entry point | Warning | None | | [CESDK1001](CESDK1001.md) | Plugin startup code calls an enabled-only API | Error | Move the call to `OnEnable` | | [CESDK1003](CESDK1003.md) | A Cheat Engine-owned value is being destroyed | Error | Keep it borrowed or use an explicit `Owned` transfer | | [CESDK1004](CESDK1004.md) | Exception can escape an [UnmanagedCallersOnly] method | Warning | Wrap the body in try/catch | | [CESDK1005](CESDK1005.md) | Plugin lifecycle callback must not be `async void` | Error | Keep `OnEnable`/`OnDisable` synchronous | +| [CESDK1020](CESDK1020.md) | PointerSize built from the plugin process width | Warning | None; read the target bitness or configured pointer size from Cheat Engine | | [CESDK2001](CESDK2001.md) | Lua binding needs AllowUnsafeBlocks | Error | None | | [CESDK2002](CESDK2002.md) | Type cannot receive a generated Lua binding part | Error | None | | [CESDK2003](CESDK2003.md) | [LuaFunction] method cannot be exported by a generated thunk | Error | None | @@ -28,6 +32,20 @@ separate EngineApi generator owns | [CESDK2005](CESDK2005.md) | Lua function name is duplicated | Error | Give one valid export a distinct Lua name | | [CESDK2006](CESDK2006.md) | Lua annotation target cannot receive generated code | Error | Declare the supported borrowed-handle/member shape | | [CESDK2007](CESDK2007.md) | User member collides with a generated Lua binding identity | Error | Rename the member or change the binding declaration | +| [CESDK2010](CESDK2010.md) | Optional Lua argument is not in a trailing run | Error | Declare optional arguments after the required ones | +| [CESDK2011](CESDK2011.md) | Optional or variadic Lua result shape is invalid | Error | Required, then optional results; one variadic pair last | +| [CESDK2012](CESDK2012.md) | Type impersonates an SDK Lua contract type | Error | Use the CheatEngine.SDK.Lua type | +| [CESDK2013](CESDK2013.md) | LuaOptional is not supported in this position | Error | Use an argument or an `out` result of a supported kind | +| [CESDK3001](CESDK3001.md) | Engine API specification is invalid | Error | Correct the reported spec line | +| [CESDK3002](CESDK3002.md) | Engine API specification has a generated-identity conflict | Error | Keep one spec file per generated type | +| [CESDK3003](CESDK3003.md) | Engine API specification does not declare the ce77 contract | Error | Add the `contract: ce77` header | +| [CESDK3004](CESDK3004.md) | Engine API optional argument is invalid | Error | Declare `opt:` arguments last | +| [CESDK3005](CESDK3005.md) | Engine API optional or variadic result is invalid | Error | `result:`, then `opt-result:`, then one `rest:` | +| [CESDK5001](CESDK5001.md) | Worker-thread Lua admission is experimental | Error (compiler) | None | +| [CESDK5010](CESDK5010.md) | Scan deadline and cooperative termination are experimental | Error (compiler) | None | +| [CESDK5011](CESDK5011.md) | First-found AOB scan is experimental | Error (compiler) | None | +| [CESDK7001](CESDK7001.md) | PointerSize.FromArchitecture is obsolete | Warning | None; use `TargetArchitectureObservation.ConfiguredPointerSize` or `Bitness` | +| [CESDK9102](CESDK9102.md) | A CheatEngine.SDK plugin library sets PublishAot (MSBuild, packaged build target) | Warning | None | Configure a rule like any other analyzer diagnostic: diff --git a/analyzers/docs/internal-lua-direct-api-boundary.md b/analyzers/docs/internal-lua-direct-api-boundary.md index 77734b78..d4845e8b 100644 --- a/analyzers/docs/internal-lua-direct-api-boundary.md +++ b/analyzers/docs/internal-lua-direct-api-boundary.md @@ -2,7 +2,7 @@ `LuaDirectApiBoundaryGuardTests` is a repository build gate, not a consumer-facing Roslyn diagnostic. It scans the SDK production layers for calls to raw `CheatEngine.SDK.Lua.Interop.Api.LuaApi` members classified in -[`protected-operations.json`](../../eng/lua-bridge/protected-operations.json) as requiring the native bridge. +[`protected-operations.json`](../../libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json) as requiring the native bridge. The test resolves direct member access, `using static` imports, and aliases of the exact `LuaApi` type. It reports the source file and line in its assertion message. The raw API facade itself remains exempt because it is the declaration diff --git a/eng/BannedSymbols.txt b/eng/BannedSymbols.txt index 6b575f65..ea8901ce 100644 --- a/eng/BannedSymbols.txt +++ b/eng/BannedSymbols.txt @@ -32,3 +32,10 @@ M:System.Activator.CreateInstance``1;No reflection-based activation: construct t M:System.Activator.CreateInstanceFrom(System.String,System.String);No reflection-based activation: construct through generated code or a static abstract factory. M:System.Activator.CreateInstanceFrom(System.String,System.String,System.Object[]);No reflection-based activation: construct through generated code or a static abstract factory. M:System.Activator.CreateInstanceFrom(System.String,System.String,System.Boolean,System.Reflection.BindingFlags,System.Reflection.Binder,System.Object[],System.Globalization.CultureInfo,System.Object[]);No reflection-based activation: construct through generated code or a static abstract factory. +// Reflection type discovery: enumerating or resolving types by name is not trim or AOT safe either. +M:System.Reflection.Assembly.GetTypes;No reflection-based type discovery: the plugin type is known at compile time and constructed by the generated bootstrap (audit A04-13). +M:System.Reflection.Assembly.GetExportedTypes;No reflection-based type discovery: the plugin type is known at compile time and constructed by the generated bootstrap (audit A04-13). +M:System.Reflection.Module.GetTypes;No reflection-based type discovery: the plugin type is known at compile time and constructed by the generated bootstrap (audit A04-13). +M:System.Type.GetType(System.String);No reflection-based type discovery: the plugin type is known at compile time and constructed by the generated bootstrap (audit A04-13). +M:System.Type.GetType(System.String,System.Boolean);No reflection-based type discovery: the plugin type is known at compile time and constructed by the generated bootstrap (audit A04-13). +M:System.Type.GetType(System.String,System.Boolean,System.Boolean);No reflection-based type discovery: the plugin type is known at compile time and constructed by the generated bootstrap (audit A04-13). diff --git a/eng/Shipping.props b/eng/Shipping.props index 151771f5..d7ff060d 100644 --- a/eng/Shipping.props +++ b/eng/Shipping.props @@ -25,4 +25,14 @@ + + + + + diff --git a/eng/Tests.props b/eng/Tests.props index b962f32b..2037556c 100644 --- a/eng/Tests.props +++ b/eng/Tests.props @@ -23,6 +23,9 @@ + + + diff --git a/eng/Validate-CeSurfaceCatalog.py b/eng/Validate-CeSurfaceCatalog.py deleted file mode 100644 index 8f3a0bcc..00000000 --- a/eng/Validate-CeSurfaceCatalog.py +++ /dev/null @@ -1,626 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the source-indexed Cheat Engine extension-surface catalogue offline.""" - -import argparse -import copy -import hashlib -import json -import sys -from pathlib import Path - - -CATALOG_DIRECTORY = Path("documentations/CheatEngine.SDK/catalog") -DECLARATIONS_FILE = "ce-7.7.0.10621-x64.declarations.json" -CAPABILITIES_FILE = "ce-7.7.0.10621-x64.capabilities.json" -CONFLICTS_FILE = "ce-7.7.0.10621-x64.conflicts.json" -HOST_PROFILES_FILE = "ce-7.7.0.10621-x64.host-profiles.json" -ADVANCED_FAMILIES_FILE = "ce-7.7.0.10621-x64.advanced-families.json" -EXPECTED_CATALOG_ID = "cheat-engine-extension-surface" -EXPECTED_CLASSIC_SOURCE_SHA256 = "B6500DF1E94D7BB011B38E173B2603197B7A1F304496D751EDE82E57E36E532F" -EXPECTED_CLASSIC_SLOT_MANIFEST_SHA256 = "AFACA989C7F117FC90D7D18CA30D8C8CB5049972ED91AC80132C209BA085BA5C" -EXPECTED_CLASSIC_SLOT_GROUPS = { - "direct-prefix-v1": 18, - "hookable-pointer-indirect-suffix": 64, - "borrowed-delphi-objects": 2, - "extension-v2": 3, - "extension-v3": 8, - "extension-v4": 60, - "extension-v5": 4, -} -EXPECTED_CALLBACK_IDS = { - "classic.callback.address-list", - "classic.callback.memory-view", - "classic.callback.debug-event", - "classic.callback.process-watcher", - "classic.callback.function-pointer-change", - "classic.callback.main-menu", - "classic.callback.disassembler-context-click", - "classic.callback.disassembler-context-popup", - "classic.callback.disassembler-render-line", - "classic.callback.auto-assembler", -} -EXPECTED_ADVANCED_FAMILY_IDS = { - "advanced.auto-assembler", - "advanced.dbvm", - "advanced.debugger", - "advanced.hashing", - "advanced.hotkeys", - "advanced.il2cpp", - "advanced.mono", - "advanced.remote-execution-injection", - "advanced.speedhack", - "advanced.structures", - "advanced.timers", - "advanced.ui-forms", -} -REQUIRED_CAPABILITY_FIELDS = { - "id", - "layer", - "symbol", - "declaration_refs", - "interop", - "thread_affinity", - "ownership", - "failure_shape", - "availability", - "qualification", - "profile_ids", - "conflict_ids", -} -REQUIRED_INTEROP_FIELDS = {"calling_convention", "parameter_widths", "result", "indirection"} -REQUIRED_OWNERSHIP_FIELDS = {"registration", "callback", "arguments"} -REQUIRED_CONFLICT_FIELDS = ( - "c_declaration", - "pascal_declaration", - "evidence", - "resolution", - "required_availability", - "blocks_live_qualification", -) -REQUIRED_ADVANCED_FAMILY_FIELDS = { - "id", - "title", - "owner", - "scope", - "host_prerequisites", - "privilege_requirements", - "inputs_results_cleanup", - "failure_modes", - "evidence_gap", - "source_status", - "source_refs", - "dependencies", - "support_axes", - "qualification_gates", - "adoption_decision", - "availability", - "qualification", - "profile_ids", -} -REQUIRED_ADVANCED_OWNER_FIELDS = {"sdk", "client"} -REQUIRED_ADVANCED_SCOPE_FIELDS = {"authorization", "target_scope", "policy"} -REQUIRED_ADVANCED_INPUT_RESULT_CLEANUP_FIELDS = {"inputs", "result", "cleanup"} -REQUIRED_ADVANCED_SUPPORT_AXES = { - "implementation", - "artifact", - "host", - "live_qualification", - "policy", - "lifecycle_cleanup", -} -REQUIRED_ADVANCED_QUALIFICATION_GATES = {"fixture", "live", "negative", "cleanup"} -ALLOWED_AVAILABILITY = { - "catalogued-only", - "fixture-only", - "implemented-with-known-contract-gap", - "mapped", - "not-a-support-claim", - "opaque", - "planned", - "source-only", - "unavailable", -} -ALLOWED_QUALIFICATION = { - "build-only", - "fixture-qualified", - "not-executed", - "not-qualified", - "source-indexed-only", - "unqualified", -} -ALLOWED_UNRESOLVED_CONFLICT_AVAILABILITY = {"opaque", "unavailable"} -ALLOWED_ADVANCED_AXIS_STATES = { - "contract-not-approved", - "not-identified", - "not-observed", - "not-qualified", - "explicit-opt-in-required", -} -ALLOWED_ADVANCED_FAMILY_AVAILABILITY = {"unavailable"} - - -def load_catalog(root: Path) -> dict[str, object]: - """Load the committed documents, returning an independent mutable graph for tests.""" - directory = root / CATALOG_DIRECTORY - result: dict[str, object] = {} - for file_name in (DECLARATIONS_FILE, CAPABILITIES_FILE, CONFLICTS_FILE, HOST_PROFILES_FILE, ADVANCED_FAMILIES_FILE): - path = directory / file_name - try: - result[file_name] = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - result[file_name] = {"_missing_file": str(path)} - except json.JSONDecodeError as error: - result[file_name] = {"_invalid_json": f"{path}: {error}"} - return copy.deepcopy(result) - - -def validate_catalog(catalog: dict[str, object], repository_root: Path) -> list[str]: - """Return all deterministic validation errors; never contact a host or a network endpoint.""" - errors: list[str] = [] - declarations = _document(catalog, DECLARATIONS_FILE, errors) - capabilities_document = _document(catalog, CAPABILITIES_FILE, errors) - conflicts_document = _document(catalog, CONFLICTS_FILE, errors) - profiles_document = _document(catalog, HOST_PROFILES_FILE, errors) - advanced_families_document = _document(catalog, ADVANCED_FAMILIES_FILE, errors) - if errors: - return errors - - for name, document in ( - (DECLARATIONS_FILE, declarations), - (CAPABILITIES_FILE, capabilities_document), - (CONFLICTS_FILE, conflicts_document), - (HOST_PROFILES_FILE, profiles_document), - (ADVANCED_FAMILIES_FILE, advanced_families_document), - ): - if document.get("schema_version") != 1: - errors.append(f"{name}: schema_version must be 1.") - if document.get("catalog_id") != EXPECTED_CATALOG_ID: - errors.append(f"{name}: catalog_id must be {EXPECTED_CATALOG_ID!r}.") - - _validate_declarations(declarations, errors) - profiles = _validate_profiles(profiles_document, errors) - capabilities = _validate_capabilities(capabilities_document, profiles, repository_root, errors) - conflicts, conflict_states = _validate_conflicts(conflicts_document, capabilities, declarations, errors) - _validate_conflicted_capabilities(capabilities, conflicts, conflict_states, errors) - _validate_examples(capabilities_document, capabilities, errors) - _validate_advanced_families(advanced_families_document, repository_root, errors) - return errors - - -def _document(catalog: dict[str, object], name: str, errors: list[str]) -> dict[str, object]: - document = catalog.get(name) - if not isinstance(document, dict): - errors.append(f"{name}: document is missing or is not an object.") - return {} - missing = document.get("_missing_file") - invalid = document.get("_invalid_json") - if isinstance(missing, str): - errors.append(f"{name}: required file is missing ({missing}).") - return {} - if isinstance(invalid, str): - errors.append(f"{name}: invalid JSON ({invalid}).") - return {} - if not document: - errors.append(f"{name}: document must be a non-empty object.") - return {} - return document - - -def _validate_declarations(document: dict[str, object], errors: list[str]) -> None: - sources = document.get("sources") - if not isinstance(sources, list) or len(sources) != 1 or not isinstance(sources[0], dict): - errors.append("declarations: exactly one pinned classic source is required.") - else: - source = sources[0] - if source.get("sha256") != EXPECTED_CLASSIC_SOURCE_SHA256: - errors.append("declarations: cepluginsdk.h SHA-256 does not match the reviewed pinned source.") - if source.get("revision") != "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37": - errors.append("declarations: classic source revision changed without an explicit catalogue update.") - - exported = document.get("classic_exported_functions") - if not isinstance(exported, dict): - errors.append("declarations: classic_exported_functions is required.") - return - slots = exported.get("slots") - if not isinstance(slots, list): - errors.append("declarations: slots must be an array.") - return - if exported.get("slot_count") != 159 or len(slots) != 159: - errors.append("declarations: the classic ExportedFunctions table must contain exactly 159 slots.") - - group_counts: dict[str, int] = {} - slot_names: set[str] = set() - manifest: list[dict[str, object]] = [] - for index, slot in enumerate(slots): - if not isinstance(slot, dict): - errors.append(f"declarations: slot {index} is not an object.") - continue - symbol = slot.get("symbol") - source = slot.get("source") - declaration = slot.get("declaration") - group = slot.get("group") - if not isinstance(symbol, str) or not symbol: - errors.append(f"declarations: slot {index} has no symbol.") - continue - if symbol in slot_names: - errors.append(f"declarations: duplicate classic slot {symbol!r}.") - slot_names.add(symbol) - if not isinstance(source, dict) or source.get("source_id") != "ce-classic-c-header" or not _valid_locator(source): - errors.append(f"declarations: slot {symbol!r} has no valid pinned source locator.") - if not isinstance(declaration, str) or not declaration.endswith(";"): - errors.append(f"declarations: slot {symbol!r} has no declaration text.") - if group not in EXPECTED_CLASSIC_SLOT_GROUPS: - errors.append(f"declarations: slot {symbol!r} has invalid group {group!r}.") - else: - group_counts[group] = group_counts.get(group, 0) + 1 - if slot.get("slot_width_bits_x64") not in (32, 64): - errors.append(f"declarations: slot {symbol!r} has an invalid x64 slot width.") - if not isinstance(slot.get("indirection"), str) or not isinstance(slot.get("owner"), str): - errors.append(f"declarations: slot {symbol!r} lacks indirection or owner metadata.") - if isinstance(source, dict) and isinstance(declaration, str): - manifest.append({"symbol": symbol, "line": source.get("line_start"), "declaration": declaration}) - - if group_counts != EXPECTED_CLASSIC_SLOT_GROUPS: - errors.append(f"declarations: classic slot groups differ from {EXPECTED_CLASSIC_SLOT_GROUPS!r}.") - digest = hashlib.sha256(json.dumps(manifest, separators=(",", ":"), ensure_ascii=True).encode("utf-8")).hexdigest().upper() - if exported.get("slot_manifest_sha256") != EXPECTED_CLASSIC_SLOT_MANIFEST_SHA256 or digest != EXPECTED_CLASSIC_SLOT_MANIFEST_SHA256: - errors.append("declarations: classic slot manifest no longer matches the reviewed 159-slot baseline.") - required_slots = {"ReadProcessMemory", "GetAddressFromPointer", "GetLuaState", "MainThreadCall", "FixMem"} - missing_slots = required_slots - slot_names - if missing_slots: - errors.append(f"declarations: required classic slots are absent: {sorted(missing_slots)!r}.") - hook_slots = [slot for slot in slots if isinstance(slot, dict) and slot.get("group") == "hookable-pointer-indirect-suffix"] - if any(slot.get("indirection") != "pointer-to-function-pointer-slot" for slot in hook_slots): - errors.append("declarations: every hookable classic slot must remain pointer-to-function-pointer-slot.") - - -def _validate_profiles(document: dict[str, object], errors: list[str]) -> dict[str, dict[str, object]]: - raw_profiles = document.get("profiles") - profiles: dict[str, dict[str, object]] = {} - if not isinstance(raw_profiles, list): - errors.append("host profiles: profiles must be an array.") - return profiles - for profile in raw_profiles: - if not isinstance(profile, dict) or not isinstance(profile.get("id"), str): - errors.append("host profiles: every profile needs an id.") - continue - identifier = profile["id"] - if identifier in profiles: - errors.append(f"host profiles: duplicate profile id {identifier!r}.") - continue - profiles[identifier] = profile - host = profile.get("host") - target = profile.get("target") - if not isinstance(host, dict) or host.get("architecture") not in {"x64"}: - errors.append(f"host profiles: {identifier!r} must explicitly state the x64 host architecture.") - if not isinstance(target, dict): - errors.append(f"host profiles: {identifier!r} needs a distinct target object.") - continue - if target.get("architecture") == "same-as-host" or target.get("pointer_width_bits") == "same-as-host": - errors.append(f"host profiles: {identifier!r} illegally infers target facts from the host.") - if "architecture" not in target or "pointer_width_bits" not in target: - errors.append(f"host profiles: {identifier!r} must state target architecture and pointer-width observation state.") - return profiles - - -def _validate_capabilities(document: dict[str, object], profiles: dict[str, dict[str, object]], repository_root: Path, - errors: list[str]) -> dict[str, dict[str, object]]: - raw_capabilities = document.get("capabilities") - capabilities: dict[str, dict[str, object]] = {} - if not isinstance(raw_capabilities, list): - errors.append("capabilities: capabilities must be an array.") - return capabilities - for capability in raw_capabilities: - if not isinstance(capability, dict): - errors.append("capabilities: each entry must be an object.") - continue - identifier = capability.get("id") - if not isinstance(identifier, str) or not identifier: - errors.append("capabilities: each entry needs a non-empty id.") - continue - if identifier in capabilities: - errors.append(f"capabilities: duplicate id {identifier!r}.") - continue - capabilities[identifier] = capability - absent = REQUIRED_CAPABILITY_FIELDS - capability.keys() - if absent: - errors.append(f"capabilities: {identifier!r} is missing required fields {sorted(absent)!r}.") - interop = capability.get("interop") - if not isinstance(interop, dict) or REQUIRED_INTEROP_FIELDS - interop.keys(): - errors.append(f"capabilities: {identifier!r} has incomplete interop metadata.") - ownership = capability.get("ownership") - if not isinstance(ownership, dict) or REQUIRED_OWNERSHIP_FIELDS - ownership.keys(): - errors.append(f"capabilities: {identifier!r} has incomplete ownership metadata.") - if capability.get("availability") not in ALLOWED_AVAILABILITY: - errors.append(f"capabilities: {identifier!r} has unsupported availability state.") - if capability.get("qualification") not in ALLOWED_QUALIFICATION: - errors.append(f"capabilities: {identifier!r} has unsupported qualification state.") - references = capability.get("declaration_refs") - if not isinstance(references, list) or not references: - errors.append(f"capabilities: {identifier!r} needs at least one declaration reference.") - else: - for reference in references: - if not isinstance(reference, dict) or not _valid_locator(reference): - errors.append(f"capabilities: {identifier!r} has an invalid declaration locator.") - continue - path = reference.get("path") - if isinstance(path, str) and not path.startswith("Cheat Engine/") and not (repository_root / path).is_file(): - errors.append(f"capabilities: {identifier!r} references missing SDK source {path!r}.") - profile_ids = capability.get("profile_ids") - if not isinstance(profile_ids, list): - errors.append(f"capabilities: {identifier!r} profile_ids must be an array.") - continue - for profile_id in profile_ids: - if profile_id not in profiles: - errors.append(f"capabilities: {identifier!r} references unknown profile {profile_id!r}.") - if capability.get("qualification") == "live-qualified": - errors.append(f"capabilities: {identifier!r} cannot claim live qualification without a reviewed capture profile.") - if capability.get("availability") == "planned" and profile_ids: - errors.append(f"capabilities: planned {identifier!r} must not claim a host profile.") - if capability.get("layer") in {"memory-scan", "target-memory"} and capability.get("availability") == "mapped": - for profile_id in profile_ids: - target = profiles[profile_id].get("target") - if isinstance(target, dict) and target.get("architecture") not in {"x64", "x86", "arm64", "arm32"}: - errors.append(f"capabilities: target-dependent {identifier!r} cannot be mapped against an unobserved target profile.") - - callback_ids = {identifier for identifier, item in capabilities.items() if item.get("layer") == "classic-callback"} - if callback_ids != EXPECTED_CALLBACK_IDS: - errors.append("capabilities: exactly the nine PluginType families and ten callback slots must be catalogued.") - return capabilities - - -def _validate_conflicts(document: dict[str, object], capabilities: dict[str, dict[str, object]], - declarations: dict[str, object], errors: list[str]) -> tuple[dict[str, dict[str, object]], dict[str, str]]: - raw_conflicts = document.get("conflicts") - conflicts: dict[str, dict[str, object]] = {} - states: dict[str, str] = {} - if not isinstance(raw_conflicts, list): - errors.append("conflicts: conflicts must be an array.") - return conflicts, states - for conflict in raw_conflicts: - if not isinstance(conflict, dict) or not isinstance(conflict.get("id"), str): - errors.append("conflicts: every entry needs an id.") - continue - identifier = conflict["id"] - if identifier in conflicts: - errors.append(f"conflicts: duplicate id {identifier!r}.") - continue - conflicts[identifier] = conflict - for required in REQUIRED_CONFLICT_FIELDS: - if required not in conflict: - errors.append(f"conflicts: {identifier!r} is missing {required!r}.") - states[identifier] = _conflict_resolution_state(conflict, identifier, errors) - - evidence = conflict.get("evidence") - if not isinstance(evidence, list) or not evidence: - errors.append(f"conflicts: {identifier!r} needs at least one source locator.") - elif any(not isinstance(locator, dict) or not _valid_locator(locator) for locator in evidence): - errors.append(f"conflicts: {identifier!r} has an invalid source locator.") - - required_availability = conflict.get("required_availability") - if not isinstance(required_availability, str) or required_availability not in ALLOWED_UNRESOLVED_CONFLICT_AVAILABILITY: - errors.append(f"conflicts: {identifier!r} requires an opaque or unavailable availability state.") - if states[identifier] == "unresolved" and conflict.get("blocks_live_qualification") is not True: - errors.append(f"conflicts: unresolved {identifier!r} must block live qualification.") - - affected = conflict.get("affected_capability_ids") - if not isinstance(affected, list): - errors.append(f"conflicts: {identifier!r} affected_capability_ids must be an array.") - continue - for capability_id in affected: - if not isinstance(capability_id, str) or not capability_id: - errors.append(f"conflicts: {identifier!r} affected_capability_ids must contain non-empty strings.") - continue - if capability_id not in capabilities: - errors.append(f"conflicts: {identifier!r} references unknown capability {capability_id!r}.") - continue - capability_conflicts = capabilities[capability_id].get("conflict_ids") - if not isinstance(capability_conflicts, list) or identifier not in capability_conflicts: - errors.append(f"conflicts: {identifier!r} is not reciprocated by capability {capability_id!r}.") - - if identifier == "classic.fixmem-null-host-slot": - _validate_fixmem_conflict_locator(conflict, declarations, errors) - required_conflicts = { - "classic.type0-address-width", - "classic.type3-return-and-width", - "classic.type4-return-shape", - "classic.type6-popup-bool-pointer-width", - "classic.get-address-from-pointer-return-width", - "classic.fixmem-null-host-slot", - } - absent = required_conflicts - conflicts.keys() - if absent: - errors.append(f"conflicts: mandatory historical conflicts are missing: {sorted(absent)!r}.") - return conflicts, states - - -def _conflict_resolution_state(conflict: dict[str, object], identifier: str, errors: list[str]) -> str: - """Return an unresolved-safe state while validating structured conflict resolution metadata.""" - resolution = conflict.get("resolution") - if isinstance(resolution, str): - if resolution.strip(): - return "unresolved" - errors.append(f"conflicts: {identifier!r} needs a non-empty resolution summary.") - return "unresolved" - if not isinstance(resolution, dict) or set(resolution) != {"state", "summary"}: - errors.append(f"conflicts: {identifier!r} resolution must be legacy prose or a state/summary object.") - return "unresolved" - state = resolution.get("state") - summary = resolution.get("summary") - if not isinstance(state, str) or state != "unresolved" or not isinstance(summary, str) or not summary.strip(): - errors.append(f"conflicts: {identifier!r} resolution must declare the unresolved state and a non-empty summary.") - return "unresolved" - - -def _validate_fixmem_conflict_locator(conflict: dict[str, object], declarations: dict[str, object], errors: list[str]) -> None: - """Require the FixMem conflict's C-header evidence to match its canonical slot locator.""" - expected = _classic_slot_locator(declarations, "FixMem") - evidence = conflict.get("evidence") - if expected is None or not isinstance(evidence, list) or not any( - isinstance(locator, dict) and _same_locator(locator, expected) for locator in evidence): - errors.append("conflicts: FixMem C-header evidence must match the FixMem declaration locator.") - - -def _classic_slot_locator(declarations: dict[str, object], symbol: str) -> dict[str, object] | None: - """Find the canonical locator for one named classic export slot.""" - exported = declarations.get("classic_exported_functions") - if not isinstance(exported, dict): - return None - slots = exported.get("slots") - if not isinstance(slots, list): - return None - for slot in slots: - if isinstance(slot, dict) and slot.get("symbol") == symbol and isinstance(slot.get("source"), dict): - return slot["source"] - return None - - -def _same_locator(left: dict[str, object], right: dict[str, object]) -> bool: - """Compare the source identity and inclusive range of two source locators.""" - return all(left.get(field) == right.get(field) for field in ("source_id", "path", "line_start", "line_end")) - - -def _validate_conflicted_capabilities(capabilities: dict[str, dict[str, object]], conflicts: dict[str, dict[str, object]], - conflict_states: dict[str, str], errors: list[str]) -> None: - for capability_id, capability in capabilities.items(): - conflict_ids = capability.get("conflict_ids") - if not isinstance(conflict_ids, list): - continue - for conflict_id in conflict_ids: - conflict = conflicts.get(conflict_id) - if conflict is None: - errors.append(f"capabilities: {capability_id!r} references unknown conflict {conflict_id!r}.") - continue - affected_capability_ids = conflict.get("affected_capability_ids") - if isinstance(affected_capability_ids, list) and capability_id not in affected_capability_ids: - errors.append(f"capabilities: {capability_id!r} is not listed by conflict {conflict_id!r}.") - if conflict_states.get(conflict_id) == "unresolved" and capability.get("availability") != conflict.get("required_availability"): - errors.append(f"capabilities: unresolved conflict {conflict_id!r} requires {conflict.get('required_availability')!r} availability for {capability_id!r}.") - if conflict_states.get(conflict_id) == "unresolved" and capability.get("qualification") == "live-qualified": - errors.append(f"capabilities: {capability_id!r} cannot be live-qualified while {conflict_id!r} is unresolved.") - - -def _validate_examples(document: dict[str, object], capabilities: dict[str, dict[str, object]], errors: list[str]) -> None: - examples = document.get("examples") - if not isinstance(examples, list) or not examples: - errors.append("capabilities: at least one source-indexed example is required.") - return - for example in examples: - if not isinstance(example, dict) or example.get("capability_id") not in capabilities: - errors.append("capabilities: every example must reference a known capability.") - - -def _validate_advanced_families(document: dict[str, object], repository_root: Path, errors: list[str]) -> None: - families = document.get("families") - if not isinstance(families, list): - errors.append("advanced families: families must be an array.") - return - - identifiers: set[str] = set() - for family in families: - if not isinstance(family, dict): - errors.append("advanced families: every family must be an object.") - continue - identifier = family.get("id") - if not isinstance(identifier, str) or not identifier: - errors.append("advanced families: every family needs a non-empty id.") - continue - if identifier in identifiers: - errors.append(f"advanced families: duplicate id {identifier!r}.") - identifiers.add(identifier) - absent = REQUIRED_ADVANCED_FAMILY_FIELDS - family.keys() - if absent: - errors.append(f"advanced families: {identifier!r} is missing required fields {sorted(absent)!r}.") - - owner = family.get("owner") - if not isinstance(owner, dict) or REQUIRED_ADVANCED_OWNER_FIELDS - owner.keys() or any( - not isinstance(value, str) or not value for value in owner.values()): - errors.append(f"advanced families: {identifier!r} needs non-empty SDK and Client ownership statements.") - scope = family.get("scope") - if not isinstance(scope, dict) or REQUIRED_ADVANCED_SCOPE_FIELDS - scope.keys(): - errors.append(f"advanced families: {identifier!r} has incomplete scope metadata.") - elif scope.get("authorization") != "local-authorized-process-only" or scope.get("policy") != "explicit-opt-in-required": - errors.append(f"advanced families: {identifier!r} must preserve local authorization and explicit policy opt-in.") - - for field in ("host_prerequisites", "privilege_requirements", "failure_modes"): - value = family.get(field) - if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item for item in value): - errors.append(f"advanced families: {identifier!r} needs a non-empty {field} list.") - inputs_results_cleanup = family.get("inputs_results_cleanup") - if not isinstance(inputs_results_cleanup, dict) or REQUIRED_ADVANCED_INPUT_RESULT_CLEANUP_FIELDS - inputs_results_cleanup.keys() or any( - not isinstance(value, str) or not value for value in inputs_results_cleanup.values()): - errors.append(f"advanced families: {identifier!r} needs inputs, result, and cleanup boundaries.") - - if not isinstance(family.get("evidence_gap"), str) or not family.get("evidence_gap"): - errors.append(f"advanced families: {identifier!r} needs an explicit evidence gap.") - source_status = family.get("source_status") - references = family.get("source_refs") - if source_status not in {"pinned-call-path-located", "pinned-call-path-not-located"}: - errors.append(f"advanced families: {identifier!r} has an invalid source status.") - if not isinstance(references, list): - errors.append(f"advanced families: {identifier!r} source_refs must be an array.") - elif source_status == "pinned-call-path-located" and not references: - errors.append(f"advanced families: {identifier!r} must retain a pinned source locator.") - elif source_status == "pinned-call-path-not-located" and references: - errors.append(f"advanced families: {identifier!r} cannot attach a locator it says was not located.") - for reference in references if isinstance(references, list) else []: - if not isinstance(reference, dict) or not _valid_locator(reference): - errors.append(f"advanced families: {identifier!r} has an invalid source locator.") - continue - path = reference.get("path") - if isinstance(path, str) and not path.startswith("Cheat Engine/") and not (repository_root / path).is_file(): - errors.append(f"advanced families: {identifier!r} references missing SDK source {path!r}.") - - dependencies = family.get("dependencies") - if not isinstance(dependencies, list) or any(not isinstance(item, str) or not item for item in dependencies): - errors.append(f"advanced families: {identifier!r} dependencies must be a string array.") - axes = family.get("support_axes") - if not isinstance(axes, dict) or set(axes) != REQUIRED_ADVANCED_SUPPORT_AXES: - errors.append(f"advanced families: {identifier!r} must retain all independent support axes.") - else: - for axis, value in axes.items(): - if not isinstance(value, dict) or not isinstance(value.get("state"), str) or not isinstance(value.get("requirement"), str) or not value["requirement"]: - errors.append(f"advanced families: {identifier!r} axis {axis!r} needs a state and requirement.") - elif value["state"] not in ALLOWED_ADVANCED_AXIS_STATES: - errors.append(f"advanced families: {identifier!r} cannot satisfy {axis!r} before independent review.") - gates = family.get("qualification_gates") - if not isinstance(gates, dict) or set(gates) != REQUIRED_ADVANCED_QUALIFICATION_GATES or any( - not isinstance(value, list) or not value or any(not isinstance(item, str) or not item for item in value) - for value in gates.values()): - errors.append(f"advanced families: {identifier!r} needs non-empty fixture, live, negative, and cleanup gates.") - decision = family.get("adoption_decision") - if not isinstance(decision, dict) or decision.get("state") != "deferred" or decision.get("implementation_issue") != "not-created" or not isinstance(decision.get("reason"), str) or not decision["reason"]: - errors.append(f"advanced families: {identifier!r} needs its own deferred adoption decision.") - availability = family.get("availability") - if not isinstance(availability, str) or availability not in ALLOWED_ADVANCED_FAMILY_AVAILABILITY or family.get("qualification") != "not-qualified" or family.get("profile_ids") != []: - errors.append(f"advanced families: {identifier!r} remains unavailable and unqualified without a profile.") - - if identifiers != EXPECTED_ADVANCED_FAMILY_IDS: - errors.append("advanced families: the independently gated family set does not match the reviewed SDK-020 partition.") - - -def _valid_locator(locator: dict[str, object]) -> bool: - source_id = locator.get("source_id") - path = locator.get("path") - line_start = locator.get("line_start") - line_end = locator.get("line_end") - return isinstance(source_id, str) and bool(source_id.strip()) and isinstance(path, str) and bool(path.strip()) and type(line_start) is int and type(line_end) is int and line_start > 0 and line_end >= line_start - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path.cwd(), help="Repository root containing the catalogue.") - arguments = parser.parse_args() - root = arguments.root.resolve() - errors = validate_catalog(load_catalog(root), root) - if errors: - print("CE extension-surface catalogue validation failed:", file=sys.stderr) - for error in errors: - print(f"- {error}", file=sys.stderr) - return 1 - print("CE extension-surface catalogue validation passed.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/eng/Validate-EngineeringManifest.py b/eng/Validate-EngineeringManifest.py deleted file mode 100644 index 6c890341..00000000 --- a/eng/Validate-EngineeringManifest.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Validate the checked-in engineering roadmap without contacting GitHub.""" - -from __future__ import annotations - -import json -import re -import sys -from collections.abc import Iterable -from pathlib import Path -from typing import Any - - -MANIFEST_RELATIVE_PATH = Path("documentations/engineering/backlog.json") -ROADMAP_RELATIVE_PATH = Path("ROADMAP.md") -ARCHIVE_RECONCILIATION_RELATIVE_PATH = Path( - "documentations/engineering/ARCHIVE_RECONCILIATION.md" -) -EXPECTED_SCHEMA_VERSION = 2 -EXPECTED_ARCHIVE_SHA256 = "fc1f178916ec14fb993e405018112ddedd8dc316a86aa6fe9251b42c09547802" -REQUIRED_ITEM_FIELDS = ( - "id", - "repository_key", - "kind", - "title", - "parent", - "children", - "blocked_by", - "blocks", - "acceptance", - "sources", - "path", - "body_template", -) - - -def read_manifest(repository_root: Path) -> object: - """Read the versioned manifest and report malformed JSON as a validation error.""" - manifest_path = repository_root / MANIFEST_RELATIVE_PATH - try: - return json.loads(manifest_path.read_text(encoding="utf-8")) - except FileNotFoundError as error: - raise ValueError(f"Missing manifest: {MANIFEST_RELATIVE_PATH.as_posix()}") from error - except json.JSONDecodeError as error: - raise ValueError(f"Invalid manifest JSON: {error}") from error - - -def validate_manifest(manifest: object, repository_root: Path) -> list[str]: - """Return every deterministic manifest error; this verifier intentionally stays offline.""" - if not isinstance(manifest, dict): - return ["Manifest root must be an object."] - - errors: list[str] = [] - if manifest.get("schema_version") != EXPECTED_SCHEMA_VERSION: - errors.append( - f"schema_version must be {EXPECTED_SCHEMA_VERSION}, got {manifest.get('schema_version')!r}." - ) - - items = manifest.get("items") - if not isinstance(items, list) or not items: - return [*errors, "items must be a non-empty array."] - - item_by_id = collect_items(items, errors) - external_reference_ids = collect_external_references(manifest, errors) - validate_distinct_reference_ids(item_by_id, external_reference_ids, errors) - validate_archive_context(manifest, repository_root, errors) - validate_items(item_by_id, external_reference_ids, repository_root, errors) - validate_dependency_graph(item_by_id, external_reference_ids, errors) - validate_roadmap_and_reconciliation(item_by_id, repository_root, errors) - return errors - - -def collect_items(items: Iterable[Any], errors: list[str]) -> dict[str, dict[str, Any]]: - item_by_id: dict[str, dict[str, Any]] = {} - for index, item in enumerate(items): - if not isinstance(item, dict): - errors.append(f"items[{index}] must be an object.") - continue - - identifier = item.get("id") - if not isinstance(identifier, str) or not identifier: - errors.append(f"items[{index}].id must be a non-empty string.") - continue - if identifier in item_by_id: - errors.append(f"Duplicate planning ID: {identifier}.") - continue - item_by_id[identifier] = item - return item_by_id - - -def collect_external_references(manifest: dict[str, Any], errors: list[str]) -> set[str]: - references = manifest.get("external_references") - if not isinstance(references, list): - errors.append("external_references must be an array.") - return set() - - identifiers: set[str] = set() - for index, reference in enumerate(references): - if not isinstance(reference, dict): - errors.append(f"external_references[{index}] must be an object.") - continue - identifier = reference.get("id") - repository = reference.get("repository") - role = reference.get("role") - if not isinstance(identifier, str) or not identifier: - errors.append(f"external_references[{index}].id must be a non-empty string.") - continue - if identifier in identifiers: - errors.append(f"Duplicate external planning ID: {identifier}.") - identifiers.add(identifier) - if repository != "CheatEngineNet/CheatEngine.Client": - errors.append( - f"External reference {identifier} must identify CheatEngineNet/CheatEngine.Client." - ) - if not isinstance(role, str) or not role: - errors.append(f"External reference {identifier} must have a non-empty role.") - return identifiers - - -def validate_distinct_reference_ids( - item_by_id: dict[str, dict[str, Any]], - external_reference_ids: set[str], - errors: list[str], -) -> None: - for identifier in sorted(item_by_id.keys() & external_reference_ids): - errors.append(f"Planning ID {identifier} cannot be both local and external.") - - -def validate_archive_context( - manifest: dict[str, Any], repository_root: Path, errors: list[str] -) -> None: - review_context = manifest.get("review_context") - if not isinstance(review_context, dict): - errors.append("review_context must be an object.") - return - - archive = review_context.get("archive") - if not isinstance(archive, dict): - errors.append("review_context.archive must be an object.") - elif archive.get("sha256") != EXPECTED_ARCHIVE_SHA256: - errors.append("review_context.archive.sha256 does not match the reviewed package receipt.") - - reconciliation = review_context.get("reconciliation_document") - if reconciliation != ARCHIVE_RECONCILIATION_RELATIVE_PATH.as_posix(): - errors.append("review_context.reconciliation_document must point to ARCHIVE_RECONCILIATION.md.") - elif not (repository_root / reconciliation).is_file(): - errors.append(f"Missing reconciliation document: {reconciliation}.") - - -def validate_items( - item_by_id: dict[str, dict[str, Any]], - external_reference_ids: set[str], - repository_root: Path, - errors: list[str], -) -> None: - for identifier, item in item_by_id.items(): - for field in REQUIRED_ITEM_FIELDS: - if field not in item: - errors.append(f"{identifier} is missing required field '{field}'.") - - if item.get("repository_key") != "sdk": - errors.append(f"{identifier}.repository_key must be 'sdk'.") - validate_parent_and_children(identifier, item, item_by_id, errors) - blocked_by = validate_string_collection(identifier, item, "blocked_by", errors) - blocks = validate_string_collection(identifier, item, "blocks", errors) - validate_string_collection(identifier, item, "acceptance", errors) - validate_string_collection(identifier, item, "sources", errors) - validate_work_item_file(identifier, item, repository_root, errors) - - for dependency in blocked_by: - if dependency not in item_by_id and dependency not in external_reference_ids: - errors.append(f"{identifier}.blocked_by references undeclared ID {dependency}.") - for dependent in blocks: - if dependent not in item_by_id and dependent not in external_reference_ids: - errors.append(f"{identifier}.blocks references undeclared ID {dependent}.") - - -def validate_parent_and_children( - identifier: str, item: dict[str, Any], item_by_id: dict[str, dict[str, Any]], errors: list[str] -) -> None: - parent = item.get("parent") - if parent is not None and (not isinstance(parent, str) or not parent): - errors.append(f"{identifier}.parent must be a non-empty string or null.") - elif parent is not None and parent not in item_by_id: - errors.append(f"{identifier}.parent references undeclared ID {parent}.") - elif parent is not None and identifier not in safe_string_collection( - item_by_id[parent], "children" - ): - errors.append(f"{identifier}.parent and {parent}.children disagree.") - - children = validate_string_collection(identifier, item, "children", errors) - for child in children: - child_item = item_by_id.get(child) - if child_item is None: - errors.append(f"{identifier}.children references undeclared ID {child}.") - elif child_item.get("parent") != identifier: - errors.append(f"{identifier}.children and {child}.parent disagree.") - - -def validate_string_collection( - identifier: str, item: dict[str, Any], field: str, errors: list[str] -) -> list[str]: - values = item.get(field) - if not isinstance(values, list): - errors.append(f"{identifier}.{field} must be an array.") - return [] - strings: list[str] = [] - for value in values: - if not isinstance(value, str) or not value: - errors.append(f"{identifier}.{field} must contain only non-empty strings.") - return [] - strings.append(value) - if len(strings) != len(set(strings)): - errors.append(f"{identifier}.{field} contains duplicate IDs.") - return strings - - -def safe_string_collection(item: dict[str, Any], field: str) -> list[str]: - """Return a collection only when prior validation could safely iterate it.""" - values = item.get(field) - if not isinstance(values, list): - return [] - - strings: list[str] = [] - for value in values: - if not isinstance(value, str) or not value: - return [] - strings.append(value) - return strings - - -def validate_work_item_file( - identifier: str, item: dict[str, Any], repository_root: Path, errors: list[str] -) -> None: - relative_path = item.get("path") - if not isinstance(relative_path, str) or not relative_path: - errors.append(f"{identifier}.path must be a non-empty string.") - return - work_items_root = (repository_root / "documentations/engineering/work-items").resolve() - item_path = (repository_root / relative_path).resolve() - try: - item_path.relative_to(work_items_root) - except ValueError: - errors.append( - f"{identifier}.path must resolve under documentations/engineering/work-items/." - ) - return - - if not item_path.is_file(): - errors.append(f"{identifier}.path does not exist: {relative_path}.") - return - contents = item_path.read_text(encoding="utf-8") - marker = f"" - heading = f"## {identifier} — {item.get('title', '')}" - if marker not in contents: - errors.append(f"{relative_path} is missing {marker}.") - if heading not in contents: - errors.append(f"{relative_path} does not match the manifest title for {identifier}.") - - -def validate_dependency_graph( - item_by_id: dict[str, dict[str, Any]], external_reference_ids: set[str], errors: list[str] -) -> None: - declared_external_edges: set[str] = set() - for identifier, item in item_by_id.items(): - for dependent in safe_string_collection(item, "blocks"): - if dependent in external_reference_ids: - declared_external_edges.add(dependent) - elif dependent in item_by_id and identifier not in item_by_id[dependent].get( - "blocked_by", [] - ): - errors.append(f"{identifier}.blocks and {dependent}.blocked_by disagree.") - - for dependency in safe_string_collection(item, "blocked_by"): - if dependency in item_by_id and identifier not in item_by_id[dependency].get( - "blocks", [] - ): - errors.append(f"{identifier}.blocked_by and {dependency}.blocks disagree.") - - unused_external_references = external_reference_ids - declared_external_edges - for identifier in sorted(unused_external_references): - errors.append(f"External reference {identifier} has no SDK blocks edge.") - find_dependency_cycles(item_by_id, errors) - - -def find_dependency_cycles(item_by_id: dict[str, dict[str, Any]], errors: list[str]) -> None: - visiting: set[str] = set() - visited: set[str] = set() - - def visit(identifier: str, trail: list[str]) -> None: - if identifier in visiting: - cycle_start = trail.index(identifier) - cycle = trail[cycle_start:] + [identifier] - errors.append(f"Dependency cycle: {' -> '.join(cycle)}.") - return - if identifier in visited: - return - visiting.add(identifier) - trail.append(identifier) - for dependency in safe_string_collection(item_by_id[identifier], "blocked_by"): - if dependency in item_by_id: - visit(dependency, trail) - trail.pop() - visiting.remove(identifier) - visited.add(identifier) - - for identifier in item_by_id: - visit(identifier, []) - - -def validate_roadmap_and_reconciliation( - item_by_id: dict[str, dict[str, Any]], repository_root: Path, errors: list[str] -) -> None: - roadmap_path = repository_root / ROADMAP_RELATIVE_PATH - if not roadmap_path.is_file(): - errors.append(f"Missing roadmap: {ROADMAP_RELATIVE_PATH.as_posix()}.") - else: - roadmap = roadmap_path.read_text(encoding="utf-8") - for item in item_by_id.values(): - if item.get("kind") in {"roadmap", "epic"}: - continue - relative_path = item.get("path") - if isinstance(relative_path, str) and f"({relative_path})" not in roadmap: - errors.append(f"ROADMAP.md does not link {relative_path}.") - - reconciliation_path = repository_root / ARCHIVE_RECONCILIATION_RELATIVE_PATH - if not reconciliation_path.is_file(): - return - reconciliation = reconciliation_path.read_text(encoding="utf-8") - if EXPECTED_ARCHIVE_SHA256 not in reconciliation.lower(): - errors.append("ARCHIVE_RECONCILIATION.md does not contain the archive SHA-256.") - for number in range(1, 37): - identifier = f"R{number:02d}" - if not contains_trace_identifier(reconciliation, identifier): - errors.append(f"ARCHIVE_RECONCILIATION.md does not trace {identifier}.") - for number in range(1, 81): - identifier = f"T{number:03d}" - if not contains_trace_identifier(reconciliation, identifier): - errors.append(f"ARCHIVE_RECONCILIATION.md does not trace {identifier}.") - - -def contains_trace_identifier(document: str, identifier: str) -> bool: - pattern = rf"(? int: - repository_root = Path(__file__).resolve().parents[1] - try: - manifest = read_manifest(repository_root) - except ValueError as error: - print(f"Engineering manifest validation failed: {error}", file=sys.stderr) - return 1 - - errors = validate_manifest(manifest, repository_root) - if errors: - print("Engineering manifest validation failed:", file=sys.stderr) - for error in errors: - print(f"- {error}", file=sys.stderr) - return 1 - - print( - f"Validated {len(manifest['items'])} work items, " - f"{len(manifest['external_references'])} external references, and the local dependency DAG." - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/eng/lua-bridge/README.md b/eng/lua-bridge/README.md deleted file mode 100644 index 5f53e4b9..00000000 --- a/eng/lua-bridge/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# Protected Lua-operation catalogue - -`protected-operations.json` is the versioned, machine-readable source of truth for the **currently supported** -operations of the C11 Lua-protection microkernel. It records the bridge opcode and capability, managed constant and -wrapper name, the native protected stack effect, error class, ownership, and CE/Lua provenance. It also contains the -conservative policy for direct calls to the raw `LuaApi` methods used by this boundary. - -It is deliberately an engineering input, not a runtime file. A shipping plugin never opens this JSON file: source -generators, structural tests, and build-time validation consume it at compile/test time and emit constants or compare -the native/managed sides. The C bridge remains hand-written C11 because `lua_pcallk` must establish a native -`setjmp`/`longjmp` boundary. This catalogue must not generate C or turn a raising Lua operation into a managed reverse -callback. - -## Contract - -The operation entries are ordered by opcode. `bridgeContract.operationBitmap` is a 64-bit hexadecimal bitmap derived -from those opcodes, and lets a generator create the managed required-bitmap constant and tests verify the native -contract. `nativeEnum`, `managed.constant`, and `managed.wrapper` intentionally identify their current C and C# -counterparts so a validator can report drift rather than relying on positional assumptions. - -`LuaProtectedOperationContract.Count` is the number of active catalogue entries, not the greatest opcode plus one. -Opcodes are permanent ABI identities and may become sparse after a versioned retirement; `RequiredBitmap` and -`IsDefined` remain the authoritative membership checks. - -`stack` is relative to the Lua stack before the bridge's light C closure is pushed. Its `inputCount` therefore means -the values supplied as `lua_pcallk` arguments, not any private stack copies that a managed adapter temporarily adds. -On a protected-call failure, Lua consumes those arguments and leaves one error object; the recorded failure delta makes -that rule explicit. - -`raises` uses the Lua 5.3 convention: - -- `never` — the documented path has the `-` error marker and can be a direct primitive when all normal Lua stack - preconditions hold. -- `memory` — the documented path may fail by memory error (`m`), therefore it must run below the native bridge. -- `any` — the operation includes an intentional `lua_error`, host code, or unresolved source conflict, so it must run - below the native bridge. - -`directApiPolicy` is intentionally conservative. It lists only the raw APIs this microkernel needs to classify. An -unlisted raw `LuaApi` method is **not** automatically approved for direct use; it needs its own evidence entry before a -future analyzer or generator can allow it. - -The only conditional direct rule currently recorded is `lua_pushcclosure` with `upvalueCount == 0` after a successful -`lua_checkstack(state, 1)` immediately before on the same call path. Lua 5.3.6 implements that exact light-C-function case without a closure -allocation. The generic operation remains protected because nonzero upvalues can allocate. - -The exact CE 7.7-identical Lua fixture additionally proves the useful failure behavior of `lua_checkstack(4096)` under -a rejecting allocator: it returns `0`, preserves a zero-height stack, and leaves the state reusable. This is recorded -as `ExactInstalledFile`, not `ObservedLive`: it validates the hash-identical fixture DLL and does not make a claim -about a running Cheat Engine process. - -## Consumers and validation - -The `CheatEngine.SDK.SourceGenerators.LuaBridgeContract` generator receives this file as an `AdditionalFile`, parses -it deterministically, and emits only the managed enum, operation count, bitmap, membership check, and diagnostics. It -must not inspect the installed CE filesystem, network, time, or environment. The C microkernel remains an independent -compilation unit and is verified against the generated data; it is not generated from JSON. - -Before changing an opcode, wrapper name, bitmap, direct-call decision, stack effect, ownership, or provenance: - -1. Update `protected-operations.json` and keep it valid against `protected-operations.schema.json`. -2. Update the hand-written C bridge and managed wrapper behavior together where the contract changed; never add a - hand-written managed opcode declaration beside the generated projection. -3. Extend the generator/structural tests and run the native failure probe. -4. Rebuild the x64 bridge and update its checked-in binary only when its C source or xmake project changed. - -The provenance records intentionally keep CE 7.7 host behavior distinct from generic Lua 5.3 behavior. In particular, -the `PushHostObject` failure path remains `any` until P0-LIVE-005 verifies the CE 7.7 pusher against the exact host. - -Run the structural drift check from the repository root before handing the catalogue to a new generator or analyzer: - -```powershell -pwsh eng/lua-bridge/Test-ProtectedOperationCatalog.ps1 -``` - -The script derives the bitmap from the JSON, checks unique IDs/opcodes/symbols and provenance, then compares every -entry with the hand-written C enum/cases. It does not compile C and it is never loaded by the shipping runtime. The -managed projection is deliberately not checked through a hand-written `.cs` file: the -`CheatEngine.SDK.SourceGenerators.LuaBridgeContract` generator's -`CatalogEmissionTests.Catalog_operations_emit_a_numeric_sorted_enum_and_required_bitmap` test feeds this same JSON as -an `AdditionalText` and asserts the emitted enum and bitmap. That test is the CI-proof of the generated C# half. - -Primary evidence: [Lua 5.3 manual](https://www.lua.org/manual/5.3/manual.html#4.8), -[Lua 5.3 `lapi.c`](https://www.lua.org/source/5.3/lapi.c.html), -[Lua 5.3 `lauxlib.c`](https://www.lua.org/source/5.3/lauxlib.c.html), and the pinned -[Cheat Engine `LuaClass.pas`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaClass.pas). diff --git a/eng/lua-bridge/Test-ProtectedOperationCatalog.ps1 b/eng/lua-bridge/Test-ProtectedOperationCatalog.ps1 deleted file mode 100644 index b3a5ced1..00000000 --- a/eng/lua-bridge/Test-ProtectedOperationCatalog.ps1 +++ /dev/null @@ -1,108 +0,0 @@ -[CmdletBinding()] -param( - [string]$CatalogPath = (Join-Path $PSScriptRoot 'protected-operations.json'), - [string]$SchemaPath = (Join-Path $PSScriptRoot 'protected-operations.schema.json'), - [string]$NativeBridgePath = (Join-Path $PSScriptRoot '..\..\native\cheatengine-sdk-lua-bridge\cheatengine_sdk_lua_bridge.c') -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function Require([bool] $Condition, [string] $Message) { - if (-not $Condition) { - throw $Message - } -} - -function Require-Property($Object, [string] $Name, [string] $Context) { - Require ($null -ne $Object.PSObject.Properties[$Name]) "$Context is missing '$Name'." -} - -function Escape-Regex([string] $Value) { - return [regex]::Escape($Value) -} - -Require (Test-Path -LiteralPath $CatalogPath -PathType Leaf) "The catalogue does not exist: $CatalogPath" -Require (Test-Path -LiteralPath $SchemaPath -PathType Leaf) "The catalogue schema does not exist: $SchemaPath" -Require (Test-Path -LiteralPath $NativeBridgePath -PathType Leaf) "The native bridge source does not exist: $NativeBridgePath" -Require (Test-Json -Path $CatalogPath -SchemaFile $SchemaPath) "The catalogue is not valid against '$SchemaPath'." - -$catalog = Get-Content -LiteralPath $CatalogPath -Raw | ConvertFrom-Json -Require ($catalog.schemaVersion -eq 1) 'Only protected-operation catalogue schema version 1 is supported.' -Require ($catalog.catalogId -eq 'cheatengine-sdk-lua-protected-operations') 'The catalogue id is invalid.' -Require ($catalog.bridgeContract.operationBitmapWidth -eq 64) 'The bridge operation bitmap must be 64 bits.' - -$allowedRaises = @('never', 'memory', 'any') -$byId = @{} -$byOpcode = @{} -$byNativeEnum = @{} -$byManagedConstant = @{} -$bitmap = [UInt64]0 - -foreach ($operation in @($catalog.operations)) { - foreach ($property in @('id', 'nativeEnum', 'managed', 'opcode', 'capability', 'protected', 'requiresNativeProtection', 'raises', 'stack', 'ownership', 'hostOperation', 'provenance')) { - Require-Property $operation $property "Operation" - } - - Require (-not $byId.ContainsKey($operation.id)) "Duplicate operation id '$($operation.id)'." - Require (-not $byOpcode.ContainsKey([int]$operation.opcode)) "Duplicate bridge opcode '$($operation.opcode)'." - Require (-not $byNativeEnum.ContainsKey($operation.nativeEnum)) "Duplicate native enum '$($operation.nativeEnum)'." - Require (-not $byManagedConstant.ContainsKey($operation.managed.constant)) "Duplicate managed constant '$($operation.managed.constant)'." - Require (($operation.opcode -ge 0) -and ($operation.opcode -lt 64)) "Operation '$($operation.id)' has an opcode outside the 64-bit bitmap." - Require ($operation.protected -eq $true) "Operation '$($operation.id)' must remain a protected operation." - Require ($operation.requiresNativeProtection -eq $true) "Operation '$($operation.id)' must remain below the C11 lua_pcallk boundary." - Require ($allowedRaises -contains $operation.raises) "Operation '$($operation.id)' has an invalid raises classification '$($operation.raises)'." - Require (@($operation.provenance).Count -gt 0) "Operation '$($operation.id)' has no provenance." - - $byId.Add($operation.id, $operation) - $byOpcode.Add([int]$operation.opcode, $operation) - $byNativeEnum.Add($operation.nativeEnum, $operation) - $byManagedConstant.Add($operation.managed.constant, $operation) - $bitmap = $bitmap -bor ([UInt64]1 -shl [int]$operation.opcode) -} - -$expectedBitmap = [Convert]::ToUInt64(([string]$catalog.bridgeContract.operationBitmap).Substring(2), 16) -Require ($bitmap -eq $expectedBitmap) ('The operation bitmap is 0x{0:X16}, but the operations derive 0x{1:X16}.' -f $expectedBitmap, $bitmap) - -$nativeSource = Get-Content -LiteralPath $NativeBridgePath -Raw -$nativeEnumMatch = [regex]::Match($nativeSource, 'enum\s*\{\s*(?OP_PUSH_BYTES\s*=\s*0,[\s\S]*?CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_COUNT\s*=\s*\d+)\s*\};') -Require $nativeEnumMatch.Success 'Could not find the native protected-operation enum.' - -$nativeCountMatch = [regex]::Match($nativeEnumMatch.Groups['values'].Value, 'CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_COUNT\s*=\s*(?\d+)') -Require $nativeCountMatch.Success 'The native protected-operation enum has no count sentinel.' -Require ([int]$nativeCountMatch.Groups['count'].Value -eq @($catalog.operations).Count) 'The native protected-operation count does not match the catalogue.' - -foreach ($operation in @($catalog.operations)) { - $nativeOpcodePattern = '\b' + (Escape-Regex $operation.nativeEnum) + '\s*=\s*' + [int]$operation.opcode + '\b' - Require ([regex]::IsMatch($nativeEnumMatch.Groups['values'].Value, $nativeOpcodePattern)) "Native enum '$($operation.nativeEnum)' does not equal catalogue opcode $($operation.opcode)." - Require ([regex]::IsMatch($nativeSource, ('case\s+' + (Escape-Regex $operation.nativeEnum) + '\s*:'))) "Native bridge has no case for '$($operation.nativeEnum)'." - Require ($nativeSource.Contains($operation.nativeEnum)) "Native bridge bitmap does not name '$($operation.nativeEnum)'." - -} - -$managedSymbols = @{} -foreach ($policy in @($catalog.directApiPolicy)) { - foreach ($property in @('managedSymbol', 'nativeSymbol', 'raises', 'allowedDirectly', 'requiresBridge', 'reason', 'provenance')) { - Require-Property $policy $property "Direct API policy" - } - - Require (-not $managedSymbols.ContainsKey($policy.managedSymbol)) "Duplicate direct API policy for '$($policy.managedSymbol)'." - Require ($allowedRaises -contains $policy.raises) "Direct API policy '$($policy.managedSymbol)' has an invalid raises classification '$($policy.raises)'." - Require (-not ($policy.allowedDirectly -and $policy.requiresBridge)) "Direct API policy '$($policy.managedSymbol)' cannot both allow direct use and require the bridge." - Require (-not ((-not $policy.allowedDirectly) -and (-not $policy.requiresBridge))) "Direct API policy '$($policy.managedSymbol)' must explicitly choose a direct or bridge route." - Require (-not ($policy.allowedDirectly -and $policy.raises -ne 'never')) "A directly allowed API '$($policy.managedSymbol)' must have the never classification." - $bridgeOperationProperty = $policy.PSObject.Properties['bridgeOperation'] - if ($null -ne $bridgeOperationProperty) { - Require ($byId.ContainsKey($bridgeOperationProperty.Value)) "Direct API policy '$($policy.managedSymbol)' refers to missing bridge operation '$($bridgeOperationProperty.Value)'." - } - $conditionalDirectUseProperty = $policy.PSObject.Properties['conditionalDirectUse'] - if ($null -ne $conditionalDirectUseProperty) { - Require ($policy.allowedDirectly -eq $false) "A conditional direct API must retain a conservative default route." - Require ($policy.requiresBridge -eq $true) "A conditional direct API must retain a conservative bridge route." - Require ($conditionalDirectUseProperty.Value.allowed -eq $true) "A conditional direct API rule must explicitly opt in." - } - Require (@($policy.provenance).Count -gt 0) "Direct API policy '$($policy.managedSymbol)' has no provenance." - $managedSymbols.Add($policy.managedSymbol, $policy) -} - -Write-Host ('Validated {0} protected operations and {1} direct Lua API policies.' -f @($catalog.operations).Count, @($catalog.directApiPolicy).Count) diff --git a/eng/lua-bridge/protected-operations.schema.json b/eng/lua-bridge/protected-operations.schema.json deleted file mode 100644 index 5fbcd7b5..00000000 --- a/eng/lua-bridge/protected-operations.schema.json +++ /dev/null @@ -1,217 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/cheat-engine/cheat-engine-sdk/eng/lua-bridge/protected-operations.schema.json", - "title": "CheatEngine.SDK protected Lua-operation catalogue", - "type": "object", - "additionalProperties": false, - "required": [ - "schemaVersion", - "catalogId", - "host", - "bridgeContract", - "classificationPreconditions", - "operations", - "directApiPolicy" - ], - "properties": { - "$schema": { "const": "./protected-operations.schema.json" }, - "schemaVersion": { "const": 1 }, - "catalogId": { "const": "cheatengine-sdk-lua-protected-operations" }, - "host": { "$ref": "#/$defs/host" }, - "bridgeContract": { "$ref": "#/$defs/bridgeContract" }, - "classificationPreconditions": { - "type": "array", - "minItems": 1, - "items": { "type": "string", "minLength": 1 } - }, - "operations": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/operation" } - }, - "directApiPolicy": { - "type": "array", - "items": { "$ref": "#/$defs/directApiPolicy" } - } - }, - "$defs": { - "status": { - "enum": [ - "ExactBinary", - "ExactInstalledFile", - "PinnedUpstream", - "ObservedLive", - "Inferred", - "Unknown" - ] - }, - "raises": { "enum": ["never", "memory", "any"] }, - "provenance": { - "type": "object", - "additionalProperties": false, - "required": ["component", "symbol", "status", "source", "verifiedOn", "verification"], - "properties": { - "component": { "type": "string", "minLength": 1 }, - "symbol": { "type": "string", "minLength": 1 }, - "status": { "$ref": "#/$defs/status" }, - "source": { "type": "string", "format": "uri" }, - "verifiedOn": { "type": "string", "format": "date" }, - "verification": { "type": "string", "minLength": 1 }, - "note": { "type": "string" } - } - }, - "host": { - "type": "object", - "additionalProperties": false, - "required": ["product", "version", "architecture", "lua", "evidence"], - "properties": { - "product": { "const": "Cheat Engine" }, - "version": { "const": "7.7.0.10621" }, - "architecture": { "const": "x64" }, - "lua": { - "type": "object", - "additionalProperties": false, - "required": ["majorMinor", "fixtureSha256"], - "properties": { - "majorMinor": { "const": "5.3" }, - "fixtureSha256": { "const": "C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2" } - } - }, - "evidence": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/provenance" } - } - } - }, - "bridgeContract": { - "type": "object", - "additionalProperties": false, - "required": ["abiMajor", "minimumAbiMinor", "operationBitmapWidth", "operationBitmap"], - "properties": { - "abiMajor": { "const": 1 }, - "minimumAbiMinor": { "const": 1 }, - "operationBitmapWidth": { "const": 64 }, - "operationBitmap": { "pattern": "^0x[0-9A-F]{16}$" } - } - }, - "stack": { - "type": "object", - "additionalProperties": false, - "required": ["scope", "inputCount", "inputDescription", "success", "failure"], - "properties": { - "scope": { "type": "string", "minLength": 1 }, - "inputCount": { "oneOf": [{ "type": "integer", "minimum": 0 }, { "type": "string", "minLength": 1 }] }, - "inputDescription": { "type": "string", "minLength": 1 }, - "success": { "$ref": "#/$defs/stackOutcome" }, - "failure": { "$ref": "#/$defs/stackOutcome" } - } - }, - "stackOutcome": { - "type": "object", - "additionalProperties": false, - "required": ["resultCount", "delta"], - "properties": { - "resultCount": { "oneOf": [{ "type": "integer", "minimum": 0 }, { "type": "string", "minLength": 1 }] }, - "delta": { "type": "string", "minLength": 1 }, - "note": { "type": "string" } - } - }, - "operation": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "nativeEnum", - "managed", - "opcode", - "capability", - "protected", - "requiresNativeProtection", - "raises", - "stack", - "ownership", - "hostOperation", - "provenance" - ], - "properties": { - "id": { "pattern": "^[A-Z][A-Za-z0-9]*$" }, - "nativeEnum": { "pattern": "^OP_[A-Z0-9_]+$" }, - "managed": { - "type": "object", - "additionalProperties": false, - "required": ["constant", "wrapper"], - "properties": { - "constant": { "pattern": "^[A-Z][A-Za-z0-9]*Operation$" }, - "wrapper": { "pattern": "^[A-Z][A-Za-z0-9]*$" } - } - }, - "opcode": { "type": "integer", "minimum": 0, "maximum": 63 }, - "capability": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, - "protected": { "const": true }, - "requiresNativeProtection": { "const": true }, - "raises": { "$ref": "#/$defs/raises" }, - "successPathRaises": { "$ref": "#/$defs/raises" }, - "provenanceConflict": { "type": "string" }, - "conditionalFastPath": { "type": "string" }, - "availability": { - "type": "object", - "additionalProperties": false, - "required": ["state", "consumerCount", "note"], - "properties": { - "state": { "enum": ["active", "supported-unused"] }, - "consumerCount": { "type": "integer", "minimum": 0 }, - "candidateRetirement": { "type": "boolean" }, - "note": { "type": "string", "minLength": 1 } - } - }, - "stack": { "$ref": "#/$defs/stack" }, - "ownership": { - "type": "object", - "additionalProperties": false, - "required": ["input", "result"], - "properties": { - "input": { "type": "string", "minLength": 1 }, - "result": { "type": "string", "minLength": 1 }, - "registry": { "type": "string" } - } - }, - "hostOperation": { "type": "string", "minLength": 1 }, - "provenance": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/provenance" } - } - } - }, - "directApiPolicy": { - "type": "object", - "additionalProperties": false, - "required": ["managedSymbol", "nativeSymbol", "raises", "allowedDirectly", "requiresBridge", "reason", "provenance"], - "properties": { - "managedSymbol": { "pattern": "^CheatEngine\\.SDK\\.Lua\\.Interop\\.Api\\.LuaApi\\.[A-Za-z0-9_]+$" }, - "nativeSymbol": { "pattern": "^[A-Za-z0-9_]+$" }, - "raises": { "$ref": "#/$defs/raises" }, - "allowedDirectly": { "type": "boolean" }, - "requiresBridge": { "type": "boolean" }, - "bridgeOperation": { "pattern": "^[A-Z][A-Za-z0-9]*$" }, - "conditionalDirectUse": { - "type": "object", - "additionalProperties": false, - "required": ["allowed", "when", "proof"], - "properties": { - "allowed": { "const": true }, - "when": { "type": "string", "minLength": 1 }, - "proof": { "type": "string", "minLength": 1 } - } - }, - "reason": { "type": "string", "minLength": 1 }, - "provenance": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/provenance" } - } - } - } - } -} diff --git a/eng/tests/test_validate_ce_surface_catalog.py b/eng/tests/test_validate_ce_surface_catalog.py deleted file mode 100644 index 6f3f0a93..00000000 --- a/eng/tests/test_validate_ce_surface_catalog.py +++ /dev/null @@ -1,202 +0,0 @@ -import copy -import importlib.util -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -VALIDATOR_PATH = REPOSITORY_ROOT / "eng" / "Validate-CeSurfaceCatalog.py" -SPECIFICATION = importlib.util.spec_from_file_location("validate_ce_surface_catalog", VALIDATOR_PATH) -if SPECIFICATION is None or SPECIFICATION.loader is None: - raise RuntimeError("Could not load the CE surface catalogue validator.") -VALIDATOR = importlib.util.module_from_spec(SPECIFICATION) -SPECIFICATION.loader.exec_module(VALIDATOR) - - -class CeSurfaceCatalogValidationTests(unittest.TestCase): - def setUp(self) -> None: - self.catalog = VALIDATOR.load_catalog(REPOSITORY_ROOT) - - def assert_valid(self, catalog: dict[str, object]) -> None: - self.assertEqual([], VALIDATOR.validate_catalog(catalog, REPOSITORY_ROOT)) - - def assert_invalid(self, catalog: dict[str, object], expected: str) -> None: - errors = VALIDATOR.validate_catalog(catalog, REPOSITORY_ROOT) - self.assertTrue(any(expected in error for error in errors), errors) - - def conflict(self, catalog: dict[str, object], identifier: str) -> dict[str, object]: - conflicts = catalog[VALIDATOR.CONFLICTS_FILE]["conflicts"] - for conflict in conflicts: - if conflict["id"] == identifier: - return conflict - self.fail(f"Conflict {identifier!r} was not found.") - - def capability(self, catalog: dict[str, object], identifier: str) -> dict[str, object]: - capabilities = catalog[VALIDATOR.CAPABILITIES_FILE]["capabilities"] - for capability in capabilities: - if capability["id"] == identifier: - return capability - self.fail(f"Capability {identifier!r} was not found.") - - def test_committed_catalogue_is_valid(self) -> None: - self.assert_valid(self.catalog) - - def test_empty_catalogue_document_fails(self) -> None: - for document_name in ( - VALIDATOR.DECLARATIONS_FILE, - VALIDATOR.CAPABILITIES_FILE, - VALIDATOR.CONFLICTS_FILE, - VALIDATOR.HOST_PROFILES_FILE, - VALIDATOR.ADVANCED_FAMILIES_FILE): - with self.subTest(document=document_name): - catalog = copy.deepcopy(self.catalog) - catalog[document_name] = {} - self.assert_invalid(catalog, f"{document_name}: document must be a non-empty object") - - def test_removing_a_classic_slot_fails(self) -> None: - catalog = copy.deepcopy(self.catalog) - slots = catalog[VALIDATOR.DECLARATIONS_FILE]["classic_exported_functions"]["slots"] - slots.pop() - self.assert_invalid(catalog, "exactly 159 slots") - - def test_reordering_a_classic_slot_fails_manifest_integrity(self) -> None: - catalog = copy.deepcopy(self.catalog) - slots = catalog[VALIDATOR.DECLARATIONS_FILE]["classic_exported_functions"]["slots"] - slots[0], slots[1] = slots[1], slots[0] - self.assert_invalid(catalog, "manifest no longer matches") - - def test_duplicate_capability_id_fails(self) -> None: - catalog = copy.deepcopy(self.catalog) - capabilities = catalog[VALIDATOR.CAPABILITIES_FILE]["capabilities"] - capabilities.append(copy.deepcopy(capabilities[0])) - self.assert_invalid(catalog, "duplicate id") - - def test_unresolved_conflict_cannot_be_promoted(self) -> None: - catalog = copy.deepcopy(self.catalog) - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["availability"] = "catalogued-only" - self.assert_invalid(catalog, "requires 'opaque' availability") - - def test_unresolved_conflict_legacy_prose_cannot_be_promoted(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["resolution"] = "unresolved; pending exact host proof" - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["availability"] = "catalogued-only" - self.assert_invalid(catalog, "requires 'opaque' availability") - - def test_unresolved_conflict_structured_state_cannot_be_promoted(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["resolution"] = {"state": "unresolved", "summary": "Pending exact host proof."} - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["availability"] = "catalogued-only" - self.assert_invalid(catalog, "requires 'opaque' availability") - - def test_unknown_structured_conflict_state_fails_closed(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["resolution"] = {"state": "resolved", "summary": "Unreviewed promotion."} - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["availability"] = "catalogued-only" - self.assert_invalid(catalog, "resolution must declare the unresolved state") - self.assert_invalid(catalog, "requires 'opaque' availability") - - def test_conflict_capability_link_must_be_bidirectional(self) -> None: - catalog = copy.deepcopy(self.catalog) - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["conflict_ids"] = [] - self.assert_invalid(catalog, "is not reciprocated by capability") - - def test_conflict_affected_capability_ids_non_list_fails_without_exception(self) -> None: - for case, value in ( - ("missing", None), - ("null", None), - ("string", "classic.callback.process-watcher"), - ("object", {})): - with self.subTest(case=case): - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - if case == "missing": - del conflict["affected_capability_ids"] - else: - conflict["affected_capability_ids"] = value - self.assert_invalid(catalog, "affected_capability_ids must be an array") - - def test_conflict_affected_capability_ids_rejects_non_string_entries(self) -> None: - for value in ([], {}): - with self.subTest(value=value): - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["affected_capability_ids"] = [value] - self.assert_invalid(catalog, "affected_capability_ids must contain non-empty strings") - - def test_conflict_required_availability_rejects_unhashable_value(self) -> None: - for value in ([], {}): - with self.subTest(value=value): - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["required_availability"] = value - self.assert_invalid(catalog, "requires an opaque or unavailable availability state") - - def test_fixmem_conflict_locator_must_match_declaration_catalogue(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.fixmem-null-host-slot") - conflict["evidence"][0]["line_start"] = 289 - conflict["evidence"][0]["line_end"] = 289 - self.assert_invalid(catalog, "FixMem C-header evidence must match the FixMem declaration locator") - - def test_conflict_locator_rejects_boolean_line_numbers(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["evidence"][0]["line_start"] = True - conflict["evidence"][0]["line_end"] = True - self.assert_invalid(catalog, "has an invalid source locator") - - def test_target_architecture_cannot_be_inferred_from_host(self) -> None: - catalog = copy.deepcopy(self.catalog) - profiles = catalog[VALIDATOR.HOST_PROFILES_FILE]["profiles"] - profiles[0]["target"]["architecture"] = "same-as-host" - self.assert_invalid(catalog, "illegally infers target facts") - - def test_live_qualification_is_rejected_without_a_reviewed_capture(self) -> None: - catalog = copy.deepcopy(self.catalog) - capabilities = catalog[VALIDATOR.CAPABILITIES_FILE]["capabilities"] - capabilities[0]["qualification"] = "live-qualified" - self.assert_invalid(catalog, "cannot claim live qualification") - - def test_advanced_family_cannot_drop_an_independent_support_axis(self) -> None: - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - del family["support_axes"]["host"] - self.assert_invalid(catalog, "all independent support axes") - - def test_advanced_family_cannot_be_promoted_from_its_ledger(self) -> None: - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - family["availability"] = "mapped" - self.assert_invalid(catalog, "remains unavailable and unqualified") - - def test_advanced_family_availability_rejects_unhashable_value(self) -> None: - for value in ([], {}): - with self.subTest(value=value): - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - family["availability"] = value - self.assert_invalid(catalog, "remains unavailable and unqualified") - - def test_advanced_family_rejects_unknown_support_axis_state(self) -> None: - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - family["support_axes"]["host"]["state"] = "review-complete" - self.assert_invalid(catalog, "cannot satisfy 'host' before independent review") - - def test_advanced_family_requires_its_own_live_gate(self) -> None: - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - family["qualification_gates"]["live"] = [] - self.assert_invalid(catalog, "needs non-empty fixture, live, negative, and cleanup gates") - - -if __name__ == "__main__": - unittest.main() diff --git a/eng/tests/test_validate_engineering_manifest.py b/eng/tests/test_validate_engineering_manifest.py deleted file mode 100644 index 6401f43e..00000000 --- a/eng/tests/test_validate_engineering_manifest.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Regression tests for the offline engineering-manifest verifier.""" - -from __future__ import annotations - -import copy -import importlib.util -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -VALIDATOR_PATH = REPOSITORY_ROOT / "eng/Validate-EngineeringManifest.py" -SPECIFICATION = importlib.util.spec_from_file_location("engineering_manifest", VALIDATOR_PATH) -if SPECIFICATION is None or SPECIFICATION.loader is None: - raise RuntimeError("Could not load the engineering manifest validator.") -VALIDATOR = importlib.util.module_from_spec(SPECIFICATION) -SPECIFICATION.loader.exec_module(VALIDATOR) - - -class EngineeringManifestValidationTests(unittest.TestCase): - def setUp(self) -> None: - self.manifest = VALIDATOR.read_manifest(REPOSITORY_ROOT) - - def test_current_manifest_is_valid(self) -> None: - errors = VALIDATOR.validate_manifest(self.manifest, REPOSITORY_ROOT) - - self.assertEqual([], errors) - - def test_duplicate_planning_id_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["items"].append(copy.deepcopy(manifest["items"][0])) - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("Duplicate planning ID: SDK-PLAN.", errors) - - def test_non_object_manifest_root_is_rejected(self) -> None: - errors = VALIDATOR.validate_manifest([], REPOSITORY_ROOT) - - self.assertEqual(["Manifest root must be an object."], errors) - - def test_invalid_collection_is_reported_without_iteration_failure(self) -> None: - manifest = copy.deepcopy(self.manifest) - sdk_001 = next(item for item in manifest["items"] if item["id"] == "SDK-001") - sdk_001["blocked_by"] = None - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("SDK-001.blocked_by must be an array.", errors) - - def test_missing_external_reference_for_sdk_edge_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["external_references"] = [ - reference - for reference in manifest["external_references"] - if reference["id"] != "CLI-007" - ] - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("SDK-008.blocks references undeclared ID CLI-007.", errors) - - def test_local_id_cannot_be_declared_as_an_external_reference(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["external_references"].append( - { - "id": "SDK-001", - "repository": "CheatEngineNet/CheatEngine.Client", - "role": "invalid test fixture", - } - ) - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("Planning ID SDK-001 cannot be both local and external.", errors) - - def test_inconsistent_reverse_edge_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - sdk_002 = next(item for item in manifest["items"] if item["id"] == "SDK-002") - sdk_002["blocks"].remove("SDK-004") - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("SDK-004.blocked_by and SDK-002.blocks disagree.", errors) - - def test_missing_parent_reciprocity_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - sdk_e01 = next(item for item in manifest["items"] if item["id"] == "SDK-E01") - sdk_e01["children"].remove("SDK-001") - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("SDK-001.parent and SDK-E01.children disagree.", errors) - - def test_dependency_cycle_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - sdk_001 = next(item for item in manifest["items"] if item["id"] == "SDK-001") - sdk_001["blocked_by"].append("SDK-002") - sdk_002 = next(item for item in manifest["items"] if item["id"] == "SDK-002") - sdk_002["blocks"].append("SDK-001") - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertTrue(any(error.startswith("Dependency cycle:") for error in errors)) - - def test_missing_work_item_file_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["items"][0]["path"] = "documentations/engineering/work-items/missing.md" - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn( - "SDK-PLAN.path does not exist: documentations/engineering/work-items/missing.md.", - errors, - ) - - def test_work_item_path_cannot_escape_the_work_items_directory(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["items"][0]["path"] = "documentations/engineering/work-items/../../ROADMAP.md" - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn( - "SDK-PLAN.path must resolve under documentations/engineering/work-items/.", - errors, - ) - - def test_trace_identifiers_require_alphanumeric_token_boundaries(self) -> None: - document = "R010 and T0010 are unrelated; R01 and T001 are traced." - - self.assertTrue(VALIDATOR.contains_trace_identifier(document, "R01")) - self.assertTrue(VALIDATOR.contains_trace_identifier(document, "T001")) - self.assertFalse(VALIDATOR.contains_trace_identifier("R010", "R01")) - self.assertFalse(VALIDATOR.contains_trace_identifier("T0010", "T001")) - - -if __name__ == "__main__": - unittest.main() diff --git a/exemples/05-aob-scans/README.md b/exemples/05-aob-scans/README.md index c485169d..b1c76334 100644 --- a/exemples/05-aob-scans/README.md +++ b/exemples/05-aob-scans/README.md @@ -96,55 +96,109 @@ protected call, and reports `NoMatches` only when it can read a valid list count Lua `nil`, a missing global, a protected Lua failure, malformed userdata, and an unreadable list count separate; plugin code must not turn a raw `CEObject` into an owner itself. +On Cheat Engine 7.7.0.10621 x64, the pinned profile, a scan that finds nothing returns no list at all, so +`TryScanOutcome` reports `NoResult`, not `NoMatches`. Read `NoResult` as "no result list: zero matches or a host +failure". It is the common outcome of a pattern that is absent, so handle it as its own case instead of throwing: + ```csharp using CheatEngine.SDK.Engine.Scanning.Aob; using CheatEngine.SDK.Engine.Values; namespace SignatureTools; +internal enum SignatureScan { Matches, NoMatches, NoResultList, Failed } + internal static class Signatures { - public static List
Scan(string pattern, AobScanOptions options = default) + public static SignatureScan Scan(string pattern, List
matches, AobScanOptions options = default) { - List
matches = []; var outcome = AobScanner.TryScanOutcome(pattern, options, out var owner); - if (outcome.Kind == AobScanOutcomeKind.NoMatches) return matches; - if (outcome.Kind != AobScanOutcomeKind.Matches || owner is null) - throw new InvalidOperationException($"AOBScan did not produce a list: {outcome.Kind} ({outcome.LuaStatus})."); - - using (owner) + switch (outcome.Kind) { - var list = owner.Value; - - for (var i = 0; i < outcome.ResultCount; i++) - { - if (list.TryGetItem(i, out var addressText) && Address.TryParse(addressText, out var address)) - matches.Add(address); - } + case AobScanOutcomeKind.Matches when owner is not null: + using (owner) + { + var list = owner.Value; + for (var i = 0; i < outcome.ResultCount; i++) + { + if (list.TryGetItem(i, out var addressText) && Address.TryParse(addressText, out var address)) + matches.Add(address); + } + } + + return SignatureScan.Matches; + + case AobScanOutcomeKind.NoMatches: + // A valid empty list: never returned on the pinned CE 7.7 profile, which reports NoResult instead. + owner?.Dispose(); + return SignatureScan.NoMatches; + + case AobScanOutcomeKind.NoResult: + // Zero matches on the pinned CE 7.7 profile, or a host failure that also returned no list. + return SignatureScan.NoResultList; + + default: + // Missing global, protected Lua failure, malformed result or unreadable count. + return SignatureScan.Failed; } + } +} +``` + +When the matches you want live in one module or range, do not post-filter the global scan: its native cost is a scan of +the whole address space. The bounded route scans only the range, is exhaustive, and reports a real zero: + +```csharp +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Values; + +namespace SignatureTools; + +internal static class ModuleSignatures +{ + // Main thread, plugin enabled, and a qualified target selected in Cheat Engine. + public static Address[] Scan(in ModuleInfo module, string pattern, int maximumResults, CancellationToken token) + { + if (!AobScanBounds.TryFromModule(in module, out var bounds)) + return []; // No image size: the module cannot bound the scan. - return matches; + var destination = new Address[maximumResults]; + var result = AobScanner.TryScanWithinBounds(pattern, bounds, AobScanOptions.Default, destination, token); + return result.Kind switch + { + AobBoundedScanOutcomeKind.Matches => destination[..result.Written], + AobBoundedScanOutcomeKind.NoMatches when !result.IsHostErrorTextUnreadable => [], + _ => throw new InvalidOperationException($"The bounded AOB scan ended with {result.Kind}.") + }; } } ``` +`result.IsMaterializationLimitReached` tells you the destination filled up before every row was read, and +`result.InBoundsCountIsExact` that `Written` is the full in-bounds count; a single match is proven only by that, never by +a first-found scan. + ```mermaid flowchart LR - A["AobScanner.TryScanOutcome(pattern, options)"] --> B["Outcome plus owned StringList
only after a valid list"] - B --> C["Verified Count
NoMatches only at zero"] - C --> D["Read items 0 to Count minus 1
hex text to Address"] - D --> E["Dispose the factory-issued owner
documented destroy path"] - E --> F["List of Address
plain managed data"] + A["AobScanner.TryScanOutcome(pattern, options)"] --> B{"Outcome kind"} + B -->|Matches| C["Read items 0 to Count minus 1
hex text to Address"] + C --> D["Dispose the factory-issued owner
documented destroy path"] + B -->|NoResult| E["No result list
zero matches or host failure"] + B -->|other| F["Failure with its own category"] + G["AobScanner.TryScanWithinBounds(pattern, bounds)"] --> H["Addresses in [Start, Stop)
session released once"] ``` -| Step | Why | -|------------------------------|------------------------------------------------------------------------------------------------------------------| -| `AobScanner.TryScanOutcome` | Performs the protected CE call, distinguishes no-match from failure, and provides an owner only for a valid list | -| `Owned` | Is the factory-issued ownership proof; `Dispose` executes the documented destroy path once | -| `AobScanOutcome.ResultCount` | Is the valid list count observed immediately after CE returns; it is not an execution bound | -| `StringList.TryGetItem(i)` | Uses Cheat Engine's zero-based index and copies one address string | -| `Address.TryParse` | Decodes CE's hexadecimal address text into the target-address type | -| `using (owner)` | Releases the list before it can escape as a stale native handle | +| Step | Why | +|----------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `AobScanner.TryScanOutcome` | Performs the protected CE call, keeps no-list, failure and malformed results apart, and provides an owner only for a valid list | +| `AobScanOutcomeKind.NoResult` | Is how zero matches arrive on the pinned CE 7.7 profile; a host failure looks the same, so it is never "not found" proof | +| `Owned` | Is the factory-issued ownership proof; `Dispose` executes the documented destroy path once | +| `AobScanOutcome.ResultCount` | Is the valid list count observed immediately after CE returns; it is not an execution bound | +| `StringList.TryGetItem(i)` | Uses Cheat Engine's zero-based index and copies one address string | +| `Address.TryParse` | Decodes CE's hexadecimal address text into the target-address type | +| `using (owner)` | Releases the list before it can escape as a stale native handle | +| `AobScanner.TryScanWithinBounds` | Bounds CE's own work to `[Start, Stop)`, stays exhaustive, and reports `NoMatches` (a factual zero once its error text is read) | ### 4. Export it and patch with it diff --git a/exemples/06-value-scans/README.md b/exemples/06-value-scans/README.md index 5295b2c8..652dab81 100644 --- a/exemples/06-value-scans/README.md +++ b/exemples/06-value-scans/README.md @@ -49,6 +49,7 @@ stateDiagram-v2 ResultsReady --> Scanning: deinitialize list, then next scan ResultsReady --> New: deinitialize list, then new scan New --> Disposed: dispose + Scanning --> Disposed: cooperative stop, then destroy child, destroy parent ResultsReady --> Disposed: deinitialize child, destroy child, destroy parent Scanning --> Invalidated: protected CE failure or ambiguous completion Invalidated --> New: reset only while original context is current @@ -75,9 +76,26 @@ snapshot and publishes it to that span only on success. `NoResults`, `Destinatio failure, stale context and cancellation are separate `MemoryScanMaterializationStatus` values. Result-cardinality, progress and retry policy stay in Client rather than becoming SDK policy. -The `*Cancellable` methods observe a `CancellationToken` before a CE call and after a synchronous CE call returns. CE's -documented `waitTillDone()` has no cancellation argument, so a cancellation milestone never claims that native work was -interrupted; it only records whether the SDK observed cancellation before work began or after it had returned. +The `*Cancellable` methods observe a `CancellationToken` before a CE call and after a synchronous CE call returns. +`WaitForCompletion` uses CE's no-timeout `waitTillDone()` form, which has no cancellation argument, so a cancellation +milestone never claims that native work was interrupted; it only records whether the SDK observed cancellation before +work began or after it had returned. + +CE 7.7 also has a timeout form, `waitTillDone(timeout)`, and a cooperative `terminateScan`. `TryWaitForCompletion` +projects the first (a timed-out wait keeps the session scanning) and `TryTerminateScan` requests one cooperative stop, +never a forced one. Both are experimental (`CESDK5010`, see [the diagnostic page](../../analyzers/docs/CESDK5010.md)) +because the timed-out path and `terminateScan` were not observed on the pinned host yet. `TryGetHostErrorText` copies +CE's `ErrorString` as a bounded fact; never branch on its wording, which changes with CE's UI language. + +Releasing a session whose scan may still be running is handled by the session: `Dispose` and `ReleaseWithOutcome` +first request one cooperative stop and wait up to five seconds for it, then destroy the found list and the scanner once +each, even when the stop is not confirmed. That can block Cheat Engine's main thread for the wait plus whatever Cheat +Engine's own destroy waits for its scan thread; `MemoryScanReleaseOutcome.Termination` reports whether the stop was +confirmed. Cheat Engine's waits can run queued main-thread work (for example `MainThread.Invoke` callbacks) that calls +back into the session. While one session member is inside a Cheat Engine call, every other member is refused and a +release is deferred until that call has returned; the interrupted start, wait, reset or stop then throws +`ObjectDisposedException`. Whether the host keeps these promises is still to be qualified on the pinned profile (Q26 +C3, including disposal while a scan runs). ## Ownership rule @@ -85,7 +103,8 @@ interrupted; it only records whether the SDK observed cancellation before work b documentation cites the exact CE ownership contract. The ownership wrapper—not `class` versus `struct`, and not a pointer returned from Lua—determines who may destroy the object. -This is the same model used by the completed AOB slice: +This is the same model used by the completed AOB slice. `TryScan` is the boolean projection; prefer `TryScanOutcome` +when the reason matters (see [guide 05](../05-aob-scans/README.md#3-scan-for-every-match)): ```csharp using CheatEngine.SDK.Engine.Scanning.Aob; @@ -106,12 +125,14 @@ plugin disable. Do not use it to infer that `createMemScan` has the same contrac ## What is still required The SDK fixture covers factual factory outcomes, alias rejection, publication rollback, ordered child/parent release, -state transitions, pre-call cancellation, bounded copying, and stale runtime/target refusal. Before the Client may -expose a live value-scan capability, the vertical slice still must record an isolated, opt-in CE 7.7 x64 probe covering -success, failure, ordered cleanup, cancellation while a scan is in progress, disable/re-enable and target changes. - -The [capability matrix](../../documentations/CheatEngine.SDK/capability-matrix.md) tracks that proof. Until then, use -typed target-memory APIs for scalar reads/writes and `AobScanner` for the ownership-proven AOB result list from the +release while a scan may still run (one cooperative stop, then each owner destroyed once), state transitions, +pre-call cancellation, deadline and termination paths, bounded copying, and stale runtime/target refusal. Before the +Client may expose a live value-scan capability, the vertical slice still must record an isolated, opt-in CE 7.7 x64 +probe covering success, failure, ordered cleanup, disposal and cancellation while a scan is in progress, +disable/re-enable and target changes. + +Audit scenarios Q25 and Q26 track that proof. Until then, +use typed target-memory APIs for scalar reads/writes and `AobScanner` for the ownership-proven AOB result list from the high-level Client; reserve `MemoryScanSessions.TryCreate` for a deliberately authorized, source-backed SDK experiment. ## Before you move on diff --git a/exemples/07-address-list/README.md b/exemples/07-address-list/README.md index c9c8c361..05d1f740 100644 --- a/exemples/07-address-list/README.md +++ b/exemples/07-address-list/README.md @@ -146,14 +146,11 @@ That dispatcher verifies, in its callback thunk, that the host runs work on the host that violates that check and it does not expose a fire-and-forget `queue(function, ...)` route. The CE 7.7 live probe still has to establish the host's real `synchronize` scheduling, errors, returns, and re-entrance behavior; see [09 · The main thread](../09-main-thread/README.md). Treat a disabled or stopping plugin as an expected failure -boundary, -not as an opportunity to retain a borrowed record. +boundary, not as an opportunity to retain a borrowed record. ## Evidence and scope -This slice is sourced from the exact CE `7.7.0.10621` x64 `celua.txt` fixture (digest recorded in the -[source index](../../documentations/CheatEngine.SDK/SOURCES.md)): `getAddressList`, the `Addresslist` class, and -the +This slice is sourced from the exact CE `7.7.0.10621` x64 `celua.txt` fixture: `getAddressList`, the `Addresslist` class, and the `MemoryRecord` members used above. Its protected-call tests exercise the managed boundary; they do not prove a live CE GUI thread contract. The classic plugin callback type 0 record is a separate ABI concern and is not this object API. diff --git a/exemples/api/README.md b/exemples/api/README.md index ac298ec4..aa9c9ad4 100644 --- a/exemples/api/README.md +++ b/exemples/api/README.md @@ -78,7 +78,7 @@ dotnet add package CheatEngine.SDK --version 1.0.0 | `AllowUnsafeBlocks` | Consumer opt-in | Set `true` for `[LuaFunction]` exports, whose generated thunks take native addresses; a `[LuaGlobal]`-only project can leave it `false` | | `EnableDynamicLoading` | `true` while empty | Copies referenced assemblies next to your plugin and writes its runtime config | | `CheatEngineSdkGenerateEntryPoint` | `true` while empty | Set `false` to write `CESDK.CESDK.CEPluginInitialize` by hand | -| `PlatformTarget` | yours | Must not be `x86`: the build stops with `CESDK9101` | +| `PlatformTarget` | yours | Leave it unset, or set `AnyCPU` or `x64`; any other explicit value (`x86`, `ARM`, `ARM64`, …) stops the build with `CESDK9101` (`PlatformTargetTests`) | | Requirement | Version | |--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -328,7 +328,7 @@ returns the Cheat Engine name as UTF-8 (empty for an undefined value or a flag c The normal public API is organized by domain; raw Lua names stay inside the binding layer. Every slice distinguishes a disabled plugin (a lifecycle error) from a CE-reported failure, an unavailable global, protected Lua failure, or an invalid marshalled result. Its source, CE version, architecture, thread requirement, ownership, and normal return -semantics belong in the [capability matrix](../../documentations/CheatEngine.SDK/capability-matrix.md). +semantics are catalogued in the audit dossier, not republished in this repository. | Domain | Public direction | Ownership and thread boundary | |--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -344,8 +344,8 @@ semantics belong in the [capability matrix](../../documentations/CheatEngine.SDK The owned `AutoAssemblerPatch` is the low-level primitive; high-level Auto Assembler capabilities, arbitrary execution/injection, debugger, DBK/DBVM, UI/D3D, network, and SQL domains remain intentionally opt-in and outside the -ordinary API path until they have a capability, authorization, lifetime, rollback, and live-test contract. See -the [advanced-domain boundaries](../../documentations/CheatEngine.SDK/advanced-domains/README.md). +ordinary API path until they have a capability, authorization, lifetime, rollback, and live-test contract. Their +dispositions are tracked as deferred families in the audit dossier, not republished in this repository. ## Threads diff --git a/exemples/recipes/structures/README.md b/exemples/recipes/structures/README.md index b9f3dfd6..32cbb69e 100644 --- a/exemples/recipes/structures/README.md +++ b/exemples/recipes/structures/README.md @@ -4,7 +4,7 @@ **A deliberately deferred recipe: do not manufacture ownership for Cheat Engine structures.** -**Level** `Advanced` · **Time** `5 min` · **Needs** the Engine capability matrix +**Level** `Advanced` · **Time** `5 min` · **Needs** the Lua surface catalogue [Examples index](../../README.md) · [Recipes](../README.md) @@ -50,8 +50,8 @@ the exact CE build: | Lifetime | A factory-issued `Owned` only if deterministic destruction is proven; otherwise a borrowed handle or no API | | Tests | Fixture tests for stack/cleanup plus an isolated, opt-in CE 7.7 live probe | -That work belongs to the evidence and capability process, not to a recipe that guesses from an object pointer. The -[capability matrix](../../../documentations/CheatEngine.SDK/capability-matrix.md) is the current source of truth. +That work belongs to the evidence and capability process, not to a recipe that guesses from an object pointer. Its +disposition is tracked as a deferred family in the audit dossier, not republished in this repository. ## Before you move on diff --git a/global.json b/global.json index 9773a5f5..6aea4700 100644 --- a/global.json +++ b/global.json @@ -1,8 +1,9 @@ { "sdk": { "version": "10.0.401", - "rollForward": "latestFeature", - "allowPrerelease": false + "rollForward": "disable", + "allowPrerelease": false, + "errorMessage": "CheatEngine.SDK builds only with .NET SDK 10.0.401 exactly (global.json rollForward: disable, NuGet lock files). Install it with: winget install Microsoft.DotNet.SDK.10 --version 10.0.401" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/libs/CheatEngine.SDK.Abi/Native/AddressListPluginInit.cs b/libs/CheatEngine.SDK.Abi/Native/AddressListPluginInit.cs index c0c46036..eaa9526d 100644 --- a/libs/CheatEngine.SDK.Abi/Native/AddressListPluginInit.cs +++ b/libs/CheatEngine.SDK.Abi/Native/AddressListPluginInit.cs @@ -14,10 +14,13 @@ namespace CheatEngine.SDK.Abi.Native; /// that transcription; it does not qualify a live callback boundary. /// /// -/// Not mapped: the selection record the callback receives. The header declares its address field -/// pointer-sized, the Pascal unit's callback record declares it 32-bit (and the unit carries a second variant with -/// a 1-byte pointer flag). The two layouts differ on 64-bit from the second field on and it is not established -/// which one the host follows, so the parameter stays void*. +/// The selection record the callback receives. Its oracle is the host type +/// TPlugin0_SelectedRecord of the pinned plugin.pas (lines 726-735, address: ptrUint, +/// ispointer: BOOL), which agrees with the C header and is mirrored by the internal +/// PluginType0Record. The Pascal kit unit's two variants (address: dword, and a one-byte +/// ispointer: boolean) are known-wrong for x64 (see libs/CheatEngine.SDK.Abi/README.md). What the CE 7.7.0.10621 +/// binary passes is still not observed (no managed-hostfxr route registers a type-0 function), so the +/// callback stays void*: no callable signature is published. /// /// /// Passed by address to the RegisterFunction slot of the classic table; the record itself only has to diff --git a/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs b/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs index 73db789e..8f738885 100644 --- a/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs +++ b/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs @@ -30,7 +30,7 @@ public BoundedDebugEventObservationBuffer(int capacity, DebugEventObservationOve { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); if (overflowPolicy is not DebugEventObservationOverflowPolicy.DropNewest - and not DebugEventObservationOverflowPolicy.DropOldest) + and not DebugEventObservationOverflowPolicy.DropOldest) { throw new ArgumentOutOfRangeException(nameof(overflowPolicy)); } diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs index 58b0e44d..2cb6520d 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs @@ -207,9 +207,9 @@ private static int Dispatch(void* nativeEvent) } if (!TryEnterActiveCallback( - out ClassicDebugEventDispatcher? dispatcher, - out DebugEventDecisionHandler handler, - out BoundedDebugEventObservationBuffer? observations)) + out ClassicDebugEventDispatcher? dispatcher, + out DebugEventDecisionHandler handler, + out BoundedDebugEventObservationBuffer? observations)) { return 0; } diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs index c224ed43..1234f740 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs @@ -7,15 +7,27 @@ namespace CheatEngine.SDK.Abi.Native; /// Copies the qualified physical prefix of a classic exported-functions table from caller-bounded bytes. ///
/// -/// This is not a classic-host facade. It neither retains the source bytes nor invokes, dereferences, or assigns -/// any slot in the copied table. A future host integration must establish the source buffer's lifetime separately. +/// +/// This is not a classic-host facade. It neither retains the source bytes nor invokes, dereferences, or assigns +/// any slot in the copied table. A future host integration must establish the source buffer's lifetime +/// separately. +/// +/// +/// All or nothing. The copy succeeds only when both the declared size and the physical buffer cover the +/// whole prefix, = 144 bytes, the minDeclaredSize of slot 17 in +/// tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json. A table that declares, say, 64 bytes yields no prefix at all, +/// not the first seven slots; ClassicExportedFunctionsSlotReader is the per-slot observation path. +/// /// internal static class ClassicExportedFunctionsPrefixReader { /// Number of bytes occupied by the table's declared-size field. internal const int DeclaredSizeByteCount = sizeof(int); - /// Number of bytes in the only physically mapped classic table prefix. + /// + /// Number of bytes in the only physically mapped classic table prefix: the minimum declared size of registry slot + /// 17 (8 * (17 + 1)). + /// internal const int DirectPrefixByteCount = 144; /// diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsSlotReader.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsSlotReader.cs new file mode 100644 index 00000000..95c88236 --- /dev/null +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsSlotReader.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace CheatEngine.SDK.Abi.Native; + +/// +/// Observes one pointer-sized slot of a classic ExportedFunctions table from caller-bounded bytes, after +/// checking both the table's declared size and the physical buffer against that slot's minimum size. +/// +/// +/// +/// Observation only. The reader never dereferences, invokes, assigns or caches a slot. It exists so that +/// the rule of audit annex 04 is executable before any classic facade: reading slot N needs a declared +/// size of at least 8 * (N + 1) bytes (the registry's minDeclaredSize), a larger table is +/// compatible, and a slot beyond the declared or physical size is refused before any byte of it is read. +/// +/// +/// Scope. Slots 1 to 158 of TExportedFunctions5 (plugin contract 6, 159 fields, 1272 bytes on x64, +/// tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json); slot 0 is the declared size itself. No managed-hostfxr plugin +/// receives this table, so there is no production caller: the classic facade is deferred and every slot stays +/// NotObserved on the qualifiable profile. x64 only, like the rest of the assembly. +/// +/// +[SuppressMessage("Meziantou.Analyzer", "MA0182", + Justification = + "Intentionally internal, observation-only classic slot reader, exercised by friend-assembly Q39 tests; no production caller exists because the classic facade is deferred (no managed-hostfxr route reaches the classic table).")] +internal static class ClassicExportedFunctionsSlotReader +{ + /// Number of fields of the host table type TExportedFunctions5, slot 0 included. + internal const int SlotCount = 159; + + /// x64 size of the complete table: 8 bytes per slot, the 32-bit slot 0 padded to 8. + internal const int TableByteCount = 1272; + + private const int SlotByteCount = 8; + + /// + /// Reads the raw value of when the declared size and the physical buffer both reach + /// 8 * (slot + 1) bytes. + /// + /// The table bytes, starting at its 32-bit declared-size field. + /// A slot index from 1 to 158. + /// The observation, or when refused. + /// + /// for an out-of-range slot, a non-x64 process, a buffer shorter than the declared-size + /// field, or a declared or physical size below the slot's minimum; otherwise . + /// + internal static bool TryReadSlot(ReadOnlySpan tableBytes, int slot, out ClassicSlotObservation observation) + { + observation = default; + if (slot < 1 || slot >= SlotCount || !AbiArchitecture.IsSupported || IntPtr.Size != SlotByteCount) + { + return false; + } + + if (tableBytes.Length < sizeof(int)) + { + return false; + } + + int requiredBytes = SlotByteCount * (slot + 1); + int declaredSize = MemoryMarshal.Read(tableBytes); + if (declaredSize < requiredBytes || tableBytes.Length < requiredBytes) + { + return false; + } + + nint rawValue = MemoryMarshal.Read(tableBytes.Slice(SlotByteCount * slot, SlotByteCount)); + observation = new ClassicSlotObservation(slot, rawValue); + return true; + } +} diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicSlotObservation.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicSlotObservation.cs new file mode 100644 index 00000000..3c56e562 --- /dev/null +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicSlotObservation.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace CheatEngine.SDK.Abi.Native; + +/// +/// One raw read of one slot of a classic ExportedFunctions table, as returned by +/// . +/// +/// +/// +/// An observation, not a capability: is the pointer-sized value the table held when it +/// was read. Depending on the slot (see tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json) it is a function address, +/// the address of a host cell that holds a function pointer, the address of a host variable, or nil. Nothing +/// here dereferences, invokes or caches it; two reads of the same slot are two independent observations. +/// +/// Layout (64-bit): 16 bytes. 0, 8. +/// +[SuppressMessage("Meziantou.Analyzer", "MA0182", + Justification = + "Internal observation value of the classic slot reader, exercised by friend-assembly Q39 tests. The classic facade that would consume it is deferred (no managed-hostfxr route reaches the classic table).")] +[StructLayout(LayoutKind.Sequential)] +internal readonly struct ClassicSlotObservation +{ + /// Initializes an observation of . + /// The slot index, 1 to 158. + /// The pointer-sized value read at offset 8 * slot. + internal ClassicSlotObservation(int slot, nint rawValue) + { + Slot = slot; + RawValue = rawValue; + } + + /// The slot index (offset 0). + internal readonly int Slot; + + /// The raw pointer-sized value of the slot (offset 8). Never dereferenced by the SDK. + internal readonly nint RawValue; + + /// Gets a value indicating whether the slot held nil: nothing to call, observe or hook. + internal bool IsNull => RawValue == 0; +} diff --git a/libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs b/libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs index 5ea7255e..2b213159 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs @@ -13,20 +13,24 @@ namespace CheatEngine.SDK.Abi.Native; /// validate , and impose its own lifetime, main-thread, and failure policy. /// /// -/// -/// Evidence status: source-indexed C header plus compiled-transcription fixture for x64 layout. -/// -/// The fields through -/// are the contiguous C-header-declared part of -/// ExportedFunctions in the pinned historical cepluginsdk.h. The MSVC x64 fixture validates the -/// physical 144-byte transcription, not a live host. All direct functions are declared __stdcall in the -/// C header. That declaration does not itself make an individual slot callable: conflicting and historically -/// null slots remain opaque below. +/// Evidence: slots 0-17 of the classic slot registry (tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json). Its +/// authority is the host type TExportedFunctions5 of the pinned plugin.pas (fields lines 47-226, +/// assignments of TPluginHandler.create lines 1856-2045); cepluginsdk.h and +/// cepluginsdk.pas are mirror columns only. Offsets are Deduced from the declarations (x64 natural +/// alignment: slot 0 is a 32-bit integer at 0, slot N at 8N) and the MSVC x64 fixture validates the physical +/// 144-byte transcription, not a live host: on ce-7.7.0.10621-x64-managed-hostfxr every slot stays +/// NotObserved. The pluginexports.pas implementations assigned to the typed slots are declared +/// stdcall; that declaration does not itself make an individual slot callable: the nil slot 14 +/// () and the divergent slot 17 (, registry +/// divergence D01) remain opaque below, and ClassicSlotRegistryPrefixTests keeps this type and the +/// registry in lockstep. /// /// -/// The next native field is ReadProcessMemory, documented by the header as a pointer to a pointer that -/// can be hooked. That hook-bearing suffix, Delphi object references, and all later capabilities are purposely -/// excluded from this type. Their ABI and ownership must be introduced with a dedicated dangerous facade. +/// Slot 18 (ReadProcessMemory) and the 140 slots after it are purposely excluded from this type: they mix +/// function-pointer cells the host lets plugins hook, data and object-reference cells, nil slots and direct +/// function addresses (see the registry's nature and indirection). Their ABI and ownership must be +/// introduced with a dedicated facade after a per-slot qualification; no suffix slot has a +/// delegate* here. /// /// [SuppressMessage("Meziantou.Analyzer", "MA0182", diff --git a/libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs b/libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs index 7cdf4951..aba09c29 100644 --- a/libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs +++ b/libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs @@ -1,11 +1,19 @@ namespace CheatEngine.SDK.Abi.Native; /// -/// The names of the three functions a native plugin DLL exports (the path a Native AOT build of a plugin -/// would take, because such a DLL has no CLR header and Cheat Engine therefore treats it as native). +/// The names of the three functions a classic native plugin DLL exports. Classic native load path only: a +/// NativeAOT DLL exposing them is not a supported CheatEngine.SDK profile, because Cheat Engine unloads plugins with +/// FreeLibrary, which .NET does not support for NativeAOT libraries; see libs/CheatEngine.SDK.Abi/README.md. /// /// /// +/// Not a replacement for the managed bootstrap. Exporting these three names instead of the generated +/// CESDK.CESDK.CEPluginInitialize changes the load profile, the table the plugin receives (the classic +/// 159-slot table instead of ) and the unload contract at once. +/// Analyzer CESDK0006 reports an [UnmanagedCallersOnly] export of any CEPlugin_ name, and +/// the packaged build target reports PublishAot on a plugin library with CESDK9102. +/// +/// /// All three are stdcall and return a 4-byte boolean (): /// /// @@ -28,7 +36,9 @@ namespace CheatEngine.SDK.Abi.Native; /// /// /// Evidence (verified): the three prototypes at the end of cepluginsdk.h and the module-definition -/// file of the official C sample plugin (CE 7.7.0.10621). The Native AOT load path as a whole is untested. +/// file of the official C sample plugin (CE 7.7.0.10621). The classic native load path is not qualified on any +/// profile, and a NativeAOT plugin DLL is not supported (unload restriction: +/// https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries). /// /// /// Compile-time constants, so they can be used as [UnmanagedCallersOnly(EntryPoint = ...)] arguments. diff --git a/libs/CheatEngine.SDK.Abi/Native/PluginType.cs b/libs/CheatEngine.SDK.Abi/Native/PluginType.cs index 2810123e..0aecf617 100644 --- a/libs/CheatEngine.SDK.Abi/Native/PluginType.cs +++ b/libs/CheatEngine.SDK.Abi/Native/PluginType.cs @@ -15,7 +15,12 @@ namespace CheatEngine.SDK.Abi.Native; /// byte (inferred from the compiler mode the unit selects), which is harmless because the value only ever /// travels by value in a register or a full stack slot, never inside a structure. /// -/// Native (Native AOT) load path only. A managed plugin has no RegisterFunction slot. +/// +/// Classic native load path only; not a supported CheatEngine.SDK profile (a NativeAOT plugin DLL cannot be +/// unloaded by Cheat Engine, see libs/CheatEngine.SDK.Abi/README.md). A managed plugin has no +/// RegisterFunction slot. The registry's callbackCategories keep values, contexts and host forms of +/// all nine members in lockstep with this enumeration. +/// /// public enum PluginType { diff --git a/libs/CheatEngine.SDK.Abi/Native/PluginType0Record.cs b/libs/CheatEngine.SDK.Abi/Native/PluginType0Record.cs index 61363e22..9bd68569 100644 --- a/libs/CheatEngine.SDK.Abi/Native/PluginType0Record.cs +++ b/libs/CheatEngine.SDK.Abi/Native/PluginType0Record.cs @@ -13,11 +13,26 @@ namespace CheatEngine.SDK.Abi.Native; /// callback-record divergence explicit. /// /// -/// Evidence status: source-indexed C header plus compiled-transcription fixture for x64 layout. The -/// declaration is PLUGINTYPE0_RECORD in the pinned historical cepluginsdk.h. The MSVC x64 -/// fixture validates this 48-byte transcription and its offsets, but does not establish which divergent Pascal -/// callback record a live host supplies. The corresponding Pascal SDK exposes a one-byte boolean pointer -/// flag, so it remains corroborating context rather than authority for a callable projection. +/// Oracle: the host type actually called. The pinned host source passes a +/// TPlugin0_SelectedRecord declared in plugin.pas lines 726-735 (commit ec45d5f, SHA-256 +/// 358f51a39ad14d00ecba3c9137f440152d4ab85f1d2498068fa81fca906d09db): address: ptrUint and +/// ispointer: BOOL. It agrees field by field with the C header PLUGINTYPE0_RECORD +/// (cepluginsdk.h lines 27-37), and this record matches both: offsets 0/8/16/20/24/32/40/41, widths +/// 8/8/4/4/8/8/1/1, 48 bytes (evidence Deduced from the declarations). +/// +/// +/// Known-wrong mirrors. The Pascal kit unit cepluginsdk.pas (lines 161-170) declares a record +/// of the same name with address: dword, which moves ispointer to 12 and countoffsets to +/// 16 while keeping 48 bytes, and a TSelectedRecord (lines 147-156) with a one-byte +/// ispointer: boolean at the host offsets. Neither is x64 authority; a size check alone cannot tell them +/// apart (per-field table in libs/CheatEngine.SDK.Abi/README.md, tests SelectedRecordOracleTests and the +/// compiled fixture facts host_plugin0_selected_record, pascal_dword_mirror_selected_record, +/// pascal_boolean_mirror_selected_record). +/// +/// +/// Not observed on the CE 7.7 binary. No managed-hostfxr route reaches a type-0 registration, so which +/// record the 7.7.0.10621 binary passes stays NotObserved; the type-0 +/// therefore remains void* and no callable signature exists. /// /// /// Strings are host-owned, NUL-terminated bytes. is host-owned and contains exactly @@ -27,7 +42,7 @@ namespace CheatEngine.SDK.Abi.Native; /// [SuppressMessage("Meziantou.Analyzer", "MA0182", Justification = - "This intentionally internal C-header mirror is retained as the type-0 callback ABI contract, exercised by friend-assembly layout tests, and verified against the native-fixture contract. It remains opaque until a safe hosting facade can own the borrowed record lifetime.")] + "This intentionally internal record is the type-0 callback ABI contract (host type of plugin.pas, equal to the C header), exercised by friend-assembly layout and oracle tests, and verified against the native-fixture contract. It remains opaque until a safe hosting facade can own the borrowed record lifetime.")] [StructLayout(LayoutKind.Sequential)] internal unsafe struct PluginType0Record { diff --git a/libs/CheatEngine.SDK.Abi/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Abi/PublicAPI.Shipped.txt new file mode 100644 index 00000000..af9849db --- /dev/null +++ b/libs/CheatEngine.SDK.Abi/PublicAPI.Shipped.txt @@ -0,0 +1,126 @@ +#nullable enable +CheatEngine.SDK.Abi.AbiArchitecture +CheatEngine.SDK.Abi.AbiConstants +CheatEngine.SDK.Abi.Bool32 +CheatEngine.SDK.Abi.Bool32.Bool32() -> void +CheatEngine.SDK.Abi.Bool32.Bool32(int rawValue) -> void +CheatEngine.SDK.Abi.Bool32.Equals(CheatEngine.SDK.Abi.Bool32 other) -> bool +CheatEngine.SDK.Abi.Bool32.IsTrue.get -> bool +CheatEngine.SDK.Abi.Bool32.RawValue.get -> int +CheatEngine.SDK.Abi.Bool32.ToBoolean() -> bool +CheatEngine.SDK.Abi.Bool8 +CheatEngine.SDK.Abi.Bool8.Bool8() -> void +CheatEngine.SDK.Abi.Bool8.Bool8(byte rawValue) -> void +CheatEngine.SDK.Abi.Bool8.Equals(CheatEngine.SDK.Abi.Bool8 other) -> bool +CheatEngine.SDK.Abi.Bool8.IsTrue.get -> bool +CheatEngine.SDK.Abi.Bool8.RawValue.get -> byte +CheatEngine.SDK.Abi.Bool8.ToBoolean() -> bool +CheatEngine.SDK.Abi.Managed.ManagedEntryPoint +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.CheckSynchronize -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.GetLuaState -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.LuaPushClassInstance -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.LuaRegister -> void* +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.ManagedExportedFunctions() -> void +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.ProcessMessages -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.SizeOfExportedFunctions -> int +CheatEngine.SDK.Abi.Managed.PluginInitRecord +CheatEngine.SDK.Abi.Managed.PluginInitRecord.DisablePlugin -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.PluginInitRecord.EnablePlugin -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.PluginInitRecord.GetVersion -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.PluginInitRecord.Name -> byte* +CheatEngine.SDK.Abi.Managed.PluginInitRecord.PluginInitRecord() -> void +CheatEngine.SDK.Abi.Managed.PluginInitRecord.Version -> uint +CheatEngine.SDK.Abi.Native.AddressListPluginInit +CheatEngine.SDK.Abi.Native.AddressListPluginInit.AddressListPluginInit() -> void +CheatEngine.SDK.Abi.Native.AddressListPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.AddressListPluginInit.Name -> byte* +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase.Finalize = 3 -> CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase.Initialize = 0 -> CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase.Phase1 = 1 -> CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase.Phase2 = 2 -> CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPluginInit +CheatEngine.SDK.Abi.Native.AutoAssemblerPluginInit.AutoAssemblerPluginInit() -> void +CheatEngine.SDK.Abi.Native.AutoAssemblerPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.DebugEventPluginInit +CheatEngine.SDK.Abi.Native.DebugEventPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.DebugEventPluginInit.DebugEventPluginInit() -> void +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.CallbackOnPopup -> void* +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.DisassemblerContextPluginInit() -> void +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Name -> byte* +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Shortcut -> byte* +CheatEngine.SDK.Abi.Native.DisassemblerRenderLinePluginInit +CheatEngine.SDK.Abi.Native.DisassemblerRenderLinePluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.DisassemblerRenderLinePluginInit.DisassemblerRenderLinePluginInit() -> void +CheatEngine.SDK.Abi.Native.FunctionPointerChangePluginInit +CheatEngine.SDK.Abi.Native.FunctionPointerChangePluginInit.Callback -> void* +CheatEngine.SDK.Abi.Native.FunctionPointerChangePluginInit.FunctionPointerChangePluginInit() -> void +CheatEngine.SDK.Abi.Native.MainMenuPluginInit +CheatEngine.SDK.Abi.Native.MainMenuPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.MainMenuPluginInit.MainMenuPluginInit() -> void +CheatEngine.SDK.Abi.Native.MainMenuPluginInit.Name -> byte* +CheatEngine.SDK.Abi.Native.MainMenuPluginInit.Shortcut -> byte* +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit.MemoryViewPluginInit() -> void +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit.Name -> byte* +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit.Shortcut -> byte* +CheatEngine.SDK.Abi.Native.NativeExportNames +CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.AddressList = 0 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.AutoAssembler = 8 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.DisassemblerContext = 6 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.DisassemblerRenderLine = 7 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.FunctionPointerChange = 4 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.MainMenu = 5 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.MemoryView = 1 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.OnDebugEvent = 2 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.ProcessWatcherEvent = 3 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginVersion +CheatEngine.SDK.Abi.Native.PluginVersion.PluginName -> byte* +CheatEngine.SDK.Abi.Native.PluginVersion.PluginVersion() -> void +CheatEngine.SDK.Abi.Native.PluginVersion.Version -> uint +CheatEngine.SDK.Abi.Native.ProcessWatcherPluginInit +CheatEngine.SDK.Abi.Native.ProcessWatcherPluginInit.Callback -> void* +CheatEngine.SDK.Abi.Native.ProcessWatcherPluginInit.ProcessWatcherPluginInit() -> void +const CheatEngine.SDK.Abi.AbiConstants.SdkVersion = 6 -> int +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.Failure = 0 -> int +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.FullTypeName = "CESDK.CESDK" -> string! +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.MethodName = "CEPluginInitialize" -> string! +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.Namespace = "CESDK" -> string! +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.Success = 1 -> int +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.TypeName = "CESDK" -> string! +const CheatEngine.SDK.Abi.Native.NativeExportNames.DisablePlugin = "CEPlugin_DisablePlugin" -> string! +const CheatEngine.SDK.Abi.Native.NativeExportNames.GetVersion = "CEPlugin_GetVersion" -> string! +const CheatEngine.SDK.Abi.Native.NativeExportNames.InitializePlugin = "CEPlugin_InitializePlugin" -> string! +override CheatEngine.SDK.Abi.Bool32.Equals(object? obj) -> bool +override CheatEngine.SDK.Abi.Bool32.GetHashCode() -> int +override CheatEngine.SDK.Abi.Bool32.ToString() -> string! +override CheatEngine.SDK.Abi.Bool8.Equals(object? obj) -> bool +override CheatEngine.SDK.Abi.Bool8.GetHashCode() -> int +override CheatEngine.SDK.Abi.Bool8.ToString() -> string! +static CheatEngine.SDK.Abi.AbiArchitecture.IsSupported.get -> bool +static CheatEngine.SDK.Abi.AbiArchitecture.ThrowIfUnsupported() -> void +static CheatEngine.SDK.Abi.Bool32.False.get -> CheatEngine.SDK.Abi.Bool32 +static CheatEngine.SDK.Abi.Bool32.FromBoolean(bool value) -> CheatEngine.SDK.Abi.Bool32 +static CheatEngine.SDK.Abi.Bool32.True.get -> CheatEngine.SDK.Abi.Bool32 +static CheatEngine.SDK.Abi.Bool32.explicit operator bool(CheatEngine.SDK.Abi.Bool32 value) -> bool +static CheatEngine.SDK.Abi.Bool32.implicit operator CheatEngine.SDK.Abi.Bool32(bool value) -> CheatEngine.SDK.Abi.Bool32 +static CheatEngine.SDK.Abi.Bool32.operator !(CheatEngine.SDK.Abi.Bool32 value) -> bool +static CheatEngine.SDK.Abi.Bool32.operator !=(CheatEngine.SDK.Abi.Bool32 left, CheatEngine.SDK.Abi.Bool32 right) -> bool +static CheatEngine.SDK.Abi.Bool32.operator ==(CheatEngine.SDK.Abi.Bool32 left, CheatEngine.SDK.Abi.Bool32 right) -> bool +static CheatEngine.SDK.Abi.Bool32.operator false(CheatEngine.SDK.Abi.Bool32 value) -> bool +static CheatEngine.SDK.Abi.Bool32.operator true(CheatEngine.SDK.Abi.Bool32 value) -> bool +static CheatEngine.SDK.Abi.Bool8.False.get -> CheatEngine.SDK.Abi.Bool8 +static CheatEngine.SDK.Abi.Bool8.FromBoolean(bool value) -> CheatEngine.SDK.Abi.Bool8 +static CheatEngine.SDK.Abi.Bool8.True.get -> CheatEngine.SDK.Abi.Bool8 +static CheatEngine.SDK.Abi.Bool8.explicit operator bool(CheatEngine.SDK.Abi.Bool8 value) -> bool +static CheatEngine.SDK.Abi.Bool8.implicit operator CheatEngine.SDK.Abi.Bool8(bool value) -> CheatEngine.SDK.Abi.Bool8 +static CheatEngine.SDK.Abi.Bool8.operator !(CheatEngine.SDK.Abi.Bool8 value) -> bool +static CheatEngine.SDK.Abi.Bool8.operator !=(CheatEngine.SDK.Abi.Bool8 left, CheatEngine.SDK.Abi.Bool8 right) -> bool +static CheatEngine.SDK.Abi.Bool8.operator ==(CheatEngine.SDK.Abi.Bool8 left, CheatEngine.SDK.Abi.Bool8 right) -> bool +static CheatEngine.SDK.Abi.Bool8.operator false(CheatEngine.SDK.Abi.Bool8 value) -> bool +static CheatEngine.SDK.Abi.Bool8.operator true(CheatEngine.SDK.Abi.Bool8 value) -> bool diff --git a/libs/CheatEngine.SDK.Abi/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Abi/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..13e0fce2 --- /dev/null +++ b/libs/CheatEngine.SDK.Abi/PublicAPI.Unshipped.txt @@ -0,0 +1,28 @@ +#nullable enable +*REMOVED*CheatEngine.SDK.Abi.Native.AddressListPluginInit.Callback -> delegate* unmanaged[Stdcall] +*REMOVED*CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.AddressListPluginInit.Callback -> void* +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.BoundedDebugEventObservationBuffer(int capacity, CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy overflowPolicy) -> void +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.Capacity.get -> int +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.Count.get -> int +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.DroppedObservationCount.get -> long +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.OverflowPolicy.get -> CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.TryPublish(in CheatEngine.SDK.Abi.Native.DebugEventObservation observation) -> bool +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.TryRead(out CheatEngine.SDK.Abi.Native.DebugEventObservation observation) -> bool +CheatEngine.SDK.Abi.Native.DebugEventDecision +CheatEngine.SDK.Abi.Native.DebugEventDecision.ContinueWithCheatEngine = 0 -> CheatEngine.SDK.Abi.Native.DebugEventDecision +CheatEngine.SDK.Abi.Native.DebugEventDecision.PluginOwnsContinuation = 1 -> CheatEngine.SDK.Abi.Native.DebugEventDecision +CheatEngine.SDK.Abi.Native.DebugEventDecisionHandler +CheatEngine.SDK.Abi.Native.DebugEventObservation +CheatEngine.SDK.Abi.Native.DebugEventObservation.DebugEventObservation() -> void +CheatEngine.SDK.Abi.Native.DebugEventObservation.DebugEventObservation(long sequenceNumber, uint eventCode, uint processId, uint threadId) -> void +CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy +CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy.DropNewest = 0 -> CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy +CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy.DropOldest = 1 -> CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Callback -> void* +readonly CheatEngine.SDK.Abi.Native.DebugEventObservation.EventCode -> uint +readonly CheatEngine.SDK.Abi.Native.DebugEventObservation.ProcessId -> uint +readonly CheatEngine.SDK.Abi.Native.DebugEventObservation.SequenceNumber -> long +readonly CheatEngine.SDK.Abi.Native.DebugEventObservation.ThreadId -> uint +virtual CheatEngine.SDK.Abi.Native.DebugEventDecisionHandler.Invoke(in CheatEngine.SDK.Abi.Native.DebugEventObservation observation) -> CheatEngine.SDK.Abi.Native.DebugEventDecision diff --git a/libs/CheatEngine.SDK.Abi/README.md b/libs/CheatEngine.SDK.Abi/README.md index 168a61bd..ba45a5e8 100644 --- a/libs/CheatEngine.SDK.Abi/README.md +++ b/libs/CheatEngine.SDK.Abi/README.md @@ -64,7 +64,8 @@ official [pinned and [pinned `plugin.pas`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/plugin.pas). -The source index records the installed-file hashes reviewed for the historic CE 7.7 baseline. The independently +These installed-file hashes were recorded in a source index that was retired on 2026-09-22 and is not restored; they +remain declarations (`DeclaredRepo`) until an exact-host measurement re-measures them. The independently compiled fixture is deliberately more limited: it compiles a checked-in transcription of the pinned upstream C-header subset under MSVC x64, validates 104 facts, and compares its `sizeof`, `offsetof`, alignment, export, and topology facts with a versioned expectation. The Debug CI test run also passes that facts file into a compiled managed test, which measures @@ -81,6 +82,96 @@ write a one-byte flag, and do not invoke this callback slot from a production pl The `CheatEngine.SDK` package embeds the assembly and its XML documentation under `lib/net10.0`. It is not a package of its own. +## Two tables, two routes + +Cheat Engine hands a plugin one of two exported-function tables, depending on how the plugin was loaded: + +| Route | Entry point | Table the plugin receives | +|-------------------|-----------------------------------------------------|--------------------------------------------------------------------------------------------| +| Managed (hostfxr) | `CESDK.CESDK.CEPluginInitialize(IntPtr, int)` | `ManagedExportedFunctions`: 48 bytes, six fields, filled by Cheat Engine's managed loader | +| Classic native | `CEPlugin_GetVersion`, `CEPlugin_InitializePlugin` | `TExportedFunctions5` of `plugin.pas`: 159 slots, 1272 bytes on x64 | + +The tables are distinct: the managed table is not a prefix of the classic one, and nothing converts one into the +other. A CheatEngine.SDK plugin loads through the managed route only, so it has **no route to the classic table**: the +classic records in this assembly (the internal 144-byte `ExportedFunctionsPrefix`, the classic dispatcher) are +layouts and source-contract code, never a way for a managed plugin to reach a classic slot. +`AbiRouteSeparationTests.Managed_exports_are_never_converted_to_the_classic_prefix` and +`AbiRouteSeparationTests.Only_the_classic_dispatcher_and_readers_consume_the_prefix` +(`tests/CheatEngine.SDK.Repository.Tests/Abi/`) enforce the separation in `libs/**`. The 159-slot classic table itself +— its authority, mirrors and divergences from the C and Pascal kits — is committed test data, never a top-level +`docs/` page: `tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json`, read only by +`ClassicSlotRegistryDocumentTests` and `ClassicSlotRegistryPrefixTests`. + +## The type-0 selection record oracle + +The oracle for `PluginType0Record` is the host type actually passed: `TPlugin0_SelectedRecord` of the pinned +`plugin.pas` (lines 726-735, `address: ptrUint; ispointer: BOOL`), which the C header `PLUGINTYPE0_RECORD` agrees +with. The Pascal kit unit `cepluginsdk.pas` has two known-wrong mirrors. All three are 48 bytes, so only a per-field +check separates them (`SelectedRecordOracleTests`, registry divergence D09): + +| Field | Host type (oracle) | `cepluginsdk.pas` dword mirror (lines 161-170) | `cepluginsdk.pas` `TSelectedRecord` (lines 147-156) | +|-----------------------|---------------------|--------------------------------------------------|--------------------------------------------------------| +| `interpretedaddress` | 0, 8 bytes | 0, 8 bytes | 0, 8 bytes | +| `address` | 8, 8 bytes | 8, **4 bytes** | 8, 8 bytes | +| `ispointer` | 16, 4 bytes | **12**, 4 bytes | 16, **1 byte** | +| `countoffsets` | 20, 4 bytes | **16**, 4 bytes | 20, 4 bytes | +| `offsets` | 24, 8 bytes | 24, 8 bytes | 24, 8 bytes | +| `description` | 32, 8 bytes | 32, 8 bytes | 32, 8 bytes | +| `valuetype` | 40, 1 byte | 40, 1 byte | 40, 1 byte | +| `size` | 41, 1 byte | 41, 1 byte | 41, 1 byte | + +Which record the 7.7.0.10621 binary passes is `NotObserved`: no managed route reaches a type-0 registration, so the +type-0 callback stays `void*`. + +## NativeAOT plugin profile (F02) + +This section publishes the restrictions of audit finding F02 before any support claim: **a NativeAOT plugin DLL is +not a supported CheatEngine.SDK profile.** Unloading a Native AOT library with `FreeLibrary` or `dlclose` is not +supported by .NET (https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries, audit source EXT-01), and +Cheat Engine removes a plugin with `FreeLibrary`. + +| Profile | Status in CheatEngine.SDK 2.0 | Evidence and boundary | +|---|---|---| +| Historical CLR route: `MSCorEE.dll` hosting with a string entry point, as in the historical public C# template | Documentary only, **not supported** | Described by the pinned public source (`ce-public-src-ec45d5f`), which is never qualifiable. The SDK does not downgrade to that bootstrap. | +| **Managed hostfxr route** | **The only qualifiable profile**: `ce-7.7.0.10621-x64-managed-hostfxr` | Cheat Engine 7.7.0.10621 x64 (`cheatengine-x86_64.exe` SHA-256 `9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d`), with a `ce.runtimeconfig.json` recorded as a local modification (`LocalModified`), never an installer baseline. The plugin is a framework-dependent folder; the SDK generates `int CESDK.CESDK.CEPluginInitialize(IntPtr args, int size)`, which fills the 36-byte packed `PluginInitRecord` and receives the 48-byte managed exports table. | +| NativeAOT plugin DLL | **Not supported** | Cheat Engine unloads plugins with `FreeLibrary`, which .NET does not support for NativeAOT libraries. No residence model exists in 2.0: a resident native adapter separate from an AOT component is a possible future architecture with its own contract and proof (audit A04-07, deferred), not a change of the existing profile. Q42 is recorded `NotApplicable` for this reason. | +| Classic native plugin exporting `CEPlugin_*` | **Not provided by the SDK** | The classic path receives the 159-slot classic table (the committed registry above); no managed route reaches it and the SDK has no classic facade. A package can never add native exports to a consumer: only `UnmanagedCallersOnly` methods of the published assembly become exports (https://learn.microsoft.com/dotnet/core/deploying/native-aot/interop#native-exports). | +| x86 or ARM64 host | **Not supported** | `AbiArchitecture` accepts x64 only; the packaged target refuses other explicit `PlatformTarget` values with `CESDK9101`. x64 layout tests make no x86 or ARM64 promise. | + +**What "AOT-compatible libraries" means.** The shipping libraries set `IsAotCompatible=true`, and +`tests/CheatEngine.SDK.AotProbe` publishes the complete shipping graph as a standalone Native AOT executable. That +establishes one thing: the libraries pass the trimming and AOT analysis for that published graph (audit A23-F02-1). It +does not establish that Cheat Engine can load, host or unload an AOT artefact, and it is not a plugin profile. **A +NativeAOT publish success is never a Cheat Engine load success.** + +**Why the NativeAOT plugin DLL route stays closed.** + +1. **Unload.** Cheat Engine's native loader calls `FreeLibrary` when it removes a plugin (pinned `plugin.pas`), and + .NET does not support unloading a NativeAOT library (EXT-01). Combining the two contracts is a deduction, not a + proof that every AOT architecture is impossible; it is enough to refuse the route until a residence model is + qualified. +2. **Table.** A classic native plugin receives the classic `ExportedFunctions` table, not the managed exports table + the SDK is built on. The two layouts are unrelated (48 bytes against 1272), and nothing converts one into the + other (ADR-02). +3. **No drop-in replacement.** Replacing the managed bootstrap with three `CEPlugin_*` exports is **not** a complete + solution: it changes the load profile, the table the plugin receives, and the unload contract at once (audit + A23-F02-3). `NativeExportNames` documents the names for completeness only, with this caveat. +4. **Coexistence.** Even a future residence model would have to repeat the two-plugin protocol (Q09, Q10): a solution + to unloading does not isolate static state or Lua globals by itself. + +| Diagnostic | Where | What it catches | +|---|---|---| +| `CESDK9102` (`analyzers/docs/CESDK9102.md`) | MSBuild warning of the packaged `build/CheatEngine.SDK.targets` (target `CheatEngineSdkWarnNativeAotPluginProfile`), direct package consumers only | A library (not `Exe`/`WinExe`) that sets `PublishAot=true`, with or without `NativeLib`. Suppressible with `NoWarn`. | +| `CESDK0006` (`analyzers/docs/CESDK0006.md`) | Roslyn analyzer shipped in the package | An `[UnmanagedCallersOnly]` method whose constant `EntryPoint` starts with `CEPlugin_`, including `NativeExportNames.*`. Unprefixed historical names are not flagged (documented limitation). | + +**Evidence.** Q41 (NativeAOT publish and export inspection, C0/C2): C0 is `Passed` from the static contract tests +below — the loader harness refuses any `CEPlugin_*` export, any missing probe export and any unexpected export +(`tests/CheatEngine.SDK.NativeAotLoaderHarness`, unit tests in `tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests`), +the probe exports exactly its two names, and `CESDK9102`/`CESDK0006` are tested; C2 stays `NotExecuted` until the +first green CI `aot` run is recorded. The harness maps the probe only in a short-lived process and never frees it +(`library.unload=not-attempted`). Q42 (removal of a NativeAOT plugin profile, C3/C4) is `NotApplicable`: the profile +is unsupported (EXT-01), so no Cheat Engine run is planned for it. + ## Use The enable callback receives a pointer to an exports record that Cheat Engine owns. Copy it during the call, honor its diff --git a/libs/CheatEngine.SDK.Abi/packages.lock.json b/libs/CheatEngine.SDK.Abi/packages.lock.json new file mode 100644 index 00000000..75a512c2 --- /dev/null +++ b/libs/CheatEngine.SDK.Abi/packages.lock.json @@ -0,0 +1,37 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + } + } + } +} \ No newline at end of file diff --git a/libs/CheatEngine.SDK.Annotations/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Annotations/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7e10bfee --- /dev/null +++ b/libs/CheatEngine.SDK.Annotations/PublicAPI.Shipped.txt @@ -0,0 +1,30 @@ +#nullable enable +CheatEngine.SDK.Annotations.Lifetime.CEOwnedAttribute +CheatEngine.SDK.Annotations.Lifetime.CEOwnedAttribute.CEOwnedAttribute() -> void +CheatEngine.SDK.Annotations.Lifetime.RequiresPluginEnabledAttribute +CheatEngine.SDK.Annotations.Lifetime.RequiresPluginEnabledAttribute.RequiresPluginEnabledAttribute() -> void +CheatEngine.SDK.Annotations.Lua.LuaClassAttribute +CheatEngine.SDK.Annotations.Lua.LuaClassAttribute.LuaClassAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaClassAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute +CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute.LuaFunctionAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute +CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute.LuaGlobalAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute +CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute.LuaMethodAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute +CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute.LuaPropertyAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaStackEffectAttribute +CheatEngine.SDK.Annotations.Lua.LuaStackEffectAttribute.Delta.get -> int +CheatEngine.SDK.Annotations.Lua.LuaStackEffectAttribute.LuaStackEffectAttribute(int delta) -> void +CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute +CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute.CheatEnginePluginAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Threading.MainThreadOnlyAttribute +CheatEngine.SDK.Annotations.Threading.MainThreadOnlyAttribute.MainThreadOnlyAttribute() -> void +CheatEngine.SDK.Annotations.Threading.RunsOnMainThreadAttribute +CheatEngine.SDK.Annotations.Threading.RunsOnMainThreadAttribute.RunsOnMainThreadAttribute() -> void diff --git a/libs/CheatEngine.SDK.Annotations/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Annotations/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..63a44e02 --- /dev/null +++ b/libs/CheatEngine.SDK.Annotations/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +#nullable enable +CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute +CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute.LuaMarshallerAttribute(System.Type! marshallerType) -> void +CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute.MarshallerType.get -> System.Type! diff --git a/libs/CheatEngine.SDK.Annotations/packages.lock.json b/libs/CheatEngine.SDK.Annotations/packages.lock.json new file mode 100644 index 00000000..75a512c2 --- /dev/null +++ b/libs/CheatEngine.SDK.Annotations/packages.lock.json @@ -0,0 +1,37 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + } + } + } +} \ No newline at end of file diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs index ca6eaa62..30036470 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs @@ -32,7 +32,7 @@ public static bool TryGetGlobal(LuaRef cache, ReadOnlySpan try { if (!LuaGlobalFunctions.TryPush(state, cache, global) || !state.TryCall(0, 1).IsOk || - !TMarshaller.TryRead(state, -1, out result)) + !TMarshaller.TryRead(state, -1, out result)) { return LuaCallSupport.Fail(state, top, out result); } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs index 2a3f924c..721ed89a 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs @@ -4,6 +4,7 @@ using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Tables; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; @@ -23,7 +24,15 @@ namespace CheatEngine.SDK.Engine.AddressList; /// /// All new arguments and results are typed values; no crosses this public boundary. /// Existing borrowed and wrappers remain compatibility -/// views and are not ownership capabilities. +/// views and are not ownership capabilities. A record attached to the address list is owned by that list: no +/// command here wraps it in an owner, and a second delete of the same identifier reports +/// without a second destroy. +/// +/// +/// Every command is refused with , before any Lua +/// call, while runs on the calling thread (for example from a script of the +/// table being loaded), and with when the Lua +/// runtime identity changed between the preflight and the mutation. /// /// public static class AddressListMutations @@ -37,6 +46,11 @@ public static class AddressListMutations [RequiresPluginEnabled] public static MemoryRecordMutationOutcome Delete(MemoryRecordId recordId) { + if (CheatTableFiles.IsLoadInProgressOnCurrentThread) + { + return NotAttempted(MemoryRecordMutationProblem.TableLoadInProgress); + } + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); LuaState state = operation.State; LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; @@ -58,7 +72,7 @@ public static MemoryRecordMutationOutcome Delete(MemoryRecordId recordId) if (LuaRuntime.CurrentStateIdentity != identity) { - return NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); + return NotAttempted(MemoryRecordMutationProblem.RuntimeIdentityChanged); } LuaStatus status = record.Handle.TryPushMethodLeavingObject(state, "destroy"u8); @@ -108,6 +122,11 @@ public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, Mem MemoryRecordParentTraversalLimit traversalLimit) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(traversalLimit.MaximumHops, nameof(traversalLimit)); + if (CheatTableFiles.IsLoadInProgressOnCurrentThread) + { + return NotAttempted(MemoryRecordMutationProblem.TableLoadInProgress); + } + if (parentId.HasValue && parentId.Value == recordId) { return NotAttempted(MemoryRecordMutationProblem.SelfParent); @@ -129,7 +148,7 @@ public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, Mem if (LuaRuntime.CurrentStateIdentity != identity) { - return NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); + return NotAttempted(MemoryRecordMutationProblem.RuntimeIdentityChanged); } if (parentId.HasValue) @@ -158,6 +177,164 @@ public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, Mem } } + /// + /// Sets the Active state of the current address-list record with and reports the + /// real effect: the state before and after, a host refusal, a pending asynchronous activation, or an indeterminate + /// effect. + /// + /// The CE MemoryRecord.ID to resolve in the current list. + /// to activate the record, to deactivate it. + /// The factual outcome of the one command. + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// + /// One admitted Lua operation: resolve the list and the record by identifier, read Active, return + /// without calling the setter when it already has the + /// requested value, set Active once, then read Active and AsyncProcessing back. A raise after + /// the setter started, or a failed read-back, is ; + /// an asynchronous record still processing is ; a + /// read-back that differs from the request is . + /// + /// + /// The setter is never called twice. Record callbacks run inside it: an OnActivate or + /// OnDeactivate can refuse, and an OnActivationFailure handler that asks Cheat Engine to retry + /// can make Cheat Engine loop inside this one call. The SDK cannot prevent a third-party handler from looping + /// and adds no retry of its own. + /// + /// + [RequiresPluginEnabled] + public static MemoryRecordActivationOutcome SetActive(MemoryRecordId recordId, bool active) + { + if (CheatTableFiles.IsLoadInProgressOnCurrentThread) + { + return ActivationNotAttempted(MemoryRecordMutationProblem.TableLoadInProgress, active, null, LuaStatus.Ok); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; + int top = state.Top; + ActivationProgress progress = new(); + try + { + MemoryRecordMutationOutcome preflight = TryGetCurrentList(state, out AddressList list); + if (preflight.Problem == MemoryRecordMutationProblem.None) + { + preflight = TryResolveRecord(state, list, recordId, false, out MemoryRecord resolved); + if (preflight.Problem == MemoryRecordMutationProblem.None) + { + return SetActiveCore(state, resolved, active, identity, progress); + } + } + + return ActivationNotAttempted(preflight.Problem, active, null, preflight.LuaStatus); + } + catch (LuaException exception) + { + return progress.SetterStarted + ? Activation(MemoryRecordActivationOutcomeKind.Indeterminate, MemoryRecordMutationProblem.LuaFailure, + active, progress.Before, null, null, exception.Status) + : ActivationNotAttempted(MemoryRecordMutationProblem.LuaFailure, active, progress.Before, + exception.Status); + } + finally + { + state.SetTop(top); + } + } + + private static MemoryRecordActivationOutcome SetActiveCore(LuaState state, MemoryRecord record, bool active, + LuaStateIdentity identity, ActivationProgress progress) + { + MemoryRecordMutationProblem problem = TryReadBoolean(state, record, "Active"u8, out bool current, + out LuaStatus status); + if (problem != MemoryRecordMutationProblem.None) + { + return ActivationNotAttempted(problem, active, null, status); + } + + progress.Before = current; + if (current == active) + { + return Activation(MemoryRecordActivationOutcomeKind.Unchanged, MemoryRecordMutationProblem.None, active, + current, null, null, LuaStatus.Ok); + } + + if (LuaRuntime.CurrentStateIdentity != identity) + { + return ActivationNotAttempted(MemoryRecordMutationProblem.RuntimeIdentityChanged, active, current, + LuaStatus.Ok); + } + + // The one setter call. Record callbacks (OnActivate, [ENABLE]/[DISABLE], OnActivationFailure) run inside it. + state.PushBoolean(active); + progress.SetterStarted = true; + status = record.Handle.TrySetProperty(state, "Active"u8); + if (!status.IsOk) + { + return Activation(MemoryRecordActivationOutcomeKind.Indeterminate, MemoryRecordMutationProblem.LuaFailure, + active, current, null, null, status); + } + + problem = TryReadBoolean(state, record, "Active"u8, out bool after, out status); + if (problem != MemoryRecordMutationProblem.None) + { + return Activation(MemoryRecordActivationOutcomeKind.Indeterminate, problem, active, current, null, null, + status); + } + + problem = TryReadBoolean(state, record, "AsyncProcessing"u8, out bool processing, out status); + if (problem != MemoryRecordMutationProblem.None) + { + return Activation(MemoryRecordActivationOutcomeKind.Indeterminate, problem, active, current, after, null, + status); + } + + if (processing) + { + return Activation(MemoryRecordActivationOutcomeKind.Pending, MemoryRecordMutationProblem.None, active, + current, after, true, LuaStatus.Ok); + } + + MemoryRecordActivationOutcomeKind kind = after == active + ? MemoryRecordActivationOutcomeKind.Applied + : MemoryRecordActivationOutcomeKind.RefusedByHost; + return Activation(kind, MemoryRecordMutationProblem.None, active, current, after, false, LuaStatus.Ok); + } + + // Reads a boolean property and restores the stack: LuaFailure for a raise, InvalidResult for any other type. + private static MemoryRecordMutationProblem TryReadBoolean(LuaState state, MemoryRecord record, + ReadOnlySpan property, out bool value, out LuaStatus status) + { + int top = state.Top; + status = record.Handle.TryGetProperty(state, property); + if (!status.IsOk) + { + state.SetTop(top); + value = false; + return MemoryRecordMutationProblem.LuaFailure; + } + + bool isBoolean = state.TypeOf(-1) == LuaType.Boolean; + value = isBoolean && state.ToBoolean(-1); + state.SetTop(top); + return isBoolean ? MemoryRecordMutationProblem.None : MemoryRecordMutationProblem.InvalidResult; + } + + private static MemoryRecordActivationOutcome ActivationNotAttempted(MemoryRecordMutationProblem problem, + bool requested, bool? before, LuaStatus status) + { + return new MemoryRecordActivationOutcome(MemoryRecordActivationOutcomeKind.NotAttempted, problem, requested, + before, null, null, status); + } + + private static MemoryRecordActivationOutcome Activation(MemoryRecordActivationOutcomeKind kind, + MemoryRecordMutationProblem problem, bool requested, bool? before, bool? after, bool? asyncProcessing, + LuaStatus status) + { + return new MemoryRecordActivationOutcome(kind, problem, requested, before, after, asyncProcessing, status); + } + private static MemoryRecordMutationOutcome TryPrepareParentAssignment(LuaState state, MemoryRecordId recordId, MemoryRecordId? parentId, MemoryRecordParentTraversalLimit traversalLimit, out MemoryRecord child, out MemoryRecord parent) @@ -261,7 +438,7 @@ private static MemoryRecordMutationOutcome ValidateParentChain(LuaState state, M { HashSet seen = new(); MemoryRecord current = proposedParent; - for (int hops = 0;; hops++) + for (int hops = 0; ; hops++) { LuaStatus status = current.Handle.TryGetProperty(state, "ID"u8); if (!status.IsOk) @@ -328,4 +505,20 @@ private static MemoryRecordMutationOutcome Indeterminate(LuaStatus status) return new MemoryRecordMutationOutcome(MemoryRecordMutationEffect.Indeterminate, MemoryRecordMutationProblem.LuaFailure, status); } + + // Progress of one SetActive command, shared with the catch block of its caller. + private sealed class ActivationProgress + { + public bool SetterStarted + { + get; + set; + } + + public bool? Before + { + get; + set; + } + } } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecord.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecord.cs index 7ca8247f..ba088b52 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecord.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecord.cs @@ -228,6 +228,55 @@ public bool TryGetCurrentAddress(out Address address) return Handle.TryCallMethod("getCurrentAddress"u8, out address); } + /// Gets CE's Active property: whether the record is activated (frozen, or its script enabled). + /// The activation state; default on failure. + /// when CE returned a boolean. + /// + /// A read only. The borrowed handle has no setter for Active: activation is an effectful command with its + /// own outcome, . + /// + [RequiresPluginEnabled] + public bool TryGetActive(out bool active) + { + return Handle.TryGetProperty("Active"u8, out active); + } + + /// Gets CE's Async property: whether activating this record runs asynchronously (script records). + /// The value; default on failure. + /// when CE returned a boolean. + [RequiresPluginEnabled] + public bool TryGetAsync(out bool isAsync) + { + return Handle.TryGetProperty("Async"u8, out isAsync); + } + + /// Gets CE's AsyncProcessing property: whether an asynchronous activation is still being processed. + /// The value; default on failure. + /// when CE returned a boolean. + [RequiresPluginEnabled] + public bool TryGetAsyncProcessing(out bool processing) + { + return Handle.TryGetProperty("AsyncProcessing"u8, out processing); + } + + /// Gets CE's Script property: the Auto Assembler script of an Auto Assembler record. + /// A newly allocated managed string; default on failure. + /// when CE returned a Lua string (a non-script record typically returns none). + [RequiresPluginEnabled] + public bool TryGetScript([MaybeNullWhen(false)] out string script) + { + return Handle.TryGetProperty("Script"u8, out script); + } + + /// Gets CE's OffsetCount property: the number of pointer offsets (0 for a plain address). + /// The count; default on failure. + /// when CE returned a 32-bit integer. + [RequiresPluginEnabled] + public bool TryGetOffsetCount(out int offsetCount) + { + return Handle.TryGetProperty("OffsetCount"u8, out offsetCount); + } + /// Gets a direct child by its zero-based child position. /// The position in CE's Child[index] accessor; 0 is the first child. /// A borrowed, Cheat-Engine-owned child; default when there is no child at the index. diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordActivationOutcome.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordActivationOutcome.cs new file mode 100644 index 00000000..a85ace9a --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordActivationOutcome.cs @@ -0,0 +1,88 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.AddressList; + +/// The factual result of one command. +/// +/// follows from : is +/// ; and +/// are ; every +/// other kind is . The before and after states are the values actually read; +/// means that no value was observed. +/// +[StructLayout(LayoutKind.Auto)] +public readonly struct MemoryRecordActivationOutcome +{ + internal MemoryRecordActivationOutcome(MemoryRecordActivationOutcomeKind kind, MemoryRecordMutationProblem problem, + bool requestedActive, bool? activeBefore, bool? activeAfter, bool? asyncProcessingAfter, LuaStatus luaStatus) + { + Kind = kind; + Problem = problem; + RequestedActive = requestedActive; + ActiveBefore = activeBefore; + ActiveAfter = activeAfter; + AsyncProcessingAfter = asyncProcessingAfter; + LuaStatus = luaStatus; + } + + /// Gets the category of the command. + public MemoryRecordActivationOutcomeKind Kind + { + get; + } + + /// Gets how far the activation went, derived from only. + public EngineEffectState Effect => Kind switch + { + MemoryRecordActivationOutcomeKind.Applied => EngineEffectState.Applied, + MemoryRecordActivationOutcomeKind.Unchanged or MemoryRecordActivationOutcomeKind.NotAttempted => + EngineEffectState.NotStarted, + _ => EngineEffectState.Unknown + }; + + /// + /// Gets the reason for , + /// or + /// for , and + /// otherwise. + /// + public MemoryRecordMutationProblem Problem + { + get; + } + + /// Gets the requested Active state. + public bool RequestedActive + { + get; + } + + /// Gets the Active state read before the setter, or when it was not read. + public bool? ActiveBefore + { + get; + } + + /// Gets the Active state read after the setter, or when it was not observed. + public bool? ActiveAfter + { + get; + } + + /// + /// Gets the AsyncProcessing state read after the setter, or when it was not observed. + /// + public bool? AsyncProcessingAfter + { + get; + } + + /// Gets the protected Lua status of a failed access; otherwise . + public LuaStatus LuaStatus + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordActivationOutcomeKind.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordActivationOutcomeKind.cs new file mode 100644 index 00000000..aaebd45e --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordActivationOutcomeKind.cs @@ -0,0 +1,42 @@ +namespace CheatEngine.SDK.Engine.AddressList; + +/// The factual result of one command. +/// +/// The SDK never retries an activation. A third-party OnActivationFailure handler that asks Cheat Engine to +/// retry can make Cheat Engine loop inside the one setter call; the SDK cannot prevent that and does not add a loop of +/// its own. +/// +public enum MemoryRecordActivationOutcomeKind +{ + /// No outcome was recorded: the value of . + Unknown = 0, + + /// The setter ran and the record reads back the requested state, with no asynchronous processing pending. + Applied = 1, + + /// The record was already in the requested state; the setter was not called. + Unchanged = 2, + + /// + /// The setter ran but the record still reads the previous state: Cheat Engine or a record callback refused (an + /// OnActivate returning false, a failed [ENABLE]). The refusal may have applied part of its effects. + /// + RefusedByHost = 3, + + /// + /// The setter ran and the asynchronous activation of the record is still being processed; the final state is not + /// known yet. + /// + Pending = 4, + + /// + /// The setter raised after it started, or the state could not be read back after the setter: the effect is unknown. + /// + Indeterminate = 5, + + /// + /// The command was refused before the setter: see (record not + /// found, table load in progress, runtime identity changed, lookup failure). + /// + NotAttempted = 6 +} diff --git a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationProblem.cs b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationProblem.cs index c99b95f6..7b71ff01 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationProblem.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/MemoryRecordMutationProblem.cs @@ -34,5 +34,17 @@ public enum MemoryRecordMutationProblem LuaFailure, /// CE returned a value outside the typed contract. - InvalidResult + InvalidResult, + + /// + /// A table load () is running on the calling thread, so the mutation + /// was refused before any Lua call: the record identifiers it would resolve are being replaced. + /// + TableLoadInProgress = 11, + + /// + /// The Lua runtime identity (attach epoch or state generation) changed between the preflight and the mutation, so + /// the mutation was not attempted. + /// + RuntimeIdentityChanged = 12 } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs index c2eeeeb9..2184a939 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs @@ -3,8 +3,10 @@ using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Runtime; namespace CheatEngine.SDK.Engine.Allocation; @@ -12,17 +14,28 @@ namespace CheatEngine.SDK.Engine.Allocation; /// The sole owner of one target-process allocation returned by . /// /// -/// The region is not a managed allocation and has no finalizer: deAlloc must execute while the plugin remains -/// enabled. CE 7.7's catalog establishes no GUI-thread affinity for this global, so this type intentionally carries -/// no MainThreadOnly assertion until a live probe provides that evidence. Call in a -/// block for best-effort, no-throw cleanup, or when the caller must -/// observe a -/// failure. Both paths consume ownership before invoking CE; an expected failure, a binding/marshalling failure, or a -/// Lua exception never causes a retry. This makes concurrent and repeated cleanup deterministic and prevents a stale -/// address from being freed twice. Before invoking CE, the owner reads the current selection and refuses when it is -/// not the captured process incarnation; it never selects a process for cleanup. CE exposes no primitive that makes -/// that observation atomic with a following ambient-target Lua call, so a selection change in that external interval -/// remains unqualified rather than being represented as a stronger guarantee. +/// +/// The region is not a managed allocation and has no finalizer: deAlloc must execute while the plugin +/// remains enabled. CE 7.7's catalog establishes no GUI-thread affinity for this global, so this type +/// intentionally carries no MainThreadOnly assertion until a live probe provides that evidence. Call +/// in a block for best-effort, no-throw cleanup, +/// for a no-throw structured result, or when the +/// caller must observe a failure as an exception. Every path consumes ownership before invoking CE; an expected +/// failure, a binding/marshalling failure, or a Lua exception never causes a retry. This makes concurrent and +/// repeated cleanup deterministic and prevents a stale address from being freed twice. +/// +/// +/// Origin. The region is bound to the Lua runtime identity and the target process incarnation that +/// created it (). After a re-enable or a controlled Lua state replacement it refuses cleanup +/// without any Cheat Engine call and reports : the +/// allocation may remain in the target as a residue. After a detach, cleanup cannot begin and reports +/// . Before invoking CE, the owner reads the current selection and +/// refuses when it is not the captured process incarnation; it never reopens or selects a process for cleanup. +/// CE exposes no primitive that makes that observation atomic with a following ambient-target Lua call, so a +/// selection change in that external interval remains unqualified rather than being represented as a stronger +/// guarantee. A later allocation that reuses the same address is never freed through this owner: after its one +/// release attempt the owner is consumed. +/// /// public sealed class AllocatedRegion : IDisposable { @@ -33,8 +46,7 @@ public sealed class AllocatedRegion : IDisposable private int _released; internal AllocatedRegion(ITargetBoundMemoryAllocationOperations targetBoundOperations, Address address, - TargetAllocationSize size, - TargetProcessIncarnation targetIncarnation) + TargetAllocationSize size, TargetProcessIncarnation targetIncarnation, LuaStateIdentity runtime) { ArgumentNullException.ThrowIfNull(targetBoundOperations); if (address.IsZero) @@ -50,6 +62,7 @@ internal AllocatedRegion(ITargetBoundMemoryAllocationOperations targetBoundOpera _targetBoundOperations = targetBoundOperations; TargetIncarnation = targetIncarnation; + Origin = new EngineResourceOrigin(runtime, targetIncarnation); _address = address; _size = size; } @@ -87,12 +100,25 @@ public TargetAllocationSize Size public bool IsDisposed => Volatile.Read(ref _released) != 0; /// Gets the copied process incarnation that was qualified when this allocation was created. + /// The same value as the of . public TargetProcessIncarnation TargetIncarnation { get; } - /// Gets the factual outcome of the one release attempt, including a safe target refusal. + /// + /// Gets the Lua runtime identity and the target process incarnation that created this allocation. Readable after + /// the owner was consumed, for diagnostics. + /// + public EngineResourceOrigin Origin + { + get; + } + + /// + /// Gets the factual outcome of the one release attempt: confirmed release, a safe target or runtime refusal, + /// cleanup that could not begin, or an attempted but unconfirmed deallocation. + /// public TargetReleaseOutcome LastReleaseOutcome => _lastReleaseOutcome; /// @@ -100,14 +126,15 @@ public TargetProcessIncarnation TargetIncarnation /// /// /// This method is intended for / cleanup. It intentionally discards - /// expected CE, - /// Lua, binding, and marshalling failures, but still consumes ownership so a later call never retries a possibly - /// partial deallocation. Call when the outcome must be observed. + /// expected CE, Lua, binding, marshalling and lifecycle failures, but still consumes ownership so a later call never + /// retries a possibly partial deallocation. records what happened, including a + /// refusal after a re-enable and after a detach. Call + /// when the failure must be observed as an exception. /// [RequiresPluginEnabled] public void Dispose() { - if (!TryTakeOwnership()) + if (!TryTakeOwnership() || RefuseStaleRuntime()) { return; } @@ -130,7 +157,13 @@ public void Dispose() /// Releases the target allocation and reports every failure to the caller. /// /// Ownership was already released or disposed. + /// + /// The allocation belongs to a previous Lua runtime identity (ownership consumed without any CE call), or a + /// lifecycle violation prevented the deallocation from beginning (ownership consumed; see + /// ). + /// /// Cheat Engine reported that deallocation did not complete. + /// The current target is not the captured process incarnation. /// The required CE global is absent or non-callable. /// The CE binding cannot uphold its documented contract. /// The binding returned an invalid success/failure shape. @@ -147,6 +180,11 @@ public void Release() ThrowDisposed(); } + if (RefuseStaleRuntime()) + { + ThrowRuntimeChanged(); + } + TargetMemoryOperationOutcome outcome = ReleaseTakenWithOutcome(); if (outcome.IsSuccess) { @@ -167,6 +205,11 @@ public void Release() /// for the target-incarnation check without inspecting exception text. ///
/// Ownership was already released or disposed. + /// + /// The allocation belongs to a previous Lua runtime identity (ownership consumed without any CE call), or a + /// lifecycle violation prevented the deallocation from beginning (ownership consumed; see + /// ). + /// [RequiresPluginEnabled] public TargetMemoryOperationOutcome ReleaseWithOutcome() { @@ -175,10 +218,27 @@ public TargetMemoryOperationOutcome ReleaseWithOutcome() ThrowDisposed(); } + if (RefuseStaleRuntime()) + { + ThrowRuntimeChanged(); + } + return ReleaseTakenWithOutcome(); } - /// Releases this owner and returns the target-bound outcome, including safe target refusal. + /// + /// Releases this owner and returns the target-bound outcome. Never throws once ownership was taken. + /// + /// + /// after a confirmed deallocation; a target refusal + /// (, , + /// , + /// ); + /// after a re-enable or a controlled state replacement; + /// when a detached runtime prevented the call; or + /// after an attempted deallocation that CE did not + /// confirm. + /// /// Ownership was already released or disposed. [RequiresPluginEnabled] public TargetReleaseOutcome ReleaseWithTargetOutcome() @@ -188,10 +248,34 @@ public TargetReleaseOutcome ReleaseWithTargetOutcome() ThrowDisposed(); } - _ = ReleaseTakenWithOutcome(); + if (!RefuseStaleRuntime()) + { + try + { + _ = ReleaseTakenWithOutcome(); + } + catch (Exception) + { + // ReleaseTakenWithOutcome has recorded the factual outcome of the failure; nothing is retried. + } + } + return LastReleaseOutcome; } + // Refuses, without any CE call, an allocation whose Lua universe is no longer current. The comparison is identity + // only: an "is attached" pre-check would break consumer implementations that run without an attached runtime. + private bool RefuseStaleRuntime() + { + if (EngineResourceOrigin.IsCurrent(Origin.Runtime)) + { + return false; + } + + _lastReleaseOutcome = TargetReleaseOutcome.RefusedRuntimeChanged(); + return true; + } + private TargetMemoryOperationOutcome ReleaseTakenWithOutcome() { try @@ -206,6 +290,13 @@ private TargetMemoryOperationOutcome ReleaseTakenWithOutcome() : TargetReleaseOutcome.Refused(targetCheck); return outcome; } + catch (InvalidOperationException) when (!LuaRuntime.IsAttached || + !EngineResourceOrigin.IsCurrent(Origin.Runtime)) + { + // The binding could not acquire a Lua operation: deAlloc never ran. + _lastReleaseOutcome = TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure); + throw; + } catch (EngineException exception) { _lastReleaseOutcome = TargetReleaseOutcome.Unconfirmed(exception.Kind); @@ -213,6 +304,7 @@ private TargetMemoryOperationOutcome ReleaseTakenWithOutcome() } catch (Exception) { + // A consumer implementation may have started an effect before it threw. _lastReleaseOutcome = TargetReleaseOutcome.Unconfirmed(null); throw; } @@ -221,7 +313,7 @@ private TargetMemoryOperationOutcome ReleaseTakenWithOutcome() private void ThrowForReleaseOutcome(TargetMemoryOperationOutcome outcome) { if (outcome.Kind is TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable or - TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) { throw new EngineTargetIdentityException("TargetMemoryDeallocate", LastReleaseOutcome.TargetCheck.GetValueOrDefault()); @@ -274,4 +366,10 @@ private static void ThrowDisposed() throw new ObjectDisposedException(nameof(AllocatedRegion), "The target allocation is no longer owned: it was released or disposed."); } + + private static void ThrowRuntimeChanged() + { + throw new InvalidOperationException( + "The target allocation belongs to a previous Lua runtime identity; ownership was consumed without deallocating it."); + } } diff --git a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegionFactory.cs b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegionFactory.cs index 8bd31392..01669403 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegionFactory.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegionFactory.cs @@ -1,8 +1,9 @@ using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Runtime; namespace CheatEngine.SDK.Engine.Allocation; /// Internal owner-publication seam used to qualify post-allocation compensation. internal delegate AllocatedRegion AllocatedRegionFactory(ITargetBoundMemoryAllocationOperations operations, - Address address, TargetAllocationSize size, TargetProcessIncarnation targetIncarnation); + Address address, TargetAllocationSize size, TargetProcessIncarnation targetIncarnation, LuaStateIdentity runtime); diff --git a/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOutcomeOperations.cs b/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOutcomeOperations.cs deleted file mode 100644 index 218f057a..00000000 --- a/libs/CheatEngine.SDK.Engine/Allocation/ITargetMemoryAllocationOutcomeOperations.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CheatEngine.SDK.Annotations.Lifetime; -using CheatEngine.SDK.Engine.Values; - -namespace CheatEngine.SDK.Engine.Allocation; - -/// -/// An additive detailed-outcome view of for consumers that need -/// to distinguish expected Cheat Engine results from binding-boundary failures without parsing exception text. -/// -/// -/// This interface does not replace . Existing consumers retain its -/// and throwing facade behavior, while implementations may expose this view alongside it. -/// Neither result carries a Lua state, Lua error object, or error message. -/// -public interface ITargetMemoryAllocationOutcomeOperations -{ - /// Runs the allocation operation and returns its structured outcome. - /// The target allocation request. - /// The result category and nonzero address on success. - /// The plugin is not enabled or its Lua operation scope is unavailable. - [RequiresPluginEnabled] - public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request); - - /// Runs the deallocation operation and returns its structured outcome. - /// The owned nonzero address in the target process. - /// The original allocation request size. - /// The result category for the deallocation operation. - /// The plugin is not enabled or its Lua operation scope is unavailable. - [RequiresPluginEnabled] - public TargetMemoryOperationOutcome DeallocateWithOutcome(Address address, TargetAllocationSize size); -} diff --git a/libs/CheatEngine.SDK.Engine/Allocation/LuaTargetMemoryAllocationOperations.cs b/libs/CheatEngine.SDK.Engine/Allocation/LuaTargetMemoryAllocationOperations.cs index 58f8a354..71abca69 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/LuaTargetMemoryAllocationOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/LuaTargetMemoryAllocationOperations.cs @@ -17,14 +17,21 @@ namespace CheatEngine.SDK.Engine.Allocation; /// deAlloc. /// /// -/// The binding uses attach-epoch-aware global references, one protected call per operation, and restores the Lua -/// stack on every return or exception. It deliberately does not infer a GUI-thread requirement: the CE 7.7 Lua -/// contract for these two globals provides none. A allocation result and a false -/// deallocation result are expected operation failures; a result of another shape remains a stable marshalling -/// failure. The class is stateless and may be shared by multiple instances. +/// +/// The binding uses attach-epoch-aware global references, one protected call per operation, and restores the Lua +/// stack on every return or exception. It deliberately does not infer a GUI-thread requirement: the CE 7.7 Lua +/// contract for these two globals provides none. A allocation result and a false +/// deallocation result are expected operation failures; a result of another shape remains a stable marshalling +/// failure. The class is stateless and may be shared by multiple instances. +/// +/// +/// The type is internal on purpose: its direct allocateMemory/deAlloc members yield a live address +/// without an owner. Consumers reach it only through , which never publishes +/// an allocation without an or a reported compensation. +/// /// -public sealed class LuaTargetMemoryAllocationOperations : ITargetMemoryAllocationOperations, - ITargetMemoryAllocationOutcomeOperations, ITargetBoundMemoryAllocationOperations +internal sealed class LuaTargetMemoryAllocationOperations : ITargetMemoryAllocationOperations, + ITargetBoundMemoryAllocationOperations { private const string AllocateOperation = "TargetMemoryAllocate"; private const string DeallocateOperation = "TargetMemoryDeallocate"; @@ -120,9 +127,11 @@ public bool TryDeallocate(Address address, TargetAllocationSize size) return GetDeallocationResultOrThrow(DeallocateWithOutcome(address, size)); } - /// + /// Runs allocateMemory once and returns its structured outcome; the address has no owner. + /// The target allocation request. + /// The result category and nonzero address on success. [RequiresPluginEnabled] - public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request) + public static TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request) { if (request.Size.Value <= 0) { @@ -143,9 +152,12 @@ public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest } } - /// + /// Runs deAlloc once against the ambient target and returns its structured outcome. + /// The nonzero address in the target process. + /// The original allocation request size. + /// The result category for the deallocation operation. [RequiresPluginEnabled] - public TargetMemoryOperationOutcome DeallocateWithOutcome(Address address, TargetAllocationSize size) + public static TargetMemoryOperationOutcome DeallocateWithOutcome(Address address, TargetAllocationSize size) { if (address.IsZero || size.Value <= 0) { diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationAcquireOutcome.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationAcquireOutcome.cs new file mode 100644 index 00000000..5c2c9a91 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetAllocationAcquireOutcome.cs @@ -0,0 +1,83 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.SDK.Engine.Allocation; + +/// +/// The factual result of : what Cheat Engine returned, whether an +/// effect happened, the target observation it was made against, whether an owner was published and, when Cheat +/// Engine allocated but no owner could be published, the result of the one compensation attempt. +/// +/// +/// +/// An allocation never yields a live address without an owner silently: when is +/// and is , +/// is set. Whenever Cheat Engine returned an address, +/// keeps it ( is nonzero) even when no owner was published, +/// so a caller can recover the residue manually when the compensation was refused or unconfirmed. +/// +/// +/// is not a result of the allocator: its is +/// and it has no owner. +/// +/// +[StructLayout(LayoutKind.Auto)] +public readonly struct TargetAllocationAcquireOutcome +{ + internal TargetAllocationAcquireOutcome(TargetMemoryAllocationOutcome allocation, EngineEffectState effect, + TargetSelectionObservation targetObservation, TargetReleaseOutcome? compensation, bool hasOwner) + { + Allocation = allocation; + Effect = effect; + TargetObservation = targetObservation; + Compensation = compensation; + HasOwner = hasOwner; + } + + /// + /// Gets the result of the Cheat Engine allocation call: its category and, whenever Cheat Engine returned one, the + /// allocated address, including when no owner was published. + /// + public TargetMemoryAllocationOutcome Allocation + { + get; + } + + /// Gets how far the allocation went. + /// + /// when no allocation call began (compatibility seam only, target not + /// qualified, global unavailable); when Cheat Engine returned its + /// documented negative result (nil); when Cheat Engine returned + /// an address; for a protected failure or a malformed result. + /// + public EngineEffectState Effect + { + get; + } + + /// + /// Gets the target observation made before the allocation call; when no observation + /// was made (the allocator only has the compatibility seam, or the binding failed before observing). + /// + public TargetSelectionObservation TargetObservation + { + get; + } + + /// + /// Gets the result of the one compensation attempt, set only when Cheat Engine allocated but no owner was + /// published (unqualified target, runtime identity changed during the call, or owner publication failed). + /// + public TargetReleaseOutcome? Compensation + { + get; + } + + /// Gets whether an owner was published through the out parameter. + public bool HasOwner + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs index a4453288..6413a29e 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs @@ -3,8 +3,10 @@ using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Runtime; namespace CheatEngine.SDK.Engine.Allocation; @@ -12,15 +14,27 @@ namespace CheatEngine.SDK.Engine.Allocation; /// Allocates explicitly owned regions in the attached target process through the CE 7.7 allocation contract. /// /// -/// This facade validates the strongly typed request and makes an expected CE failure explicit as -/// . Protected Lua, binding, and marshalling failures use the canonical -/// Engine error hierarchy instead of being folded into an expected CE result. The generated CE binding is supplied -/// through . An owned allocation additionally requires the -/// implementation to opt in to ; a legacy direct-operation -/// implementation is never silently used to create an owner with an unverified cleanup target. +/// +/// This facade validates the strongly typed request. makes an expected CE failure explicit +/// as ; reports every result as a +/// instead. Protected Lua, binding, and marshalling failures keep +/// their canonical Engine categories. The generated CE binding is supplied through +/// . An owned allocation additionally requires the implementation +/// to opt in to ; a legacy direct-operation implementation +/// is never silently used to create an owner with an unverified cleanup target. +/// +/// +/// An allocation is bound to the Lua runtime identity and the target incarnation that created it +/// (). After a re-enable or a controlled state replacement the region refuses +/// cleanup and reports the residue; it never reopens or reselects a target. No public SDK entry point yields a +/// live allocation without an owner: when Cheat Engine allocated but no owner can be published, the allocator +/// makes exactly one compensation attempt and reports it. +/// /// public sealed class TargetMemoryAllocator { + private const string AllocateOperation = "TargetMemoryAllocate"; + private readonly ITargetMemoryAllocationOperations _operations; /// @@ -28,7 +42,8 @@ public sealed class TargetMemoryAllocator /// /// /// The binding resolves its globals only while an enabled plugin has a Lua state. Constructing this facade does - /// not contact Cheat Engine and is safe before plugin enable; remains lifecycle-gated. + /// not contact Cheat Engine and is safe before plugin enable; and + /// remain lifecycle-gated. /// public TargetMemoryAllocator() : this(LuaTargetMemoryAllocationOperations.Instance) @@ -59,9 +74,9 @@ public TargetMemoryAllocator(ITargetMemoryAllocationOperations operations) /// The protected CE Lua call failed. /// The operation cannot qualify a target incarnation. /// - /// Cheat Engine accepted an allocation but an owner could not be published; - /// - /// records the one target-qualified compensation attempt or an unconfirmed effect when no address was available. + /// Cheat Engine accepted an allocation but an owner could not be published (including a Lua runtime identity change + /// during the call); records the one + /// target-qualified compensation attempt or an unconfirmed effect when no address was available. /// [RequiresPluginEnabled] public AllocatedRegion Allocate(TargetAllocationRequest request) @@ -69,22 +84,48 @@ public AllocatedRegion Allocate(TargetAllocationRequest request) return AllocateCore(request, CreateRegion); } + /// + /// Attempts an allocation and publishes an owner only when Cheat Engine returned an address for a qualified + /// target in the runtime that made the call. Never throws an Engine failure: every result is an outcome. + /// + /// The allocation size, optional target base preference, and optional initial protection. + /// + /// The sole owner of the allocation when is + /// ; otherwise . + /// + /// + /// The factual outcome. is + /// when no allocation call began (only the compatibility seam + /// is available, the target is not qualified, the global is + /// unavailable), for Cheat Engine's documented nil, + /// when Cheat Engine returned an address, and + /// for a protected failure or a malformed result. When Cheat Engine + /// allocated but no owner is published, reports the one + /// compensation attempt and the address stays readable for manual recovery. + /// + /// has the default or an invalid size. + /// + /// A lifecycle violation surfaced by the binding: the plugin is not enabled or the calling thread has no Lua + /// state. + /// + [RequiresPluginEnabled] + public TargetAllocationAcquireOutcome TryAllocate(TargetAllocationRequest request, out AllocatedRegion? region) + { + return TryAllocateCore(request, CreateRegion, out region); + } + // The factory is internal so tests can fail publication after the effect without allowing consumers to choose a // different ownership policy. Keep the target-bound tuple local until the owner has been published. internal AllocatedRegion AllocateCore(TargetAllocationRequest request, AllocatedRegionFactory factory) { ArgumentNullException.ThrowIfNull(factory); - if (request.Size.Value <= 0) - { - throw new ArgumentOutOfRangeException(nameof(request), request.Size.Value, - "An allocation request must have a positive size."); - } - + ValidateRequest(request); if (_operations is not ITargetBoundMemoryAllocationOperations targetBound) { - throw new EngineTargetIdentityException("TargetMemoryAllocate", GetUnavailableTargetCheck()); + throw new EngineTargetIdentityException(AllocateOperation, GetUnavailableTargetCheck()); } + LuaStateIdentity runtime = LuaRuntime.CurrentStateIdentity; TargetMemoryAllocationOutcome allocationOutcome = targetBound.AllocateBoundWithOutcome(request, out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation); bool allocated = allocationOutcome.IsSuccess; @@ -93,13 +134,13 @@ internal AllocatedRegion AllocateCore(TargetAllocationRequest request, Allocated { if (!observation.IsQualified) { - throw new EngineTargetIdentityException("TargetMemoryAllocate", + throw new EngineTargetIdentityException(AllocateOperation, TargetSelection.CreateUnavailableCheck(observation)); } if (!address.IsZero) { - throw new EngineMarshallingException("TargetMemoryAllocate", EngineMarshallingDirection.Result, + throw new EngineMarshallingException(AllocateOperation, EngineMarshallingDirection.Result, "a null target address on failure", "a nonzero target address on failure"); } @@ -116,61 +157,102 @@ internal AllocatedRegion AllocateCore(TargetAllocationRequest request, Allocated ThrowUnqualifiedSuccessfulAllocation(observation); } + if (!EngineResourceOrigin.IsCurrent(runtime)) + { + TargetReleaseOutcome refused = CompensateFailedPublication(targetBound, address, request.Size, incarnation, + runtime); + throw new EngineResourceHandoffException(AllocateOperation, refused, CreateRuntimeChangedCause()); + } + try { - return factory(targetBound, address, request.Size, incarnation); + return factory(targetBound, address, request.Size, incarnation, runtime); } catch (Exception exception) { TargetReleaseOutcome cleanupOutcome = - CompensateFailedPublication(targetBound, address, request.Size, incarnation); - throw new EngineResourceHandoffException("TargetMemoryAllocate", cleanupOutcome, exception); + CompensateFailedPublication(targetBound, address, request.Size, incarnation, runtime); + throw new EngineResourceHandoffException(AllocateOperation, cleanupOutcome, exception); } } - /// - /// Attempts an allocation while retaining a compact factual category for each expected or Engine-boundary - /// result. - /// - /// The allocation size, optional target base preference, and optional initial protection. - /// The structured allocation outcome and a nonzero target address on success. - /// - /// This additive API does not change the direct contract. - /// It reports when an implementation has not opted - /// into , because creating an owner without a qualified - /// cleanup target would be unsafe. Lifecycle failures outside the Engine failure hierarchy still throw. - /// - [RequiresPluginEnabled] - public TargetMemoryAllocationOutcome AllocateWithOutcome(TargetAllocationRequest request) + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "The allocation, effect classification and owner publication form one transaction.")] + internal TargetAllocationAcquireOutcome TryAllocateCore(TargetAllocationRequest request, + AllocatedRegionFactory factory, out AllocatedRegion? region) { - if (request.Size.Value <= 0) + ArgumentNullException.ThrowIfNull(factory); + ValidateRequest(request); + region = null; + if (_operations is not ITargetBoundMemoryAllocationOperations targetBound) { - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.MarshallingFailure)); + return new TargetAllocationAcquireOutcome(Failed(EngineFailureKind.TargetIdentityUnavailable), + EngineEffectState.NotStarted, default, null, false); } - if (_operations is not ITargetBoundMemoryAllocationOperations targetBound) + // Captured before the bound call; the call's own admitted operation is compared with it afterwards, so an + // attach or state replacement that raced with the call is detected and never yields an owner. + LuaStateIdentity runtime = LuaRuntime.CurrentStateIdentity; + TargetMemoryAllocationOutcome allocation; + TargetProcessIncarnation incarnation; + TargetSelectionObservation observation; + try + { + allocation = targetBound.AllocateBoundWithOutcome(request, out incarnation, out observation); + } + catch (EngineException exception) { - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable)); + EngineEffectState effect = exception.Kind is EngineFailureKind.GlobalUnavailable or + EngineFailureKind.TargetIdentityUnavailable + ? EngineEffectState.NotStarted + : EngineEffectState.Unknown; + return new TargetAllocationAcquireOutcome(TargetMemoryAllocationOutcome.Failed(CreateOutcome(exception)), + effect, default, null, false); } - try + if (!allocation.IsSuccess) { - TargetMemoryAllocationOutcome outcome = - targetBound.AllocateBoundWithOutcome(request, out _, out TargetSelectionObservation observation); - if (!observation.IsQualified) - { - return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( - EngineFailureKind.TargetIdentityUnavailable)); - } + return ClassifyFailedAllocation(allocation, observation); + } - return outcome; + Address address = allocation.Address; + if (address.IsZero) + { + return new TargetAllocationAcquireOutcome(Failed(EngineFailureKind.MarshallingFailure), + EngineEffectState.Unknown, observation, null, false); } - catch (EngineException exception) + + if (!observation.IsQualified) { - return TargetMemoryAllocationOutcome.Failed(CreateOutcome(exception)); + // No deAlloc against a target that cannot be qualified: the refusal is the one compensation result. + return new TargetAllocationAcquireOutcome(allocation, EngineEffectState.Applied, observation, + TargetReleaseOutcome.Refused(TargetSelection.CreateUnavailableCheck(observation)), false); } + + if (!EngineResourceOrigin.IsCurrent(runtime)) + { + return new TargetAllocationAcquireOutcome(allocation, EngineEffectState.Applied, observation, + CompensateFailedPublication(targetBound, address, request.Size, incarnation, runtime), false); + } + + AllocatedRegion? published; + try + { + published = factory(targetBound, address, request.Size, incarnation, runtime); + } + catch (Exception) + { + published = null; + } + + if (published is null) + { + return new TargetAllocationAcquireOutcome(allocation, EngineEffectState.Applied, observation, + CompensateFailedPublication(targetBound, address, request.Size, incarnation, runtime), false); + } + + region = published; + return new TargetAllocationAcquireOutcome(allocation, EngineEffectState.Applied, observation, null, true); } internal static TargetMemoryOperationOutcome CreateOutcome(EngineException exception) @@ -180,15 +262,68 @@ internal static TargetMemoryOperationOutcome CreateOutcome(EngineException excep : TargetMemoryOperationOutcome.Failed(exception.Kind); } + private static TargetAllocationAcquireOutcome ClassifyFailedAllocation(TargetMemoryAllocationOutcome allocation, + TargetSelectionObservation observation) + { + if (!observation.IsQualified) + { + return new TargetAllocationAcquireOutcome(allocation, EngineEffectState.NotStarted, observation, null, + false); + } + + if (!allocation.Address.IsZero) + { + // A failure that still carries an address is contradictory: keep the address for manual recovery and do + // not claim that nothing happened. + return new TargetAllocationAcquireOutcome( + new TargetMemoryAllocationOutcome( + TargetMemoryOperationOutcome.Failed(EngineFailureKind.MarshallingFailure), allocation.Address), + EngineEffectState.Unknown, observation, null, false); + } + + EngineEffectState effect = allocation.Operation.Kind switch + { + TargetMemoryOperationOutcomeKind.ExpectedFailure => EngineEffectState.NotApplied, + TargetMemoryOperationOutcomeKind.GlobalUnavailable or TargetMemoryOperationOutcomeKind.CapabilityUnavailable + or TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable + or TargetMemoryOperationOutcomeKind.TargetIdentityMismatch => EngineEffectState.NotStarted, + _ => EngineEffectState.Unknown + }; + return new TargetAllocationAcquireOutcome(allocation, effect, observation, null, false); + } + + private static TargetMemoryAllocationOutcome Failed(EngineFailureKind failureKind) + { + return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed(failureKind)); + } + + private static void ValidateRequest(TargetAllocationRequest request) + { + if (request.Size.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(request), request.Size.Value, + "An allocation request must have a positive size."); + } + } + private static AllocatedRegion CreateRegion(ITargetBoundMemoryAllocationOperations operations, Address address, - TargetAllocationSize size, TargetProcessIncarnation targetIncarnation) + TargetAllocationSize size, TargetProcessIncarnation targetIncarnation, LuaStateIdentity runtime) { - return new AllocatedRegion(operations, address, size, targetIncarnation); + return new AllocatedRegion(operations, address, size, targetIncarnation, runtime); } + // The one compensation attempt for an allocation whose owner could not be published. It is refused without a call + // when the Lua runtime that made the allocation is no longer current, and reports NotInvoked when the runtime + // detached before the deallocation could begin (a lifecycle exception from the bound operations). private static TargetReleaseOutcome CompensateFailedPublication(ITargetBoundMemoryAllocationOperations operations, - Address address, TargetAllocationSize size, TargetProcessIncarnation targetIncarnation) + Address address, TargetAllocationSize size, TargetProcessIncarnation targetIncarnation, + LuaStateIdentity runtime) { + if (!EngineResourceOrigin.IsCurrent(runtime)) + { + return TargetReleaseOutcome.RefusedRuntimeChanged(); + } + try { TargetMemoryOperationOutcome outcome = operations.DeallocateBoundWithOutcome(targetIncarnation, address, @@ -202,6 +337,10 @@ private static TargetReleaseOutcome CompensateFailedPublication(ITargetBoundMemo ? TargetReleaseOutcome.Released() : TargetReleaseOutcome.Unconfirmed(outcome.FailureKind); } + catch (InvalidOperationException) when (!LuaRuntime.IsAttached || !EngineResourceOrigin.IsCurrent(runtime)) + { + return TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure); + } catch (EngineException exception) { return TargetReleaseOutcome.Unconfirmed(exception.Kind); @@ -212,12 +351,18 @@ private static TargetReleaseOutcome CompensateFailedPublication(ITargetBoundMemo } } + private static InvalidOperationException CreateRuntimeChangedCause() + { + return new InvalidOperationException( + "The Lua runtime identity changed while the allocation was made, so no owner can be published for it."); + } + [DoesNotReturn] private static void ThrowUnknownSuccessfulAllocation() { - EngineMarshallingException cause = new("TargetMemoryAllocate", EngineMarshallingDirection.Result, + EngineMarshallingException cause = new(AllocateOperation, EngineMarshallingDirection.Result, "a nonzero target address on success", "a null target address on success"); - throw new EngineResourceHandoffException("TargetMemoryAllocate", + throw new EngineResourceHandoffException(AllocateOperation, TargetReleaseOutcome.Unconfirmed(EngineFailureKind.MarshallingFailure), cause); } @@ -225,20 +370,20 @@ private static void ThrowUnknownSuccessfulAllocation() private static void ThrowUnqualifiedSuccessfulAllocation(TargetSelectionObservation observation) { TargetIdentityCheck check = TargetSelection.CreateUnavailableCheck(observation); - EngineTargetIdentityException cause = new("TargetMemoryAllocate", check); - throw new EngineResourceHandoffException("TargetMemoryAllocate", TargetReleaseOutcome.Refused(check), cause); + EngineTargetIdentityException cause = new(AllocateOperation, check); + throw new EngineResourceHandoffException(AllocateOperation, TargetReleaseOutcome.Refused(check), cause); } private static void ThrowForAllocationOutcome(TargetMemoryOperationOutcome outcome) { if (outcome.Kind == TargetMemoryOperationOutcomeKind.ExpectedFailure) { - throw new EngineOperationFailedException("TargetMemoryAllocate"); + throw new EngineOperationFailedException(AllocateOperation); } if (outcome.Kind == TargetMemoryOperationOutcomeKind.GlobalUnavailable) { - throw new EngineGlobalUnavailableException("TargetMemoryAllocate"); + throw new EngineGlobalUnavailableException(AllocateOperation); } if (outcome.Kind == TargetMemoryOperationOutcomeKind.CapabilityUnavailable) @@ -248,22 +393,22 @@ private static void ThrowForAllocationOutcome(TargetMemoryOperationOutcome outco if (outcome.Kind == TargetMemoryOperationOutcomeKind.ProtectedLuaFailure) { - throw new EngineLuaException("TargetMemoryAllocate", outcome.LuaStatus); + throw new EngineLuaException(AllocateOperation, outcome.LuaStatus); } if (outcome.Kind == TargetMemoryOperationOutcomeKind.MarshallingFailure) { - throw new EngineMarshallingException("TargetMemoryAllocate", EngineMarshallingDirection.Result, + throw new EngineMarshallingException(AllocateOperation, EngineMarshallingDirection.Result, "a target address or nil", "a result that is neither an address nor nil"); } if (outcome.Kind is TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable or - TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) { - throw new EngineTargetIdentityException("TargetMemoryAllocate", GetUnavailableTargetCheck()); + throw new EngineTargetIdentityException(AllocateOperation, GetUnavailableTargetCheck()); } - throw new EngineBindingException("TargetMemoryAllocate"); + throw new EngineBindingException(AllocateOperation); } private static TargetIdentityCheck GetUnavailableTargetCheck() diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AssemblePreference.cs b/libs/CheatEngine.SDK.Engine/Assembly/AssemblePreference.cs new file mode 100644 index 00000000..9803b831 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AssemblePreference.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// The jump-encoding preference Cheat Engine's assemble global accepts as its third argument. +/// +/// The values mirror Cheat Engine's own constants, TassemblerPreference=(apNone, apShort, apLong, apFar) +/// (Assemblerunit.pas:2712-2714 at cheat-engine/cheat-engine@ec45d5f, ObservedSource) and the CE 7.7 Lua +/// catalogue (celua.txt:236-238, ExactInstalledFile). They are passed to CE unchanged and frozen by a test. A +/// preference changes which encoding CE picks for a jump or call; it is recorded in +/// so that assembled bytes are never separated from the context that +/// produced them (audit A15-04, A15-18). +/// +[SuppressMessage("Naming", "CA1720:Identifiers should not contain type names", + Justification = + "The members mirror Cheat Engine's apShort/apLong/apFar jump preferences; renaming them would hide the CE names they mirror.")] +public enum AssemblePreference : byte +{ + /// No preference: Cheat Engine chooses the encoding (apNone, 0). + None = 0, + + /// Prefer the short encoding (apShort, 1). + Short = 1, + + /// Prefer the long encoding (apLong, 2). + Long = 2, + + /// Prefer the far encoding (apFar, 3). + Far = 3 +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerAllocationInfo.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerAllocationInfo.cs new file mode 100644 index 00000000..748a0cf7 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerAllocationInfo.cs @@ -0,0 +1,49 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// A copied entry of the allocs section of Cheat Engine's Auto Assembler disable information. +/// +/// A diagnostic copy only: the SDK never frees an allocation from this value. Cheat Engine's [DISABLE] +/// section, run through the patch's rooted disable-info table, owns the release of script allocations. +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct AutoAssemblerAllocationInfo +{ + internal AutoAssemblerAllocationInfo(string name, Address address, long size, Address? preferredAddress) + { + Name = name; + Address = address; + Size = size; + PreferredAddress = preferredAddress; + } + + /// Gets the allocation name used by the script (the key of the allocs entry). + public string Name + { + get; + } + + /// Gets the target address of the allocation (the entry's address field). + public Address Address + { + get; + } + + /// Gets the allocation size in bytes (the entry's size field). + public long Size + { + get; + } + + /// + /// Gets the preferred address Cheat Engine recorded (the entry's prefered field, spelled as Cheat Engine + /// spells it), or when the field is absent. + /// + public Address? PreferredAddress + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerApplyOutcome.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerApplyOutcome.cs new file mode 100644 index 00000000..d4c397e4 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerApplyOutcome.cs @@ -0,0 +1,149 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// +/// The factual result of one +/// +/// activation attempt. +/// +/// +/// +/// follows from : is +/// ; and +/// are +/// ; every other kind is . A +/// rejection by Cheat Engine does not prove that nothing changed (a script can apply part of its effects before +/// it fails), and a target change observed after the effect makes the effect uncertain, not rolled back. +/// +/// +/// The host text is copied only when is set, bounded, and is +/// never parsed. is always reported when Cheat Engine returned a third result, even +/// when its text was not captured. +/// +/// +[StructLayout(LayoutKind.Auto)] +public readonly struct AutoAssemblerApplyOutcome +{ + internal AutoAssemblerApplyOutcome(AutoAssemblerApplyOutcomeKind kind, LuaStatus luaStatus, + TargetSelectionObservation targetObservation, AutoAssemblerHostTextCopy hostText, + AutoAssemblerHostTextCopy hostWarnings, bool hasHostWarnings, TargetIdentityCheck? postEffectTargetCheck, + AutoAssemblerDisableInfoSnapshot? disableInfo, TargetReleaseOutcome? compensation, bool hasPatch) + { + Kind = kind; + LuaStatus = luaStatus; + TargetObservation = targetObservation; + HostText = hostText.Text; + HostTextTruncated = hostText.Truncated; + HostWarnings = hostWarnings.Text; + HostWarningsTruncated = hostWarnings.Truncated; + HasHostWarnings = hasHostWarnings; + PostEffectTargetCheck = postEffectTargetCheck; + DisableInfo = disableInfo; + Compensation = compensation; + HasPatch = hasPatch; + } + + /// Gets the category of the attempt. + public AutoAssemblerApplyOutcomeKind Kind + { + get; + } + + /// Gets how far the activation went, derived from only. + public EngineEffectState Effect => Kind switch + { + AutoAssemblerApplyOutcomeKind.Applied => EngineEffectState.Applied, + AutoAssemblerApplyOutcomeKind.GlobalUnavailable or AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable => + EngineEffectState.NotStarted, + _ => EngineEffectState.Unknown + }; + + /// + /// Gets the protected Lua status for ; otherwise + /// . + /// + public LuaStatus LuaStatus + { + get; + } + + /// + /// Gets Cheat Engine's bounded, unparsed rejection detail, only for + /// when host text was captured and the detail is a string. + /// + public string? HostText + { + get; + } + + /// Gets whether was cut at . + public bool HostTextTruncated + { + get; + } + + /// Gets whether Cheat Engine returned a non-nil third result (compilation warnings). + public bool HasHostWarnings + { + get; + } + + /// + /// Gets Cheat Engine's bounded, unparsed compilation warnings, only when host text was captured and the third + /// result is a string. + /// + public string? HostWarnings + { + get; + } + + /// Gets whether was cut at . + public bool HostWarningsTruncated + { + get; + } + + /// Gets the target observation made before the activation. + public TargetSelectionObservation TargetObservation + { + get; + } + + /// + /// Gets the validation of the captured target made after Cheat Engine applied the script, inside the same Lua + /// operation; when the script was not applied. + /// + public TargetIdentityCheck? PostEffectTargetCheck + { + get; + } + + /// + /// Gets the bounded copy of the disable information for an applied script (also when publication failed and the + /// copy succeeded); otherwise . + /// + public AutoAssemblerDisableInfoSnapshot? DisableInfo + { + get; + } + + /// + /// Gets the result of the one compensating disable, set only for + /// . + /// + public TargetReleaseOutcome? Compensation + { + get; + } + + /// Gets whether a patch owner was published through the out parameter. + public bool HasPatch + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerApplyOutcomeKind.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerApplyOutcomeKind.cs new file mode 100644 index 00000000..7ddc1e83 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerApplyOutcomeKind.cs @@ -0,0 +1,46 @@ +namespace CheatEngine.SDK.Engine.Assembly; + +/// The factual category of one Auto Assembler activation attempt. +/// +/// A category is never derived from Cheat Engine's error text. Cheat Engine gives no factual signal that separates a +/// syntax error, an unresolved symbol, an impossible allocation, a failed include or a failed DLL injection, so all of +/// them are (partially distinguishable by design); the opt-in host text is diagnostic only. +/// +public enum AutoAssemblerApplyOutcomeKind +{ + /// No outcome was recorded: the value of . + Unknown = 0, + + /// Cheat Engine applied the script, the target was unchanged afterwards, and the patch owner was published. + Applied = 1, + + /// + /// Cheat Engine applied the script, but the current target no longer matched the qualified incarnation afterwards. + /// The patch owner is still published with its original incarnation; its release refuses another target. + /// + AppliedTargetChanged = 2, + + /// Cheat Engine returned . It does not prove that nothing changed. + Rejected = 3, + + /// The autoAssemble global was absent or not a function; nothing was called. + GlobalUnavailable = 4, + + /// The protected Lua call (or the global resolution) raised. + ProtectedLuaFailure = 5, + + /// + /// Cheat Engine returned a result outside the documented shapes, such as a non-boolean first result or success + /// without a disable-info table. + /// + InvalidResult = 6, + + /// The current target could not be qualified as a process incarnation; nothing was called. + TargetIdentityUnavailable = 7, + + /// + /// Cheat Engine applied the script but the disable information could not be rooted, copied or handed to an owner; + /// the SDK made its one target-qualified compensating disable, reported as the outcome's compensation. + /// + HandoffFailed = 8 +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerCheckOutcome.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerCheckOutcome.cs new file mode 100644 index 00000000..996d7694 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerCheckOutcome.cs @@ -0,0 +1,57 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// The factual result of one syntax check. +/// +/// A syntax check is not proof that an activation will succeed: the target or its symbols may change before the +/// activation, and allocations or injections are not attempted. A check never creates an owner. +/// +[StructLayout(LayoutKind.Auto)] +public readonly struct AutoAssemblerCheckOutcome +{ + internal AutoAssemblerCheckOutcome(AutoAssemblerCheckOutcomeKind kind, LuaStatus luaStatus, string? hostText, + bool hostTextTruncated) + { + Kind = kind; + LuaStatus = luaStatus; + HostText = hostText; + HostTextTruncated = hostTextTruncated; + } + + /// Gets the category of the check. + public AutoAssemblerCheckOutcomeKind Kind + { + get; + } + + /// + /// Gets the protected Lua status for ; otherwise + /// . + /// + public LuaStatus LuaStatus + { + get; + } + + /// + /// Gets Cheat Engine's bounded, unparsed error text for , only + /// when was set and Cheat Engine returned a string; otherwise + /// . + /// + public string? HostText + { + get; + } + + /// Gets whether was cut at . + public bool HostTextTruncated + { + get; + } + + /// Gets whether Cheat Engine accepted the script section. + public bool IsAccepted => Kind == AutoAssemblerCheckOutcomeKind.Accepted; +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerCheckOutcomeKind.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerCheckOutcomeKind.cs new file mode 100644 index 00000000..32634a22 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerCheckOutcomeKind.cs @@ -0,0 +1,24 @@ +namespace CheatEngine.SDK.Engine.Assembly; + +/// The factual category of one autoAssembleCheck syntax check. +/// A category is never derived from Cheat Engine's error text. +public enum AutoAssemblerCheckOutcomeKind +{ + /// No outcome was recorded: the value of . + Unknown = 0, + + /// Cheat Engine accepted the script section. This does not prove that an activation will succeed. + Accepted = 1, + + /// Cheat Engine reported a problem in the script section. + Rejected = 2, + + /// The autoAssembleCheck global was absent or not a function; nothing was called. + GlobalUnavailable = 3, + + /// The protected Lua call (or the global resolution) raised. + ProtectedLuaFailure = 4, + + /// Cheat Engine returned a result outside the documented shape (a non-boolean first result). + InvalidResult = 5 +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerDisableInfoSnapshot.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerDisableInfoSnapshot.cs new file mode 100644 index 00000000..40a2c80a --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerDisableInfoSnapshot.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// +/// A bounded, copied view of the disable information Cheat Engine returned for an applied Auto Assembler script: +/// its allocations, registered symbols, exception ranges and symbols. +/// +/// +/// +/// The snapshot is diagnostic data. The SDK never frees, unregisters or destroys anything from it: the patch's +/// rooted disable-info table stays the only disable authority, passed back unchanged to Cheat Engine's +/// [DISABLE] handling. In particular ccodesymbols is a symbol list that Cheat Engine registered: +/// it is reported by only and is never owned, destroyed or unregistered by the +/// SDK. +/// +/// +/// The copy is bounded by entries per section and +/// bytes per name, reads keys only when they are +/// strings (a numeric key is never converted), reads fields without metamethods, and traverses dictionaries with +/// a protected next. Because the traversal order of a Lua table is unspecified, +/// and are sorted by name with ordinal comparison; the indexed sections +/// and keep Cheat Engine's index order. An entry +/// with an unexpected shape is skipped and makes +/// ; a reached limit makes it +/// . Neither fails the activation (a deliberate +/// choice: a diagnostic copy that failed must not force a compensating disable of a patch Cheat Engine applied). +/// +/// +public sealed class AutoAssemblerDisableInfoSnapshot +{ + private AutoAssemblerDisableInfoSnapshot(IReadOnlyList allocations, + IReadOnlyList registeredSymbols, IReadOnlyList
exceptionRanges, + IReadOnlyList symbols, bool hasCCodeSymbolList, + AutoAssemblerDisableInfoSnapshotStatus status) + { + Allocations = allocations; + RegisteredSymbols = registeredSymbols; + ExceptionRanges = exceptionRanges; + Symbols = symbols; + HasCCodeSymbolList = hasCCodeSymbolList; + Status = status; + } + + /// Gets the copied allocs entries, sorted by ordinal name. + public IReadOnlyList Allocations + { + get; + } + + /// Gets the copied registeredsymbols names, in Cheat Engine's index order. + public IReadOnlyList RegisteredSymbols + { + get; + } + + /// Gets the copied exceptionlist start addresses, in Cheat Engine's index order. + public IReadOnlyList
ExceptionRanges + { + get; + } + + /// Gets the copied symbols entries (symbols and labels of the script), sorted by ordinal name. + public IReadOnlyList Symbols + { + get; + } + + /// + /// Gets whether Cheat Engine reported a ccodesymbols symbol list. That list belongs to Cheat Engine: the SDK + /// never owns, destroys or unregisters it; the [DISABLE] handling of the rooted table does. + /// + public bool HasCCodeSymbolList + { + get; + } + + /// Gets how completely the table was copied. + public AutoAssemblerDisableInfoSnapshotStatus Status + { + get; + } + + /// + /// Copies the disable-info table at . Never raises a Lua error and restores the stack. + /// + internal static AutoAssemblerDisableInfoSnapshot Read(LuaState state, int tableIndex, + AutoAssemblerOptions options) + { + int table = state.AbsoluteIndex(tableIndex); + Reader reader = new(state, table, options); + List allocations = reader.ReadAllocations(); + List registeredSymbols = reader.ReadRegisteredSymbols(); + List
exceptionRanges = reader.ReadExceptionRanges(); + List symbols = reader.ReadSymbols(); + bool hasCCodeSymbolList = reader.ReadHasCCodeSymbolList(); + + allocations.Sort(static (left, right) => string.CompareOrdinal(left.Name, right.Name)); + symbols.Sort(static (left, right) => string.CompareOrdinal(left.Name, right.Name)); + return new AutoAssemblerDisableInfoSnapshot(allocations.AsReadOnly(), registeredSymbols.AsReadOnly(), + exceptionRanges.AsReadOnly(), symbols.AsReadOnly(), hasCCodeSymbolList, reader.Status); + } + + private sealed class Reader + { + private readonly AutoAssemblerOptions _options; + private readonly LuaState _state; + private readonly int _table; + private bool _malformed; + private bool _truncated; + + public Reader(LuaState state, int table, AutoAssemblerOptions options) + { + _state = state; + _table = table; + _options = options; + } + + public AutoAssemblerDisableInfoSnapshotStatus Status => _malformed + ? AutoAssemblerDisableInfoSnapshotStatus.Malformed + : _truncated + ? AutoAssemblerDisableInfoSnapshotStatus.Truncated + : AutoAssemblerDisableInfoSnapshotStatus.Complete; + + public List ReadAllocations() + { + List allocations = []; + using LuaFrame frame = new(_state); + if (!TryPushSection("allocs"u8, out int section)) + { + return allocations; + } + + ReadDictionary(section, allocations, static (reader, name, value, list) => + { + if (reader.TryReadAllocation(name, value, out AutoAssemblerAllocationInfo allocation)) + { + list.Add(allocation); + } + }); + return allocations; + } + + public List ReadSymbols() + { + List symbols = []; + using LuaFrame frame = new(_state); + if (!TryPushSection("symbols"u8, out int section)) + { + return symbols; + } + + ReadDictionary(section, symbols, static (reader, name, value, list) => + { + if (Address.TryRead(reader._state, value, out Address address)) + { + list.Add(new AutoAssemblerSymbolInfo(name, address)); + } + else + { + reader._malformed = true; + } + }); + return symbols; + } + + public List ReadRegisteredSymbols() + { + List names = []; + using LuaFrame frame = new(_state); + if (!TryPushSection("registeredsymbols"u8, out int section)) + { + return names; + } + + ReadSequence(section, names, static (reader, element, list) => + { + if (reader.TryReadName(element, out string? name)) + { + list.Add(name); + } + }); + return names; + } + + public List
ReadExceptionRanges() + { + List
ranges = []; + using LuaFrame frame = new(_state); + if (!TryPushSection("exceptionlist"u8, out int section)) + { + return ranges; + } + + ReadSequence(section, ranges, static (reader, element, list) => + { + if (Address.TryRead(reader._state, element, out Address address)) + { + list.Add(address); + } + else + { + reader._malformed = true; + } + }); + return ranges; + } + + public bool ReadHasCCodeSymbolList() + { + using LuaFrame frame = new(_state); + _state.PushString("ccodesymbols"u8); + LuaType type = _state.RawGet(_table); + if (type == LuaType.Nil) + { + return false; + } + + if (CEObject.TryRead(_state, -1, out _)) + { + return true; + } + + _malformed = true; + return false; + } + + // Pushes t[name] without metamethods. Absent is an empty section; any non-table value is malformed. + private bool TryPushSection(ReadOnlySpan name, out int section) + { + _state.PushString(name); + LuaType type = _state.RawGet(_table); + section = _state.Top; + if (type == LuaType.Table) + { + return true; + } + + if (type != LuaType.Nil) + { + _malformed = true; + } + + return false; + } + + // Protected next-traversal of a name-keyed section, bounded by visited entries. The caller's frame restores the + // stack, including when the traversal stops with a key and a value still pushed. + private void ReadDictionary(int section, List list, Action> readEntry) + { + int visited = 0; + _state.PushNil(); + while (true) + { + LuaStatus status = _state.TryNext(section, out bool hasNext); + if (!status.IsOk) + { + _malformed = true; + return; + } + + if (!hasNext) + { + return; + } + + if (visited == _options.MaxDisableInfoEntries) + { + _truncated = true; + return; + } + + visited++; + int value = _state.Top; + if (TryReadName(value - 1, out string? name)) + { + readEntry(this, name, value, list); + } + + _state.Pop(1); + } + } + + // Raw reads of t[1..n] with n = the raw length, bounded by the entry limit. + private void ReadSequence(int section, List list, Action> readElement) + { + ulong length = _state.RawLength(section); + ulong limit = (ulong) _options.MaxDisableInfoEntries; + if (length > limit) + { + _truncated = true; + length = limit; + } + + for (long index = 1; (ulong) index <= length; index++) + { + _ = _state.RawGetIndex(section, index); + if (_state.IsNil(-1)) + { + _malformed = true; + } + else + { + readElement(this, _state.Top, list); + } + + _state.Pop(1); + } + } + + private bool TryReadName(int index, [NotNullWhen(true)] out string? name) + { + // Only a real string: a numeric key is never converted in place during a traversal. + if (!_state.TryReadUtf8(index, out ReadOnlySpan utf8)) + { + _malformed = true; + name = null; + return false; + } + + if (utf8.Length > _options.MaxDisableInfoNameBytes) + { + _truncated = true; + name = null; + return false; + } + + name = Encoding.UTF8.GetString(utf8); + return true; + } + + private bool TryReadAllocation(string name, int value, out AutoAssemblerAllocationInfo allocation) + { + allocation = default; + if (!_state.IsTable(value)) + { + _malformed = true; + return false; + } + + using LuaFrame frame = new(_state); + _state.PushString("address"u8); + _ = _state.RawGet(value); + if (!Address.TryRead(_state, -1, out Address address)) + { + _malformed = true; + return false; + } + + _state.PushString("size"u8); + _ = _state.RawGet(value); + if (!_state.IsInteger(-1) || !_state.TryReadInteger(-1, out long size) || size < 0) + { + _malformed = true; + return false; + } + + _state.PushString("prefered"u8); + LuaType preferredType = _state.RawGet(value); + Address? preferred = null; + if (preferredType != LuaType.Nil) + { + if (!Address.TryRead(_state, -1, out Address preferredAddress)) + { + _malformed = true; + return false; + } + + preferred = preferredAddress; + } + + allocation = new AutoAssemblerAllocationInfo(name, address, size, preferred); + return true; + } + } +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerDisableInfoSnapshotStatus.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerDisableInfoSnapshotStatus.cs new file mode 100644 index 00000000..de7dc401 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerDisableInfoSnapshotStatus.cs @@ -0,0 +1,24 @@ +namespace CheatEngine.SDK.Engine.Assembly; + +/// How completely a disable-info snapshot copied Cheat Engine's disable-info table. +/// +/// A truncated or malformed snapshot never fails an activation: the rooted disable-info table, not the snapshot, +/// stays the disable authority. +/// +public enum AutoAssemblerDisableInfoSnapshotStatus +{ + /// No snapshot status was recorded: the value of . + Unknown = 0, + + /// Every projected section was copied. + Complete = 1, + + /// An entry or name limit was reached; the copied entries are exact, some were not copied. + Truncated = 2, + + /// + /// At least one entry or section had an unexpected shape and was skipped (this status wins over + /// ). + /// + Malformed = 3 +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerHostText.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerHostText.cs new file mode 100644 index 00000000..8280b570 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerHostText.cs @@ -0,0 +1,58 @@ +using System; +using System.Buffers; +using System.Text; + +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// +/// Bounded, never-parsed copies of Cheat Engine's Auto Assembler host text (rejection detail, compilation warnings). +/// +/// +/// The bytes are bounded before decoding: at most maxBytes UTF-8 bytes are read from the Lua string, +/// and a scalar split by the cut is removed with +/// (https://learn.microsoft.com/dotnet/api/system.text.rune.decodelastfromutf8), so a truncated copy never ends in a +/// replacement character that Cheat Engine did not send. Embedded NUL bytes are kept. The text is diagnostic only: no +/// outcome category is ever derived from it. +/// +internal static class AutoAssemblerHostText +{ + // A UTF-8 scalar is at most four bytes, so a cut can leave at most three bytes of an incomplete scalar. + private const int MaxSplitScalarBytes = 3; + + /// + /// Copies the string at when is set; any other value, or no + /// capture, gives . + /// + internal static AutoAssemblerHostTextCopy Copy(LuaState state, int index, bool capture, int maxBytes) + { + if (!capture || !state.TryReadUtf8(index, out ReadOnlySpan utf8)) + { + return default; + } + + bool truncated = utf8.Length > maxBytes; + ReadOnlySpan kept = truncated ? TrimSplitScalar(utf8[..maxBytes]) : utf8; + return new AutoAssemblerHostTextCopy(Encoding.UTF8.GetString(kept), truncated); + } + + private static ReadOnlySpan TrimSplitScalar(ReadOnlySpan bytes) + { + int trimmed = 0; + while (!bytes.IsEmpty && trimmed < MaxSplitScalarBytes) + { + OperationStatus status = Rune.DecodeLastFromUtf8(bytes, out _, out int consumed); + if (status == OperationStatus.Done) + { + break; + } + + int remove = Math.Clamp(consumed, 1, MaxSplitScalarBytes - trimmed); + bytes = bytes[..^remove]; + trimmed += remove; + } + + return bytes; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerHostTextCopy.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerHostTextCopy.cs new file mode 100644 index 00000000..1914b3db --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerHostTextCopy.cs @@ -0,0 +1,7 @@ +using System.Runtime.InteropServices; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// A bounded copy of one host text value; when nothing was copied. +[StructLayout(LayoutKind.Auto)] +internal readonly record struct AutoAssemblerHostTextCopy(string? Text, bool Truncated); diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerOptions.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerOptions.cs new file mode 100644 index 00000000..f8ad8cfd --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerOptions.cs @@ -0,0 +1,113 @@ +using System; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// +/// Bounds and opt-ins for +/// and . +/// +/// +/// +/// Host text (Cheat Engine's rejection detail and its compilation warnings) is copied only when +/// is , because it can contain script source and file paths. +/// It is copied bounded (at most UTF-8 bytes, cut at a scalar boundary) and is +/// never parsed: an outcome category never depends on it. +/// +/// +/// The disable-info snapshot copies at most entries per section and skips +/// names longer than UTF-8 bytes; either limit marks the snapshot +/// . The rooted disable-info table itself is never +/// truncated: it stays the disable authority. +/// +/// +/// Values are validated when the options are used: an out-of-range value makes the call throw +/// before any Lua work. Instances are immutable after initialization and +/// can be shared between threads. +/// +/// +public sealed class AutoAssemblerOptions +{ + /// The smallest accepted . + public const int MinHostTextBytes = 1; + + /// The largest accepted . + public const int MaxHostTextBytesLimit = 8192; + + /// The smallest accepted . + public const int MinDisableInfoEntries = 1; + + /// The largest accepted . + public const int MaxDisableInfoEntriesLimit = 65536; + + /// The smallest accepted . + public const int MinDisableInfoNameBytes = 1; + + /// The largest accepted . + public const int MaxDisableInfoNameBytesLimit = 4096; + + /// Gets the defaults: no host text, 512-byte text bound, 1024 entries per section, 256-byte names. + public static AutoAssemblerOptions Default + { + get; + } = new(); + + /// + /// Gets a value indicating whether Cheat Engine's rejection detail and compilation warnings are copied, bounded, + /// into the outcome. by default. + /// + public bool CaptureHostText + { + get; + init; + } + + /// + /// Gets the maximum number of UTF-8 bytes copied from one host text value (1 to 8192; 512 by default). A longer + /// text is cut at a scalar boundary and flagged as truncated. + /// + public int MaxHostTextBytes + { + get; + init; + } = 512; + + /// + /// Gets the maximum number of entries visited per disable-info section (1 to 65536; 1024 by default). + /// + public int MaxDisableInfoEntries + { + get; + init; + } = 1024; + + /// + /// Gets the maximum UTF-8 byte length of one copied disable-info name (1 to 4096; 256 by default). A longer name + /// is skipped, never cut, so a copied name is always exact. + /// + public int MaxDisableInfoNameBytes + { + get; + init; + } = 256; + + internal void Validate(string parameterName) + { + if (MaxHostTextBytes is < MinHostTextBytes or > MaxHostTextBytesLimit) + { + throw new ArgumentOutOfRangeException(parameterName, MaxHostTextBytes, + "MaxHostTextBytes must be between 1 and 8192."); + } + + if (MaxDisableInfoEntries is < MinDisableInfoEntries or > MaxDisableInfoEntriesLimit) + { + throw new ArgumentOutOfRangeException(parameterName, MaxDisableInfoEntries, + "MaxDisableInfoEntries must be between 1 and 65536."); + } + + if (MaxDisableInfoNameBytes is < MinDisableInfoNameBytes or > MaxDisableInfoNameBytesLimit) + { + throw new ArgumentOutOfRangeException(parameterName, MaxDisableInfoNameBytes, + "MaxDisableInfoNameBytes must be between 1 and 4096."); + } + } +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatch.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatch.cs index 19a82d0c..39b3dfe1 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatch.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatch.cs @@ -1,8 +1,10 @@ using System; +using System.Runtime.ExceptionServices; using System.Threading; using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -24,7 +26,15 @@ namespace CheatEngine.SDK.Engine.Assembly; /// A Lua runtime detach, re-attach, or supported state replacement invalidates the disable-info reference. /// In that case CE cannot safely execute the old [DISABLE] information. The owner is consumed and /// becomes rather than routing a stale table -/// into a new Lua state. +/// into a new Lua state: after a re-enable or a state replacement the outcome is +/// (the patch was made in another Lua universe, see +/// ); after a detach alone it is . +/// +/// +/// is a bounded, diagnostic copy of the disable information. The SDK never derives a +/// cleanup action from it and never rebuilds a [DISABLE] script from saved bytes: the rooted table Cheat +/// Engine returned is the only disable authority. No rollback stronger than Cheat Engine's own is promised: a +/// disable that returns or raises may have applied part of its effects. /// /// public sealed class AutoAssemblerPatch : IDisposable @@ -34,13 +44,24 @@ public sealed class AutoAssemblerPatch : IDisposable private TargetReleaseOutcome _lastReleaseOutcome; private int _requiresManualRecovery; - internal AutoAssemblerPatch(string script, LuaRef disableInfo, TargetProcessIncarnation targetIncarnation) + internal AutoAssemblerPatch(string script, LuaRef disableInfo, EngineResourceOrigin origin, + AutoAssemblerDisableInfoSnapshot snapshot, TargetIdentityCheck? postApplyTargetCheck) { ArgumentException.ThrowIfNullOrWhiteSpace(script); ArgumentNullException.ThrowIfNull(disableInfo); + ArgumentNullException.ThrowIfNull(snapshot); + if (!origin.Target.HasValue) + { + throw new ArgumentException("An Auto Assembler patch is bound to a target process incarnation.", + nameof(origin)); + } + _script = script; _disableInfo = disableInfo; - TargetIncarnation = targetIncarnation; + Origin = origin; + TargetIncarnation = origin.Target.GetValueOrDefault(); + DisableInfo = snapshot; + PostApplyTargetCheck = postApplyTargetCheck; } /// @@ -72,11 +93,36 @@ public bool IsEnabled public bool IsDisposed => Volatile.Read(ref _disableInfo) is null; /// Gets the copied process incarnation that was qualified when this patch was applied. + /// The same value as the of . public TargetProcessIncarnation TargetIncarnation { get; } + /// + /// Gets the Lua runtime identity and the target process incarnation in which Cheat Engine applied the script. + /// Readable after the owner was consumed, for diagnostics. + /// + public EngineResourceOrigin Origin + { + get; + } + + /// Gets the bounded, diagnostic copy of the disable information Cheat Engine returned. + public AutoAssemblerDisableInfoSnapshot DisableInfo + { + get; + } + + /// + /// Gets the validation of the captured target made right after Cheat Engine applied the script, in the same Lua + /// operation; a non-current check means the effect happened while the selection changed. + /// + public TargetIdentityCheck? PostApplyTargetCheck + { + get; + } + /// Gets the factual result of the one disable attempt, including a safe target refusal. public TargetReleaseOutcome LastReleaseOutcome => _lastReleaseOutcome; @@ -92,7 +138,7 @@ public void Dispose() return; } - _ = ReleaseTakenWithTargetOutcome(disableInfo); + _ = ReleaseTakenWithTargetOutcome(disableInfo, out _); } /// @@ -104,6 +150,10 @@ public void Dispose() /// The required CE global is absent or not a function. /// The protected CE Lua call failed. /// CE returned a non-boolean disable result. + /// + /// The patch belongs to a previous Lua runtime identity (consumed without any CE call), or a lifecycle violation + /// prevented the disable from beginning. + /// /// /// Ownership is consumed before the CE call. If the call returns false, raises, or cannot use an invalidated /// state, is set and a future call cannot re-run [DISABLE]. A @@ -112,46 +162,29 @@ public void Dispose() [RequiresPluginEnabled] public void Release() { - LuaRef disableInfo = TakeOwnership(); - bool disableInvocationStarted = false; - try + TargetReleaseOutcome outcome = ReleaseTakenWithTargetOutcome(TakeOwnership(), out Exception? failure); + if (outcome.Status == TargetReleaseStatus.Released) { - _lastReleaseOutcome = AutoAssemblerPatcher.TryDisable(_script, disableInfo, TargetIncarnation, - out disableInvocationStarted); - if (_lastReleaseOutcome.Status == TargetReleaseStatus.Released) - { - return; - } - - Volatile.Write(ref _requiresManualRecovery, 1); - if (_lastReleaseOutcome.TargetCheck.HasValue) - { - throw new EngineTargetIdentityException("AutoAssemblerDisable", _lastReleaseOutcome.TargetCheck.Value); - } - - throw new EngineOperationFailedException("AutoAssemblerDisable"); + return; } - catch (EngineTargetIdentityException) + + if (failure is not null) { - Volatile.Write(ref _requiresManualRecovery, 1); - throw; + ExceptionDispatchInfo.Throw(failure); } - catch (EngineException exception) + + if (outcome.TargetCheck.HasValue) { - _lastReleaseOutcome = disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(exception.Kind) - : TargetReleaseOutcome.NotInvoked(exception.Kind); - Volatile.Write(ref _requiresManualRecovery, 1); - throw; + throw new EngineTargetIdentityException("AutoAssemblerDisable", outcome.TargetCheck.Value); } - catch + + if (outcome.Status == TargetReleaseStatus.RefusedRuntimeChanged) { - _lastReleaseOutcome = disableInvocationStarted - ? TargetReleaseOutcome.Unconfirmed(null) - : TargetReleaseOutcome.NotInvoked(); - Volatile.Write(ref _requiresManualRecovery, 1); - throw; + throw new InvalidOperationException( + "The Auto Assembler patch belongs to a previous Lua runtime identity; ownership was consumed without disabling it."); } + + throw new EngineOperationFailedException("AutoAssemblerDisable"); } /// @@ -159,8 +192,9 @@ public void Release() /// /// /// when Cheat Engine confirmed disable, a safe refusal when the - /// captured target is no longer current, when cleanup could not - /// begin, or an unconfirmed outcome when an attempted disable could have had partial effects. + /// captured target is no longer current, when the patch + /// was applied in a previous Lua runtime identity, when cleanup could + /// not begin, or an unconfirmed outcome when an attempted disable could have had partial effects. /// /// The owner was already released or disposed. /// @@ -172,7 +206,7 @@ public void Release() [RequiresPluginEnabled] public TargetReleaseOutcome ReleaseWithTargetOutcome() { - return ReleaseTakenWithTargetOutcome(TakeOwnership()); + return ReleaseTakenWithTargetOutcome(TakeOwnership(), out _); } private LuaRef TakeOwnership() @@ -187,22 +221,27 @@ private LuaRef TakeOwnership() return disableInfo; } - private TargetReleaseOutcome ReleaseTakenWithTargetOutcome(LuaRef disableInfo) + // The one disable attempt. A failure is recorded as the factual outcome and handed back for Release to rethrow; the + // other paths discard it. Ownership was consumed by the caller, so nothing here can run [DISABLE] twice. + private TargetReleaseOutcome ReleaseTakenWithTargetOutcome(LuaRef disableInfo, out Exception? failure) { + failure = null; bool disableInvocationStarted = false; try { - _lastReleaseOutcome = AutoAssemblerPatcher.TryDisable(_script, disableInfo, TargetIncarnation, + _lastReleaseOutcome = AutoAssemblerPatcher.TryDisable(_script, disableInfo, Origin, out disableInvocationStarted); } catch (EngineException exception) { + failure = exception; _lastReleaseOutcome = disableInvocationStarted ? TargetReleaseOutcome.Unconfirmed(exception.Kind) : TargetReleaseOutcome.NotInvoked(exception.Kind); } - catch (Exception) + catch (Exception exception) { + failure = exception; _lastReleaseOutcome = disableInvocationStarted ? TargetReleaseOutcome.Unconfirmed(null) : TargetReleaseOutcome.NotInvoked(); diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatchFactory.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatchFactory.cs index 0addc373..ecda5f4c 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatchFactory.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatchFactory.cs @@ -1,3 +1,4 @@ +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Lua.References; @@ -5,4 +6,4 @@ namespace CheatEngine.SDK.Engine.Assembly; /// Internal patch-publication seam used only for deterministic post-effect failure tests. internal delegate AutoAssemblerPatch AutoAssemblerPatchFactory(string script, LuaRef disableInfo, - TargetProcessIncarnation targetIncarnation); + EngineResourceOrigin origin, AutoAssemblerDisableInfoSnapshot snapshot, TargetIdentityCheck postApplyTargetCheck); diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs index 6041aec6..9bc6a283 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs @@ -1,10 +1,13 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -14,22 +17,35 @@ namespace CheatEngine.SDK.Engine.Assembly; /// /// Applies an Auto Assembler script against the current Cheat Engine target and creates the corresponding owned -/// disable lease. +/// disable lease; checks a script without applying it. /// /// -/// This is deliberately the low-level SDK escape hatch. It passes only the script to CE's -/// autoAssemble global and never supplies CE's targetSelf argument. A caller that reaches this API -/// is responsible for the script's content; higher-level APIs should expose typed, capability-gated patches -/// rather than arbitrary script execution. The SDK captures a qualified target incarnation before it applies a -/// script and validates it before disabling the resulting owner; it never supplies targetSelf, opens, or -/// selects a process. CE exposes no inspected primitive that binds that observation atomically to the following -/// ambient-target call, so an external selection transition in that interval is not live-qualified as safe. +/// +/// This is deliberately the low-level SDK escape hatch. It passes only the script to CE's autoAssemble +/// global and never supplies CE's targetself argument. A caller that reaches this API is responsible for +/// the script's content; higher-level APIs should expose typed, capability-gated patches rather than arbitrary +/// script execution. The SDK captures a qualified target incarnation before it applies a script, validates it +/// again in the same Lua operation right after the effect, and validates it before disabling the resulting +/// owner; it never supplies targetself, opens, or selects a process. CE exposes no inspected primitive +/// that binds that observation atomically to the following ambient-target call, so an external selection +/// transition in that interval is not live-qualified as safe. +/// +/// +/// autoAssemble is called with three results (success, disable information or error detail, compilation +/// warnings) so that no result Cheat Engine returns is dropped. +/// reports every result as an ; the older and +/// keep their Boolean and exception contracts. Both globals are resolved through the SDK's +/// cached, protected global push. +/// /// public static class AutoAssemblerPatcher { private const string ApplyOperation = "AutoAssemblerApply"; private const string DisableOperation = "AutoAssemblerDisable"; + private static readonly LuaRef SAutoAssemble = new(); + private static readonly LuaRef SAutoAssembleCheck = new(); + /// /// Applies and returns the sole owner of the resulting CE disable information. /// @@ -72,120 +88,295 @@ public static AutoAssemblerPatch Apply(string script) /// CE accepted the script but disable-info tracking or patch publication failed; the exception reports its one /// target-qualified disable attempt. /// + /// + /// A result does not prove that nothing changed; use + /// for the effect state and the diagnostic. + /// [RequiresPluginEnabled] public static bool TryApply(string script, [NotNullWhen(true)] out AutoAssemblerPatch? patch) { return TryApplyCore(script, out patch, CreateDisableInfo, CreatePatch); } - // The seams are internal test infrastructure. A caller cannot select tracking or ownership behavior; they let the - // SDK prove that every exception between a successful apply and publication retains one compensation authority. - [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", - Justification = "This internal seam must keep the apply, compensation, and publication transaction together.")] - internal static bool TryApplyCore(string script, [NotNullWhen(true)] out AutoAssemblerPatch? patch, - AutoAssemblerDisableInfoTracker disableInfoTracker, AutoAssemblerPatchFactory patchFactory) + /// + /// Applies with and reports a factual + /// outcome instead of a Boolean or an Engine exception. + /// + /// The complete Auto Assembler script, including its [ENABLE] and [DISABLE] sections. + /// + /// The patch owner when is ; otherwise + /// . + /// + /// The outcome of the one activation attempt. + /// is empty or white-space only. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static AutoAssemblerApplyOutcome TryApplyWithOutcome(string script, out AutoAssemblerPatch? patch) + { + return TryApplyWithOutcome(script, AutoAssemblerOptions.Default, out patch); + } + + /// + /// Applies and reports a factual outcome: the category, the effect state, the opt-in + /// bounded host text and warnings, the target observations, the disable-info snapshot, and the compensation made + /// when a patch could not be published. No Engine exception crosses this method. + /// + /// The complete Auto Assembler script, including its [ENABLE] and [DISABLE] sections. + /// The host-text opt-in and the snapshot bounds. + /// + /// The patch owner when is ; otherwise + /// . + /// + /// The outcome of the one activation attempt. + /// is empty or white-space only. + /// is . + /// A bound of is out of range. + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// When Cheat Engine applied the script and the target no longer matched afterwards, the outcome is + /// and the patch is still published with its + /// original incarnation: its release refuses the other target instead of losing the only disable token. + /// + [RequiresPluginEnabled] + public static AutoAssemblerApplyOutcome TryApplyWithOutcome(string script, AutoAssemblerOptions options, + out AutoAssemblerPatch? patch) + { + return ApplyCore(script, options, CreateDisableInfo, CreatePatch, false, out patch, out _); + } + + /// + /// Checks with autoAssembleCheck and . + /// + /// The Auto Assembler script to check. + /// to check the [ENABLE] section, for [DISABLE]. + /// The outcome of the check. + /// is empty or white-space only. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static AutoAssemblerCheckOutcome TryCheck(string script, bool enable) + { + return TryCheck(script, enable, AutoAssemblerOptions.Default); + } + + /// + /// Checks with autoAssembleCheck, passing exactly the script and the enable flag + /// (never targetself). + /// + /// The Auto Assembler script to check. + /// to check the [ENABLE] section, for [DISABLE]. + /// The host-text opt-in and bound. + /// The outcome of the check. + /// is empty or white-space only. + /// is . + /// A bound of is out of range. + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// A syntax check is not proof that the activation will succeed: targets or symbols may change before it, and + /// allocations or injections are not attempted. A check never creates an owner and never replaces handling the + /// result of the activation itself. + /// + [RequiresPluginEnabled] + public static AutoAssemblerCheckOutcome TryCheck(string script, bool enable, AutoAssemblerOptions options) { ArgumentException.ThrowIfNullOrWhiteSpace(script); - ArgumentNullException.ThrowIfNull(disableInfoTracker); - ArgumentNullException.ThrowIfNull(patchFactory); + ArgumentNullException.ThrowIfNull(options); + options.Validate(nameof(options)); + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); LuaState state = operation.State; using LuaFrame frame = new(state); + LuaGlobalPushOutcome global = + LuaGlobalFunctions.TryPushWithOutcome(state, SAutoAssembleCheck, "autoAssembleCheck"u8); + if (!global.IsSuccess) + { + return global.Status == LuaGlobalPushStatus.Unavailable + ? new AutoAssemblerCheckOutcome(AutoAssemblerCheckOutcomeKind.GlobalUnavailable, LuaStatus.Ok, null, + false) + : new AutoAssemblerCheckOutcome(AutoAssemblerCheckOutcomeKind.ProtectedLuaFailure, + ToFailureStatus(global.LuaStatus), null, false); + } - TargetSelectionObservation targetObservation = TargetSelection.ObserveCurrent(state); - if (!targetObservation.IsQualified) + StringMarshaller.Push(state, script); + state.PushBoolean(enable); + LuaStatus status = state.TryCall(2, 2); + if (!status.IsOk) { - throw new EngineTargetIdentityException(ApplyOperation, - TargetSelection.CreateUnavailableCheck(targetObservation)); + return new AutoAssemblerCheckOutcome(AutoAssemblerCheckOutcomeKind.ProtectedLuaFailure, status, null, + false); } - if (!TryApplyScript(script, state, out int disableInfoIndex)) + if (state.TypeOf(-2) != LuaType.Boolean) { - patch = null; - return false; + return new AutoAssemblerCheckOutcome(AutoAssemblerCheckOutcomeKind.InvalidResult, LuaStatus.Ok, null, + false); } - // Retain the original table on the stack while the copy is rooted. A protected ref failure consumes only the - // copy and leaves the original table as the one remaining authority for a direct, target-checked disable. - LuaRef disableInfo = TrackDisableInfo(script, state, disableInfoIndex, disableInfoTracker, targetObservation); - try + if (state.ToBoolean(-2)) { - patch = patchFactory(script, disableInfo, targetObservation.Incarnation.GetValueOrDefault()); - return true; + return new AutoAssemblerCheckOutcome(AutoAssemblerCheckOutcomeKind.Accepted, LuaStatus.Ok, null, false); } - catch (Exception exception) + + AutoAssemblerHostTextCopy text = + AutoAssemblerHostText.Copy(state, -1, options.CaptureHostText, options.MaxHostTextBytes); + return new AutoAssemblerCheckOutcome(AutoAssemblerCheckOutcomeKind.Rejected, LuaStatus.Ok, text.Text, + text.Truncated); + } + + // The seams are internal test infrastructure. A caller cannot select tracking or ownership behavior; they let the + // SDK prove that every exception between a successful apply and publication retains one compensation authority. + // This is the legacy Boolean/exception projection of ApplyCore. + internal static bool TryApplyCore(string script, [NotNullWhen(true)] out AutoAssemblerPatch? patch, + AutoAssemblerDisableInfoTracker disableInfoTracker, AutoAssemblerPatchFactory patchFactory) + { + AutoAssemblerApplyOutcome outcome = ApplyCore(script, AutoAssemblerOptions.Default, disableInfoTracker, + patchFactory, true, out patch, out Exception? cause); + switch (outcome.Kind) { - TargetReleaseOutcome cleanupOutcome = CompensateFailedPublication(script, disableInfo, - targetObservation.Incarnation.GetValueOrDefault()); - throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); + case AutoAssemblerApplyOutcomeKind.Applied or AutoAssemblerApplyOutcomeKind.AppliedTargetChanged: + return patch is not null; + case AutoAssemblerApplyOutcomeKind.Rejected: + patch = null; + return false; + case AutoAssemblerApplyOutcomeKind.HandoffFailed: + throw new EngineResourceHandoffException(ApplyOperation, outcome.Compensation.GetValueOrDefault(), + cause); + default: + ExceptionDispatchInfo.Throw(cause ?? new EngineBindingException(ApplyOperation)); + patch = null; + return false; } } - private static bool TryApplyScript(string script, LuaState state, out int disableInfoIndex) + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "The apply, post-effect validation, rooting, snapshot and publication form one transaction.")] + internal static AutoAssemblerApplyOutcome ApplyCore(string script, AutoAssemblerOptions options, + AutoAssemblerDisableInfoTracker disableInfoTracker, AutoAssemblerPatchFactory patchFactory, + bool createLegacyCause, out AutoAssemblerPatch? patch, out Exception? cause) { - PushAutoAssemble(state, ApplyOperation); + ArgumentException.ThrowIfNullOrWhiteSpace(script); + ArgumentNullException.ThrowIfNull(options); + options.Validate(nameof(options)); + ArgumentNullException.ThrowIfNull(disableInfoTracker); + ArgumentNullException.ThrowIfNull(patchFactory); + patch = null; + cause = null; + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + // Stable for the whole admitted operation: a transition closes admission and drains it first. + LuaStateIdentity runtime = LuaRuntime.CurrentStateIdentity; + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(state); + if (!observation.IsQualified) + { + cause = new EngineTargetIdentityException(ApplyOperation, + TargetSelection.CreateUnavailableCheck(observation)); + return Outcome(AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable, observation); + } + + TargetProcessIncarnation incarnation = observation.Incarnation.GetValueOrDefault(); + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, SAutoAssemble, "autoAssemble"u8); + if (!global.IsSuccess) + { + if (global.Status == LuaGlobalPushStatus.Unavailable) + { + cause = new EngineGlobalUnavailableException(ApplyOperation); + return Outcome(AutoAssemblerApplyOutcomeKind.GlobalUnavailable, observation); + } + + LuaStatus resolutionFailure = ToFailureStatus(global.LuaStatus); + cause = new EngineLuaException(ApplyOperation, resolutionFailure); + return Outcome(AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure, observation, resolutionFailure); + } + + // autoAssemble(script) with three results: success, disable information or error detail, warnings. The + // targetself argument is never passed. StringMarshaller.Push(state, script); - LuaStatus status = state.TryCall(1, 2); + LuaStatus status = state.TryCall(1, 3); if (!status.IsOk) { - ThrowLua(state, status, ApplyOperation); + cause = createLegacyCause ? CreateLuaException(state, status, ApplyOperation) : null; + return Outcome(AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure, observation, status); } - if (state.TypeOf(-2) != LuaType.Boolean) + int success = state.AbsoluteIndex(-3); + int detail = success + 1; + int warnings = success + 2; + bool hasWarnings = !state.IsNil(warnings); + AutoAssemblerHostTextCopy warningText = + AutoAssemblerHostText.Copy(state, warnings, options.CaptureHostText, options.MaxHostTextBytes); + if (state.TypeOf(success) != LuaType.Boolean) { - ThrowUnexpectedResult(ApplyOperation, "a boolean success result", state.TypeOf(-2)); + cause = CreateUnexpectedResult(ApplyOperation, "a boolean success result", state.TypeOf(success)); + return new AutoAssemblerApplyOutcome(AutoAssemblerApplyOutcomeKind.InvalidResult, LuaStatus.Ok, + observation, default, warningText, hasWarnings, null, null, null, false); } - if (!state.ToBoolean(-2)) + if (!state.ToBoolean(success)) { - disableInfoIndex = 0; - return false; + AutoAssemblerHostTextCopy detailText = + AutoAssemblerHostText.Copy(state, detail, options.CaptureHostText, options.MaxHostTextBytes); + return new AutoAssemblerApplyOutcome(AutoAssemblerApplyOutcomeKind.Rejected, LuaStatus.Ok, observation, + detailText, warningText, hasWarnings, null, null, null, false); } - if (!state.IsTable(-1)) + if (!state.IsTable(detail)) { - ThrowUnexpectedResult(ApplyOperation, "a disable-info table on success", state.TypeOf(-1)); + cause = CreateUnexpectedResult(ApplyOperation, "a disable-info table on success", state.TypeOf(detail)); + return new AutoAssemblerApplyOutcome(AutoAssemblerApplyOutcomeKind.InvalidResult, LuaStatus.Ok, + observation, default, warningText, hasWarnings, null, null, null, false); } - disableInfoIndex = state.AbsoluteIndex(-1); - return true; - } + // Validate the captured target again inside the same operation: a change observed after the effect makes it + // uncertain but keeps the only disable token, bound to the original incarnation. + TargetIdentityCheck postCheck = TargetSelection.ValidateCurrent(state, incarnation); + AutoAssemblerApplyOutcomeKind appliedKind = postCheck.IsCurrent + ? AutoAssemblerApplyOutcomeKind.Applied + : AutoAssemblerApplyOutcomeKind.AppliedTargetChanged; + EngineResourceOrigin origin = new(runtime, incarnation); + + // Retain the original table on the stack while the copy is rooted. A failed root leaves the original table as + // the one remaining authority for a direct, target-checked disable. + LuaRef? disableInfo = TryTrackDisableInfo(state, detail, warnings, disableInfoTracker, out Exception? trackingFailure); + if (disableInfo is null) + { + cause = trackingFailure; + TargetReleaseOutcome stackCompensation = TryDisableFromStack(script, state, detail, incarnation); + return new AutoAssemblerApplyOutcome(AutoAssemblerApplyOutcomeKind.HandoffFailed, LuaStatus.Ok, + observation, default, warningText, hasWarnings, postCheck, null, stackCompensation, false); + } - private static LuaRef TrackDisableInfo(string script, LuaState state, int disableInfoIndex, - AutoAssemblerDisableInfoTracker disableInfoTracker, TargetSelectionObservation targetObservation) - { - state.PushValue(disableInfoIndex); + AutoAssemblerDisableInfoSnapshot? snapshot = null; try { - LuaRef disableInfo = disableInfoTracker(state); - if (disableInfo is null) - { - throw new InvalidOperationException("The disable-info tracker returned no reference."); - } - - return disableInfo; + snapshot = AutoAssemblerDisableInfoSnapshot.Read(state, detail, options); + patch = patchFactory(script, disableInfo, origin, snapshot, postCheck) ?? + throw new InvalidOperationException("The Auto Assembler patch factory returned no patch."); } catch (Exception exception) { - state.SetTop(disableInfoIndex); - TargetReleaseOutcome cleanupOutcome = TryDisableFromStack(script, state, disableInfoIndex, - targetObservation.Incarnation.GetValueOrDefault()); - throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); + patch = null; + cause = exception; + TargetReleaseOutcome compensation = CompensateFailedPublication(script, disableInfo, origin); + return new AutoAssemblerApplyOutcome(AutoAssemblerApplyOutcomeKind.HandoffFailed, LuaStatus.Ok, + observation, default, warningText, hasWarnings, postCheck, snapshot, compensation, false); } + + return new AutoAssemblerApplyOutcome(appliedKind, LuaStatus.Ok, observation, default, warningText, + hasWarnings, postCheck, snapshot, null, true); } // The owner always routes cleanup through this method. Keeping the LuaRef release in its finally block prevents a // failed protected call from pinning CE's disable-info table and makes retrying a possibly partial disable impossible. - internal static TargetReleaseOutcome TryDisable(string script, LuaRef disableInfo, TargetProcessIncarnation target, + internal static TargetReleaseOutcome TryDisable(string script, LuaRef disableInfo, EngineResourceOrigin origin, out bool disableInvocationStarted) { ArgumentNullException.ThrowIfNull(disableInfo); disableInvocationStarted = false; - - if (!LuaRuntime.IsAttached || !disableInfo.IsCurrent) + if (TryRefuseBeforeInvocation(disableInfo, origin, out TargetReleaseOutcome refusal)) { - disableInfo.Dispose(); - return TargetReleaseOutcome.NotInvoked(); + return refusal; } try @@ -195,7 +386,8 @@ internal static TargetReleaseOutcome TryDisable(string script, LuaRef disableInf try { using LuaFrame frame = new(state); - TargetIdentityCheck targetCheck = TargetSelection.ValidateCurrent(state, target); + TargetIdentityCheck targetCheck = + TargetSelection.ValidateCurrent(state, origin.Target.GetValueOrDefault()); if (!targetCheck.IsCurrent) { return TargetReleaseOutcome.Refused(targetCheck); @@ -212,12 +404,12 @@ internal static TargetReleaseOutcome TryDisable(string script, LuaRef disableInf LuaStatus status = state.TryCall(2, 1); if (!status.IsOk) { - ThrowLua(state, status, DisableOperation); + throw CreateLuaException(state, status, DisableOperation); } if (state.TypeOf(-1) != LuaType.Boolean) { - ThrowUnexpectedResult(DisableOperation, "a boolean disable result", state.TypeOf(-1)); + throw CreateUnexpectedResult(DisableOperation, "a boolean disable result", state.TypeOf(-1)); } return state.ToBoolean(-1) @@ -240,24 +432,70 @@ internal static TargetReleaseOutcome TryDisable(string script, LuaRef disableInf } } + // A patch applied in another Lua universe (re-enable or controlled state replacement) is refused: its table belongs + // to that universe. A detached runtime or a stale reference cannot begin a call either. Both consume the reference. + private static bool TryRefuseBeforeInvocation(LuaRef disableInfo, EngineResourceOrigin origin, + out TargetReleaseOutcome refusal) + { + if (!EngineResourceOrigin.IsCurrent(origin.Runtime)) + { + disableInfo.Dispose(); + refusal = TargetReleaseOutcome.RefusedRuntimeChanged(); + return true; + } + + if (!LuaRuntime.IsAttached || !disableInfo.IsCurrent) + { + disableInfo.Dispose(); + refusal = TargetReleaseOutcome.NotInvoked(); + return true; + } + + refusal = default; + return false; + } + + private static LuaRef? TryTrackDisableInfo(LuaState state, int disableInfoIndex, int top, + AutoAssemblerDisableInfoTracker disableInfoTracker, out Exception? failure) + { + failure = null; + state.PushValue(disableInfoIndex); + try + { + LuaRef disableInfo = disableInfoTracker(state); + if (disableInfo is null) + { + throw new InvalidOperationException("The disable-info tracker returned no reference."); + } + + return disableInfo; + } + catch (Exception exception) + { + state.SetTop(top); + failure = exception; + return null; + } + } + private static LuaRef CreateDisableInfo(LuaState state) { return state.CreateRef(); } - private static AutoAssemblerPatch CreatePatch(string script, LuaRef disableInfo, - TargetProcessIncarnation targetIncarnation) + private static AutoAssemblerPatch CreatePatch(string script, LuaRef disableInfo, EngineResourceOrigin origin, + AutoAssemblerDisableInfoSnapshot snapshot, TargetIdentityCheck postApplyTargetCheck) { - return new AutoAssemblerPatch(script, disableInfo, targetIncarnation); + return new AutoAssemblerPatch(script, disableInfo, origin, snapshot, postApplyTargetCheck); } private static TargetReleaseOutcome CompensateFailedPublication(string script, LuaRef disableInfo, - TargetProcessIncarnation targetIncarnation) + EngineResourceOrigin origin) { bool disableInvocationStarted = false; try { - return TryDisable(script, disableInfo, targetIncarnation, out disableInvocationStarted); + return TryDisable(script, disableInfo, origin, out disableInvocationStarted); } catch (EngineException exception) { @@ -322,30 +560,44 @@ private static TargetReleaseOutcome TryDisableFromStack(string script, LuaState private static void PushAutoAssemble(LuaState state, string operation) { - LuaStatus status = state.TryGetGlobal("autoAssemble"u8); - if (!status.IsOk) + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, SAutoAssemble, "autoAssemble"u8); + if (global.IsSuccess) { - ThrowLua(state, status, operation); + return; } - if (!state.IsFunction(-1)) + if (global.Status == LuaGlobalPushStatus.Unavailable) { throw new EngineGlobalUnavailableException(operation); } + + throw new EngineLuaException(operation, ToFailureStatus(global.LuaStatus)); + } + + private static AutoAssemblerApplyOutcome Outcome(AutoAssemblerApplyOutcomeKind kind, + TargetSelectionObservation observation, LuaStatus luaStatus = default) + { + return new AutoAssemblerApplyOutcome(kind, luaStatus, observation, default, default, false, null, null, null, + false); + } + + // A resolution failure always carries a non-success status; never report a failure as LuaStatus.Ok. + private static LuaStatus ToFailureStatus(LuaStatus status) + { + return status.IsOk ? LuaStatus.RuntimeError : status; } - [DoesNotReturn] - private static void ThrowLua(LuaState state, LuaStatus status, string operation) + private static EngineLuaException CreateLuaException(LuaState state, LuaStatus status, string operation) { LuaError error = LuaError.FromStack(state, status); - throw new EngineLuaException(operation, status, + return new EngineLuaException(operation, status, "The protected Lua call for Engine operation '" + operation + "' failed.", new LuaException(error)); } - [DoesNotReturn] - private static void ThrowUnexpectedResult(string operation, string expected, LuaType actual) + private static EngineMarshallingException CreateUnexpectedResult(string operation, string expected, + LuaType actual) { - throw new EngineMarshallingException(operation, EngineMarshallingDirection.Result, expected, + return new EngineMarshallingException(operation, EngineMarshallingDirection.Result, expected, "a Lua " + actual.ToString().ToLowerInvariant() + " value"); } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerSymbolInfo.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerSymbolInfo.cs new file mode 100644 index 00000000..0bc105ea --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerSymbolInfo.cs @@ -0,0 +1,28 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// A copied entry of the symbols section of Cheat Engine's Auto Assembler disable information. +[StructLayout(LayoutKind.Auto)] +public readonly record struct AutoAssemblerSymbolInfo +{ + internal AutoAssemblerSymbolInfo(string name, Address address) + { + Name = name; + Address = address; + } + + /// Gets the symbol or label name of the script. + public string Name + { + get; + } + + /// Gets the target address Cheat Engine assigned to the symbol. + public Address Address + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembler.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembler.cs index 87b811a6..d743fa1b 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembler.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembler.cs @@ -15,16 +15,25 @@ namespace CheatEngine.SDK.Engine.Assembly; /// Assembles one instruction through CE's protected Lua assemble global. /// /// -/// The CE 7.7.0.10621 implementation accepts the instruction text and optional address, then returns a Lua -/// byte table on success or nil when it rejects the instruction. This API always supplies the address: -/// it is the explicit origin CE receives when it evaluates a relative operand. It does not claim relocation -/// support beyond that CE operation or reconfigure CE's ambient target architecture. +/// CE's assemble(line, address?, preference?, skipRangeCheck?) returns a Lua byte table on success. It +/// returns nil alone when it rejects the instruction, or nil and a message when the attempt raised +/// inside CE (LuaHandler.pas:2914-2961 at ec45d5f, ObservedSource; celua.txt:236-238). Both are +/// : an unknown opcode or a missing symbol are both +/// rejections, and the SDK never reads or parses the message (audit A15-20, A15-21). This API always supplies the +/// address: it is the explicit origin CE receives when it evaluates a relative operand. It does not claim +/// relocation support beyond that CE operation or reconfigure CE's ambient target architecture. /// /// /// The destination is caller-owned. The table is validated completely before the first byte is copied, so a -/// short destination or malformed later element cannot publish a prefix. Neither the table nor Lua text is -/// retained after the stack is restored. This API does not invoke a classic native assembler slot; unresolved -/// host ABI projections remain unavailable. +/// short destination or malformed later element cannot publish a prefix; a destination that is too small is +/// reported with the required length and no byte written (A15-19). Neither the table nor Lua text is retained +/// after the stack is restored. This API does not invoke a classic native assembler slot; the classic +/// buffer-based slots are not applicable to the managed-hostfxr profile and stay unprojected. +/// +/// +/// The target PID is checked before and after CE's call. A change observed after CE produced the bytes is +/// , no byte is copied, and no rollback is promised: the +/// checks are observations, not a lock (A15-06). /// /// public static class InstructionAssembler @@ -47,14 +56,77 @@ public static class InstructionAssembler /// A profile, capacity, instruction, availability, protected-call, or result-shape outcome. /// is . /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// This overload passes exactly two arguments to assemble (the text and the address), so CE applies its + /// defaults for the preference and the range check. Use the overload with to + /// pass and record them. + /// [RequiresPluginEnabled] - [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", - Justification = "The protected call, full table validation, target recheck, and copy share one stack frame.")] public static InstructionOperationStatus TryAssemble(InstructionTargetProfile targetProfile, string instruction, Address address, Span destination, out int written, out int requiredLength) { ArgumentNullException.ThrowIfNull(instruction); + return TryAssembleCore(targetProfile, instruction, address, null, false, destination, out written, + out requiredLength); + } + + /// + /// Assembles one instruction into caller-owned storage with an explicit origin, jump-encoding preference and + /// range-check option, and echoes all of them in . + /// + /// + /// The CE-observed selected PID and instruction profile used to validate + /// . + /// + /// The instruction source sent to CE as UTF-8 without normalization. + /// The target origin supplied to CE. + /// The jump-encoding preference passed to CE unchanged. + /// + /// The range-check option passed to CE. When , CE skips its own check that a relative + /// operand is reachable from and emits bytes even when they cannot encode the intended + /// target; the caller then owns that risk. + /// + /// Caller-owned storage for the complete assembled byte sequence. + /// + /// The echo of the call: target, profile, origin, preference and range-check option on every outcome, plus the + /// written and required lengths. + /// + /// A profile, capacity, instruction, availability, protected-call, or result-shape outcome. + /// is . + /// + /// is not a defined value; nothing is sent to CE. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// This overload always passes four arguments to assemble: the text, the address as a Lua integer, the + /// preference as its CE integer and the option as a Lua boolean. + /// + [RequiresPluginEnabled] + public static InstructionOperationStatus TryAssemble(InstructionTargetProfile targetProfile, string instruction, + Address address, AssemblePreference preference, bool skipRangeCheck, Span destination, + out InstructionAssembly assembly) + { + ArgumentNullException.ThrowIfNull(instruction); + if ((byte) preference > (byte) AssemblePreference.Far) + { + throw new ArgumentOutOfRangeException(nameof(preference), preference, + "The assembler preference must be None, Short, Long or Far."); + } + + InstructionOperationStatus status = TryAssembleCore(targetProfile, instruction, address, preference, + skipRangeCheck, destination, out int written, out int requiredLength); + assembly = new InstructionAssembly(targetProfile.Target, targetProfile.Profile, address, preference, + skipRangeCheck, written, requiredLength); + return status; + } + + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "The protected call, full table validation, target recheck, and copy share one stack frame.")] + private static InstructionOperationStatus TryAssembleCore(InstructionTargetProfile targetProfile, + string instruction, Address address, AssemblePreference? preference, bool skipRangeCheck, + Span destination, out int written, out int requiredLength) + { written = 0; requiredLength = 0; @@ -75,13 +147,13 @@ public static InstructionOperationStatus TryAssemble(InstructionTargetProfile ta return targetStatus; } - LuaGlobalPushStatus global = LuaGlobalFunctions.TryPushWithStatus(state, SAssemble, "assemble"u8); - if (global == LuaGlobalPushStatus.Unavailable) + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, SAssemble, "assemble"u8); + if (global.Status == LuaGlobalPushStatus.Unavailable) { return InstructionOperationStatus.GlobalUnavailable; } - if (global != LuaGlobalPushStatus.Success) + if (!global.IsSuccess) { return InstructionOperationStatus.LuaFailure; } @@ -89,7 +161,16 @@ public static InstructionOperationStatus TryAssemble(InstructionTargetProfile ta int resultStart = state.Top - 1; StringMarshaller.Push(state, instruction); Address.Push(state, address); - if (!state.TryCall(2, LuaState.MultipleResults).IsOk) + int argumentCount = 2; + if (preference is AssemblePreference explicitPreference) + { + // The four-argument form: CE's TassemblerPreference integer, then the range-check boolean. + state.PushInteger((long) explicitPreference); + state.PushBoolean(skipRangeCheck); + argumentCount = 4; + } + + if (!state.TryCall(argumentCount, LuaState.MultipleResults).IsOk) { return InstructionOperationStatus.LuaFailure; } @@ -106,6 +187,8 @@ public static InstructionOperationStatus TryAssemble(InstructionTargetProfile ta return InstructionOperationStatus.InvalidResult; } + // nil alone (CE rejected the line) and nil plus a message (CE raised internally) are both a rejection. The + // second value is never read, so no outcome depends on message text. if (state.IsNil(resultIndex)) { return InstructionOperationStatus.InstructionRejected; diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembly.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembly.cs new file mode 100644 index 00000000..1843a035 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionAssembly.cs @@ -0,0 +1,34 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.SDK.Engine.Assembly; + +/// A copied echo of one assembler call: the context sent to Cheat Engine and the size of its result. +/// +/// +/// The same instruction text assembles to different bytes for a different origin, jump preference or range-check +/// option, or for another target profile. This value keeps them together with the result so that bytes are never +/// reused in a context they were not produced for (audit ch.15, A15-04). It is filled on every outcome of +/// : +/// the context fields always echo the request, while is non-zero only on success. +/// +/// It owns no Lua storage and no Cheat Engine object; it stays valid after the Lua runtime detaches. +/// +/// The CE-selected process identifier of the profile the call was validated against. +/// The instruction profile the call was validated against. +/// The target address sent to CE as the instruction origin. +/// The jump-encoding preference sent to CE. +/// The range-check option sent to CE. +/// The number of bytes copied to the destination on success; zero for every other outcome. +/// The exact byte-table length when CE returned a valid table; zero otherwise. +[StructLayout(LayoutKind.Auto)] +public readonly record struct InstructionAssembly( + TargetProcessId Target, + InstructionProfile Profile, + Address Origin, + AssemblePreference Preference, + bool SkipRangeCheck, + int Written, + int RequiredLength); diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs index 5ea03595..d889fb9e 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs @@ -120,9 +120,9 @@ public static InstructionOperationStatus TryDisassemble(InstructionTargetProfile } if (!state.TryReadUtf8(-4, out ReadOnlySpan addressUtf8) || - !state.TryReadUtf8(-3, out ReadOnlySpan bytesUtf8) || - !state.TryReadUtf8(-2, out ReadOnlySpan opcodeUtf8) || - !state.TryReadUtf8(-1, out ReadOnlySpan extraUtf8)) + !state.TryReadUtf8(-3, out ReadOnlySpan bytesUtf8) || + !state.TryReadUtf8(-2, out ReadOnlySpan opcodeUtf8) || + !state.TryReadUtf8(-1, out ReadOnlySpan extraUtf8)) { return InstructionOperationStatus.InvalidResult; } @@ -166,11 +166,14 @@ public static InstructionOperationStatus TryDisassemble(InstructionTargetProfile private static InstructionOperationStatus PushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) { - return LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, cache, name); + if (global.IsSuccess) { - LuaGlobalPushStatus.Success => InstructionOperationStatus.Success, - LuaGlobalPushStatus.Unavailable => InstructionOperationStatus.GlobalUnavailable, - _ => InstructionOperationStatus.LuaFailure - }; + return InstructionOperationStatus.Success; + } + + return global.Status == LuaGlobalPushStatus.Unavailable + ? InstructionOperationStatus.GlobalUnavailable + : InstructionOperationStatus.LuaFailure; } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionNavigator.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionNavigator.cs index bd87fe70..a896396d 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionNavigator.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionNavigator.cs @@ -115,13 +115,13 @@ private static InstructionOperationStatus TryCallAddressGlobal(InstructionTarget return targetStatus; } - LuaGlobalPushStatus global = LuaGlobalFunctions.TryPushWithStatus(state, cache, name); - if (global == LuaGlobalPushStatus.Unavailable) + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, cache, name); + if (global.Status == LuaGlobalPushStatus.Unavailable) { return InstructionOperationStatus.GlobalUnavailable; } - if (global != LuaGlobalPushStatus.Success) + if (!global.IsSuccess) { return InstructionOperationStatus.LuaFailure; } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionOperationStatus.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionOperationStatus.cs index 3e564957..9104f2f0 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionOperationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionOperationStatus.cs @@ -2,49 +2,71 @@ namespace CheatEngine.SDK.Engine.Assembly; /// Describes the factual result of a bounded instruction operation. /// -/// The values intentionally distinguish a caller-supplied profile or destination failure from a missing CE -/// primitive, a protected Lua failure, and a CE result that does not match the pinned contract. No value turns -/// a successful PID-and-ISA observation into a process-incarnation guarantee, a target-selection lock, or live -/// host qualification. +/// +/// The values intentionally distinguish a caller-supplied profile or destination failure from a missing CE +/// primitive, a protected Lua failure, and a CE result that does not match the pinned contract. No value turns +/// a successful PID-and-ISA observation into a process-incarnation guarantee, a target-selection lock, or live +/// host qualification. +/// +/// +/// The numeric values are explicit and stable. is the zero value, so an unassigned status +/// (for example default(InstructionOperationStatus)) never reads as . +/// /// public enum InstructionOperationStatus { + /// No operation result was recorded; never a success. + Unknown = 0, + /// The operation completed and copied its complete result. - Success, + Success = 1, /// /// The instruction profile does not name a supported x86, x64, ARM32, or ARM64 architecture with its matching - /// width. + /// width, or Cheat Engine reported contradictory target ISA-family facts (both the x86 and the ARM family, or + /// neither). /// - InvalidProfile, + InvalidProfile = 2, /// The input or returned address has bits outside the caller-declared profile width. - AddressExceedsProfileWidth, + AddressExceedsProfileWidth = 3, - /// Cheat Engine did not report a positive selected target process identifier. - TargetNotSelected, + /// + /// Cheat Engine did not report a positive selected target process identifier when the profile was observed, or + /// the supplied profile names no target. An operation whose re-check finds no target reports + /// instead. + /// + TargetNotSelected = 4, /// - /// Cheat Engine reported a different selected target before and after an instruction profile observation or - /// operation. + /// Cheat Engine reported a different selected target before and after an instruction profile observation, or an + /// operation's re-check found a selection other than the profiled process: another identifier, no target, or the + /// file-as-process sentinel. After the host call this is an uncertainty status: the call ran, but its result is + /// not attributed to the profiled target and nothing is copied. /// - TargetChanged, + TargetChanged = 5, /// The caller-owned destination cannot hold the complete byte result; no byte was written. - DestinationTooSmall, + DestinationTooSmall = 6, /// The raw UTF-8 line exceeds the explicit maximum before the SDK decodes or publishes text. - OutputTooLong, + OutputTooLong = 7, /// Cheat Engine rejected an otherwise well-formed assembly instruction. - InstructionRejected, + InstructionRejected = 8, /// The required Cheat Engine Lua global was missing or was not callable. - GlobalUnavailable, + GlobalUnavailable = 9, /// A protected Lua lookup, argument push, or call failed. - LuaFailure, + LuaFailure = 10, /// Cheat Engine returned a value whose type, table elements, or instruction length violated the contract. - InvalidResult + InvalidResult = 11, + + /// + /// The selected target is not an operating-system process: Cheat Engine reported the file-as-process sentinel + /// identifier (4294967295). A file opened as a process has no instruction profile in the SDK. + /// + UnsupportedTargetBackend = 12 } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfile.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfile.cs index 1328dc0d..997f86e8 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfile.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfile.cs @@ -10,14 +10,16 @@ namespace CheatEngine.SDK.Engine.Assembly; /// /// This is a validation contract, not a command that reconfigures Cheat Engine's ambient assembler or /// disassembler. obtains the facts from CE's target -/// probes. In particular, the SDK never substitutes , the x64 CE-host width, -/// or a module bitness flag for this profile. +/// probes: the ISA family from targetIsX86/targetIsArm and the width from Cheat Engine's 64-bit +/// process flag targetIs64Bit. On Cheat Engine an x64 target is the x86 family with the 64-bit flag set. +/// The SDK never substitutes , the x64 CE-host width, CE's configured pointer +/// size (getPointerSize), or a module bitness flag for this profile. /// /// -/// The pinned CE 7.7 source establishes the Lua operation shapes only. It does not supply a reviewed live -/// host observation that the current ambient target matches this profile. A profile therefore rejects only -/// inconsistent values and over-wide addresses before Lua is entered; it does not claim target identity, -/// relocation support, or host qualification. +/// The mapping follows (spike C3 D2, ObservedHost, +/// Lua-only design input, and CE source ec45d5f, ObservedSource). Fixture tests cover it at C1/C2; it is not a +/// live host qualification. A profile rejects only inconsistent values and over-wide addresses before Lua is +/// entered; it does not claim target identity, relocation support, or host qualification. /// /// [StructLayout(LayoutKind.Sequential)] diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs index c371053a..8f263bf4 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs @@ -1,10 +1,8 @@ -using System; - using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Lua.Calls; -using CheatEngine.SDK.Lua.CompilerServices; -using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -14,29 +12,37 @@ namespace CheatEngine.SDK.Engine.Assembly; /// /// /// The observation calls getOpenedProcessID, targetIs64Bit, targetIsX86, and -/// targetIsArm under one Lua runtime admission, then checks the process identifier a second time. It -/// rejects contradictory architecture facts rather than inferring an ISA from a pointer width or from the -/// managed host. +/// targetIsArm under one Lua runtime admission, then checks the process identifier a second time. The +/// profile follows : on Cheat Engine, x86-64 is the x86 +/// family plus the 64-bit flag (targetIsX86() == true and targetIs64Bit() == true), the 64-bit +/// flag alone never selects a family, and "contradictory" means both families or neither. The profile width is +/// Cheat Engine's 64-bit process flag (targetIs64Bit), never getPointerSize, the managed host +/// width, or PointerSize.FromArchitecture. +/// +/// +/// With no target selected Cheat Engine reports exactly the facts of an x64 target (spike C3 D2, ObservedHost, +/// Lua-only, 2026-09-22), so the process identifier is read first and a zero identifier returns +/// before any ISA probe runs. The file-as-process +/// sentinel identifier 4294967295 returns , +/// also before any probe. Cheat Engine's "assembly mode" is the same 64-bit process flag +/// (setAssemblerMode writes it); the SDK reads it through targetIs64Bit and never calls +/// setAssemblerMode. /// /// /// CE exposes ambient target state rather than a target-selection lock. The repeated identifier makes a -/// selection transition observable during this operation, but it does not identify a process incarnation or -/// prove that a later assembler or disassembler call runs against the same target. Consumers must retain -/// the resulting and pass it to an instruction operation, which -/// performs the same -/// before-and-after coherence check. +/// selection transition observable during this operation, but it is an observation, not a lock: it does not +/// identify a process incarnation or prove that a later assembler or disassembler call runs against the same +/// target. Consumers must retain the resulting and pass it to an +/// instruction operation, which performs the same before-and-after coherence check and reports an observed +/// change after the effect as without promising any +/// rollback. /// /// public static class InstructionProfiles { - private static readonly LuaRef SGetOpenedProcessId = new(); - private static readonly LuaRef STargetIs64Bit = new(); - private static readonly LuaRef STargetIsX86 = new(); - private static readonly LuaRef STargetIsArm = new(); - /// Observes the current CE target and returns a profile only when its selected PID and ISA probes agree. /// The copied profile and selected PID only when the returned status is success. - /// A target, availability, protected-Lua, or profile-consistency outcome. + /// A target, backend, availability, protected-Lua, or profile-consistency outcome. /// /// The plugin is not enabled or the calling thread has no Lua /// state. @@ -50,47 +56,22 @@ public static InstructionOperationStatus TryObserveCurrent(out InstructionTarget int top = state.Top; try { - InstructionOperationStatus status = TryGetCurrentTarget(state, out TargetProcessId firstTarget); - if (status != InstructionOperationStatus.Success) + TargetProbeResult probe = TargetArchitectureProbe.Observe(state, TargetProbeFacts.InstructionSet, + TargetProbeFacts.InstructionSet); + if (probe.Status != TargetProbeStatus.Success) { - return status; + return FromProbe(probe.Status); } - status = TryCallBoolean(state, STargetIs64Bit, "targetIs64Bit"u8, out bool is64Bit); - if (status != InstructionOperationStatus.Success) - { - return status; - } - - status = TryCallBoolean(state, STargetIsX86, "targetIsX86"u8, out bool isX86); - if (status != InstructionOperationStatus.Success) - { - return status; - } - - status = TryCallBoolean(state, STargetIsArm, "targetIsArm"u8, out bool isArm); - if (status != InstructionOperationStatus.Success) - { - return status; - } - - if (!TryCreateProfile(is64Bit, isX86, isArm, out InstructionProfile profile)) + if (!RuntimeInfo.TryDeriveTargetArchitecture(probe.IsX86Family.GetValueOrDefault(), + probe.IsArmFamily.GetValueOrDefault(), probe.Is64Bit.GetValueOrDefault(), + out CheatEngineArchitecture architecture)) { return InstructionOperationStatus.InvalidProfile; } - status = TryGetCurrentTarget(state, out TargetProcessId finalTarget); - if (status != InstructionOperationStatus.Success) - { - return status; - } - - if (firstTarget != finalTarget) - { - return InstructionOperationStatus.TargetChanged; - } - - targetProfile = new InstructionTargetProfile(firstTarget, profile); + targetProfile = new InstructionTargetProfile(new TargetProcessId(probe.ProcessId), + ToProfile(architecture)); return InstructionOperationStatus.Success; } catch (LuaException) @@ -104,104 +85,51 @@ public static InstructionOperationStatus TryObserveCurrent(out InstructionTarget } } + /// + /// Re-checks, before or after an instruction call, that CE still selects the profiled process. Any other valid + /// selection (another identifier, no target, or the file-as-process sentinel) differs from the profile and is + /// , which after the effect is the uncertainty status of + /// audit A15-06; a failed or malformed read keeps its own status. + /// internal static InstructionOperationStatus TryVerifyCurrent(LuaState state, TargetProcessId expectedTarget) { - InstructionOperationStatus status = TryGetCurrentTarget(state, out TargetProcessId actualTarget); - return status == InstructionOperationStatus.Success && actualTarget != expectedTarget - ? InstructionOperationStatus.TargetChanged - : status; - } - - private static InstructionOperationStatus TryGetCurrentTarget(LuaState state, out TargetProcessId target) - { - target = default; - InstructionOperationStatus status = TryPushGlobal(state, SGetOpenedProcessId, "getOpenedProcessID"u8); - if (status != InstructionOperationStatus.Success) - { - return status; - } - - if (!state.TryCall(0, 1).IsOk) - { - return InstructionOperationStatus.LuaFailure; - } - - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long value) || - value is < 0 or > int.MaxValue) - { - return InstructionOperationStatus.InvalidResult; - } - - if (value == 0) - { - return InstructionOperationStatus.TargetNotSelected; - } - - target = new TargetProcessId((int) value); - return InstructionOperationStatus.Success; - } - - private static InstructionOperationStatus TryCallBoolean(LuaState state, LuaRef cache, ReadOnlySpan name, - out bool value) - { - value = default; - InstructionOperationStatus status = TryPushGlobal(state, cache, name); - if (status != InstructionOperationStatus.Success) - { - return status; - } - - if (!state.TryCall(0, 1).IsOk) - { - return InstructionOperationStatus.LuaFailure; - } - - if (state.TypeOf(-1) != LuaType.Boolean) + TargetProbeStatus status = TargetArchitectureProbe.ReadProcessId(state, out int actualTarget, out _); + return status switch { - return InstructionOperationStatus.InvalidResult; - } - - value = state.ToBoolean(-1); - return InstructionOperationStatus.Success; + TargetProbeStatus.Success => actualTarget == expectedTarget.Value + ? InstructionOperationStatus.Success + : InstructionOperationStatus.TargetChanged, + TargetProbeStatus.NoTargetSelected or TargetProbeStatus.FileAsProcess => InstructionOperationStatus + .TargetChanged, + _ => FromProbe(status) + }; } - private static InstructionOperationStatus TryPushGlobal(LuaState state, LuaRef cache, ReadOnlySpan name) + private static InstructionOperationStatus FromProbe(TargetProbeStatus status) { - return LuaGlobalFunctions.TryPushWithStatus(state, cache, name) switch + return status switch { - LuaGlobalPushStatus.Success => InstructionOperationStatus.Success, - LuaGlobalPushStatus.Unavailable => InstructionOperationStatus.GlobalUnavailable, - _ => InstructionOperationStatus.LuaFailure + TargetProbeStatus.Success => InstructionOperationStatus.Success, + TargetProbeStatus.NoTargetSelected => InstructionOperationStatus.TargetNotSelected, + TargetProbeStatus.FileAsProcess => InstructionOperationStatus.UnsupportedTargetBackend, + TargetProbeStatus.TargetChanged => InstructionOperationStatus.TargetChanged, + TargetProbeStatus.GlobalUnavailable => InstructionOperationStatus.GlobalUnavailable, + TargetProbeStatus.LuaFailure => InstructionOperationStatus.LuaFailure, + TargetProbeStatus.InvalidProcessId or TargetProbeStatus.InvalidResult => InstructionOperationStatus + .InvalidResult, + _ => InstructionOperationStatus.Unknown }; } - private static bool TryCreateProfile(bool is64Bit, bool isX86, bool isArm, out InstructionProfile profile) + private static InstructionProfile ToProfile(CheatEngineArchitecture architecture) { - if (isX86 && isArm) - { - profile = default; - return false; - } - - if (isX86) + return architecture switch { - profile = is64Bit ? default : InstructionProfile.X86; - return !is64Bit; - } - - if (isArm) - { - profile = is64Bit ? InstructionProfile.Arm64 : InstructionProfile.Arm32; - return true; - } - - if (is64Bit) - { - profile = InstructionProfile.X64; - return true; - } - - profile = default; - return false; + CheatEngineArchitecture.X86 => InstructionProfile.X86, + CheatEngineArchitecture.X64 => InstructionProfile.X64, + CheatEngineArchitecture.Arm32 => InstructionProfile.Arm32, + CheatEngineArchitecture.Arm64 => InstructionProfile.Arm64, + _ => default + }; } } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionTargetProfile.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionTargetProfile.cs index d0cae993..9ba45d08 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionTargetProfile.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionTargetProfile.cs @@ -10,11 +10,13 @@ namespace CheatEngine.SDK.Engine.Assembly; /// globals. /// /// -/// Instances are created only by . Instruction operations -/// re-check the target process identifier before and after their CE call and report +/// Instances are created only by , which reads the selected +/// process identifier before the ISA probes because Cheat Engine reports x64-like facts when no target is selected. +/// Instruction operations re-check the target process identifier before and after their CE call and report /// when either check differs. This is a coherence check, -/// not a host lock or process-incarnation observation: Cheat Engine can still change its ambient selection after -/// the final check, an unseen A→B→A transition is not detected, and a reused PID is not distinguished. +/// an observation rather than a lock: Cheat Engine can still change its ambient selection after the final check, +/// an unseen A→B→A transition is not detected, a reused PID is not distinguished, and a change observed after the +/// CE effect reports uncertainty without any rollback promise. /// [StructLayout(LayoutKind.Sequential)] public readonly record struct InstructionTargetProfile diff --git a/libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj b/libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj index 8f2efd22..e03a993c 100644 --- a/libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj +++ b/libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj @@ -20,6 +20,7 @@ + diff --git a/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs b/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs index b793f0af..b130deb9 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs @@ -312,8 +312,8 @@ public static ReadOnlySpan ToCEName(this MemoryProtection value) public static bool TryParseCEName(ReadOnlySpan ceName, out MemoryProtection value) { for (MemoryProtection candidate = MemoryProtection.ReadOnly; - candidate <= MemoryProtection.ExecuteWriteCopy; - candidate = (MemoryProtection) ((uint) candidate << 1)) + candidate <= MemoryProtection.ExecuteWriteCopy; + candidate = (MemoryProtection) ((uint) candidate << 1)) { if (ceName.SequenceEqual(candidate.ToCEName())) { diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs index f504b52b..0493514c 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs @@ -49,6 +49,6 @@ public string Operation private static string CreateDefaultMessage(string operation) { return "The required binding global for Cheat Engine operation '" + - RequireText(operation, nameof(operation)) + "' is unavailable."; + RequireText(operation, nameof(operation)) + "' is unavailable."; } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs index 77bb10a3..0ff4c182 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs @@ -75,6 +75,6 @@ private static string CreateDefaultMessage(string operation, LuaStatus status) } return "The protected Lua operation '" + RequireText(operation, nameof(operation)) + "' failed with status " + - status + "."; + status + "."; } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs index 92f6ed1d..61a54231 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs @@ -87,8 +87,8 @@ private static string CreateDefaultMessage(string operation, EngineMarshallingDi string directionText = ValidateDirection(direction) == EngineMarshallingDirection.Argument ? "argument" : "result"; return "The " + directionText + " of Cheat Engine operation '" + - RequireText(operation, nameof(operation)) + "' could not be marshalled: expected " + - RequireText(expected, nameof(expected)) + ", observed " + RequireText(actual, nameof(actual)) + "."; + RequireText(operation, nameof(operation)) + "' could not be marshalled: expected " + + RequireText(expected, nameof(expected)) + ", observed " + RequireText(actual, nameof(actual)) + "."; } private static EngineMarshallingDirection ValidateDirection(EngineMarshallingDirection direction) diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs index a95af947..f29f792e 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs @@ -48,7 +48,7 @@ public TargetReleaseOutcome CleanupOutcome private static string CreateMessage(string operation, TargetReleaseOutcome cleanupOutcome) { return "The Engine operation '" + RequireText(operation, nameof(operation)) + - "' completed before its ownership could be published; compensation ended as " + - cleanupOutcome.Status + "."; + "' completed before its ownership could be published; compensation ended as " + + cleanupOutcome.Status + "."; } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs index 40e0063f..0791a540 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs @@ -36,6 +36,6 @@ public TargetIdentityCheck Check private static string CreateMessage(string operation, TargetIdentityCheck check) { return "The Engine operation '" + RequireText(operation, nameof(operation)) + - "' was refused because its original target is not verified as current (" + check.Kind + ")."; + "' was refused because its original target is not verified as current (" + check.Kind + ")."; } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs b/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs index e081e23c..73b5aedf 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs @@ -512,7 +512,7 @@ private static InspectionStatus ReadModuleCollection(LuaState state, int tableIn for (int index = 0; index < sequenceCount; index++) { if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadModuleInfo(state, -1, out snapshot[index])) + !TryReadModuleInfo(state, -1, out snapshot[index])) { return InspectionStatus.InvalidResult; } @@ -554,7 +554,7 @@ private static InspectionStatus ReadSectionCollection(LuaState state, int tableI for (int index = 0; index < sequenceCount; index++) { if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadModuleSectionInfo(state, -1, out snapshot[index])) + !TryReadModuleSectionInfo(state, -1, out snapshot[index])) { return InspectionStatus.InvalidResult; } @@ -596,7 +596,7 @@ private static InspectionStatus ReadMemoryRegionCollection(LuaState state, int t for (int index = 0; index < sequenceCount; index++) { if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadMemoryRegionInfo(state, -1, out snapshot[index])) + !TryReadMemoryRegionInfo(state, -1, out snapshot[index])) { return InspectionStatus.InvalidResult; } @@ -627,10 +627,10 @@ private static bool TryReadModuleInfo(LuaState state, int tableIndex, out Module { module = default; if (!TryReadRequiredStringField(state, tableIndex, "Name"u8, out string? name) || - !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || - !TryReadOptionalMemorySizeField(state, tableIndex, "Size"u8, out MemorySize? size) || - !TryReadBooleanField(state, tableIndex, "Is64Bit"u8, out bool is64Bit) || - !TryReadRequiredStringField(state, tableIndex, "PathToFile"u8, out string? pathToFile)) + !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || + !TryReadOptionalMemorySizeField(state, tableIndex, "Size"u8, out MemorySize? size) || + !TryReadBooleanField(state, tableIndex, "Is64Bit"u8, out bool is64Bit) || + !TryReadRequiredStringField(state, tableIndex, "PathToFile"u8, out string? pathToFile)) { return false; } @@ -643,9 +643,9 @@ private static bool TryReadModuleSectionInfo(LuaState state, int tableIndex, out { section = default; if (!TryReadRequiredStringField(state, tableIndex, "Name"u8, out string? name) || - !TryReadMemorySizeField(state, tableIndex, "Size"u8, out MemorySize size) || - !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || - !TryReadUInt64Field(state, tableIndex, "FileAddress"u8, out ulong fileAddress)) + !TryReadMemorySizeField(state, tableIndex, "Size"u8, out MemorySize size) || + !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || + !TryReadUInt64Field(state, tableIndex, "FileAddress"u8, out ulong fileAddress)) { return false; } @@ -658,9 +658,9 @@ private static bool TryReadSymbolInfo(LuaState state, int tableIndex, out Symbol { symbol = default; if (!TryReadRequiredStringField(state, tableIndex, "modulename"u8, out string? moduleName) || - !TryReadRequiredStringField(state, tableIndex, "searchkey"u8, out string? searchKey) || - !TryReadAddressField(state, tableIndex, "address"u8, out Address address) || - !TryReadMemorySizeField(state, tableIndex, "symbolsize"u8, out MemorySize size)) + !TryReadRequiredStringField(state, tableIndex, "searchkey"u8, out string? searchKey) || + !TryReadAddressField(state, tableIndex, "address"u8, out Address address) || + !TryReadMemorySizeField(state, tableIndex, "symbolsize"u8, out MemorySize size)) { return false; } @@ -673,14 +673,14 @@ private static bool TryReadMemoryRegionInfo(LuaState state, int tableIndex, out { region = default; if (!TryReadAddressField(state, tableIndex, "BaseAddress"u8, out Address baseAddress) || - !TryReadAddressField(state, tableIndex, "AllocationBase"u8, out Address allocationBase) || - !TryReadProtectionField(state, tableIndex, "AllocationProtect"u8, - out MemoryProtection allocationProtection) || - !TryReadMemorySizeField(state, tableIndex, "RegionSize"u8, out MemorySize size) || - !TryReadUInt32Field(state, tableIndex, "State"u8, out uint stateValue) || - !TryReadProtectionField(state, tableIndex, "Protect"u8, out MemoryProtection protection) || - !TryReadUInt32Field(state, tableIndex, "Type"u8, out uint typeValue) || - !TryReadOptionalStringField(state, tableIndex, "Extra"u8, out string? extra)) + !TryReadAddressField(state, tableIndex, "AllocationBase"u8, out Address allocationBase) || + !TryReadProtectionField(state, tableIndex, "AllocationProtect"u8, + out MemoryProtection allocationProtection) || + !TryReadMemorySizeField(state, tableIndex, "RegionSize"u8, out MemorySize size) || + !TryReadUInt32Field(state, tableIndex, "State"u8, out uint stateValue) || + !TryReadProtectionField(state, tableIndex, "Protect"u8, out MemoryProtection protection) || + !TryReadUInt32Field(state, tableIndex, "Type"u8, out uint typeValue) || + !TryReadOptionalStringField(state, tableIndex, "Extra"u8, out string? extra)) { return false; } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolList.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolList.cs new file mode 100644 index 00000000..21e0f4d7 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolList.cs @@ -0,0 +1,517 @@ +using System; + +using CheatEngine.SDK.Annotations.Lifetime; +using CheatEngine.SDK.Annotations.Lua; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Marshalling; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// +/// A borrowed handle to Cheat Engine's SymbolList class: a list of symbols that maps names to addresses and +/// addresses to names, and that can be registered with Cheat Engine's symbol handler. +/// +/// +/// +/// Ownership. The handle owns nothing and has no destroy, register or unregister member. A list created by +/// belongs in ; registering it transfers that owner +/// into a , which unregisters before destroying. The main list returned +/// by belongs to Cheat Engine: it is only ever a borrowed handle, and no SDK +/// API can register, unregister or destroy it. The ccodesymbols list of an Auto Assembler disable-info +/// table is Cheat Engine's too and is never exposed as an owner. +/// +/// +/// Evidence. CE 7.7.0.10621 celua.txt (SHA-256 +/// AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00), SymbolList class, lines +/// 3523-3563; a Lua-only host observation of 2026-09-22 confirmed that createSymbolList() returns an +/// unregistered caller-owned object destroyed once. The optional addSymbol arguments +/// (skipAddressToSymbolLookup, extradata) and the module members are not projected. +/// +/// +/// Results. Every member runs protected calls, restores the Lua stack, and returns a +/// : when the member is +/// absent, when an access or call raised (including every call on +/// a list Cheat Engine already destroyed), for a documented +/// nil (a symbol that is not found), and for any other +/// shape. No category is derived from Lua error text. Thread affinity is not established: no +/// MainThreadOnly claim is made. +/// +/// +public readonly struct SymbolList : IEquatable, ICEObject, ILuaMarshaller +{ + /// Wraps an untyped Cheat Engine object handle as a SymbolList handle. + /// The native host-object handle; its runtime class is not checked. + /// A pure value operation: it grants no ownership. + public SymbolList(CEObject handle) + { + Handle = handle; + } + + /// Gets the handle that names no SymbolList. + public static SymbolList Null => default; + + /// + public CEObject Handle + { + get; + } + + /// Gets a value indicating whether this handle names no object. + public bool IsNull => Handle.IsNull; + + /// + public static SymbolList FromHandle(CEObject handle) + { + return new SymbolList(handle); + } + + /// Compares two SymbolList handles by native-object identity. + /// The first handle. + /// The second handle. + public static bool operator ==(SymbolList left, SymbolList right) + { + return left.Handle == right.Handle; + } + + /// Compares two SymbolList handles by native-object identity. + /// The first handle. + /// The second handle. + public static bool operator !=(SymbolList left, SymbolList right) + { + return left.Handle != right.Handle; + } + + /// + public bool Equals(SymbolList other) + { + return Handle == other.Handle; + } + + /// + public override bool Equals(object? obj) + { + return obj is SymbolList other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Handle.GetHashCode(); + } + + /// Formats the native-object identity for diagnostics. + /// SymbolList(CEObject@0x...), or SymbolList(null). + public override string ToString() + { + return IsNull ? "SymbolList(null)" : "SymbolList(" + Handle + ")"; + } + + /// + [RequiresPluginEnabled] + [LuaStackEffect(1)] + public static void Push(LuaState state, SymbolList value) + { + value.Handle.Push(state); + } + + /// + [LuaStackEffect(0)] + public static bool TryRead(LuaState state, int index, out SymbolList value) + { + if (CEObject.TryRead(state, index, out CEObject handle)) + { + value = FromHandle(handle); + return true; + } + + value = default; + return false; + } + + /// Removes every symbol from this list with CE's clear(). + /// The protected call outcome. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TryClear() + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + LuaOperationStatus status = TryPushMember(state, "clear"u8); + return status.IsSuccess ? Call(state, 0, 0) : status; + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// + /// Adds a symbol with CE's addSymbol(modulename, searchkey, address, symbolsize), passing exactly these four + /// arguments. + /// + /// The module name recorded for the symbol. + /// The name the symbol is found by; forwarded as exact UTF-8, embedded NUL included. + /// The target address of the symbol. + /// The symbol extent in bytes. + /// The protected call outcome. + /// or is null. + /// is negative. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TryAddSymbol(string moduleName, string searchKey, Address address, int size) + { + ArgumentNullException.ThrowIfNull(moduleName); + ArgumentNullException.ThrowIfNull(searchKey); + ArgumentOutOfRangeException.ThrowIfNegative(size); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + LuaOperationStatus status = TryPushMember(state, "addSymbol"u8); + if (!status.IsSuccess) + { + return status; + } + + StringMarshaller.Push(state, moduleName); + StringMarshaller.Push(state, searchKey); + Address.Push(state, address); + state.PushInteger(size); + return Call(state, 4, 0); + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// Deletes the symbol with with CE's deleteSymbol(searchkey). + /// The search key of the symbol to delete. + /// The protected call outcome. + /// is null. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TryDeleteSymbol(string searchKey) + { + ArgumentNullException.ThrowIfNull(searchKey); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + LuaOperationStatus status = TryPushMember(state, "deleteSymbol"u8); + if (!status.IsSuccess) + { + return status; + } + + StringMarshaller.Push(state, searchKey); + return Call(state, 1, 0); + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// Deletes the symbol at with CE's deleteSymbol(address). + /// The target address of the symbol to delete. + /// The protected call outcome. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TryDeleteSymbol(Address address) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + LuaOperationStatus status = TryPushMember(state, "deleteSymbol"u8); + if (!status.IsSuccess) + { + return status; + } + + Address.Push(state, address); + return Call(state, 1, 0); + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// Finds the symbol whose range contains with CE's getSymbolFromAddress. + /// The target address to look up; it does not have to be the symbol's start. + /// The copied symbol on success; default otherwise. + /// + /// , when no symbol + /// contains the address, or for a malformed symbol table. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TryGetSymbolFromAddress(Address address, out SymbolInfo symbol) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + symbol = default; + try + { + LuaOperationStatus status = TryPushMember(state, "getSymbolFromAddress"u8); + if (!status.IsSuccess) + { + return status; + } + + Address.Push(state, address); + return CallForSymbol(state, out symbol); + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// Finds the symbol with with CE's getSymbolFromString. + /// The search key to look up. + /// The copied symbol on success; default otherwise. + /// + /// , when the key is + /// not found, or for a malformed symbol table. + /// + /// is null. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TryGetSymbolFromString(string searchKey, out SymbolInfo symbol) + { + ArgumentNullException.ThrowIfNull(searchKey); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + symbol = default; + try + { + LuaOperationStatus status = TryPushMember(state, "getSymbolFromString"u8); + if (!status.IsSuccess) + { + return status; + } + + StringMarshaller.Push(state, searchKey); + return CallForSymbol(state, out symbol); + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// Reads CE's Name property: an optional name that identifies the list. + /// A copied managed string on success; otherwise. + /// + /// for a string, for + /// nil, for any other type. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TryGetName(out string? name) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + name = null; + try + { + LuaStatus status = Handle.TryGetPropertyLeavingObject(state, "Name"u8); + if (!status.IsOk) + { + return LuaOperationStatus.LuaFailure(status); + } + + if (state.IsNil(-1)) + { + return LuaOperationStatus.NilResult; + } + + return state.TryReadString(-1, out name) ? LuaOperationStatus.Success : LuaOperationStatus.InvalidResult; + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// Sets CE's Name property. + /// The name to assign. + /// The protected assignment outcome. + /// is null. + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TrySetName(string name) + { + ArgumentNullException.ThrowIfNull(name); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + StringMarshaller.Push(state, name); + LuaStatus status = Handle.TrySetProperty(state, "Name"u8); + return status.IsOk ? LuaOperationStatus.Success : LuaOperationStatus.LuaFailure(status); + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// Reads CE's PID property: the process identifier the list refers to. + /// The process identifier on success; 0 otherwise. + /// + /// for a 32-bit integer, + /// for nil, for any other value. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public LuaOperationStatus TryGetProcessId(out int processId) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + processId = 0; + try + { + LuaStatus status = Handle.TryGetPropertyLeavingObject(state, "PID"u8); + if (!status.IsOk) + { + return LuaOperationStatus.LuaFailure(status); + } + + if (state.IsNil(-1)) + { + return LuaOperationStatus.NilResult; + } + + return state.IsInteger(-1) && Int32Marshaller.TryRead(state, -1, out processId) + ? LuaOperationStatus.Success + : LuaOperationStatus.InvalidResult; + } + catch (LuaException exception) + { + return LuaOperationStatus.LuaFailure(exception.Status); + } + finally + { + state.SetTop(top); + } + } + + /// + /// Calls the argument-less register() or unregister() of this list on . Only + /// and call it, for a list they own; the stack + /// is restored by the caller. + /// + /// The calling thread's admitted state. + /// register or unregister, UTF-8. + /// Whether the protected call itself began. + internal LuaOperationStatus TryInvokeRegistration(LuaState state, ReadOnlySpan method, out bool invoked) + { + invoked = false; + LuaOperationStatus status = TryPushMember(state, method); + if (!status.IsSuccess) + { + return status; + } + + invoked = true; + return Call(state, 0, 0); + } + + // Pushes obj.member (the bound function Cheat Engine returns), leaving the object below it for the caller's restore: + // GlobalUnavailable when the member is absent, InvalidResult when it is not a function. + private LuaOperationStatus TryPushMember(LuaState state, ReadOnlySpan member) + { + LuaStatus status = Handle.TryGetPropertyLeavingObject(state, member); + if (!status.IsOk) + { + return LuaOperationStatus.LuaFailure(status); + } + + if (state.IsNil(-1)) + { + return LuaOperationStatus.GlobalUnavailable; + } + + return state.IsFunction(-1) ? LuaOperationStatus.Success : LuaOperationStatus.InvalidResult; + } + + private static LuaOperationStatus Call(LuaState state, int argumentCount, int resultCount) + { + LuaStatus status = state.TryCall(argumentCount, resultCount); + return status.IsOk ? LuaOperationStatus.Success : LuaOperationStatus.LuaFailure(status); + } + + private static LuaOperationStatus CallForSymbol(LuaState state, out SymbolInfo symbol) + { + symbol = default; + LuaStatus status = state.TryCall(1, 1); + if (!status.IsOk) + { + return LuaOperationStatus.LuaFailure(status); + } + + if (state.IsNil(-1)) + { + return LuaOperationStatus.NilResult; + } + + return SymbolTableReader.TryRead(state, -1, out symbol) + ? LuaOperationStatus.Success + : LuaOperationStatus.InvalidResult; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationHandoffException.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationHandoffException.cs new file mode 100644 index 00000000..03049b96 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationHandoffException.cs @@ -0,0 +1,45 @@ +using System; + +using CheatEngine.SDK.Engine.Errors; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// +/// Represents a managed lease-construction failure after Cheat Engine registered a symbol list and the SDK made its +/// one permitted compensating unregister(). +/// +/// +/// The managed failure remains . records whether +/// the compensation was confirmed (the caller keeps its Owned<SymbolList>, now unregistered), could not +/// begin, or remains indeterminate (the list was then abandoned without destroy, because destroying a possibly +/// registered list could leave a dangling entry in Cheat Engine's symbol handler). It never offers a retry. +/// +public sealed class SymbolListRegistrationHandoffException : EngineException +{ + /// + /// Initializes a handoff failure with the result of the single compensation attempt and its managed cause. + /// + /// The compensation result observed after lease construction failed. + /// The managed failure that prevented lease construction. + public SymbolListRegistrationHandoffException(SymbolListRegistrationReleaseOutcome cleanupOutcome, + Exception? innerException) + : base(CreateMessage(cleanupOutcome), innerException) + { + CleanupOutcome = cleanupOutcome; + } + + /// Gets the factual result of the one compensation attempt. + public SymbolListRegistrationReleaseOutcome CleanupOutcome + { + get; + } + + /// + public override EngineFailureKind Kind => EngineFailureKind.BindingFailure; + + private static string CreateMessage(SymbolListRegistrationReleaseOutcome cleanupOutcome) + { + return "Cheat Engine registered a symbol list before its managed lease could be published; compensation ended as " + + cleanupOutcome.UnregisterKind + "."; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationLease.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationLease.cs new file mode 100644 index 00000000..ddccbf9f --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationLease.cs @@ -0,0 +1,161 @@ +using System; +using System.Threading; + +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// +/// The sole owner of a plugin-created while it is registered with Cheat Engine's symbol +/// handler. Releasing it unregisters the list first, then destroys it. +/// +/// +/// +/// Created by , which transfers the list's into the +/// lease. The order of is fixed: an already terminal lease reports +/// ; a detached runtime or another Lua universe reports +/// and abandons the list without any Cheat Engine call; +/// an unavailable unregister member reports +/// and keeps the lease retryable; an unregister() that raised reports +/// and abandons the list without +/// destroying it (destroying a possibly registered list could leave a dangling entry in the symbol handler); a +/// confirmed unregister() is followed by the list's one destroy through +/// . +/// +/// +/// The list is unregistered by object, not by name, so no target check is needed. There is no finalizer. The lease +/// is synchronized: concurrent releases make one attempt. +/// +/// +public sealed class SymbolListRegistrationLease : IDisposable +{ + private readonly Lock _gate = new(); + private Owned? _list; + private SymbolListRegistrationReleaseOutcome? _terminalOutcome; + + internal SymbolListRegistrationLease(Owned list, bool registrationConfirmed) + { + ArgumentNullException.ThrowIfNull(list); + _list = list; + Origin = list.Origin; + RegistrationConfirmed = registrationConfirmed; + } + + /// Gets the Lua runtime identity that created the list. A symbol list is not bound to a target process. + public EngineResourceOrigin Origin + { + get; + } + + /// + /// Gets whether Cheat Engine confirmed the register() call. when the call raised + /// after it began: the list may or may not be registered, so the lease still unregisters before destroying. + /// + public bool RegistrationConfirmed + { + get; + } + + /// Gets the registered list as a borrowed handle. + /// The lease reached a terminal outcome. + public SymbolList List + { + get + { + lock (_gate) + { + if (_list is null || _list.IsDisposed) + { + throw new ObjectDisposedException(nameof(SymbolListRegistrationLease), + "The symbol-list registration lease no longer owns its list."); + } + + return _list.Value; + } + } + } + + /// Gets whether the lease reached a terminal outcome. + public bool IsTerminal + { + get + { + lock (_gate) + { + return _terminalOutcome.HasValue; + } + } + } + + /// Calls and intentionally discards its structured outcome. + public void Dispose() + { + _ = Release(); + } + + /// Unregisters the list, then destroys it, and reports both steps. Never throws. + /// The outcome of both steps; see the type remarks for the order and the terminal cases. + public SymbolListRegistrationReleaseOutcome Release() + { + lock (_gate) + { + if (_terminalOutcome.HasValue || _list is null) + { + return new SymbolListRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.AlreadyReleased, + null, default); + } + + Owned list = _list; + if (!LuaRuntime.IsAttached || !EngineResourceOrigin.IsCurrent(Origin.Runtime)) + { + return Terminal(SymbolRegistrationReleaseKind.StaleRuntime, null, list.ReleaseWithOutcome()); + } + + if (!LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)) + { + // Attached, but no operation could be admitted (a transition, or no state for this thread): no call. + return new SymbolListRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, + null, default); + } + + LuaOperationStatus status; + bool invoked; + using (operation) + { + LuaState state = operation.State; + using LuaFrame frame = new(state); + status = list.Value.TryInvokeRegistration(state, "unregister"u8, out invoked); + } + + if (status.IsSuccess) + { + return Terminal(SymbolRegistrationReleaseKind.Released, status, list.ReleaseWithOutcome()); + } + + if (!invoked) + { + // No unregister began (member absent, or its resolution failed): nothing changed, retry later. + return new SymbolListRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, status, + default); + } + + _ = list.Abandon(); + return Terminal(SymbolRegistrationReleaseKind.CleanupIndeterminate, status, + TargetReleaseOutcome.NotInvoked(EngineFailureKind.ProtectedLuaFailure)); + } + } + + private SymbolListRegistrationReleaseOutcome Terminal(SymbolRegistrationReleaseKind kind, + LuaOperationStatus? status, TargetReleaseOutcome listRelease) + { + SymbolListRegistrationReleaseOutcome outcome = new(kind, status, listRelease); + _terminalOutcome = outcome; + _list = null; + return outcome; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationLeaseFactory.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationLeaseFactory.cs new file mode 100644 index 00000000..db09663c --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationLeaseFactory.cs @@ -0,0 +1,7 @@ +using CheatEngine.SDK.Engine.Objects; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// Internal lease-construction seam used only to test a managed failure after a successful register(). +internal delegate SymbolListRegistrationLease SymbolListRegistrationLeaseFactory(Owned list, + bool registrationConfirmed); diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationReleaseOutcome.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationReleaseOutcome.cs new file mode 100644 index 00000000..4d600137 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolListRegistrationReleaseOutcome.cs @@ -0,0 +1,57 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// The structured outcome of releasing a : unregister, then destroy. +[StructLayout(LayoutKind.Auto)] +public readonly struct SymbolListRegistrationReleaseOutcome +{ + internal SymbolListRegistrationReleaseOutcome(SymbolRegistrationReleaseKind unregisterKind, + LuaOperationStatus? unregisterStatus, TargetReleaseOutcome listRelease) + { + UnregisterKind = unregisterKind; + UnregisterStatus = unregisterStatus; + ListRelease = listRelease; + } + + /// + /// Gets how the unregister step ended: , + /// , , + /// (retryable) or + /// . + /// + public SymbolRegistrationReleaseKind UnregisterKind + { + get; + } + + /// + /// Gets the status of the unregister() call; when it was never called (the lease + /// was already terminal, the runtime is stale, or no operation could be admitted). This is a nullable value + /// rather than () so a no-call outcome can never read + /// as (A08-26). + /// + public LuaOperationStatus? UnregisterStatus + { + get; + } + + /// + /// Gets how the owned list ended: after a confirmed destroy, + /// after a destroy that raised, + /// or when + /// the list was abandoned without any call, and while the list is + /// still owned (a retryable outcome, or a compensation that left the list with its caller). + /// + public TargetReleaseOutcome ListRelease + { + get; + } + + /// Gets whether no later release attempt can be made through the lease. + public bool IsTerminal => + UnregisterKind is not (SymbolRegistrationReleaseKind.Unknown or SymbolRegistrationReleaseKind.CleanupUnavailable); +} diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolLists.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolLists.cs new file mode 100644 index 00000000..bcc7e6ee --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolLists.cs @@ -0,0 +1,261 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Annotations.Lifetime; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.CompilerServices; +using CheatEngine.SDK.Lua.References; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// +/// Creates plugin-owned objects, exposes Cheat Engine's main symbol list as a borrowed +/// handle, and registers an owned list through a . +/// +/// +/// +/// Three things stay distinct: a symbol registered on its own (), a symbol list owned +/// by the plugin (, an destroyed once), and the main list borrowed +/// from Cheat Engine (, never destroyable, registrable or unregistrable through the SDK). +/// +/// +/// Only the zero-argument createSymbolList() is projected: its overload that takes an initial list and a +/// name registers the list automatically, which would publish a registration before its owner exists. Globals are +/// resolved through the SDK's cached, protected global push; no category is derived from Lua error text; a +/// detached runtime throws . +/// +/// +public static class SymbolLists +{ + private static readonly LuaRef SCreateSymbolList = new(); + private static readonly LuaRef SGetMainSymbolList = new(); + + /// Creates one empty, unregistered, plugin-owned symbol list with createSymbolList(). + /// The owner on success; otherwise. + /// + /// with an owner, , + /// , , or + /// when Cheat Engine returned something that is not a host + /// object. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static LuaOperationStatus TryCreate(out Owned? list) + { + list = null; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + LuaOperationStatus status = TryCallGlobal(state, SCreateSymbolList, "createSymbolList"u8, out CEObject handle); + if (!status.IsSuccess) + { + return status; + } + + list = Publish(state, handle); + return LuaOperationStatus.Success; + } + + /// Gets Cheat Engine's main symbol list with getMainSymbolList(), as a borrowed handle only. + /// A borrowed, Cheat-Engine-owned list; default on failure. + /// The protected call outcome, with the same categories as . + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// The main list belongs to Cheat Engine's symbol handler: never destroy, register or unregister it. No SDK API + /// accepts a borrowed for those operations. + /// + [RequiresPluginEnabled] + public static LuaOperationStatus TryGetMain([CEOwned] out SymbolList list) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + LuaOperationStatus status = TryCallGlobal(state, SGetMainSymbolList, "getMainSymbolList"u8, + out CEObject handle); + list = status.IsSuccess ? SymbolList.FromHandle(handle) : default; + return status; + } + + /// + /// Registers an owned list with Cheat Engine's symbol handler (register()) and transfers its ownership into a + /// lease that unregisters before it destroys. + /// + /// The owned list; on a call that began, its ownership moves into . + /// + /// The lease after register() began: with + /// on success, when the call raised; when no + /// call began (the owner then stays with the caller). + /// + /// + /// The status of register(). with a lease means the + /// registration state is unknown: the list never returns to a caller who could destroy a possibly registered list. + /// + /// is . + /// no longer owns a list. + /// + /// The plugin is not enabled, the calling thread has no Lua state, or was created in a + /// previous Lua runtime identity. Unlike a stale 's own Dispose/TryDestroy/ + /// , this throw does not consume : no register + /// call was attempted, so the caller can still dispose the list itself through its normal destroy path. + /// + /// + /// Cheat Engine registered the list but the lease could not be constructed; the exception reports the one + /// compensating unregister(). + /// + [RequiresPluginEnabled] + public static LuaOperationStatus TryRegister(Owned list, out SymbolListRegistrationLease? lease) + { + return TryRegisterCore(list, out lease, CreateLease); + } + + // The factory is an internal test seam: it makes a managed failure after a successful register() deterministic + // without letting consumers choose another ownership policy. + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "The register call, ownership transfer and compensation form one transaction.")] + internal static LuaOperationStatus TryRegisterCore(Owned list, out SymbolListRegistrationLease? lease, + SymbolListRegistrationLeaseFactory leaseFactory) + { + ArgumentNullException.ThrowIfNull(list); + ArgumentNullException.ThrowIfNull(leaseFactory); + lease = null; + SymbolList value = list.Value; + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + if (!EngineResourceOrigin.IsCurrent(list.Origin.Runtime)) + { + throw new InvalidOperationException( + "The symbol list was created in a previous Lua runtime identity and cannot be registered."); + } + + LuaState state = operation.State; + LuaOperationStatus status; + bool invoked; + using (LuaFrame frame = new(state)) + { + status = value.TryInvokeRegistration(state, "register"u8, out invoked); + } + + if (!invoked) + { + return status; + } + + Owned transferred = list.PrepareTransfer(); + if (!status.IsSuccess) + { + // register() began and raised: keep the list out of the caller's hands, unconfirmed. + lease = new SymbolListRegistrationLease(transferred, false); + list.CompleteTransfer(transferred); + return status; + } + + try + { + lease = leaseFactory(transferred, true) ?? + throw new InvalidOperationException("The symbol-list registration lease factory returned no lease."); + } + catch (Exception exception) + { + lease = null; + SymbolListRegistrationReleaseOutcome compensation = CompensateFailedPublication(state, list); + throw new SymbolListRegistrationHandoffException(compensation, exception); + } + + list.CompleteTransfer(transferred); + return status; + } + + private static SymbolListRegistrationLease CreateLease(Owned list, bool registrationConfirmed) + { + return new SymbolListRegistrationLease(list, registrationConfirmed); + } + + // One unregister() after a register() whose lease could not be published. On success the caller keeps its + // unregistered owner; when the unregister raised, the list is abandoned without destroy (it may still be registered). + private static SymbolListRegistrationReleaseOutcome CompensateFailedPublication(LuaState state, + Owned list) + { + LuaOperationStatus status; + bool invoked; + using (LuaFrame frame = new(state)) + { + status = list.Value.TryInvokeRegistration(state, "unregister"u8, out invoked); + } + + if (status.IsSuccess) + { + return new SymbolListRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Released, status, default); + } + + if (!invoked) + { + return new SymbolListRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, status, + default); + } + + _ = list.Abandon(); + return new SymbolListRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupIndeterminate, status, + TargetReleaseOutcome.NotInvoked(EngineFailureKind.ProtectedLuaFailure)); + } + + // Calls a zero-argument global that returns one host object. + private static LuaOperationStatus TryCallGlobal(LuaState state, LuaRef cache, ReadOnlySpan name, + out CEObject handle) + { + handle = CEObject.Null; + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, cache, name); + if (!global.IsSuccess) + { + return global.Status == LuaGlobalPushStatus.Unavailable + ? LuaOperationStatus.GlobalUnavailable + : LuaOperationStatus.LuaFailure(global.LuaStatus.IsOk ? LuaStatus.RuntimeError : global.LuaStatus); + } + + LuaStatus status = state.TryCall(0, 1); + if (!status.IsOk) + { + return LuaOperationStatus.LuaFailure(status); + } + + if (state.IsNil(-1)) + { + return LuaOperationStatus.NilResult; + } + + return CEObject.TryRead(state, -1, out handle) ? LuaOperationStatus.Success : LuaOperationStatus.InvalidResult; + } + + // Between the read of a caller-owned handle and the publication of its owner, the raw handle is the only authority + // able to destroy the object (audit A08-09). If publication throws, destroy it once, then report the failure. + private static Owned Publish(LuaState state, CEObject handle) + { + try + { + return new Owned(SymbolList.FromHandle(handle)); + } + catch (Exception) + { + RollBack(state, handle); + throw; + } + } + + private static void RollBack(LuaState state, CEObject handle) + { + using LuaFrame rollback = new(state); + try + { + _ = handle.TryDestroy(state); + } + catch (Exception) + { + // The publication failure is the primary cause; a push failure here must not replace it, and the one + // destroy attempt is never retried. + } + } +} diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLease.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLease.cs index 1abdc9f2..659d02f9 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLease.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLease.cs @@ -1,5 +1,7 @@ using System; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Runtime; namespace CheatEngine.SDK.Engine.Inspection; @@ -8,10 +10,15 @@ namespace CheatEngine.SDK.Engine.Inspection; /// /// /// Cheat Engine's registerSymbol API returns no opaque registration token, while -/// unregisterSymbol(name) removes solely by name. Consequently this lease can prevent an older lease -/// from deleting a newer registration made through in this SDK -/// instance; it cannot prove that a Lua script, plugin, another SDK copy, or direct CE call has not replaced the -/// name. It must never be interpreted as exclusive host-wide ownership. +/// unregisterSymbol(name) removes solely by name. This lease therefore records the leased +/// and, before unregistering, resolves the name again: when it no longer resolves the +/// release reports , and when it resolves to another +/// address it reports ; neither sends an unregister, so a +/// newer third-party definition is never removed. The check is best effort and not atomic: a third party can +/// still replace the name between the lookup and the unregister, and a name that parses as an expression or that +/// collides case-insensitively with another symbol can produce a conservative Replaced. It must never be +/// interpreted as exclusive host-wide ownership. Leases made through +/// in this SDK instance additionally never unregister a newer lease's registration of the same name. /// /// /// Cleanup is explicit and deterministic. There is no finalizer because calling CE from a finalizer thread is @@ -24,11 +31,13 @@ public sealed class SymbolRegistrationLease : IDisposable private SymbolRegistrationReleaseKind? _terminalKind; private bool _terminalOutcomeObserved; - internal SymbolRegistrationLease(SymbolName name, SymbolRegistrationOptions options, LuaStateIdentity identity) + internal SymbolRegistrationLease(SymbolName name, Address address, SymbolRegistrationOptions options, + LuaStateIdentity identity) { Name = name; + Address = address; Options = options; - Identity = identity; + Origin = new EngineResourceOrigin(identity, null); } /// Gets the registered name. @@ -37,6 +46,21 @@ public SymbolName Name get; } + /// Gets the target address the name was registered at: the value the release verifies before unregistering. + public Address Address + { + get; + } + + /// + /// Gets the Lua runtime identity in which the name was registered. A symbol registration is not bound to a target + /// process incarnation. + /// + public EngineResourceOrigin Origin + { + get; + } + /// Gets the persistence option used when registering the name. public SymbolRegistrationOptions Options { @@ -46,10 +70,7 @@ public SymbolRegistrationOptions Options /// Gets whether this lease has reached a terminal outcome. public bool IsTerminal => _terminalKind.HasValue; - internal LuaStateIdentity Identity - { - get; - } + internal LuaStateIdentity Identity => Origin.Runtime; /// Calls and intentionally discards its structured outcome. /// diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeaseFactory.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeaseFactory.cs index 68f7ce70..3412c46d 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeaseFactory.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeaseFactory.cs @@ -1,7 +1,8 @@ +using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Runtime; namespace CheatEngine.SDK.Engine.Inspection; /// Internal lease-construction seam used only to test managed publication failures after CE registration. -internal delegate SymbolRegistrationLease SymbolRegistrationLeaseFactory(SymbolName name, +internal delegate SymbolRegistrationLease SymbolRegistrationLeaseFactory(SymbolName name, Address address, SymbolRegistrationOptions options, LuaStateIdentity identity); diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseKind.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseKind.cs index 7187b798..68767c41 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseKind.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseKind.cs @@ -1,23 +1,45 @@ namespace CheatEngine.SDK.Engine.Inspection; -/// Classifies an explicit attempt to release a coordinated symbol-registration lease. +/// Classifies an explicit attempt to release a symbol registration or a symbol-list registration lease. +/// +/// is the zero value: an outcome that was never assigned never reads as a release. Only +/// and leave a lease retryable. +/// public enum SymbolRegistrationReleaseKind { - /// The coordinator removed this lease's current CE registration. - Released, + /// No release outcome was recorded: the value of . + Unknown = 0, + + /// The lease's registration was removed from Cheat Engine's symbol handler. + Released = 1, /// A terminal cleanup outcome had already been returned for this lease. - AlreadyReleased, + AlreadyReleased = 2, /// A newer registration through this SDK coordinator replaced the lease, so no CE unregister was sent. - Superseded, + Superseded = 3, - /// The Lua attach epoch or state generation changed, so no CE unregister was sent to a new runtime. - StaleRuntime, + /// + /// The runtime is detached, or the Lua attach epoch or state generation changed, so no CE call was sent to another + /// runtime. + /// + StaleRuntime = 4, - /// No unregister call began because the runtime could not currently admit the operation. - CleanupUnavailable, + /// + /// No unregister call began because the runtime could not currently admit the operation, a required global or + /// member was unavailable, or the lease's ownership of the name could not be verified. The lease stays retryable. + /// + CleanupUnavailable = 5, /// An unregister call began and failed, so the host-side registration is indeterminate and will not retry. - CleanupIndeterminate + CleanupIndeterminate = 6, + + /// + /// The name now resolves to another address: a third party replaced the registration, so the lease skipped the + /// unregister rather than remove the newer definition. + /// + Replaced = 7, + + /// The name no longer resolves: it was removed by someone else, so the lease skipped the unregister. + ExternallyRemoved = 8 } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseOutcome.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseOutcome.cs index 37e83c69..da11ae70 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationReleaseOutcome.cs @@ -8,7 +8,7 @@ namespace CheatEngine.SDK.Engine.Inspection; [StructLayout(LayoutKind.Sequential)] public readonly struct SymbolRegistrationReleaseOutcome { - internal SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind kind, LuaOperationStatus status) + internal SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind kind, LuaOperationStatus? status) { Kind = kind; Status = status; @@ -20,12 +20,27 @@ public SymbolRegistrationReleaseKind Kind get; } - /// Gets CE's protected unregister status when a CE lookup or unregister was attempted. - public LuaOperationStatus Status + /// + /// Gets the status of the last CE call the release made: the unregister call, or the name lookup for + /// , + /// and a lookup failure. when no CE call was made at all + /// (, , + /// before any call). This is deliberately a nullable + /// value rather than (): a no-call outcome must never + /// be mistaken for regardless of which member of + /// numbers zero (A08-26). + /// + /// + /// A lookup that failed with a protected Lua error is reported as a Lua failure with + /// : the address-resolution primitive keeps the category, not the + /// exact protected status. + /// + public LuaOperationStatus? Status { get; } /// Gets whether no later explicit release attempt can be made through this lease. - public bool IsTerminal => Kind is not SymbolRegistrationReleaseKind.CleanupUnavailable; + public bool IsTerminal => + Kind is not (SymbolRegistrationReleaseKind.Unknown or SymbolRegistrationReleaseKind.CleanupUnavailable); } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs index f138f20b..7375e67c 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; using System.Threading; using CheatEngine.SDK.Annotations.Lifetime; @@ -16,14 +17,18 @@ namespace CheatEngine.SDK.Engine.Inspection; /// /// Provenance and compatibility. This surface binds exactly the Client source-record call shapes for /// registerSymbol, unregisterSymbol, and getNameFromAddress. It deliberately does not add -/// unqualified optional arguments or a managed name-selection policy. +/// unqualified optional arguments or a managed name-selection policy. Registration keeps Cheat Engine's own +/// behavior for an existing name: the SDK adds no collision preflight (a consumer that needs a collision policy +/// resolves the name first with ). /// /// /// Lifetime and ownership. Lookup returns a newly allocated managed string and owns no Lua reference or /// CE object. Registering a symbol mutates CE's host-wide symbol table; it does not yield an independently owned /// CE resource, and this class intentionally does not claim exclusive ownership of a name. -/// adds only a same-SDK cleanup coordinator: CE's name-only unregister cannot -/// prove that an external script or plugin has replaced a registration. +/// returns a lease that records the registered address and, before its one +/// unregister, verifies that the name still resolves to it (best effort, not atomic: CE has no registration +/// token). Cheat Engine's global that deletes every registered symbol at once is deliberately not bound: it is +/// never a per-plugin cleanup path, because it also removes the symbols of scripts, tables and other plugins. /// /// /// Calls are generated through the SDK's protected, attach-epoch-aware Lua binding path. They restore the Lua @@ -35,6 +40,15 @@ public static partial class SymbolRegistry private static readonly Lock SOwnedRegistrationGate = new(); private static readonly Dictionary SOwnedRegistrations = new(); + /// + /// Gets the status to report for a release step that made no Lua call at all: . A no-call + /// outcome is never represented by (), because + /// currently numbers as its + /// zero value, which would make read for a + /// release that never called Cheat Engine (A08-26). + /// + private static LuaOperationStatus? NoBindingCall => null; + /// Gets CE's formatted name for a target-process address with CE's default name sources. /// The target-process address passed to CE as the sole argument. /// A copied managed string only when the returned status is successful. @@ -48,7 +62,7 @@ public static partial LuaOperationStatus TryGetName([LuaMarshaller(typeof(Addres /// Registers a user-defined symbol at a target-process address. /// The registration name CE will add to its symbol handler. /// The target-process address associated with . - /// The persistence option for the registration. + /// The persistence option for the registration, forwarded unchanged as CE's donotsave. /// The protected binding outcome. /// is default or otherwise invalid. /// The plugin is not enabled or the calling thread has no Lua state. @@ -90,10 +104,10 @@ public static LuaOperationStatus Unregister(SymbolName name) } } - /// Registers a symbol and returns an explicit, coordinator-qualified cleanup lease on success. + /// Registers a symbol and returns an explicit, replacement-checking cleanup lease on success. /// The registration name CE will add to its symbol handler. /// The target-process address associated with . - /// The persistence option for the registration. + /// The persistence option for the registration, forwarded unchanged as CE's donotsave. /// The protected registration status and a lease only on successful registration. /// is default or otherwise invalid. /// The plugin is not enabled or the calling thread has no Lua state. @@ -102,10 +116,13 @@ public static LuaOperationStatus Unregister(SymbolName name) /// coordinator-qualified compensation attempt. /// /// - /// The lease prevents older leases from unregistering a newer registration made through this coordinator. CE has - /// no registration token, so this method makes no claim about replacements made outside that coordinator. A - /// successful CE registration is compensated once if its managed lease cannot be published; callers must inspect - /// rather than retrying by name. + /// The lease prevents older leases from unregistering a newer registration made through this coordinator, and + /// skips its unregister when the name was removed or now resolves to another address (a third-party + /// replacement). CE has no registration token, so that check is best effort and not atomic. A registration over + /// an existing name keeps Cheat Engine's behavior: when CE refuses it, no lease is created and nothing is + /// unregistered. A successful CE registration is compensated once if its managed lease cannot be published; + /// callers must inspect rather than retrying by + /// name. /// [RequiresPluginEnabled] public static SymbolRegistrationAcquireOutcome TryRegisterOwned(SymbolName name, Address address, @@ -147,7 +164,7 @@ internal static SymbolRegistrationAcquireOutcome TryRegisterOwnedCore(SymbolName SupersedeCurrentLease(name); try { - SymbolRegistrationLease lease = leaseFactory(name, options, identity); + SymbolRegistrationLease lease = leaseFactory(name, address, options, identity); if (lease is null) { throw new InvalidOperationException("The symbol-registration lease factory returned no lease."); @@ -158,7 +175,7 @@ internal static SymbolRegistrationAcquireOutcome TryRegisterOwnedCore(SymbolName } catch (Exception exception) { - SymbolRegistrationReleaseOutcome cleanupOutcome = CompensateFailedPublication(name, identity); + SymbolRegistrationReleaseOutcome cleanupOutcome = CompensateFailedPublication(name, address, identity); throw new SymbolRegistrationHandoffException(cleanupOutcome, exception); } } @@ -180,23 +197,36 @@ internal static SymbolRegistrationReleaseOutcome ReleaseOwned(SymbolRegistration { if (lease.ObserveTerminalKind() is { } terminalKind) { - return new SymbolRegistrationReleaseOutcome(terminalKind, LuaOperationStatus.Success); + return new SymbolRegistrationReleaseOutcome(terminalKind, NoBindingCall); } if (!LuaRuntime.IsAttached || lease.Identity != LuaRuntime.CurrentStateIdentity) { - return MarkStaleRuntime(lease); + return MarkStaleRuntime(lease, NoBindingCall); } if (!SOwnedRegistrations.TryGetValue(lease.Name, out SymbolRegistrationLease? current) || - !ReferenceEquals(current, lease)) + !ReferenceEquals(current, lease)) { lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.Superseded); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Superseded, - LuaOperationStatus.Success); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Superseded, NoBindingCall); } - return ReleaseCurrentLease(lease); + ReplacementCheck check = CheckRegistration(lease.Name, lease.Address, lease.Identity); + switch (check.Kind) + { + case SymbolRegistrationReleaseKind.Released: + return ReleaseCurrentLease(lease); + case SymbolRegistrationReleaseKind.StaleRuntime: + return MarkStaleRuntime(lease, NoBindingCall); + case SymbolRegistrationReleaseKind.Replaced or SymbolRegistrationReleaseKind.ExternallyRemoved: + RemoveCurrentLease(lease); + lease.MarkTerminalAndObserve(check.Kind); + return new SymbolRegistrationReleaseOutcome(check.Kind, check.Status); + default: + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, + check.Status); + } } } @@ -208,9 +238,9 @@ private static SymbolRegistrationReleaseOutcome ReleaseCurrentLease(SymbolRegist status = UnregisterCore(lease.Name); } catch (InvalidOperationException) when (!LuaRuntime.IsAttached || - lease.Identity != LuaRuntime.CurrentStateIdentity) + lease.Identity != LuaRuntime.CurrentStateIdentity) { - return MarkStaleRuntime(lease); + return MarkStaleRuntime(lease, NoBindingCall); } catch (InvalidOperationException) { @@ -248,13 +278,52 @@ private static SymbolRegistrationReleaseOutcome ReleaseCurrentLease(SymbolRegist } private static SymbolRegistrationReleaseOutcome MarkStaleRuntime(SymbolRegistrationLease lease, - LuaOperationStatus status = default) + LuaOperationStatus? status) { RemoveCurrentLease(lease); lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, status); } + // Verifies, before an unregister by name, that the name still resolves to the registered address. The result kind is + // Released when the unregister may proceed, Replaced/ExternallyRemoved when it must be skipped, StaleRuntime when the + // runtime detached or changed, and CleanupUnavailable when ownership cannot be verified. Best effort: CE has no + // registration token, and the lookup is not atomic with the following unregister. + private static ReplacementCheck CheckRegistration(SymbolName name, Address address, LuaStateIdentity identity) + { + InspectionStatus lookup; + Address current; + try + { + lookup = EngineInspection.ResolveAddress(new SymbolExpression(name.Value), default, out current); + } + catch (InvalidOperationException) when (!LuaRuntime.IsAttached || identity != LuaRuntime.CurrentStateIdentity) + { + return new ReplacementCheck(SymbolRegistrationReleaseKind.StaleRuntime, NoBindingCall); + } + catch (InvalidOperationException) + { + return new ReplacementCheck(SymbolRegistrationReleaseKind.CleanupUnavailable, + LuaOperationStatus.GlobalUnavailable); + } + + return lookup switch + { + InspectionStatus.Success when current == address => new ReplacementCheck( + SymbolRegistrationReleaseKind.Released, LuaOperationStatus.Success), + InspectionStatus.Success => new ReplacementCheck(SymbolRegistrationReleaseKind.Replaced, + LuaOperationStatus.Success), + InspectionStatus.NotFound => new ReplacementCheck(SymbolRegistrationReleaseKind.ExternallyRemoved, + LuaOperationStatus.NilResult), + InspectionStatus.GlobalUnavailable => new ReplacementCheck(SymbolRegistrationReleaseKind.CleanupUnavailable, + LuaOperationStatus.GlobalUnavailable), + InspectionStatus.LuaFailure => new ReplacementCheck(SymbolRegistrationReleaseKind.CleanupUnavailable, + LuaOperationStatus.LuaFailure(LuaStatus.RuntimeError)), + _ => new ReplacementCheck(SymbolRegistrationReleaseKind.CleanupUnavailable, + LuaOperationStatus.InvalidResult) + }; + } + private static void ValidateName(SymbolName name) { if (string.IsNullOrWhiteSpace(name.Value)) @@ -263,10 +332,10 @@ private static void ValidateName(SymbolName name) } } - private static SymbolRegistrationLease CreateLease(SymbolName name, SymbolRegistrationOptions options, - LuaStateIdentity identity) + private static SymbolRegistrationLease CreateLease(SymbolName name, Address address, + SymbolRegistrationOptions options, LuaStateIdentity identity) { - return new SymbolRegistrationLease(name, options, identity); + return new SymbolRegistrationLease(name, address, options, identity); } private static void PublishLease(SymbolName name, SymbolRegistrationLease lease) @@ -276,14 +345,20 @@ private static void PublishLease(SymbolName name, SymbolRegistrationLease lease) // Publication runs while the coordinator gate is held, so an SDK-coordinated replacement cannot interleave between // the successful register and this one compensation attempt. The identity check prevents an old registration from - // being unregistered through a replacement Lua runtime. - private static SymbolRegistrationReleaseOutcome CompensateFailedPublication(SymbolName name, + // being unregistered through a replacement Lua runtime, and the replacement check prevents removing a name that no + // longer maps to the address just registered. + private static SymbolRegistrationReleaseOutcome CompensateFailedPublication(SymbolName name, Address address, LuaStateIdentity identity) { if (!LuaRuntime.IsAttached || identity != LuaRuntime.CurrentStateIdentity) { - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, - LuaOperationStatus.Success); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, NoBindingCall); + } + + ReplacementCheck check = CheckRegistration(name, address, identity); + if (check.Kind != SymbolRegistrationReleaseKind.Released) + { + return new SymbolRegistrationReleaseOutcome(check.Kind, check.Status); } LuaOperationStatus status; @@ -292,10 +367,9 @@ private static SymbolRegistrationReleaseOutcome CompensateFailedPublication(Symb status = UnregisterCore(name); } catch (InvalidOperationException) when (!LuaRuntime.IsAttached || - identity != LuaRuntime.CurrentStateIdentity) + identity != LuaRuntime.CurrentStateIdentity) { - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, - LuaOperationStatus.Success); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, NoBindingCall); } catch (InvalidOperationException) { @@ -333,7 +407,7 @@ private static void SupersedeCurrentLease(SymbolName name) private static void RemoveCurrentLease(SymbolRegistrationLease lease) { if (SOwnedRegistrations.TryGetValue(lease.Name, out SymbolRegistrationLease? current) && - ReferenceEquals(current, lease)) + ReferenceEquals(current, lease)) { SOwnedRegistrations.Remove(lease.Name); } @@ -343,4 +417,8 @@ private static bool OperationCouldHaveStarted(LuaOperationStatus status) { return status.Kind is not (LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.StackUnavailable); } + + /// The result of the pre-unregister name lookup. + [StructLayout(LayoutKind.Auto)] + private readonly record struct ReplacementCheck(SymbolRegistrationReleaseKind Kind, LuaOperationStatus? Status); } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolTableReader.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolTableReader.cs new file mode 100644 index 00000000..e5f03cd5 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolTableReader.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// +/// Reads Cheat Engine's SymbolList "Symbol table" (modulename, searchkey, address, +/// symbolsize) into a without metamethods and without converting values in place. +/// +internal static class SymbolTableReader +{ + /// + /// Reads the table at ; when it is not a table or a field has the + /// wrong type. The stack is restored. + /// + internal static bool TryRead(LuaState state, int index, out SymbolInfo symbol) + { + symbol = default; + int table = state.AbsoluteIndex(index); + if (!state.IsTable(table)) + { + return false; + } + + using LuaFrame frame = new(state); + if (!TryReadString(state, table, "modulename"u8, out string? moduleName) || + !TryReadString(state, table, "searchkey"u8, out string? searchKey)) + { + return false; + } + + _ = PushField(state, table, "address"u8); + if (!Address.TryRead(state, -1, out Address address)) + { + return false; + } + + _ = PushField(state, table, "symbolsize"u8); + if (!state.IsInteger(-1) || !state.TryReadInteger(-1, out long size) || size < 0) + { + return false; + } + + symbol = new SymbolInfo(moduleName, searchKey, address, new MemorySize((ulong) size)); + return true; + } + + private static bool TryReadString(LuaState state, int table, ReadOnlySpan field, + [NotNullWhen(true)] out string? value) + { + _ = PushField(state, table, field); + return state.TryReadString(-1, out value); + } + + private static LuaType PushField(LuaState state, int table, ReadOnlySpan field) + { + state.PushString(field); + return state.RawGet(table); + } +} diff --git a/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs b/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs index aec09e51..7a499299 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs @@ -18,6 +18,19 @@ namespace CheatEngine.SDK.Engine.Memory; /// specify main-thread affinity for these globals, so no unsupported affinity claim is made here. This class has /// no object ownership: all returned values and caller-provided buffers remain managed values. /// +/// +/// Host, not target. These methods address Cheat Engine's own process. The attached target process is +/// with CheatEngine.SDK.Engine.Values.Address; neither address type converts to +/// the other. +/// +/// +/// Text and bytes. maximumLength is passed unchanged to CE's local string primitive. Its unit for a +/// wide read (characters or bytes), and the terminator CE reads or writes for a wide string, are not qualified on +/// the pinned CE profile (qualification Q20, level C3). The byte forms keep the exact bytes, embedded NULs and +/// invalid UTF-8 included; decodes invalid UTF-8 to U+FFFD. The +/// overload reports a +/// confirmed contiguous prefix with . +/// /// [RequiresPluginEnabled] public static class HostMemory @@ -70,8 +83,8 @@ public static bool TryReadInt8(HostAddress address, out sbyte value, out MemoryA public static bool TryReadUInt16(HostAddress address, out ushort value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallIntegerLocal"u8, address.ToInt64(), false, - true, - out long raw, out failure) || raw < 0 || raw > ushort.MaxValue) + true, + out long raw, out failure) || raw < 0 || raw > ushort.MaxValue) { value = default; if (failure == MemoryAccessFailure.None) @@ -90,8 +103,8 @@ public static bool TryReadUInt16(HostAddress address, out ushort value, out Memo public static bool TryReadInt16(HostAddress address, out short value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallIntegerLocal"u8, address.ToInt64(), true, - true, - out long raw, out failure) || raw < short.MinValue || raw > short.MaxValue) + true, + out long raw, out failure) || raw < short.MinValue || raw > short.MaxValue) { value = default; if (failure == MemoryAccessFailure.None) @@ -110,8 +123,8 @@ public static bool TryReadInt16(HostAddress address, out short value, out Memory public static bool TryReadUInt32(HostAddress address, out uint value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadInteger, "readIntegerLocal"u8, address.ToInt64(), false, - true, out long raw, - out failure) || raw < 0 || (ulong) raw > uint.MaxValue) + true, out long raw, + out failure) || raw < 0 || (ulong) raw > uint.MaxValue) { value = default; if (failure == MemoryAccessFailure.None) @@ -130,8 +143,8 @@ public static bool TryReadUInt32(HostAddress address, out uint value, out Memory public static bool TryReadInt32(HostAddress address, out int value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadInteger, "readIntegerLocal"u8, address.ToInt64(), true, - true, out long raw, - out failure) || raw < int.MinValue || raw > int.MaxValue) + true, out long raw, + out failure) || raw < int.MinValue || raw > int.MaxValue) { value = default; if (failure == MemoryAccessFailure.None) @@ -150,8 +163,8 @@ public static bool TryReadInt32(HostAddress address, out int value, out MemoryAc public static bool TryReadUInt64(HostAddress address, out ulong value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadQword, "readQwordLocal"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; @@ -173,8 +186,8 @@ public static bool TryReadInt64(HostAddress address, out long value, out MemoryA public static bool TryReadPointer(HostAddress address, out HostAddress value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadPointer, "readPointerLocal"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; @@ -331,7 +344,10 @@ public static bool TryReadUtf8(HostAddress address, int maximumLength, SpanReads local UTF-8 text and reports the exact capacity required by the returned value. /// The CE-host address to read. - /// The maximum character count passed to CE's documented string primitive. + /// + /// The maximum length passed unchanged to CE's documented string primitive; its unit for a wide read is not + /// qualified (see the class remarks). Must not be negative. + /// /// The caller-owned UTF-8 storage; it is unchanged when it is too small. /// Whether CE should read a wide-character string. /// The copied byte count, which is zero on failure. diff --git a/libs/CheatEngine.SDK.Engine/Memory/MemoryAccessFailure.cs b/libs/CheatEngine.SDK.Engine/Memory/MemoryAccessFailure.cs index c49f7160..793e1550 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/MemoryAccessFailure.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/MemoryAccessFailure.cs @@ -25,12 +25,16 @@ public enum MemoryAccessFailure /// The protected Lua call raised, including an allocation failure reported by the Lua protection bridge. LuaError, - /// Cheat Engine returned or an incomplete byte table for a read operation. + /// + /// Cheat Engine returned for a read operation, or an incomplete byte table to an overload that + /// reports no copied count (nothing is copied then). + /// ReadFailed, /// /// Cheat Engine returned a contiguous byte-table prefix that was shorter than the requested destination. This is - /// reported only by the overload that returns a copied byte count. + /// reported only by the overload that returns a copied byte count, which then holds the length of that prefix and + /// is never zero. /// PartialRead, diff --git a/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs b/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs index 6ac46977..342fe523 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs @@ -288,7 +288,7 @@ private static bool TryCopyCompleteBytes(LuaState state, int table, Span d { LuaType type = state.RawGetIndex(table, index + 1L); bool valid = type == LuaType.Number && state.TryReadInteger(-1, out long value) - && (ulong) value <= byte.MaxValue; + && (ulong) value <= byte.MaxValue; state.Pop(1); if (!valid) { @@ -319,7 +319,7 @@ private static bool TryCopyPartialBytes(LuaState state, int table, Span de LuaType type = state.RawGetIndex(table, index + 1L); long value = default; bool valid = type == LuaType.Number && state.TryReadInteger(-1, out value) - && (ulong) value <= byte.MaxValue; + && (ulong) value <= byte.MaxValue; state.Pop(1); if (!valid) { diff --git a/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs b/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs index 41ab0122..35cccc18 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs @@ -28,6 +28,20 @@ namespace CheatEngine.SDK.Engine.Memory; /// is a /// lifecycle violation, not a CE read failure, and is therefore never folded into that result. /// +/// +/// Target, not host. These methods address the process Cheat Engine is attached to, through CE's +/// read*/write* globals. Cheat Engine's own process is (the *Local +/// globals) with the distinct ; neither address type converts to the other. +/// +/// +/// Text and bytes. maximumLength is passed unchanged to CE's string primitive. Its unit for a wide +/// read (characters or bytes), and the terminator CE reads or writes for a wide string, are not qualified on the +/// pinned CE profile (qualification Q20, level C3). The byte forms keep the exact bytes, embedded NULs and invalid +/// UTF-8 included; decodes invalid UTF-8 to U+FFFD. The +/// overload reports a +/// confirmed contiguous prefix with ; the overload without a count +/// copies nothing unless every requested byte arrived. +/// /// [RequiresPluginEnabled] public static class TargetMemory @@ -55,8 +69,8 @@ public static class TargetMemory public static bool TryReadUInt8(Address address, out byte value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadByte, "readByte"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; @@ -82,9 +96,9 @@ public static bool TryReadInt8(Address address, out sbyte value, out MemoryAcces public static bool TryReadUInt16(Address address, out ushort value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallInteger"u8, address.ToInt64(), false, - true, - out long raw, - out failure)) + true, + out long raw, + out failure)) { value = default; return false; @@ -97,9 +111,9 @@ public static bool TryReadUInt16(Address address, out ushort value, out MemoryAc public static bool TryReadInt16(Address address, out short value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallInteger"u8, address.ToInt64(), true, - true, - out long raw, - out failure)) + true, + out long raw, + out failure)) { value = default; return false; @@ -112,8 +126,8 @@ public static bool TryReadInt16(Address address, out short value, out MemoryAcce public static bool TryReadUInt32(Address address, out uint value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadInteger, "readInteger"u8, address.ToInt64(), false, - true, out long raw, - out failure)) + true, out long raw, + out failure)) { value = default; return false; @@ -126,8 +140,8 @@ public static bool TryReadUInt32(Address address, out uint value, out MemoryAcce public static bool TryReadInt32(Address address, out int value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadInteger, "readInteger"u8, address.ToInt64(), true, - true, out long raw, - out failure)) + true, out long raw, + out failure)) { value = default; return false; @@ -140,8 +154,8 @@ public static bool TryReadInt32(Address address, out int value, out MemoryAccess public static bool TryReadUInt64(Address address, out ulong value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadQword, "readQword"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; @@ -162,8 +176,8 @@ public static bool TryReadInt64(Address address, out long value, out MemoryAcces public static bool TryReadPointer(Address address, out Address value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadPointer, "readPointer"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; @@ -391,7 +405,10 @@ public static bool TryReadUtf8(Address address, int maximumLength, Span de /// Reads UTF-8 text into caller-owned storage and reports the exact capacity required by the returned text. /// The target address to read. - /// The maximum character count passed to CE's documented string primitive. + /// + /// The maximum length passed unchanged to CE's documented string primitive; its unit for a wide read is not + /// qualified (see the class remarks). Must not be negative. + /// /// The caller-owned UTF-8 storage; it is unchanged when it is too small. /// Whether CE should read a wide-character string. /// The copied byte count, which is zero on failure. diff --git a/libs/CheatEngine.SDK.Engine/Objects/EngineEffectState.cs b/libs/CheatEngine.SDK.Engine/Objects/EngineEffectState.cs new file mode 100644 index 00000000..4913cf3e --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Objects/EngineEffectState.cs @@ -0,0 +1,37 @@ +namespace CheatEngine.SDK.Engine.Objects; + +/// +/// How far an effectful Cheat Engine operation went, as far as the SDK can establish it without parsing host text. +/// +/// +/// +/// The audit's three effect states (not started, applied, unknown) are extended by : a +/// documented negative result is a fact, and merging it into would lose information +/// (ADR-08). Cheat Engine gives no factual signal for a partial effect, so a partial effect is reported as +/// . +/// +/// +/// is the zero value: an outcome that was never assigned never reads as an established +/// effect. +/// +/// +public enum EngineEffectState +{ + /// + /// An effect may or may not have happened: a Cheat Engine error, a protected failure after the call began, a + /// rejection that does not prove the absence of changes, or an observation change after the effect. + /// + Unknown = 0, + + /// No effectful Cheat Engine call began. + NotStarted = 1, + + /// + /// Cheat Engine completed the call and returned its documented negative result (for example nil from + /// allocateMemory). + /// + NotApplied = 2, + + /// Cheat Engine confirmed the effect. + Applied = 3 +} diff --git a/libs/CheatEngine.SDK.Engine/Objects/EngineResourceOrigin.cs b/libs/CheatEngine.SDK.Engine/Objects/EngineResourceOrigin.cs new file mode 100644 index 00000000..d5956eb0 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Objects/EngineResourceOrigin.cs @@ -0,0 +1,80 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.SDK.Engine.Objects; + +/// +/// The context in which a durable SDK resource was created: the Lua runtime identity (attach epoch and state +/// generation) and, for a target-bound resource, the qualified target process incarnation. +/// +/// +/// +/// Every durable resource the SDK owns or leases (, an allocated region, an Auto +/// Assembler patch, a symbol registration lease, a symbol-list registration lease) captures its origin inside +/// the admitted Lua operation that performed the effect. Cleanup compares the origin with the current context +/// before any Cheat Engine call: a resource created in another Lua universe (after a re-enable or a controlled +/// state replacement) is refused and reported, and a target-bound resource is never released against another +/// target incarnation. +/// +/// +/// Both identity components are compared: an equal attach epoch with a different state generation is another +/// Lua universe (audit A08-02). The value is a copied observation, not a capability. Consumers cannot +/// manufacture one: the only constructor is internal to the SDK. means that no origin +/// was captured. +/// +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct EngineResourceOrigin +{ + internal EngineResourceOrigin(LuaStateIdentity runtime, TargetProcessIncarnation? target) + { + Runtime = runtime; + Target = target; + } + + /// Gets the Lua runtime identity (attach epoch and state generation) that created the resource. + /// The attach epoch is zero only for : no runtime identity was captured. + public LuaStateIdentity Runtime + { + get; + } + + /// Gets the qualified target process incarnation the resource is bound to, or . + /// + /// A resource that does not act on the ambient target (a plugin-owned Cheat Engine object, a symbol registration) + /// has no target component. + /// + public TargetProcessIncarnation? Target + { + get; + } + + /// Gets whether the resource is bound to a target process incarnation. + public bool IsTargetBound => Target.HasValue; + + /// + /// Gets whether a runtime is attached and its current identity equals . + /// + /// + /// A lock-free diagnostic observation made at the time of the read: an attach, detach or state replacement on + /// another thread can change the answer immediately afterwards. It is not a lock and never authorizes an + /// operation; the SDK's cleanup paths repeat the comparison inside their admitted Lua operation. A + /// origin is never current. + /// + public bool IsCurrentRuntime => Runtime.AttachEpoch != 0 && LuaRuntime.IsAttached && + Runtime == LuaRuntime.CurrentStateIdentity; + + /// Captures the current runtime identity, without a target component. + internal static EngineResourceOrigin CaptureRuntime() + { + return new EngineResourceOrigin(LuaRuntime.CurrentStateIdentity, null); + } + + /// Whether is still the current Lua universe (attachment state is not checked). + internal static bool IsCurrent(LuaStateIdentity runtime) + { + return runtime == LuaRuntime.CurrentStateIdentity; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Objects/Owned.cs b/libs/CheatEngine.SDK.Engine/Objects/Owned.cs index 283b702c..e0719fec 100644 --- a/libs/CheatEngine.SDK.Engine/Objects/Owned.cs +++ b/libs/CheatEngine.SDK.Engine/Objects/Owned.cs @@ -3,6 +3,8 @@ using System.Runtime.CompilerServices; using CheatEngine.SDK.Annotations.Lifetime; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -31,25 +33,36 @@ namespace CheatEngine.SDK.Engine.Objects; /// Construction and transfer. The constructor is internal: only a sourced SDK factory which received a /// documented caller-owned CE result can create an owner. A consumer cannot convert an arbitrary borrowed handle /// into a destructible object. is the only public move operation: it returns a new owner -/// and leaves this one empty. is deliberately different: it stops managed cleanup and -/// returns a borrowed handle; it does not grant another caller permission to construct an owner. +/// and leaves this one empty; the new owner keeps this owner's . is +/// deliberately different: it stops managed cleanup and returns a borrowed handle; it does not grant +/// another caller permission to construct an owner. +/// +/// +/// Origin. The owner captures the Lua runtime identity (attach epoch and state generation) current when +/// its factory created the object, inside that factory's admitted Lua operation. An object created in one Lua +/// universe is never destroyed in another: after a re-enable or a controlled Lua state replacement the owner is +/// consumed without any Cheat Engine call and reports +/// . The native object may then still exist (a residue +/// to recover manually); destroying a handle through another universe could free an unrelated object. /// /// /// Lifetime. calls the object's destroy() through a protected call while /// the plugin binding and its host-object pusher remain available. There is no finalizer (an object must never /// be destroyed from the finalizer thread, and a native object must never be freed behind Cheat Engine's back at /// an arbitrary time), so an undisposed wrapper leaks the object until Cheat Engine exits. Dispose twice is -/// harmless. +/// harmless. is the never-throwing form that always consumes the owner and +/// reports what happened. /// /// -/// Failure modes. When the destroy call raises (the object is already gone, or its class refuses), the -/// wrapper is still marked empty: a destroy is never retried, because the object may be half freed. -/// returns that status with the message on the stack; -/// intentionally -/// discards it after an invocation began. By contrast, a detached runtime, a missing pusher, or a state that is -/// unavailable on the calling thread prevents an invocation from beginning. Those cases throw and retain this -/// owner, so the plugin can retry before disable finishes or explicitly it. They never -/// masquerade as successful destruction. +/// Failure modes. When the destroy call raises (the object is already gone, for example destroyed by its +/// parent, or its class refuses), the wrapper is still marked empty: a destroy is never retried, because the +/// object may be half freed. returns that status with the message on the stack; +/// intentionally discards it after an invocation began, and +/// records it as unconfirmed. By contrast, a detached runtime, a missing pusher, or a state that is unavailable on +/// the calling thread prevents an invocation from beginning. Those cases make and +/// throw and retain this owner, so the plugin can retry before disable finishes or +/// explicitly it (a later re-enable makes the retained owner stale, so the next attempt +/// consumes it without a Cheat Engine call). They never masquerade as successful destruction. /// /// /// Threads. One owner, one thread at a time; the type is not synchronized. Reading @@ -63,15 +76,29 @@ namespace CheatEngine.SDK.Engine.Objects; public sealed class Owned : IDisposable where T : struct, ICEObject { + private TargetReleaseOutcome _lastReleaseOutcome; private T _value; - /// Takes ownership of from an SDK factory with an established ownership contract. + /// + /// Takes ownership of from an SDK factory with an established ownership contract, + /// capturing the current Lua runtime identity as its origin. + /// /// /// A handle to an object nobody else owns, obtained from a documented SDK creation binding or from /// . /// /// is a null handle. + /// Call it inside the admitted Lua operation that created the object, so the captured identity is stable. internal Owned(T value) + : this(value, LuaRuntime.CurrentStateIdentity) + { + } + + /// Takes ownership of with an explicit origin, for transfers. + /// The handle to own. + /// The Lua runtime identity that created the object. + /// is a null handle. + internal Owned(T value, LuaStateIdentity origin) { if (value.Handle.IsNull) { @@ -79,11 +106,13 @@ internal Owned(T value) } _value = value; + Origin = new EngineResourceOrigin(origin, null); } /// /// Gets the typed handle, for calling the object's members. A borrowed view: do not keep it beyond the wrapper's - /// life. + /// life, and check of before using a + /// handle kept across a re-enable. /// /// The wrapper was disposed, transferred or abandoned. public T Value @@ -105,15 +134,36 @@ public T Value /// /// Gets a value indicating whether the wrapper no longer owns anything, after , - /// , or . + /// , , or . /// public bool IsDisposed => _value.Handle.IsNull; + /// + /// Gets the Lua runtime identity that created the object. The owner has no target component: a plugin-owned Cheat + /// Engine object is not bound to a target process incarnation. + /// + /// Still readable after the owner was consumed, for diagnostics. + public EngineResourceOrigin Origin + { + get; + } + + /// + /// Gets the factual outcome of the release attempt that consumed this owner: + /// after a confirmed destroy(), + /// after a destroy() that raised, + /// when the origin runtime was no longer current, or + /// when could not begin a call. + /// while the owner is live, and after a transfer or an abandonment. + /// + public TargetReleaseOutcome LastReleaseOutcome => _lastReleaseOutcome; + /// /// Destroys the object through on the ambient state, discarding a protected Lua - /// failure after an invocation began. It is idempotent. If the runtime is detached, the current thread cannot - /// obtain a state, or the binding has no host-object pusher, it throws and retains ownership so the caller can - /// retry or explicitly abandon the object; it never reports a no-op as a completed destruction. + /// failure after an invocation began. It is idempotent. If the origin runtime identity is no longer current, the + /// owner is consumed without any Cheat Engine call and the refusal is recorded. If the runtime is detached, the + /// current thread cannot obtain a state, or the binding has no host-object pusher, it throws and retains ownership + /// so the caller can retry or explicitly abandon the object; it never reports a no-op as a completed destruction. /// public void Dispose() { @@ -123,11 +173,82 @@ public void Dispose() } using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + if (!IsOriginCurrent()) + { + ConsumeWithoutDestroy(TargetReleaseOutcome.RefusedRuntimeChanged()); + return; + } + LuaState state = operation.State; using LuaFrame frame = new(state); _ = TryDestroyCore(state); } + /// + /// Releases the object and reports the outcome. Never throws and always consumes this owner, on every path: the + /// one permitted destroy() is never retried. + /// + /// + /// when the owner was already consumed; + /// when the origin runtime is no longer current (no + /// Cheat Engine call); with + /// when the runtime is detached, admission is closed, the thread + /// has no Lua state or the binding has no host-object pusher (no call; the object leaks until Cheat Engine exits); + /// after a confirmed destroy(); or + /// with + /// when destroy() raised. + /// + /// + /// Use it where cleanup must not throw and must not be retried: a detached runtime consumes the owner here, + /// whereas retains it. The Lua stack is restored on every path. + /// + public TargetReleaseOutcome ReleaseWithOutcome() + { + if (IsDisposed) + { + return _lastReleaseOutcome; + } + + if (!IsOriginCurrent()) + { + return ConsumeWithoutDestroy(TargetReleaseOutcome.RefusedRuntimeChanged()); + } + + if (!LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)) + { + return ConsumeWithoutDestroy(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); + } + + using (operation) + { + // The identity is stable while an operation is admitted: a transition closes admission and drains first. + if (!IsOriginCurrent()) + { + return ConsumeWithoutDestroy(TargetReleaseOutcome.RefusedRuntimeChanged()); + } + + LuaState state = operation.State; + if (!CanStartDestruction(state)) + { + return ConsumeWithoutDestroy(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); + } + + using LuaFrame frame = new(state); + LuaStatus status; + try + { + status = _value.Handle.TryDestroy(state); + } + catch (Exception) + { + // Only the host-object push can throw, before destroy() is called: nothing was invoked. + return ConsumeWithoutDestroy(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); + } + + return Consume(status); + } + } + /// /// The handle as a borrowed value, for passing the object to an API that does not take ownership. Same as /// , named for the intent. @@ -140,7 +261,8 @@ public T ToBorrowed() } /// - /// Moves this ownership capability into a new wrapper without calling CE. The source wrapper becomes empty. + /// Moves this ownership capability into a new wrapper without calling CE. The source wrapper becomes empty; the + /// new wrapper keeps the source's . /// /// The new sole owner. /// The wrapper was disposed, transferred or abandoned. @@ -152,11 +274,12 @@ public Owned Transfer() } // A multi-owner handoff prepares every destination before any source is made empty. These members are internal so - // that a consumer cannot ever observe the brief, private preparation state as a second ownership capability. + // that a consumer cannot ever observe the brief, private preparation state as a second ownership capability. The + // destination keeps the source's origin: a transfer never re-stamps an owner with the current runtime identity. internal Owned PrepareTransfer() { T value = Value; - return new Owned(value); + return new Owned(value, Origin.Runtime); } internal void CompleteTransfer(Owned destination) @@ -196,9 +319,12 @@ public T Abandon() /// The calling thread's state. /// The status of the destroy call. /// - /// The plugin is not enabled, the caller has no Lua state, or the attached host binding has no object pusher - /// (an embedding without LuaPushClassInstance). Nothing was called, the stack is untouched, and this - /// wrapper retains ownership for an explicit retry or . + /// The plugin is not enabled, the caller has no Lua state, the supplied state is not the calling thread's, or the + /// attached host binding has no object pusher (an embedding without LuaPushClassInstance): nothing was + /// called, the stack is untouched, and this wrapper retains ownership for an explicit retry or + /// . Also thrown, after the owner was consumed without any call, when the owner + /// belongs to a previous Lua runtime identity ( then reports + /// ). /// [RequiresPluginEnabled] public LuaStatus TryDestroy(LuaState state) @@ -215,15 +341,21 @@ public LuaStatus TryDestroy(LuaState state) "The supplied Lua state is not the state currently assigned to this thread by the attached host."); } + if (!IsOriginCurrent()) + { + ConsumeWithoutDestroy(TargetReleaseOutcome.RefusedRuntimeChanged()); + throw new InvalidOperationException( + "The owned Cheat Engine object belongs to a previous Lua runtime identity; ownership was consumed without destroy."); + } + return TryDestroyCore(operation.State); } private LuaStatus TryDestroyCore(LuaState state) { EnsureDestructionCanStart(state); - CEObject handle = _value.Handle; - LuaStatus status = handle.TryDestroy(state); - _value = default; + LuaStatus status = _value.Handle.TryDestroy(state); + _ = Consume(status); return status; } @@ -233,6 +365,34 @@ public override string ToString() return IsDisposed ? "Owned(disposed)" : "Owned(" + _value.Handle + ")"; } + private bool IsOriginCurrent() + { + return EngineResourceOrigin.IsCurrent(Origin.Runtime); + } + + // A destroy() call was made: the owner is consumed whatever its status and never retried. + private TargetReleaseOutcome Consume(LuaStatus destroyStatus) + { + _value = default; + _lastReleaseOutcome = destroyStatus.IsOk + ? TargetReleaseOutcome.Released() + : TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ProtectedLuaFailure); + return _lastReleaseOutcome; + } + + private TargetReleaseOutcome ConsumeWithoutDestroy(TargetReleaseOutcome outcome) + { + _value = default; + _lastReleaseOutcome = outcome; + return outcome; + } + + private static bool CanStartDestruction(LuaState state) + { + LuaHostBinding binding = LuaRuntime.CurrentBinding; + return binding.IsValid && binding.HostObjectPusher != 0 && !state.IsNull; + } + private static void EnsureDestructionCanStart(LuaState state) { LuaHostBinding binding = LuaRuntime.CurrentBinding; diff --git a/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs b/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs index ae9d4cdd..13a2823a 100644 --- a/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs +++ b/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs @@ -31,14 +31,43 @@ public static bool TryCreate([NotNullWhen(true)] out Owned? list) LuaState state = operation.State; using LuaFrame frame = new(state); if (!LuaGlobalFunctions.TryPush(state, SCreateStringList, "createStringlist"u8) || - !state.TryCall(0, 1).IsOk || - !CEObject.TryRead(state, -1, out CEObject handle)) + !state.TryCall(0, 1).IsOk || + !CEObject.TryRead(state, -1, out CEObject handle)) { list = null; return false; } - list = new Owned(StringList.FromHandle(handle)); + list = Publish(state, handle); return true; } + + // Between the read of a caller-owned handle and the publication of its owner, the raw handle is the only authority + // able to destroy the object (audit A08-09). If publication throws, destroy it once, then report the failure. + private static Owned Publish(LuaState state, CEObject handle) + { + try + { + return new Owned(StringList.FromHandle(handle)); + } + catch (Exception) + { + RollBack(state, handle); + throw; + } + } + + private static void RollBack(LuaState state, CEObject handle) + { + using LuaFrame rollback = new(state); + try + { + _ = handle.TryDestroy(state); + } + catch (Exception) + { + // The publication failure is the primary cause; a host-object push failure here must not replace it, and the + // one destroy attempt is never retried. + } + } } diff --git a/libs/CheatEngine.SDK.Engine/Processes/CurrentProcessObservation.cs b/libs/CheatEngine.SDK.Engine/Processes/CurrentProcessObservation.cs index 9f711cd1..bc4e4124 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/CurrentProcessObservation.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/CurrentProcessObservation.cs @@ -7,11 +7,21 @@ namespace CheatEngine.SDK.Engine.Processes; /// A copied observation of Cheat Engine's currently selected target process. /// -/// Cheat Engine's process globals establish only the selected process identifier and pointer width. They do not -/// establish a target ISA, process name, executable path, handle, or process-lifetime guarantee. A 32- or 64-bit -/// pointer width alone cannot distinguish x86 from ARM, so the target architecture remains unknown. Higher layers -/// may combine this value with independently acquired local metadata, but must not treat that metadata as atomically -/// coupled to the selection. +/// +/// This observation carries the selected process identifier and the process bitness only. Its +/// is Cheat Engine's 64-bit process flag (targetIs64Bit) as a width, which is +/// what CE's readPointer follows (spike C3 D3d); it is not Cheat Engine's configured pointer size +/// (getPointerSize), which setPointerSize can change independently. Read the configured size with +/// and the separate ISA-family, ABI, Android +/// and backend facts with . +/// +/// +/// A 32- or 64-bit width alone cannot distinguish x86 from ARM, so this type states no target architecture. +/// Higher layers may combine it with independently acquired local metadata, but must not treat that metadata +/// as atomically coupled to the selection. +/// /// +/// The positive selected process identifier from getOpenedProcessID. +/// The process bitness from targetIs64Bit; not the configured pointer size. [StructLayout(LayoutKind.Sequential)] public readonly record struct CurrentProcessObservation(TargetProcessId Id, PointerSize PointerSize); diff --git a/libs/CheatEngine.SDK.Engine/Processes/HostProbeFacts.cs b/libs/CheatEngine.SDK.Engine/Processes/HostProbeFacts.cs new file mode 100644 index 00000000..810ac4fe --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Processes/HostProbeFacts.cs @@ -0,0 +1,23 @@ +using System; + +namespace CheatEngine.SDK.Engine.Processes; + +/// The Cheat Engine host facts that RuntimeHostOperations.ObserveHost reads, one flag per Lua global. +[Flags] +internal enum HostProbeFacts : byte +{ + /// No fact. + None = 0, + + /// getCheatEngineFileVersion. + FileVersion = 1, + + /// getSystemArchitecture. + SystemArchitecture = 2, + + /// cheatEngineIs64Bit. + CheatEngineBitness = 4, + + /// getOperatingSystem. + OperatingSystem = 8 +} diff --git a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs index 7c38c75e..7a692272 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs @@ -7,8 +7,15 @@ namespace CheatEngine.SDK.Engine.Processes; /// A compact, allocation-free status for one runtime process operation. /// -/// is meaningful only for . -/// The status never copies a Lua error object or localized message from the transient Lua stack. +/// +/// is meaningful only for . +/// The status never copies a Lua error object or localized message from the transient Lua stack. +/// +/// +/// default(ProcessOperationStatus) has the kind , so +/// is for a status that no operation assigned. Only +/// reports success. +/// /// [StructLayout(LayoutKind.Sequential)] public readonly struct ProcessOperationStatus : IEquatable @@ -32,7 +39,7 @@ public LuaStatus LuaStatus } /// Gets a successful status. - public static ProcessOperationStatus Success => default; + public static ProcessOperationStatus Success => new(ProcessOperationStatusKind.Success, LuaStatus.Ok); /// Gets a status for a target that is not currently selected. public static ProcessOperationStatus TargetNotAttached => @@ -49,6 +56,16 @@ public LuaStatus LuaStatus /// Gets a status for a result outside the documented process-observation shape. public static ProcessOperationStatus InvalidResult => new(ProcessOperationStatusKind.InvalidResult, LuaStatus.Ok); + /// + /// Gets a status for a selected process identifier that differed between the two reads bracketing an + /// observation. + /// + public static ProcessOperationStatus TargetChanged => new(ProcessOperationStatusKind.TargetChanged, LuaStatus.Ok); + + /// Gets a status for Cheat Engine's file-as-process sentinel identifier (4294967295). + public static ProcessOperationStatus FileAsProcessTarget => + new(ProcessOperationStatusKind.FileAsProcessTarget, LuaStatus.Ok); + /// Creates a status for a protected Lua failure. /// The non-success protected Lua status. /// is successful. diff --git a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs index 3349342c..f52f2e99 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs @@ -1,26 +1,45 @@ namespace CheatEngine.SDK.Engine.Processes; /// The factual result of observing or selecting Cheat Engine's target process. +/// +/// The numeric values are explicit and stable. is the zero value, so an unassigned status +/// (for example default(ProcessOperationStatus)) never reads as . +/// public enum ProcessOperationStatusKind { + /// No operation result was recorded; never a success. + Unknown = 0, + /// The requested process operation completed and its declared facts were observed. - Success, + Success = 1, /// Cheat Engine reported that no process is selected. - TargetNotAttached, + TargetNotAttached = 2, /// /// An explicit selection call returned normally, but the immediately observed process was absent or did not match /// the requested identifier. /// - SelectionNotConfirmed, + SelectionNotConfirmed = 3, /// A required Lua global was absent or was not callable. - GlobalUnavailable, + GlobalUnavailable = 4, /// A protected Lua lookup or invocation failed. - ProtectedLuaFailure, + ProtectedLuaFailure = 5, /// The host returned a value outside the documented process-observation shape. - InvalidResult + InvalidResult = 6, + + /// + /// Cheat Engine reported a different selected process identifier in the two reads that bracket the observation, + /// so the facts read between them cannot be attributed to one target. + /// + TargetChanged = 7, + + /// + /// Cheat Engine reported the file-as-process sentinel identifier (4294967295): the selected target is a file + /// opened as a process, not an operating-system process, and no target fact is read for it. + /// + FileAsProcessTarget = 8 } diff --git a/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs b/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs index 8bda10ca..8af5ab5f 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs @@ -12,15 +12,28 @@ namespace CheatEngine.SDK.Engine.Processes; /// Protected CE 7.7 runtime-global observations for a currently enabled plugin. /// -/// Each operation acquires one Lua-runtime admission, resolves its global through an attach-epoch-aware reference, -/// and restores the Lua stack on every result. The CE source catalogue does not establish a main-thread requirement -/// for these globals, so this class neither dispatches nor claims that a caller may use a state from another thread. -/// Results are copied scalar facts with no CE object, handle, or lifetime ownership. +/// +/// Each operation acquires one Lua-runtime admission, resolves its global through an attach-epoch-aware +/// reference, and restores the Lua stack on every result. The CE source catalogue does not establish a +/// main-thread requirement for these globals, so this class neither dispatches nor claims that a caller may use +/// a state from another thread. Results are copied scalar facts with no CE object, handle, or lifetime +/// ownership. +/// +/// +/// Every global read here is a read-only query of the Cheat Engine host: getCEVersion, +/// getCheatEngineFileVersion, getSystemArchitecture, cheatEngineIs64Bit, +/// getOperatingSystem and getABI. No operation loads a driver, changes a setting or selects a +/// target (audit A17-18, Q45). An absent global, a raising global, a raw nil and a malformed value keep +/// distinct kinds, and no outcome is parsed from Lua error text. +/// /// public static class RuntimeHostOperations { private static readonly LuaRef SGetCheatEngineVersion = new(); + private static readonly LuaRef SGetCheatEngineFileVersion = new(); private static readonly LuaRef SGetSystemArchitecture = new(); + private static readonly LuaRef SCheatEngineIs64Bit = new(); + private static readonly LuaRef SGetOperatingSystem = new(); private static readonly LuaRef SGetTargetAbi = new(); /// Reads CE's legacy, coarse getCEVersion number. @@ -29,7 +42,7 @@ public static class RuntimeHostOperations /// The plugin is not enabled or the calling thread has no Lua state. /// /// This result is not a complete four-component CE file version. Consumers must not turn it into one by rounding - /// or formatting it. + /// or formatting it; reads the complete version. /// [RequiresPluginEnabled] public static LuaOperationStatus TryGetCheatEngineVersion(out double version) @@ -38,6 +51,30 @@ public static LuaOperationStatus TryGetCheatEngineVersion(out double version) return TryGetCheatEngineVersion(operation.State, out version); } + /// Reads CE's complete file version through getCheatEngineFileVersion. + /// The four version components on success; otherwise the default value. + /// + /// The protected binding result: when CE returned no value (the + /// public source returns nothing when the executable's version resource is unreadable) and + /// when the packed integer is not a non-negative Lua integer, + /// the second value is neither a table nor nil, or the table's major, minor, + /// release or build field is not the integer the packed value encodes. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// CE 7.7.0.10621 x64 returned two values: the integer 0x700070000297D and a table with major, + /// minor, release, build and string fields (spike C3 D5, Lua-only, ObservedHost design + /// input; LuaHandler.pas:13271-13324 at ec45d5f, ObservedSource). The integer is authoritative and + /// decoded by ; the table is a cross-check, and its extra fields + /// (FileVersion, ProductVersion, ...) are ignored. + /// + [RequiresPluginEnabled] + public static LuaOperationStatus TryGetCheatEngineFileVersion(out CheatEngineVersion version) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + return TryReadFileVersion(operation.State, out version); + } + /// Reads and decodes CE's getSystemArchitecture host-architecture discriminant. /// The decoded CE host architecture, or unknown for an unrecognized documented result. /// The protected binding result. @@ -46,26 +83,36 @@ public static LuaOperationStatus TryGetCheatEngineVersion(out double version) public static LuaOperationStatus TryGetSystemArchitecture(out CheatEngineArchitecture architecture) { using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - LuaState state = operation.State; - int top = state.Top; - try - { - LuaOperationStatus status = - TryCallInteger(state, SGetSystemArchitecture, "getSystemArchitecture"u8, out int code); - if (!status.IsSuccess) - { - architecture = CheatEngineArchitecture.Unknown; - return status; - } + return TryReadSystemArchitecture(operation.State, out architecture); + } - return RuntimeInfo.TryDecodeSystemArchitecture(code, out architecture) - ? LuaOperationStatus.Success - : LuaOperationStatus.InvalidResult; - } - finally - { - state.SetTop(top); - } + /// Reads CE's cheatEngineIs64Bit flag: whether the Cheat Engine host process is 64-bit. + /// The reported flag on success; otherwise . + /// + /// The protected binding result: for nil and + /// for any other non-boolean value. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + /// This host fact is never derived from getSystemArchitecture or from the plugin's own width. + [RequiresPluginEnabled] + public static LuaOperationStatus TryIsCheatEngine64Bit(out bool is64Bit) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + return TryCallBoolean(operation.State, SCheatEngineIs64Bit, "cheatEngineIs64Bit"u8, out is64Bit); + } + + /// Reads and decodes CE's getOperatingSystem discriminant. + /// The decoded operating system, or unknown when the call did not succeed. + /// + /// The protected binding result; a code outside is + /// . + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static LuaOperationStatus TryGetOperatingSystem(out CheatEngineOperatingSystem operatingSystem) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + return TryReadOperatingSystem(operation.State, out operatingSystem); } /// Reads and decodes CE's getABI target ABI-family discriminant. @@ -97,6 +144,73 @@ public static LuaOperationStatus TryGetTargetAbi(out TargetAbi abi) } } + /// + /// Reads the four Cheat Engine host facts (file version, system architecture, 64-bit flag, operating system) in + /// one Lua admission. + /// + /// The copied host facts on success; otherwise the default value. + /// + /// Success when every present global returned a well-formed value. An absent global is not a failure: its field + /// stays or unknown. A raising global is , + /// a nil where a value is required is , and a malformed + /// value is ; none of them is downgraded to "unknown". + /// + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// getCheatEngineFileVersion may legitimately return no value; + /// is then , as for an absent global. Nothing is inferred between the fields. + /// + [RequiresPluginEnabled] + public static LuaOperationStatus ObserveHost(out CheatEngineHostObservation host) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + return ObserveHost(operation.State, out host, out _); + } + + internal static LuaOperationStatus ObserveHost(LuaState state, out CheatEngineHostObservation host, + out HostProbeFacts absent) + { + host = default; + absent = HostProbeFacts.None; + + LuaOperationStatus status = TryReadFileVersion(state, out CheatEngineVersion fileVersion); + CheatEngineVersion? observedFileVersion = fileVersion; + if (status.Kind is LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.NilResult) + { + absent |= status.Kind == LuaOperationStatusKind.GlobalUnavailable + ? HostProbeFacts.FileVersion + : HostProbeFacts.None; + observedFileVersion = null; + } + else if (!status.IsSuccess) + { + return status; + } + + status = TryReadSystemArchitecture(state, out CheatEngineArchitecture systemArchitecture); + if (!Accept(status, HostProbeFacts.SystemArchitecture, ref absent)) + { + return status; + } + + status = TryCallBoolean(state, SCheatEngineIs64Bit, "cheatEngineIs64Bit"u8, out bool is64Bit); + bool? cheatEngineIs64Bit = status.IsSuccess ? is64Bit : null; + if (!Accept(status, HostProbeFacts.CheatEngineBitness, ref absent)) + { + return status; + } + + status = TryReadOperatingSystem(state, out CheatEngineOperatingSystem operatingSystem); + if (!Accept(status, HostProbeFacts.OperatingSystem, ref absent)) + { + return status; + } + + host = new CheatEngineHostObservation(observedFileVersion, systemArchitecture, cheatEngineIs64Bit, + operatingSystem); + return LuaOperationStatus.Success; + } + internal static LuaOperationStatus TryCallInteger(LuaState state, LuaRef cache, ReadOnlySpan globalName, out int value) { @@ -114,8 +228,9 @@ internal static LuaOperationStatus TryCallInteger(LuaState state, LuaRef cache, return LuaOperationStatus.LuaFailure(status); } - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long raw) || - raw is < int.MinValue or > int.MaxValue) + // Cheat Engine pushes these codes as Lua integers; a float, even an integral one, is malformed. The target probe + // applies the same rule to getABI and getPointerSize, so one global is never classified two ways. + if (!state.IsInteger(-1) || !state.TryReadInteger(-1, out long raw) || raw is < int.MinValue or > int.MaxValue) { value = default; return state.IsNil(-1) ? LuaOperationStatus.NilResult : LuaOperationStatus.InvalidResult; @@ -125,6 +240,209 @@ internal static LuaOperationStatus TryCallInteger(LuaState state, LuaRef cache, return LuaOperationStatus.Success; } + private static bool Accept(LuaOperationStatus status, HostProbeFacts fact, ref HostProbeFacts absent) + { + if (status.Kind == LuaOperationStatusKind.GlobalUnavailable) + { + absent |= fact; + return true; + } + + return status.IsSuccess; + } + + private static LuaOperationStatus TryReadSystemArchitecture(LuaState state, + out CheatEngineArchitecture architecture) + { + int top = state.Top; + try + { + LuaOperationStatus status = + TryCallInteger(state, SGetSystemArchitecture, "getSystemArchitecture"u8, out int code); + if (!status.IsSuccess) + { + architecture = CheatEngineArchitecture.Unknown; + return status; + } + + return RuntimeInfo.TryDecodeSystemArchitecture(code, out architecture) + ? LuaOperationStatus.Success + : LuaOperationStatus.InvalidResult; + } + finally + { + state.SetTop(top); + } + } + + private static LuaOperationStatus TryReadOperatingSystem(LuaState state, + out CheatEngineOperatingSystem operatingSystem) + { + int top = state.Top; + try + { + LuaOperationStatus status = + TryCallInteger(state, SGetOperatingSystem, "getOperatingSystem"u8, out int code); + if (!status.IsSuccess) + { + operatingSystem = CheatEngineOperatingSystem.Unknown; + return status; + } + + return RuntimeInfo.TryDecodeOperatingSystem(code, out operatingSystem) + ? LuaOperationStatus.Success + : LuaOperationStatus.InvalidResult; + } + finally + { + state.SetTop(top); + } + } + + private static LuaOperationStatus TryCallBoolean(LuaState state, LuaRef cache, ReadOnlySpan globalName, + out bool value) + { + value = false; + int top = state.Top; + try + { + LuaGlobalPushOutcome resolution = LuaGlobalFunctions.TryPushWithOutcome(state, cache, globalName); + if (!resolution.IsSuccess) + { + return resolution.ToOperationStatus(); + } + + LuaStatus status = state.TryCall(0, 1); + if (!status.IsOk) + { + return LuaOperationStatus.LuaFailure(status); + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return state.IsNil(-1) ? LuaOperationStatus.NilResult : LuaOperationStatus.InvalidResult; + } + + value = state.ToBoolean(-1); + return LuaOperationStatus.Success; + } + finally + { + state.SetTop(top); + } + } + + private static LuaOperationStatus TryReadFileVersion(LuaState state, out CheatEngineVersion version) + { + version = default; + int top = state.Top; + try + { + LuaGlobalPushOutcome resolution = + LuaGlobalFunctions.TryPushWithOutcome(state, SGetCheatEngineFileVersion, "getCheatEngineFileVersion"u8); + if (!resolution.IsSuccess) + { + return resolution.ToOperationStatus(); + } + + // Two results: CE returns (packed integer, table) or nothing at all; missing results are padded with nil. + LuaStatus status = state.TryCall(0, 2); + if (!status.IsOk) + { + return LuaOperationStatus.LuaFailure(status); + } + + int packedIndex = top + 1; + int tableIndex = top + 2; + if (state.IsNil(packedIndex)) + { + return LuaOperationStatus.NilResult; + } + + if (!state.IsInteger(packedIndex) || !state.TryReadInteger(packedIndex, out long packed) || + !RuntimeInfo.TryDecodeFileVersion(packed, out CheatEngineVersion decoded)) + { + return LuaOperationStatus.InvalidResult; + } + + if (!state.IsNil(tableIndex)) + { + if (!state.IsTable(tableIndex)) + { + return LuaOperationStatus.InvalidResult; + } + + status = MatchesVersionTable(state, tableIndex, decoded, out bool matches); + if (!status.IsOk) + { + return LuaOperationStatus.LuaFailure(status); + } + + if (!matches) + { + return LuaOperationStatus.InvalidResult; + } + } + + version = decoded; + return LuaOperationStatus.Success; + } + finally + { + state.SetTop(top); + } + } + + private static LuaStatus MatchesVersionTable(LuaState state, int tableIndex, CheatEngineVersion expected, + out bool matches) + { + matches = false; + LuaStatus status = TryRawGetIntegerField(state, tableIndex, "major"u8, out long? major); + if (!status.IsOk || major != expected.Major) + { + return status; + } + + status = TryRawGetIntegerField(state, tableIndex, "minor"u8, out long? minor); + if (!status.IsOk || minor != expected.Minor) + { + return status; + } + + status = TryRawGetIntegerField(state, tableIndex, "release"u8, out long? release); + if (!status.IsOk || release != expected.Release) + { + return status; + } + + status = TryRawGetIntegerField(state, tableIndex, "build"u8, out long? build); + matches = status.IsOk && build == expected.Build; + return status; + } + + // Reads t[field] without metamethods: CE builds this table itself. A non-integer field yields null. + private static LuaStatus TryRawGetIntegerField(LuaState state, int tableIndex, ReadOnlySpan field, + out long? value) + { + value = null; + int top = state.Top; + LuaStatus status = state.TryPushString(field); + if (!status.IsOk) + { + state.SetTop(top); + return status; + } + + _ = state.RawGet(tableIndex); + if (state.IsInteger(-1) && state.TryReadInteger(-1, out long raw)) + { + value = raw; + } + + state.SetTop(top); + return LuaStatus.Ok; + } + private static LuaOperationStatus TryGetCheatEngineVersion(LuaState state, out double version) { int top = state.Top; @@ -146,7 +464,7 @@ private static LuaOperationStatus TryGetCheatEngineVersion(LuaState state, out d } if (state.TypeOf(-1) != LuaType.Number || !state.TryReadNumber(-1, out version) || - !double.IsFinite(version) || version < 0) + !double.IsFinite(version) || version < 0) { version = default; return state.IsNil(-1) ? LuaOperationStatus.NilResult : LuaOperationStatus.InvalidResult; diff --git a/libs/CheatEngine.SDK.Engine/Processes/RuntimeObservations.cs b/libs/CheatEngine.SDK.Engine/Processes/RuntimeObservations.cs new file mode 100644 index 00000000..c95641f7 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Processes/RuntimeObservations.cs @@ -0,0 +1,167 @@ +using System; + +using CheatEngine.SDK.Annotations.Lifetime; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Processes; + +/// Produces a snapshot from read-only Cheat Engine runtime probes. +/// +/// +/// reads the host facts (getCheatEngineFileVersion, +/// getSystemArchitecture, cheatEngineIs64Bit, getOperatingSystem) and then the target facts +/// (getOpenedProcessID, isConnectedToCEServer, targetIs64Bit, targetIsX86, +/// targetIsArm, targetIsAndroid, getABI, getPointerSize, getOpenedProcessID) +/// in one Lua admission. That is the complete list: the snapshot never calls openProcess, +/// openFileAsProcess, setPointerSize, setAssemblerMode, pause, a dbk_* or a +/// dbvm_* global, so taking it loads no driver, runs nothing remotely and changes no target (audit A17-18, +/// Q45; fixture-tested at C1/C2). +/// +/// +/// holds one entry per capability whose globals were probed: available +/// when every global resolved and returned a well-formed value, unavailable when a global is absent. A capability +/// that was not probed (for example every target capability when no target is selected) is not listed, so its +/// state stays unknown. No entry is filled by inference (ADR-09). Each entry carries the same contract metadata +/// as the runtime-capabilities EngineApi specification: minimum CE 7.7.0.10621 (celua.txt, +/// ExactInstalledFile, and spike C3), the Cheat Engine process scope and x64 for host facts, the target scope and +/// an unknown architecture requirement for target facts, unknown thread affinity, no ownership, and a single +/// value except the file version, which may have none. +/// +/// +public static class RuntimeObservations +{ + private static readonly RuntimeCapabilityContract SHostValue = new(CheatEngineVersion.Ce77010621, + RuntimeArchitectureScope.CheatEngine, RuntimeArchitectureRequirement.X64, RuntimeThreadRequirement.Unknown, + RuntimeOwnership.None, RuntimeReturnSemantics.Value); + + private static readonly RuntimeCapabilityContract SHostOptionalValue = + SHostValue with + { + ReturnSemantics = RuntimeReturnSemantics.OptionalValue + }; + + private static readonly RuntimeCapabilityContract STargetValue = new(CheatEngineVersion.Ce77010621, + RuntimeArchitectureScope.Target, RuntimeArchitectureRequirement.Unknown, RuntimeThreadRequirement.Unknown, + RuntimeOwnership.None, RuntimeReturnSemantics.Value); + + /// Observes the Cheat Engine host and, when one is selected, the target, and returns an SDK-produced snapshot. + /// The snapshot only when the returned status is successful; otherwise . + /// + /// + /// with host facts and, when a target is selected, target + /// facts. No selected target is a legitimate snapshot: is then + /// . An absent global is not a failure either: its fact stays unknown and its capability + /// is unavailable (an absent getOpenedProcessID or targetIs64Bit leaves the target unobserved). + /// + /// + /// for a file opened as a process, which has no + /// capability profile in the SDK (audit A12-07); , + /// or + /// when a probe cannot be attributed, raised or returned + /// a malformed value. is for each of them. + /// + /// + /// The plugin is not enabled or the calling thread has no Lua state. + [RequiresPluginEnabled] + public static ProcessOperationStatus TryObserveRuntimeInfo(out RuntimeInfo? info) + { + info = null; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + int top = state.Top; + try + { + LuaOperationStatus hostStatus = + RuntimeHostOperations.ObserveHost(state, out CheatEngineHostObservation host, out HostProbeFacts absent); + if (!hostStatus.IsSuccess) + { + return FromHostStatus(hostStatus); + } + + ProcessOperationStatus targetStatus = RuntimeProcessOperations.ObserveTargetArchitectureCore(state, + out TargetArchitectureObservation observation, out TargetProbeResult probe); + TargetArchitectureObservation? target; + switch (targetStatus.Kind) + { + case ProcessOperationStatusKind.Success: + target = observation; + break; + case ProcessOperationStatusKind.TargetNotAttached: + case ProcessOperationStatusKind.GlobalUnavailable: + target = null; + break; + default: + return targetStatus; + } + + info = new RuntimeInfo(host, target, CreateCapabilities(absent, probe)); + return ProcessOperationStatus.Success; + } + finally + { + state.SetTop(top); + } + } + + private static ProcessOperationStatus FromHostStatus(LuaOperationStatus status) + { + return status.Kind switch + { + LuaOperationStatusKind.LuaFailure => ProcessOperationStatus.ProtectedLuaFailure(status.LuaStatus), + LuaOperationStatusKind.GlobalUnavailable => ProcessOperationStatus.GlobalUnavailable, + _ => ProcessOperationStatus.InvalidResult + }; + } + + private static RuntimeCapabilities CreateCapabilities(HostProbeFacts absentHostFacts, in TargetProbeResult probe) + { + RuntimeCapabilityAvailability[] entries = new RuntimeCapabilityAvailability[10]; + int count = 0; + entries[count++] = Host(RuntimeCapabilityId.CheatEngineVersion, HostProbeFacts.FileVersion, absentHostFacts, + SHostOptionalValue); + entries[count++] = Host(RuntimeCapabilityId.SystemArchitecture, HostProbeFacts.SystemArchitecture, + absentHostFacts, SHostValue); + entries[count++] = Host(RuntimeCapabilityId.CheatEngineBitness, HostProbeFacts.CheatEngineBitness, + absentHostFacts, SHostValue); + entries[count++] = Host(RuntimeCapabilityId.OperatingSystem, HostProbeFacts.OperatingSystem, absentHostFacts, + SHostValue); + + AddTarget(entries, ref count, RuntimeCapabilityId.CurrentProcess, TargetProbeFacts.SelectedProcess, probe); + AddTarget(entries, ref count, RuntimeCapabilityId.TargetBackend, TargetProbeFacts.CeServerConnection, probe); + AddTarget(entries, ref count, RuntimeCapabilityId.TargetArchitecture, TargetProbeFacts.InstructionSet, probe); + AddTarget(entries, ref count, RuntimeCapabilityId.TargetAndroid, TargetProbeFacts.Android, probe); + AddTarget(entries, ref count, RuntimeCapabilityId.TargetAbi, TargetProbeFacts.Abi, probe); + AddTarget(entries, ref count, RuntimeCapabilityId.ConfiguredPointerSize, TargetProbeFacts.ConfiguredPointerSize, + probe); + return RuntimeCapabilities.Create(entries.AsSpan(0, count)); + } + + private static RuntimeCapabilityAvailability Host(RuntimeCapabilityId capability, HostProbeFacts fact, + HostProbeFacts absent, RuntimeCapabilityContract contract) + { + RuntimeCapabilityAvailabilityState state = (absent & fact) != HostProbeFacts.None + ? RuntimeCapabilityAvailabilityState.Unavailable + : RuntimeCapabilityAvailabilityState.Available; + return new RuntimeCapabilityAvailability(capability, state, contract); + } + + // A capability is listed only when its globals were probed: unavailable when any of them is absent, available when + // all of them resolved to a well-formed value. + private static void AddTarget(RuntimeCapabilityAvailability[] entries, ref int count, RuntimeCapabilityId capability, + TargetProbeFacts facts, in TargetProbeResult probe) + { + if ((probe.Absent & facts) != TargetProbeFacts.None) + { + entries[count++] = new RuntimeCapabilityAvailability(capability, + RuntimeCapabilityAvailabilityState.Unavailable, STargetValue); + } + else if ((probe.Resolved & facts) == facts) + { + entries[count++] = new RuntimeCapabilityAvailability(capability, + RuntimeCapabilityAvailabilityState.Available, STargetValue); + } + } +} diff --git a/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs b/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs index cd1559ea..f0e5cf2d 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs @@ -13,22 +13,36 @@ namespace CheatEngine.SDK.Engine.Processes; /// Protected CE 7.7 operations for observing and explicitly selecting the current target process. /// -/// The operations use getOpenedProcessID, openProcess, and targetIs64Bit. They preserve -/// absence, unavailable-global, protected-Lua, malformed-result, and unconfirmed-selection outcomes without -/// parsing Lua error text. They do not provide name lookup, process enumeration, automatic selection, OS-handle -/// ownership, process-lifetime atomicity, target-ISA detection, or a main-thread dispatch guarantee. CE's source -/// catalogue does not prove a GUI-thread requirement for this subset, so calls run on the acquiring thread's -/// host-provided Lua state under one lifecycle admission. +/// +/// The operations use getOpenedProcessID, openProcess (only in +/// ), targetIs64Bit and, for and +/// , the read-only target probes isConnectedToCEServer, +/// targetIsX86, targetIsArm, targetIsAndroid, getABI and getPointerSize. They +/// preserve absence, unavailable-global, protected-Lua, malformed-result, target-change, file-as-process and +/// unconfirmed-selection outcomes without parsing Lua error text. They never call setPointerSize, +/// setAssemblerMode, openFileAsProcess or a driver global (audit A17-18, Q45). +/// +/// +/// They do not provide name lookup, process enumeration, automatic selection, OS-handle ownership, +/// process-lifetime atomicity, or a main-thread dispatch guarantee. CE's source catalogue does not prove a +/// GUI-thread requirement for this subset, so calls run on the acquiring thread's host-provided Lua state under +/// one lifecycle admission. With no target selected Cheat Engine reports x64-like facts (spike C3 D2), so every +/// observation reads getOpenedProcessID first and reads no target fact when it is 0. +/// /// public static class RuntimeProcessOperations { - private static readonly LuaRef SGetOpenedProcessId = new(); private static readonly LuaRef SOpenProcess = new(); private static readonly LuaRef STargetIs64Bit = new(); - /// Observes the current CE target process and its pointer width. + /// Observes the current CE target process and its bitness. /// The copied target observation only when the returned status is successful. - /// The factual protected process-observation status. + /// + /// The factual protected process-observation status: + /// for identifier 0, for the file-as-process + /// sentinel 4294967295, and for any other identifier + /// outside (0, ]. + /// /// The plugin is not enabled or the calling thread has no Lua state. [RequiresPluginEnabled] public static ProcessOperationStatus ObserveCurrent(out CurrentProcessObservation observation) @@ -46,6 +60,10 @@ public static ProcessOperationStatus ObserveCurrent(out CurrentProcessObservatio /// /// is default or otherwise non-positive. /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// This is the only operation of this class that changes Cheat Engine's state. Selecting a process also resets + /// Cheat Engine's configured pointer size to the target default (spike C3 D3c). + /// [RequiresPluginEnabled] public static ProcessOperationStatus SelectAndObserve(TargetProcessId processId, out CurrentProcessObservation observation) @@ -65,7 +83,7 @@ public static ProcessOperationStatus SelectAndObserve(TargetProcessId processId, status = TryGetOpenedProcessId(state, out TargetProcessId? observedProcessId); if (status.Kind == ProcessOperationStatusKind.TargetNotAttached || - (status.IsSuccess && observedProcessId != processId)) + (status.IsSuccess && observedProcessId != processId)) { observation = default; return ProcessOperationStatus.SelectionNotConfirmed; @@ -77,14 +95,14 @@ public static ProcessOperationStatus SelectAndObserve(TargetProcessId processId, return status; } - status = TryGetTargetPointerSize(state, out PointerSize pointerSize); + status = TryGetTargetBitness(state, out PointerSize bitness); if (!status.IsSuccess) { observation = default; return status; } - observation = new CurrentProcessObservation(observedProcessId!.Value, pointerSize); + observation = new CurrentProcessObservation(observedProcessId!.Value, bitness); return ProcessOperationStatus.Success; } finally @@ -93,6 +111,142 @@ public static ProcessOperationStatus SelectAndObserve(TargetProcessId processId, } } + /// + /// Reads Cheat Engine's configured pointer size (getPointerSize) for the selected target, bracketed by + /// two selected-PID reads in one Lua admission. + /// + /// + /// The raw integer Cheat Engine returned when it is a Lua integer that fits , also for an + /// width; otherwise zero. + /// + /// The configured size when it is exactly 4 or 8 bytes; otherwise unknown. + /// + /// for a raw 4 or 8; + /// for any other integer (kept in + /// ), a float or a value outside ; + /// without reading the value when no target is + /// selected; , + /// , + /// or + /// otherwise. + /// + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// + /// The configured pointer size is not the target bitness, and neither comes from the plugin's + /// IntPtr.Size. On CE 7.7.0.10621 x64, getPointerSize reported 4 on a 64-bit target after + /// setPointerSize(4) while targetIs64Bit stayed true and readPointer still read 8 bytes; + /// setPointerSize accepted 2; re-selecting the target reset the value (spike C3 D3, Lua-only, + /// ObservedHost design input). The value is per-attachment state: any later selection invalidates it. + /// + /// The SDK never calls setPointerSize. + /// + [RequiresPluginEnabled] + public static ProcessOperationStatus TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize) + { + rawBytes = 0; + pointerSize = PointerSize.Unknown; + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + TargetProbeResult probe = TargetArchitectureProbe.Observe(operation.State, + TargetProbeFacts.ConfiguredPointerSize, TargetProbeFacts.ConfiguredPointerSize); + if (probe.Status != TargetProbeStatus.Success) + { + return FromProbe(probe); + } + + rawBytes = probe.ConfiguredPointerSizeBytes.GetValueOrDefault(); + switch (rawBytes) + { + case 4: + pointerSize = PointerSize.Bit32; + return ProcessOperationStatus.Success; + case 8: + pointerSize = PointerSize.Bit64; + return ProcessOperationStatus.Success; + default: + return ProcessOperationStatus.InvalidResult; + } + } + + /// + /// Observes Cheat Engine's separate facts about the selected target (backend, bitness, ISA families, Android, + /// ABI and configured pointer size) between two selected-PID reads in one Lua admission. + /// + /// The copied facts only when the returned status is successful. + /// + /// when the PID was positive and unchanged and + /// targetIs64Bit returned a boolean; and + /// without reading any fact; + /// when the bracketing reads differ; + /// when getOpenedProcessID or + /// targetIs64Bit is absent; when any probe + /// raises; when any probe returns a value of the wrong + /// type (a nil boolean is malformed, not ). + /// + /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// + /// The optional globals (isConnectedToCEServer, targetIsX86, targetIsArm, + /// targetIsAndroid, getABI, getPointerSize) may be absent: their facts are then + /// or unknown in , never . A + /// CEServer connection is reported as together with the facts CE reports + /// about the remote target; only Targets.TargetSelection refuses such a target for incarnation + /// evidence. + /// + /// + /// Evidence: spike C3 D2/D3/D5 (CE 7.7.0.10621 x64, Lua-only, ObservedHost design input) and + /// LuaHandler.pas:8280-8313, :10906-10918, :15962-15966 at ec45d5f (ObservedSource). + /// Fixture tests cover this operation at C1/C2; it is not host-qualified. + /// + /// + [RequiresPluginEnabled] + public static ProcessOperationStatus ObserveTargetArchitecture(out TargetArchitectureObservation observation) + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + return ObserveTargetArchitectureCore(operation.State, out observation, out _); + } + + internal static ProcessOperationStatus ObserveTargetArchitectureCore(LuaState state, + out TargetArchitectureObservation observation, out TargetProbeResult probe) + { + probe = TargetArchitectureProbe.Observe(state, TargetProbeFacts.AllTargetFacts, TargetProbeFacts.Bitness); + if (probe.Status != TargetProbeStatus.Success) + { + observation = default; + return FromProbe(probe); + } + + TargetBackend backend = probe.IsConnectedToCeServer switch + { + true => TargetBackend.CEServer, + false => TargetBackend.LocalProcess, + null => TargetBackend.Unknown + }; + observation = new TargetArchitectureObservation(new TargetProcessId(probe.ProcessId), backend, + probe.Is64Bit.GetValueOrDefault() ? PointerSize.Bit64 : PointerSize.Bit32, probe.IsX86Family, + probe.IsArmFamily, probe.IsAndroid, probe.AbiCode, probe.ConfiguredPointerSizeBytes); + return ProcessOperationStatus.Success; + } + + internal static ProcessOperationStatus FromProbe(in TargetProbeResult probe) + { + return FromProbe(probe.Status, probe.LuaStatus); + } + + private static ProcessOperationStatus FromProbe(TargetProbeStatus status, LuaStatus luaStatus) + { + return status switch + { + TargetProbeStatus.Success => ProcessOperationStatus.Success, + TargetProbeStatus.NoTargetSelected => ProcessOperationStatus.TargetNotAttached, + TargetProbeStatus.FileAsProcess => ProcessOperationStatus.FileAsProcessTarget, + TargetProbeStatus.TargetChanged => ProcessOperationStatus.TargetChanged, + TargetProbeStatus.GlobalUnavailable => ProcessOperationStatus.GlobalUnavailable, + TargetProbeStatus.LuaFailure => ProcessOperationStatus.ProtectedLuaFailure(luaStatus), + _ => ProcessOperationStatus.InvalidResult + }; + } + private static ProcessOperationStatus ObserveCurrent(LuaState state, out CurrentProcessObservation observation) { int top = state.Top; @@ -105,14 +259,14 @@ private static ProcessOperationStatus ObserveCurrent(LuaState state, out Current return status; } - status = TryGetTargetPointerSize(state, out PointerSize pointerSize); + status = TryGetTargetBitness(state, out PointerSize bitness); if (!status.IsSuccess) { observation = default; return status; } - observation = new CurrentProcessObservation(processId.Value, pointerSize); + observation = new CurrentProcessObservation(processId.Value, bitness); return ProcessOperationStatus.Success; } finally @@ -123,48 +277,21 @@ private static ProcessOperationStatus ObserveCurrent(LuaState state, out Current private static ProcessOperationStatus TryGetOpenedProcessId(LuaState state, out TargetProcessId? processId) { - LuaGlobalPushOutcome resolution = - LuaGlobalFunctions.TryPushWithOutcome(state, SGetOpenedProcessId, "getOpenedProcessID"u8); - if (!resolution.IsSuccess) - { - processId = default; - return FromResolution(resolution); - } - - LuaStatus luaStatus = state.TryCall(0, 1); - if (!luaStatus.IsOk) - { - processId = default; - return ProcessOperationStatus.ProtectedLuaFailure(luaStatus); - } - - if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long value) || - value is < 0 or > int.MaxValue) - { - processId = default; - return ProcessOperationStatus.InvalidResult; - } - - if (value == 0) - { - processId = default; - return ProcessOperationStatus.TargetNotAttached; - } - - processId = new TargetProcessId((int) value); - return ProcessOperationStatus.Success; + TargetProbeStatus status = TargetArchitectureProbe.ReadProcessId(state, out int value, out LuaStatus luaStatus); + processId = status == TargetProbeStatus.Success ? new TargetProcessId(value) : null; + return FromProbe(status, luaStatus); } - private static ProcessOperationStatus TryGetTargetPointerSize(LuaState state, out PointerSize pointerSize) + private static ProcessOperationStatus TryGetTargetBitness(LuaState state, out PointerSize bitness) { ProcessOperationStatus status = TryCallBoolean(state, STargetIs64Bit, "targetIs64Bit"u8, out bool is64Bit); if (!status.IsSuccess) { - pointerSize = PointerSize.Unknown; + bitness = PointerSize.Unknown; return status; } - pointerSize = is64Bit ? PointerSize.Bit64 : PointerSize.Bit32; + bitness = is64Bit ? PointerSize.Bit64 : PointerSize.Bit32; return ProcessOperationStatus.Success; } diff --git a/libs/CheatEngine.SDK.Engine/Processes/TargetArchitectureProbe.cs b/libs/CheatEngine.SDK.Engine/Processes/TargetArchitectureProbe.cs new file mode 100644 index 00000000..d09e0c8a --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Processes/TargetArchitectureProbe.cs @@ -0,0 +1,372 @@ +using System; +using System.Diagnostics; + +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.CompilerServices; +using CheatEngine.SDK.Lua.References; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Processes; + +/// +/// The single implementation of Cheat Engine's "selected PID, target facts, selected PID" read sequence, shared by +/// the instruction profile, the runtime process operations and the runtime snapshot. +/// +/// +/// +/// Every call runs inside the caller's LuaRuntimeOperation and restores the recorded stack top on every +/// exit path. Each global is resolved through LuaGlobalFunctions.TryPushWithOutcome and a static +/// cache, so an absent global, a raising global (with its protected ) +/// and a value of the wrong type stay three distinct outcomes. No error text is read. +/// +/// +/// The process identifier is read first. With no target selected, CE 7.7.0.10621 reports exactly the facts of an +/// x64 target (targetIsX86, targetIs64Bit and an 8-byte pointer size; spike C3 D2, ObservedHost, +/// Lua-only, 2026-09-22), so a zero identifier stops the probe before any fact is read. The file-as-process +/// sentinel 4294967295 also stops it: openFileAsProcess stores processid:=$FFFFFFFF and +/// getOpenedProcessID pushes that dword as a Lua integer (LuaHandler.pas:14101-14108, +/// :4228-4232 at cheat-engine/cheat-engine@ec45d5f, ObservedSource; not observed on the 7.7 binary). That +/// value is not a multiple of four, so it can never be a Windows process identifier. +/// +/// +/// The probe only reads. It never calls setPointerSize, setAssemblerMode, openProcess, +/// openFileAsProcess or any driver global (audit A17-18, Q45). The second identifier read makes a +/// selection change during the probe observable; it is not a lock and does not detect an unobserved A-to-B-to-A +/// transition. +/// +/// +internal static class TargetArchitectureProbe +{ + /// The value getOpenedProcessID returns for a file opened as a process: $FFFFFFFF as a Lua integer. + internal const long FileAsProcessSentinel = 4294967295L; + + private static readonly LuaRef SGetOpenedProcessId = new(); + private static readonly LuaRef SIsConnectedToCeServer = new(); + private static readonly LuaRef STargetIs64Bit = new(); + private static readonly LuaRef STargetIsX86 = new(); + private static readonly LuaRef STargetIsArm = new(); + private static readonly LuaRef STargetIsAndroid = new(); + private static readonly LuaRef SGetAbi = new(); + private static readonly LuaRef SGetPointerSize = new(); + + /// + /// Reads the selected PID, the requested facts in their fixed order, and the selected PID again, under the + /// caller's Lua operation. + /// + /// The admitted Lua state of the caller's operation. + /// The facts to read; is always read. + /// + /// The subset of whose absence fails the probe with + /// ; any other absent fact stays . + /// + /// The status and every fact read up to the point the probe stopped. + internal static TargetProbeResult Observe(LuaState state, TargetProbeFacts read, TargetProbeFacts required) + { + Debug.Assert((read & TargetProbeFacts.SelectedProcess) == TargetProbeFacts.None, + "The selected process is always read."); + Debug.Assert((required & ~read) == TargetProbeFacts.None, "A required fact must also be requested."); + + int top = state.Top; + Accumulator facts = default; + try + { + TargetProbeStatus status = ReadProcessId(state, out int processId, out LuaStatus luaStatus); + facts.RecordProcessIdRead(status); + if (status != TargetProbeStatus.Success) + { + return facts.ToResult(status, luaStatus, 0); + } + + status = ReadFacts(state, read, required, ref facts, out luaStatus); + if (status != TargetProbeStatus.Success) + { + return facts.ToResult(status, luaStatus, processId); + } + + // The closing read keeps its own failure (never a partial success) and turns any other selection, no + // selection or the file-as-process sentinel into a target change. + status = ReadProcessId(state, out int finalProcessId, out luaStatus); + status = status switch + { + TargetProbeStatus.Success when finalProcessId == processId => TargetProbeStatus.Success, + TargetProbeStatus.Success or TargetProbeStatus.NoTargetSelected or TargetProbeStatus.FileAsProcess => + TargetProbeStatus.TargetChanged, + _ => status + }; + return facts.ToResult(status, luaStatus, processId); + } + finally + { + state.SetTop(top); + } + } + + /// Reads and classifies getOpenedProcessID once, leaving the stack as it found it. + /// The admitted Lua state of the caller's operation. + /// The positive identifier when the result is ; otherwise zero. + /// The protected status of a ; otherwise OK. + /// + /// , , + /// , , + /// or . + /// + internal static TargetProbeStatus ReadProcessId(LuaState state, out int processId, out LuaStatus luaStatus) + { + processId = 0; + luaStatus = LuaStatus.Ok; + int top = state.Top; + try + { + LuaGlobalPushOutcome global = + LuaGlobalFunctions.TryPushWithOutcome(state, SGetOpenedProcessId, "getOpenedProcessID"u8); + if (!global.IsSuccess) + { + return FromResolution(global, out luaStatus); + } + + LuaStatus call = state.TryCall(0, 1); + if (!call.IsOk) + { + luaStatus = call; + return TargetProbeStatus.LuaFailure; + } + + // getOpenedProcessID pushes a Lua integer; a float, even an integral one, is malformed like every other + // integer fact the probe reads. + if (!state.IsInteger(-1) || !state.TryReadInteger(-1, out long raw)) + { + return TargetProbeStatus.InvalidProcessId; + } + + return ClassifyProcessId(raw, out processId); + } + finally + { + state.SetTop(top); + } + } + + /// Classifies a raw getOpenedProcessID integer. + /// The Lua integer. + /// The positive identifier when the result is ; otherwise zero. + /// The selected, no-target, file-as-process or invalid category. + internal static TargetProbeStatus ClassifyProcessId(long raw, out int processId) + { + processId = 0; + if (raw == 0) + { + return TargetProbeStatus.NoTargetSelected; + } + + // Compare as a 64-bit value before the int range check: the sentinel is an unsigned dword, not -1. + if (raw == FileAsProcessSentinel) + { + return TargetProbeStatus.FileAsProcess; + } + + if (raw is < 0 or > int.MaxValue) + { + return TargetProbeStatus.InvalidProcessId; + } + + processId = (int) raw; + return TargetProbeStatus.Success; + } + + private static TargetProbeStatus ReadFacts(LuaState state, TargetProbeFacts read, TargetProbeFacts required, + ref Accumulator facts, out LuaStatus luaStatus) + { + TargetProbeStatus status = ReadFact(state, read, required, TargetProbeFacts.CeServerConnection, + SIsConnectedToCeServer, "isConnectedToCEServer"u8, ref facts, out luaStatus); + if (status != TargetProbeStatus.Success) + { + return status; + } + + status = ReadFact(state, read, required, TargetProbeFacts.Bitness, STargetIs64Bit, "targetIs64Bit"u8, + ref facts, out luaStatus); + if (status != TargetProbeStatus.Success) + { + return status; + } + + status = ReadFact(state, read, required, TargetProbeFacts.X86Family, STargetIsX86, "targetIsX86"u8, ref facts, + out luaStatus); + if (status != TargetProbeStatus.Success) + { + return status; + } + + status = ReadFact(state, read, required, TargetProbeFacts.ArmFamily, STargetIsArm, "targetIsArm"u8, ref facts, + out luaStatus); + if (status != TargetProbeStatus.Success) + { + return status; + } + + status = ReadFact(state, read, required, TargetProbeFacts.Android, STargetIsAndroid, "targetIsAndroid"u8, + ref facts, out luaStatus); + if (status != TargetProbeStatus.Success) + { + return status; + } + + status = ReadFact(state, read, required, TargetProbeFacts.Abi, SGetAbi, "getABI"u8, ref facts, out luaStatus); + return status != TargetProbeStatus.Success + ? status + : ReadFact(state, read, required, TargetProbeFacts.ConfiguredPointerSize, SGetPointerSize, + "getPointerSize"u8, ref facts, out luaStatus); + } + + private static TargetProbeStatus ReadFact(LuaState state, TargetProbeFacts read, TargetProbeFacts required, + TargetProbeFacts fact, LuaRef cache, ReadOnlySpan name, ref Accumulator facts, out LuaStatus luaStatus) + { + luaStatus = LuaStatus.Ok; + if ((read & fact) == TargetProbeFacts.None) + { + return TargetProbeStatus.Success; + } + + int top = state.Top; + try + { + LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, cache, name); + if (global.Status == LuaGlobalPushStatus.Unavailable) + { + facts.Absent |= fact; + return (required & fact) != TargetProbeFacts.None + ? TargetProbeStatus.GlobalUnavailable + : TargetProbeStatus.Success; + } + + if (!global.IsSuccess) + { + return FromResolution(global, out luaStatus); + } + + LuaStatus call = state.TryCall(0, 1); + if (!call.IsOk) + { + luaStatus = call; + return TargetProbeStatus.LuaFailure; + } + + if (IsIntegerFact(fact)) + { + // CE pushes these with lua_pushinteger. A float, even an integral one, and a value outside int are + // refused rather than rounded or truncated. + if (!state.IsInteger(-1) || !state.TryReadInteger(-1, out long raw) || raw is < int.MinValue or > int.MaxValue) + { + return TargetProbeStatus.InvalidResult; + } + + facts.Store(fact, (int) raw); + } + else + { + // nil is not false: a boolean fact must be a Lua boolean. + if (state.TypeOf(-1) != LuaType.Boolean) + { + return TargetProbeStatus.InvalidResult; + } + + facts.Store(fact, state.ToBoolean(-1)); + } + + return TargetProbeStatus.Success; + } + finally + { + state.SetTop(top); + } + } + + private static bool IsIntegerFact(TargetProbeFacts fact) + { + return fact is TargetProbeFacts.Abi or TargetProbeFacts.ConfiguredPointerSize; + } + + private static TargetProbeStatus FromResolution(LuaGlobalPushOutcome global, out LuaStatus luaStatus) + { + if (global.Status == LuaGlobalPushStatus.Unavailable) + { + luaStatus = LuaStatus.Ok; + return TargetProbeStatus.GlobalUnavailable; + } + + luaStatus = global.LuaStatus; + return TargetProbeStatus.LuaFailure; + } + + private struct Accumulator + { + internal TargetProbeFacts Resolved; + internal TargetProbeFacts Absent; + private bool? _isConnectedToCeServer; + private bool? _is64Bit; + private bool? _isX86Family; + private bool? _isArmFamily; + private bool? _isAndroid; + private int? _abiCode; + private int? _configuredPointerSizeBytes; + + internal void RecordProcessIdRead(TargetProbeStatus status) + { + if (status == TargetProbeStatus.GlobalUnavailable) + { + Absent |= TargetProbeFacts.SelectedProcess; + } + else if (status is TargetProbeStatus.Success or TargetProbeStatus.NoTargetSelected + or TargetProbeStatus.FileAsProcess) + { + Resolved |= TargetProbeFacts.SelectedProcess; + } + } + + internal void Store(TargetProbeFacts fact, bool value) + { + Resolved |= fact; + switch (fact) + { + case TargetProbeFacts.CeServerConnection: + _isConnectedToCeServer = value; + break; + case TargetProbeFacts.Bitness: + _is64Bit = value; + break; + case TargetProbeFacts.X86Family: + _isX86Family = value; + break; + case TargetProbeFacts.ArmFamily: + _isArmFamily = value; + break; + case TargetProbeFacts.Android: + _isAndroid = value; + break; + default: + Debug.Fail("Not a boolean fact."); + break; + } + } + + internal void Store(TargetProbeFacts fact, int value) + { + Resolved |= fact; + if (fact == TargetProbeFacts.Abi) + { + _abiCode = value; + } + else + { + Debug.Assert(fact == TargetProbeFacts.ConfiguredPointerSize, "Not an integer fact."); + _configuredPointerSizeBytes = value; + } + } + + internal readonly TargetProbeResult ToResult(TargetProbeStatus status, LuaStatus luaStatus, int processId) + { + return new TargetProbeResult(status, luaStatus, processId, Resolved, Absent, _isConnectedToCeServer, + _is64Bit, _isX86Family, _isArmFamily, _isAndroid, _abiCode, _configuredPointerSizeBytes); + } + } +} diff --git a/libs/CheatEngine.SDK.Engine/Processes/TargetProbeFacts.cs b/libs/CheatEngine.SDK.Engine/Processes/TargetProbeFacts.cs new file mode 100644 index 00000000..741b54cd --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Processes/TargetProbeFacts.cs @@ -0,0 +1,46 @@ +using System; + +namespace CheatEngine.SDK.Engine.Processes; + +/// The Cheat Engine target facts that can read, one flag per Lua global. +/// +/// The probe always reads first and last. The other facts are read in the fixed +/// order of their flag values, and only when the caller requests them, so an operation that needs the instruction +/// set never calls getPointerSize, getABI or isConnectedToCEServer. +/// +[Flags] +internal enum TargetProbeFacts : byte +{ + /// No fact. + None = 0, + + /// isConnectedToCEServer: whether Cheat Engine is connected to a CEServer backend. + CeServerConnection = 1, + + /// targetIs64Bit: Cheat Engine's 64-bit process flag for the selected target. + Bitness = 2, + + /// targetIsX86: whether the selected target belongs to the x86 ISA family. + X86Family = 4, + + /// targetIsArm: whether the selected target belongs to the ARM ISA family. + ArmFamily = 8, + + /// targetIsAndroid: whether Cheat Engine reports an Android target. + Android = 16, + + /// getABI: the raw target ABI-family code. + Abi = 32, + + /// getPointerSize: Cheat Engine's configured pointer size, as a raw integer. + ConfiguredPointerSize = 64, + + /// getOpenedProcessID: the selected process identifier, read before and after every other fact. + SelectedProcess = 128, + + /// The three facts that define an instruction profile. + InstructionSet = Bitness | X86Family | ArmFamily, + + /// Every fact the probe can read after the first process-identifier read. + AllTargetFacts = CeServerConnection | Bitness | X86Family | ArmFamily | Android | Abi | ConfiguredPointerSize +} diff --git a/libs/CheatEngine.SDK.Engine/Processes/TargetProbeResult.cs b/libs/CheatEngine.SDK.Engine/Processes/TargetProbeResult.cs new file mode 100644 index 00000000..a9d775a0 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Processes/TargetProbeResult.cs @@ -0,0 +1,106 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Processes; + +/// The copied facts and status of one observation. +/// +/// A fact was either not requested, not reached, or its global was absent; +/// and tell those cases apart. The facts are kept on every exit path +/// so that a caller can report which globals it probed, but only a result +/// attributes them to . +/// +[StructLayout(LayoutKind.Auto)] +internal readonly struct TargetProbeResult +{ + internal TargetProbeResult(TargetProbeStatus status, LuaStatus luaStatus, int processId, + TargetProbeFacts resolved, TargetProbeFacts absent, bool? isConnectedToCeServer, bool? is64Bit, + bool? isX86Family, bool? isArmFamily, bool? isAndroid, int? abiCode, int? configuredPointerSizeBytes) + { + Status = status; + LuaStatus = luaStatus; + ProcessId = processId; + Resolved = resolved; + Absent = absent; + IsConnectedToCeServer = isConnectedToCeServer; + Is64Bit = is64Bit; + IsX86Family = isX86Family; + IsArmFamily = isArmFamily; + IsAndroid = isAndroid; + AbiCode = abiCode; + ConfiguredPointerSizeBytes = configuredPointerSizeBytes; + } + + /// Gets the result category. + internal TargetProbeStatus Status + { + get; + } + + /// Gets the protected Lua status for ; otherwise OK. + internal LuaStatus LuaStatus + { + get; + } + + /// Gets the positive selected process identifier from the first read, or zero. + internal int ProcessId + { + get; + } + + /// Gets the facts whose global was resolved and returned a well-formed value. + internal TargetProbeFacts Resolved + { + get; + } + + /// Gets the facts whose global was probed and found absent or not callable. + internal TargetProbeFacts Absent + { + get; + } + + /// Gets isConnectedToCEServer(), when read. + internal bool? IsConnectedToCeServer + { + get; + } + + /// Gets targetIs64Bit(), when read. + internal bool? Is64Bit + { + get; + } + + /// Gets targetIsX86(), when read. + internal bool? IsX86Family + { + get; + } + + /// Gets targetIsArm(), when read. + internal bool? IsArmFamily + { + get; + } + + /// Gets targetIsAndroid(), when read. + internal bool? IsAndroid + { + get; + } + + /// Gets the raw getABI() integer, when read. + internal int? AbiCode + { + get; + } + + /// Gets the raw getPointerSize() integer, when read; any 32-bit integer is kept. + internal int? ConfiguredPointerSizeBytes + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Processes/TargetProbeStatus.cs b/libs/CheatEngine.SDK.Engine/Processes/TargetProbeStatus.cs new file mode 100644 index 00000000..8ed8b945 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Processes/TargetProbeStatus.cs @@ -0,0 +1,38 @@ +namespace CheatEngine.SDK.Engine.Processes; + +/// The internal result category of one observation. +/// Each public operation maps these categories to its own status type; none is exposed as-is. +internal enum TargetProbeStatus : byte +{ + /// No probe result was recorded. + Unknown = 0, + + /// A positive process identifier was read twice with the same value and every required fact was read. + Success = 1, + + /// getOpenedProcessID returned 0: no target is selected, and no target fact was read. + NoTargetSelected = 2, + + /// + /// getOpenedProcessID returned the file-as-process sentinel 4294967295, and no target fact was read. + /// + FileAsProcess = 3, + + /// + /// getOpenedProcessID returned a value that is not a Lua integer (a float, even an integral one, is refused) + /// or an integer outside the supported identifier range. + /// + InvalidProcessId = 4, + + /// A required global was absent or not callable. + GlobalUnavailable = 5, + + /// Resolving or calling a global raised; the protected LuaStatus is kept. + LuaFailure = 6, + + /// A fact global returned a value of the wrong Lua type or outside its supported range. + InvalidResult = 7, + + /// The two process-identifier reads that bracket the facts disagree. + TargetChanged = 8 +} diff --git a/libs/CheatEngine.SDK.Engine/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Engine/PublicAPI.Shipped.txt new file mode 100644 index 00000000..d013e807 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/PublicAPI.Shipped.txt @@ -0,0 +1,891 @@ +#nullable enable +CheatEngine.SDK.Engine.AddressList.AddressList +CheatEngine.SDK.Engine.AddressList.AddressList.AddressList() -> void +CheatEngine.SDK.Engine.AddressList.AddressList.AddressList(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.AddressList.AddressList.Equals(CheatEngine.SDK.Engine.AddressList.AddressList other) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.AddressList.AddressList.IsNull.get -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryCreateMemoryRecord(out CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryGetCount(out int count) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryGetMemoryRecord(int zeroBasedIndex, out CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryGetMemoryRecordById(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryGetSelectedRecord(out CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TrySetSelectedRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressListAccess +CheatEngine.SDK.Engine.AddressList.MemoryRecord +CheatEngine.SDK.Engine.AddressList.MemoryRecord.Equals(CheatEngine.SDK.Engine.AddressList.MemoryRecord other) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.AddressList.MemoryRecord.IsNull.get -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.MemoryRecord() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecord.MemoryRecord(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetAddressExpression(out string! addressExpression) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetChild(int zeroBasedIndex, out CheatEngine.SDK.Engine.AddressList.MemoryRecord child) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetCurrentAddress(out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetDescription(out string! description) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetId(out CheatEngine.SDK.Engine.AddressList.MemoryRecordId id) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetIndex(out int zeroBasedIndex) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetParent(out CheatEngine.SDK.Engine.AddressList.MemoryRecord parent) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetValue(out string! value) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetVariableType(out CheatEngine.SDK.Engine.Enums.VariableType variableType) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TrySetAddressExpression(string! addressExpression) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TrySetDescription(string! description) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TrySetValue(string! value) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TrySetVariableType(CheatEngine.SDK.Engine.Enums.VariableType variableType) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecordId +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.CompareTo(CheatEngine.SDK.Engine.AddressList.MemoryRecordId other) -> int +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.CompareTo(object? obj) -> int +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Equals(CheatEngine.SDK.Engine.AddressList.MemoryRecordId other) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.MemoryRecordId() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.MemoryRecordId(int value) -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Value.get -> int +CheatEngine.SDK.Engine.Allocation.AllocatedRegion +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Dispose() -> void +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.IsDisposed.get -> bool +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Release() -> void +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Size.get -> CheatEngine.SDK.Engine.Allocation.TargetAllocationSize +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOperations +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOperations.TryAllocate(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request, out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOperations.TryDeallocate(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size) -> bool +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.Equals(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest other) -> bool +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.PreferredBaseAddress.get -> CheatEngine.SDK.Engine.Values.Address? +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.Protection.get -> CheatEngine.SDK.Engine.Enums.MemoryProtection? +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.Size.get -> CheatEngine.SDK.Engine.Allocation.TargetAllocationSize +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.TargetAllocationRequest() -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.TargetAllocationRequest(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size, CheatEngine.SDK.Engine.Values.Address? preferredBaseAddress = null, CheatEngine.SDK.Engine.Enums.MemoryProtection? protection = null) -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.Equals(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize other) -> bool +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.TargetAllocationSize() -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.TargetAllocationSize(long value) -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.Value.get -> long +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator.Allocate(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request) -> CheatEngine.SDK.Engine.Allocation.AllocatedRegion! +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator.TargetMemoryAllocator(CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOperations! operations) -> void +CheatEngine.SDK.Engine.Enums.BreakpointMethod +CheatEngine.SDK.Engine.Enums.BreakpointMethod.DebugRegister = 1 -> CheatEngine.SDK.Engine.Enums.BreakpointMethod +CheatEngine.SDK.Engine.Enums.BreakpointMethod.Exception = 2 -> CheatEngine.SDK.Engine.Enums.BreakpointMethod +CheatEngine.SDK.Engine.Enums.BreakpointMethod.Int3 = 0 -> CheatEngine.SDK.Engine.Enums.BreakpointMethod +CheatEngine.SDK.Engine.Enums.BreakpointTrigger +CheatEngine.SDK.Engine.Enums.BreakpointTrigger.Access = 1 -> CheatEngine.SDK.Engine.Enums.BreakpointTrigger +CheatEngine.SDK.Engine.Enums.BreakpointTrigger.Execute = 0 -> CheatEngine.SDK.Engine.Enums.BreakpointTrigger +CheatEngine.SDK.Engine.Enums.BreakpointTrigger.Write = 2 -> CheatEngine.SDK.Engine.Enums.BreakpointTrigger +CheatEngine.SDK.Engine.Enums.CEEnumNames +CheatEngine.SDK.Engine.Enums.ContinueMethod +CheatEngine.SDK.Engine.Enums.ContinueMethod.Run = 0 -> CheatEngine.SDK.Engine.Enums.ContinueMethod +CheatEngine.SDK.Engine.Enums.ContinueMethod.StepInto = 1 -> CheatEngine.SDK.Engine.Enums.ContinueMethod +CheatEngine.SDK.Engine.Enums.ContinueMethod.StepOver = 2 -> CheatEngine.SDK.Engine.Enums.ContinueMethod +CheatEngine.SDK.Engine.Enums.DuplicateHandling +CheatEngine.SDK.Engine.Enums.DuplicateHandling.Accept = 1 -> CheatEngine.SDK.Engine.Enums.DuplicateHandling +CheatEngine.SDK.Engine.Enums.DuplicateHandling.Error = 2 -> CheatEngine.SDK.Engine.Enums.DuplicateHandling +CheatEngine.SDK.Engine.Enums.DuplicateHandling.Ignore = 0 -> CheatEngine.SDK.Engine.Enums.DuplicateHandling +CheatEngine.SDK.Engine.Enums.EnumMarshaller +CheatEngine.SDK.Engine.Enums.EnumMarshaller.EnumMarshaller() -> void +CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.FastScanMethod.Aligned = 1 -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.FastScanMethod.LastDigits = 2 -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.FastScanMethod.NotAligned = 0 -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.Execute = 16 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ExecuteRead = 32 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ExecuteReadWrite = 64 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ExecuteWriteCopy = 128 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.None = 0 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ReadOnly = 2 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ReadWrite = 4 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.WriteCopy = 8 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Enums.RoundingType.ExtremeRounded = 1 -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Enums.RoundingType.Rounded = 0 -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Enums.RoundingType.Truncated = 2 -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.BiggerThan = 3 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.Changed = 9 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.DecreasedValue = 7 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.DecreasedValueBy = 8 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.ExactValue = 1 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.IncreasedValue = 5 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.IncreasedValueBy = 6 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.SmallerThan = 4 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.Unchanged = 10 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.UnknownValue = 0 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.ValueBetween = 2 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.All = 10 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.AutoAssembler = 11 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Binary = 9 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Byte = 0 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.ByteArray = 8 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Custom = 13 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Double = 5 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Dword = 2 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Grouped = 14 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Pointer = 12 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Qword = 3 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Single = 4 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.String = 6 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.WideString = 7 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Word = 1 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Errors.EngineBindingException +CheatEngine.SDK.Engine.Errors.EngineBindingException.Binding.get -> string! +CheatEngine.SDK.Engine.Errors.EngineBindingException.EngineBindingException(string! binding) -> void +CheatEngine.SDK.Engine.Errors.EngineBindingException.EngineBindingException(string! binding, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineBindingException.EngineBindingException(string! binding, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.Capability.get -> string! +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.EngineCapabilityUnavailableException(string! capability) -> void +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.EngineCapabilityUnavailableException(string! capability, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.EngineCapabilityUnavailableException(string! capability, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineException +CheatEngine.SDK.Engine.Errors.EngineException.EngineException(string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineException.EngineException(string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.BindingFailure = 4 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.CapabilityUnavailable = 2 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.ExpectedOperationFailure = 0 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.GlobalUnavailable = 1 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.MarshallingFailure = 5 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.ProtectedLuaFailure = 3 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.EngineGlobalUnavailableException(string! operation) -> void +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.EngineGlobalUnavailableException(string! operation, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.EngineGlobalUnavailableException(string! operation, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.Operation.get -> string! +CheatEngine.SDK.Engine.Errors.EngineLuaException +CheatEngine.SDK.Engine.Errors.EngineLuaException.EngineLuaException(string! operation, CheatEngine.SDK.Lua.Calls.LuaStatus status) -> void +CheatEngine.SDK.Engine.Errors.EngineLuaException.EngineLuaException(string! operation, CheatEngine.SDK.Lua.Calls.LuaStatus status, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineLuaException.EngineLuaException(string! operation, CheatEngine.SDK.Lua.Calls.LuaStatus status, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineLuaException.Operation.get -> string! +CheatEngine.SDK.Engine.Errors.EngineLuaException.Status.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection +CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection.Argument = 0 -> CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection +CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection.Result = 1 -> CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection +CheatEngine.SDK.Engine.Errors.EngineMarshallingException +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Actual.get -> string! +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Direction.get -> CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.EngineMarshallingException(string! operation, CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection direction, string! expected, string! actual) -> void +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.EngineMarshallingException(string! operation, CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection direction, string! expected, string! actual, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.EngineMarshallingException(string! operation, CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection direction, string! expected, string! actual, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Expected.get -> string! +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Operation.get -> string! +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.EngineOperationFailedException(string! operation) -> void +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.EngineOperationFailedException(string! operation, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.EngineOperationFailedException(string! operation, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.Operation.get -> string! +CheatEngine.SDK.Engine.Generated.MemoryScalars +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.AddressResolutionOptions() -> void +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.AddressResolutionOptions(bool UseHostSymbolTable = false, bool Shallow = false) -> void +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Deconstruct(out bool UseHostSymbolTable, out bool Shallow) -> void +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Equals(CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions other) -> bool +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Shallow.get -> bool +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Shallow.init -> void +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.UseHostSymbolTable.get -> bool +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.UseHostSymbolTable.init -> void +CheatEngine.SDK.Engine.Inspection.EngineInspection +CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.DestinationTooSmall = 2 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.GlobalUnavailable = 3 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.InvalidResult = 5 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.LuaFailure = 4 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.NotFound = 1 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.Success = 0 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.AllocationBase.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.AllocationBase.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.AllocationProtection.get -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.AllocationProtection.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.BaseAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.BaseAddress.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Deconstruct(out CheatEngine.SDK.Engine.Values.Address BaseAddress, out CheatEngine.SDK.Engine.Values.Address AllocationBase, out CheatEngine.SDK.Engine.Enums.MemoryProtection AllocationProtection, out CheatEngine.SDK.Engine.Inspection.MemorySize Size, out CheatEngine.SDK.Engine.Inspection.MemoryRegionState State, out CheatEngine.SDK.Engine.Enums.MemoryProtection Protection, out CheatEngine.SDK.Engine.Inspection.MemoryRegionType Type, out string? Extra) -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Equals(CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo other) -> bool +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Extra.get -> string? +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Extra.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.MemoryRegionInfo() -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.MemoryRegionInfo(CheatEngine.SDK.Engine.Values.Address BaseAddress, CheatEngine.SDK.Engine.Values.Address AllocationBase, CheatEngine.SDK.Engine.Enums.MemoryProtection AllocationProtection, CheatEngine.SDK.Engine.Inspection.MemorySize Size, CheatEngine.SDK.Engine.Inspection.MemoryRegionState State, CheatEngine.SDK.Engine.Enums.MemoryProtection Protection, CheatEngine.SDK.Engine.Inspection.MemoryRegionType Type, string? Extra) -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Protection.get -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Protection.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Size.get -> CheatEngine.SDK.Engine.Inspection.MemorySize +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Size.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.State.get -> CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.State.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Type.get -> CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Type.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionState.Committed = 4096 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionState.Free = 65536 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionState.Reserved = 8192 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemoryRegionType.Image = 16777216 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemoryRegionType.Mapped = 262144 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemoryRegionType.Private = 131072 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemorySize +CheatEngine.SDK.Engine.Inspection.MemorySize.CompareTo(CheatEngine.SDK.Engine.Inspection.MemorySize other) -> int +CheatEngine.SDK.Engine.Inspection.MemorySize.Equals(CheatEngine.SDK.Engine.Inspection.MemorySize other) -> bool +CheatEngine.SDK.Engine.Inspection.MemorySize.MemorySize() -> void +CheatEngine.SDK.Engine.Inspection.MemorySize.MemorySize(ulong value) -> void +CheatEngine.SDK.Engine.Inspection.MemorySize.Value.get -> ulong +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.Equals(CheatEngine.SDK.Engine.Inspection.ModuleFileOffset other) -> bool +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.ModuleFileOffset() -> void +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.ModuleFileOffset(ulong value) -> void +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.Value.get -> ulong +CheatEngine.SDK.Engine.Inspection.ModuleInfo +CheatEngine.SDK.Engine.Inspection.ModuleInfo.BaseAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.ModuleInfo.BaseAddress.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Deconstruct(out string! Name, out CheatEngine.SDK.Engine.Values.Address BaseAddress, out CheatEngine.SDK.Engine.Inspection.MemorySize? ImageSize, out bool Is64Bit, out string! PathToFile) -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Equals(CheatEngine.SDK.Engine.Inspection.ModuleInfo other) -> bool +CheatEngine.SDK.Engine.Inspection.ModuleInfo.ImageSize.get -> CheatEngine.SDK.Engine.Inspection.MemorySize? +CheatEngine.SDK.Engine.Inspection.ModuleInfo.ImageSize.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Is64Bit.get -> bool +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Is64Bit.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.ModuleInfo() -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.ModuleInfo(string! Name, CheatEngine.SDK.Engine.Values.Address BaseAddress, CheatEngine.SDK.Engine.Inspection.MemorySize? ImageSize, bool Is64Bit, string! PathToFile) -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Name.get -> string! +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Name.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.PathToFile.get -> string! +CheatEngine.SDK.Engine.Inspection.ModuleInfo.PathToFile.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleName +CheatEngine.SDK.Engine.Inspection.ModuleName.Equals(CheatEngine.SDK.Engine.Inspection.ModuleName other) -> bool +CheatEngine.SDK.Engine.Inspection.ModuleName.ModuleName() -> void +CheatEngine.SDK.Engine.Inspection.ModuleName.ModuleName(string! value) -> void +CheatEngine.SDK.Engine.Inspection.ModuleName.Value.get -> string! +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Address.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Deconstruct(out string! Name, out CheatEngine.SDK.Engine.Inspection.MemorySize Size, out CheatEngine.SDK.Engine.Values.Address Address, out CheatEngine.SDK.Engine.Inspection.ModuleFileOffset FileOffset) -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Equals(CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo other) -> bool +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.FileOffset.get -> CheatEngine.SDK.Engine.Inspection.ModuleFileOffset +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.FileOffset.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.ModuleSectionInfo() -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.ModuleSectionInfo(string! Name, CheatEngine.SDK.Engine.Inspection.MemorySize Size, CheatEngine.SDK.Engine.Values.Address Address, CheatEngine.SDK.Engine.Inspection.ModuleFileOffset FileOffset) -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Name.get -> string! +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Name.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Size.get -> CheatEngine.SDK.Engine.Inspection.MemorySize +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Size.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolExpression +CheatEngine.SDK.Engine.Inspection.SymbolExpression.Equals(CheatEngine.SDK.Engine.Inspection.SymbolExpression other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolExpression.SymbolExpression() -> void +CheatEngine.SDK.Engine.Inspection.SymbolExpression.SymbolExpression(string! value) -> void +CheatEngine.SDK.Engine.Inspection.SymbolExpression.Value.get -> string! +CheatEngine.SDK.Engine.Inspection.SymbolInfo +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Address.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Deconstruct(out string! ModuleName, out string! SearchKey, out CheatEngine.SDK.Engine.Values.Address Address, out CheatEngine.SDK.Engine.Inspection.MemorySize Size) -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Equals(CheatEngine.SDK.Engine.Inspection.SymbolInfo other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolInfo.ModuleName.get -> string! +CheatEngine.SDK.Engine.Inspection.SymbolInfo.ModuleName.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.SearchKey.get -> string! +CheatEngine.SDK.Engine.Inspection.SymbolInfo.SearchKey.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Size.get -> CheatEngine.SDK.Engine.Inspection.MemorySize +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Size.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.SymbolInfo() -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.SymbolInfo(string! ModuleName, string! SearchKey, CheatEngine.SDK.Engine.Values.Address Address, CheatEngine.SDK.Engine.Inspection.MemorySize Size) -> void +CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Inspection.TargetProcessId.Equals(CheatEngine.SDK.Engine.Inspection.TargetProcessId other) -> bool +CheatEngine.SDK.Engine.Inspection.TargetProcessId.TargetProcessId() -> void +CheatEngine.SDK.Engine.Inspection.TargetProcessId.TargetProcessId(int value) -> void +CheatEngine.SDK.Engine.Inspection.TargetProcessId.Value.get -> int +CheatEngine.SDK.Engine.Memory.HostAddress +CheatEngine.SDK.Engine.Memory.HostAddress.Equals(CheatEngine.SDK.Engine.Memory.HostAddress other) -> bool +CheatEngine.SDK.Engine.Memory.HostAddress.HostAddress() -> void +CheatEngine.SDK.Engine.Memory.HostAddress.HostAddress(nuint value) -> void +CheatEngine.SDK.Engine.Memory.HostAddress.IsZero.get -> bool +CheatEngine.SDK.Engine.Memory.HostAddress.ToInt64() -> long +CheatEngine.SDK.Engine.Memory.HostAddress.ToString(string? format, System.IFormatProvider? formatProvider) -> string! +CheatEngine.SDK.Engine.Memory.HostAddress.Value.get -> nuint +CheatEngine.SDK.Engine.Memory.HostMemory +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall = 4 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.GlobalUnavailable = 1 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult = 6 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.LuaError = 2 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.None = 0 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.ReadFailed = 3 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed = 5 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.TargetMemory +CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.CEObject.CEObject() -> void +CheatEngine.SDK.Engine.Objects.CEObject.CEObject(nint value) -> void +CheatEngine.SDK.Engine.Objects.CEObject.Equals(CheatEngine.SDK.Engine.Objects.CEObject other) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.CEObject.IsNull.get -> bool +CheatEngine.SDK.Engine.Objects.CEObject.Push(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Engine.Objects.CEObject.TryCallMethod(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name, int argumentCount, int resultCount) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryCallMethod(System.ReadOnlySpan name) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.TryCallMethod(System.ReadOnlySpan name, out TResult result) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.TryGetIndex(CheatEngine.SDK.Lua.State.LuaState state, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryGetProperty(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryGetProperty(System.ReadOnlySpan name, out TValue value) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.TryGetPropertyLeavingObject(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryPushMethod(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryPushMethodLeavingObject(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TrySetIndex(CheatEngine.SDK.Lua.State.LuaState state, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TrySetProperty(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TrySetProperty(System.ReadOnlySpan name, TValue value) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.Value.get -> nint +CheatEngine.SDK.Engine.Objects.ICEObject +CheatEngine.SDK.Engine.Objects.ICEObject.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> TSelf +CheatEngine.SDK.Engine.Objects.ICEObject.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.Owned +CheatEngine.SDK.Engine.Objects.Owned.Abandon() -> T +CheatEngine.SDK.Engine.Objects.Owned.Dispose() -> void +CheatEngine.SDK.Engine.Objects.Owned.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.Owned.IsDisposed.get -> bool +CheatEngine.SDK.Engine.Objects.Owned.ToBorrowed() -> T +CheatEngine.SDK.Engine.Objects.Owned.Transfer() -> CheatEngine.SDK.Engine.Objects.Owned! +CheatEngine.SDK.Engine.Objects.Owned.TryDestroy(CheatEngine.SDK.Lua.State.LuaState state) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.Owned.Value.get -> T +CheatEngine.SDK.Engine.Objects.StringList +CheatEngine.SDK.Engine.Objects.StringList.Equals(CheatEngine.SDK.Engine.Objects.StringList other) -> bool +CheatEngine.SDK.Engine.Objects.StringList.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.StringList.IsNull.get -> bool +CheatEngine.SDK.Engine.Objects.StringList.StringList() -> void +CheatEngine.SDK.Engine.Objects.StringList.StringList(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.Objects.StringList.TryAdd(string! value, out int zeroBasedIndex) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryClear() -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryDelete(int zeroBasedIndex) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetCaseSensitive(out bool value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetCount(out int count) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetDuplicates(out CheatEngine.SDK.Engine.Enums.DuplicateHandling value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetItem(int zeroBasedIndex, out string! value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetSorted(out bool value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetText(out string! text) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryIndexOf(string! value, out int zeroBasedIndex) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetCaseSensitive(bool value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetDuplicates(CheatEngine.SDK.Engine.Enums.DuplicateHandling value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetItem(int zeroBasedIndex, string! value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetSorted(bool value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetText(string! text) -> bool +CheatEngine.SDK.Engine.Objects.StringLists +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.Arm32 = 3 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.Arm64 = 4 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.X64 = 2 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.X86 = 1 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Build.get -> int +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.CheatEngineVersion() -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.CheatEngineVersion(int major, int minor, int release, int build) -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.CompareTo(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion other) -> int +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Equals(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion other) -> bool +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Major.get -> int +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Minor.get -> int +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Release.get -> int +CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Runtime.PointerSize.Bits.get -> int +CheatEngine.SDK.Engine.Runtime.PointerSize.Bytes.get -> int +CheatEngine.SDK.Engine.Runtime.PointerSize.Equals(CheatEngine.SDK.Engine.Runtime.PointerSize other) -> bool +CheatEngine.SDK.Engine.Runtime.PointerSize.IsKnown.get -> bool +CheatEngine.SDK.Engine.Runtime.PointerSize.PointerSize() -> void +CheatEngine.SDK.Engine.Runtime.PointerSize.PointerSize(int bytes) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.Any = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.Arm32 = 4 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.Arm64 = 5 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.X64 = 3 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.X86 = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope.CheatEngine = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope.Target = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Count.get -> int +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Entries.get -> System.ReadOnlySpan +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Equals(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities? other) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.GetState(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId capability) -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.TryGet(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId capability, out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability availability) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Capability.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Capability.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Contract.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Contract.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Deconstruct(out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId Capability, out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState State, out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract Contract) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Equals(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability other) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.IsAvailable.get -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.IsKnown.get -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.RuntimeCapabilityAvailability() -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.RuntimeCapabilityAvailability(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId Capability, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState State, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract Contract) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.State.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.State.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState.Available = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState.Unavailable = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ArchitectureRequirement.get -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ArchitectureRequirement.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ArchitectureScope.get -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ArchitectureScope.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Deconstruct(out CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? MinimumCheatEngineVersion, out CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope ArchitectureScope, out CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement ArchitectureRequirement, out CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement ThreadRequirement, out CheatEngine.SDK.Engine.Runtime.RuntimeOwnership Ownership, out CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics ReturnSemantics) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Equals(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract other) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.MinimumCheatEngineVersion.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.MinimumCheatEngineVersion.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Ownership.get -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Ownership.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ReturnSemantics.get -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ReturnSemantics.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.RuntimeCapabilityContract() -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.RuntimeCapabilityContract(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? MinimumCheatEngineVersion, CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope ArchitectureScope, CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement ArchitectureRequirement, CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement ThreadRequirement, CheatEngine.SDK.Engine.Runtime.RuntimeOwnership Ownership, CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics ReturnSemantics) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ThreadRequirement.get -> CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ThreadRequirement.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.Equals(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId other) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.IsEmpty.get -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.RuntimeCapabilityId() -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.RuntimeCapabilityId(string! value) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.Value.get -> string! +CheatEngine.SDK.Engine.Runtime.RuntimeInfo +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.Capabilities.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.PointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.RuntimeInfo(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion version, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture systemArchitecture, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, CheatEngine.SDK.Engine.Runtime.PointerSize pointerSize, CheatEngine.SDK.Engine.Runtime.TargetAbi targetAbi, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! capabilities) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.Version.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership.Borrowed = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership.None = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership.Owned = 3 -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.BooleanStatus = 3 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.OptionalValue = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.Value = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.Void = 4 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement.AnyThread = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement.MainThread = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.TargetAbi.Unix = 2 -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.TargetAbi.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.TargetAbi.Windows = 1 -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.AlignmentMethod.get -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.AlignmentParameter.get -> string? +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.AobScanOptions() -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.AobScanOptions(string? protectionFlags, CheatEngine.SDK.Engine.Enums.FastScanMethod alignmentMethod, string? alignmentParameter) -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.Equals(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions other) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.ProtectionFlags.get -> string? +CheatEngine.SDK.Engine.Scanning.Aob.AobScanner +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.AlignmentParameter.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.FastScanMethod.get -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.FirstScanRequest() -> void +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.FirstScanRequest(CheatEngine.SDK.Engine.Enums.ScanOption scanOption, CheatEngine.SDK.Engine.Enums.VariableType variableType, CheatEngine.SDK.Engine.Enums.RoundingType roundingType, string! input1, string! input2, CheatEngine.SDK.Engine.Values.Address startAddress, CheatEngine.SDK.Engine.Values.Address stopAddress, string! protectionFlags, CheatEngine.SDK.Engine.Enums.FastScanMethod fastScanMethod, string! alignmentParameter, bool isHexadecimalInput, bool isNotBinaryString, bool isUnicodeScan, bool isCaseSensitive) -> void +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.Input1.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.Input2.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.IsCaseSensitive.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.IsHexadecimalInput.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.IsNotBinaryString.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.IsUnicodeScan.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.ProtectionFlags.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.RoundingType.get -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.ScanOption.get -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.StartAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.StopAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Scanning.Values.FoundList +CheatEngine.SDK.Engine.Scanning.Values.FoundList.Equals(CheatEngine.SDK.Engine.Scanning.Values.FoundList other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.FoundList() -> void +CheatEngine.SDK.Engine.Scanning.Values.FoundList.FoundList(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.Scanning.Values.FoundList.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Scanning.Values.FoundList.IsNull.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.TryGetAddress(int zeroBasedIndex, out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.TryGetAddressText(int zeroBasedIndex, out string? address) -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.TryGetCount(out ulong count) -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.TryGetValueText(int zeroBasedIndex, out string? value) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemScan +CheatEngine.SDK.Engine.Scanning.Values.MemScan.Equals(CheatEngine.SDK.Engine.Scanning.Values.MemScan other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemScan.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Scanning.Values.MemScan.IsNull.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemScan.MemScan() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemScan.MemScan(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanException +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanException.FailureKind.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanException.Operation.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.LuaError = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.MissingCapability = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.UnexpectedResult = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Dispose() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Reset() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.ResultCount.get -> ulong +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Results.get -> CheatEngine.SDK.Engine.Scanning.Values.FoundList +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Scanner.get -> CheatEngine.SDK.Engine.Scanning.Values.MemScan +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.StartFirstScan(in CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest request) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.StartNextScan(in CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest request) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.State.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryGetAddress(int zeroBasedIndex, out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryGetValue(int zeroBasedIndex, out string? value) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.WaitForCompletion() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.Disposed = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.Invalidated = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.New = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.ResultsReady = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.Scanning = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanStateException +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanStateException.Operation.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanStateException.State.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.Input1.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.Input2.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsCaseSensitive.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsHexadecimalInput.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsNotBinaryString.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsPercentageScan.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsUnicodeScan.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.NextScanRequest() -> void +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.NextScanRequest(CheatEngine.SDK.Engine.Enums.ScanOption scanOption, CheatEngine.SDK.Engine.Enums.RoundingType roundingType, string! input1, string! input2, bool isHexadecimalInput, bool isNotBinaryString, bool isUnicodeScan, bool isCaseSensitive, bool isPercentageScan, string? savedResultName = null) -> void +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.RoundingType.get -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.SavedResultName.get -> string? +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.ScanOption.get -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Values.Address.Add(long offset) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Values.Address.Address() -> void +CheatEngine.SDK.Engine.Values.Address.Address(ulong value) -> void +CheatEngine.SDK.Engine.Values.Address.CompareTo(CheatEngine.SDK.Engine.Values.Address other) -> int +CheatEngine.SDK.Engine.Values.Address.CompareTo(object? obj) -> int +CheatEngine.SDK.Engine.Values.Address.Equals(CheatEngine.SDK.Engine.Values.Address other) -> bool +CheatEngine.SDK.Engine.Values.Address.IsZero.get -> bool +CheatEngine.SDK.Engine.Values.Address.Subtract(long offset) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Values.Address.ToInt64() -> long +CheatEngine.SDK.Engine.Values.Address.ToString(string? format, System.IFormatProvider? formatProvider) -> string! +CheatEngine.SDK.Engine.Values.Address.ToUInt64() -> ulong +CheatEngine.SDK.Engine.Values.Address.TryFormat(System.Span utf8Destination, out int bytesWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) -> bool +CheatEngine.SDK.Engine.Values.Address.TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) -> bool +CheatEngine.SDK.Engine.Values.Address.Value.get -> ulong +CheatEngine.SDK.Engine.Values.IndexBase +CheatEngine.SDK.Engine.Values.LuaSequence +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState) +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).RawGetSequenceItem(int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).RawSequenceCount(int tableIndex) -> int +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).RawSetSequenceItem(int tableIndex, int zeroBasedIndex) -> void +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).TryGetSequenceItem(int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).TrySetSequenceItem(int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +abstract CheatEngine.SDK.Engine.Errors.EngineException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +const CheatEngine.SDK.Engine.Values.IndexBase.FirstLuaKey = 1 -> long +const CheatEngine.SDK.Engine.Values.IndexBase.FirstObjectIndex = 0 -> int +override CheatEngine.SDK.Engine.AddressList.AddressList.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.AddressList.AddressList.GetHashCode() -> int +override CheatEngine.SDK.Engine.AddressList.AddressList.ToString() -> string! +override CheatEngine.SDK.Engine.AddressList.MemoryRecord.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.AddressList.MemoryRecord.GetHashCode() -> int +override CheatEngine.SDK.Engine.AddressList.MemoryRecord.ToString() -> string! +override CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.AddressList.MemoryRecordId.GetHashCode() -> int +override CheatEngine.SDK.Engine.AddressList.MemoryRecordId.ToString() -> string! +override CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.GetHashCode() -> int +override CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.GetHashCode() -> int +override CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.ToString() -> string! +override CheatEngine.SDK.Engine.Errors.EngineBindingException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineLuaException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.MemorySize.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.MemorySize.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.MemorySize.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.ModuleInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.ModuleName.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.ModuleName.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.ModuleName.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.SymbolExpression.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.SymbolExpression.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.SymbolExpression.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.SymbolInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.TargetProcessId.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.TargetProcessId.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.TargetProcessId.ToString() -> string! +override CheatEngine.SDK.Engine.Memory.HostAddress.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Memory.HostAddress.GetHashCode() -> int +override CheatEngine.SDK.Engine.Memory.HostAddress.ToString() -> string! +override CheatEngine.SDK.Engine.Objects.CEObject.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Objects.CEObject.GetHashCode() -> int +override CheatEngine.SDK.Engine.Objects.CEObject.ToString() -> string! +override CheatEngine.SDK.Engine.Objects.Owned.ToString() -> string! +override CheatEngine.SDK.Engine.Objects.StringList.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Objects.StringList.GetHashCode() -> int +override CheatEngine.SDK.Engine.Objects.StringList.ToString() -> string! +override CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.ToString() -> string! +override CheatEngine.SDK.Engine.Runtime.PointerSize.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Runtime.PointerSize.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.ToString() -> string! +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.FoundList.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Scanning.Values.FoundList.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.FoundList.ToString() -> string! +override CheatEngine.SDK.Engine.Scanning.Values.MemScan.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Scanning.Values.MemScan.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.MemScan.ToString() -> string! +override CheatEngine.SDK.Engine.Values.Address.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Values.Address.GetHashCode() -> int +override CheatEngine.SDK.Engine.Values.Address.ToString() -> string! +static CheatEngine.SDK.Engine.AddressList.AddressList.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.AddressList.AddressList +static CheatEngine.SDK.Engine.AddressList.AddressList.Null.get -> CheatEngine.SDK.Engine.AddressList.AddressList +static CheatEngine.SDK.Engine.AddressList.AddressList.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.AddressList.AddressList value) -> void +static CheatEngine.SDK.Engine.AddressList.AddressList.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.AddressList.AddressList value) -> bool +static CheatEngine.SDK.Engine.AddressList.AddressList.operator !=(CheatEngine.SDK.Engine.AddressList.AddressList left, CheatEngine.SDK.Engine.AddressList.AddressList right) -> bool +static CheatEngine.SDK.Engine.AddressList.AddressList.operator ==(CheatEngine.SDK.Engine.AddressList.AddressList left, CheatEngine.SDK.Engine.AddressList.AddressList right) -> bool +static CheatEngine.SDK.Engine.AddressList.AddressListAccess.TryGetCurrent(out CheatEngine.SDK.Engine.AddressList.AddressList addressList) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.AddressList.MemoryRecord +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.Null.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecord +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.AddressList.MemoryRecord value) -> void +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.AddressList.MemoryRecord value) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.operator !=(CheatEngine.SDK.Engine.AddressList.MemoryRecord left, CheatEngine.SDK.Engine.AddressList.MemoryRecord right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.operator ==(CheatEngine.SDK.Engine.AddressList.MemoryRecord left, CheatEngine.SDK.Engine.AddressList.MemoryRecord right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Compare(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> int +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.AddressList.MemoryRecordId value) -> void +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.AddressList.MemoryRecordId value) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator !=(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator <(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator <=(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator ==(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator >(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator >=(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.operator !=(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest left, CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.operator ==(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest left, CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.operator !=(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize left, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.operator ==(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize left, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize right) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.BreakpointMethod value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.BreakpointTrigger value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.ContinueMethod value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.DuplicateHandling value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.FastScanMethod value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.MemoryProtection value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.RoundingType value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.ScanOption value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.VariableType value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.BreakpointMethod value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.BreakpointTrigger value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.ContinueMethod value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.DuplicateHandling value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.FastScanMethod value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.MemoryProtection value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.RoundingType value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.ScanOption value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.VariableType value) -> bool +static CheatEngine.SDK.Engine.Enums.EnumMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, TEnum value) -> void +static CheatEngine.SDK.Engine.Enums.EnumMarshaller.ToInt64(TEnum value) -> long +static CheatEngine.SDK.Engine.Enums.EnumMarshaller.TryFromInt64(long bits, out TEnum value) -> bool +static CheatEngine.SDK.Engine.Enums.EnumMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out TEnum value) -> bool +static CheatEngine.SDK.Engine.Errors.EngineException.RequireText(string! value, string! parameterName) -> string! +static CheatEngine.SDK.Engine.Generated.MemoryScalars.TryReadInt32(CheatEngine.SDK.Engine.Values.Address address, out int value) -> bool +static CheatEngine.SDK.Engine.Generated.MemoryScalars.TryReadInt64(CheatEngine.SDK.Engine.Values.Address address, out long value) -> bool +static CheatEngine.SDK.Engine.Generated.MemoryScalars.WriteInt32(CheatEngine.SDK.Engine.Values.Address address, int value) -> bool +static CheatEngine.SDK.Engine.Generated.MemoryScalars.WriteInt64(CheatEngine.SDK.Engine.Values.Address address, long value) -> bool +static CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.operator !=(CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions left, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions right) -> bool +static CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.operator ==(CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions left, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions right) -> bool +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateMemoryRegions(System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateModules(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateModules(System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateSections(CheatEngine.SDK.Engine.Inspection.ModuleName moduleName, System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateSections(CheatEngine.SDK.Engine.Values.Address moduleBase, System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.GetMemoryRegionInfo(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo region) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.GetSymbolInfo(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, out CheatEngine.SDK.Engine.Inspection.SymbolInfo symbol) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.ResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions options, out CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.operator !=(CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo left, CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.operator ==(CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo left, CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator !=(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator <(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator <=(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator ==(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator >(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator >=(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.operator !=(CheatEngine.SDK.Engine.Inspection.ModuleFileOffset left, CheatEngine.SDK.Engine.Inspection.ModuleFileOffset right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.operator ==(CheatEngine.SDK.Engine.Inspection.ModuleFileOffset left, CheatEngine.SDK.Engine.Inspection.ModuleFileOffset right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleInfo.operator !=(CheatEngine.SDK.Engine.Inspection.ModuleInfo left, CheatEngine.SDK.Engine.Inspection.ModuleInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleInfo.operator ==(CheatEngine.SDK.Engine.Inspection.ModuleInfo left, CheatEngine.SDK.Engine.Inspection.ModuleInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleName.operator !=(CheatEngine.SDK.Engine.Inspection.ModuleName left, CheatEngine.SDK.Engine.Inspection.ModuleName right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleName.operator ==(CheatEngine.SDK.Engine.Inspection.ModuleName left, CheatEngine.SDK.Engine.Inspection.ModuleName right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.operator !=(CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo left, CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.operator ==(CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo left, CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolExpression.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolExpression left, CheatEngine.SDK.Engine.Inspection.SymbolExpression right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolExpression.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolExpression left, CheatEngine.SDK.Engine.Inspection.SymbolExpression right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolInfo.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolInfo left, CheatEngine.SDK.Engine.Inspection.SymbolInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolInfo.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolInfo left, CheatEngine.SDK.Engine.Inspection.SymbolInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.TargetProcessId.operator !=(CheatEngine.SDK.Engine.Inspection.TargetProcessId left, CheatEngine.SDK.Engine.Inspection.TargetProcessId right) -> bool +static CheatEngine.SDK.Engine.Inspection.TargetProcessId.operator ==(CheatEngine.SDK.Engine.Inspection.TargetProcessId left, CheatEngine.SDK.Engine.Inspection.TargetProcessId right) -> bool +static CheatEngine.SDK.Engine.Memory.HostAddress.FromInt64(long value) -> CheatEngine.SDK.Engine.Memory.HostAddress +static CheatEngine.SDK.Engine.Memory.HostAddress.Zero.get -> CheatEngine.SDK.Engine.Memory.HostAddress +static CheatEngine.SDK.Engine.Memory.HostAddress.operator !=(CheatEngine.SDK.Engine.Memory.HostAddress left, CheatEngine.SDK.Engine.Memory.HostAddress right) -> bool +static CheatEngine.SDK.Engine.Memory.HostAddress.operator ==(CheatEngine.SDK.Engine.Memory.HostAddress left, CheatEngine.SDK.Engine.Memory.HostAddress right) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadBytes(CheatEngine.SDK.Engine.Memory.HostAddress address, System.Span destination, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadDouble(CheatEngine.SDK.Engine.Memory.HostAddress address, out double value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadInt16(CheatEngine.SDK.Engine.Memory.HostAddress address, out short value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadInt32(CheatEngine.SDK.Engine.Memory.HostAddress address, out int value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadInt64(CheatEngine.SDK.Engine.Memory.HostAddress address, out long value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadInt8(CheatEngine.SDK.Engine.Memory.HostAddress address, out sbyte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadPointer(CheatEngine.SDK.Engine.Memory.HostAddress address, out CheatEngine.SDK.Engine.Memory.HostAddress value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadSingle(CheatEngine.SDK.Engine.Memory.HostAddress address, out float value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadString(CheatEngine.SDK.Engine.Memory.HostAddress address, int maximumLength, bool wideCharacter, out string? value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUInt16(CheatEngine.SDK.Engine.Memory.HostAddress address, out ushort value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUInt32(CheatEngine.SDK.Engine.Memory.HostAddress address, out uint value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUInt64(CheatEngine.SDK.Engine.Memory.HostAddress address, out ulong value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUInt8(CheatEngine.SDK.Engine.Memory.HostAddress address, out byte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUtf8(CheatEngine.SDK.Engine.Memory.HostAddress address, int maximumLength, System.Span destination, bool wideCharacter, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteBytes(CheatEngine.SDK.Engine.Memory.HostAddress address, System.ReadOnlySpan value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteDouble(CheatEngine.SDK.Engine.Memory.HostAddress address, double value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteInt16(CheatEngine.SDK.Engine.Memory.HostAddress address, short value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteInt32(CheatEngine.SDK.Engine.Memory.HostAddress address, int value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteInt64(CheatEngine.SDK.Engine.Memory.HostAddress address, long value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteInt8(CheatEngine.SDK.Engine.Memory.HostAddress address, sbyte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWritePointer(CheatEngine.SDK.Engine.Memory.HostAddress address, CheatEngine.SDK.Engine.Memory.HostAddress value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteSingle(CheatEngine.SDK.Engine.Memory.HostAddress address, float value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteString(CheatEngine.SDK.Engine.Memory.HostAddress address, System.ReadOnlySpan value, bool wideCharacter, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUInt16(CheatEngine.SDK.Engine.Memory.HostAddress address, ushort value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUInt32(CheatEngine.SDK.Engine.Memory.HostAddress address, uint value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUInt64(CheatEngine.SDK.Engine.Memory.HostAddress address, ulong value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUInt8(CheatEngine.SDK.Engine.Memory.HostAddress address, byte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUtf8(CheatEngine.SDK.Engine.Memory.HostAddress address, System.ReadOnlySpan value, bool wideCharacter, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadBytes(CheatEngine.SDK.Engine.Values.Address address, System.Span destination, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadDouble(CheatEngine.SDK.Engine.Values.Address address, out double value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadInt16(CheatEngine.SDK.Engine.Values.Address address, out short value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadInt32(CheatEngine.SDK.Engine.Values.Address address, out int value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadInt64(CheatEngine.SDK.Engine.Values.Address address, out long value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadInt8(CheatEngine.SDK.Engine.Values.Address address, out sbyte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadPointer(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Values.Address value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadSingle(CheatEngine.SDK.Engine.Values.Address address, out float value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadString(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, bool wideCharacter, out string? value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUInt16(CheatEngine.SDK.Engine.Values.Address address, out ushort value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUInt32(CheatEngine.SDK.Engine.Values.Address address, out uint value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUInt64(CheatEngine.SDK.Engine.Values.Address address, out ulong value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUInt8(CheatEngine.SDK.Engine.Values.Address address, out byte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUtf8(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, System.Span destination, bool wideCharacter, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteBytes(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteDouble(CheatEngine.SDK.Engine.Values.Address address, double value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteInt16(CheatEngine.SDK.Engine.Values.Address address, short value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteInt32(CheatEngine.SDK.Engine.Values.Address address, int value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteInt64(CheatEngine.SDK.Engine.Values.Address address, long value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteInt8(CheatEngine.SDK.Engine.Values.Address address, sbyte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWritePointer(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Values.Address value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteSingle(CheatEngine.SDK.Engine.Values.Address address, float value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteString(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan value, bool wideCharacter, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUInt16(CheatEngine.SDK.Engine.Values.Address address, ushort value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUInt32(CheatEngine.SDK.Engine.Values.Address address, uint value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUInt64(CheatEngine.SDK.Engine.Values.Address address, ulong value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUInt8(CheatEngine.SDK.Engine.Values.Address address, byte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUtf8(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan value, bool wideCharacter, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Objects.CEObject.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Objects.CEObject +static CheatEngine.SDK.Engine.Objects.CEObject.Null.get -> CheatEngine.SDK.Engine.Objects.CEObject +static CheatEngine.SDK.Engine.Objects.CEObject.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Objects.CEObject value) -> void +static CheatEngine.SDK.Engine.Objects.CEObject.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Objects.CEObject value) -> bool +static CheatEngine.SDK.Engine.Objects.CEObject.operator !=(CheatEngine.SDK.Engine.Objects.CEObject left, CheatEngine.SDK.Engine.Objects.CEObject right) -> bool +static CheatEngine.SDK.Engine.Objects.CEObject.operator ==(CheatEngine.SDK.Engine.Objects.CEObject left, CheatEngine.SDK.Engine.Objects.CEObject right) -> bool +static CheatEngine.SDK.Engine.Objects.StringList.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Objects.StringList +static CheatEngine.SDK.Engine.Objects.StringList.Null.get -> CheatEngine.SDK.Engine.Objects.StringList +static CheatEngine.SDK.Engine.Objects.StringList.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Objects.StringList value) -> void +static CheatEngine.SDK.Engine.Objects.StringList.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Objects.StringList value) -> bool +static CheatEngine.SDK.Engine.Objects.StringList.operator !=(CheatEngine.SDK.Engine.Objects.StringList left, CheatEngine.SDK.Engine.Objects.StringList right) -> bool +static CheatEngine.SDK.Engine.Objects.StringList.operator ==(CheatEngine.SDK.Engine.Objects.StringList left, CheatEngine.SDK.Engine.Objects.StringList right) -> bool +static CheatEngine.SDK.Engine.Objects.StringLists.TryCreate(out CheatEngine.SDK.Engine.Objects.Owned? list) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Ce77010621.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator !=(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator <(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator <=(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator ==(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator >(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator >=(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.PointerSize.Bit32.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +static CheatEngine.SDK.Engine.Runtime.PointerSize.Bit64.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +static CheatEngine.SDK.Engine.Runtime.PointerSize.FromArchitecture(CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture architecture) -> CheatEngine.SDK.Engine.Runtime.PointerSize +static CheatEngine.SDK.Engine.Runtime.PointerSize.Unknown.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +static CheatEngine.SDK.Engine.Runtime.PointerSize.operator !=(CheatEngine.SDK.Engine.Runtime.PointerSize left, CheatEngine.SDK.Engine.Runtime.PointerSize right) -> bool +static CheatEngine.SDK.Engine.Runtime.PointerSize.operator ==(CheatEngine.SDK.Engine.Runtime.PointerSize left, CheatEngine.SDK.Engine.Runtime.PointerSize right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Create(System.ReadOnlySpan entries) -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Empty.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.operator !=(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.operator ==(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Unknown.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.operator !=(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.operator ==(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.CheatEngineVersion.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.operator !=(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.operator ==(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TryDecodeSystemArchitecture(int code, out CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture architecture) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TryDecodeTargetAbi(int code, out CheatEngine.SDK.Engine.Runtime.TargetAbi abi) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.Default.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.operator !=(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.operator ==(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScan(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, out CheatEngine.SDK.Engine.Objects.Owned? results) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScan(string! pattern, out CheatEngine.SDK.Engine.Objects.Owned? results) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.ExactValue(CheatEngine.SDK.Engine.Enums.VariableType variableType, string! input) -> CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest +static CheatEngine.SDK.Engine.Scanning.Values.FoundList.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Scanning.Values.FoundList +static CheatEngine.SDK.Engine.Scanning.Values.FoundList.operator !=(CheatEngine.SDK.Engine.Scanning.Values.FoundList left, CheatEngine.SDK.Engine.Scanning.Values.FoundList right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.FoundList.operator ==(CheatEngine.SDK.Engine.Scanning.Values.FoundList left, CheatEngine.SDK.Engine.Scanning.Values.FoundList right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemScan.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Scanning.Values.MemScan +static CheatEngine.SDK.Engine.Scanning.Values.MemScan.operator !=(CheatEngine.SDK.Engine.Scanning.Values.MemScan left, CheatEngine.SDK.Engine.Scanning.Values.MemScan right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemScan.operator ==(CheatEngine.SDK.Engine.Scanning.Values.MemScan left, CheatEngine.SDK.Engine.Scanning.Values.MemScan right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Adopt(CheatEngine.SDK.Engine.Objects.Owned! scanner, CheatEngine.SDK.Engine.Objects.Owned! foundList) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession! +static CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.ExactValue(string! input) -> CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest +static CheatEngine.SDK.Engine.Values.Address.FromInt64(long bits) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.FromUInt64(ulong value) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.Parse(System.ReadOnlySpan text) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.Parse(string! text) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Values.Address value) -> void +static CheatEngine.SDK.Engine.Values.Address.TryParse(System.ReadOnlySpan utf8, out CheatEngine.SDK.Engine.Values.Address address) -> bool +static CheatEngine.SDK.Engine.Values.Address.TryParse(System.ReadOnlySpan text, out CheatEngine.SDK.Engine.Values.Address address) -> bool +static CheatEngine.SDK.Engine.Values.Address.TryParse(string? text, out CheatEngine.SDK.Engine.Values.Address address) -> bool +static CheatEngine.SDK.Engine.Values.Address.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Values.Address value) -> bool +static CheatEngine.SDK.Engine.Values.Address.Zero.get -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.explicit operator ulong(CheatEngine.SDK.Engine.Values.Address address) -> ulong +static CheatEngine.SDK.Engine.Values.Address.implicit operator CheatEngine.SDK.Engine.Values.Address(ulong value) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.operator !=(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator +(CheatEngine.SDK.Engine.Values.Address address, long offset) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.operator -(CheatEngine.SDK.Engine.Values.Address address, long offset) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.operator <(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator <=(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator ==(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator >(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator >=(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.IndexBase.FromLuaKey(long luaKey) -> int +static CheatEngine.SDK.Engine.Values.IndexBase.ToLuaKey(int zeroBasedIndex) -> long +static CheatEngine.SDK.Engine.Values.IndexBase.TryFromLuaKey(long luaKey, out int zeroBasedIndex) -> bool +static CheatEngine.SDK.Engine.Values.LuaSequence.RawGetSequenceItem(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.State.LuaType +static CheatEngine.SDK.Engine.Values.LuaSequence.RawSequenceCount(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex) -> int +static CheatEngine.SDK.Engine.Values.LuaSequence.RawSetSequenceItem(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex, int zeroBasedIndex) -> void +static CheatEngine.SDK.Engine.Values.LuaSequence.TryGetSequenceItem(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Engine.Values.LuaSequence.TrySetSequenceItem(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +~override CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.ModuleInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.ModuleInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.SymbolInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.SymbolInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.ToString() -> string +~override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ToString() -> string diff --git a/libs/CheatEngine.SDK.Engine/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Engine/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..ecb80eeb --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/PublicAPI.Unshipped.txt @@ -0,0 +1,968 @@ +#nullable enable +*REMOVED*CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.AddressResolutionOptions(bool UseHostSymbolTable = false, bool Shallow = false) -> void +*REMOVED*CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Deconstruct(out bool UseHostSymbolTable, out bool Shallow) -> void +*REMOVED*CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.UseHostSymbolTable.get -> bool +*REMOVED*CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.UseHostSymbolTable.init -> void +*REMOVED*CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall = 4 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +*REMOVED*CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult = 6 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +*REMOVED*CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed = 5 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.AddressList.AddressListMutations +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetActive(out bool active) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetAsync(out bool isAsync) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetAsyncProcessing(out bool processing) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetOffsetCount(out int offsetCount) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetScript(out string! script) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.ActiveAfter.get -> bool? +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.ActiveBefore.get -> bool? +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.AsyncProcessingAfter.get -> bool? +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.Effect.get -> CheatEngine.SDK.Engine.Objects.EngineEffectState +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.Kind.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.MemoryRecordActivationOutcome() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.Problem.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome.RequestedActive.get -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind.Applied = 1 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind.Indeterminate = 5 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind.NotAttempted = 6 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind.Pending = 4 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind.RefusedByHost = 3 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind.Unchanged = 2 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind.Unknown = 0 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect.Completed = 1 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect.Indeterminate = 2 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect.NotAttempted = 0 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.Effect.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.IsCompleted.get -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.MemoryRecordMutationOutcome() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.Problem.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.AddressListUnavailable = 2 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.CycleDetected = 6 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.GlobalUnavailable = 8 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.InvalidResult = 10 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.LuaFailure = 9 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.None = 1 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.ParentNotFound = 4 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.RecordNotFound = 3 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.RuntimeIdentityChanged = 12 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.SelfParent = 5 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.TableLoadInProgress = 11 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.TraversalLimitReached = 7 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.Uninitialized = 0 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit +CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit.MaximumHops.get -> int +CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit.MemoryRecordParentTraversalLimit() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit.MemoryRecordParentTraversalLimit(int maximumHops) -> void +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.LastReleaseOutcome.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Origin.get -> CheatEngine.SDK.Engine.Objects.EngineResourceOrigin +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.ReleaseWithOutcome() -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.ReleaseWithTargetOutcome() -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.TargetIncarnation.get -> CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation +CheatEngine.SDK.Engine.Allocation.ITargetBoundMemoryAllocationOperations +CheatEngine.SDK.Engine.Allocation.ITargetBoundMemoryAllocationOperations.AllocateBoundWithOutcome(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request, out CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation incarnation, out CheatEngine.SDK.Engine.Targets.TargetSelectionObservation observation) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +CheatEngine.SDK.Engine.Allocation.ITargetBoundMemoryAllocationOperations.DeallocateBoundWithOutcome(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation expected, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size, out CheatEngine.SDK.Engine.Targets.TargetIdentityCheck targetCheck) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.ITargetBoundMemoryAllocationOperations.TryDeallocateBound(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation expected, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size, out CheatEngine.SDK.Engine.Targets.TargetIdentityCheck targetCheck) -> bool +CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome +CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome.Allocation.get -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome.Compensation.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome? +CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome.Effect.get -> CheatEngine.SDK.Engine.Objects.EngineEffectState +CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome.HasOwner.get -> bool +CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome.TargetAllocationAcquireOutcome() -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome.TargetObservation.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Equals(CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome other) -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Operation.get -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.TargetMemoryAllocationOutcome() -> void +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator.TargetMemoryAllocator() -> void +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator.TryAllocate(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request, out CheatEngine.SDK.Engine.Allocation.AllocatedRegion? region) -> CheatEngine.SDK.Engine.Allocation.TargetAllocationAcquireOutcome +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Equals(CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome other) -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.FailureKind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind? +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.IsExpectedFailure.get -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Kind.get -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.TargetMemoryOperationOutcome() -> void +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.BindingFailure = 6 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.CapabilityUnavailable = 4 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.ExpectedFailure = 2 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.GlobalUnavailable = 3 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.MarshallingFailure = 7 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.ProtectedLuaFailure = 5 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.Succeeded = 1 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.TargetIdentityMismatch = 9 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable = 8 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.Unspecified = 0 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Assembly.AssemblePreference +CheatEngine.SDK.Engine.Assembly.AssemblePreference.Far = 3 -> CheatEngine.SDK.Engine.Assembly.AssemblePreference +CheatEngine.SDK.Engine.Assembly.AssemblePreference.Long = 2 -> CheatEngine.SDK.Engine.Assembly.AssemblePreference +CheatEngine.SDK.Engine.Assembly.AssemblePreference.None = 0 -> CheatEngine.SDK.Engine.Assembly.AssemblePreference +CheatEngine.SDK.Engine.Assembly.AssemblePreference.Short = 1 -> CheatEngine.SDK.Engine.Assembly.AssemblePreference +CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo +CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.AutoAssemblerAllocationInfo() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.Equals(CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo other) -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.Name.get -> string! +CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.PreferredAddress.get -> CheatEngine.SDK.Engine.Values.Address? +CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.Size.get -> long +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.AutoAssemblerApplyOutcome() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.Compensation.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome? +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.DisableInfo.get -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot? +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.Effect.get -> CheatEngine.SDK.Engine.Objects.EngineEffectState +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.HasHostWarnings.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.HasPatch.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.HostText.get -> string? +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.HostTextTruncated.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.HostWarnings.get -> string? +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.HostWarningsTruncated.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.Kind.get -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.PostEffectTargetCheck.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck? +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome.TargetObservation.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.Applied = 1 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.AppliedTargetChanged = 2 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.GlobalUnavailable = 4 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.HandoffFailed = 8 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.InvalidResult = 6 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure = 5 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.Rejected = 3 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable = 7 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind.Unknown = 0 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome.AutoAssemblerCheckOutcome() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome.HostText.get -> string? +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome.HostTextTruncated.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome.IsAccepted.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome.Kind.get -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind.Accepted = 1 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind.GlobalUnavailable = 3 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind.InvalidResult = 5 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind.ProtectedLuaFailure = 4 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind.Rejected = 2 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind.Unknown = 0 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot.Allocations.get -> System.Collections.Generic.IReadOnlyList! +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot.ExceptionRanges.get -> System.Collections.Generic.IReadOnlyList! +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot.HasCCodeSymbolList.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot.RegisteredSymbols.get -> System.Collections.Generic.IReadOnlyList! +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot.Status.get -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot.Symbols.get -> System.Collections.Generic.IReadOnlyList! +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus.Complete = 1 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus.Malformed = 3 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus.Truncated = 2 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus +CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus.Unknown = 0 -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.AutoAssemblerOptions() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.CaptureHostText.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.CaptureHostText.init -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxDisableInfoEntries.get -> int +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxDisableInfoEntries.init -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxDisableInfoNameBytes.get -> int +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxDisableInfoNameBytes.init -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxHostTextBytes.get -> int +CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxHostTextBytes.init -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.DisableInfo.get -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshot! +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.Dispose() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.IsDisposed.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.IsEnabled.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.LastReleaseOutcome.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.Origin.get -> CheatEngine.SDK.Engine.Objects.EngineResourceOrigin +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.PostApplyTargetCheck.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck? +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.Release() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.ReleaseWithTargetOutcome() -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.RequiresManualRecovery.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.TargetIncarnation.get -> CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher +CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo +CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.AutoAssemblerSymbolInfo() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.Equals(CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo other) -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.Name.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionAssembler +CheatEngine.SDK.Engine.Assembly.InstructionAssembly +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Deconstruct(out CheatEngine.SDK.Engine.Inspection.TargetProcessId Target, out CheatEngine.SDK.Engine.Assembly.InstructionProfile Profile, out CheatEngine.SDK.Engine.Values.Address Origin, out CheatEngine.SDK.Engine.Assembly.AssemblePreference Preference, out bool SkipRangeCheck, out int Written, out int RequiredLength) -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Equals(CheatEngine.SDK.Engine.Assembly.InstructionAssembly other) -> bool +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.InstructionAssembly() -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.InstructionAssembly(CheatEngine.SDK.Engine.Inspection.TargetProcessId Target, CheatEngine.SDK.Engine.Assembly.InstructionProfile Profile, CheatEngine.SDK.Engine.Values.Address Origin, CheatEngine.SDK.Engine.Assembly.AssemblePreference Preference, bool SkipRangeCheck, int Written, int RequiredLength) -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Origin.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Origin.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Preference.get -> CheatEngine.SDK.Engine.Assembly.AssemblePreference +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Preference.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Profile.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Profile.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.RequiredLength.get -> int +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.RequiredLength.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.SkipRangeCheck.get -> bool +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.SkipRangeCheck.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Target.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Target.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Written.get -> int +CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Written.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembler +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Address.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.AddressText.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.AddressText.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Bytes.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Bytes.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Deconstruct(out CheatEngine.SDK.Engine.Values.Address Address, out string! AddressText, out string! Bytes, out string! Opcode, out string! Extra, out int Utf8ByteLength) -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Equals(CheatEngine.SDK.Engine.Assembly.InstructionDisassembly other) -> bool +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Extra.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Extra.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.InstructionDisassembly() -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.InstructionDisassembly(CheatEngine.SDK.Engine.Values.Address Address, string! AddressText, string! Bytes, string! Opcode, string! Extra, int Utf8ByteLength) -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Opcode.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Opcode.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Utf8ByteLength.get -> int +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Utf8ByteLength.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionNavigator +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.AddressExceedsProfileWidth = 3 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.DestinationTooSmall = 6 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.GlobalUnavailable = 9 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.InstructionRejected = 8 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.InvalidProfile = 2 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.InvalidResult = 11 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.LuaFailure = 10 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.OutputTooLong = 7 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.Success = 1 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.TargetChanged = 5 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.TargetNotSelected = 4 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.Unknown = 0 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.UnsupportedTargetBackend = 12 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionProfile +CheatEngine.SDK.Engine.Assembly.InstructionProfile.AddressWidth.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Assembly.InstructionProfile.Architecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Assembly.InstructionProfile.Equals(CheatEngine.SDK.Engine.Assembly.InstructionProfile other) -> bool +CheatEngine.SDK.Engine.Assembly.InstructionProfile.InstructionProfile() -> void +CheatEngine.SDK.Engine.Assembly.InstructionProfile.IsValid.get -> bool +CheatEngine.SDK.Engine.Assembly.InstructionProfiles +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.Equals(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile other) -> bool +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.InstructionTargetProfile() -> void +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.Profile.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.Target.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Errors.EngineFailureKind.TargetIdentityMismatch = 7 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.TargetIdentityUnavailable = 6 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException +CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException.CleanupOutcome.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException.EngineResourceHandoffException(string! operation, CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome cleanupOutcome, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException.Operation.get -> string! +CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException +CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException.Check.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck +CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException.EngineTargetIdentityException(string! operation, CheatEngine.SDK.Engine.Targets.TargetIdentityCheck check) -> void +CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException.Operation.get -> string! +CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.AddressResolutionOptions(bool Shallow = false) -> void +CheatEngine.SDK.Engine.Inspection.SymbolList +CheatEngine.SDK.Engine.Inspection.SymbolList.Equals(CheatEngine.SDK.Engine.Inspection.SymbolList other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolList.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Inspection.SymbolList.IsNull.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolList.SymbolList() -> void +CheatEngine.SDK.Engine.Inspection.SymbolList.SymbolList(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.Inspection.SymbolList.TryAddSymbol(string! moduleName, string! searchKey, CheatEngine.SDK.Engine.Values.Address address, int size) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolList.TryClear() -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolList.TryDeleteSymbol(CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolList.TryDeleteSymbol(string! searchKey) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolList.TryGetName(out string? name) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolList.TryGetProcessId(out int processId) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolList.TryGetSymbolFromAddress(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Inspection.SymbolInfo symbol) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolList.TryGetSymbolFromString(string! searchKey, out CheatEngine.SDK.Engine.Inspection.SymbolInfo symbol) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolList.TrySetName(string! name) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationHandoffException +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationHandoffException.CleanupOutcome.get -> CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationHandoffException.SymbolListRegistrationHandoffException(CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome cleanupOutcome, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationLease +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationLease.Dispose() -> void +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationLease.IsTerminal.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationLease.List.get -> CheatEngine.SDK.Engine.Inspection.SymbolList +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationLease.Origin.get -> CheatEngine.SDK.Engine.Objects.EngineResourceOrigin +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationLease.RegistrationConfirmed.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationLease.Release() -> CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome.IsTerminal.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome.ListRelease.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome.SymbolListRegistrationReleaseOutcome() -> void +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome.UnregisterKind.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationReleaseOutcome.UnregisterStatus.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus? +CheatEngine.SDK.Engine.Inspection.SymbolLists +CheatEngine.SDK.Engine.Inspection.SymbolName +CheatEngine.SDK.Engine.Inspection.SymbolName.Equals(CheatEngine.SDK.Engine.Inspection.SymbolName other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolName.SymbolName() -> void +CheatEngine.SDK.Engine.Inspection.SymbolName.SymbolName(string! value) -> void +CheatEngine.SDK.Engine.Inspection.SymbolName.Value.get -> string! +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome.HasLease.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome.Lease.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease? +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome.Status.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome.SymbolRegistrationAcquireOutcome() -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException.CleanupOutcome.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException.SymbolRegistrationHandoffException(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome cleanupOutcome, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Dispose() -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.IsTerminal.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Name.get -> CheatEngine.SDK.Engine.Inspection.SymbolName +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Options.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Origin.get -> CheatEngine.SDK.Engine.Objects.EngineResourceOrigin +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Release() -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.Deconstruct(out bool DoNotSave) -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.DoNotSave.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.DoNotSave.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.Equals(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.SymbolRegistrationOptions() -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.SymbolRegistrationOptions(bool DoNotSave = false) -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.AlreadyReleased = 2 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.CleanupIndeterminate = 6 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.CleanupUnavailable = 5 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.ExternallyRemoved = 8 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.Released = 1 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.Replaced = 7 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.StaleRuntime = 4 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.Superseded = 3 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.Unknown = 0 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome.IsTerminal.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome.Kind.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome.Status.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus? +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome.SymbolRegistrationReleaseOutcome() -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistry +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall = 5 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult = 9 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.PartialRead = 4 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.PointerValueExceedsTargetWidth = 7 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.PointerWidthUnknown = 6 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed = 8 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Objects.EngineEffectState +CheatEngine.SDK.Engine.Objects.EngineEffectState.Applied = 3 -> CheatEngine.SDK.Engine.Objects.EngineEffectState +CheatEngine.SDK.Engine.Objects.EngineEffectState.NotApplied = 2 -> CheatEngine.SDK.Engine.Objects.EngineEffectState +CheatEngine.SDK.Engine.Objects.EngineEffectState.NotStarted = 1 -> CheatEngine.SDK.Engine.Objects.EngineEffectState +CheatEngine.SDK.Engine.Objects.EngineEffectState.Unknown = 0 -> CheatEngine.SDK.Engine.Objects.EngineEffectState +CheatEngine.SDK.Engine.Objects.EngineResourceOrigin +CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.EngineResourceOrigin() -> void +CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.Equals(CheatEngine.SDK.Engine.Objects.EngineResourceOrigin other) -> bool +CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.IsCurrentRuntime.get -> bool +CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.IsTargetBound.get -> bool +CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.Runtime.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.Target.get -> CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation? +CheatEngine.SDK.Engine.Objects.Owned.LastReleaseOutcome.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Objects.Owned.Origin.get -> CheatEngine.SDK.Engine.Objects.EngineResourceOrigin +CheatEngine.SDK.Engine.Objects.Owned.ReleaseWithOutcome() -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.CurrentProcessObservation() -> void +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.CurrentProcessObservation(CheatEngine.SDK.Engine.Inspection.TargetProcessId Id, CheatEngine.SDK.Engine.Runtime.PointerSize PointerSize) -> void +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Deconstruct(out CheatEngine.SDK.Engine.Inspection.TargetProcessId Id, out CheatEngine.SDK.Engine.Runtime.PointerSize PointerSize) -> void +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Equals(CheatEngine.SDK.Engine.Processes.CurrentProcessObservation other) -> bool +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Id.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Id.init -> void +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.PointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.PointerSize.init -> void +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.Equals(CheatEngine.SDK.Engine.Processes.ProcessOperationStatus other) -> bool +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.Kind.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.ProcessOperationStatus() -> void +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.FileAsProcessTarget = 8 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.GlobalUnavailable = 4 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.InvalidResult = 6 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.ProtectedLuaFailure = 5 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.SelectionNotConfirmed = 3 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.Success = 1 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.TargetChanged = 7 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.TargetNotAttached = 2 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.Unknown = 0 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.RuntimeHostOperations +CheatEngine.SDK.Engine.Processes.RuntimeObservations +CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.CheatEngineHostObservation() -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.CheatEngineHostObservation(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? FileVersion, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture SystemArchitecture, bool? CheatEngineIs64Bit, CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem OperatingSystem) -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.CheatEngineIs64Bit.get -> bool? +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.CheatEngineIs64Bit.init -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.Deconstruct(out CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? FileVersion, out CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture SystemArchitecture, out bool? CheatEngineIs64Bit, out CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem OperatingSystem) -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.Equals(CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation other) -> bool +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.FileVersion.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.FileVersion.init -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.OperatingSystem.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.OperatingSystem.init -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.SystemArchitecture.init -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem +CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem.Linux = 3 -> CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem +CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem.MacOS = 2 -> CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem +CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem +CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem.Windows = 1 -> CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem +CheatEngine.SDK.Engine.Runtime.PointerSize.TryReadLittleEndian(System.ReadOnlySpan source, out ulong value) -> bool +CheatEngine.SDK.Engine.Runtime.PointerSize.TryWriteLittleEndian(ulong value, System.Span destination) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.Host.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation? +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.RuntimeInfo(CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation host, CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation? target, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! capabilities) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.Target.get -> CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation? +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.Abi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.AbiCode.get -> int? +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.Architecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.Backend.get -> CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.Bitness.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.ConfiguredPointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.ConfiguredPointerSizeBytes.get -> int? +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.ConfiguredPointerSizeDiffersFromBitness.get -> bool? +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.Equals(CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation other) -> bool +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.IsAndroid.get -> bool? +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.IsArmFamily.get -> bool? +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.IsX86Family.get -> bool? +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.ProcessId.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.TargetArchitectureObservation() -> void +CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.TargetArchitectureObservation(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, CheatEngine.SDK.Engine.Runtime.TargetBackend backend, CheatEngine.SDK.Engine.Runtime.PointerSize bitness, bool? isX86Family, bool? isArmFamily, bool? isAndroid, int? abiCode, int? configuredPointerSizeBytes) -> void +CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.SDK.Engine.Runtime.TargetBackend.CEServer = 3 -> CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.SDK.Engine.Runtime.TargetBackend.FileAsProcess = 2 -> CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.SDK.Engine.Runtime.TargetBackend.LocalProcess = 1 -> CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.SDK.Engine.Runtime.TargetBackend.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.Cancelled = 12 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.HostReportedError = 7 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.InvalidBounds = 3 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.InvalidResult = 8 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.Matches = 1 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.NoMatches = 2 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.RuntimeInvalidated = 11 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.ScanFailed = 5 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.SessionCreationFailed = 4 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.TargetChanged = 9 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.TargetIdentityUnavailable = 10 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind.WaitTimedOut = 6 -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.AobBoundedScanResult() -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.AtOrAfterStopSkipped.get -> ulong +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.BelowStartSkipped.get -> ulong +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.CopyElapsed.get -> System.TimeSpan +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.Creation.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.Equals(CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult other) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.HostErrorText.get -> string? +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.HostResultCount.get -> ulong +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.HostScanElapsed.get -> System.TimeSpan +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.InBoundsCountIsExact.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.IsHostErrorTextTruncated.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.IsHostErrorTextUnreadable.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.IsMaterializationLimitReached.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.Kind.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.Release.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.RowsRead.get -> ulong +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.Termination.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.TotalElapsed.get -> System.TimeSpan +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.UnreadHostRows.get -> ulong +CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.Written.get -> int +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.Cancelled = 11 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.Found = 1 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.FoundOutsideBounds = 3 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.InvalidBounds = 4 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.InvalidResult = 7 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.NotFound = 2 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.RuntimeInvalidated = 10 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.ScanFailed = 6 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.SessionCreationFailed = 5 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.TargetChanged = 8 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.TargetIdentityUnavailable = 9 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.AobFirstFoundResult() -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.Creation.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.Equals(CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult other) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.HasAddress.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.HostScanElapsed.get -> System.TimeSpan +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.Kind.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.Release.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds +CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.AobScanBounds() -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.Contains(CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.Equals(CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds other) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.IsValid.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.Length.get -> ulong +CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.Start.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.Stop.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.AobScanOutcome() -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.Equals(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome other) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.HasResultCount.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.Kind.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.ResultCount.get -> int +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.GlobalUnavailable = 3 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.InvalidResult = 6 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.Matches = 1 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.NoMatches = 2 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.NoResult = 5 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.ProtectedLuaFailure = 4 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.ResultListCountUnavailable = 7 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.GlobalUnavailable = 2 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.InvalidResult = 5 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.LuaFailure = 3 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.NoResult = 4 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.Success = 1 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext +CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.After.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.AobScanTargetContext() -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.Before.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.Equals(CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext other) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.IsSameQualifiedIncarnation.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone.CancelledBeforeNativeCall = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone.None = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone.ObservedAfterNativeCall = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.Equals(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.MemoryScanCreationOutcome() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.Status.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.TargetObservation.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.AliasedFoundList = 8 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.GlobalUnavailable = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.InvalidFoundListResult = 7 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.InvalidScannerResult = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.LuaFailure = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.NoFoundListResult = 6 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.NoScannerResult = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.RollbackUnconfirmed = 9 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.Success = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.TargetIdentityUnavailable = 10 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.RuntimeInvalidated = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.TargetIdentityMismatch = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.TargetIdentityUnavailable = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.None = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.ProtectedLuaFailure = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.RuntimeIdentityChanged = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.ScanTerminated = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.TargetChanged = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.TargetProcessReused = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.Cancelled = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.DestinationTooSmall = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.InvalidResult = 9 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.LuaFailure = 8 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.NoResults = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.PageStartOutOfRange = 10 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.RuntimeInvalidated = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.Success = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.TargetIdentityMismatch = 7 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.TargetIdentityUnavailable = 6 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.Equals(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.FoundList.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.FoundListOwnershipConsumed.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.MemScan.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.MemScanOwnershipConsumed.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.MemoryScanReleaseOutcome() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.OwnershipConsumed.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.Termination.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Address.init -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Deconstruct(out CheatEngine.SDK.Engine.Values.Address Address, out string! Value) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Equals(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.MemoryScanResult() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.MemoryScanResult(CheatEngine.SDK.Engine.Values.Address Address, string! Value) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Value.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Value.init -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Abandon() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.InvalidationReason.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.LastCancellationMilestone.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.LastReleaseOutcome.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.LastTargetCheck.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck? +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.ReleaseWithOutcome() -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.ResetCancellable(System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.RuntimeIdentity.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.StartFirstScanCancellable(in CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest request, System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.StartNextScanCancellable(in CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest request, System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TargetObservation.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryCopyResults(System.Span destination, out ulong totalCount, out int written) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryCopyResultsCancellable(System.Span destination, out ulong totalCount, out int written, System.Threading.CancellationToken cancellationToken) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryCopyResultsPage(int firstResultIndex, System.Span destination, out ulong totalCount, out int written) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryCopyResultsPageCancellable(int firstResultIndex, System.Span destination, out ulong totalCount, out int written, System.Threading.CancellationToken cancellationToken) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryGetHostErrorText(out string? text, out bool truncated) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.WaitForCompletionCancellable(System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus.Confirmed = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus.NotInvoked = 6 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus.NotRequired = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus.TerminateFailed = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus.WaitFailed = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus.WaitTimedOut = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.Completed = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.InitializationFailed = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.InvalidResult = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.LuaFailure = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.RuntimeInvalidated = 6 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.TargetIdentityMismatch = 8 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.TargetIdentityUnavailable = 7 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.TimedOut = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +CheatEngine.SDK.Engine.Tables.CheatTableFiles +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.Equals(CheatEngine.SDK.Engine.Targets.TargetIdentityCheck other) -> bool +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.IsCurrent.get -> bool +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.Kind.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.Observed.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.TargetIdentityCheck() -> void +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.BackendUnknown = 11 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.Current = 1 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.CurrentTargetUnqualified = 3 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.FileAsProcess = 10 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.GlobalUnavailable = 6 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.InvalidResult = 8 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.LuaFailure = 7 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.NoTargetSelected = 2 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.ProcessReused = 5 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.RemoteBackend = 9 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.TargetChanged = 4 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.Unspecified = 0 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence.CheatEngineSelectedProcessId = 1 -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence.LocalBackendConfirmed = 4 -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence.LocalProcessStartTime = 2 -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence.None = 0 -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.Equals(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation other) -> bool +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.ProcessId.get -> int +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.StartedAtUtcTicks.get -> long +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.TargetProcessIncarnation() -> void +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.Equals(CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome other) -> bool +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.FailureKind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind? +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.RequiresManualRecovery.get -> bool +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.Status.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.TargetCheck.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck? +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.TargetReleaseOutcome() -> void +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.NotInvoked = 7 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedIdentityUnavailable = 3 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedNoTarget = 2 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedProcessReused = 5 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedRuntimeChanged = 8 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedTargetChanged = 4 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.Released = 1 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.UnconfirmedAfterInvocation = 6 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.Unspecified = 0 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetSelection +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Backend.get -> CheatEngine.SDK.Engine.Runtime.TargetBackend +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Equals(CheatEngine.SDK.Engine.Targets.TargetSelectionObservation other) -> bool +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Evidence.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Incarnation.get -> CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation? +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.IsQualified.get -> bool +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.SelectedProcessId.get -> int? +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Status.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.TargetSelectionObservation() -> void +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.CurrentTargetBackendUnknown = 9 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.CurrentTargetFileAsProcess = 8 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.CurrentTargetQualified = 1 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.CurrentTargetRemoteBackend = 7 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.CurrentTargetUnqualified = 3 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.GlobalUnavailable = 4 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.InvalidResult = 6 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.LuaFailure = 5 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.NoTargetSelected = 2 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.Unspecified = 0 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +[CESDK5010]CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryTerminateScan(System.TimeSpan waitTimeout) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus +[CESDK5010]CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryWaitForCompletion(System.TimeSpan timeout) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus +[CESDK5010]static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanWithinBounds(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds bounds, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, System.TimeSpan waitTimeout, System.Span destination, System.Threading.CancellationToken cancellationToken) -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult +[CESDK5011]static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryFindFirstFoundWithinBounds(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds bounds, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, System.Threading.CancellationToken cancellationToken) -> CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult +const CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxDisableInfoEntriesLimit = 65536 -> int +const CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxDisableInfoNameBytesLimit = 4096 -> int +const CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MaxHostTextBytesLimit = 8192 -> int +const CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MinDisableInfoEntries = 1 -> int +const CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MinDisableInfoNameBytes = 1 -> int +const CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.MinHostTextBytes = 1 -> int +const CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.HostErrorTextMaximumUtf8Bytes = 1024 -> int +override CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.InstructionAssembly.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.InstructionProfile.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.GetHashCode() -> int +override CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Inspection.SymbolList.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.SymbolList.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.SymbolList.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationHandoffException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Inspection.SymbolName.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.SymbolName.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.SymbolName.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.GetHashCode() -> int +override CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.GetHashCode() -> int +override CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.GetHashCode() -> int +override CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.GetHashCode() -> int +override CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.GetHashCode() -> int +override CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.GetHashCode() -> int +override CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.GetHashCode() -> int +static CheatEngine.SDK.Engine.AddressList.AddressListMutations.Delete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId recordId) -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome +static CheatEngine.SDK.Engine.AddressList.AddressListMutations.SetActive(CheatEngine.SDK.Engine.AddressList.MemoryRecordId recordId, bool active) -> CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcome +static CheatEngine.SDK.Engine.AddressList.AddressListMutations.SetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId recordId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId) -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome +static CheatEngine.SDK.Engine.AddressList.AddressListMutations.SetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId recordId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId, CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit traversalLimit) -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome +static CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit.Default.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit +static CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Failed(CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome operation) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +static CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Succeeded(CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +static CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.operator !=(CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome left, CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.operator ==(CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome left, CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Failed(CheatEngine.SDK.Engine.Errors.EngineFailureKind failureKind, CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus = default(CheatEngine.SDK.Lua.Calls.LuaStatus)) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +static CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Succeeded() -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +static CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.operator !=(CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome left, CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.operator ==(CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome left, CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome right) -> bool +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.operator !=(CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo left, CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo right) -> bool +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.operator ==(CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo left, CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo right) -> bool +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions.Default.get -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions! +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher.Apply(string! script) -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch! +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher.TryApply(string! script, out CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch? patch) -> bool +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher.TryApplyWithOutcome(string! script, CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions! options, out CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch? patch) -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher.TryApplyWithOutcome(string! script, out CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch? patch) -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcome +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher.TryCheck(string! script, bool enable) -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher.TryCheck(string! script, bool enable, CheatEngine.SDK.Engine.Assembly.AutoAssemblerOptions! options) -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcome +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.operator !=(CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo left, CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo right) -> bool +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.operator ==(CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo left, CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionAssembler.TryAssemble(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, string! instruction, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Assembly.AssemblePreference preference, bool skipRangeCheck, System.Span destination, out CheatEngine.SDK.Engine.Assembly.InstructionAssembly assembly) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionAssembler.TryAssemble(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, string! instruction, CheatEngine.SDK.Engine.Values.Address address, System.Span destination, out int written, out int requiredLength) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionAssembly.operator !=(CheatEngine.SDK.Engine.Assembly.InstructionAssembly left, CheatEngine.SDK.Engine.Assembly.InstructionAssembly right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionAssembly.operator ==(CheatEngine.SDK.Engine.Assembly.InstructionAssembly left, CheatEngine.SDK.Engine.Assembly.InstructionAssembly right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionDisassembler.TryDisassemble(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, CheatEngine.SDK.Engine.Values.Address address, int maximumUtf8Bytes, out CheatEngine.SDK.Engine.Assembly.InstructionDisassembly instruction, out int requiredUtf8Bytes) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.operator !=(CheatEngine.SDK.Engine.Assembly.InstructionDisassembly left, CheatEngine.SDK.Engine.Assembly.InstructionDisassembly right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.operator ==(CheatEngine.SDK.Engine.Assembly.InstructionDisassembly left, CheatEngine.SDK.Engine.Assembly.InstructionDisassembly right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionNavigator.TryGetLength(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, CheatEngine.SDK.Engine.Values.Address address, out int length) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionNavigator.TryGetPrevious(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Values.Address previous) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.Arm32.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.Arm64.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.X64.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.X86.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.operator !=(CheatEngine.SDK.Engine.Assembly.InstructionProfile left, CheatEngine.SDK.Engine.Assembly.InstructionProfile right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.operator ==(CheatEngine.SDK.Engine.Assembly.InstructionProfile left, CheatEngine.SDK.Engine.Assembly.InstructionProfile right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionProfiles.TryObserveCurrent(out CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.operator !=(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile left, CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.operator ==(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile left, CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile right) -> bool +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.GetCheatEngineVersion() -> double +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.GetConfiguredPointerSizeBytes() -> int +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.GetOperatingSystemCode() -> int +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.GetSystemArchitectureCode() -> int +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.GetTargetAbiCode() -> int +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.IsCheatEngine64Bit() -> bool +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.IsConnectedToCEServer() -> bool +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.IsTarget64Bit() -> bool +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.IsTargetAndroid() -> bool +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.IsTargetArm() -> bool +static CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes.IsTargetX86() -> bool +static CheatEngine.SDK.Engine.Inspection.EngineInspection.ResolveHostAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions options, out CheatEngine.SDK.Engine.Memory.HostAddress address) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.SymbolList.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Inspection.SymbolList +static CheatEngine.SDK.Engine.Inspection.SymbolList.Null.get -> CheatEngine.SDK.Engine.Inspection.SymbolList +static CheatEngine.SDK.Engine.Inspection.SymbolList.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Inspection.SymbolList value) -> void +static CheatEngine.SDK.Engine.Inspection.SymbolList.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Inspection.SymbolList value) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolList.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolList left, CheatEngine.SDK.Engine.Inspection.SymbolList right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolList.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolList left, CheatEngine.SDK.Engine.Inspection.SymbolList right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolLists.TryCreate(out CheatEngine.SDK.Engine.Objects.Owned? list) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Inspection.SymbolLists.TryGetMain(out CheatEngine.SDK.Engine.Inspection.SymbolList list) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Inspection.SymbolLists.TryRegister(CheatEngine.SDK.Engine.Objects.Owned! list, out CheatEngine.SDK.Engine.Inspection.SymbolListRegistrationLease? lease) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Inspection.SymbolName.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Inspection.SymbolName value) -> void +static CheatEngine.SDK.Engine.Inspection.SymbolName.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Inspection.SymbolName value) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolName.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolName left, CheatEngine.SDK.Engine.Inspection.SymbolName right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolName.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolName left, CheatEngine.SDK.Engine.Inspection.SymbolName right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions left, CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions left, CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolRegistry.Register(CheatEngine.SDK.Engine.Inspection.SymbolName name, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions options = default(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions)) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Inspection.SymbolRegistry.TryGetName(CheatEngine.SDK.Engine.Values.Address address, out string? name) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Inspection.SymbolRegistry.TryRegisterOwned(CheatEngine.SDK.Engine.Inspection.SymbolName name, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions options = default(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions)) -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome +static CheatEngine.SDK.Engine.Inspection.SymbolRegistry.Unregister(CheatEngine.SDK.Engine.Inspection.SymbolName name) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadBytes(CheatEngine.SDK.Engine.Memory.HostAddress address, System.Span destination, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUtf8(CheatEngine.SDK.Engine.Memory.HostAddress address, int maximumLength, System.Span destination, bool wideCharacter, out int written, out int requiredLength, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteBytes(CheatEngine.SDK.Engine.Memory.HostAddress address, System.ReadOnlySpan value, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadBytes(CheatEngine.SDK.Engine.Values.Address address, System.Span destination, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadPointer(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Runtime.PointerSize pointerSize, out CheatEngine.SDK.Engine.Values.Address value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUtf8(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, System.Span destination, bool wideCharacter, out int written, out int requiredLength, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteBytes(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan value, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWritePointer(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Values.Address value, CheatEngine.SDK.Engine.Runtime.PointerSize pointerSize, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.operator !=(CheatEngine.SDK.Engine.Objects.EngineResourceOrigin left, CheatEngine.SDK.Engine.Objects.EngineResourceOrigin right) -> bool +static CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.operator ==(CheatEngine.SDK.Engine.Objects.EngineResourceOrigin left, CheatEngine.SDK.Engine.Objects.EngineResourceOrigin right) -> bool +static CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.operator !=(CheatEngine.SDK.Engine.Processes.CurrentProcessObservation left, CheatEngine.SDK.Engine.Processes.CurrentProcessObservation right) -> bool +static CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.operator ==(CheatEngine.SDK.Engine.Processes.CurrentProcessObservation left, CheatEngine.SDK.Engine.Processes.CurrentProcessObservation right) -> bool +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.FileAsProcessTarget.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.GlobalUnavailable.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.InvalidResult.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.ProtectedLuaFailure(CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.SelectionNotConfirmed.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.Success.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.TargetChanged.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.TargetNotAttached.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.operator !=(CheatEngine.SDK.Engine.Processes.ProcessOperationStatus left, CheatEngine.SDK.Engine.Processes.ProcessOperationStatus right) -> bool +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.operator ==(CheatEngine.SDK.Engine.Processes.ProcessOperationStatus left, CheatEngine.SDK.Engine.Processes.ProcessOperationStatus right) -> bool +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.ObserveHost(out CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation host) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetCheatEngineFileVersion(out CheatEngine.SDK.Engine.Runtime.CheatEngineVersion version) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetCheatEngineVersion(out double version) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetOperatingSystem(out CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem operatingSystem) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetSystemArchitecture(out CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture architecture) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetTargetAbi(out CheatEngine.SDK.Engine.Runtime.TargetAbi abi) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryIsCheatEngine64Bit(out bool is64Bit) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeObservations.TryObserveRuntimeInfo(out CheatEngine.SDK.Engine.Runtime.RuntimeInfo? info) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations.ObserveCurrent(out CheatEngine.SDK.Engine.Processes.CurrentProcessObservation observation) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations.ObserveTargetArchitecture(out CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation observation) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations.SelectAndObserve(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, out CheatEngine.SDK.Engine.Processes.CurrentProcessObservation observation) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations.TryGetConfiguredPointerSize(out int rawBytes, out CheatEngine.SDK.Engine.Runtime.PointerSize pointerSize) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.operator !=(CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation left, CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.operator ==(CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation left, CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.CheatEngineBitness.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.ConfiguredPointerSize.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.CurrentProcess.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.OperatingSystem.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.ProcessSelection.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.TargetAndroid.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.TargetBackend.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TryDecodeFileVersion(long packed, out CheatEngine.SDK.Engine.Runtime.CheatEngineVersion version) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TryDecodeOperatingSystem(int code, out CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem operatingSystem) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TryDeriveTargetArchitecture(bool isX86Family, bool isArmFamily, bool is64Bit, out CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture architecture) -> bool +static CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.operator !=(CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation left, CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation right) -> bool +static CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.operator ==(CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation left, CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.operator !=(CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult left, CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.operator ==(CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult left, CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.operator !=(CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult left, CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.operator ==(CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult left, CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.TryCreate(CheatEngine.SDK.Engine.Values.Address start, CheatEngine.SDK.Engine.Values.Address stop, out CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds bounds) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.TryFromModule(in CheatEngine.SDK.Engine.Inspection.ModuleInfo module, out CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds bounds) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.operator !=(CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.operator ==(CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.GlobalUnavailable.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.InvalidResult.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.Matches(int resultCount) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.NoMatches.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.NoResult.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.ProtectedLuaFailure(CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.ResultListCountUnavailable.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.operator !=(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.operator ==(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.operator !=(CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.operator ==(CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanDetailed(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, out CheatEngine.SDK.Engine.Objects.Owned? results) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanDetailed(string! pattern, out CheatEngine.SDK.Engine.Objects.Owned? results) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanOutcome(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, out CheatEngine.SDK.Engine.Objects.Owned? results) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanOutcome(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, out CheatEngine.SDK.Engine.Objects.Owned? results, out CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext targetContext) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanOutcome(string! pattern, out CheatEngine.SDK.Engine.Objects.Owned? results) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanWithinBounds(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds bounds, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, System.Span destination, System.Threading.CancellationToken cancellationToken) -> CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult +static CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.ByteArray(string! pattern, CheatEngine.SDK.Engine.Values.Address startAddress, CheatEngine.SDK.Engine.Values.Address stopAddress) -> CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest +static CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.ByteArray(string! pattern, CheatEngine.SDK.Engine.Values.Address startAddress, CheatEngine.SDK.Engine.Values.Address stopAddress, string! protectionFlags, CheatEngine.SDK.Engine.Enums.FastScanMethod fastScanMethod, string! alignmentParameter) -> CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.operator !=(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.operator ==(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.operator !=(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.operator ==(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.operator !=(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.operator ==(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions.TryCreate(out CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession? session) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions.TryCreateDetailed(out CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession? session) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions.TryCreateWithOutcome(out CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession? session) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome +static CheatEngine.SDK.Engine.Tables.CheatTableFiles.TryLoad(string! path, bool merge) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Tables.CheatTableFiles.TrySave(string! path) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.operator !=(CheatEngine.SDK.Engine.Targets.TargetIdentityCheck left, CheatEngine.SDK.Engine.Targets.TargetIdentityCheck right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.operator ==(CheatEngine.SDK.Engine.Targets.TargetIdentityCheck left, CheatEngine.SDK.Engine.Targets.TargetIdentityCheck right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.operator !=(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation left, CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.operator ==(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation left, CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.operator !=(CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome left, CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.operator ==(CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome left, CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetSelection.ObserveCurrent() -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +static CheatEngine.SDK.Engine.Targets.TargetSelection.ValidateCurrent(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation expected) -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck +static CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.operator !=(CheatEngine.SDK.Engine.Targets.TargetSelectionObservation left, CheatEngine.SDK.Engine.Targets.TargetSelectionObservation right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.operator ==(CheatEngine.SDK.Engine.Targets.TargetSelectionObservation left, CheatEngine.SDK.Engine.Targets.TargetSelectionObservation right) -> bool +~override CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.AutoAssemblerAllocationInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.AutoAssemblerSymbolInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.InstructionAssembly.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.InstructionAssembly.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.InstructionProfile.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.InstructionProfile.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.ToString() -> string +~override CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Objects.EngineResourceOrigin.ToString() -> string +~override CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.ToString() -> string +~override CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Runtime.CheatEngineHostObservation.ToString() -> string +~override CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Runtime.TargetArchitectureObservation.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanResult.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundResult.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Aob.AobScanBounds.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Aob.AobScanTargetContext.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.ToString() -> string +~override CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.ToString() -> string +~override CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.ToString() -> string diff --git a/libs/CheatEngine.SDK.Engine/README.md b/libs/CheatEngine.SDK.Engine/README.md index 2760c175..837a5839 100644 --- a/libs/CheatEngine.SDK.Engine/README.md +++ b/libs/CheatEngine.SDK.Engine/README.md @@ -20,19 +20,19 @@ Cheat Engine. This library encodes each rule once, in a type. | Namespace | Types | Role | |--------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `CheatEngine.SDK.Engine.Objects` | `CEObject`, `ICEObject` | Borrowed handle: the native object pointer, equal by identity, with property, index and method access | -| `CheatEngine.SDK.Engine.Objects` | `Owned` | Ownership of an object the plugin created; `Dispose` destroys it | +| `CheatEngine.SDK.Engine.Objects` | `Owned`, `EngineResourceOrigin`, `EngineEffectState` | Ownership of an object the plugin created; `Dispose` destroys it once, never in another Lua universe; origin and effect vocabulary shared by every durable resource | | `CheatEngine.SDK.Engine.Values` | `Address` | An address read from a Lua integer or hexadecimal text; its own Lua marshaller | | `CheatEngine.SDK.Engine.Values` | `IndexBase`, `LuaSequence` | Zero-based indices over Cheat Engine objects and Lua sequences | | `CheatEngine.SDK.Engine.Enums` | Enums, `CEEnumNames`, `EnumMarshaller` | Numeric constants, their Cheat Engine names, and Lua integer marshalling | -| `CheatEngine.SDK.Engine.Runtime` | `RuntimeInfo`, `RuntimeCapabilities` | Explicit runtime observations and evidence metadata; never inferred host facts | +| `CheatEngine.SDK.Engine.Runtime` | `RuntimeInfo` (SDK-produced or caller-supplied), `RuntimeCapabilities`, `CheatEngineHostObservation`, `TargetArchitectureObservation`, `CheatEngineOperatingSystem`, `TargetBackend` | Separate host, target ISA family, bitness, configured pointer size, ABI, OS, Android and backend facts; never inferred from one another | | `CheatEngine.SDK.Engine.Memory` | `TargetMemory`, `HostMemory`, `HostAddress` | Separate target/CE-host scalar, bounded span, target-width pointer, string and byte-table access | -| `CheatEngine.SDK.Engine.Inspection` | `EngineInspection` | Copied modules, sections, symbols, address resolution and memory-region snapshots | -| `CheatEngine.SDK.Engine.Allocation` | `TargetMemoryAllocator`, `AllocatedRegion` | Explicit ownership for target allocation, via a reviewed binding seam | +| `CheatEngine.SDK.Engine.Inspection` | `EngineInspection`, `SymbolRegistry`, `SymbolLists`, `SymbolList` | Copied modules, sections, symbols, address resolution and memory-region snapshots; symbol registration leases and plugin-owned symbol lists | +| `CheatEngine.SDK.Engine.Allocation` | `TargetMemoryAllocator`, `AllocatedRegion`, `TargetAllocationAcquireOutcome` | Explicit ownership for target allocation, via a reviewed binding seam; never a live address without an owner or a reported compensation | | `CheatEngine.SDK.Engine.Assembly` | `AutoAssemblerPatcher`, `AutoAssemblerPatch`, `InstructionProfiles`, `InstructionAssembler`, `InstructionDisassembler`, `InstructionNavigator` | Auto Assembler owns a single `disableInfo`; separately, bounded profile-qualified Lua instruction operations return copied values and structured outcomes | -| `CheatEngine.SDK.Engine.Scanning` | `AobScanner`, `StringList`, `MemoryScanSession` | AOB result ownership and conservative MemScan/FoundList state transitions | -| `CheatEngine.SDK.Engine.AddressList` | `AddressListMutations`, `MemoryRecordId` | ID-addressed record commands; borrowed GUI views never become managed owners | +| `CheatEngine.SDK.Engine.Scanning` | `AobScanner`, `AobScanBounds`, `StringList`, `MemoryScanSession` | AOB result ownership, the bounded exhaustive AOB route, and conservative MemScan/FoundList state transitions | +| `CheatEngine.SDK.Engine.AddressList` | `AddressListMutations`, `MemoryRecordId`, `MemoryRecordActivationOutcome` | ID-addressed record commands, including activation with its real effect; borrowed GUI views never become managed owners | | `CheatEngine.SDK.Engine.Errors` | `EngineException` hierarchy, `EngineResourceHandoffException` | Stable distinction between expected CE, unavailable global, Lua, binding and marshalling failures; post-effect ownership publication reports its one cleanup attempt | -| `CheatEngine.SDK.Engine.Generated` | `MemoryScalars` | Existing generated scalar wrappers for the earlier memory contract | +| `CheatEngine.SDK.Engine.Generated` | `MemoryScalars`, `RuntimeCapabilityProbes` | Generated wrappers: the earlier scalar memory contract, and raw read-only CE 7.7 runtime facts (ce77 spec) | The CE 7.7 vertical slices add the following public domains. They use the same protected Lua boundary, but their evidence and availability are intentionally separate: a catalogued Lua name is not a guarantee that every later CE @@ -40,20 +40,20 @@ host has the same contract. | Namespace | Public surface | Boundary and result contract | |----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Runtime` | `RuntimeInfo`, `RuntimeCapabilities`, and `RuntimeCapabilityContract` | An immutable snapshot of explicitly observed version, architecture, pointer-width and availability facts. Unknown remains unknown; an available global does not fill an unobserved ownership, thread or return field. | +| `Runtime` | `RuntimeInfo`, `RuntimeCapabilities`, `RuntimeCapabilityContract`, `CheatEngineHostObservation`, `TargetArchitectureObservation`, `TargetBackend`; produced by `Processes.RuntimeObservations` | An immutable snapshot of explicitly observed version, architecture, configured pointer size, backend and availability facts. Unknown remains unknown; an available global does not fill an unobserved ownership, thread or return field, and no fact is derived from another (see [Runtime facts and target backends](#runtime-facts-and-target-backends)). | | `Memory` | `TargetMemory`, `HostMemory`, `Address`, `HostAddress`, `PointerSize`, `MemoryAccessFailure` | Keeps attached-target addresses distinct from CE-host addresses. Target-width pointer, scalar, bounded-span, and string calls report expected CE/binding/Lua/result failures through `Try*` results; they do not claim a universal GUI-thread rule. | | `Inspection` | `EngineInspection` and module, section, symbol and region value types | Returns copied managed snapshots. `NotFound` is used only where the CE 7.7 Lua contract documents `nil`; malformed data and Lua failures remain distinct status values. | -| `Allocation` | `TargetMemoryAllocator`, `AllocatedRegion` | Models one target allocation as an explicit, single-use owner. It does not infer a GUI-thread requirement from an unspecific CE global, and reports a failed post-effect owner handoff with its one compensation outcome. | -| `Objects` / `Scanning.Aob` | `StringList`, `StringLists`, `AobScanner` | `StringLists.TryCreate` and a successful `AobScanner.TryScan` out value yield `Owned` only after a host object is returned. A list borrowed from CE must never be wrapped or destroyed by plugin code. | -| `Scanning.Values` | `MemScan`, `FoundList`, `MemoryScanSessions`, `MemoryScanSession`, scan requests and states | The factory creates and owns the scanner/child pair, retains rollback authority through publication, and the session serializes documented state transitions and releases the child before the parent. It is explicitly main-thread-only; the generic `Owned` wrapper is not. | -| `AddressList` | `AddressListAccess`, `AddressList`, `MemoryRecord`, `MemoryRecordId`, `AddressListMutations` | The current GUI list and records are borrowed CE-owned handles. Mutations resolve IDs inside one protected command and report completed, not-started, or indeterminate effect; they do not promise historic record identity or a runtime-enforceable GUI-thread guard. | -| `Assembly` | `InstructionTargetProfile`, `InstructionAssembler`, `InstructionDisassembler`, `InstructionNavigator`, `InstructionDisassembly`, `InstructionOperationStatus` | `InstructionProfiles` observes PID/probe/PID under one Lua admission. Each instruction call validates target width and rechecks that PID before and after CE's ambient operation; its result is copied and bounded, but that coherence check is not a target lock or a live-host qualification. | +| `Allocation` | `TargetMemoryAllocator`, `AllocatedRegion`, `TargetAllocationAcquireOutcome` | Models one target allocation as an explicit, single-use owner bound to its Lua runtime identity and target incarnation. It does not infer a GUI-thread requirement from an unspecific CE global, and reports a failed post-effect owner handoff with its one compensation outcome. | +| `Objects` / `Scanning.Aob` | `StringList`, `StringLists`, `AobScanner`, `AobScanBounds`, `AobBoundedScanResult` | `StringLists.TryCreate` and a successful `AobScanner.TryScan` out value yield `Owned` only after a host object is returned; a failed owner publication destroys the raw list once. `TryScanWithinBounds` is the bounded, exhaustive AOB route. A list borrowed from CE must never be wrapped or destroyed by plugin code. | +| `Scanning.Values` | `MemScan`, `FoundList`, `MemoryScanSessions`, `MemoryScanSession`, scan requests and states | The factory creates and owns the scanner/child pair, retains rollback authority through publication, and the session serializes documented state transitions, stops a running scan cooperatively on release, and releases the child before the parent. It is explicitly main-thread-only; the generic `Owned` wrapper is not. | +| `AddressList` | `AddressListAccess`, `AddressList`, `MemoryRecord`, `MemoryRecordId`, `AddressListMutations` | The current GUI list and records are borrowed CE-owned handles. Mutations resolve IDs inside one protected command and report completed, not-started, or indeterminate effect (activation: before/after state, host refusal, pending); they do not promise historic record identity. | +| `Assembly` | `InstructionTargetProfile`, `InstructionAssembler`, `InstructionAssembly`, `AssemblePreference`, `InstructionDisassembler`, `InstructionNavigator`, `InstructionDisassembly`, `InstructionOperationStatus` | `InstructionProfiles` observes PID/probe/PID under one Lua admission and maps CE's x86 family plus 64-bit flag to x64. Each instruction call validates target width and rechecks that PID before and after CE's ambient operation; its result is copied and bounded, and the detailed assembler overload echoes the origin, jump preference and range-check option it sent. That coherence check is not a target lock or a live-host qualification. | | `Errors` | `EngineException` and stable subclasses | Separates expected operation failure, global absence, Lua failure, binding violation and marshalling violation instead of exposing a raw Lua stack error as the public Engine contract. | -The per-capability provenance, minimum CE version, architecture, thread, ownership and return semantics belong to the -versioned [capability matrix](../../documentations/CheatEngine.SDK/capability-matrix.md). Fixture tests validate -managed behavior and the pinned Lua fixture; opt-in live evidence is recorded separately and is not implied by these -wrappers. +The per-capability provenance, minimum CE version, architecture, thread, ownership and return semantics are tracked +against the audit's Lua surface register; executed host evidence is a separate, qualified claim. Fixture tests +validate managed behavior and the pinned Lua fixture; opt-in live evidence is recorded separately and is not implied +by these wrappers. A `CEObject` is the native object pointer and nothing else. `CEObject.TryRead` decodes it from a full userdata whose first pointer-sized field holds the pointer, and `Push` hands it back through the host. A property is `obj.Name`. A @@ -71,9 +71,16 @@ return `false` for a nil or wrong-kind value and keep no Lua error message. The never to a flag. Construct one only from a handle that a sourced factory returned as plugin-owned or that another owner transferred. `Owned` does not infer a universal thread affinity: CE 7.7 does not document one for every `destroy()` implementation. A narrower typed surface, such as `MemoryScanSession`, must carry and enforce its own -evidence-backed thread rule. There is no finalizer. Dispose every owner while the plugin is still enabled; after -detach, destruction cannot begin and the caller must explicitly choose a recorded shutdown path. `Abandon()` gives up -managed cleanup without granting another caller permission to create an owner. +evidence-backed thread rule. There is no finalizer. An owner records the Lua runtime identity (attach epoch and state +generation) that created it as its `Origin`, and a transfer keeps it. It never destroys in another Lua universe: after a +re-enable or a controlled state replacement, `Dispose` consumes the owner without any Cheat Engine call and +`LastReleaseOutcome` reports `RefusedRuntimeChanged`; `TryDestroy` does the same and throws. Dispose every owner while +the plugin is still enabled: while detached, `Dispose` and `TryDestroy` throw and retain the owner (a later re-enable +makes it stale, so the next attempt consumes it without a call). `ReleaseWithOutcome()` never throws and always consumes +the owner: `Released`, `UnconfirmedAfterInvocation` when `destroy()` raised (for example because a parent destroyed the +object first), `RefusedRuntimeChanged`, or `NotInvoked` when no call could begin. A destroy is never retried. +`Abandon()` gives up managed cleanup without granting another caller permission to create an owner. `Value` stays a +borrowed view: check `Origin.IsCurrentRuntime` before using a handle kept across a re-enable. `Address.TryRead` tries hexadecimal text first (optional `0x`, no sign, no decimal form, 64-bit overflow refused). It then reads a Lua integer by bit reinterpretation, so an address above `long.MaxValue` round trips. `ToString()` gives @@ -98,10 +105,14 @@ The Engine extensions are anchored to the workspace's versioned CE 7.7 evidence against CE 7.7.0.10621 x64, not a claim that arbitrary CE builds have identical behavior. No normal test starts Cheat Engine or attaches another process. -`RuntimeInfo` is an immutable snapshot supplied by an integration layer. It deliberately does not turn a legacy -floating-point `getCEVersion` result into a complete file version and does not infer target architecture, pointer -width, ABI, ownership, return semantics or thread affinity. `RuntimeCapabilities` records each observed capability as -available, unavailable or unknown with the evidence fields that are actually known. +`RuntimeInfo` is an immutable snapshot, produced by `RuntimeObservations.TryObserveRuntimeInfo` or supplied by an +integration layer through its legacy constructor. The SDK-produced snapshot carries a `CheatEngineHostObservation` and, +when a target is selected, a `TargetArchitectureObservation`; its `PointerSize` is Cheat Engine's configured pointer size +(`getPointerSize`), not the target bitness. It deliberately does not turn a legacy floating-point `getCEVersion` result +into a complete file version (it reads `getCheatEngineFileVersion` instead) and does not infer target architecture, +pointer width, ABI, ownership, return semantics or thread affinity. `RuntimeCapabilities` records each probed capability +as available or unavailable with the evidence fields that are actually known; a capability that was not probed stays +unknown. `TargetMemory` accepts only target `Address` values; `HostMemory` accepts only `HostAddress`. Neither type converts implicitly to the other. Both expose signed and unsigned 8/16/32/64-bit scalars, pointers, `float`/`double`, ordered @@ -122,63 +133,101 @@ invalid result separate. The CE 7.7 catalog does not establish affinity for thes dispatch nor carry a main-thread assertion. `TargetSelection` keeps a Cheat Engine selection observation separate from a process incarnation. A qualified -incarnation combines the selected PID read from `getOpenedProcessID` with the local process creation time; missing, -malformed, inaccessible and no-target facts remain explicit observations instead of fabricated identities. It neither -opens nor selects a process. It can only compare observations: no inspected CE primitive makes an observation atomic -with a following ambient-target Lua effect, so an external selection transition in that interval, including an unseen -A→B→A sequence, remains unqualified. +incarnation combines the selected PID read from `getOpenedProcessID` with the local process creation time, and only +when `isConnectedToCEServer()` returned `false` in the same operation; missing, malformed, inaccessible and no-target +facts remain explicit observations instead of fabricated identities. A CEServer connection, an absent backend probe +and the file-as-process sentinel PID are refused with their own statuses and never produce local creation-time evidence, +so target-bound owners refuse them (see the backend table below). It neither opens nor selects a process. It can only +compare observations: no inspected CE primitive makes an observation atomic with a following ambient-target Lua +effect, so an external selection transition in that interval, including an unseen A→B→A sequence, remains unqualified. `InstructionProfiles.TryObserveCurrent` has a narrower purpose than process qualification: it reads CE's selected PID, -the documented target ISA probes, and the PID again to construct an `InstructionTargetProfile`. It refuses -contradictory or unknown ISA facts instead of using the managed host width. `InstructionAssembler` always sends its +the documented target ISA probes, and the PID again to construct an `InstructionTargetProfile`. On Cheat Engine an x64 +target is the x86 family plus the 64-bit flag (`targetIsX86() == true` and `targetIs64Bit() == true`), so the profile +follows `RuntimeInfo.TryDeriveTargetArchitecture` (see [Runtime facts and target backends](#runtime-facts-and-target-backends)): +x86 family gives X64 or X86, ARM family gives Arm64 or Arm32, and both families or neither is `InvalidProfile`. It never +uses the managed host width or CE's configured pointer size. With no target selected CE reports x64-like facts, so a +zero PID is `TargetNotSelected` before any probe; the file-as-process sentinel PID is `UnsupportedTargetBackend`. +`InstructionAssembler` always sends its explicit `Address` to CE as the relative-operand origin, validates the complete returned byte table, and copies no -prefix on a malformed result, short destination, or observed target change. `InstructionDisassembler` bounds and copies +prefix on a malformed result, short destination, or observed target change. Its detailed overload also sends an +`AssemblePreference` (CE's `apNone`/`apShort`/`apLong`/`apFar`) and the `skipRangeCheck` option, always as four +arguments, and returns an `InstructionAssembly` echo of the target, profile, origin, preference and option; the legacy +overload still sends exactly two arguments. `nil` with or without a message is `InstructionRejected` and the message +is never read. `InstructionDisassembler` bounds and copies the raw UTF-8 display line before resolving the split helper, bounds all four raw split fields before decoding them, then returns managed strings; consumers never need to parse that UI text. `InstructionNavigator.TryGetPrevious` retains CE's documented estimate semantics. These mappings use protected Lua globals only. The historical classic `Assembler`, `Disassembler`, `disassembleEx`, `previousOpcode`, and `nextOpcode` slots remain unprojected because their reviewed ABI and output-capacity evidence is conflicting or -insufficient. Fixture tests verify these managed contracts; no test in this repository qualifies a live CE target, +insufficient; their buffer-based forms are not applicable to the managed-hostfxr profile (audit A15-19). Fixture tests verify these managed contracts; no test in this repository qualifies a live CE target, target architecture, selection lock, relocation backend, or plugin Native AOT loading. -`TargetMemoryAllocator` uses `LuaTargetMemoryAllocationOperations` by default and retains +`TargetMemoryAllocator` uses the production `allocateMemory`/`deAlloc` binding by default and retains `ITargetMemoryAllocationOperations` as the direct compatibility seam. The production binding preserves CE's optional -target address and page-protection positions. To create an `AllocatedRegion`, an implementation must additionally opt -into `ITargetBoundMemoryAllocationOperations`, which captures a qualified incarnation and refuses a later observed -mismatch without selecting a replacement target. A legacy direct seam remains usable for its original ambient-target -operations, but is not silently converted into an owner with an unverified cleanup target. `AllocateWithOutcome` and -`ReleaseWithOutcome` are additive structured views: they distinguish success, documented negative results, unavailable -globals, protected Lua failures, malformed results, and target-identity refusal without exposing a Lua state or parsing -an error message. `Dispose` is best-effort, no-throw cleanup. Both release paths consume ownership first, so a -potentially partial deallocation is never retried. CE 7.7 has no documented separate post-allocation protection call in -this surface. If CE accepts an allocation but managed owner publication then throws, the allocator attempts the same -target-qualified deallocation once and throws `EngineResourceHandoffException`; its `CleanupOutcome` distinguishes a -confirmed release, a safe refusal against a replacement target, and an effect requiring manual recovery. It never -retries an uncertain native effect. - -`AobScanner.TryScanDetailed` retains global-unavailable, protected-Lua-failure, raw `nil`, malformed-result and -successful-list outcomes; `TryScan` keeps its compatible `bool` projection and supplies a caller-owned -`Owned` through its `out` parameter. A valid empty list is still a successful caller-owned result, not a -match classification. `AobScanner.TryScanOutcome` adds the factual distinction that a valid `StringList` with a -verified zero count is `NoMatches`; raw `nil` remains a distinct result, never a no-match inference. It retains the -caller-owned list for both `Matches` and `NoMatches`, reports an unreadable or negative count as a separate outcome -after disposing that otherwise unreturnable owner, and preserves the protected Lua status without copying transient -error text. The CE call is synchronous: this SDK exposes no range, module, result-limit, early-stop or -`CancellationToken` control because none is established for this `AOBScan` path. Client code may cap its own copied -data after the full host list returns, but that does not bound or interrupt CE work. CE documents an AOB result list as -caller-freed; `StringList` itself remains a borrowed handle. `MemScan` and `FoundList` are borrowed handle values, while -`MemoryScanSessions.TryCreate` is the SDK's source-backed CE 7.7 creation path: it immediately owns the returned parent -and child, holds one Lua operation across both calls, retains raw handles until their owner is published, rolls the -child back before its parent on every later failure, and transfers the pair only to `MemoryScanSession`; an ordinary -consumer cannot create an `Owned` or `Owned` manually. `AutoAssemblerPatcher` retains the returned -disable-info table while it is rooted and performs one target-qualified disable if tracking or patch publication fails; -the same `EngineResourceHandoffException` reports whether that compensation was confirmed, safely refused, or uncertain. -`AutoAssemblerPatch.ReleaseWithTargetOutcome` is the additive structured view of its one cleanup attempt: it reports -confirmed disable, a target-qualified refusal, cleanup that could not begin, or an attempted unconfirmed effect without -deriving a new cleanup action from the script or an address. It still rejects a second release because its sole owner -has been consumed. The patch never -reselects a target, and an ambient target transition between an observation and a Lua effect remains an explicit -host-contract gap rather than a claim that the SDK can lock Cheat Engine's selection. +target address and page-protection positions; it is internal, because its direct members would yield a live address +without an owner. To create an `AllocatedRegion`, an implementation must additionally opt into +`ITargetBoundMemoryAllocationOperations`, which captures a qualified incarnation and refuses a later observed mismatch +without selecting a replacement target. A legacy direct seam remains usable for its original ambient-target operations, +but is not silently converted into an owner with an unverified cleanup target. + +`Allocate` keeps its throwing contract. `TryAllocate(request, out AllocatedRegion? region)` reports every result as a +`TargetAllocationAcquireOutcome` instead: the CE call result (its address stays readable whenever CE returned one), an +`EngineEffectState` (`NotStarted` before any call, `NotApplied` for CE's documented `nil`, `Applied` when CE returned an +address, `Unknown` for a protected failure or a malformed result), the target observation, and `HasOwner`. When CE +allocated but no owner can be published (an unqualified target, a Lua runtime identity change during the call, or a +failed owner publication), it makes exactly one target-qualified compensation and reports it as `Compensation`, so an +applied allocation never leaves without an owner or a compensation result. + +An allocation is bound to the runtime identity and target incarnation that created it (`AllocatedRegion.Origin`). After a +re-enable or a controlled state replacement the region refuses cleanup without any call (`RefusedRuntimeChanged`) and +reports the residue; after a detach, cleanup cannot begin (`NotInvoked`). It never reopens or reselects a target. +`Dispose` and `ReleaseWithTargetOutcome` never throw once ownership is taken; `Release` and `ReleaseWithOutcome` throw a +lifecycle `InvalidOperationException` for those two cases. Every release path consumes ownership first, so a +potentially partial deallocation is never retried, and a released region can never free a later allocation that reuses +its address. CE 7.7 has no documented separate post-allocation protection call in this surface. If CE accepts an +allocation but managed owner publication then throws, `Allocate` attempts the same target-qualified deallocation once +and throws `EngineResourceHandoffException`; its `CleanupOutcome` distinguishes a confirmed release, a safe refusal, +cleanup that could not begin, and an effect requiring manual recovery. + +`AobScanner.TryScanDetailed` retains global-unavailable, protected-Lua-failure, raw `nil` (or no value), +malformed-result and successful-list outcomes; `TryScan` keeps its compatible `bool` projection and supplies a +caller-owned `Owned` through its `out` parameter. A valid empty list is still a successful caller-owned +result, not a match classification. `AobScanner.TryScanOutcome` adds the factual distinction that a valid `StringList` +with a verified zero count is `NoMatches`; raw `nil` remains a distinct result, never a no-match inference. On the pinned +profile `ce-7.7.0.10621-x64-managed-hostfxr`, zero matches are reported this way: `AOBScan` returns no value, which the +SDK reports as `NoResult`, and an empty list was never observed (host observation, spike 2026-09-22; the Q27 C3 receipt +is still pending). `NoMatches` on this global route stays reserved for a valid empty list and is unreachable on that +profile. `TryScanOutcome` retains the caller-owned list for both `Matches` and `NoMatches`, reports an unreadable or +negative count as a separate outcome after disposing that otherwise unreturnable owner, and preserves the protected Lua +status without copying transient error text; no category is ever derived from Lua error text. If publishing the managed +owner of a returned list fails, the SDK destroys the raw list once and rethrows the original exception. An overload also +reports the target observations made immediately before and after the call (`AobScanTargetContext`), as facts only. The +global `AOBScan` call is synchronous and unbounded: this SDK exposes no range, module, result-limit, early-stop or +`CancellationToken` control for it because none is established for this path. Client code may cap its own copied data +after the full host list returns, but that does not bound or interrupt CE work. CE documents an AOB result list as +caller-freed; `StringList` itself remains a borrowed handle. + +`AobScanner.TryScanWithinBounds` is the bounded route: a `MemoryScanSession` byte-array first scan +(`FirstScanRequest.ByteArray`) whose CE work is limited to an `AobScanBounds` range `[Start, Stop)`. It always switches +`OnlyOneResult` off, reads addresses only, drops and counts rows below `Start` (CE's start bound is not byte-exact) and +reports a factual `NoMatches` when no row lies in the bounds and CE reported no error text. On the pinned profile this +route returned exactly the in-module subset of the global route (Lua-only host observation, spike D4; the Q28 C3 +receipt is still pending). A deadline overload and the session's `TryWaitForCompletion`/`TryTerminateScan` are +`[Experimental("CESDK5010")]` because CE's timed-out wait and `terminateScan` were not observed on the pinned host; the +separately named first-found opt-in, `TryFindFirstFoundWithinBounds`, is `[Experimental("CESDK5011")]` because its result +is "first found, order unspecified", never a uniqueness proof. See [Scan limits and cost](#scan-limits-and-cost). + +`MemScan` and `FoundList` are borrowed handle values, while `MemoryScanSessions.TryCreate` is the SDK's source-backed CE +7.7 creation path: it immediately owns the returned parent and child, holds one Lua operation across both calls, retains +raw handles until their owner is published, rolls the child back before its parent on every later failure, and transfers +the pair only to `MemoryScanSession`; an ordinary consumer cannot create an `Owned` or `Owned` +manually. Releasing a session whose scan may still run first requests one cooperative stop (`terminateScan(false)`, then a +five-second bounded wait), then destroys the child and the parent once each even when the stop is not confirmed, which +can block CE's main thread; `MemoryScanReleaseOutcome.Termination` reports the stop. CE's waits can run queued +main-thread work (`waitTillDone` pumps `CheckSynchronize`) that calls back into the session: while one session member is +inside a CE call, every other member is refused and a release or abandon is deferred until that call has returned, so no +destroy runs under a CE call on the same objects. The Client must still keep value scanning capability-gated until its opt-in CE 7.7 x64 live scenario validates creation, cleanup, disable/re-enable, and target changes. The session guards the `firstScan → waitTillDone → initialize → read → deinitialize` order and rejects worker-thread cleanup while attached because its owned children use the existing SDK @@ -186,22 +235,90 @@ owner destruction contract. That conservative SDK guard is not evidence that CE' operation main-thread-only. The raw `MemScan` and `FoundList` handles likewise carry no `MainThreadOnly` metadata before a live probe establishes one. +`AutoAssemblerPatcher` calls `autoAssemble(script)` with three results (success, disable information or error detail, +compilation warnings) and never passes `targetself`. `TryApplyWithOutcome` reports an `AutoAssemblerApplyOutcome` +instead of a Boolean: `Applied`, `AppliedTargetChanged` (CE applied the script but the captured target no longer +matched right afterwards; the patch is still published with its original incarnation, and its release refuses the +other target), `Rejected`, `GlobalUnavailable`, `ProtectedLuaFailure`, `InvalidResult`, `TargetIdentityUnavailable` or +`HandoffFailed` (CE applied the script but the disable information could not be rooted, copied or handed to a patch; the +one target-qualified compensating disable is the outcome's `Compensation`). Its `EngineEffectState` is `Applied` only +for `Applied`, `NotStarted` for an unavailable global or target, and `Unknown` otherwise: a rejection by CE does not +prove that nothing changed. CE gives no factual signal that separates a syntax error, an unresolved symbol, an +impossible allocation, a failed include or a failed DLL injection, so these are all `Rejected`: partially +distinguishable by design. No category is ever derived from CE's text. With `AutoAssemblerOptions.CaptureHostText` +(off by default, because the text can contain script source and paths) the outcome also carries CE's rejection detail +and warnings, copied bounded to `MaxHostTextBytes` UTF-8 bytes and cut at a scalar boundary; `HasHostWarnings` is +reported whether or not the text was captured. + +Every applied patch carries an `AutoAssemblerDisableInfoSnapshot`: a bounded copy of the `allocs`, `registeredsymbols`, +`exceptionlist` and `symbols` sections (dictionaries sorted by ordinal name, sequences in CE's order, addresses above +2^63 exact) and whether a `ccodesymbols` list exists. That list belongs to CE: the SDK never owns, destroys or +unregisters it. The snapshot is diagnostic data. A malformed or truncated snapshot (status `Malformed` or `Truncated`) +never fails the activation: the rooted table, passed back unchanged to CE's `[DISABLE]` handling, stays the only disable +authority, and no `[DISABLE]` script is ever rebuilt from saved bytes. The patch exposes `Origin` (runtime identity and +incarnation), `DisableInfo` and `PostApplyTargetCheck`. `Dispose`, `Release` and `ReleaseWithTargetOutcome` consume the +owner before one CE disable and never repeat it; after a re-enable or a controlled state replacement they report +`RefusedRuntimeChanged` without a call. No rollback stronger than CE's own is promised: a disable that returns `false` +or raises leaves `RequiresManualRecovery` set. `TryCheck(script, enable)` calls `autoAssembleCheck` with exactly the +script and the enable flag and never creates an owner; a syntax check is not proof that the activation will succeed. +Both globals are resolved through the cached protected global push. `Apply` and `TryApply` keep their compatible +contract and throw `EngineResourceHandoffException` for a failed handoff, with the same compensation outcome. The patch +never reselects a target, and an ambient target transition between an observation and a Lua effect remains an explicit +host-contract gap rather than a claim that the SDK can lock Cheat Engine's selection. + `AddressList` and `MemoryRecord` are always borrowed GUI handles, including a record created by `AddressList.TryCreateMemoryRecord`, because CE adds it to the address list. Their possible GUI affinity is documented as an inference only: they deliberately have no `MainThreadOnly` metadata until the opt-in CE 7.7 dispatcher probe establishes an enforceable host contract. -`AddressListMutations` is the typed mutation boundary for those GUI-owned records. Its `Delete` and `SetParent` -commands take only `MemoryRecordId` values (and an explicit hierarchy traversal bound), resolve against the current -address list in one admitted Lua operation, and return a structured effect. A protected failure after `destroy()` or -the `Parent` setter starts is intentionally **indeterminate**, not a retry-safe failure. The command result contains no -snapshot: a consumer that needs one must read a new view after a completed result. - -`SymbolRegistry.TryRegisterOwned` returns `SymbolRegistrationLease`, an explicit cleanup coordinator rather than an -exclusive CE owner. CE supplies no registration token and unregisters by name only, so the lease prevents an older lease -from deleting a newer registration made through the same SDK coordinator; it cannot detect a replacement by external -Lua, another plugin, or another SDK copy. On runtime epoch or state-generation change it sends no unregister to the new -universe. +`AddressListMutations` is the typed mutation boundary for those GUI-owned records. Its `Delete`, `SetParent` and +`SetActive` commands take only `MemoryRecordId` values (and an explicit hierarchy traversal bound), resolve against the +current address list in one admitted Lua operation, and return a structured effect. A protected failure after +`destroy()` or the `Parent` setter starts is intentionally **indeterminate**, not a retry-safe failure; a second delete +of the same identifier reports `RecordNotFound` without a second destroy. The command result contains no snapshot: a +consumer that needs one must read a new view after a completed result. Every command is refused with +`TableLoadInProgress`, before any Lua call, while `CheatTableFiles.TryLoad` runs on the same thread (for example from a +script of the table being loaded). + +`SetActive(MemoryRecordId, bool)` reads `Active`, does not call the setter when the record already has the requested +state (`Unchanged`), calls the setter once, then reads `Active` and `AsyncProcessing` back: `Applied`, `RefusedByHost` +(an `OnActivate` returning false or a failed `[ENABLE]`, which may have applied part of its effects), `Pending` (an +asynchronous record still processing), or `Indeterminate` (the setter raised after it started, or the read-back +failed). It never retries: a third-party `OnActivationFailure` handler that asks CE to retry can make CE loop inside the +one setter call, which the SDK cannot prevent. `MemoryRecord` adds reads of `Active`, `Async`, `AsyncProcessing`, +`Script` and `OffsetCount`, and deliberately has no `Active` setter on the borrowed handle. + +`CheatTableFiles` projects only the file overloads `loadTable(path, merge)` and `saveTable(path)`, with exactly those +arguments. The file overload has no option to suppress CE's Lua-script dialog, so a table that contains scripts may +prompt or execute Lua. The stream overloads of `loadTable`/`saveTable` and the `protect`/`dontDeactivateDesignerForms` +options of `saveTable` are not projected until a CE stream projection exists; the Lua surface catalogue records them as +deferred. The SDK applies no path policy: the application's trust policy owns which tables may be loaded, and a file +must never be turned into a stream to bypass it. + +`SymbolRegistry.TryRegisterOwned` returns `SymbolRegistrationLease`, an explicit cleanup capability rather than an +exclusive CE owner. CE supplies no registration token and unregisters by name only. The lease records the registered +`Address` and its `Origin`; before its one unregister it resolves the name again: when the name no longer resolves it +reports `ExternallyRemoved`, and when it resolves to another address it reports `Replaced`; neither sends an unregister, +so a newer third-party definition is never removed. The check is best effort and not atomic (a third party can replace +the name between the lookup and the unregister), and a name that parses as an expression or collides +case-insensitively can produce a conservative `Replaced`. A failed lookup keeps the lease retryable +(`CleanupUnavailable`). The lease also never unregisters a newer registration made through the same SDK coordinator, +and on a runtime epoch or state-generation change it sends nothing to the new universe. Registration keeps CE's own +behavior for an existing name; a consumer that needs a collision policy resolves the name first with +`EngineInspection.ResolveAddress`. CE's `deleteAllRegisteredSymbols` is deliberately not bound: it removes the symbols +of every script, table and plugin, so it is never a per-plugin cleanup path (a repository test keeps it out of the +shipping sources). + +`SymbolLists` keeps three things distinct: a symbol registered on its own, a symbol list owned by the plugin, and the +main list borrowed from CE. `TryCreate` binds the zero-argument `createSymbolList()` only (its initial-list overload +registers automatically) and returns an `Owned`; `TryGetMain` returns the main list as a borrowed +`SymbolList` that no SDK API can register, unregister or destroy. `TryRegister` transfers the owned list into a +`SymbolListRegistrationLease` once `register()` began (unconfirmed when it raised), and the lease unregisters before it +destroys: an `unregister()` that raised abandons the list without destroying it, because destroying a possibly +registered list could leave a dangling entry in CE's symbol handler. The borrowed `SymbolList` members (`clear`, +`addSymbol` with exactly four arguments, `deleteSymbol`, `getSymbolFromAddress`, `getSymbolFromString`, `Name`, `PID`) +return a `LuaOperationStatus` that separates a missing member, a Lua failure, a not-found `nil` and a malformed symbol +table. ```csharp using CheatEngine.SDK.Engine.Generated; @@ -239,12 +356,144 @@ The library references [`CheatEngine.SDK.Lua`](../CheatEngine.SDK.Lua/README.md) Cheat Engine through `LuaRuntime`, which `CheatEngine.SDK.Hosting` attaches when the plugin is enabled. The assembly ships in the `CheatEngine.SDK` package under `lib/net10.0`. +### Runtime facts and target backends + +Cheat Engine reports each runtime fact through its own Lua global. The SDK reads each one separately, keeps an absent +global as `null` or `Unknown` (never `false`), and never derives one fact from another. Every observation of a target +reads `getOpenedProcessID` first, and reads no target fact when it is 0 or the file-as-process sentinel. The fact +observations `RuntimeProcessOperations.ObserveTargetArchitecture` and `TryGetConfiguredPointerSize`, +`RuntimeObservations.TryObserveRuntimeInfo` and `InstructionProfiles.TryObserveCurrent` also read it last and report a +difference as `TargetChanged`; instruction operations re-check it before and after their Cheat Engine call. +`TargetSelection.ObserveCurrent` and `RuntimeProcessOperations.ObserveCurrent` read it once, before their other probes, +and `RuntimeProcessOperations.SelectAndObserve` reads it once, after `openProcess`, to confirm the selection. + +| Fact | CE global | SDK member | When unknown | +|--------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------|-----------------------------------------------| +| CE file version | `getCheatEngineFileVersion` | `RuntimeHostOperations.TryGetCheatEngineFileVersion`, `CheatEngineHostObservation.FileVersion` | absent global or no value returned | +| CE host architecture | `getSystemArchitecture` | `RuntimeHostOperations.TryGetSystemArchitecture`, `CheatEngineHostObservation.SystemArchitecture` | absent global | +| CE is 64-bit | `cheatEngineIs64Bit` | `RuntimeHostOperations.TryIsCheatEngine64Bit`, `CheatEngineHostObservation.CheatEngineIs64Bit` | absent global; never taken from the host arch | +| CE operating system | `getOperatingSystem` | `RuntimeHostOperations.TryGetOperatingSystem`, `CheatEngineHostObservation.OperatingSystem` | absent global | +| Selected process | `getOpenedProcessID` | `TargetArchitectureObservation.ProcessId` | 0: no target, no fact is read | +| Target backend | `isConnectedToCEServer` | `TargetArchitectureObservation.Backend`, `TargetSelectionObservation.Backend` | absent global: `Unknown` | +| Target bitness (CE's 64-bit flag) | `targetIs64Bit` | `TargetArchitectureObservation.Bitness`, `CurrentProcessObservation.PointerSize` | required by the target observation | +| Target ISA family | `targetIsX86`, `targetIsArm` | `TargetArchitectureObservation.IsX86Family`, `.IsArmFamily`, `.Architecture` | absent global: `null`, architecture `Unknown` | +| Android target | `targetIsAndroid` | `TargetArchitectureObservation.IsAndroid` | absent global: `null` | +| Target ABI | `getABI` | `TargetArchitectureObservation.AbiCode`, `.Abi` | absent global, or an undocumented code (kept raw) | +| CE's configured pointer size | `getPointerSize` | `RuntimeProcessOperations.TryGetConfiguredPointerSize`, `TargetArchitectureObservation.ConfiguredPointerSize`, `RuntimeInfo.PointerSize` | absent global, or any value other than 4 or 8 (kept raw) | + +`RuntimeObservations.TryObserveRuntimeInfo` reads all of them in one admission and produces a `RuntimeInfo`; +`RuntimeCapabilityProbes` (generated from the `ce77` runtime spec) exposes the raw values for callers that need them. +Both are projections of the same globals. Their integer policies differ: a generated `int32` result goes through the +EngineApi marshaller, which also converts an integral float or an integer numeral string, while the members above +require the Lua integer that Cheat Engine pushes and report any other value as `InvalidResult` +(`generated_int32_probe_converts_an_integral_float_or_numeral_string_that_the_structured_api_refuses`). None +of these calls `setPointerSize`, `setAssemblerMode`, `openProcess`, `openFileAsProcess` or a `dbk_*`/`dbvm_*` global: a +runtime query loads no driver and changes no target (audit A17-18, Q45). + +The ISA family is reported separately from the 64-bit flag, and the SDK derives an architecture only from both +families plus the flag, through the pure `RuntimeInfo.TryDeriveTargetArchitecture`: + +| `targetIsX86` | `targetIsArm` | Architecture | +|---------------|---------------|-----------------------------------------| +| true | false | `targetIs64Bit` ? `X64` : `X86` | +| false | true | `targetIs64Bit` ? `Arm64` : `Arm32` | +| true | true | `Unknown` (instruction profile refused) | +| false | false | `Unknown` (instruction profile refused) | +| absent | any | `Unknown` (instruction profile refused) | + +Evidence: CE 7.7.0.10621 x64 reported `targetIsX86() == true` and `targetIs64Bit() == true` for an x64 target and +`targetIsX86() == true`, `targetIs64Bit() == false` for an x86 target (spike C3 D2: a Lua-only host observation used as +a design input, not a qualification). The public CE source at `ec45d5f` agrees (`ProcessHandlerUnit.pas:24`, +`:115-138`). With no target selected CE also reports the x86 family, the 64-bit flag and an 8-byte pointer size, which +is why the process identifier comes first. + +The configured pointer size is not the bitness. On the same host, `setPointerSize(4)` on an x64 target made +`getPointerSize()` return 4 while `targetIs64Bit()` stayed true and `readPointer` kept reading 8 bytes; +`setPointerSize(2)` was accepted, and selecting the target again reset the value (spike C3 D3). The SDK therefore reports +both, keeps any raw configured value, never calls `setPointerSize`, and never takes a target width from `IntPtr.Size`. +`PointerSize.FromArchitecture` is obsolete ([CESDK7001](../../analyzers/docs/CESDK7001.md)). The little-endian pointer +encoding of `PointerSize.TryReadLittleEndian`/`TryWriteLittleEndian` is an assumption of the local x86/x64 profile, not a +general Cheat Engine fact (audit A12-04). + +Target backends produce different evidence and are separate profiles (audit A12-03, ADR-11): + +| Backend | How the SDK recognises it | Incarnation evidence | Qualified backend | +|-----------------|----------------------------------------------------------------------------------|-----------------------|-------------------------------------------------------| +| `LocalProcess` | `isConnectedToCEServer() == false` and a PID in (0, `int.MaxValue`] | PID + local StartTime | yes, profile `ce-7.7.0.10621-x64-managed-hostfxr` | +| `FileAsProcess` | sentinel PID 4294967295 (CE source `ec45d5f`, ObservedSource; ToQualify on 7.7) | none | no | +| `CEServer` | `isConnectedToCEServer() == true` | none | no | +| `Unknown` | `isConnectedToCEServer` absent | none | no | + +A file opened as a process has no operating-system process: the SDK refuses it and never searches for a Windows process +(A12-07). A local BCL PID and creation time do not describe a PID served remotely by CEServer (A12-05). Cheat Engine's +target stays ambient: the user, another plugin or a script can switch it at any time, and the SDK's before-and-after +checks reduce that risk without making an operation a transaction or a lock (A12-01). + +These behaviours are proven by fixture tests (C1/C2) only, never by a run on a Cheat Engine host (audit ADR-04): the +host-level (C3/C4) scenarios Q30, Q31, Q32 and Q45 are not executed. + +## Ownership and origin (Checkpoint D) + +Every durable resource knows the context that created it (`EngineResourceOrigin`: the Lua runtime identity, attach epoch +and state generation, compared together, and a target process incarnation for target-bound resources), its one release +action, the ways it can be destroyed behind the plugin's back, and what it does when the target or the Lua state +changed. The application chooses the policy; the SDK keeps native authority; a Client lease delegates to these owners +and never owns destroy itself. Only sourced SDK factories create owners, a getter never grants destroy authority, and +no finalizer ever repairs a forgotten cleanup. Effects use one vocabulary, `EngineEffectState` (`Unknown = 0`, +`NotStarted`, `NotApplied`, `Applied`). + +| Resource | Origin captured | Release action | External-destruction risk | On target change | On Lua state change / re-enable | On detach | Retry policy | +|----------|-----------------|----------------|---------------------------|------------------|---------------------------------|-----------|--------------| +| `Owned` (StringList, SymbolList, MemScan/FoundList inside a session) | Runtime identity, inside the creating factory's admitted operation; a transfer keeps it | One `destroy()` (`Dispose`, `TryDestroy`, `ReleaseWithOutcome`) | A parent or CE destroys the object first: the destroy raises, `UnconfirmedAfterInvocation` | Not target-bound | Consumed without a call, `RefusedRuntimeChanged` (residue) | `Dispose`/`TryDestroy` throw and retain; `ReleaseWithOutcome` consumes, `NotInvoked` | Never after a destroy began | +| `AllocatedRegion` | Runtime identity and qualified incarnation, around the allocating call | One target-validated `deAlloc` | The target exits or its PID is reused: refused; a later allocation at the same address is never freed through this owner | Refused (`RefusedTargetChanged`, `RefusedProcessReused`, `RefusedNoTarget`), residue reported, never reselects | Refused without a call, `RefusedRuntimeChanged` | `NotInvoked` | Never | +| `AutoAssemblerPatch` | Runtime identity and incarnation, validated again right after the effect | One `autoAssemble(script, disableInfo)` with the rooted table | A script or table disables it, or the target exits: the disable fails, manual recovery | Refused, token kept until consumed; a change during the effect is `AppliedTargetChanged` | Refused without a call, `RefusedRuntimeChanged`; the reference is dropped without touching the new state | `NotInvoked` | Never | +| `SymbolRegistrationLease` | Runtime identity and the registered address | Name lookup, then one `unregisterSymbol(name)` only if the name still maps to the address | A third party replaces or removes the name: `Replaced` / `ExternallyRemoved`, no unregister | Not target-bound (a lookup in another target reads as a conservative `Replaced`/`ExternallyRemoved`) | `StaleRuntime`, no call | `StaleRuntime`, no call | Only `CleanupUnavailable` (no call began) | +| `SymbolListRegistrationLease` | The list owner's runtime identity | `unregister()`, then the list's one `destroy()` | CE destroys the list: `unregister()` raises, `CleanupIndeterminate`, abandoned without destroy | Not target-bound | `StaleRuntime`, list abandoned without a call | `StaleRuntime`, list abandoned without a call | Only when `unregister` is unavailable | +| `MemoryScanSession` (S-SCAN, Wave 2) | `RuntimeIdentity` and target observation (an `Origin` property is added by S-SCAN) | FoundList `deinitialize`/`destroy`, then MemScan `destroy` | CE or a parent destroys a child: unconfirmed | Refused without cleanup | Consumed without cleanup | Consumed without cleanup | Never | +| `MemoryRecord` (address list) | None: a borrowed CE-owned record | None by the plugin; `AddressListMutations` commands resolve the record by identifier in each command | A table reload, a parent delete or CE removes it: `RecordNotFound` | Not target-bound | Commands resolve again; an identifier is never a historic identity | Commands throw (lifecycle) | Never; a second delete reports `RecordNotFound` | +| Timers, hotkeys, lookup callbacks | Placeholder: S-EVT, Wave 3 | | | | | | | + +An external `resetLuaState` that bypasses the SDK's controlled replacement is not detected today: the state generation +does not advance, so this policy does not trigger; detection is a Lua runtime concern. Every row is a fixture contract +(managed doubles and the bundled Lua), not host qualification: the host-level scenarios (Q08.a, Q17, Q30.a, Q30.b, +Q30.e, Q34, Q35) are tracked by the `Qualification` trait on the tests named in the promise rows below, and their C3/C4 +evidence is future work against the exact Cheat Engine host (Wave 4). + +## Scan limits and cost + +A scan has four distinct limits, named the same way in the types and XML docs: + +| Limit | Where it lives | What it bounds | +|-----------------------|------------------------------------------------------------------------------------------------------|-------------------------------------------------------------| +| CE work limit | `AobScanBounds` `[Start, Stop)` passed to the MemScan first scan | The memory CE scans; `Stop` is exclusive (a match must fit) | +| Available results | `AobBoundedScanResult.HostResultCount`, `MemoryScanSession.ResultCount` | What CE found, including rows the SDK later drops | +| Materialization limit | The caller's destination length; `AobBoundedScanResult.IsMaterializationLimitReached` | How many rows the SDK reads and copies | +| Call deadline | The optional wait timeout; `AobBoundedScanOutcomeKind.WaitTimedOut`, `MemoryScanWaitStatus.TimedOut` | How long one call waits for CE (experimental, CESDK5010) | + +The global `AOBScan` route has none of these: its native cost is a scan of the whole address space, whatever the caller +filters or caps afterwards. The bounded route bounds the CE work, and its copy reads one `getAddress` per needed row and +never `getValue`. `AobBoundedScanResult` reports `HostScanElapsed` (from just before the SDK's first-scan call to the end of +the wait, so it also contains the SDK's two target-identity checks and `FoundList.initialize`), `CopyElapsed` (count, +error text and rows) and `TotalElapsed` (session creation to release) separately. + +Indicative host figures, from the Lua-only C3 spike of 2026-09-22 on the pinned profile under about 41 % concurrent CPU +load (not a receipt, not SDK code): when 1 of 100 291 global matches lay in the scanned module, the range route took about +31–34 ms against about 455–470 ms for the global route plus a managed filter (roughly 14 times cheaper); when all 7628 +matches lay in the module, both were comparable (68–73 ms against 75–86 ms), because the per-row copy (about 5 µs per +`getAddress`) dominated. This is why the copy is bounded by the destination and reads addresses only. The fixture tests +count CE calls as a C1 cost proxy (`AobBoundedScanTests`); host timings are C3 work: the Q28 receipts must re-measure on +`tests/CheatEngine.SDK.QualificationTarget` without concurrent build load, and benchmarks of the fixture paths belong to +the performance lot. Both remain not executed at C3. + ## Promise The tests in `tests/CheatEngine.SDK.Engine.Tests` drive a simulated Cheat Engine object model on a real Lua 5.3 state. -1. `Owned` destroys its object once, never retries a destroy that raised, and only throws from `Dispose` when the - protected destroy call cannot begin; a failure returned by CE is consumed after the call starts (`OwnedTests`). +1. `Owned` destroys its object once, never retries a destroy that raised, never destroys in another Lua runtime + identity (consumed without a call after a re-enable or a controlled state replacement), keeps its origin across a + transfer, and only throws from `Dispose` when the protected destroy call cannot begin; `ReleaseWithOutcome` never + throws. Every durable resource exposes its `Origin`, and no public getter returns an owner (`OwnedTests`, + `EngineResourceOriginTests`, `OwnershipSurfaceTests`). 2. A Lua error never becomes an exception: typed members return `false`, stack-level members return a `LuaStatus` with one error value (`CEObjectTests`). 3. Members that push the object throw `InvalidOperationException` before the plugin is enabled (`CEObjectValueTests`). @@ -259,27 +508,60 @@ The tests in `tests/CheatEngine.SDK.Engine.Tests` drive a simulated Cheat Engine `CEEnumNamesTests`). 9. `CheatEngine.SDK.Engine.dll` and its XML documentation ship in the package, and the EngineApi generator never does (`PackageContentsTests`). Warnings are errors, so every public member is documented. -10. Runtime facts remain explicit and unknown fields stay unknown; capability observations are immutable copies - (`RuntimeContractsTests`). +10. Runtime facts remain explicit and unknown fields stay unknown; capability observations are immutable copies; the + ISA family, bitness, configured pointer size, ABI, Android, host and backend facts are read separately and never + derived from one another (`RuntimeContractsTests`, `RuntimeProcessOperationsTests`, `RuntimeHostOperationsTests`, + `RuntimeObservationsTests`, `RuntimeCapabilityProbesTests`). 11. Target and host memory cannot cross address spaces implicitly; scalar, span, text and failure paths keep order and restore their Lua stack (`MemoryApiTests`). 12. Inspection publishes complete snapshots only, and distinguishes `nil`, malformed result, unavailable global and Lua failure (`EngineInspectionTests`). -13. Allocation ownership is consumed once, AOB lists are owned deterministically, and the MemScan/FoundList state - machine - destroys its child before its parent (`AllocatedRegionTests`, `AobScannerTests`, `MemoryScanSessionTests`). +13. Allocation ownership is consumed once; `TryAllocate` never yields a live address without an owner or a reported + compensation, a release after detach reports `NotInvoked` and after re-enable is refused, and a released region never + frees a later allocation at the same address (`AllocatedRegionTests`, `TargetMemoryAllocatorTests`, + `AllocationLifecycleTests`, `TargetBoundAllocationTests`). AOB lists are owned deterministically (a failed owner + publication destroys the raw list once), and the MemScan/FoundList state machine destroys its child before its + parent (`AobScannerTests`, `AobScannerPublicationTests`, `MemoryScanSessionTests`). 14. Address-list and memory-record wrappers remain borrowed and intentionally do not assert an unproven main-thread - contract; ID-addressed mutations validate hierarchy and preserve indeterminate host effects - (`AddressListValueTests`, `AddressListLuaTests`, `AddressListMutationsTests`). -15. Runtime metadata preserves unknown fields; target and host address spaces cannot be mixed; expected memory failures - do not become exceptions (`RuntimeContractsTests`, `MemoryApiTests`). + contract; ID-addressed mutations validate hierarchy and preserve indeterminate host effects; activation reports the + before and after state, a host refusal, a pending asynchronous activation or an indeterminate effect and never + retries; mutations are refused during a table load (`AddressListValueTests`, `AddressListLuaTests`, + `AddressListMutationsTests`, `MemoryRecordActivationTests`, `AddressListExitTests`). +15. Runtime metadata preserves unknown fields; an SDK-produced `RuntimeInfo` reports CE's configured pointer size and + lists only probed capabilities; runtime probes call only read-only globals; target and host address spaces cannot be + mixed; expected memory failures do not become exceptions (`RuntimeContractsTests`, `RuntimeObservationsTests`, + `MemoryApiTests`). 16. Module, section, symbol and region calls distinguish documented `nil` from Lua/binding/malformed-result failures and never publish a partial copied destination (`EngineInspectionTests`). 17. Allocation, AOB, StringList, scan-session and address-list tests exercise ownership transfer, zero-based access, - deterministic child-before-parent cleanup, forbidden scan state transitions, and coordinator-qualified symbol - cleanup (`AllocatedRegionTests`, `AobScannerTests`, `StringListTests`, `MemoryScanSessionTests`, - `AddressListLuaTests`, `AddressListMutationsTests`, `SymbolRegistryTests`). These are fixture contracts, - not a substitute for a controlled CE 7.7 live run. + deterministic child-before-parent cleanup, forbidden scan state transitions, the chapter-13 scan battery, symbol + leases that never unregister a replaced or removed name, and symbol lists unregistered before they are destroyed + (`AllocatedRegionTests`, `AobScannerTests`, `StringListTests`, `MemoryScanSessionTests`, + `MemoryScanSessionFactoryTests`, `MemoryScanSessionBatteryTests`, `AddressListLuaTests`, + `AddressListMutationsTests`, `SymbolRegistryTests`, `SymbolLeaseReplacementTests`, `SymbolListTests`). These are + fixture contracts, not a substitute for a controlled CE 7.7 live run. +18. Instruction profiles map CE's x86 family plus 64-bit flag to x64 and refuse contradictory, absent, no-target and + file-as-process facts; the assembler echoes the origin, preference and range-check option it sent; target selection + refuses local incarnation evidence for CEServer, unknown-backend and file-as-process targets + (`InstructionOperationsTests`, `InstructionAssemblerTests`, `RuntimeProcessOperationsTests`, + `RuntimeObservationsTests`, `TargetSelectionTests`, `RuntimeCapabilityProbesTests`). These are C1/C2 fixture + contracts, not host qualification. +19. AOB zero matches on the pinned profile surface as `NoResult`, never `NoMatches`; only the bounded MemScan route + reports a factual `NoMatches`. Bounded scans are exhaustive (`OnlyOneResult` off), bound CE's work to + `[Start, Stop)`, post-filter the start, name their four limits and report host-scan and copy durations separately; + first-found stays a separately named experimental opt-in; a session stops a running scan cooperatively, once, before + destroying the child and then the parent, and a release requested from inside one of its own CE waits is deferred + until that wait has returned (`AobScannerTests`, `AobBoundedScanTests`, `AobFirstFoundScanTests`, + `MemoryScanSessionReleaseTests`, `MemoryScanSessionDeadlineTests`). These are fixture contracts; the Q27–Q29 C3 host + receipts are still pending. +20. Auto Assembler activation reports a factual outcome with its effect state, keeps every result CE returns (disable + information, warnings), copies host text only on request and bounded, never derives a category from it, publishes a + bounded disable-info snapshot that never fails the activation, and `TryCheck` never creates an owner + (`AutoAssemblerOutcomeTests`, `AutoAssemblerDisableInfoSnapshotTests`, `AutoAssemblerPatcherTests`). +21. `CheatTableFiles.TryLoad` passes exactly the path and the merge flag, and its load scope refuses re-entrant + address-list mutations and ends on every exit path (`CheatTableFilesTests`). +22. No shipping library declares a finalizer or binds the global that deletes every registered symbol + (`OwnershipPolicyTests` in `tests/CheatEngine.SDK.Repository.Tests`). ## Run the tests diff --git a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineHostObservation.cs b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineHostObservation.cs new file mode 100644 index 00000000..f9473272 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineHostObservation.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; + +namespace CheatEngine.SDK.Engine.Runtime; + +/// A copied observation of facts about the Cheat Engine host process itself, independent of any target. +/// +/// +/// Each field comes from exactly one Cheat Engine global and nothing is inferred between them: in particular +/// never fills . A or +/// Unknown field means the global is absent (for , also that CE returned no +/// version because its version resource was unreadable). +/// +/// +/// Produced by RuntimeHostOperations.ObserveHost. The spike C3 values for CE 7.7.0.10621 x64 (Lua-only, +/// ObservedHost design input) are file version 7.7.0.10621, system architecture x86_64, 64-bit and Windows. +/// +/// +/// The complete file version from getCheatEngineFileVersion, or . +/// The host architecture from getSystemArchitecture, or unknown. +/// Whether cheatEngineIs64Bit reported a 64-bit Cheat Engine, or . +/// The operating system from getOperatingSystem, or unknown. +[StructLayout(LayoutKind.Auto)] +public readonly record struct CheatEngineHostObservation( + CheatEngineVersion? FileVersion, + CheatEngineArchitecture SystemArchitecture, + bool? CheatEngineIs64Bit, + CheatEngineOperatingSystem OperatingSystem); diff --git a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineOperatingSystem.cs b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineOperatingSystem.cs new file mode 100644 index 00000000..9c39fbcb --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineOperatingSystem.cs @@ -0,0 +1,29 @@ +namespace CheatEngine.SDK.Engine.Runtime; + +/// The operating system Cheat Engine reports it runs on, decoded from getOperatingSystem. +/// +/// +/// Values are semantic, not the raw Lua integers: maps the +/// codes documented by the CE 7.7 Lua catalogue (celua.txt:14: 0, 1, 2 for Windows, macOS and Linux). +/// The public CE source at ec45d5f returns 0 on Windows and 1 on every other build (LuaHandler.pas:14863-14867, +/// ObservedSource), so a non-Windows code is catalogue evidence only. +/// +/// +/// Only is part of the qualified profile (code 0, observed on CE 7.7.0.10621 x64 by spike +/// C3, a Lua-only design input). Another value is a reported fact, not a supported host. +/// +/// +public enum CheatEngineOperatingSystem : byte +{ + /// No operating-system fact is available. + Unknown = 0, + + /// Cheat Engine reported Windows (code 0). + Windows = 1, + + /// Cheat Engine reported macOS (code 1). + MacOS = 2, + + /// Cheat Engine reported Linux (code 2). + Linux = 3 +} diff --git a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs index f98e87d3..a1c63ec5 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs @@ -10,9 +10,10 @@ namespace CheatEngine.SDK.Engine.Runtime; /// /// /// CE 7.7 exposes the complete major, minor, release and build fields through -/// getCheatEngineFileVersion. The current EngineApi grammar cannot marshal that Lua table, so this type must -/// not be constructed by converting getCEVersion's floating-point result. It is immutable and does not query -/// Cheat Engine. +/// getCheatEngineFileVersion, which the SDK reads with +/// CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetCheatEngineFileVersion (a hand-written +/// binding: the call returns a packed integer and a table). This type must never be constructed by converting +/// getCEVersion's floating-point result. It is immutable and does not query Cheat Engine. /// [StructLayout(LayoutKind.Sequential)] public readonly struct CheatEngineVersion : IEquatable, IComparable @@ -109,9 +110,9 @@ public override int GetHashCode() public override string ToString() { return Major.ToString(CultureInfo.InvariantCulture) + "." + - Minor.ToString(CultureInfo.InvariantCulture) + "." + - Release.ToString(CultureInfo.InvariantCulture) + "." + - Build.ToString(CultureInfo.InvariantCulture); + Minor.ToString(CultureInfo.InvariantCulture) + "." + + Release.ToString(CultureInfo.InvariantCulture) + "." + + Build.ToString(CultureInfo.InvariantCulture); } /// Tests two versions for equality. diff --git a/libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs b/libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs index e68665ba..2a2e75de 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs @@ -40,9 +40,26 @@ public PointerSize(int bytes) /// Gets a value indicating whether the width has been established. public bool IsKnown => _bytes != 0; - /// Derives the width implied by a known process architecture. - /// The process architecture. - /// The corresponding width, or when is unknown. + /// Returns the natural instruction-set width of an architecture: 4 bytes for x86 and ARM32, 8 for x64 and ARM64. + /// The architecture. + /// The natural ISA width, or when is unknown. + /// + /// + /// Obsolete (CESDK7001). An architecture determines neither Cheat Engine's configured pointer size nor + /// the target bitness: on CE 7.7.0.10621 x64, getPointerSize reported 4 on a 64-bit x64 target after + /// setPointerSize(4), and readPointer kept following the 64-bit flag (spike C3 D3, Lua-only, + /// ObservedHost design input). Use for the + /// configured size and for the bitness. + /// + /// + /// The method remains, unchanged, for binary compatibility with CheatEngine.SDK 1.0.0 and is removed no earlier + /// than the next major version. + /// + /// + [Obsolete( + "An architecture does not determine Cheat Engine's configured pointer size or the target bitness. Use TargetArchitectureObservation.ConfiguredPointerSize or Bitness.", + DiagnosticId = "CESDK7001", + UrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md")] public static PointerSize FromArchitecture(CheatEngineArchitecture architecture) { return architecture switch @@ -65,7 +82,9 @@ public static PointerSize FromArchitecture(CheatEngineArchitecture architecture) /// /// /// This is explicit primitive marshalling, not an unmanaged-struct projection. A 32-bit target still consumes - /// four bytes when this SDK runs in CE's supported 64-bit host process. + /// four bytes when this SDK runs in CE's supported 64-bit host process. Little-endian byte order is an assumption + /// of the local x86/x64 target profile (audit A12-04), not a general Cheat Engine fact: the SDK does not observe + /// a target's byte order. /// public bool TryReadLittleEndian(ReadOnlySpan source, out ulong value) { @@ -112,7 +131,8 @@ public bool TryReadLittleEndian(ReadOnlySpan source, out ulong value) /// /// /// This is explicit primitive marshalling, not an unmanaged-struct projection. A 32-bit target rejects high - /// bits instead of silently truncating them through the x64 host process. + /// bits instead of silently truncating them through the x64 host process. Little-endian byte order is an + /// assumption of the local x86/x64 target profile (audit A12-04), not a general Cheat Engine fact. /// public bool TryWriteLittleEndian(ulong value, Span destination) { diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityId.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityId.cs index 57f15f61..0b05b113 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityId.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeCapabilityId.cs @@ -44,6 +44,21 @@ public RuntimeCapabilityId(string value) /// Gets the SDK capability for the target ABI query. public static RuntimeCapabilityId TargetAbi => new("Runtime.TargetAbi"); + /// Gets the SDK capability for Cheat Engine's configured pointer size, which is not the target bitness. + public static RuntimeCapabilityId ConfiguredPointerSize => new("Runtime.ConfiguredPointerSize"); + + /// Gets the SDK capability for whether the Cheat Engine host process is 64-bit. + public static RuntimeCapabilityId CheatEngineBitness => new("Runtime.CheatEngineBitness"); + + /// Gets the SDK capability for the operating system Cheat Engine reports. + public static RuntimeCapabilityId OperatingSystem => new("Runtime.OperatingSystem"); + + /// Gets the SDK capability for whether Cheat Engine reports an Android target. + public static RuntimeCapabilityId TargetAndroid => new("Runtime.TargetAndroid"); + + /// Gets the SDK capability for establishing the target backend (local process or CEServer). + public static RuntimeCapabilityId TargetBackend => new("Runtime.TargetBackend"); + /// public bool Equals(RuntimeCapabilityId other) { diff --git a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeInfo.cs b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeInfo.cs index 36fc5136..7a1c7908 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/RuntimeInfo.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/RuntimeInfo.cs @@ -3,24 +3,37 @@ namespace CheatEngine.SDK.Engine.Runtime; /// -/// An immutable, caller-supplied Cheat Engine runtime snapshot: complete version, CE host and target architecture, +/// An immutable Cheat Engine runtime snapshot: complete version, CE host and target architecture, CE's configured /// pointer size, target ABI, and optional capability observations. /// /// -/// This type makes no runtime call and does not infer one fact from another. In particular, -/// stays until target probes -/// establish it, and may contain unknown availability or contract fields. +/// +/// The SDK produces a snapshot with CheatEngine.SDK.Engine.Processes.RuntimeObservations.TryObserveRuntimeInfo, +/// which fills and, when a target is selected, ; the legacy properties are +/// then derived from those observations without inference. A snapshot can also carry caller-supplied facts +/// through the legacy constructor, in which case and are +/// . +/// +/// +/// This type makes no runtime call and does not infer one fact from another. In particular, +/// stays until target probes +/// establish it, and may contain unknown availability or contract fields. +/// /// public sealed class RuntimeInfo { - /// Initializes a runtime snapshot from explicit observations. + /// Initializes a runtime snapshot from explicit, caller-supplied facts. /// The complete Cheat Engine file version. /// The CE host architecture reported by getSystemArchitecture. /// The architecture established by target probes, or unknown. - /// The observed pointer width for the applicable process context, or unknown. + /// + /// The pointer size the caller attributes to this snapshot, or unknown. An SDK-produced snapshot uses Cheat + /// Engine's configured pointer size (getPointerSize) here. + /// /// The ABI family reported by getABI, or unknown. /// The immutable optional-capability observations for this snapshot. /// is . + /// This constructor records caller-supplied facts as-is; and stay . public RuntimeInfo( CheatEngineVersion version, CheatEngineArchitecture systemArchitecture, @@ -37,7 +50,52 @@ public RuntimeInfo( Capabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities)); } - /// Gets the complete CE file version supplied for this snapshot. + /// Initializes a runtime snapshot from separate host and target observations. + /// The Cheat Engine host facts. + /// The selected target's facts, or when no target facts were observed. + /// The immutable optional-capability observations for this snapshot. + /// is . + /// + /// The legacy properties are derived without inference: is the observed file version or + /// the default value when it was not observed, is + /// , is Cheat Engine's + /// configured pointer size (, not the target + /// bitness), and is ; each is unknown + /// without a target. + /// + public RuntimeInfo(CheatEngineHostObservation host, TargetArchitectureObservation? target, + RuntimeCapabilities capabilities) + { + Host = host; + Target = target; + Version = host.FileVersion ?? default; + SystemArchitecture = host.SystemArchitecture; + TargetArchitecture = target?.Architecture ?? CheatEngineArchitecture.Unknown; + PointerSize = target?.ConfiguredPointerSize ?? PointerSize.Unknown; + TargetAbi = target?.Abi ?? TargetAbi.Unknown; + Capabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities)); + } + + /// Gets the Cheat Engine host observation of an SDK-produced snapshot, or for caller-supplied facts. + public CheatEngineHostObservation? Host + { + get; + } + + /// + /// Gets the selected target's observation of an SDK-produced snapshot, or when no target + /// was selected, its facts could not be read, or the snapshot holds caller-supplied facts. + /// + /// The target bitness is ; it is not . + public TargetArchitectureObservation? Target + { + get; + } + + /// + /// Gets the complete CE file version of this snapshot; the default value means it was not observed (for an + /// SDK-produced snapshot, getCheatEngineFileVersion was absent or returned no value). + /// public CheatEngineVersion Version { get; @@ -55,7 +113,15 @@ public CheatEngineArchitecture TargetArchitecture get; } - /// Gets the observed pointer width for the applicable process context, or unknown. + /// + /// Gets Cheat Engine's configured pointer size (getPointerSize) when the SDK produced this snapshot, or the + /// caller-supplied width; unknown when it was not observed or is not 4 or 8 bytes. + /// + /// + /// This is not the target bitness: Cheat Engine keeps the configured size separately, setPointerSize can + /// change it, and readPointer follows the bitness instead (spike C3 D3). Read the bitness from + /// through . + /// public PointerSize PointerSize { get; @@ -99,6 +165,113 @@ public static bool TryDecodeSystemArchitecture(int code, out CheatEngineArchitec } } + /// + /// Derives a target architecture from Cheat Engine's ISA-family and bitness facts (targetIsX86, + /// targetIsArm and targetIs64Bit) without using any pointer width. + /// + /// The value of targetIsX86. + /// The value of targetIsArm. + /// The value of targetIs64Bit, Cheat Engine's 64-bit process flag. + /// + /// or for the x86 family, + /// or for the ARM + /// family; when the families are contradictory. + /// + /// + /// when exactly one family flag is set; when both or neither + /// are set. + /// + /// + /// + /// Cheat Engine reports x86-64 as the x86 family plus the 64-bit flag: an x64 target has + /// targetIsX86() == true and targetIs64Bit() == true. That was observed on CE 7.7.0.10621 x64 + /// for an x64 and an x86 target (spike C3 D2, ObservedHost, Lua-only, 2026-09-22) and matches + /// TSystemArchitecture=(archX86=0, archArm=1) with x86_64 setting archX86 together with the + /// 64-bit flag (ProcessHandlerUnit.pas:24, :115-138 at cheat-engine/cheat-engine@ec45d5f, + /// ObservedSource). The 64-bit flag therefore never selects an ISA family by itself. + /// + /// + /// This is a pure function. With no target selected Cheat Engine also reports x86 family plus 64-bit, so a + /// caller must read getOpenedProcessID first and never derive an architecture for process identifier + /// 0. The mapping is fixture-tested (C1/C2); it is not a host qualification. + /// + /// + public static bool TryDeriveTargetArchitecture(bool isX86Family, bool isArmFamily, bool is64Bit, + out CheatEngineArchitecture architecture) + { + if (isX86Family == isArmFamily) + { + architecture = CheatEngineArchitecture.Unknown; + return false; + } + + if (isX86Family) + { + architecture = is64Bit ? CheatEngineArchitecture.X64 : CheatEngineArchitecture.X86; + return true; + } + + architecture = is64Bit ? CheatEngineArchitecture.Arm64 : CheatEngineArchitecture.Arm32; + return true; + } + + /// Decodes a CE 7.7 getOperatingSystem result: 0=Windows, 1=macOS, 2=Linux. + /// The raw CE Lua integer. + /// The decoded operating system, or . + /// only for a code documented by the CE 7.7 Lua catalogue. + /// + /// The codes follow celua.txt:14 (CE 7.7.0.10621 x64, ExactInstalledFile). The public CE source at ec45d5f + /// returns 1 for every non-Windows build (LuaHandler.pas:14863-14867, ObservedSource), so a non-Windows + /// code is catalogue evidence only; only 0 was observed on the qualified profile (spike C3). + /// + public static bool TryDecodeOperatingSystem(int code, out CheatEngineOperatingSystem operatingSystem) + { + switch (code) + { + case 0: + operatingSystem = CheatEngineOperatingSystem.Windows; + return true; + case 1: + operatingSystem = CheatEngineOperatingSystem.MacOS; + return true; + case 2: + operatingSystem = CheatEngineOperatingSystem.Linux; + return true; + default: + operatingSystem = CheatEngineOperatingSystem.Unknown; + return false; + } + } + + /// + /// Splits the packed integer that getCheatEngineFileVersion returns first into a complete file version: + /// major, minor, release and build, 16 bits each from the most significant. + /// + /// The non-negative packed Lua integer, for example 0x700070000297D for 7.7.0.10621. + /// The four components, or the default value when is negative. + /// + /// for a negative value: a major component above 0x7FFF sets the sign bit of the + /// packed Lua integer, and such a version is refused rather than read back from a negative number. + /// + /// + /// The layout (major shl 48 or minor shl 32 or release shl 16 or build) was observed on CE 7.7.0.10621 x64 + /// (spike C3 D5, Lua-only, ObservedHost design input) and matches lua_getFileVersion in the public source + /// (LuaHandler.pas:13271-13318 at ec45d5f, ObservedSource). This never converts getCEVersion's + /// floating-point value. + /// + public static bool TryDecodeFileVersion(long packed, out CheatEngineVersion version) + { + if (packed < 0) + { + version = default; + return false; + } + + version = new CheatEngineVersion((int) ((packed >> 48) & 0xFFFF), (int) ((packed >> 32) & 0xFFFF), + (int) ((packed >> 16) & 0xFFFF), (int) (packed & 0xFFFF)); + return true; + } + /// Decodes a CE 7.7 getABI result: 0 for Windows and 1 for Unix/Linux. /// The raw CE Lua integer. /// The decoded ABI family, or . diff --git a/libs/CheatEngine.SDK.Engine/Runtime/TargetArchitectureObservation.cs b/libs/CheatEngine.SDK.Engine/Runtime/TargetArchitectureObservation.cs new file mode 100644 index 00000000..9bad0e18 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Runtime/TargetArchitectureObservation.cs @@ -0,0 +1,147 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Inspection; + +namespace CheatEngine.SDK.Engine.Runtime; + +/// +/// A copied observation of Cheat Engine's facts about its selected target: backend, bitness, ISA family, Android, +/// ABI and configured pointer size, each kept separate. +/// +/// +/// +/// Produced by RuntimeProcessOperations.ObserveTargetArchitecture, which reads the selected process +/// identifier before and after these facts in one Lua admission and never reads a fact when no target is +/// selected. Each fact comes from one Cheat Engine global; a or Unknown fact +/// means its global is absent, never . The computed members below never fill a missing +/// fact from another one. +/// +/// +/// is Cheat Engine's 64-bit process flag (targetIs64Bit, CE's +/// processhandler.is64Bit; setAssemblerMode writes the same flag). CE's readPointer +/// follows it. is a different fact: the per-attachment value of +/// getPointerSize, which setPointerSize can set to any integer and which (re)attaching resets. +/// Spike C3 D3 (CE 7.7.0.10621 x64, Lua-only, ObservedHost design input) observed a configured size of 4 on a +/// 64-bit target while readPointer still read 8 bytes. Neither fact is derived from the other, from an +/// architecture, or from the plugin's IntPtr.Size. +/// +/// +/// The observation describes what Cheat Engine reports about the target, including a CEServer target. It is +/// not an incarnation proof: Targets.TargetSelection decides whether a local process identity can be +/// established. Consumers may construct values for tests or composition; a constructed value carries whatever +/// its creator asserts. +/// +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct TargetArchitectureObservation +{ + /// Initializes a target observation from explicit facts. + /// The selected process identifier from getOpenedProcessID. + /// The backend established from isConnectedToCEServer. + /// Cheat Engine's 64-bit process flag as a width: 8 bytes when set, 4 otherwise. + /// targetIsX86, or when the global is absent. + /// targetIsArm, or when the global is absent. + /// targetIsAndroid, or when the global is absent. + /// The raw getABI integer, or when the global is absent. + /// + /// The raw getPointerSize integer, any value Cheat Engine returned, or when the + /// global is absent. + /// + public TargetArchitectureObservation(TargetProcessId processId, TargetBackend backend, PointerSize bitness, + bool? isX86Family, bool? isArmFamily, bool? isAndroid, int? abiCode, int? configuredPointerSizeBytes) + { + ProcessId = processId; + Backend = backend; + Bitness = bitness; + IsX86Family = isX86Family; + IsArmFamily = isArmFamily; + IsAndroid = isAndroid; + AbiCode = abiCode; + ConfiguredPointerSizeBytes = configuredPointerSizeBytes; + } + + /// Gets the selected process identifier the facts were read for. + public TargetProcessId ProcessId + { + get; + } + + /// Gets the backend Cheat Engine uses for the target, or unknown when it cannot be established. + public TargetBackend Backend + { + get; + } + + /// Gets Cheat Engine's 64-bit process flag (targetIs64Bit) as a width; this is not the configured pointer size. + public PointerSize Bitness + { + get; + } + + /// Gets whether Cheat Engine reports the x86 ISA family (targetIsX86), or when absent. + /// An x64 target is reported as the x86 family with the 64-bit flag set. + public bool? IsX86Family + { + get; + } + + /// Gets whether Cheat Engine reports the ARM ISA family (targetIsArm), or when absent. + public bool? IsArmFamily + { + get; + } + + /// Gets whether Cheat Engine reports an Android target (targetIsAndroid), or when absent. + /// means the global is absent; it is never read as . + public bool? IsAndroid + { + get; + } + + /// Gets the raw getABI integer, or when the global is absent. + /// An undocumented code is kept here while stays unknown. + public int? AbiCode + { + get; + } + + /// Gets the raw getPointerSize integer, or when the global is absent. + /// Any integer is kept, including values other than 4 and 8 that setPointerSize accepts (spike C3 D3b). + public int? ConfiguredPointerSizeBytes + { + get; + } + + /// Gets the decoded ABI family, or unknown when is absent or undocumented. + public TargetAbi Abi => + AbiCode is int code && RuntimeInfo.TryDecodeTargetAbi(code, out TargetAbi abi) ? abi : TargetAbi.Unknown; + + /// + /// Gets the configured pointer size when is exactly 4 or 8; otherwise + /// unknown. + /// + public PointerSize ConfiguredPointerSize => ConfiguredPointerSizeBytes switch + { + 4 => PointerSize.Bit32, + 8 => PointerSize.Bit64, + _ => PointerSize.Unknown + }; + + /// + /// Gets the architecture derived by when both family facts + /// and the bitness are known; otherwise, or when the families are contradictory, unknown. + /// + public CheatEngineArchitecture Architecture => + IsX86Family is bool isX86 && IsArmFamily is bool isArm && Bitness.IsKnown && + RuntimeInfo.TryDeriveTargetArchitecture(isX86, isArm, Bitness == PointerSize.Bit64, + out CheatEngineArchitecture architecture) + ? architecture + : CheatEngineArchitecture.Unknown; + + /// + /// Gets whether the raw configured pointer size differs from the bitness width (audit Q31.a), or + /// when either fact is unknown. + /// + public bool? ConfiguredPointerSizeDiffersFromBitness => + ConfiguredPointerSizeBytes is int configured && Bitness.IsKnown ? configured != Bitness.Bytes : null; +} diff --git a/libs/CheatEngine.SDK.Engine/Runtime/TargetBackend.cs b/libs/CheatEngine.SDK.Engine/Runtime/TargetBackend.cs new file mode 100644 index 00000000..188ebdd6 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Runtime/TargetBackend.cs @@ -0,0 +1,46 @@ +namespace CheatEngine.SDK.Engine.Runtime; + +/// How Cheat Engine reaches its selected target: a local process, a file opened as a process, or CEServer. +/// +/// +/// The backends produce different evidence and are separate profiles (audit ADR-11, A12-03). +/// is the only qualified backend, the one of the Cheat Engine profile +/// ce-7.7.0.10621-x64-managed-hostfxr; the other backends are unqualified. The SDK recognises the backend +/// as follows: +/// +/// +/// +/// +/// isConnectedToCEServer() returned for a selected process identifier. +/// +/// +/// +/// +/// getOpenedProcessID() returned the file-as-process sentinel 4294967295 (CE source ec45d5f, +/// ObservedSource; not yet observed on the 7.7 binary). +/// +/// +/// +/// +/// isConnectedToCEServer() returned . +/// +/// +/// +/// The isConnectedToCEServer global is absent, so the backend cannot be established. +/// +/// +/// +public enum TargetBackend : byte +{ + /// The backend could not be established. + Unknown = 0, + + /// A local operating-system process; the only qualified backend. + LocalProcess = 1, + + /// A file opened as a process with openFileAsProcess; it has no operating-system process. + FileAsProcess = 2, + + /// A target served remotely by CEServer; a local process identifier and creation time do not describe it. + CEServer = 3 +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScan.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScan.cs new file mode 100644 index 00000000..558a4d49 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScan.cs @@ -0,0 +1,352 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Threading; + +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// +/// The bounded, exhaustive AOB route of : one MemScan/FoundList session, a hexadecimal +/// byte-array first scan over [Start, Stop) with OnlyOneResult off, a wait, and an address-only, +/// start-post-filtered copy into a caller-bounded destination. Every exit releases the session once, child before +/// parent. +/// +internal static class AobBoundedScan +{ + private const string ContextOperation = "AobScanner.TryScanWithinBounds"; + + internal static AobBoundedScanResult Run(string pattern, AobScanBounds bounds, AobScanOptions options, + int? waitMilliseconds, Span
destination, CancellationToken cancellationToken) + { + return Run(pattern, bounds, options, waitMilliseconds, destination, ArrayPool
.Shared, + cancellationToken); + } + + // stagingPool is a test seam, not an extension point: production always passes ArrayPool
.Shared. Tests + // substitute a pool whose Rent fails to prove that a managed allocation failure after the session exists still + // releases it once and propagates unchanged (audit ch.08, F13). + internal static AobBoundedScanResult Run(string pattern, AobScanBounds bounds, AobScanOptions options, + int? waitMilliseconds, Span
destination, ArrayPool
stagingPool, + CancellationToken cancellationToken) + { + if (!bounds.IsValid) + { + return Refused(AobBoundedScanOutcomeKind.InvalidBounds); + } + + RequireEnabledMainThread(); + if (cancellationToken.IsCancellationRequested) + { + return Refused(AobBoundedScanOutcomeKind.Cancelled); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + long started = Stopwatch.GetTimestamp(); + AobBoundedScanFacts facts = new() + { + Creation = MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? session), + Termination = MemoryScanTerminationStatus.NotRequired + }; + if (facts.Creation.Status != MemoryScanCreationStatus.Success || session is null) + { + facts.Kind = AobBoundedScanOutcomeKind.SessionCreationFailed; + } + else + { + // Nothing between the session's publication and RunSession's releasing try may allocate or throw. + RunSession(state, session, pattern, bounds, options, waitMilliseconds, destination, stagingPool, + ref facts, cancellationToken); + } + + facts.TotalElapsed = Stopwatch.GetElapsedTime(started); + return new AobBoundedScanResult(in facts); + } + + // Releases the session once whatever happens. The staging buffer is rented inside the releasing try, so a managed + // allocation failure (a destination too large for one array, memory pressure) still releases the found list and + // then its scanner, and the original exception propagates (audit ch.08, F13). In-bounds addresses are staged so that + // nothing reaches the caller's destination unless the scan succeeds. + private static void RunSession(LuaState state, MemoryScanSession session, string pattern, AobScanBounds bounds, + AobScanOptions options, int? waitMilliseconds, Span
destination, ArrayPool
stagingPool, + ref AobBoundedScanFacts facts, CancellationToken cancellationToken) + { + Address[]? staging = null; + try + { + try + { + staging = stagingPool.Rent(destination.Length); + facts.Kind = Execute(state, session, pattern, bounds, options, waitMilliseconds, + staging.AsSpan(0, destination.Length), ref facts, cancellationToken); + } + catch (MemoryScanException exception) + { + facts.Kind = FromException(exception, ref facts); + } + finally + { + facts.Release = session.ReleaseWithOutcome(); + } + + if (staging is not null && + facts.Kind is AobBoundedScanOutcomeKind.Matches or AobBoundedScanOutcomeKind.NoMatches) + { + staging.AsSpan(0, facts.Written).CopyTo(destination); + } + else + { + facts.Written = 0; + } + } + finally + { + if (staging is not null) + { + stagingPool.Return(staging); + } + } + } + + private static AobBoundedScanOutcomeKind Execute(LuaState state, MemoryScanSession session, string pattern, + AobScanBounds bounds, AobScanOptions options, int? waitMilliseconds, Span
staged, + ref AobBoundedScanFacts facts, CancellationToken cancellationToken) + { + // Exhaustive by construction (F07, A13-07): OnlyOneResult is always switched off before the first scan, and + // IsUnique, Result, getOnlyResult and the scan callbacks are never touched. + LuaStatus onlyOne = session.SetOnlyOneResultCore(state, false); + if (!onlyOne.IsOk) + { + facts.LuaStatus = FailureStatus(onlyOne); + return AobBoundedScanOutcomeKind.ScanFailed; + } + + if (cancellationToken.IsCancellationRequested) + { + return AobBoundedScanOutcomeKind.Cancelled; + } + + // An omitted protection string is CE's "find everything" value, never a nil (celua.txt line 847). + FirstScanRequest request = FirstScanRequest.ByteArray(pattern, bounds.Start, bounds.Stop, + options.ProtectionFlags ?? string.Empty, options.AlignmentMethod, options.AlignmentParameter ?? string.Empty); + long scanStarted = Stopwatch.GetTimestamp(); + session.StartFirstScan(in request); + AobBoundedScanOutcomeKind waited = Wait(state, session, waitMilliseconds, ref facts); + if (waited != AobBoundedScanOutcomeKind.Unknown) + { + return waited; + } + + facts.HostScanElapsed = Stopwatch.GetElapsedTime(scanStarted); + if (cancellationToken.IsCancellationRequested) + { + return AobBoundedScanOutcomeKind.Cancelled; + } + + MemoryScanMaterializationStatus context = session.TryEnsureCurrentContextCore(state); + if (context != MemoryScanMaterializationStatus.Success) + { + return FromContext(context); + } + + long copyStarted = Stopwatch.GetTimestamp(); + try + { + return Copy(state, session, bounds, staged, ref facts, cancellationToken); + } + finally + { + facts.CopyElapsed = Stopwatch.GetElapsedTime(copyStarted); + } + } + + // Returns Unknown when the scan completed and its results are readable; otherwise the terminal outcome. + private static AobBoundedScanOutcomeKind Wait(LuaState state, MemoryScanSession session, int? waitMilliseconds, + ref AobBoundedScanFacts facts) + { + MemoryScanMaterializationStatus context = session.TryEnsureCurrentContextCore(state); + if (context != MemoryScanMaterializationStatus.Success) + { + return FromContext(context); + } + + MemoryScanWaitStatus waited = session.WaitCore(state, waitMilliseconds, true, out LuaStatus luaStatus); + switch (waited) + { + case MemoryScanWaitStatus.Completed: + return AobBoundedScanOutcomeKind.Unknown; + case MemoryScanWaitStatus.TimedOut: + // The call deadline expired: one cooperative stop and one bounded settle wait, never forced or retried. + facts.Termination = + session.TerminateAndSettleCore(state, MemoryScanSession.ReleaseTerminationWaitMilliseconds); + return AobBoundedScanOutcomeKind.WaitTimedOut; + case MemoryScanWaitStatus.InvalidResult: + return AobBoundedScanOutcomeKind.InvalidResult; + default: + facts.LuaStatus = FailureStatus(luaStatus); + return AobBoundedScanOutcomeKind.ScanFailed; + } + } + + private static AobBoundedScanOutcomeKind Copy(LuaState state, MemoryScanSession session, AobScanBounds bounds, + Span
staged, ref AobBoundedScanFacts facts, CancellationToken cancellationToken) + { + ulong count = session.ReadResultCountCore(state); + facts.HostResultCount = count; + facts.UnreadHostRows = count; + ReadHostErrorText(state, session, ref facts); + AobBoundedScanOutcomeKind rows = ReadRows(state, session, bounds, staged, count, ref facts, + cancellationToken, out int staging); + if (rows != AobBoundedScanOutcomeKind.Unknown) + { + return rows; + } + + facts.Written = staging; + facts.IsMaterializationLimitReached = staging == staged.Length && facts.UnreadHostRows > 0; + if (staging > 0) + { + return AobBoundedScanOutcomeKind.Matches; + } + + // A present error text is reported by presence only; its content is never parsed (A07-23, A24-23). + return facts.HostErrorText is null + ? AobBoundedScanOutcomeKind.NoMatches + : AobBoundedScanOutcomeKind.HostReportedError; + } + + // Reads one address per row until the rows or the staging buffer run out. Returns Unknown when the copy completed. + private static AobBoundedScanOutcomeKind ReadRows(LuaState state, MemoryScanSession session, AobScanBounds bounds, + Span
staged, ulong count, ref AobBoundedScanFacts facts, CancellationToken cancellationToken, + out int staging) + { + // Rows are addressed with CE's Int32 index; rows beyond it stay unread and make the in-bounds count inexact. + ulong readable = Math.Min(count, (ulong) int.MaxValue); + staging = 0; + for (int index = 0; (ulong) index < readable && staging < staged.Length; index++) + { + if (cancellationToken.IsCancellationRequested) + { + return AobBoundedScanOutcomeKind.Cancelled; + } + + MemoryScanRowRead row = session.TryReadAddressRowCore(state, index, out Address address, + out LuaStatus luaStatus); + facts.RowsRead++; + facts.UnreadHostRows = count - facts.RowsRead; + if (row == MemoryScanRowRead.LuaFailure) + { + facts.LuaStatus = FailureStatus(luaStatus); + return AobBoundedScanOutcomeKind.ScanFailed; + } + + if (row != MemoryScanRowRead.Read) + { + return AobBoundedScanOutcomeKind.InvalidResult; + } + + // CE's start bound is not byte-exact (spike D4.2): drop and count what begins below it. The stop bound is + // honoured by CE (D4.1); the second check is defensive. + if (address < bounds.Start) + { + facts.BelowStartSkipped++; + } + else if (address >= bounds.Stop) + { + facts.AtOrAfterStopSkipped++; + } + else + { + staged[staging++] = address; + } + } + + return cancellationToken.IsCancellationRequested + ? AobBoundedScanOutcomeKind.Cancelled + : AobBoundedScanOutcomeKind.Unknown; + } + + // A secondary diagnostic: its failure is recorded and never replaces the primary outcome (binding DoD A.10). + private static void ReadHostErrorText(LuaState state, MemoryScanSession session, ref AobBoundedScanFacts facts) + { + if (!session.TryReadHostErrorTextCore(state, out string? text, out bool truncated)) + { + facts.IsHostErrorTextUnreadable = true; + return; + } + + if (text.Length > 0) + { + facts.HostErrorText = text; + facts.IsHostErrorTextTruncated = truncated; + } + } + + private static AobBoundedScanOutcomeKind FromException(MemoryScanException exception, + ref AobBoundedScanFacts facts) + { + switch (exception.FailureKind) + { + case MemoryScanFailureKind.TargetIdentityMismatch: + return AobBoundedScanOutcomeKind.TargetChanged; + case MemoryScanFailureKind.TargetIdentityUnavailable: + return AobBoundedScanOutcomeKind.TargetIdentityUnavailable; + case MemoryScanFailureKind.RuntimeInvalidated: + return AobBoundedScanOutcomeKind.RuntimeInvalidated; + case MemoryScanFailureKind.UnexpectedResult: + return AobBoundedScanOutcomeKind.InvalidResult; + default: + facts.LuaStatus = exception.InnerException is LuaException lua + ? FailureStatus(lua.Status) + : LuaStatus.RuntimeError; + return AobBoundedScanOutcomeKind.ScanFailed; + } + } + + private static AobBoundedScanOutcomeKind FromContext(MemoryScanMaterializationStatus context) + { + return context switch + { + MemoryScanMaterializationStatus.RuntimeInvalidated => AobBoundedScanOutcomeKind.RuntimeInvalidated, + MemoryScanMaterializationStatus.TargetIdentityMismatch => AobBoundedScanOutcomeKind.TargetChanged, + _ => AobBoundedScanOutcomeKind.TargetIdentityUnavailable + }; + } + + private static AobBoundedScanResult Refused(AobBoundedScanOutcomeKind kind) + { + AobBoundedScanFacts facts = new() + { + Kind = kind, + Termination = MemoryScanTerminationStatus.NotRequired + }; + return new AobBoundedScanResult(in facts); + } + + private static LuaStatus FailureStatus(LuaStatus status) + { + return status.IsOk ? LuaStatus.RuntimeError : status; + } + + private static void RequireEnabledMainThread() + { + if (!LuaRuntime.IsAttached) + { + throw new InvalidOperationException( + "The Cheat Engine plugin is not enabled, so " + ContextOperation + " cannot acquire its Lua state."); + } + + if (!LuaRuntime.IsMainThread) + { + throw new InvalidOperationException( + ContextOperation + " must run on Cheat Engine's main thread; it does not dispatch work implicitly."); + } + } +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanFacts.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanFacts.cs new file mode 100644 index 00000000..f71d6cee --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanFacts.cs @@ -0,0 +1,31 @@ +using System; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// The mutable facts collected by one bounded scan before they are frozen into its result. +[StructLayout(LayoutKind.Auto)] +internal struct AobBoundedScanFacts +{ + internal AobBoundedScanOutcomeKind Kind; + internal MemoryScanCreationOutcome Creation; + internal LuaStatus LuaStatus; + internal ulong HostResultCount; + internal int Written; + internal ulong RowsRead; + internal ulong UnreadHostRows; + internal ulong BelowStartSkipped; + internal ulong AtOrAfterStopSkipped; + internal bool IsMaterializationLimitReached; + internal string? HostErrorText; + internal bool IsHostErrorTextTruncated; + internal bool IsHostErrorTextUnreadable; + internal TimeSpan HostScanElapsed; + internal TimeSpan CopyElapsed; + internal TimeSpan TotalElapsed; + internal MemoryScanTerminationStatus Termination; + internal MemoryScanReleaseOutcome Release; +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanOutcomeKind.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanOutcomeKind.cs new file mode 100644 index 00000000..8334533a --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanOutcomeKind.cs @@ -0,0 +1,63 @@ +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// The factual outcome of a bounded, exhaustive AOB scan over a MemScan session. +/// +/// The zero value is ; a default value never reads as success. Only and +/// publish addresses. Categories come from CE result types, counts and the presence of an +/// error text, never from the content of a Lua or CE error message. +/// +public enum AobBoundedScanOutcomeKind +{ + /// No outcome has been observed. + Unknown = 0, + + /// At least one in-bounds match was copied to the destination. + Matches = 1, + + /// + /// The scan completed and no returned row lies in the bounds. When CE's error text was read and empty, this is a + /// factual zero, unlike the global AOBScan route on the pinned profile. When the error text could not be + /// read, is and a host error + /// cannot be excluded: check that flag before treating this outcome as "not found". + /// + NoMatches = 2, + + /// The bounds were empty or inverted; refused before any CE call. + InvalidBounds = 3, + + /// The MemScan/FoundList session could not be created; see . + SessionCreationFailed = 4, + + /// + /// A protected scan call (setOnlyOneResult, firstScan, the wait, initialize, the count or a + /// row read) raised; see . + /// + ScanFailed = 5, + + /// + /// The call deadline expired before CE reported completion; the SDK requested one cooperative stop, reported in + /// . + /// + WaitTimedOut = 6, + + /// + /// The scan completed with no in-bounds row while CE reported a non-empty error text; the text is copied, bounded + /// and unparsed, into . + /// + HostReportedError = 7, + + /// CE returned a malformed wait result, count or row address. + InvalidResult = 8, + + /// The selected target is no longer the incarnation the session was created for; nothing was published. + TargetChanged = 9, + + /// The selected target could not be qualified during the scan; nothing was published. + TargetIdentityUnavailable = 10, + + /// The Lua runtime identity changed during the scan; nothing was published. + RuntimeInvalidated = 11, + + /// Cancellation was observed between CE calls; nothing was published. + Cancelled = 12 +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanResult.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanResult.cs new file mode 100644 index 00000000..120d1288 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobBoundedScanResult.cs @@ -0,0 +1,196 @@ +using System; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// +/// The factual result of a bounded, exhaustive AOB scan: its outcome, the four scan limits it met, the copy +/// accounting, CE's error text, separate host-scan and copy durations, and the session's release. +/// +/// +/// +/// Four limits are distinct: the CE work limit is the passed to CE; +/// is the number of available results CE reported (including rows outside +/// the bounds); the materialization limit is the caller's destination length, reached when +/// ; the call deadline is the optional wait timeout, whose +/// expiry is . +/// +/// +/// The accounting fields describe what the SDK read even on a failure; is non-zero only for +/// a success, because nothing is published otherwise. Addresses are copied in CE's found-list order. The C3 +/// spike observed ascending order, but CE does not document one: never infer "lowest address" from the first +/// element. Uniqueness needs an exhausted domain () or a second in-bounds match +/// ( of two or more), never a first-found scan. +/// +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct AobBoundedScanResult +{ + internal AobBoundedScanResult(in AobBoundedScanFacts facts) + { + Kind = facts.Kind; + Creation = facts.Creation; + LuaStatus = facts.LuaStatus; + HostResultCount = facts.HostResultCount; + Written = facts.Written; + RowsRead = facts.RowsRead; + UnreadHostRows = facts.UnreadHostRows; + BelowStartSkipped = facts.BelowStartSkipped; + AtOrAfterStopSkipped = facts.AtOrAfterStopSkipped; + IsMaterializationLimitReached = facts.IsMaterializationLimitReached; + HostErrorText = facts.HostErrorText; + IsHostErrorTextTruncated = facts.IsHostErrorTextTruncated; + IsHostErrorTextUnreadable = facts.IsHostErrorTextUnreadable; + HostScanElapsed = facts.HostScanElapsed; + CopyElapsed = facts.CopyElapsed; + TotalElapsed = facts.TotalElapsed; + Termination = facts.Termination; + Release = facts.Release; + } + + /// Gets the factual outcome category. + public AobBoundedScanOutcomeKind Kind + { + get; + } + + /// + /// Gets whether addresses were published: or + /// . + /// + public bool IsSuccess => Kind is AobBoundedScanOutcomeKind.Matches or AobBoundedScanOutcomeKind.NoMatches; + + /// + /// Gets the session factory outcome, including the target observation made before the scanner was created; the + /// default value when no session creation was attempted. + /// + public MemoryScanCreationOutcome Creation + { + get; + } + + /// + /// Gets the protected Lua status of the failed call for ; + /// otherwise . + /// + public LuaStatus LuaStatus + { + get; + } + + /// Gets the available-results count CE reported for the found list, including rows outside the bounds. + public ulong HostResultCount + { + get; + } + + /// Gets how many in-bounds addresses were copied to the destination; zero unless the scan succeeded. + public int Written + { + get; + } + + /// Gets how many found-list rows were read (one getAddress call each). + public ulong RowsRead + { + get; + } + + /// Gets how many available rows were not read, because the destination was full or the copy stopped. + public ulong UnreadHostRows + { + get; + } + + /// + /// Gets how many returned addresses lay below and were dropped: CE's start + /// bound is not byte-exact on the pinned profile (spike D4.2). + /// + public ulong BelowStartSkipped + { + get; + } + + /// + /// Gets how many returned addresses lay at or above and were dropped; expected to + /// be zero because CE honours the stop bound (spike D4.1). + /// + public ulong AtOrAfterStopSkipped + { + get; + } + + /// Gets whether the destination filled up while unread rows remained (the materialization limit). + public bool IsMaterializationLimitReached + { + get; + } + + /// + /// Gets whether the in-bounds count of a successful scan is exact, i.e. every available row was read, so that + /// is the number of in-bounds matches. + /// + public bool InBoundsCountIsExact => IsSuccess && UnreadHostRows == 0; + + /// + /// Gets CE's non-empty ErrorString text, copied and bounded to + /// UTF-8 bytes; when it was + /// empty, unreadable or never read. It is a fact, never parsed or classified. + /// + public string? HostErrorText + { + get; + } + + /// Gets whether is a prefix of a longer host text. + public bool IsHostErrorTextTruncated + { + get; + } + + /// Gets whether reading CE's error text failed; this never changes . + public bool IsHostErrorTextUnreadable + { + get; + } + + /// + /// Gets the time from just before the SDK's first-scan call to the end of the successful wait: CE's scan cost, + /// plus a small SDK overhead that this span also contains (two target-identity checks, each one + /// getOpenedProcessID call and one process start-time read, and the FoundList.initialize call). + /// + public TimeSpan HostScanElapsed + { + get; + } + + /// Gets the time spent reading the count, the error text and the rows: the copy cost. + public TimeSpan CopyElapsed + { + get; + } + + /// Gets the time from session creation to the end of its release. + public TimeSpan TotalElapsed + { + get; + } + + /// + /// Gets the cooperative stop the route requested after its call deadline expired; + /// when it requested none. + /// + public MemoryScanTerminationStatus Termination + { + get; + } + + /// Gets the one child-before-parent release of the session; the default value when none was created. + public MemoryScanReleaseOutcome Release + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundOutcomeKind.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundOutcomeKind.cs new file mode 100644 index 00000000..83e74f1c --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundOutcomeKind.cs @@ -0,0 +1,49 @@ +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// The factual outcome of a first-found AOB scan (CE's OnlyOneResult mode). +/// +/// The zero value is ; a default value never reads as found. means +/// "CE stopped on some match in the bounds", never "the only match" or "the lowest match": CE documents the address as +/// the first result found, without an order. +/// +public enum AobFirstFoundOutcomeKind +{ + /// No outcome has been observed. + Unknown = 0, + + /// CE reported a match whose address lies in the bounds; it is some match, not a unique or lowest one. + Found = 1, + + /// CE reported no match: getOnlyResult returned no value or nil. + NotFound = 2, + + /// + /// CE reported a match below the start bound. Indeterminate: CE's start bound is not byte-exact, so CE may have + /// stopped on a match just before the range, and whether an in-bounds match exists is unknown. + /// + FoundOutsideBounds = 3, + + /// The bounds were empty or inverted; refused before any CE call. + InvalidBounds = 4, + + /// The MemScan/FoundList session could not be created; see . + SessionCreationFailed = 5, + + /// A protected scan call raised; see . + ScanFailed = 6, + + /// getOnlyResult returned something other than an integer or no value (a float, string or boolean). + InvalidResult = 7, + + /// The selected target is no longer the incarnation the session was created for; no address is reported. + TargetChanged = 8, + + /// The selected target could not be qualified during the scan; no address is reported. + TargetIdentityUnavailable = 9, + + /// The Lua runtime identity changed during the scan; no address is reported. + RuntimeInvalidated = 10, + + /// Cancellation was observed before the scan started; no address is reported. + Cancelled = 11 +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundResult.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundResult.cs new file mode 100644 index 00000000..4229bc00 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundResult.cs @@ -0,0 +1,73 @@ +using System; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// The factual result of a first-found AOB scan: the reported address, if any, and the session's release. +/// +/// A first-found result is never a uniqueness proof, never the lowest address, and must never back a bounded or +/// range scan, a "require single" query, or any exhaustive query; use the exhaustive bounded route for those. +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct AobFirstFoundResult +{ + internal AobFirstFoundResult(AobFirstFoundOutcomeKind kind, Address address, MemoryScanCreationOutcome creation, + LuaStatus luaStatus, TimeSpan hostScanElapsed, MemoryScanReleaseOutcome release) + { + Kind = kind; + Address = address; + Creation = creation; + LuaStatus = luaStatus; + HostScanElapsed = hostScanElapsed; + Release = release; + } + + /// Gets the factual outcome category. + public AobFirstFoundOutcomeKind Kind + { + get; + } + + /// + /// Gets the address CE reported, for and + /// ; otherwise zero. + /// + public Address Address + { + get; + } + + /// Gets whether CE reported an address ( is meaningful). + public bool HasAddress => Kind is AobFirstFoundOutcomeKind.Found or AobFirstFoundOutcomeKind.FoundOutsideBounds; + + /// Gets the session factory outcome; the default value when no session creation was attempted. + public MemoryScanCreationOutcome Creation + { + get; + } + + /// + /// Gets the protected Lua status of the failed call for ; + /// otherwise . + /// + public LuaStatus LuaStatus + { + get; + } + + /// Gets the time from just before firstScan to the end of the wait. + public TimeSpan HostScanElapsed + { + get; + } + + /// Gets the one child-before-parent release of the session; the default value when none was created. + public MemoryScanReleaseOutcome Release + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundScan.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundScan.cs new file mode 100644 index 00000000..cd0b5025 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobFirstFoundScan.cs @@ -0,0 +1,194 @@ +using System; +using System.Diagnostics; +using System.Threading; + +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// +/// The first-found AOB route of : one MemScan/FoundList session with OnlyOneResult +/// on, a byte-array first scan over [Start, Stop), a wait, and one getOnlyResult read. The found list is +/// never initialized (CE documents that the one-result mode does not fill it). Every exit releases the session once. +/// +internal static class AobFirstFoundScan +{ + private const string ContextOperation = "AobScanner.TryFindFirstFoundWithinBounds"; + + internal static AobFirstFoundResult Run(string pattern, AobScanBounds bounds, AobScanOptions options, + CancellationToken cancellationToken) + { + if (!bounds.IsValid) + { + return Refused(AobFirstFoundOutcomeKind.InvalidBounds); + } + + RequireEnabledMainThread(); + if (cancellationToken.IsCancellationRequested) + { + return Refused(AobFirstFoundOutcomeKind.Cancelled); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + MemoryScanCreationOutcome creation = MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? session); + if (creation.Status != MemoryScanCreationStatus.Success || session is null) + { + return new AobFirstFoundResult(AobFirstFoundOutcomeKind.SessionCreationFailed, Address.Zero, creation, + LuaStatus.Ok, TimeSpan.Zero, default); + } + + AobFirstFoundOutcomeKind kind; + Address address = Address.Zero; + LuaStatus luaStatus = LuaStatus.Ok; + TimeSpan hostScanElapsed = TimeSpan.Zero; + MemoryScanReleaseOutcome release; + try + { + kind = Execute(state, session, pattern, bounds, options, cancellationToken, out address, out luaStatus, + out hostScanElapsed); + } + catch (MemoryScanException exception) + { + kind = FromException(exception, out luaStatus); + } + finally + { + release = session.ReleaseWithOutcome(); + } + + if (kind is not (AobFirstFoundOutcomeKind.Found or AobFirstFoundOutcomeKind.FoundOutsideBounds)) + { + address = Address.Zero; + } + + return new AobFirstFoundResult(kind, address, creation, luaStatus, hostScanElapsed, release); + } + + private static AobFirstFoundOutcomeKind Execute(LuaState state, MemoryScanSession session, string pattern, + AobScanBounds bounds, AobScanOptions options, CancellationToken cancellationToken, out Address address, + out LuaStatus luaStatus, out TimeSpan hostScanElapsed) + { + address = Address.Zero; + hostScanElapsed = TimeSpan.Zero; + luaStatus = session.SetOnlyOneResultCore(state, true); + if (!luaStatus.IsOk) + { + luaStatus = FailureStatus(luaStatus); + return AobFirstFoundOutcomeKind.ScanFailed; + } + + if (cancellationToken.IsCancellationRequested) + { + return AobFirstFoundOutcomeKind.Cancelled; + } + + FirstScanRequest request = FirstScanRequest.ByteArray(pattern, bounds.Start, bounds.Stop, + options.ProtectionFlags ?? string.Empty, options.AlignmentMethod, options.AlignmentParameter ?? string.Empty); + long scanStarted = Stopwatch.GetTimestamp(); + session.StartFirstScan(in request); + MemoryScanMaterializationStatus context = session.TryEnsureCurrentContextCore(state); + if (context != MemoryScanMaterializationStatus.Success) + { + return FromContext(context); + } + + // The one-result mode does not fill the found list: never initialize it (celua.txt line 2656). + MemoryScanWaitStatus waited = session.WaitCore(state, null, false, out luaStatus); + hostScanElapsed = Stopwatch.GetElapsedTime(scanStarted); + if (waited != MemoryScanWaitStatus.Completed) + { + luaStatus = FailureStatus(luaStatus); + return AobFirstFoundOutcomeKind.ScanFailed; + } + + context = session.TryEnsureCurrentContextCore(state); + if (context != MemoryScanMaterializationStatus.Success) + { + return FromContext(context); + } + + return Classify(session.TryReadOnlyResultCore(state, out address, out luaStatus), bounds, address, + ref luaStatus); + } + + private static AobFirstFoundOutcomeKind Classify(MemoryScanOnlyResult read, AobScanBounds bounds, Address address, + ref LuaStatus luaStatus) + { + switch (read) + { + case MemoryScanOnlyResult.Found: + // A match below Start is indeterminate: CE's start bound is not byte-exact (spike D4.2), so it may have + // stopped on a match just before the range; whether an in-bounds match exists is unknown. + return bounds.Contains(address) + ? AobFirstFoundOutcomeKind.Found + : AobFirstFoundOutcomeKind.FoundOutsideBounds; + case MemoryScanOnlyResult.NotFound: + return AobFirstFoundOutcomeKind.NotFound; + case MemoryScanOnlyResult.LuaFailure: + luaStatus = FailureStatus(luaStatus); + return AobFirstFoundOutcomeKind.ScanFailed; + default: + return AobFirstFoundOutcomeKind.InvalidResult; + } + } + + private static AobFirstFoundOutcomeKind FromException(MemoryScanException exception, out LuaStatus luaStatus) + { + luaStatus = LuaStatus.Ok; + switch (exception.FailureKind) + { + case MemoryScanFailureKind.TargetIdentityMismatch: + return AobFirstFoundOutcomeKind.TargetChanged; + case MemoryScanFailureKind.TargetIdentityUnavailable: + return AobFirstFoundOutcomeKind.TargetIdentityUnavailable; + case MemoryScanFailureKind.RuntimeInvalidated: + return AobFirstFoundOutcomeKind.RuntimeInvalidated; + case MemoryScanFailureKind.UnexpectedResult: + return AobFirstFoundOutcomeKind.InvalidResult; + default: + luaStatus = exception.InnerException is LuaException lua ? FailureStatus(lua.Status) : LuaStatus.RuntimeError; + return AobFirstFoundOutcomeKind.ScanFailed; + } + } + + private static AobFirstFoundOutcomeKind FromContext(MemoryScanMaterializationStatus context) + { + return context switch + { + MemoryScanMaterializationStatus.RuntimeInvalidated => AobFirstFoundOutcomeKind.RuntimeInvalidated, + MemoryScanMaterializationStatus.TargetIdentityMismatch => AobFirstFoundOutcomeKind.TargetChanged, + _ => AobFirstFoundOutcomeKind.TargetIdentityUnavailable + }; + } + + private static AobFirstFoundResult Refused(AobFirstFoundOutcomeKind kind) + { + return new AobFirstFoundResult(kind, Address.Zero, default, LuaStatus.Ok, TimeSpan.Zero, default); + } + + private static LuaStatus FailureStatus(LuaStatus status) + { + return status.IsOk ? LuaStatus.RuntimeError : status; + } + + private static void RequireEnabledMainThread() + { + if (!LuaRuntime.IsAttached) + { + throw new InvalidOperationException( + "The Cheat Engine plugin is not enabled, so " + ContextOperation + " cannot acquire its Lua state."); + } + + if (!LuaRuntime.IsMainThread) + { + throw new InvalidOperationException( + ContextOperation + " must run on Cheat Engine's main thread; it does not dispatch work implicitly."); + } + } +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobResultListPublisher.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobResultListPublisher.cs new file mode 100644 index 00000000..01a9e042 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobResultListPublisher.cs @@ -0,0 +1,14 @@ +using CheatEngine.SDK.Engine.Objects; + +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// Internal ownership-publication seam used only to prove the AOB result-list rollback. +/// +/// This delegate is not a consumer extension point. always publishes the caller-owned +/// StringList through its own SDK-sourced owner construction after a protected AOBScan call returned +/// a host object. Tests substitute a failing publisher to prove that the still-unpublished raw list is destroyed +/// exactly once and that the original failure propagates. +/// +/// The borrowed handle of the list that AOBScan returned. +/// The sole owner of that list. +internal delegate Owned AobResultListPublisher(StringList list); diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanBounds.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanBounds.cs new file mode 100644 index 00000000..b405d2e7 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanBounds.cs @@ -0,0 +1,107 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// +/// A non-empty, half-open target-address range [Start, Stop): the CE work limit of +/// . +/// +/// +/// +/// is exclusive and is passed to CE as the MemScan stop address: on the pinned profile +/// ce-7.7.0.10621-x64-managed-hostfxr, CE reports a match only when it fits entirely below it (Lua-only host +/// observation, spike 2026-09-22, D4.1). is inclusive for the caller, but CE does not treat it +/// byte-exactly (D4.2): the bounded route drops, and counts, every address CE returns below it. +/// +/// +/// The name deliberately differs from the Client's AobScanRange, whose end is an inclusive filter on the +/// match start: translate between the two rather than passing one for the other. Instances come only from +/// and ; the default value is invalid, and an invalid value +/// is refused before any CE call. +/// +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct AobScanBounds +{ + private AobScanBounds(Address start, Address stop) + { + Start = start; + Stop = stop; + } + + /// Gets the inclusive lower bound. The bounded route post-filters CE results against it. + public Address Start + { + get; + } + + /// Gets the exclusive upper bound: the CE work limit, passed to CE as the MemScan stop address. + public Address Stop + { + get; + } + + /// Gets the number of bytes in [Start, Stop), or zero for an invalid value. + public ulong Length => IsValid ? Stop.Value - Start.Value : 0; + + /// Gets whether the range is non-empty. The default value is not. + public bool IsValid => Start < Stop; + + /// Gets whether an address lies in [Start, Stop). + /// The target address to test. + /// + /// when is at or above and below + /// . + /// + public bool Contains(Address address) + { + return address >= Start && address < Stop; + } + + /// Creates the half-open range [start, stop). + /// The inclusive lower bound. + /// The exclusive upper bound. + /// The range when the method returns ; otherwise the invalid default. + /// when the range is empty or inverted ( not above ). + public static bool TryCreate(Address start, Address stop, out AobScanBounds bounds) + { + if (stop <= start) + { + bounds = default; + return false; + } + + bounds = new AobScanBounds(start, stop); + return true; + } + + /// + /// Creates the range [BaseAddress, BaseAddress + ImageSize) of a module snapshot, which is CE's own module + /// convention for a stop address. + /// + /// A module snapshot, for example from . + /// The module range when the method returns ; otherwise the invalid default. + /// + /// when the module has no reported image size, a zero image size, or an end beyond the + /// 64-bit address space. No CE call is made. + /// + /// + /// The snapshot can be stale: a module unloaded or reloaded after it was enumerated makes these bounds describe + /// memory that no longer belongs to it. + /// + public static bool TryFromModule(in ModuleInfo module, out AobScanBounds bounds) + { + if (module.ImageSize is not { } size || size.Value == 0 || + size.Value > ulong.MaxValue - module.BaseAddress.Value) + { + bounds = default; + return false; + } + + bounds = new AobScanBounds(module.BaseAddress, new Address(module.BaseAddress.Value + size.Value)); + return true; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs index 90dbe341..db1f5304 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs @@ -94,8 +94,8 @@ public string? AlignmentParameter public bool Equals(AobScanOptions other) { return string.Equals(ProtectionFlags, other.ProtectionFlags, StringComparison.Ordinal) && - AlignmentMethod == other.AlignmentMethod && - string.Equals(AlignmentParameter, other.AlignmentParameter, StringComparison.Ordinal); + AlignmentMethod == other.AlignmentMethod && + string.Equals(AlignmentParameter, other.AlignmentParameter, StringComparison.Ordinal); } /// diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcomeKind.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcomeKind.cs index 6e98b196..e31f602c 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcomeKind.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOutcomeKind.cs @@ -2,10 +2,22 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; /// The factual outcome of a protected Cheat Engine AOBScan operation. /// -/// is deliberately narrower than : it is reported only after -/// CE returned a valid StringList and its Count property was read as zero. A raw Lua nil has -/// no documented no-match meaning for this CE primitive and remains . The zero value is -/// , so a default outcome is never interpreted as a successful scan. +/// +/// The zero value is , so a default outcome is never interpreted as a successful scan. +/// +/// +/// is deliberately narrower than : it is reported only after CE +/// returned a valid StringList and its Count property was read as zero. On the pinned profile +/// ce-7.7.0.10621-x64-managed-hostfxr, AOBScan never returns such a list: zero matches return no +/// value, which the protected one-result call reads as nil and this binding reports as +/// (host observation, spike 2026-09-22; the Q27 C3 receipt is still pending). On that +/// profile, is therefore unreachable on the global route, and +/// means "zero matches or a host failure that also produced no list": the SDK keeps it raw rather than +/// converting it to a no-match classification. Only the bounded MemScan route, +/// , +/// reports a factual zero on that profile. +/// +/// Categories come from the result's Lua type and arity only, never from a Lua error message. /// public enum AobScanOutcomeKind { @@ -15,7 +27,10 @@ public enum AobScanOutcomeKind /// CE returned a valid StringList whose verified count is positive. Matches, - /// CE returned a valid StringList whose verified count is zero. + /// + /// CE returned a valid StringList whose verified count is zero. Unreachable on the pinned CE 7.7.0.10621 x64 + /// profile, where zero matches are reported as . + /// NoMatches, /// The required AOBScan global was absent or was not callable. @@ -24,7 +39,10 @@ public enum AobScanOutcomeKind /// A protected Lua global lookup or invocation failed. ProtectedLuaFailure, - /// CE returned Lua nil; this binding does not reinterpret it as no matches. + /// + /// CE returned no value or Lua nil; this binding does not reinterpret it as no matches. On the pinned + /// CE 7.7.0.10621 x64 profile, this is how zero matches are reported. + /// NoResult, /// CE returned a non-nil value that was not a valid host object. diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanStatus.cs index 41dca123..ec55018f 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanStatus.cs @@ -2,26 +2,40 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; /// Describes the outcome of a protected Cheat Engine AOBScan call. /// -/// is the only value that supplies an -/// result. A -/// is reserved for the documented Lua nil result. A valid, caller-owned -/// StringList with zero entries is still : this binding does not reinterpret an empty -/// list as a failed scan or a match classification. +/// +/// The zero value is ; a default value never reads as success. is +/// the only value that supplies an result. A valid, +/// caller-owned StringList with zero entries is still : this binding does not +/// reinterpret an empty list as a failed scan or a match classification. +/// +/// +/// is the raw, factual category for a call that produced no host object. On the pinned +/// profile ce-7.7.0.10621-x64-managed-hostfxr, AOBScan reports zero matches this way: it returns +/// no value, which the one-result protected call reads as nil (host observation, spike 2026-09-22; the +/// Q27 C3 receipt is still pending). A nil can also come from other host paths, so the SDK never turns it +/// into a no-match classification. +/// /// public enum AobScanStatus { + /// No scan status has been observed; never produced by a completed call. + Unknown = 0, + /// Cheat Engine returned a caller-owned StringList host object. - Success, + Success = 1, /// The required AOBScan global was absent or was not callable. - GlobalUnavailable, + GlobalUnavailable = 2, /// The protected global lookup, argument push, or Lua call failed. - LuaFailure, + LuaFailure = 3, - /// Cheat Engine returned Lua nil. - NoResult, + /// + /// Cheat Engine returned no value or Lua nil. On the pinned CE 7.7.0.10621 x64 profile this is how zero + /// matches are reported; the category stays raw because nil can also come from a host failure. + /// + NoResult = 4, /// Cheat Engine returned a non-nil value that was not a host object. - InvalidResult + InvalidResult = 5 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanTargetContext.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanTargetContext.cs new file mode 100644 index 00000000..dd089bdc --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanTargetContext.cs @@ -0,0 +1,52 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.SDK.Engine.Scanning.Aob; + +/// +/// The Cheat Engine target observations made immediately before and immediately after one global AOBScan +/// call, inside the same admitted Lua operation. +/// +/// +/// +/// The global AOBScan route is target-agnostic: it scans whatever process CE has selected when the call +/// runs, and CE returns bare address strings. These observations are facts that let the caller decide whether the +/// returned addresses can be attributed to the target it expected. They never refuse the scan and never change +/// the scan's . +/// +/// +/// When is , the addresses may belong to +/// another target (the selection changed during the call, or it could not be qualified before or after it). Even +/// when it is , an A-to-B-to-A transition completed entirely between the two observations +/// remains unobservable; see . +/// +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct AobScanTargetContext +{ + internal AobScanTargetContext(TargetSelectionObservation before, TargetSelectionObservation after) + { + Before = before; + After = after; + } + + /// Gets the target observation made immediately before the AOBScan call. + public TargetSelectionObservation Before + { + get; + } + + /// Gets the target observation made immediately after the AOBScan call returned. + public TargetSelectionObservation After + { + get; + } + + /// + /// Gets whether both observations are qualified and denote the same process incarnation (process identifier and + /// local start time). + /// + public bool IsSameQualifiedIncarnation => + Before.IsQualified && After.IsQualified && Before.Incarnation == After.Incarnation; +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanner.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanner.cs index f96f65ee..60f8b58b 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanner.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanner.cs @@ -1,8 +1,13 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Threading; using CheatEngine.SDK.Annotations.Lifetime; +using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.Marshalling; @@ -23,7 +28,7 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; /// /// /// preserves the distinct outcomes of an -/// unresolved global, protected Lua failure, CE nil, and a malformed non-nil result. +/// unresolved global, protected Lua failure, CE nil (or no value), and a malformed non-nil result. /// further distinguishes a validated empty /// StringList from those failures without assigning no-match meaning to raw nil. The boolean /// TryScan overloads retain their existing convenience contract by returning for @@ -33,11 +38,37 @@ namespace CheatEngine.SDK.Engine.Scanning.Aob; /// returns follows 's existing main-thread destruction contract. /// /// -/// The CE primitive is synchronous and this SDK exposes no range/module restriction, result limit, early-stop, -/// or CancellationToken parameter for it: none is a verified AOBScan execution control. A Client -/// may decide whether to admit or wait for work and may cap strings after copying them, but neither action -/// interrupts a running CE scan or proves a bound on CE work. Copy every needed string while the returned owner -/// is alive, then dispose that owner exactly once. +/// On the pinned profile ce-7.7.0.10621-x64-managed-hostfxr, zero matches are reported as +/// / : AOBScan returns no +/// value, and an empty StringList was never observed (host observation, spike 2026-09-22; the Q27 C3 +/// receipt is still pending). on this global route stays reserved for +/// a valid empty list and is unreachable on that profile. The SDK keeps NoResult raw because a host +/// failure can produce the same shape; outcome categories come from the result's Lua type and arity, never from +/// Lua error text. +/// +/// +/// Once CE has returned a host list, the SDK holds its only destroy authority until the managed owner exists. A +/// managed failure while publishing that owner (for example an allocation failure) is a lifecycle fault: the SDK +/// destroys the unpublished list once, never retries, discards the status of that one attempt, and rethrows the +/// original exception. No owner escapes and no list is leaked or destroyed twice. +/// +/// +/// The global AOBScan primitive is synchronous and unbounded: this SDK exposes no range/module restriction, +/// result limit, early-stop, or CancellationToken parameter for it, because none is a verified +/// AOBScan execution control. Its native cost is a scan of the whole address space whatever a caller +/// filters afterwards. A Client may cap strings after copying them, but that neither interrupts a running CE scan +/// nor bounds CE work. Copy every needed string while the returned owner is alive, then dispose that owner exactly +/// once. +/// +/// +/// The bounded route is +/// : a +/// MemScan byte-array scan whose CE work is limited to [Start, Stop), exhaustive (never a first match), +/// with an address-only copy. Its four limits are distinct: the CE work limit (), the +/// available results (), the materialization limit (the +/// destination length, ) and the call deadline +/// (the optional wait timeout, ). Uniqueness needs an +/// exhausted domain or a second in-bounds match, never a first-found scan. /// /// public static class AobScanner @@ -49,6 +80,10 @@ public static class AobScanner /// The caller-owned result list, or on failure/no result. /// when CE returned a non-null host object. /// is . + /// + /// A managed failure while publishing the owner is a lifecycle fault: the SDK destroys the unpublished list once + /// and rethrows the original exception. + /// [RequiresPluginEnabled] public static bool TryScan(string pattern, [NotNullWhen(true)] out Owned? results) { @@ -61,6 +96,10 @@ public static bool TryScan(string pattern, [NotNullWhen(true)] out OwnedThe caller-owned result list, or on failure/no result. /// when CE returned a non-null host object. /// is . + /// + /// A managed failure while publishing the owner is a lifecycle fault: the SDK destroys the unpublished list once + /// and rethrows the original exception. + /// [RequiresPluginEnabled] public static bool TryScan(string pattern, AobScanOptions options, [NotNullWhen(true)] out Owned? results) @@ -76,6 +115,10 @@ public static bool TryScan(string pattern, AobScanOptions options, /// /// The protected AOBScan outcome without parsing a Lua error message. /// is . + /// + /// A managed failure while publishing the owner is a lifecycle fault: the SDK destroys the unpublished list once + /// and rethrows the original exception. + /// [RequiresPluginEnabled] public static AobScanStatus TryScanDetailed(string pattern, out Owned? results) { @@ -91,16 +134,15 @@ public static AobScanStatus TryScanDetailed(string pattern, out Owned /// The protected AOBScan outcome without parsing a Lua error message. /// is . + /// + /// A managed failure while publishing the owner is a lifecycle fault: the SDK destroys the unpublished list once + /// and rethrows the original exception. + /// [RequiresPluginEnabled] public static AobScanStatus TryScanDetailed(string pattern, AobScanOptions options, out Owned? results) { - ArgumentNullException.ThrowIfNull(pattern); - - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - LuaState state = operation.State; - using LuaFrame frame = new(state); - return TryScanCore(state, pattern, options, out results, out _); + return TryScanDetailedCore(pattern, options, PublishResultList, out results); } /// Runs AOBScan and reports whether a valid returned StringList contains matches. @@ -111,10 +153,16 @@ public static AobScanStatus TryScanDetailed(string pattern, AobScanOptions optio /// /// /// A factual outcome that classifies no matches only from a valid StringList with count zero. Raw Lua - /// nil, unavailable globals, protected Lua failures, malformed return values, and unreadable counts remain - /// distinct. + /// nil (or no value), unavailable globals, protected Lua failures, malformed return values, and unreadable + /// counts remain distinct. On the pinned CE 7.7.0.10621 x64 profile, a scan with zero matches returns no value and + /// is therefore reported as , never + /// (host observation, spike 2026-09-22; Q27 C3 pending). /// /// is . + /// + /// A managed failure while publishing the owner is a lifecycle fault: the SDK destroys the unpublished list once + /// and rethrows the original exception. + /// [RequiresPluginEnabled] public static AobScanOutcome TryScanOutcome(string pattern, out Owned? results) { @@ -128,18 +176,279 @@ public static AobScanOutcome TryScanOutcome(string pattern, out Owned is ; otherwise /// . Copy required entries before disposing the owner exactly once. /// - /// The factual protected AOB result, including a valid empty-list no-match classification. + /// + /// The factual protected AOB result, including a valid empty-list no-match classification. On the pinned + /// CE 7.7.0.10621 x64 profile, zero matches are reported as . + /// /// is . + /// + /// A managed failure while publishing the owner is a lifecycle fault: the SDK destroys the unpublished list once + /// and rethrows the original exception. + /// [RequiresPluginEnabled] public static AobScanOutcome TryScanOutcome(string pattern, AobScanOptions options, out Owned? results) + { + return TryScanOutcomeCore(pattern, options, PublishResultList, out results); + } + + /// + /// Runs AOBScan with explicit CE protection/alignment options, reports a structured result, and reports the + /// target observations made immediately before and after the call. + /// + /// CE's AOB pattern string, passed without normalization. + /// The optional CE arguments and their exact positions. + /// + /// The caller-owned list when is ; otherwise + /// . Copy required entries before disposing the owner exactly once. + /// + /// + /// The Cheat Engine target selection observed immediately before and after the AOBScan call, within the + /// same admitted Lua operation. + /// + /// + /// The same factual outcome as + /// for the same host response. The target observations never refuse the scan and never change its kind. + /// + /// is . + /// + /// + /// AOBScan scans CE's current selection. When + /// is , the returned + /// addresses may belong to another target (the selection changed during the call, or it could not be qualified + /// before or after it); the caller decides what to do with them. + /// + /// + /// A managed failure while publishing the owner is a lifecycle fault: the SDK destroys the unpublished list + /// once and rethrows the original exception. + /// + /// + [RequiresPluginEnabled] + public static AobScanOutcome TryScanOutcome(string pattern, AobScanOptions options, + out Owned? results, out AobScanTargetContext targetContext) + { + return TryScanOutcomeCore(pattern, options, PublishResultList, out results, out targetContext); + } + + /// + /// Runs an exhaustive AOB scan whose CE work is bounded by , through a MemScan session, + /// and copies the in-bounds match addresses into . + /// + /// CE's byte-array pattern text, passed without normalization. + /// + /// The CE work limit [Start, Stop). An invalid value (the default) is refused before any CE call with + /// . + /// + /// + /// CE's protection and alignment arguments. A protection string is passed as CE's "find + /// everything" empty string. + /// + /// + /// The materialization limit: in-bounds addresses are written in CE's found-list order until it is full. It is + /// written only for and + /// ; any other outcome leaves it unchanged. + /// + /// + /// Observed before the session is created, before the scan starts, after it completes and between row reads; it + /// cannot interrupt a CE call already running. A cancelled scan publishes nothing and still releases its session. + /// + /// The factual outcome, the four limits it met, the copy accounting and the separate durations. + /// is . + /// is empty. + /// The plugin is not enabled or the caller is not on its main thread. + /// + /// + /// Unlike the global AOBScan route, the CE work itself is bounded: the session's MemScan scans only + /// [Start, Stop) with a hexadecimal byte-array first scan + /// (). + /// The scan is exhaustive: OnlyOneResult is always switched off before it starts, and IsUnique, + /// Result and the scan callbacks are never used. On the pinned profile + /// ce-7.7.0.10621-x64-managed-hostfxr this route returned exactly the in-module subset of the global + /// route, in the same order (Lua-only host observation, spike 2026-09-22, D4.5; the Q28 C3 receipt is still + /// pending). CE's start bound is not byte-exact, so addresses below are + /// dropped and counted (). + /// + /// + /// Zero in-bounds matches with an empty CE error text is the factual + /// ; with a non-empty text it is + /// , the text being copied but never parsed. Only the + /// addresses are read (one getAddress call per needed row, never getValue), and reading stops as + /// soon as the destination is full. Order is CE's found-list order, which CE does not document: never infer the + /// lowest address from the first element. Uniqueness needs an exhausted domain + /// () or a second in-bounds match (a destination of at + /// least two elements and of two or more). + /// + /// + /// The call blocks CE's main thread for the scan, the copy and the release, and waits through CE's + /// no-timeout wait. The session is released once, child before parent, on every exit. + /// + /// + /// Addresses are staged in a pooled buffer as long as and copied out only on + /// success, so the call's managed memory peak is about twice the materialization limit, even for zero + /// matches. A managed failure to obtain that buffer (for example a destination too large for one array) is a + /// lifecycle fault: the session is still released once, child before parent, and the original exception + /// propagates. + /// + /// + [MainThreadOnly] + [RequiresPluginEnabled] + public static AobBoundedScanResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + Span
destination, CancellationToken cancellationToken) + { + ValidateBoundedArguments(pattern, destination); + return AobBoundedScan.Run(pattern, bounds, options, null, destination, cancellationToken); + } + + /// + /// Runs the bounded, exhaustive AOB scan of + /// + /// with a call deadline on CE's wait. + /// + /// CE's byte-array pattern text, passed without normalization. + /// The CE work limit [Start, Stop); an invalid value is refused before any CE call. + /// CE's protection and alignment arguments. + /// + /// The call deadline of CE's waitTillDone(timeout): strictly positive and at most + /// milliseconds; a sub-millisecond value rounds up to one millisecond. + /// + /// The materialization limit; written only for a successful outcome. + /// Observed between CE calls; it cannot interrupt a CE call already running. + /// The factual outcome, the four limits it met, the copy accounting and the separate durations. + /// is . + /// is empty. + /// is outside the accepted range. + /// The plugin is not enabled or the caller is not on its main thread. + /// + /// When the deadline expires first the outcome is : the SDK + /// requests one cooperative stop (terminateScan(false), then a five-second settle wait), reports it in + /// , publishes nothing and releases the session without repeating + /// the stop. The timed-out wait and terminateScan were not observed on the pinned CE 7.7.0.10621 host + /// (spike D4.7), which is why this overload is experimental. + /// + [Experimental("CESDK5010", UrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md")] + [MainThreadOnly] + [RequiresPluginEnabled] + public static AobBoundedScanResult TryScanWithinBounds(string pattern, AobScanBounds bounds, AobScanOptions options, + TimeSpan waitTimeout, Span
destination, CancellationToken cancellationToken) + { + ValidateBoundedArguments(pattern, destination); + int milliseconds = MemoryScanSession.ToWaitMilliseconds(waitTimeout, nameof(waitTimeout)); + return AobBoundedScan.Run(pattern, bounds, options, milliseconds, destination, cancellationToken); + } + + /// + /// Runs CE's one-result MemScan mode over and reports the first match CE found: + /// first found, order unspecified, never a uniqueness proof. + /// + /// CE's byte-array pattern text, passed without normalization. + /// The CE work limit [Start, Stop); an invalid value is refused before any CE call. + /// CE's protection and alignment arguments; a protection string means "find everything". + /// + /// Observed before the session is created and before the scan starts; it cannot interrupt the scan. + /// + /// + /// with some in-bounds match, + /// , the indeterminate + /// , or a failure; the session is released once. + /// + /// is . + /// The plugin is not enabled or the caller is not on its main thread. + /// + /// + /// CE stops at the first match it finds and exposes it through getOnlyResult (celua.txt lines + /// 2656-2657); CE documents no order. The C3 spike saw the lowest in-module address three times out of three, + /// which is an observation, not a contract. The result must never back a bounded or range scan, a "require + /// single" query, or any exhaustive query: use + /// , + /// which is exhaustive, for those. + /// + /// + /// The session's found list is never initialized, because CE documents that the one-result mode does not fill + /// it. A match reported below is + /// : CE's start bound is not byte-exact, so whether an + /// in-bounds match exists is unknown. The getOnlyResult no-match path and this route's call sequence + /// were not observed on the pinned CE 7.7.0.10621 host, which is why it is experimental (CESDK5011). + /// + /// + [Experimental("CESDK5011", UrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md")] + [MainThreadOnly] + [RequiresPluginEnabled] + public static AobFirstFoundResult TryFindFirstFoundWithinBounds(string pattern, AobScanBounds bounds, + AobScanOptions options, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(pattern); + return AobFirstFoundScan.Run(pattern, bounds, options, cancellationToken); + } + + // Test seam: the same protected call and classification as TryScanDetailed, with a substitutable owner + // publication. Production callers always pass PublishResultList. + internal static AobScanStatus TryScanDetailedCore(string pattern, AobScanOptions options, + AobResultListPublisher publisher, out Owned? results) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentNullException.ThrowIfNull(publisher); using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); LuaState state = operation.State; using LuaFrame frame = new(state); - AobScanStatus status = TryScanCore(state, pattern, options, out results, out LuaStatus luaStatus); + return TryScanCore(state, pattern, options, publisher, out results, out _); + } + + // Test seam: the same protected call and classification as TryScanOutcome, with a substitutable owner + // publication. Production callers always pass PublishResultList. + internal static AobScanOutcome TryScanOutcomeCore(string pattern, AobScanOptions options, + AobResultListPublisher publisher, out Owned? results) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentNullException.ThrowIfNull(publisher); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + AobScanStatus status = TryScanCore(state, pattern, options, publisher, out results, out LuaStatus luaStatus); + return Classify(status, luaStatus, ref results); + } + + // The target-context variant: the observations bracket the protected AOBScan call inside the same admitted + // operation. They are facts only; the classification is exactly the one of the variant above. + internal static AobScanOutcome TryScanOutcomeCore(string pattern, AobScanOptions options, + AobResultListPublisher publisher, out Owned? results, out AobScanTargetContext targetContext) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentNullException.ThrowIfNull(publisher); + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + using LuaFrame frame = new(state); + TargetSelectionObservation before = ObserveTarget(state); + AobScanStatus status = TryScanCore(state, pattern, options, publisher, out results, out LuaStatus luaStatus); + TargetSelectionObservation after = ObserveTarget(state); + targetContext = new AobScanTargetContext(before, after); + return Classify(status, luaStatus, ref results); + } + + // Every member has its own arm. Unknown (the default) and Success (which never reaches this mapping, because a + // successful call is classified from its list count) map to the Unknown outcome, never to a count failure: an + // unexpected status must not be reported as a host list that was returned but could not be counted. + internal static AobScanOutcome FromStatus(AobScanStatus status, LuaStatus luaStatus) + { + return status switch + { + AobScanStatus.Unknown => default, + AobScanStatus.Success => default, + AobScanStatus.GlobalUnavailable => AobScanOutcome.GlobalUnavailable, + AobScanStatus.LuaFailure => AobScanOutcome.ProtectedLuaFailure(ToFailureStatus(luaStatus)), + AobScanStatus.NoResult => AobScanOutcome.NoResult, + AobScanStatus.InvalidResult => AobScanOutcome.InvalidResult, + _ => default + }; + } + + // Classifies a completed protected call. A published owner whose count cannot be read has no caller left to + // release it, so it is disposed here, once, before the failure outcome is returned. + private static AobScanOutcome Classify(AobScanStatus status, LuaStatus luaStatus, + ref Owned? results) + { if (status != AobScanStatus.Success) { return FromStatus(status, luaStatus); @@ -166,21 +475,21 @@ public static AobScanOutcome TryScanOutcome(string pattern, AobScanOptions optio } private static AobScanStatus TryScanCore(LuaState state, string pattern, AobScanOptions options, - out Owned? results, out LuaStatus luaStatus) + AobResultListPublisher publisher, out Owned? results, out LuaStatus luaStatus) { + results = null; luaStatus = LuaStatus.Ok; + CEObject unpublished = CEObject.Null; try { LuaGlobalPushOutcome global = LuaGlobalFunctions.TryPushWithOutcome(state, SAobScan, "AOBScan"u8); if (global.Status == LuaGlobalPushStatus.Unavailable) { - results = null; return AobScanStatus.GlobalUnavailable; } if (!global.IsSuccess) { - results = null; luaStatus = ToFailureStatus(global.LuaStatus); return AobScanStatus.LuaFailure; } @@ -189,43 +498,86 @@ private static AobScanStatus TryScanCore(LuaState state, string pattern, AobScan luaStatus = state.TryCall(argumentCount, 1); if (!luaStatus.IsOk) { - results = null; return AobScanStatus.LuaFailure; } + // CE 7.7.0.10621 returns no value on zero matches; the one-result call reads that as nil (spike D1). if (state.IsNil(-1)) { - results = null; return AobScanStatus.NoResult; } if (!CEObject.TryRead(state, -1, out CEObject handle)) { - results = null; return AobScanStatus.InvalidResult; } - results = new Owned(StringList.FromHandle(handle)); + // From here until the owner exists, this frame holds the list's only destroy authority. + unpublished = handle; + results = publisher(StringList.FromHandle(handle)); + unpublished = CEObject.Null; return AobScanStatus.Success; } - catch (LuaException exception) + catch (LuaException exception) when (unpublished.IsNull) { + // A protected failure before CE returned a list. A failure while publishing the owner is not caught here: it + // is a lifecycle fault and propagates unchanged after the single rollback below. results = null; luaStatus = ToFailureStatus(exception.Status); return AobScanStatus.LuaFailure; } + finally + { + if (!unpublished.IsNull) + { + RollBackUnpublishedList(state, unpublished); + } + } } - private static AobScanOutcome FromStatus(AobScanStatus status, LuaStatus luaStatus) + // One destroy attempt for a host list whose managed owner could not be published. It is never retried: a failed + // protected destroy may already have freed part of the object. Its status is discarded and any exception it throws + // is swallowed, so the original publication failure is the exception the caller observes. + private static void RollBackUnpublishedList(LuaState state, CEObject unpublished) { - return status switch + try { - AobScanStatus.GlobalUnavailable => AobScanOutcome.GlobalUnavailable, - AobScanStatus.LuaFailure => AobScanOutcome.ProtectedLuaFailure(ToFailureStatus(luaStatus)), - AobScanStatus.NoResult => AobScanOutcome.NoResult, - AobScanStatus.InvalidResult => AobScanOutcome.InvalidResult, - _ => AobScanOutcome.ResultListCountUnavailable - }; + using LuaFrame rollback = new(state); + _ = unpublished.TryDestroy(state); + } + catch (Exception) + { + // Deliberately ignored: see the method comment. + } + } + + private static Owned PublishResultList(StringList list) + { + return new Owned(list); + } + + private static void ValidateBoundedArguments(string pattern, Span
destination) + { + ArgumentNullException.ThrowIfNull(pattern); + if (destination.IsEmpty) + { + throw new ArgumentException( + "A bounded AOB scan needs a non-empty destination: its length is the materialization limit.", + nameof(destination)); + } + } + + private static TargetSelectionObservation ObserveTarget(LuaState state) + { + int top = state.Top; + try + { + return TargetSelection.ObserveCurrent(state); + } + finally + { + state.SetTop(top); + } } private static LuaStatus ToFailureStatus(LuaStatus luaStatus) diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/FirstScanRequest.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/FirstScanRequest.cs index 4855a561..a545b809 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/FirstScanRequest.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/FirstScanRequest.cs @@ -1,3 +1,5 @@ +using System; + using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Values; @@ -13,12 +15,24 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// method. /// /// -/// The exact order is documented in CE 7.7.0.10621 celua.txt lines 2568-2613. Start and stop are modelled as -/// target values even though the Lua document calls them integer: a 64-bit target -/// address -/// must not be narrowed to a managed . This value intentionally does not validate every semantic -/// combination CE may accept; the session validates the documented first-scan option set (including CE's -/// non-contiguous vtGrouped value) and non-null string values. +/// +/// The exact order is documented in CE 7.7.0.10621 celua.txt lines 2568-2613. Start and stop are modelled +/// as target values even though the Lua document calls them integer: a 64-bit +/// target address must not be narrowed to a managed . The session always pushes them as Lua +/// integers, bit for bit (an address at or above 2^63 becomes a negative Lua integer), never as strings, which CE +/// would resolve through its symbol handler. This value intentionally does not validate every semantic +/// combination CE may accept; the session validates the documented first-scan option set (including CE's +/// non-contiguous vtGrouped value) and non-null string values. +/// +/// +/// Range semantics observed on the pinned profile ce-7.7.0.10621-x64-managed-hostfxr (Lua-only host +/// observation, spike 2026-09-22, decision D4; not a C3 receipt): is an exclusive +/// bound and a match is reported only when it fits entirely below it; is not +/// byte-exact, so a match that begins slightly before it can be reported and a caller that needs an exact start +/// must post-filter the returned addresses. An empty or inverted range is not refused by CE (it reports zero +/// results with a misleading error text), which is why +/// refuses it before any CE call. +/// /// public readonly struct FirstScanRequest { @@ -28,8 +42,14 @@ public readonly struct FirstScanRequest /// The floating-point comparison rule. /// The primary scan text; use an empty string when the option does not need it. /// The secondary scan text; use an empty string when the option does not need it. - /// The first target address to consider. - /// The last target address to consider. + /// + /// The lower target-address bound. Not byte-exact on the pinned CE 7.7 profile: post-filter results that + /// must begin at or above it. + /// + /// + /// The exclusive upper target-address bound: on the pinned CE 7.7 profile a match is reported only when it + /// fits entirely below it. + /// /// The CE protection-flags text, such as +W-C. /// The address-alignment rule. /// The alignment rule's CE string parameter. @@ -99,13 +119,20 @@ public string Input2 get; } - /// Gets the first target address to consider. + /// + /// Gets the lower target-address bound. On the pinned CE 7.7.0.10621 x64 profile it is not byte-exact: a match + /// beginning slightly before it can be reported, so a caller that needs an exact start post-filters the results + /// (host observation, spike D4.2). + /// public Address StartAddress { get; } - /// Gets the last target address to consider. + /// + /// Gets the exclusive upper target-address bound. On the pinned CE 7.7.0.10621 x64 profile a match is reported + /// only when it fits entirely below this address (host observation, spike D4.1). + /// public Address StopAddress { get; @@ -156,7 +183,10 @@ public bool IsCaseSensitive /// Builds an exact-value request over the full 64-bit target address range. /// The value type to scan. /// The exact value text. - /// A request using CE's normal rounded, non-aligned and non-hexadecimal settings. + /// + /// A request using CE's normal rounded, non-aligned and non-hexadecimal settings, from address zero to the + /// largest 64-bit address as its stop bound. + /// public static FirstScanRequest ExactValue(VariableType variableType, string input) { return new FirstScanRequest( @@ -175,4 +205,80 @@ public static FirstScanRequest ExactValue(VariableType variableType, string inpu false, false); } + + /// + /// Builds an exhaustive hexadecimal byte-array first scan over [startAddress, stopAddress) with CE's + /// "find everything" protection string and no alignment. + /// + /// + /// CE's byte-array pattern text (for example 48 8B ?? 89), passed without normalization as the first input. + /// + /// The lower bound; not byte-exact on the pinned CE 7.7 profile (post-filter results). + /// The exclusive upper bound; a match is reported only when it fits entirely below it. + /// + /// The fourteen CE positions (soExactValue, vtByteArray, rtRounded, pattern, "", startAddress, stopAddress, "", + /// fsmNotAligned, "", true, false, false, false). + /// + /// is . + /// + /// is not greater than : an empty or inverted + /// range is refused before any CE call. + /// + /// + /// The empty protection string is CE's "find everything" value (celua.txt line 847). This request does not + /// post-filter: the bounded route of does, and a direct + /// user must drop results below itself. + /// + public static FirstScanRequest ByteArray(string pattern, Address startAddress, Address stopAddress) + { + return ByteArray(pattern, startAddress, stopAddress, string.Empty, FastScanMethod.NotAligned, string.Empty); + } + + /// + /// Builds an exhaustive hexadecimal byte-array first scan over [startAddress, stopAddress) with explicit + /// CE protection and alignment arguments. + /// + /// CE's byte-array pattern text, passed without normalization as the first input. + /// The lower bound; not byte-exact on the pinned CE 7.7 profile (post-filter results). + /// The exclusive upper bound; a match is reported only when it fits entirely below it. + /// CE's protection-flags text; the empty string means "find everything". + /// The address-alignment rule. + /// The alignment rule's CE string parameter; empty for no alignment. + /// + /// The fourteen CE positions (soExactValue, vtByteArray, rtRounded, pattern, "", startAddress, stopAddress, + /// protectionFlags, fastScanMethod, alignmentParameter, true, false, false, false). + /// + /// A string argument is . + /// + /// is not greater than : an empty or inverted + /// range is refused before any CE call. + /// + public static FirstScanRequest ByteArray(string pattern, Address startAddress, Address stopAddress, + string protectionFlags, FastScanMethod fastScanMethod, string alignmentParameter) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentNullException.ThrowIfNull(protectionFlags); + ArgumentNullException.ThrowIfNull(alignmentParameter); + if (stopAddress <= startAddress) + { + throw new ArgumentOutOfRangeException(nameof(stopAddress), stopAddress, + "A byte-array scan range must be non-empty: the exclusive stop address must be greater than the start address."); + } + + return new FirstScanRequest( + ScanOption.ExactValue, + VariableType.ByteArray, + RoundingType.Rounded, + pattern, + string.Empty, + startAddress, + stopAddress, + protectionFlags, + fastScanMethod, + alignmentParameter, + true, + false, + false, + false); + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCancellationMilestone.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCancellationMilestone.cs index ad252902..2ba5279e 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCancellationMilestone.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCancellationMilestone.cs @@ -2,8 +2,11 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// Records how a cancellation request intersected a synchronous memory-scan operation. /// -/// Cheat Engine's documented waitTillDone() form has no cancellation or timeout parameter. A milestone is -/// therefore diagnostic evidence only: it never claims that a cancellation request interrupted native scan work. +/// The session's cancellable wait uses CE's no-timeout waitTillDone() form, which has no cancellation +/// argument. CE 7.7.0.10621 also documents an optional timeout for waitTillDone (celua.txt line 2649); +/// that form is projected separately, and experimentally, by . +/// Neither form accepts a token, so a milestone is diagnostic evidence only: it never claims that a cancellation +/// request interrupted native scan work. /// public enum MemoryScanCancellationMilestone : byte { diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs index de0b12f8..23a1a8b0 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs @@ -2,40 +2,44 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// Classifies the factual result of creating a plugin-owned memory-scan session. /// -/// The status distinguishes a documented absent factory result from a protected Lua failure or an invalid host -/// value. It is fixture/source evidence for the creation binding, not a CE live-qualification claim. +/// The zero value is ; a default value never reads as success. The status distinguishes a +/// documented absent factory result from a protected Lua failure or an invalid host value. It is fixture/source +/// evidence for the creation binding, not a CE live-qualification claim. /// public enum MemoryScanCreationStatus : byte { + /// No creation status has been observed; never produced by a completed factory call. + Unknown = 0, + /// The scanner and its distinct found-list child were created and adopted by a session. - Success = 0, + Success = 1, /// The required CE factory global was absent or was not callable. - GlobalUnavailable = 1, + GlobalUnavailable = 2, /// A protected factory call failed before returning its documented result. - LuaFailure = 2, + LuaFailure = 3, /// The scanner factory returned its documented absent result, . - NoScannerResult = 3, + NoScannerResult = 4, /// The scanner factory returned a non-null value that was not a CE host object. - InvalidScannerResult = 4, + InvalidScannerResult = 5, /// The found-list factory returned its documented absent result, . - NoFoundListResult = 5, + NoFoundListResult = 6, /// The found-list factory returned a non-null value that was not a CE host object. - InvalidFoundListResult = 6, + InvalidFoundListResult = 7, /// The found-list factory returned the scanner object, so publishing a second owner was refused. - AliasedFoundList = 7, + AliasedFoundList = 8, /// A rollback destroy call began but Cheat Engine did not confirm all required cleanup operations. - RollbackUnconfirmed = 8, + RollbackUnconfirmed = 9, /// /// The selected target could not be qualified before either target-dependent CE factory was invoked. /// - TargetIdentityUnavailable = 9 + TargetIdentityUnavailable = 10 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanInvalidationReason.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanInvalidationReason.cs index 9289d454..ab61c744 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanInvalidationReason.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanInvalidationReason.cs @@ -16,5 +16,13 @@ public enum MemoryScanInvalidationReason : byte TargetChanged = 3, /// The observed target reused the original process identifier with a different start time. - TargetProcessReused = 4 + TargetProcessReused = 4, + + /// + /// The caller asked the session to stop its running scan; the scan's results are never exposed. The termination + /// status that the request returned tells whether CE was asked at all + /// ( when the session's context was refused and no CE call was + /// made) and whether a cooperative stop was confirmed. + /// + ScanTerminated = 5 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs index 47c592a1..2db87dcc 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs @@ -1,35 +1,39 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// Classifies a bounded, copied memory-scan result materialization attempt. +/// The zero value is ; a default value never reads as success. public enum MemoryScanMaterializationStatus : byte { + /// No materialization status has been observed; never produced by a completed copy attempt. + Unknown = 0, + /// Every result was copied into the caller-supplied destination. - Success = 0, + Success = 1, /// The scan completed successfully but its initialized found list contains no rows. - NoResults = 1, + NoResults = 2, /// The complete result set exceeds the caller-supplied bounded destination; no row was written. - DestinationTooSmall = 2, + DestinationTooSmall = 3, /// Cancellation was observed before a row was copied; the destination remains unchanged. - Cancelled = 3, + Cancelled = 4, /// The session belongs to a previous Lua runtime attachment or state generation. - RuntimeInvalidated = 4, + RuntimeInvalidated = 5, /// The current target cannot be qualified as the session's original target. - TargetIdentityUnavailable = 5, + TargetIdentityUnavailable = 6, /// The current target is not the session's original target incarnation. - TargetIdentityMismatch = 6, + TargetIdentityMismatch = 7, /// A protected CE operation failed while reading the result set. - LuaFailure = 7, + LuaFailure = 8, /// CE returned a count, address, or value that does not satisfy the declared scan contract. - InvalidResult = 8, + InvalidResult = 9, /// The requested page starts at or beyond a non-empty found-list count. - PageStartOutOfRange = 9 + PageStartOutOfRange = 10 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanOnlyResult.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanOnlyResult.cs new file mode 100644 index 00000000..11ca87ac --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanOnlyResult.cs @@ -0,0 +1,20 @@ +namespace CheatEngine.SDK.Engine.Scanning.Values; + +/// The SDK-internal result of reading MemScan.getOnlyResult() for the first-found AOB route. +internal enum MemoryScanOnlyResult : byte +{ + /// No read has been observed. + Unknown = 0, + + /// CE returned an integer address. + Found = 1, + + /// CE returned no value or nil: nothing was found. + NotFound = 2, + + /// CE returned a value that is not an integer (a float, string, boolean or object). + InvalidResult = 3, + + /// The protected call raised. + LuaFailure = 4 +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanReleaseOutcome.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanReleaseOutcome.cs index d0e18778..f7af2042 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanReleaseOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanReleaseOutcome.cs @@ -6,24 +6,26 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// /// The stable outcome of consuming a memory-scan session's child owner before its parent -/// owner. +/// owner, including the cooperative stop of a scan that may still have been running. /// /// /// Each owner is consumed exactly once. The two values distinguish confirmed /// cleanup, a safe refusal before the CE destroy call, cleanup that could not begin, and a native effect that began -/// but could not be confirmed. A later release or returns this same outcome -/// and never retries either CE destroy call. +/// but could not be confirmed. reports whether a scan had to be stopped first and whether +/// that stop was confirmed. A later release or returns this same outcome and +/// never retries either CE destroy call or the stop request. /// [StructLayout(LayoutKind.Auto)] public readonly record struct MemoryScanReleaseOutcome { internal MemoryScanReleaseOutcome(TargetReleaseOutcome foundList, TargetReleaseOutcome memScan, - bool foundListOwnershipConsumed, bool memScanOwnershipConsumed) + bool foundListOwnershipConsumed, bool memScanOwnershipConsumed, MemoryScanTerminationStatus termination) { FoundList = foundList; MemScan = memScan; FoundListOwnershipConsumed = foundListOwnershipConsumed; MemScanOwnershipConsumed = memScanOwnershipConsumed; + Termination = termination; } /// Gets the child found-list cleanup result, which is always processed before . @@ -52,4 +54,21 @@ public bool MemScanOwnershipConsumed /// Gets whether both session ownership capabilities have been consumed. public bool OwnershipConsumed => FoundListOwnershipConsumed && MemScanOwnershipConsumed; + + /// + /// Gets how a scan that may still have been running was stopped before the owners were released. + /// + /// + /// when no scan could be running; + /// when the one cooperative stop was confirmed; + /// when a scan may run, no stop was requested and no CE call + /// was allowed. Any other value is an unconfirmed stop, never retried: when the release could reach CE, the found + /// list and the scanner were still destroyed once each (CE's own destroy stops and waits for its scan controller); + /// and say whether they were. The default value is + /// . + /// + public MemoryScanTerminationStatus Termination + { + get; + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanRowRead.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanRowRead.cs new file mode 100644 index 00000000..87e9b46c --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanRowRead.cs @@ -0,0 +1,17 @@ +namespace CheatEngine.SDK.Engine.Scanning.Values; + +/// The SDK-internal result of reading one found-list row for a bounded AOB route. +internal enum MemoryScanRowRead : byte +{ + /// No row read has been observed. + Unknown = 0, + + /// CE returned the row's address text and it parsed as a target address. + Read = 1, + + /// The protected row call raised. + LuaFailure = 2, + + /// CE returned something that is not hexadecimal address text. + InvalidResult = 3 +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs index 7ae6d686..2ea4164d 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Text; using System.Threading; using CheatEngine.SDK.Annotations.Lifetime; @@ -35,10 +36,41 @@ namespace CheatEngine.SDK.Engine.Scanning.Values; /// release the readable view; this is a session invariant, not a claim that CE rejects every other raw sequence. /// /// -/// Dispose is idempotent and releases a ready found list before destroying the found-list child, then destroys the -/// scanner parent. It must run while the plugin is enabled and on the Cheat Engine main thread so the contained -/// values can reach destroy(). As with , no finalizer runs -/// native cleanup from an arbitrary thread. +/// Dispose is idempotent. When a scan may still be running (a first or next scan was started and no wait, +/// confirmed stop or reset has ended it), it first asks CE for a cooperative stop, terminateScan(false), +/// and waits for it with waitTillDone(5000), each once. It then releases a ready found list, destroys the +/// found-list child and finally destroys the scanner parent, each exactly once, even when the stop was not +/// confirmed: CE's own destroy of a scanner stops and waits for its scan controller rather than freeing memory +/// under a running scan thread (ObservedSource: cheat-engine/cheat-engine@ec45d5f +/// Cheat Engine/memscan.pas lines 7942-7946 and 8929-8955). Disposing a scanning session therefore blocks +/// CE's main thread for at most the five-second settle wait plus whatever CE's destroy waits; the stop is +/// cooperative and never forced. It must run while the plugin is enabled and on the Cheat Engine main thread so +/// the contained values can reach destroy(). As with , no +/// finalizer runs native cleanup from an arbitrary thread. +/// +/// +/// CE's waits can run queued main-thread work before they return: on the main thread, waitTillDone pumps +/// CheckSynchronize (ObservedSource: cheat-engine/cheat-engine@ec45d5f +/// Cheat Engine/LuaMemscan.pas lines 145-157), and newScan and destroy wait for CE's scan +/// threads (Cheat Engine/memscan.pas lines 8360-8372 and 8929-8955), which may do the same. Work queued +/// through CE's synchronize (for example with MainThread.Invoke) can therefore call back into this +/// session from inside one of its own CE calls. While one member of the session is inside a CE call, every +/// other member is refused with a , and , +/// and make no CE call: the one release (or abandon) is +/// deferred until that CE call has returned and then runs exactly once, so no destroy ever runs while a CE call +/// on the same objects is still on the stack. A start, wait, reset or stop interrupted this way makes no +/// further CE call and then throws instead of completing its +/// transition. +/// +/// +/// uses CE's no-timeout waitTillDone() form. CE 7.7.0.10621 also has a +/// timeout form returning a boolean (celua.txt line 2649) and a cooperative terminateScan (line +/// 2566); and project them. Their +/// timed-out and termination paths were not observed on the pinned host (spike D4.7), so both members are +/// [Experimental("CESDK5010")] until a Q29 C3 receipt observes them. The SDK never forces termination: a +/// forced stop can kill CE's scan thread and open a modal dialog on CE's main thread. +/// copies CE's ErrorString as a bounded, unparsed fact; no outcome +/// category is ever derived from it. /// /// public sealed class MemoryScanSession : IDisposable @@ -56,8 +88,25 @@ public sealed class MemoryScanSession : IDisposable private Owned? _foundList; private bool _isBound; private Owned? _scanner; + + // True from immediately before a firstScan/nextScan call (CE may have started work even if the call then fails) + // until a wait reports completion, a cooperative stop is confirmed, or a reset succeeds. + private bool _scanMayBeRunning; + + // The one cooperative stop request of the current scan, if any: it is never repeated, and its status is what a + // later release reports when the stop stayed unconfirmed. + private bool _terminationAttempted; + private MemoryScanTerminationStatus _termination; private TargetSelectionObservation _targetObservation; + // The member whose CE calls are in progress, or null, and whether that member is the release itself. CE's waits, + // resets and destroys can run queued main-thread work that calls back into this session (see the type remarks). + // While a member is active, every other member is refused, and a release or abandon requested meanwhile is + // recorded here and run by the active member once its CE calls have returned. + private string? _activeOperation; + private bool _releaseInProgress; + private DeferredDisposal _deferredDisposal; + private MemoryScanSession(Owned scanner, Owned foundList) { _scanner = scanner; @@ -65,6 +114,15 @@ private MemoryScanSession(Owned scanner, Owned foundList) State = MemoryScanState.New; } + /// The maximum number of UTF-8 bytes that copies from CE. + public const int HostErrorTextMaximumUtf8Bytes = 1024; + + /// + /// The bounded wait, in milliseconds, after the one cooperative stop request that release and the bounded AOB + /// route issue for a scan that may still be running. + /// + internal const int ReleaseTerminationWaitMilliseconds = 5000; + /// Gets the session's conservative, managed state. public MemoryScanState State { @@ -126,6 +184,7 @@ public MemoryScanReleaseOutcome LastReleaseOutcome /// prefer the session members for the scan lifecycle. /// /// The session was disposed. + /// Another member of the session is inside a CE call. [RequiresPluginEnabled] public MemScan Scanner { @@ -133,9 +192,17 @@ public MemScan Scanner { ThrowIfDisposed(); RequireEnabledMainThread(); - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, "MemoryScan.Scanner"); - return _scanner!.Value; + BeginSessionCall("Scanner"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, "MemoryScan.Scanner"); + return _scanner!.Value; + } + finally + { + EndSessionCall(); + } } } @@ -143,7 +210,9 @@ public MemScan Scanner /// Gets the attached found list as a borrowed handle, only after it is initialized for reading. Direct raw /// operations on the returned value bypass the session's state checks. /// - /// The results are not ready. + /// + /// The results are not ready, or another member of the session is inside a CE call. + /// /// The session was disposed. [RequiresPluginEnabled] public FoundList Results @@ -152,9 +221,17 @@ public FoundList Results { RequireState("Results", MemoryScanState.ResultsReady); RequireEnabledMainThread(); - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, "MemoryScan.Results"); - return _foundList!.Value; + BeginSessionCall("Results"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, "MemoryScan.Results"); + return _foundList!.Value; + } + finally + { + EndSessionCall(); + } } } @@ -165,7 +242,9 @@ public FoundList Results /// Result-reading methods intentionally retain CE's index parameter and therefore address /// only indices from zero through Int32.MaxValue. /// - /// The results are not ready. + /// + /// The results are not ready, or another member of the session is inside a CE call. + /// /// CE did not return a valid non-negative integer count. /// The plugin is not enabled or the caller is not on its main thread. [MainThreadOnly] @@ -175,9 +254,17 @@ public ulong ResultCount get { RequireEnabledMainThread(); - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, ResultCountOperation); - return ReadResultCount(operation.State, RequireResults()); + BeginSessionCall("ResultCount"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, ResultCountOperation); + return ReadResultCount(operation.State, RequireResults()); + } + finally + { + EndSessionCall(); + } } } @@ -185,7 +272,9 @@ public ulong ResultCount /// /// Use when the factual child and parent outcomes matter. This method is /// idempotent; it performs neither a target-dependent destroy on a worker nor a retry after an uncertain native - /// destroy. It records safe refusal or an unconfirmed result in instead. + /// destroy. It records safe refusal or an unconfirmed result in instead. When a + /// scan may still be running it first requests one cooperative stop and waits for it (at most five seconds), so it + /// can block CE's main thread; see . /// [MainThreadOnly] public void Dispose() @@ -201,10 +290,32 @@ public void Dispose() /// unconfirmed cleanup. The same result is returned after the session is already disposed. /// /// - /// This method never throws and never retries a destroy. If cleanup cannot safely begin (for example, a worker - /// thread, detached runtime, changed Lua identity, or changed target), it consumes the managed owners through - /// and reports the refusal or unavailable cleanup rather than routing handles into - /// a different CE context. A protected destroy failure consumes the corresponding owner and remains unconfirmed. + /// + /// This method never throws and never retries a destroy. If cleanup cannot safely begin (for example, a worker + /// thread, detached runtime, changed Lua identity, or changed target), it consumes the managed owners through + /// and reports the refusal or unavailable cleanup rather than routing handles + /// into a different CE context; is then + /// when a scan may still run and no stop was requested + /// before, or the unconfirmed status of the stop that was. A protected destroy failure consumes the + /// corresponding owner and remains unconfirmed. + /// + /// + /// When a scan may still be running (after a first or next scan call, including one that failed after CE may + /// have started, or after a wait that failed or timed out) and no stop was requested yet, the release calls + /// terminateScan(false) and then waitTillDone(5000), each once, before the child and parent + /// destroys. A stop that is not confirmed is reported in + /// and the child and parent are still destroyed once each; a stop already requested through + /// is never repeated. This can block CE's main thread for up to five seconds plus + /// CE's own destroy wait. + /// + /// + /// When it is called from inside a CE call that another member of this session is making (CE's waits can run + /// queued main-thread work, see the type remarks), it makes no CE call and returns the unspecified default + /// outcome (both statuses , ownership not consumed, + /// ): the single release is deferred until that CE call has + /// returned and then runs once, and holds its final outcome. A call made + /// while the release itself is in progress returns the same provisional outcome and never starts a second one. + /// /// public MemoryScanReleaseOutcome ReleaseWithOutcome() { @@ -213,26 +324,24 @@ public MemoryScanReleaseOutcome ReleaseWithOutcome() return LastReleaseOutcome; } - try + if (LuaRuntime.IsAttached && !LuaRuntime.IsMainThread) { - if (LuaRuntime.IsAttached && !LuaRuntime.IsMainThread) - { - return ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); - } + return ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); + } - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State); - if (context != MemoryScanMaterializationStatus.Success) + if (_activeOperation is not null) + { + // Re-entered from inside one of this session's own CE calls: never destroy under that call. The active + // member runs the one release when its CE call returns (the release itself simply completes). + if (_deferredDisposal == DeferredDisposal.None) { - return ConsumeWithoutCleanup(CreateRefusedReleaseOutcome(context)); + _deferredDisposal = DeferredDisposal.Release; } - return ReleaseWithinCurrentContext(operation.State); - } - catch (Exception) - { - return ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); + return default; } + + return ReleaseAsActiveCall(); } /// @@ -242,6 +351,9 @@ public MemoryScanReleaseOutcome ReleaseWithOutcome() /// This is an explicit recovery path for a session whose original Lua runtime or target cannot be validated. /// It deliberately does not claim that native destruction occurred, and it must not be used as normal cleanup. /// The found-list owner is abandoned before the scanner owner to preserve the parent/child ownership direction. + /// When it is called from inside a CE call that another member of this session is making, the abandon is + /// deferred until that CE call has returned (it takes precedence over a release requested the same way); while + /// the release itself is in progress, the release completes and this call has no effect. /// public void Abandon() { @@ -250,6 +362,16 @@ public void Abandon() return; } + if (_activeOperation is not null) + { + if (!_releaseInProgress) + { + _deferredDisposal = DeferredDisposal.Abandon; + } + + return; + } + _ = ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked()); } @@ -345,6 +467,7 @@ public void StartFirstScan(in FirstScanRequest request) /// The complete first-scan request. /// A cooperative cancellation observation token; it cannot interrupt CE. /// Cancellation was observed before the CE firstScan call began. + /// The session was released from inside this call (see the type remarks). [MainThreadOnly] [RequiresPluginEnabled] public void StartFirstScanCancellable(in FirstScanRequest request, CancellationToken cancellationToken) @@ -353,15 +476,27 @@ public void StartFirstScanCancellable(in FirstScanRequest request, CancellationT RequireEnabledMainThread(); RequireState("StartFirstScan", MemoryScanState.New); ValidateFirstRequest(in request); - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, FirstScanOperation); - ThrowIfCancelledBeforeNativeCall(cancellationToken); + BeginSessionCall("StartFirstScan"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, FirstScanOperation); + ThrowIfCancelledBeforeNativeCall(cancellationToken); - // A CE error may happen after it accepts some scan setup. Do not report the old New state after a partial call. - Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - CallFirstScan(operation.State, _scanner!.Value, in request); - State = MemoryScanState.Scanning; - ObserveCancellationAfterNativeCall(cancellationToken); + // A CE error may happen after it accepts some scan setup. Do not report the old New state after a partial + // call. + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + _scanMayBeRunning = true; + CallFirstScan(operation.State, _scanner!.Value, in request); + CompleteTransition(MemoryScanState.Scanning); + ObserveCancellationAfterNativeCall(cancellationToken); + } + finally + { + EndSessionCall(); + } + + ThrowIfReleasedDuringCall("StartFirstScan"); } /// Begins a CE next scan over the previous readable result set. @@ -382,6 +517,7 @@ public void StartNextScan(in NextScanRequest request) /// The complete next-scan request. /// A cooperative cancellation observation token; it cannot interrupt CE. /// Cancellation was observed before the CE nextScan call began. + /// The session was released from inside this call (see the type remarks). [MainThreadOnly] [RequiresPluginEnabled] public void StartNextScanCancellable(in NextScanRequest request, CancellationToken cancellationToken) @@ -390,23 +526,38 @@ public void StartNextScanCancellable(in NextScanRequest request, CancellationTok RequireEnabledMainThread(); RequireState("StartNextScan", MemoryScanState.ResultsReady); ValidateNextRequest(in request); - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, NextScanOperation); - ThrowIfCancelledBeforeNativeCall(cancellationToken); + BeginSessionCall("StartNextScan"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, NextScanOperation); + ThrowIfCancelledBeforeNativeCall(cancellationToken); - // The list stops being readable as soon as this session releases it. A failed deinitialize or nextScan leaves - // the conservative Invalidated state, from which Reset is the only recovery. - Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - CallNoResult(operation.State, _foundList!.Value.Handle, "deinitialize"u8, DeinitializeResultsOperation); - CallNextScan(operation.State, _scanner!.Value, in request); - State = MemoryScanState.Scanning; - ObserveCancellationAfterNativeCall(cancellationToken); + // The list stops being readable as soon as this session releases it. A failed deinitialize or nextScan + // leaves the conservative Invalidated state, from which Reset is the only recovery. + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + CallNoResult(operation.State, _foundList!.Value.Handle, "deinitialize"u8, DeinitializeResultsOperation); + _scanMayBeRunning = true; + CallNextScan(operation.State, _scanner!.Value, in request); + CompleteTransition(MemoryScanState.Scanning); + ObserveCancellationAfterNativeCall(cancellationToken); + } + finally + { + EndSessionCall(); + } + + ThrowIfReleasedDuringCall("StartNextScan"); } /// /// Waits through CE's no-timeout waitTillDone() form, then initializes the attached found list only after /// CE reports completion. /// + /// + /// This is CE's documented blocking form: it has no deadline and no cancellation argument. CE 7.7.0.10621 also + /// has a timeout form; projects it experimentally. + /// /// The session is not scanning. /// The protected CE call failed. /// The plugin is not enabled or the caller is not on its main thread. @@ -423,6 +574,10 @@ public void WaitForCompletion() /// /// A cooperative cancellation observation token; it cannot interrupt CE. /// Cancellation was observed before CE waitTillDone() began. + /// + /// The session was released from inside the wait (CE's wait can run queued main-thread work, see the type + /// remarks); the found list was not initialized and the release ran once after the wait returned. + /// [MainThreadOnly] [RequiresPluginEnabled] public void WaitForCompletionCancellable(CancellationToken cancellationToken) @@ -430,18 +585,39 @@ public void WaitForCompletionCancellable(CancellationToken cancellationToken) LastCancellationMilestone = MemoryScanCancellationMilestone.None; RequireEnabledMainThread(); RequireState("WaitForCompletion", MemoryScanState.Scanning); - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, WaitForCompletionOperation); - ThrowIfCancelledBeforeNativeCall(cancellationToken); + BeginSessionCall("WaitForCompletion"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, WaitForCompletionOperation); + ThrowIfCancelledBeforeNativeCall(cancellationToken); + WaitAndInitialize(operation.State, cancellationToken); + } + finally + { + EndSessionCall(); + } + + ThrowIfReleasedDuringCall("WaitForCompletion"); + } + private void WaitAndInitialize(LuaState state, CancellationToken cancellationToken) + { try { - CallWaitTillDone(operation.State, _scanner!.Value); + CallWaitTillDone(state, _scanner!.Value); + _scanMayBeRunning = false; ObserveCancellationAfterNativeCall(cancellationToken); + // A release requested from inside the wait runs as soon as this member returns; it needs no readable view. + if (IsDisposalRequested) + { + return; + } + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - CallNoResult(operation.State, _foundList!.Value.Handle, "initialize"u8, InitializeResultsOperation); - State = MemoryScanState.ResultsReady; + CallNoResult(state, _foundList!.Value.Handle, "initialize"u8, InitializeResultsOperation); + CompleteTransition(MemoryScanState.ResultsReady); } catch { @@ -469,6 +645,12 @@ public void Reset() /// Resets the CE scan only when cancellation was not observed before the first native cleanup call. /// A cooperative cancellation observation token; it cannot interrupt CE. /// Cancellation was observed before CE cleanup began. + /// + /// The session is scanning or disposed, or a cooperative stop requested through + /// was not confirmed (release or abandon the session instead), or another member + /// of the session is inside a CE call. + /// + /// The session was released from inside this call (see the type remarks). [MainThreadOnly] [RequiresPluginEnabled] public void ResetCancellable(CancellationToken cancellationToken) @@ -481,21 +663,226 @@ public void ResetCancellable(CancellationToken cancellationToken) return; } - if (State == MemoryScanState.Scanning) + if (State == MemoryScanState.Scanning || + (_terminationAttempted && _termination != MemoryScanTerminationStatus.Confirmed)) { ThrowWrongState("Reset"); } - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, ResetOperation); - ThrowIfCancelledBeforeNativeCall(cancellationToken); + BeginSessionCall("Reset"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, ResetOperation); + ThrowIfCancelledBeforeNativeCall(cancellationToken); - Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); - CallNoResult(operation.State, _foundList!.Value.Handle, "deinitialize"u8, DeinitializeResultsOperation); - CallNoResult(operation.State, _scanner!.Value.Handle, "newScan"u8, ResetOperation); - State = MemoryScanState.New; - InvalidationReason = MemoryScanInvalidationReason.None; - ObserveCancellationAfterNativeCall(cancellationToken); + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + CallNoResult(operation.State, _foundList!.Value.Handle, "deinitialize"u8, DeinitializeResultsOperation); + CallNoResult(operation.State, _scanner!.Value.Handle, "newScan"u8, ResetOperation); + + // CE's newScan stops a running scan controller before clearing its results (ObservedSource: cheat-engine/ + // cheat-engine@ec45d5f memscan.pas:8360-8372), so a successful reset ends the previous scan's lifecycle. + _scanMayBeRunning = false; + _terminationAttempted = false; + _termination = MemoryScanTerminationStatus.Unknown; + CompleteTransition(MemoryScanState.New); + ObserveCancellationAfterNativeCall(cancellationToken); + } + finally + { + EndSessionCall(); + } + + ThrowIfReleasedDuringCall("Reset"); + } + + /// + /// Waits for the running scan through CE's waitTillDone(timeout) form for at most + /// (the call deadline), then initializes the attached found list only after CE + /// reports completion. + /// + /// + /// The call deadline: strictly positive and at most milliseconds. A sub-millisecond + /// value rounds up to one millisecond. is refused: use + /// for CE's no-timeout form. + /// + /// + /// when results are ready; + /// when the deadline expired first (the session stays scanning and the scan may still run: wait again, call + /// , or release the session); a context status without any CE call; otherwise a + /// failure that invalidates the session. Categories come from the result's Lua type, never from error text. + /// + /// is outside the accepted range. + /// + /// The session is not scanning, or another member of the session is inside a CE call. + /// + /// + /// The session was disposed, including by a release requested from inside this wait (see the type remarks): the + /// found list was then not initialized and the release ran once after the wait returned. + /// + /// The plugin is not enabled or the caller is not on its main thread. + /// + /// The call pushes exactly one integer argument and reads exactly one boolean result. The + /// (timed-out) path was not observed on the pinned CE 7.7.0.10621 host (spike D4.7), which is why this member is + /// experimental. It blocks CE's main thread for at most the deadline. + /// + [Experimental("CESDK5010", UrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md")] + [MainThreadOnly] + [RequiresPluginEnabled] + public MemoryScanWaitStatus TryWaitForCompletion(TimeSpan timeout) + { + int milliseconds = ToWaitMilliseconds(timeout, nameof(timeout)); + RequireEnabledMainThread(); + RequireState("TryWaitForCompletion", MemoryScanState.Scanning); + MemoryScanWaitStatus status; + BeginSessionCall("TryWaitForCompletion"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State); + status = context == MemoryScanMaterializationStatus.Success + ? WaitCore(operation.State, milliseconds, true, out _) + : ToWaitStatus(context); + } + finally + { + EndSessionCall(); + } + + ThrowIfReleasedDuringCall("TryWaitForCompletion"); + return status; + } + + /// + /// Requests a cooperative stop of a scan that may still be running (terminateScan(false)), then waits for + /// it through waitTillDone(timeout) for at most . + /// + /// + /// The deadline of the settle wait: strictly positive and at most milliseconds; a + /// sub-millisecond value rounds up to one millisecond. + /// + /// + /// when CE confirmed the stop; + /// when the session's runtime or target context was refused + /// (no CE call); otherwise an unconfirmed stop. The request is made once and never retried. + /// + /// is outside the accepted range. + /// + /// No scan can be running (the session never started one, or a wait, a confirmed stop or a reset already ended + /// it), a stop was already requested for this scan, or another member of the session is inside a CE call. + /// + /// + /// The session was disposed, including by a release requested from inside this call's settle wait (see the type + /// remarks); that release ran once after the wait returned, without a second stop request, and its + /// reports an unconfirmed stop. + /// + /// The plugin is not enabled or the caller is not on its main thread. + /// + /// + /// The stop is always cooperative: the SDK passes an explicit force argument and exposes + /// no force option (a forced stop can kill CE's scan thread and open a modal dialog). The session always ends + /// , with + /// unless a context check recorded a more specific reason, and never exposes the stopped scan's results. + /// + /// + /// After , can start a new lifecycle. + /// After any other status, is refused; and + /// stay available, and release reports this unconfirmed status without a second stop + /// request. terminateScan and the timed-out wait were not observed on the pinned CE 7.7.0.10621 host + /// (spike D4.7), which is why this member is experimental. + /// + /// + [Experimental("CESDK5010", UrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md")] + [MainThreadOnly] + [RequiresPluginEnabled] + public MemoryScanTerminationStatus TryTerminateScan(TimeSpan waitTimeout) + { + int milliseconds = ToWaitMilliseconds(waitTimeout, nameof(waitTimeout)); + RequireEnabledMainThread(); + ThrowIfDisposed(); + if (!_scanMayBeRunning || _terminationAttempted) + { + ThrowWrongState("TryTerminateScan"); + } + + MemoryScanTerminationStatus status; + BeginSessionCall("TryTerminateScan"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State); + if (context == MemoryScanMaterializationStatus.Success) + { + status = TerminateAndSettleCore(operation.State, milliseconds); + Invalidate(MemoryScanInvalidationReason.ScanTerminated); + } + else + { + if (State != MemoryScanState.Invalidated) + { + Invalidate(MemoryScanInvalidationReason.ScanTerminated); + } + + status = MemoryScanTerminationStatus.NotInvoked; + } + } + finally + { + EndSessionCall(); + } + + ThrowIfReleasedDuringCall("TryTerminateScan"); + return status; + } + + /// + /// Copies CE's MemScan.ErrorString text as a bounded fact, without interpreting it. + /// + /// + /// The copied text when the method returns (possibly empty): at most + /// UTF-8 bytes, cut back to a UTF-8 sequence boundary, with invalid + /// sequences decoded as U+FFFD and embedded NUL characters kept. + /// + /// Whether the host text was longer than the copied prefix. + /// + /// without any scanner call when the session's runtime or target context is refused; as + /// for every session operation, a changed runtime or target incarnation then invalidates the session + /// (, + /// or + /// ). when the property + /// read raised or did not return a Lua string, which leaves the session state unchanged. + /// + /// The session was disposed. + /// Another member of the session is inside a CE call. + /// The plugin is not enabled or the caller is not on its main thread. + /// + /// Available in every state but disposed. CE's error text is a host-language diagnostic: its presence can be + /// reported, but the SDK never derives an outcome category from its content (it changes with CE's UI language, + /// and CE reports a misleading text for an empty range, spike D4.3). + /// + [MainThreadOnly] + [RequiresPluginEnabled] + public bool TryGetHostErrorText([NotNullWhen(true)] out string? text, out bool truncated) + { + ThrowIfDisposed(); + RequireEnabledMainThread(); + BeginSessionCall("TryGetHostErrorText"); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + if (TryEnsureCurrentContext(operation.State) != MemoryScanMaterializationStatus.Success) + { + text = null; + truncated = false; + return false; + } + + return TryReadHostErrorTextCore(operation.State, out text, out truncated); + } + finally + { + EndSessionCall(); + } } /// Attempts to read the parsed target address at a zero-based result index. @@ -515,24 +902,32 @@ public bool TryGetAddress(int zeroBasedIndex, out Address address) { ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); RequireEnabledMainThread(); - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, ResultAddressOperation); - FoundList foundList = RequireResults(); - if ((ulong) zeroBasedIndex >= ReadResultCount(operation.State, foundList)) + BeginSessionCall("TryGetAddress"); + try { - address = default; - return false; - } + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, ResultAddressOperation); + FoundList foundList = RequireResults(); + if ((ulong) zeroBasedIndex >= ReadResultCount(operation.State, foundList)) + { + address = default; + return false; + } + + string text = CallString(operation.State, foundList.Handle, "getAddress"u8, ResultAddressOperation, + zeroBasedIndex); + if (Address.TryParse(text, out address)) + { + return true; + } - string text = CallString(operation.State, foundList.Handle, "getAddress"u8, ResultAddressOperation, - zeroBasedIndex); - if (Address.TryParse(text, out address)) + throw new MemoryScanException(MemoryScanFailureKind.UnexpectedResult, ResultAddressOperation, + "The memory scan result address was not a hexadecimal target address."); + } + finally { - return true; + EndSessionCall(); } - - throw new MemoryScanException(MemoryScanFailureKind.UnexpectedResult, ResultAddressOperation, - "The memory scan result address was not a hexadecimal target address."); } /// Attempts to read the exact value text at a zero-based result index. @@ -552,17 +947,25 @@ public bool TryGetValue(int zeroBasedIndex, [NotNullWhen(true)] out string? valu { ArgumentOutOfRangeException.ThrowIfNegative(zeroBasedIndex); RequireEnabledMainThread(); - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - EnsureCurrentContext(operation.State, ResultValueOperation); - FoundList foundList = RequireResults(); - if ((ulong) zeroBasedIndex >= ReadResultCount(operation.State, foundList)) + BeginSessionCall("TryGetValue"); + try { - value = null; - return false; - } + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + EnsureCurrentContext(operation.State, ResultValueOperation); + FoundList foundList = RequireResults(); + if ((ulong) zeroBasedIndex >= ReadResultCount(operation.State, foundList)) + { + value = null; + return false; + } - value = CallString(operation.State, foundList.Handle, "getValue"u8, ResultValueOperation, zeroBasedIndex); - return true; + value = CallString(operation.State, foundList.Handle, "getValue"u8, ResultValueOperation, zeroBasedIndex); + return true; + } + finally + { + EndSessionCall(); + } } /// Copies the complete initialized found list into a caller-bounded managed destination. @@ -602,9 +1005,6 @@ public MemoryScanMaterializationStatus TryCopyResults(Span des /// [MainThreadOnly] [RequiresPluginEnabled] - [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", - Justification = - "This bounded materialization operation keeps its cancellation and ownership milestones together.")] public MemoryScanMaterializationStatus TryCopyResultsCancellable(Span destination, out ulong totalCount, out int written, CancellationToken cancellationToken) { @@ -617,6 +1017,22 @@ public MemoryScanMaterializationStatus TryCopyResultsCancellable(Span destination, out ulong totalCount, + out int written, CancellationToken cancellationToken) + { + totalCount = 0; + written = 0; using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State); if (context != MemoryScanMaterializationStatus.Success) @@ -713,6 +1129,22 @@ public MemoryScanMaterializationStatus TryCopyResultsPageCancellable(int firstRe ThrowWrongState("TryCopyResultsPage"); } + BeginSessionCall("TryCopyResultsPage"); + try + { + return CopyResultsPage(firstResultIndex, destination, out totalCount, out written, cancellationToken); + } + finally + { + EndSessionCall(); + } + } + + private MemoryScanMaterializationStatus CopyResultsPage(int firstResultIndex, Span destination, + out ulong totalCount, out int written, CancellationToken cancellationToken) + { + totalCount = 0; + written = 0; using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State); if (context != MemoryScanMaterializationStatus.Success) @@ -808,42 +1240,417 @@ private static MemoryScanMaterializationStatus ToMaterializationStatus(MemorySca : MemoryScanMaterializationStatus.InvalidResult; } + // Validates a TimeSpan deadline and converts it to CE's integer milliseconds, rounding a partial millisecond up so + // that a positive deadline never becomes zero. + internal static int ToWaitMilliseconds(TimeSpan timeout, string parameterName) + { + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(parameterName, timeout, + "A scan wait deadline must be strictly positive; use the no-timeout wait for CE's blocking form."); + } + + long ticks = timeout.Ticks; + long milliseconds = (ticks / TimeSpan.TicksPerMillisecond) + (ticks % TimeSpan.TicksPerMillisecond == 0 ? 0 : 1); + if (milliseconds > int.MaxValue) + { + throw new ArgumentOutOfRangeException(parameterName, timeout, + "A scan wait deadline cannot exceed Int32.MaxValue milliseconds, the range of CE's integer timeout."); + } + + return (int) milliseconds; + } + + // The shared wait: CE's no-timeout form (0 arguments, 0 results) when timeoutMilliseconds is null, otherwise the + // timeout form (1 integer argument, 1 boolean result). A completed wait clears the running flag; initializeResults + // is false only for the first-found route, whose found list must never be initialized. + internal MemoryScanWaitStatus WaitCore(LuaState state, int? timeoutMilliseconds, bool initializeResults, + out LuaStatus luaStatus) + { + MemoryScanWaitStatus waited = CallWait(state, timeoutMilliseconds, out luaStatus); + if (waited != MemoryScanWaitStatus.Completed) + { + if (waited != MemoryScanWaitStatus.TimedOut) + { + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + } + + return waited; + } + + _scanMayBeRunning = false; + + // A release requested from inside the wait runs as soon as the active member returns; it needs no readable view. + if (!initializeResults || IsDisposalRequested) + { + return MemoryScanWaitStatus.Completed; + } + + Invalidate(MemoryScanInvalidationReason.ProtectedLuaFailure); + using LuaFrame frame = new(state); + luaStatus = _foundList!.Value.Handle.TryCallMethod(state, "initialize"u8, 0, 0); + if (!luaStatus.IsOk) + { + return MemoryScanWaitStatus.InitializationFailed; + } + + CompleteTransition(MemoryScanState.ResultsReady); + return MemoryScanWaitStatus.Completed; + } + + private MemoryScanWaitStatus CallWait(LuaState state, int? timeoutMilliseconds, out LuaStatus luaStatus) + { + using LuaFrame frame = new(state); + if (timeoutMilliseconds is not { } milliseconds) + { + luaStatus = _scanner!.Value.Handle.TryCallMethod(state, "waitTillDone"u8, 0, 0); + return luaStatus.IsOk ? MemoryScanWaitStatus.Completed : MemoryScanWaitStatus.LuaFailure; + } + + state.PushInteger(milliseconds); + luaStatus = _scanner!.Value.Handle.TryCallMethod(state, "waitTillDone"u8, 1, 1); + if (!luaStatus.IsOk) + { + return MemoryScanWaitStatus.LuaFailure; + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + return MemoryScanWaitStatus.InvalidResult; + } + + return state.ToBoolean(-1) ? MemoryScanWaitStatus.Completed : MemoryScanWaitStatus.TimedOut; + } + + // The one cooperative stop request of the current scan: terminateScan(false) (1 argument, 0 results), then + // waitTillDone(timeout) (1 argument, 1 boolean result). Each is called at most once and never retried; the method + // never throws because release relies on it. + internal MemoryScanTerminationStatus TerminateAndSettleCore(LuaState state, int waitMilliseconds) + { + _terminationAttempted = true; + MemoryScanTerminationStatus status = MemoryScanTerminationStatus.TerminateFailed; + try + { + if (RequestTermination(state)) + { + // CE accepted the stop request: from here on an unexpected failure belongs to the settle wait. + status = MemoryScanTerminationStatus.WaitFailed; + status = SettleAfterTermination(state, waitMilliseconds); + } + } + catch (Exception) + { + // A binding failure leaves the stop unconfirmed (TerminateFailed before CE accepted the request, WaitFailed + // after it); it is never retried. + } + + _termination = status; + if (status == MemoryScanTerminationStatus.Confirmed) + { + _scanMayBeRunning = false; + } + + return status; + } + + private bool RequestTermination(LuaState state) + { + using LuaFrame frame = new(state); + state.PushBoolean(false); + return _scanner!.Value.Handle.TryCallMethod(state, "terminateScan"u8, 1, 0).IsOk; + } + + private MemoryScanTerminationStatus SettleAfterTermination(LuaState state, int waitMilliseconds) + { + using LuaFrame frame = new(state); + state.PushInteger(waitMilliseconds); + if (!_scanner!.Value.Handle.TryCallMethod(state, "waitTillDone"u8, 1, 1).IsOk || + state.TypeOf(-1) != LuaType.Boolean) + { + return MemoryScanTerminationStatus.WaitFailed; + } + + return state.ToBoolean(-1) ? MemoryScanTerminationStatus.Confirmed : MemoryScanTerminationStatus.WaitTimedOut; + } + + // Reads the ErrorString property through a protected property read and copies at most the documented bound. Returns + // false when the read raised or did not produce a Lua string; never classifies the text. + internal bool TryReadHostErrorTextCore(LuaState state, [NotNullWhen(true)] out string? text, out bool truncated) + { + using LuaFrame frame = new(state); + if (!_scanner!.Value.Handle.TryGetProperty(state, "ErrorString"u8).IsOk || + !state.TryReadUtf8(-1, out ReadOnlySpan utf8)) + { + text = null; + truncated = false; + return false; + } + + text = DecodeBoundedUtf8(utf8, HostErrorTextMaximumUtf8Bytes, out truncated); + return true; + } + + // Decodes at most maximumBytes bytes, cutting back to the start of a UTF-8 sequence so that the copied prefix never + // ends inside a character. A malformed run of continuation bytes is cut at the bound; invalid sequences become + // U+FFFD. Embedded NUL bytes are kept. + internal static string DecodeBoundedUtf8(ReadOnlySpan utf8, int maximumBytes, out bool truncated) + { + if (utf8.Length <= maximumBytes) + { + truncated = false; + return Encoding.UTF8.GetString(utf8); + } + + int cut = maximumBytes; + int lowest = Math.Max(0, maximumBytes - 3); + while (cut > lowest && IsUtf8Continuation(utf8[cut])) + { + cut--; + } + + if (IsUtf8Continuation(utf8[cut])) + { + cut = maximumBytes; + } + + truncated = true; + return Encoding.UTF8.GetString(utf8[..cut]); + } + + private static bool IsUtf8Continuation(byte value) + { + return (value & 0xC0) == 0x80; + } + + // Cores for the bounded AOB routes of AobScanner. They run inside the caller's admitted operation and never + // re-acquire it; state and context rules are enforced by the caller's sequence. + + // MemScan.setOnlyOneResult(value): 1 argument, 0 results. + internal LuaStatus SetOnlyOneResultCore(LuaState state, bool value) + { + using LuaFrame frame = new(state); + state.PushBoolean(value); + return _scanner!.Value.Handle.TryCallMethod(state, "setOnlyOneResult"u8, 1, 0); + } + + // The runtime and target check of every session operation, without throwing. + internal MemoryScanMaterializationStatus TryEnsureCurrentContextCore(LuaState state) + { + return TryEnsureCurrentContext(state); + } + + // FoundList.getCount() of an initialized list; throws MemoryScanException (LuaError or UnexpectedResult). + internal ulong ReadResultCountCore(LuaState state) + { + return ReadResultCount(state, RequireResults()); + } + + // MemScan.getOnlyResult(): 0 arguments, 1 result. No value or nil is "not found" (celua.txt line 2657); only a Lua + // integer is an address, read bit for bit so that an address at or above 2^63 keeps its 64-bit pattern. A float is + // refused rather than converted, like any other non-integer. + internal MemoryScanOnlyResult TryReadOnlyResultCore(LuaState state, out Address address, out LuaStatus luaStatus) + { + using LuaFrame frame = new(state); + address = default; + luaStatus = _scanner!.Value.Handle.TryCallMethod(state, "getOnlyResult"u8, 0, 1); + if (!luaStatus.IsOk) + { + return MemoryScanOnlyResult.LuaFailure; + } + + if (state.IsNil(-1)) + { + return MemoryScanOnlyResult.NotFound; + } + + if (!state.IsInteger(-1) || !state.TryReadInteger(-1, out long bits)) + { + return MemoryScanOnlyResult.InvalidResult; + } + + address = Address.FromInt64(bits); + return MemoryScanOnlyResult.Found; + } + + // FoundList.getAddress(index) of an initialized list, read as UTF-8 and parsed without allocating. It never reads + // getValue: an address-only copy costs one CE call per row. + internal MemoryScanRowRead TryReadAddressRowCore(LuaState state, int index, out Address address, + out LuaStatus luaStatus) + { + using LuaFrame frame = new(state); + state.PushInteger(index); + luaStatus = RequireResults().Handle.TryCallMethod(state, "getAddress"u8, 1, 1); + if (!luaStatus.IsOk) + { + address = default; + return MemoryScanRowRead.LuaFailure; + } + + if (!state.TryReadUtf8(-1, out ReadOnlySpan utf8) || !Address.TryParse(utf8, out address)) + { + address = default; + return MemoryScanRowRead.InvalidResult; + } + + return MemoryScanRowRead.Read; + } + + private static MemoryScanWaitStatus ToWaitStatus(MemoryScanMaterializationStatus context) + { + return context switch + { + MemoryScanMaterializationStatus.RuntimeInvalidated => MemoryScanWaitStatus.RuntimeInvalidated, + MemoryScanMaterializationStatus.TargetIdentityMismatch => MemoryScanWaitStatus.TargetIdentityMismatch, + _ => MemoryScanWaitStatus.TargetIdentityUnavailable + }; + } + private MemoryScanReleaseOutcome ReleaseWithinCurrentContext(LuaState state) { Owned? foundList = _foundList; Owned? scanner = _scanner; + MemoryScanTerminationStatus termination = StopRunningScanForRelease(state); if (State == MemoryScanState.ResultsReady && foundList is not null && !foundList.IsDisposed) { using LuaFrame frame = new(state); _ = foundList.Value.Handle.TryCallMethod(state, "deinitialize"u8, 0, 0); } + // Destroy the child, then the parent, each exactly once, even after an unconfirmed stop (audit A13-26). TargetReleaseOutcome foundListOutcome = ReleaseOwned(state, foundList); TargetReleaseOutcome scannerOutcome = ReleaseOwned(state, scanner); - return CompleteRelease(foundListOutcome, scannerOutcome); + return CompleteRelease(foundListOutcome, scannerOutcome, termination); } + // The release's one cooperative stop. A stop already requested through TryTerminateScan (or by the bounded AOB + // route after its deadline) is never repeated: its unconfirmed status is reported instead. + private MemoryScanTerminationStatus StopRunningScanForRelease(LuaState state) + { + if (!_scanMayBeRunning) + { + return MemoryScanTerminationStatus.NotRequired; + } + + return _terminationAttempted + ? _termination + : TerminateAndSettleCore(state, ReleaseTerminationWaitMilliseconds); + } + + // Consumes both owners without any CE call. A stop requested earlier (and left unconfirmed) keeps its status; a + // scan that may run without any stop request reports NotInvoked. private MemoryScanReleaseOutcome ConsumeWithoutCleanup(TargetReleaseOutcome outcome) { + MemoryScanTerminationStatus termination = !_scanMayBeRunning + ? MemoryScanTerminationStatus.NotRequired + : _terminationAttempted + ? _termination + : MemoryScanTerminationStatus.NotInvoked; ConsumeOwner(_foundList); ConsumeOwner(_scanner); - return CompleteRelease(outcome, outcome); + return CompleteRelease(outcome, outcome, termination); + } + + // The one release: runs as the session's active member so that work CE runs during its waits and destroys cannot + // start a second release (a re-entrant release returns the provisional default outcome instead). + private MemoryScanReleaseOutcome ReleaseAsActiveCall() + { + _activeOperation = "ReleaseWithOutcome"; + _releaseInProgress = true; + try + { + return ReleaseCore(); + } + finally + { + _releaseInProgress = false; + _activeOperation = null; + _deferredDisposal = DeferredDisposal.None; + } + } + + private MemoryScanReleaseOutcome ReleaseCore() + { + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State); + if (context != MemoryScanMaterializationStatus.Success) + { + return ConsumeWithoutCleanup(CreateRefusedReleaseOutcome(context)); + } + + return ReleaseWithinCurrentContext(operation.State); + } + catch (Exception) + { + return ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); + } + } + + // Marks the start of a member's CE calls. A member called from inside another member's CE call (CE ran queued + // main-thread work during it) is refused before it makes any CE call. + private void BeginSessionCall(string operation) + { + if (_activeOperation is not null) + { + throw new MemoryScanStateException(operation, State, _activeOperation); + } + + _activeOperation = operation; + } + + // Marks the end of a member's CE calls, then runs a release or abandon requested from inside them: exactly once, + // and only now that none of this session's CE calls is on the stack. Never throws. + private void EndSessionCall() + { + _activeOperation = null; + DeferredDisposal deferred = _deferredDisposal; + _deferredDisposal = DeferredDisposal.None; + if (deferred == DeferredDisposal.None || State == MemoryScanState.Disposed) + { + return; + } + + if (deferred == DeferredDisposal.Abandon) + { + _ = ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked()); + return; + } + + _ = ReleaseAsActiveCall(); + } + + // Whether a release or abandon was requested from inside the active member's CE calls: that member then makes no + // further CE call. + private bool IsDisposalRequested => _deferredDisposal != DeferredDisposal.None; + + // After a lifecycle member returned: a release deferred from inside its CE calls has disposed the session, so the + // member must not report a completed transition. + private void ThrowIfReleasedDuringCall(string operation) + { + if (State == MemoryScanState.Disposed) + { + throw new ObjectDisposedException(nameof(MemoryScanSession), + "The memory-scan session was released or abandoned by a call made while '" + operation + + "' was inside a Cheat Engine call; that release ran once, after the Cheat Engine call returned."); + } } private MemoryScanReleaseOutcome CompleteRelease(TargetReleaseOutcome foundListOutcome, - TargetReleaseOutcome scannerOutcome) + TargetReleaseOutcome scannerOutcome, MemoryScanTerminationStatus termination) { _foundList = null; _scanner = null; State = MemoryScanState.Disposed; - LastReleaseOutcome = new MemoryScanReleaseOutcome(foundListOutcome, scannerOutcome, true, true); + LastReleaseOutcome = + new MemoryScanReleaseOutcome(foundListOutcome, scannerOutcome, true, true, termination); return LastReleaseOutcome; } private TargetReleaseOutcome CreateRefusedReleaseOutcome(MemoryScanMaterializationStatus context) { if (context is MemoryScanMaterializationStatus.TargetIdentityUnavailable or - MemoryScanMaterializationStatus.TargetIdentityMismatch && LastTargetCheck.HasValue) + MemoryScanMaterializationStatus.TargetIdentityMismatch && LastTargetCheck.HasValue) { return TargetReleaseOutcome.Refused(LastTargetCheck.GetValueOrDefault()); } @@ -1089,6 +1896,14 @@ private void Invalidate(MemoryScanInvalidationReason reason) InvalidationReason = reason; } + // Every transition first records the conservative ProtectedLuaFailure invalidation; only a transition whose native + // calls all completed reaches this point and clears that provisional reason. + private void CompleteTransition(MemoryScanState state) + { + State = state; + InvalidationReason = MemoryScanInvalidationReason.None; + } + private static void CallFirstScan(LuaState state, MemScan scanner, in FirstScanRequest request) { using LuaFrame frame = new(state); @@ -1206,4 +2021,13 @@ private static void ThrowLua(LuaState state, LuaStatus status, string operation) throw new MemoryScanException(MemoryScanFailureKind.LuaError, operation, "The protected Lua call for memory scan operation '" + operation + "' failed.", new LuaException(error)); } + + // What the active member must do once its CE calls have returned. Abandon takes precedence over release: it was + // explicitly asked to make no CE call. + private enum DeferredDisposal : byte + { + None = 0, + Release = 1, + Abandon = 2 + } } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs index 3502fd93..642212d6 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs @@ -157,8 +157,8 @@ private static MemoryScanCreationStatus TryCreateDetailedCore([NotNullWhen(true) bool foundListRollbackFailed = !TryRollback(state, foundList, foundListHandle); bool scannerRollbackFailed = !TryRollback(state, scanner, scannerHandle); if ((foundListRollbackFailed || scannerRollbackFailed) - && session is null - && status != MemoryScanCreationStatus.Success) + && session is null + && status != MemoryScanCreationStatus.Success) { status = MemoryScanCreationStatus.RollbackUnconfirmed; } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanStateException.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanStateException.cs index 2e45440a..4c84d6be 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanStateException.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanStateException.cs @@ -15,6 +15,15 @@ internal MemoryScanStateException(string operation, MemoryScanState state) State = state; } + // A re-entrant call: work that Cheat Engine ran from inside the session's active CE call called back into it. + internal MemoryScanStateException(string operation, MemoryScanState state, string activeOperation) + : base("The memory-scan operation '" + operation + "' was refused because the session's '" + activeOperation + + "' operation is still inside a Cheat Engine call (the call came from work Cheat Engine ran during it).") + { + Operation = operation; + State = state; + } + /// Gets the managed operation the caller attempted. public string Operation { diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanTerminationStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanTerminationStatus.cs new file mode 100644 index 00000000..40e2f7fe --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanTerminationStatus.cs @@ -0,0 +1,40 @@ +namespace CheatEngine.SDK.Engine.Scanning.Values; + +/// +/// Classifies the one cooperative stop request for a memory scan that may still be running: terminateScan(false) +/// followed by a bounded waitTillDone(timeout). +/// +/// +/// The zero value is ; a default value never reads as a confirmed stop. The SDK never forces +/// termination and never retries it: every value other than and +/// means the scan may still be running. +/// +public enum MemoryScanTerminationStatus : byte +{ + /// No termination status has been observed. + Unknown = 0, + + /// No scan could be running, so no stop was requested. + NotRequired = 1, + + /// CE accepted the cooperative stop and its bounded wait reported completion. + Confirmed = 2, + + /// CE accepted the cooperative stop, but its bounded wait expired first; the stop is unconfirmed. + WaitTimedOut = 3, + + /// The protected terminateScan call raised; no wait followed and the stop is unconfirmed. + TerminateFailed = 4, + + /// + /// The bounded wait after the stop request raised or returned something other than one boolean; the stop is + /// unconfirmed. + /// + WaitFailed = 5, + + /// + /// A scan may be running, but no CE call was allowed (worker thread, detached runtime, stale runtime or target + /// context); the stop was not requested. + /// + NotInvoked = 6 +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanWaitStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanWaitStatus.cs new file mode 100644 index 00000000..8a11e204 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanWaitStatus.cs @@ -0,0 +1,50 @@ +namespace CheatEngine.SDK.Engine.Scanning.Values; + +/// +/// Classifies one deadline-bounded wait for a running memory scan (MemScan.waitTillDone(timeout)). +/// +/// +/// The zero value is ; a default value never reads as success. Only +/// makes results readable. is the call deadline expiring: the scan +/// may still be running and the session stays . Every other failure leaves +/// the session or refuses before any CE call; none is derived from Lua +/// error text. +/// +public enum MemoryScanWaitStatus : byte +{ + /// No wait status has been observed; never produced by a completed call. + Unknown = 0, + + /// CE reported completion before the deadline and the attached found list was initialized for reading. + Completed = 1, + + /// + /// The call deadline expired before CE reported completion. The scan may still be running; the found list was + /// not initialized and the session is still scanning. + /// + TimedOut = 2, + + /// The protected wait call raised; the session is invalidated. + LuaFailure = 3, + + /// + /// The wait returned something other than one boolean (nil, no value, a number or a string); the session + /// is invalidated. + /// + InvalidResult = 4, + + /// + /// CE reported completion, then initializing the found list for reading raised; the session is invalidated and + /// never exposes results. + /// + InitializationFailed = 5, + + /// The session belongs to an earlier Lua runtime attachment or state generation; no CE call was made. + RuntimeInvalidated = 6, + + /// The current target could not be qualified as the session's original target; no CE call was made. + TargetIdentityUnavailable = 7, + + /// The current target is not the session's original target incarnation; no CE call was made. + TargetIdentityMismatch = 8 +} diff --git a/libs/CheatEngine.SDK.Engine/Tables/CheatTableFiles.cs b/libs/CheatEngine.SDK.Engine/Tables/CheatTableFiles.cs index e4a8ab86..d53c39ba 100644 --- a/libs/CheatEngine.SDK.Engine/Tables/CheatTableFiles.cs +++ b/libs/CheatEngine.SDK.Engine/Tables/CheatTableFiles.cs @@ -2,27 +2,65 @@ using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Lua; +using CheatEngine.SDK.Engine.AddressList; using CheatEngine.SDK.Lua.Calls; namespace CheatEngine.SDK.Engine.Tables; /// Protected bindings for loading and saving Cheat Engine table files. /// -/// CE 7.7.0.10621 x64 celua.txt documents loadTable(filename, merge) and -/// saveTable(filename). This API treats its path argument as opaque host input: it normalizes no path, -/// applies no file-root policy, and owns no file or CE object. It only preserves the exact Lua call shapes, -/// protected failure category, attach-epoch-aware function cache, and stack restoration. +/// +/// CE 7.7.0.10621 x64 celua.txt documents loadTable(filename, merge) and saveTable(filename) +/// (lines 32-35). This API treats its path argument as opaque host input: it normalizes no path, applies no +/// file-root policy, and owns no file or CE object. It only preserves the exact Lua call shapes, protected failure +/// category, attach-epoch-aware function cache, and stack restoration. The trust policy (which paths may be +/// loaded, and whether a table may be loaded at all) belongs to the application; converting a file into a stream +/// must never be used to bypass it. +/// +/// +/// Overload policy. Only the file overloads are projected. The file overload of loadTable has no +/// option to suppress the Lua-script dialog, so a table that contains Lua scripts may prompt the user or execute +/// Lua. The stream overloads (loadTable(stream, merge, ignoreluascriptdialog), saveTable(stream, ...)) +/// and the protect / dontDeactivateDesignerForms options of saveTable are not projected until +/// a Cheat Engine stream projection exists; the Lua surface catalogue records them as deferred. +/// +/// +/// Re-entrancy. While runs on a thread, the typed address-list mutations of +/// issued on that thread (for example from a script of the table being loaded) +/// are refused with before any Lua call: the record +/// identifiers they would resolve are being replaced. +/// /// public static partial class CheatTableFiles { + // Nesting depth of TryLoad on this thread; restored on every exit path of TryLoad. + [ThreadStatic] private static int t_loadDepth; + + /// Gets whether is running on the calling thread. + internal static bool IsLoadInProgressOnCurrentThread => t_loadDepth != 0; + /// Loads a Cheat Engine table file, optionally merging it into the current address list. /// The opaque path text passed directly to CE. /// Whether CE should merge instead of replacing the current table. /// The protected binding outcome. /// The plugin is not enabled or the calling thread has no Lua state. - [LuaGlobal("loadTable")] + /// + /// Calls loadTable with exactly the path and the merge flag. A table that contains Lua scripts may prompt or + /// execute Lua during the call; address-list mutations issued from the same thread meanwhile are refused. + /// [RequiresPluginEnabled] - public static partial LuaOperationStatus TryLoad(string path, bool merge); + public static LuaOperationStatus TryLoad(string path, bool merge) + { + t_loadDepth++; + try + { + return TryLoadCore(path, merge); + } + finally + { + t_loadDepth--; + } + } /// Saves the current Cheat Engine table to a file. /// The opaque path text passed directly to CE. @@ -31,4 +69,7 @@ public static partial class CheatTableFiles [LuaGlobal("saveTable")] [RequiresPluginEnabled] public static partial LuaOperationStatus TrySave(string path); + + [LuaGlobal("loadTable")] + private static partial LuaOperationStatus TryLoadCore(string path, bool merge); } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheckKind.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheckKind.cs index 60ab8ace..4290ad6f 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheckKind.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityCheckKind.cs @@ -28,5 +28,14 @@ public enum TargetIdentityCheckKind : byte LuaFailure = 7, /// The target observation returned an unsupported value. - InvalidResult = 8 + InvalidResult = 8, + + /// Cheat Engine is connected to CEServer, so the current target has no local incarnation to compare. + RemoteBackend = 9, + + /// The current target is a file opened as a process, which has no process incarnation. + FileAsProcess = 10, + + /// The target backend could not be established, so no local incarnation can be compared. + BackendUnknown = 11 } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityEvidence.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityEvidence.cs index 98c42eb4..b410d3fd 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityEvidence.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetIdentityEvidence.cs @@ -6,6 +6,9 @@ namespace CheatEngine.SDK.Engine.Targets; /// /// These flags describe observations, not a universal process lock. A caller must refuse a target-mutating /// operation when the required facts are absent rather than treating a PID or target architecture as an identity. +/// is emitted only together with : a local +/// creation time says nothing about a PID served remotely by CEServer (audit A12-05) or about a file opened as a +/// process (A12-07). /// [Flags] public enum TargetIdentityEvidence : byte @@ -17,5 +20,11 @@ public enum TargetIdentityEvidence : byte CheatEngineSelectedProcessId = 1, /// The local operating system supplied the selected process's creation time. - LocalProcessStartTime = 2 + LocalProcessStartTime = 2, + + /// + /// In the same Lua operation, Cheat Engine's isConnectedToCEServer returned , so the + /// selected PID denotes a process of the local machine. + /// + LocalBackendConfirmed = 4 } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetProcessIncarnation.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetProcessIncarnation.cs index 507f7a3f..3138f484 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetProcessIncarnation.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetProcessIncarnation.cs @@ -6,8 +6,10 @@ namespace CheatEngine.SDK.Engine.Targets; /// A copied local-process incarnation identified by its PID and observed UTC creation time. /// /// A PID alone can be reused. This value is created only by the SDK after it has combined Cheat Engine's selected -/// PID with a local process creation-time observation. It is not a process handle and never grants a caller a -/// right to select, open, or release a target process. +/// PID with a local process creation-time observation, and only when Cheat Engine reported, in the same operation, +/// that it is not connected to CEServer. A local creation time does not describe a PID served remotely by CEServer +/// or a file opened as a process, so neither yields an incarnation. It is not a process handle and never grants a +/// caller a right to select, open, or release a target process. /// [StructLayout(LayoutKind.Auto)] public readonly struct TargetProcessIncarnation : IEquatable diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseOutcome.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseOutcome.cs index 9250f134..82be4da6 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseOutcome.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseOutcome.cs @@ -64,6 +64,12 @@ internal static TargetReleaseOutcome Refused(TargetIdentityCheck check) return new TargetReleaseOutcome(GetRefusalStatus(check.Kind), check, null); } + // The owner belongs to a previous Lua universe (re-enable or controlled state replacement): no CE call was made. + internal static TargetReleaseOutcome RefusedRuntimeChanged() + { + return new TargetReleaseOutcome(TargetReleaseStatus.RefusedRuntimeChanged, null, null); + } + private static TargetReleaseStatus GetRefusalStatus(TargetIdentityCheckKind kind) { return kind switch @@ -71,6 +77,10 @@ private static TargetReleaseStatus GetRefusalStatus(TargetIdentityCheckKind kind TargetIdentityCheckKind.NoTargetSelected => TargetReleaseStatus.RefusedNoTarget, TargetIdentityCheckKind.TargetChanged => TargetReleaseStatus.RefusedTargetChanged, TargetIdentityCheckKind.ProcessReused => TargetReleaseStatus.RefusedProcessReused, + // A CEServer, file-as-process or unknown backend has no local incarnation to compare, so the identity that + // cleanup needs is unavailable; the check itself keeps the specific kind. + TargetIdentityCheckKind.RemoteBackend or TargetIdentityCheckKind.FileAsProcess + or TargetIdentityCheckKind.BackendUnknown => TargetReleaseStatus.RefusedIdentityUnavailable, _ => TargetReleaseStatus.RefusedIdentityUnavailable }; } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseStatus.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseStatus.cs index 4a56fd2f..65ae37b8 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetReleaseStatus.cs @@ -25,5 +25,12 @@ public enum TargetReleaseStatus : byte UnconfirmedAfterInvocation = 6, /// The owner was consumed, but cleanup could not begin a target operation. - NotInvoked = 7 + NotInvoked = 7, + + /// + /// The owner was consumed without any Cheat Engine call because the Lua runtime identity (attach epoch or state + /// generation) that created it is no longer current: the plugin was re-enabled or the Lua state was replaced. + /// The native resource may still exist and requires manual recovery. + /// + RefusedRuntimeChanged = 8 } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs index 23af4d95..6cadc03d 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs @@ -2,6 +2,8 @@ using System.ComponentModel; using System.Diagnostics; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -11,14 +13,59 @@ namespace CheatEngine.SDK.Engine.Targets; /// Observes and validates the target currently selected by Cheat Engine. /// -/// This API never selects a process and never opens a replacement target for cleanup. It can prove only the current -/// PID plus the current local creation-time observation. It does not claim to have observed an external A-to-B-to-A -/// transition that completed between two observations; a host transition sequence or target-specific host primitive -/// remains a separate qualification requirement for that stronger guarantee. +/// +/// This API never selects a process and never opens a replacement target for cleanup. It can prove only the +/// current PID plus the current local creation-time observation, and only for a local process. It does not claim +/// to have observed an external A-to-B-to-A transition that completed between two observations; a host +/// transition sequence or target-specific host primitive remains a separate qualification requirement for that +/// stronger guarantee. Cheat Engine's target is ambient: before-and-after checks reduce risk but are not a +/// transaction or a lock (audit A12-01). +/// +/// +/// In one protected operation it reads getOpenedProcessID and then isConnectedToCEServer. The +/// backend decides which evidence exists (audit A12-03, A12-05, A12-07): +/// +/// +/// +/// PID in (0, ] and isConnectedToCEServer() == false +/// +/// Local process: the local creation time is looked up and, when available, the observation is +/// . +/// +/// +/// +/// isConnectedToCEServer() == true +/// +/// : a local PID and creation +/// time do not describe a PID served by CEServer, so no lookup runs and no incarnation is emitted. +/// +/// +/// +/// isConnectedToCEServer absent +/// +/// : without the backend +/// fact no local incarnation evidence is emitted. +/// +/// +/// +/// PID 4294967295 +/// +/// : the file-as-process +/// sentinel (LuaHandler.pas:14101-14108 at ec45d5f, ObservedSource; not observed on the 7.7 +/// binary, ToQualify). A file opened as a process has no operating-system process, so no Windows +/// process is searched for. Every value this catches was already refused as an invalid PID; it is a +/// refusal classification, and 0xFFFFFFFF is not a multiple of four, so it is never a Windows PID. +/// +/// +/// +/// +/// Only the local backend () is a qualified backend, and these +/// behaviours are fixture-tested (C1); they are not host-qualified. +/// /// public static class TargetSelection { - private static readonly LuaRef SGetOpenedProcessId = new(); + private static readonly LuaRef SIsConnectedToCeServer = new(); /// Gets a copied observation of Cheat Engine's current target selection. public static TargetSelectionObservation ObserveCurrent() @@ -56,39 +103,40 @@ public static TargetIdentityCheck ValidateCurrent(TargetProcessIncarnation expec internal static TargetSelectionObservation ObserveCurrent(LuaState state) { - LuaGlobalPushOutcome global = - LuaGlobalFunctions.TryPushWithOutcome(state, SGetOpenedProcessId, "getOpenedProcessID"u8); - if (!global.IsSuccess) + TargetProbeStatus selection = TargetArchitectureProbe.ReadProcessId(state, out int processId, out _); + switch (selection) { - return global.Status == LuaGlobalPushStatus.Unavailable - ? TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.GlobalUnavailable) - : TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.LuaFailure); + case TargetProbeStatus.Success: + break; + case TargetProbeStatus.NoTargetSelected: + return TargetSelectionObservation.NoTarget(); + case TargetProbeStatus.FileAsProcess: + return TargetSelectionObservation.FileAsProcess(); + case TargetProbeStatus.GlobalUnavailable: + return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.GlobalUnavailable); + case TargetProbeStatus.LuaFailure: + return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.LuaFailure); + default: + return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.InvalidResult); } - if (!state.TryCall(0, 1).IsOk) + TargetBackend backend = ReadBackend(state, out TargetSelectionObservationStatus failure); + if (failure != TargetSelectionObservationStatus.Unspecified) { - return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.LuaFailure); + return TargetSelectionObservation.FromStatus(failure); } - if (!state.TryReadInteger(-1, out long rawProcessId)) + switch (backend) { - return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.InvalidResult); + case TargetBackend.LocalProcess: + return TryObserveIncarnation(processId, out TargetProcessIncarnation incarnation) + ? TargetSelectionObservation.Qualified(incarnation) + : TargetSelectionObservation.Unqualified(processId); + case TargetBackend.CEServer: + return TargetSelectionObservation.RemoteBackend(processId); + default: + return TargetSelectionObservation.BackendUnknown(processId); } - - if (rawProcessId == 0) - { - return TargetSelectionObservation.NoTarget(); - } - - if (rawProcessId < 0 || rawProcessId > int.MaxValue) - { - return TargetSelectionObservation.FromStatus(TargetSelectionObservationStatus.InvalidResult); - } - - int processId = (int) rawProcessId; - return TryObserveIncarnation(processId, out TargetProcessIncarnation incarnation) - ? TargetSelectionObservation.Qualified(incarnation) - : TargetSelectionObservation.Unqualified(processId); } internal static TargetIdentityCheck ValidateCurrent(LuaState state, TargetProcessIncarnation expected) @@ -115,6 +163,41 @@ internal static TargetIdentityCheck CreateUnavailableCheck(TargetSelectionObserv return new TargetIdentityCheck(MapUnavailable(observation.Status), observation); } + // isConnectedToCEServer, in the same protected operation as the PID: false is the local backend, true is CEServer, an + // absent global leaves the backend unknown. A raising probe or a non-boolean (nil included) is a failure, never local. + private static TargetBackend ReadBackend(LuaState state, out TargetSelectionObservationStatus failure) + { + failure = TargetSelectionObservationStatus.Unspecified; + int top = state.Top; + try + { + LuaGlobalPushOutcome global = + LuaGlobalFunctions.TryPushWithOutcome(state, SIsConnectedToCeServer, "isConnectedToCEServer"u8); + if (global.Status == LuaGlobalPushStatus.Unavailable) + { + return TargetBackend.Unknown; + } + + if (!global.IsSuccess || !state.TryCall(0, 1).IsOk) + { + failure = TargetSelectionObservationStatus.LuaFailure; + return TargetBackend.Unknown; + } + + if (state.TypeOf(-1) != LuaType.Boolean) + { + failure = TargetSelectionObservationStatus.InvalidResult; + return TargetBackend.Unknown; + } + + return state.ToBoolean(-1) ? TargetBackend.CEServer : TargetBackend.LocalProcess; + } + finally + { + state.SetTop(top); + } + } + private static bool TryObserveIncarnation(int processId, out TargetProcessIncarnation incarnation) { try @@ -131,7 +214,7 @@ private static bool TryObserveIncarnation(int processId, out TargetProcessIncarn return true; } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception - or NotSupportedException or UnauthorizedAccessException) + or NotSupportedException or UnauthorizedAccessException) { incarnation = default; return false; @@ -147,6 +230,9 @@ private static TargetIdentityCheckKind MapUnavailable(TargetSelectionObservation .CurrentTargetUnqualified, TargetSelectionObservationStatus.GlobalUnavailable => TargetIdentityCheckKind.GlobalUnavailable, TargetSelectionObservationStatus.LuaFailure => TargetIdentityCheckKind.LuaFailure, + TargetSelectionObservationStatus.CurrentTargetRemoteBackend => TargetIdentityCheckKind.RemoteBackend, + TargetSelectionObservationStatus.CurrentTargetFileAsProcess => TargetIdentityCheckKind.FileAsProcess, + TargetSelectionObservationStatus.CurrentTargetBackendUnknown => TargetIdentityCheckKind.BackendUnknown, _ => TargetIdentityCheckKind.InvalidResult }; } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservation.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservation.cs index 486c0344..73ebf36e 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservation.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservation.cs @@ -1,22 +1,27 @@ using System.Runtime.InteropServices; +using CheatEngine.SDK.Engine.Runtime; + namespace CheatEngine.SDK.Engine.Targets; /// A copied observation of the target currently selected by Cheat Engine. /// /// Selection and incarnation are intentionally separate. A selected PID without a creation-time observation is -/// useful diagnostic data but is not admitted as an authority for target-mutating allocation or patch cleanup. +/// useful diagnostic data but is not admitted as an authority for target-mutating allocation or patch cleanup. Only a +/// selection can be qualified: a CEServer, file-as-process or unknown +/// backend keeps its PID (when there is one) and its backend, but never an incarnation. /// [StructLayout(LayoutKind.Auto)] public readonly record struct TargetSelectionObservation { internal TargetSelectionObservation(TargetSelectionObservationStatus status, TargetIdentityEvidence evidence, - int? selectedProcessId, TargetProcessIncarnation? incarnation) + int? selectedProcessId, TargetProcessIncarnation? incarnation, TargetBackend backend) { Status = status; Evidence = evidence; SelectedProcessId = selectedProcessId; Incarnation = incarnation; + Backend = backend; } /// Gets the factual observation category. @@ -43,6 +48,16 @@ public TargetProcessIncarnation? Incarnation get; } + /// + /// Gets the backend established for the selection: when Cheat Engine + /// reported no CEServer connection, , , + /// or when no selection was observed or the backend probe is absent. + /// + public TargetBackend Backend + { + get; + } + /// Gets whether the observation can safely identify a local target-process incarnation. public bool IsQualified => Status == TargetSelectionObservationStatus.CurrentTargetQualified && Incarnation.HasValue; @@ -50,24 +65,43 @@ public TargetProcessIncarnation? Incarnation internal static TargetSelectionObservation Qualified(TargetProcessIncarnation incarnation) { return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetQualified, - TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalProcessStartTime, - incarnation.ProcessId, incarnation); + TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalBackendConfirmed | + TargetIdentityEvidence.LocalProcessStartTime, incarnation.ProcessId, incarnation, TargetBackend.LocalProcess); } internal static TargetSelectionObservation NoTarget() { return new TargetSelectionObservation(TargetSelectionObservationStatus.NoTargetSelected, - TargetIdentityEvidence.None, null, null); + TargetIdentityEvidence.None, null, null, TargetBackend.Unknown); } internal static TargetSelectionObservation Unqualified(int processId) { return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetUnqualified, - TargetIdentityEvidence.CheatEngineSelectedProcessId, processId, null); + TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalBackendConfirmed, + processId, null, TargetBackend.LocalProcess); + } + + internal static TargetSelectionObservation RemoteBackend(int processId) + { + return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetRemoteBackend, + TargetIdentityEvidence.CheatEngineSelectedProcessId, processId, null, TargetBackend.CEServer); + } + + internal static TargetSelectionObservation FileAsProcess() + { + return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetFileAsProcess, + TargetIdentityEvidence.None, null, null, TargetBackend.FileAsProcess); + } + + internal static TargetSelectionObservation BackendUnknown(int processId) + { + return new TargetSelectionObservation(TargetSelectionObservationStatus.CurrentTargetBackendUnknown, + TargetIdentityEvidence.CheatEngineSelectedProcessId, processId, null, TargetBackend.Unknown); } internal static TargetSelectionObservation FromStatus(TargetSelectionObservationStatus status) { - return new TargetSelectionObservation(status, TargetIdentityEvidence.None, null, null); + return new TargetSelectionObservation(status, TargetIdentityEvidence.None, null, null, TargetBackend.Unknown); } } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservationStatus.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservationStatus.cs index 34ff4b7f..b9480a00 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetSelectionObservationStatus.cs @@ -22,5 +22,23 @@ public enum TargetSelectionObservationStatus : byte LuaFailure = 5, /// The observation result was not a supported integer PID. - InvalidResult = 6 + InvalidResult = 6, + + /// + /// Cheat Engine reported a PID while connected to CEServer. A local PID and creation time do not describe a + /// remote target, so no incarnation evidence is emitted. + /// + CurrentTargetRemoteBackend = 7, + + /// + /// Cheat Engine reported the file-as-process sentinel PID 4294967295. A file opened as a process has no + /// operating-system process, so no process lookup is attempted. + /// + CurrentTargetFileAsProcess = 8, + + /// + /// Cheat Engine reported a PID, but isConnectedToCEServer is absent, so a local backend cannot be + /// established and no local incarnation evidence is emitted. + /// + CurrentTargetBackendUnknown = 9 } diff --git a/libs/CheatEngine.SDK.Engine/Values/Address.cs b/libs/CheatEngine.SDK.Engine/Values/Address.cs index 5c01ee30..c71ddf7e 100644 --- a/libs/CheatEngine.SDK.Engine/Values/Address.cs +++ b/libs/CheatEngine.SDK.Engine/Values/Address.cs @@ -574,7 +574,7 @@ private static int HexDigitValue(byte b) private static void ThrowFormat(ReadOnlySpan text) { throw new FormatException("'" + text.ToString() + - "' is not a hexadecimal address (digits with an optional 0x prefix)."); + "' is not a hexadecimal address (digits with an optional 0x prefix)."); } [DoesNotReturn] diff --git a/libs/CheatEngine.SDK.Engine/packages.lock.json b/libs/CheatEngine.SDK.Engine/packages.lock.json new file mode 100644 index 00000000..13ead563 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/packages.lock.json @@ -0,0 +1,50 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs index ecd2fc9e..ac96eb6d 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs @@ -17,8 +17,14 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; /// re-encoded with the ANSI code page of the process (, i.e. /// WideCharToMultiByte(CP_ACP)): characters the code page cannot represent become ?, and the same /// name shows differently on machines with different system locales. Plugin authors who want a name that survives -/// everywhere keep it ASCII. Whether Cheat Engine 7.7 really interprets the name as ANSI rather than UTF-8 is -/// not established; the ANSI reading follows the official bootstrap. +/// everywhere keep it ASCII. +/// +/// +/// Unqualified (2.0). Whether Cheat Engine 7.7 really interprets a non-ASCII plugin name as the process +/// ANSI code page rather than UTF-8 has not been confirmed by a host-based qualification run: no local Cheat +/// Engine qualification observation is available on this branch. The ANSI reading is kept unchanged because it +/// follows the official bootstrap and there is no observation to justify a different one; this remains open +/// pending a future host-based qualification run (audit A02-22, A04-14, A20-Q05-3). /// /// /// An embedded NUL ends the name early, as it would in any C string. An empty name yields a buffer holding one diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/LuaModuleLocator.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/LuaModuleLocator.cs index 0f375ee0..1fc7d8df 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/LuaModuleLocator.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/LuaModuleLocator.cs @@ -21,6 +21,7 @@ internal static unsafe class LuaModuleLocator { // The function pointer is stored as an integer so that it can be read and written with volatile semantics. private static nint s_resolver; + private static long s_freedReferenceCount; /// /// Gets or sets the test seam: a static method returning the module handle to bind, or zero when there is none. @@ -33,6 +34,18 @@ internal static delegate* Resolver set => Volatile.Write(ref s_resolver, (nint) value); } + /// + /// Count of loader references released back through + /// (A05-01: an already-bound or refused lookup never accumulates a reference). Test seam only. + /// + internal static long FreedReferenceCountForTests => Interlocked.Read(ref s_freedReferenceCount); + + /// Resets to zero. Test seam only. + internal static void ResetFreedReferenceCountForTests() + { + Interlocked.Exchange(ref s_freedReferenceCount, 0); + } + /// Locates the module and binds the API table to it, all or nothing. /// Why it failed, for the log; null on success. /// when holds for the located module. @@ -74,6 +87,7 @@ internal static bool BindLocated(nint handle, bool counted, [NotNullWhen(false)] if (counted && (boundBefore || !bound)) { NativeLibrary.Free(handle); + Interlocked.Increment(ref s_freedReferenceCount); } if (!bound) diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs index 704cc110..9b904e6c 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs @@ -139,7 +139,7 @@ private static bool TryEnterLifecycleCallback(string callback) if (SGate.IsHeldByCurrentThread) { HostLog.Error(callback + - ": re-entered from plugin code while OnEnable or OnDisable is running on this thread; the call is refused and the outer transition decides the state."); + ": re-entered from plugin code while OnEnable or OnDisable is running on this thread; the call is refused and the outer transition decides the state."); return false; } @@ -149,7 +149,7 @@ private static bool TryEnterLifecycleCallback(string callback) } HostLog.Error(callback + - ": another lifecycle transition is already running; concurrent callbacks fail immediately and do not wait for plugin code."); + ": another lifecycle transition is already running; concurrent callbacks fail immediately and do not wait for plugin code."); return false; } @@ -220,7 +220,7 @@ private static LifecycleStart TryStartEnable(out PluginDescriptor? descriptor) if (Phase is not PluginHostLifecyclePhase.Registered) { HostLog.Error("EnablePlugin: the plugin lifecycle is in " + Phase + - "; enable is valid only from Registered."); + "; enable is valid only from Registered."); return LifecycleStart.Refused; } @@ -238,6 +238,11 @@ private static bool RunEnable(ManagedExportedFunctions* exports, uint pluginId, { Volatile.Write(ref s_incompleteEnableCleanup, 0); Volatile.Write(ref s_incompleteEnableCleanupActive, 0); + + // Opt-in identification (WI-5): at most one entry, before any copy, bind or construction is attempted, so + // it is still emitted when one of those steps fails. Never touches Lua or plugin code. + LoadIdentification.EmitIfRequested(exports, pluginId, descriptor.FactoryType); + if (!TryCopyExports(exports, out ManagedExportedFunctions copy)) { return false; @@ -296,6 +301,9 @@ private static PluginContext AttachAndPublishEnableContext( runtimeAttached = false; shutdown = null; + // Set before Attach so a worker refusal or an external-reset detection raised during this very enable is + // still observed; cleared by CleanupFailedEnable/CleanupDisable. + LuaRuntime.DiagnosticObserver = HandleLuaRuntimeDiagnostic; LuaRuntime.Attach(in binding); runtimeAttached = true; shutdown = CreateShutdownSource(); @@ -351,6 +359,7 @@ private static void CleanupFailedEnable(bool runtimeAttached, CancellationTokenS return; } + LuaRuntime.DiagnosticObserver = null; Volatile.Write(ref s_context, null); Volatile.Write(ref s_incompleteEnableCleanup, 0); Volatile.Write(ref s_incompleteEnableCleanupActive, 0); @@ -528,17 +537,17 @@ private static LifecycleStart SelectDisableStart( if (context is null) { HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; disable is valid only from Enabled or incomplete failed-enable cleanup."); + "; disable is valid only from Enabled or incomplete failed-enable cleanup."); return LifecycleStart.Refused; } if (Phase is PluginHostLifecyclePhase.Disabling) { if (Volatile.Read(ref s_incompleteEnableCleanup) == 0 - || Volatile.Read(ref s_incompleteEnableCleanupActive) != 0) + || Volatile.Read(ref s_incompleteEnableCleanupActive) != 0) { HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; a disable transition is already completing."); + "; a disable transition is already completing."); return LifecycleStart.Refused; } @@ -548,7 +557,7 @@ private static LifecycleStart SelectDisableStart( if (Phase is not PluginHostLifecyclePhase.Enabled) { HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; disable is valid only from Enabled or incomplete failed-enable cleanup."); + "; disable is valid only from Enabled or incomplete failed-enable cleanup."); return LifecycleStart.Refused; } @@ -637,6 +646,7 @@ private static bool CleanupDisable() // The operation gate has already shut out every admitted Lua caller before callback neutralization. LuaRuntime.CloseOperationAdmissionAndDrain(); LuaRuntime.Detach(); + LuaRuntime.DiagnosticObserver = null; Volatile.Write(ref s_context, null); Volatile.Write(ref s_incompleteEnableCleanup, 0); @@ -653,6 +663,28 @@ private static bool CleanupDisable() } } + // The sole subscriber of LuaRuntime.DiagnosticObserver: turns a one-shot runtime fact into one stable-category + // HostLog entry (A24 l.36: the category token never depends on CE's UI language). Invoked synchronously on the + // thread that raised the fact, outside every LuaRuntime lock. + private static void HandleLuaRuntimeDiagnostic(LuaRuntimeDiagnostic diagnostic) + { + switch (diagnostic) + { + case LuaRuntimeDiagnostic.WorkerThreadRefused: + HostLog.Warning( + string.Create(CultureInfo.InvariantCulture, + $"LuaWorkerThreadRefused: a worker thread (managed thread id {Environment.CurrentManagedThreadId}) was refused Lua admission") + + " by the 2.0 conservative default (ADR-07). Use MainThread.Invoke, or opt in from OnEnable with " + + "the unqualified LuaRuntime.AdmitWorkerThreads() [Experimental(\"CESDK5001\")]."); + break; + case LuaRuntimeDiagnostic.ExternalStateReset: + HostLog.Error( + "LuaStateReplacedExternally: the host replaced its Lua state outside this SDK's controlled reset " + + "path. Every owner from before the replacement is refused; disable and re-enable the plugin."); + break; + } + } + private enum LifecycleStart { Refused, diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs index 8f9ac2f0..5721509f 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs @@ -102,6 +102,12 @@ public static unsafe partial class PluginHost /// The plugin instance, once constructed. Tests only. internal static CheatEnginePlugin? PluginForTests => Volatile.Read(ref s_plugin); + /// + /// Whether the calling thread currently holds the factory-registration gate. Tests only: a sink observing this + /// from inside a log entry proves a rejection was logged after the gate was released (WI-6). + /// + internal static bool IsRegistrationGateHeldByCurrentThreadForTesting => SGate.IsHeldByCurrentThread; + /// /// Admits one synchronous main-thread dispatch associated with . /// @@ -118,8 +124,8 @@ internal static MainThreadWorkAdmission AdmitMainThreadWork(PluginContext contex lock (SAdmissionGate) { if (Phase is not PluginHostLifecyclePhase.Enabled || - !ReferenceEquals(context, Volatile.Read(ref s_context)) || - Volatile.Read(ref s_acceptingMainThreadWork) == 0) + !ReferenceEquals(context, Volatile.Read(ref s_context)) || + Volatile.Read(ref s_acceptingMainThreadWork) == 0) { throw new InvalidOperationException( "The plugin is stopping or disabled and no longer accepts new main-thread dispatch work."); @@ -210,10 +216,15 @@ public static int InitializeManaged(nint initRecord, int hostArgument) private static bool TryRegisterFactory(out byte* name) where TFactory : IPluginFactory { + // The rejection message is only captured under SGate; it is logged after the lock is released (WI-6 / + // A02-11-adjacent hardening) so that a sink re-entering a lifecycle path during this call never runs while + // SGate is held. + string? rejectionMessage = null; + bool registered; lock (SGate) { - PluginDescriptor? registered = s_descriptor; - if (registered is null) + PluginDescriptor? currentDescriptor = s_descriptor; + if (currentDescriptor is null) { name = AnsiNameBuffer.Allocate(TFactory.Utf8Name); s_name = name; @@ -222,19 +233,28 @@ private static bool TryRegisterFactory(out byte* name) return true; } - if (registered.FactoryType != typeof(TFactory)) + if (currentDescriptor.FactoryType != typeof(TFactory)) { - HostLog.Error( - "InitializeManaged: a plugin factory of type " + registered.FactoryType + + rejectionMessage = + "InitializeManaged: a plugin factory of type " + currentDescriptor.FactoryType + " is already registered in this Hosting assembly instance; " - + typeof(TFactory) + " is rejected. One plugin per loaded Hosting assembly instance."); + + typeof(TFactory) + " is rejected. One plugin per loaded Hosting assembly instance."; name = null; - return false; + registered = false; + } + else + { + name = s_name; + registered = true; } + } - name = s_name; - return true; + if (rejectionMessage is not null) + { + HostLog.Error(rejectionMessage); } + + return registered; } /// The current context, or an exception for code that cannot proceed without one. diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHostLifecyclePhase.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHostLifecyclePhase.cs index cba9caa9..a9b8f94f 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHostLifecyclePhase.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHostLifecyclePhase.cs @@ -1,7 +1,9 @@ namespace CheatEngine.SDK.Hosting.Bootstrap; /// -/// The stable and transitional states of the one managed plugin hosted by this assembly load context. +/// The stable and transitional states of the one managed plugin hosted by this loaded +/// CheatEngine.SDK.Hosting assembly instance. Whether that equals one per assembly load context, or one per +/// plugin, is decided by the host loader profile (Q09: C2 host-emulator facts, C4 receipts), not assumed here. /// /// /// Cheat Engine drives the transitions through the generated bootstrap's native callbacks. diff --git a/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLog.cs b/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLog.cs index f80ddff2..c0c26649 100644 --- a/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLog.cs +++ b/libs/CheatEngine.SDK.Hosting/Diagnostics/HostLog.cs @@ -24,11 +24,40 @@ namespace CheatEngine.SDK.Hosting.Diagnostics; /// delivered (guarded by ); its and /// entries sit on failure paths and are built unconditionally. /// +/// +/// Reentrancy containment (A24-21, SRC02-06). A per-thread guard drops, and counts, a write that a sink +/// makes back into from inside its own on the same +/// thread: a re-entrant recursive write can otherwise reach an uncatchable +/// that takes the host process down with it. Writes from other threads are never affected. See +/// for the sink's complete containment contract. +/// /// public static class HostLog { + /// The environment variable that opts every enable attempt into the identification diagnostic. + /// + public const string IdentifyOnEnableEnvironmentVariable = "CHEATENGINE_SDK_IDENTIFY_ON_ENABLE"; + private static IHostLogSink s_sink = DebugOutputLogSink.Instance; private static int s_minimumLevel = (int) HostLogLevel.Information; + private static int s_identifyOnEnable; + + // Test seam: production reads the real process environment through DefaultIdentifyOnEnableEnvironmentReader. + private static Func? s_identifyOnEnableEnvironmentReader; + + // Per-thread reentrancy guard (A24-21, SRC02-06): a sink that writes back into HostLog.Write from inside its own + // Write, directly or through a path that logs, would otherwise recurse until StackOverflowException, which + // cannot be caught and would take Cheat Engine down with it. Other threads are unaffected: a sink already + // running on thread A never blocks a write from thread B. + [ThreadStatic] private static bool t_writing; + + private static long s_droppedReentrantEntries; + + /// + /// Count of entries dropped because a sink re-entered on the same thread while its own + /// sink call was still running. For tests and diagnostics only. + /// + internal static long DroppedReentrantEntries => Interlocked.Read(ref s_droppedReentrantEntries); /// Gets or sets the sink. Setting restores . public static IHostLogSink Sink @@ -44,6 +73,41 @@ public static HostLogLevel MinimumLevel set => Volatile.Write(ref s_minimumLevel, (int) value); } + /// + /// Gets or sets whether the bounded CheatEngineSdkIdentification diagnostic (SDK version, bridge + /// fingerprint, bound Lua module hash, CE and runtime versions; never a user path) is written once at the start of + /// every enable attempt. Default : identification is opt-in. + /// + /// + /// + /// A plugin that wants identification on its very first enable sets this from a + /// -annotated method, which the + /// runtime runs before any plugin code. Setting it later takes effect at the next enable attempt. + /// + /// + /// The environment variable named by + /// (CHEATENGINE_SDK_IDENTIFY_ON_ENABLE=1) opts in as well, without rebuilding the plugin: either this + /// property or the environment variable being set is enough. The environment is read once per enable attempt, + /// inside a /, so a hostile or unavailable environment never + /// faults the callback. + /// + /// + public static bool IdentifyOnEnable + { + get => Volatile.Read(ref s_identifyOnEnable) != 0; + set => Volatile.Write(ref s_identifyOnEnable, value ? 1 : 0); + } + + /// + /// Test seam for the reader. (the + /// default) reads the real process environment; a test replaces it so the real environment is never touched. + /// + internal static Func? IdentifyOnEnableEnvironmentReader + { + get => Volatile.Read(ref s_identifyOnEnableEnvironmentReader); + set => Volatile.Write(ref s_identifyOnEnableEnvironmentReader, value); + } + /// /// Tells whether an entry of would be delivered; use it to skip building an expensive /// message. @@ -66,6 +130,16 @@ public static void Write(HostLogLevel level, string? message, Exception? excepti return; } + if (t_writing) + { + // A sink calling back into HostLog.Write from its own Write, directly or through a path that logs, is + // dropped and counted instead of recursing: an uncatchable StackOverflowException would otherwise take + // the host process down with it. + Interlocked.Increment(ref s_droppedReentrantEntries); + return; + } + + t_writing = true; try { Volatile.Read(ref s_sink).Write(level, message ?? string.Empty, exception); @@ -74,6 +148,10 @@ public static void Write(HostLogLevel level, string? message, Exception? excepti { // A sink that throws must not turn a logged failure into an exception at the native boundary. } + finally + { + t_writing = false; + } } internal static void Error(string message, Exception? exception = null) @@ -96,10 +174,43 @@ internal static void Trace(string message) Write(HostLogLevel.Trace, message); } - /// Resets the sink and level to their defaults. For tests. + /// + /// Tells whether an enable attempt should build and emit the identification diagnostic: either + /// is set, or the reads + /// "1". The environment read never throws. + /// + internal static bool IsIdentifyOnEnableRequested() + { + return IdentifyOnEnable || IsIdentifyOnEnableEnvironmentSet(); + } + + private static bool IsIdentifyOnEnableEnvironmentSet() + { + try + { + Func reader = IdentifyOnEnableEnvironmentReader ?? ReadIdentifyOnEnableEnvironmentVariable; + return string.Equals(reader(), "1", StringComparison.Ordinal); + } + catch (Exception) + { + // The environment must never fault a native lifecycle callback. + return false; + } + } + + private static string? ReadIdentifyOnEnableEnvironmentVariable() + { + return Environment.GetEnvironmentVariable(IdentifyOnEnableEnvironmentVariable); + } + + /// Resets the sink, level, reentrancy counter and identification opt-in to their defaults. For tests. internal static void ResetForTests() { Sink = DebugOutputLogSink.Instance; MinimumLevel = HostLogLevel.Information; + Volatile.Write(ref s_droppedReentrantEntries, 0); + t_writing = false; + IdentifyOnEnable = false; + IdentifyOnEnableEnvironmentReader = null; } } diff --git a/libs/CheatEngine.SDK.Hosting/Diagnostics/IHostLogSink.cs b/libs/CheatEngine.SDK.Hosting/Diagnostics/IHostLogSink.cs index cb1bc9c0..786f13a7 100644 --- a/libs/CheatEngine.SDK.Hosting/Diagnostics/IHostLogSink.cs +++ b/libs/CheatEngine.SDK.Hosting/Diagnostics/IHostLogSink.cs @@ -7,9 +7,21 @@ namespace CheatEngine.SDK.Hosting.Diagnostics; /// through to route host diagnostics into its own logging. /// /// -/// may be called from any thread, including from inside the lifecycle callbacks Cheat Engine -/// invokes, and must not block. It may throw: swallows sink exceptions, because a sink failure -/// inside a callback's catch block would otherwise escape into native code. +/// +/// may be called from any thread, including from inside the lifecycle callbacks Cheat Engine +/// invokes, and must not block. It may throw: swallows sink exceptions, because a sink +/// failure inside a callback's catch block would otherwise escape into native code. +/// +/// +/// Containment (A24-21, SRC02-06). A sink must not call back into , directly or +/// through a path that logs, from inside its own : drops that +/// re-entrant entry on the same thread instead of recursing, and counts it. A sink must not re-enter a plugin +/// lifecycle transition (for example calling back into EnablePlugin or DisablePlugin); the +/// lifecycle refuses that immediately rather than waiting for the sink. A sink must not acquire a Lua operation +/// while a lifecycle transition owns admission; that acquisition is refused like any other during a transition. +/// None of these containments retry on the sink's behalf: a sink that needs one of these must schedule it for +/// after returns. +/// /// public interface IHostLogSink { diff --git a/libs/CheatEngine.SDK.Hosting/Diagnostics/LoadIdentification.cs b/libs/CheatEngine.SDK.Hosting/Diagnostics/LoadIdentification.cs new file mode 100644 index 00000000..93c64b36 --- /dev/null +++ b/libs/CheatEngine.SDK.Hosting/Diagnostics/LoadIdentification.cs @@ -0,0 +1,456 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Abi; +using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Lua.Interop.Api; +using CheatEngine.SDK.Lua.Interop.Loading; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.SDK.Hosting.Diagnostics; + +/// +/// Builds and emits the opt-in CheatEngineSdkIdentification line: one bounded, path-free entry written at +/// most once per enable attempt, before any Lua binding or plugin construction is attempted (A21-34, A24-11, +/// A24-19, A20-Q46-3). +/// +/// +/// +/// Unqualified. Every field here is a fact this SDK copy can read about itself and the host process; none +/// of it is confirmed by a host-based qualification run. It is diagnostic only, never a precondition for any +/// SDK behaviour. +/// +/// +/// Non-reentrant. Building the line calls no Lua API, constructs no plugin, and never writes to +/// while it runs; only the caller writes the finished line, once, after building +/// finishes. +/// +/// +internal static unsafe partial class LoadIdentification +{ + private const string LinePrefix = "CheatEngineSdkIdentification: "; + private const int MaxValueLength = 128; + private const int MaxEntryLength = 1024; + private const string Unavailable = "unavailable"; + private const string Unknown = "unknown"; + private const uint ModuleHandleUnchangedRefCountFlag = 0x4; // GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT + private const long MaxHashedFileBytes = 16 * 1024 * 1024; + + [GeneratedRegex("(? + /// Builds and, when accepted by , writes the identification + /// line for the enable attempt in progress. Safe to call even when is null or the + /// record it points at cannot be trusted yet: only SizeOfExportedFunctions is read, never a function + /// pointer inside it. + /// + /// The host's raw exports record, copied later by the caller; read-only here. + /// The id the host assigned for this enable. + /// The registered plugin factory type. + internal static void EmitIfRequested(ManagedExportedFunctions* exports, uint pluginId, Type factoryType) + { + if (!HostLog.IsIdentifyOnEnableRequested()) + { + return; + } + + string line = Build(exports, pluginId, factoryType); + HostLog.Information(line); + } + + /// Builds the line unconditionally. Exposed for direct testing of content and bounds. + internal static string Build(ManagedExportedFunctions* exports, uint pluginId, Type factoryType) + { + StringBuilder builder = new(MaxEntryLength); + builder.Append(LinePrefix); + + AppendSdkVersion(builder); + AppendHostingIdentity(builder); + AppendField(builder, "plugin.id", pluginId.ToString(CultureInfo.InvariantCulture)); + AppendField(builder, "plugin.assembly", DescribeAssembly(factoryType.Assembly)); + AppendField(builder, "host.argument", + PluginHost.LastInitRecordArgument.ToString(CultureInfo.InvariantCulture)); + AppendField(builder, "exports.size", DescribeExportsSize(exports)); + AppendBridgeFields(builder); + AppendLuaFields(builder); + AppendCeFields(builder); + AppendField(builder, "runtime", RuntimeInformation.FrameworkDescription); + AppendField(builder, "arch", RuntimeInformation.ProcessArchitecture.ToString(), last: true); + + if (builder.Length > MaxEntryLength) + { + builder.Length = MaxEntryLength; + } + + return builder.ToString(); + } + + private static void AppendSdkVersion(StringBuilder builder) + { + string hostingVersion = ReadInformationalVersion(typeof(PluginHost).Assembly); + string luaVersion = ReadInformationalVersion(typeof(LuaRuntime).Assembly); + string luaInteropVersion = ReadInformationalVersion(typeof(LuaApi).Assembly); + string abiVersion = ReadInformationalVersion(typeof(AbiConstants).Assembly); + + bool consistent = string.Equals(hostingVersion, luaVersion, StringComparison.Ordinal) + && string.Equals(hostingVersion, luaInteropVersion, StringComparison.Ordinal) + && string.Equals(hostingVersion, abiVersion, StringComparison.Ordinal) + && !string.Equals(hostingVersion, Unknown, StringComparison.Ordinal); + + AppendField(builder, "sdk.version", hostingVersion); + AppendField(builder, "sdk.commit", ParseCommit(hostingVersion)); + AppendField(builder, "sdk.consistent", consistent ? "true" : "false"); + } + + private static string ReadInformationalVersion(Assembly assembly) + { + try + { + string? version = assembly.GetCustomAttribute() + ?.InformationalVersion; + return string.IsNullOrEmpty(version) ? Unknown : version; + } + catch (Exception) + { + return Unknown; + } + } + + private static string ParseCommit(string informationalVersion) + { + if (string.Equals(informationalVersion, Unknown, StringComparison.Ordinal)) + { + return Unknown; + } + + Match match = CommitPattern().Match(informationalVersion); + return match.Success ? match.Value.ToLowerInvariant() : Unknown; + } + + private static void AppendHostingIdentity(StringBuilder builder) + { + string mvid; + try + { + mvid = typeof(PluginHost).Assembly.ManifestModule.ModuleVersionId.ToString(); + } + catch (Exception) + { + mvid = Unknown; + } + + AppendField(builder, "hosting.mvid", mvid); + AppendField(builder, "hosting.alc", DescribeLoadContext()); + } + + private static string DescribeLoadContext() + { + try + { + AssemblyLoadContext? context = AssemblyLoadContext.GetLoadContext(typeof(PluginHost).Assembly); + if (context is null) + { + return Unknown; + } + + string kind = context.GetType().Name; + string? name = context.Name; + return string.IsNullOrEmpty(name) ? kind : kind + ""; + } + catch (Exception) + { + return Unknown; + } + } + + private static string DescribeAssembly(Assembly assembly) + { + try + { + AssemblyName name = assembly.GetName(); + return (name.Name ?? Unknown) + " " + (name.Version?.ToString() ?? Unknown); + } + catch (Exception) + { + return Unknown; + } + } + + private static string DescribeExportsSize(ManagedExportedFunctions* exports) + { + return exports is null ? "0" : exports->SizeOfExportedFunctions.ToString(CultureInfo.InvariantCulture); + } + + private static void AppendBridgeFields(StringBuilder builder) + { + ReadBridgeFacts(out string fingerprint, out string sha256); + + // The fingerprint is a fixed "<64 hex>:<64 hex>" shape (129 characters) validated by BridgeFingerprintPattern, + // or the 11-character "unavailable" fallback: never free-form, so it is exempt from the 128-character + // per-value bound that guards against unbounded native/plugin-supplied text elsewhere in this line. + AppendField(builder, "bridge.fingerprint", fingerprint, boundValue: false); + AppendField(builder, "bridge.sha256", sha256); + } + + // Loads the bridge by its assembly-relative search path (the same one the Abi/Lua.Interop layer uses at + // runtime), reads the fixed-size fingerprint export, hashes the file it was loaded from, and frees the handle. + // Never keeps a reference: identification does not need the bridge to remain resolvable afterward. + private static void ReadBridgeFacts(out string fingerprint, out string sha256) + { + fingerprint = Unavailable; + sha256 = Unavailable; + try + { + if (!NativeLibrary.TryLoad("cheatengine-sdk-lua-bridge", typeof(PluginHost).Assembly, + DllImportSearchPath.AssemblyDirectory, out nint handle)) + { + return; + } + + try + { + if (NativeLibrary.TryGetExport(handle, "cheatengine_sdk_lua_bridge_source_fingerprint", + out nint export)) + { + string? text = ReadBoundedAnsiString((byte*) export, 160); + if (text is not null && BridgeFingerprintPattern().IsMatch(text)) + { + fingerprint = text; + } + } + + string? path = TryGetModuleFilePath(handle); + if (path is not null && TryHashFile(path, out string hash)) + { + sha256 = hash; + } + } + finally + { + NativeLibrary.Free(handle); + } + } + catch (Exception) + { + fingerprint = Unavailable; + sha256 = Unavailable; + } + } + + private static void AppendLuaFields(StringBuilder builder) + { + string fileName = Unavailable; + string sha256 = Unavailable; + try + { + if (TryLocateLuaModule(out nint handle)) + { + string? path = TryGetModuleFilePath(handle); + if (path is not null) + { + string name = Path.GetFileName(path); + if (name.Length > 0) + { + fileName = name; + } + + if (TryHashFile(path, out string hash)) + { + sha256 = hash; + } + } + } + } + catch (Exception) + { + fileName = Unavailable; + sha256 = Unavailable; + } + + AppendField(builder, "lua.module", fileName); + AppendField(builder, "lua.sha256", sha256); + } + + // Uses the same test seam as the production bind (LuaModuleLocator.Resolver) so identification observes exactly + // the module a test double stands in for. In production, when no seam is installed, looks up the module without + // taking a loader reference: identification only reads facts, it never needs to keep the module alive. + private static bool TryLocateLuaModule(out nint moduleHandle) + { + delegate* resolver = LuaModuleLocator.Resolver; + if (resolver is not null) + { + moduleHandle = resolver(); + return moduleHandle != 0; + } + + moduleHandle = 0; + if (!OperatingSystem.IsWindows()) + { + return false; + } + + const string moduleName = LuaModule.CheatEngine64ModuleName; + fixed (char* name = moduleName) + { + nint handle = 0; + if (GetModuleHandleExW(ModuleHandleUnchangedRefCountFlag, name, &handle) == 0) + { + return false; + } + + moduleHandle = handle; + return handle != 0; + } + } + + private static string? TryGetModuleFilePath(nint moduleHandle) + { + if (!OperatingSystem.IsWindows() || moduleHandle == 0) + { + return null; + } + + Span buffer = stackalloc char[1024]; + int written; + fixed (char* p = buffer) + { + written = GetModuleFileNameW(moduleHandle, p, buffer.Length); + } + + return written <= 0 || written >= buffer.Length ? null : new string(buffer[..written]); + } + + private static bool TryHashFile(string path, out string sha256Hex) + { + sha256Hex = Unavailable; + try + { + using FileStream stream = new(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + if (stream.Length is < 0 or > MaxHashedFileBytes) + { + return false; + } + + sha256Hex = Convert.ToHexStringLower(SHA256.HashData(stream)); + return true; + } + catch (Exception) + { + sha256Hex = Unavailable; + return false; + } + } + + private static string? ReadBoundedAnsiString(byte* address, int maxBytes) + { + if (address is null) + { + return null; + } + + try + { + int length = 0; + while (length < maxBytes && address[length] != 0) + { + length++; + } + + return Encoding.ASCII.GetString(address, length); + } + catch (Exception) + { + return null; + } + } + + private static void AppendCeFields(StringBuilder builder) + { + string file = Unavailable; + string fileVersion = Unavailable; + try + { + string? processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + file = Path.GetFileName(processPath); + FileVersionInfo info = FileVersionInfo.GetVersionInfo(processPath); + fileVersion = info.FileVersion ?? Unavailable; + } + } + catch (Exception) + { + file = Unavailable; + fileVersion = Unavailable; + } + + AppendField(builder, "ce.file", file); + AppendField(builder, "ce.fileVersion", fileVersion); + } + + private static string LastPathSegment(string value) + { + int separator = value.LastIndexOfAny(['\\', '/']); + string tail = separator >= 0 ? value[(separator + 1)..] : value; + return tail.Length == 0 ? Unknown : tail; + } + + private static void AppendField(StringBuilder builder, string key, string value, bool last = false, + bool boundValue = true) + { + builder.Append(key).Append('=').Append(Sanitize(value, boundValue)); + if (!last) + { + builder.Append("; "); + } + } + + private static string Sanitize(string value, bool boundValue) + { + string bounded = boundValue && value.Length > MaxValueLength ? value[..MaxValueLength] : value; + if (bounded.IndexOfAny(['\r', '\n', ';']) < 0) + { + return bounded; + } + + Span scratch = bounded.Length <= 256 ? stackalloc char[bounded.Length] : new char[bounded.Length]; + for (int i = 0; i < bounded.Length; i++) + { + char c = bounded[i]; + scratch[i] = c is '\r' or '\n' ? ' ' : c is ';' ? ',' : c; + } + + return new string(scratch); + } + + // HMODULE GetModuleHandleExW(DWORD dwFlags, LPCWSTR lpModuleName, HMODULE* phModule); flag 0x4 does not take a + // loader reference (GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT): identification only reads facts about a module + // something else keeps alive. + [LibraryImport("kernel32", EntryPoint = "GetModuleHandleExW")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [SupportedOSPlatform("windows")] + private static partial int GetModuleHandleExW(uint dwFlags, char* lpModuleName, nint* phModule); + + // DWORD GetModuleFileNameW(HMODULE hModule, LPWSTR lpFilename, DWORD nSize); returns 0 on failure, or a length + // equal to nSize when the buffer was too small (the result is then not NUL-terminated and must be rejected). + [LibraryImport("kernel32", EntryPoint = "GetModuleFileNameW")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [SupportedOSPlatform("windows")] + private static partial int GetModuleFileNameW(nint hModule, char* lpFilename, int nSize); +} diff --git a/libs/CheatEngine.SDK.Hosting/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Hosting/PublicAPI.Shipped.txt new file mode 100644 index 00000000..2c0ef4f4 --- /dev/null +++ b/libs/CheatEngine.SDK.Hosting/PublicAPI.Shipped.txt @@ -0,0 +1,56 @@ +#nullable enable +CheatEngine.SDK.Hosting.Bootstrap.PluginHost +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Disabling = 4 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Enabled = 3 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Enabling = 2 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Registered = 1 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Uninitialized = 0 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Context.PluginContext +CheatEngine.SDK.Hosting.Context.PluginContext.Epoch.get -> int +CheatEngine.SDK.Hosting.Context.PluginContext.HasCheckSynchronize.get -> bool +CheatEngine.SDK.Hosting.Context.PluginContext.HasProcessMessages.get -> bool +CheatEngine.SDK.Hosting.Context.PluginContext.IsCurrent.get -> bool +CheatEngine.SDK.Hosting.Context.PluginContext.IsMainThread.get -> bool +CheatEngine.SDK.Hosting.Context.PluginContext.MainThreadId.get -> int +CheatEngine.SDK.Hosting.Context.PluginContext.PluginId.get -> uint +CheatEngine.SDK.Hosting.Context.PluginContext.ReportedExportsSize.get -> int +CheatEngine.SDK.Hosting.Context.PluginContext.ShutdownToken.get -> System.Threading.CancellationToken +CheatEngine.SDK.Hosting.Diagnostics.DebugOutputLogSink +CheatEngine.SDK.Hosting.Diagnostics.DebugOutputLogSink.Write(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel level, string! message, System.Exception? exception) -> void +CheatEngine.SDK.Hosting.Diagnostics.HostLog +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Error = 3 -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Information = 1 -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Trace = 0 -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Warning = 2 -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.IHostLogSink +CheatEngine.SDK.Hosting.Diagnostics.IHostLogSink.Write(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel level, string! message, System.Exception? exception) -> void +CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin +CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.CheatEnginePlugin() -> void +CheatEngine.SDK.Hosting.Plugin.IPluginFactory +CheatEngine.SDK.Hosting.Plugin.IPluginFactory.Create() -> CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin! +CheatEngine.SDK.Hosting.Plugin.IPluginFactory.Utf8Name.get -> System.ReadOnlySpan +CheatEngine.SDK.Hosting.Threading.MainThread +abstract CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.OnDisable() -> void +abstract CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.OnEnable() -> void +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.Context.get -> CheatEngine.SDK.Hosting.Context.PluginContext? +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.InitializeManaged(nint initRecord, int hostArgument) -> int +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.IsEnabled.get -> bool +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.IsInitialized.get -> bool +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.LastInitRecordArgument.get -> int +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.LastVersionRecordSize.get -> int +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.Phase.get -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +static CheatEngine.SDK.Hosting.Diagnostics.DebugOutputLogSink.Instance.get -> CheatEngine.SDK.Hosting.Diagnostics.DebugOutputLogSink! +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.IsEnabled(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel level) -> bool +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.MinimumLevel.get -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.MinimumLevel.set -> void +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.Sink.get -> CheatEngine.SDK.Hosting.Diagnostics.IHostLogSink! +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.Sink.set -> void +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.Write(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel level, string? message, System.Exception? exception = null) -> void +static CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.Context.get -> CheatEngine.SDK.Hosting.Context.PluginContext! +static CheatEngine.SDK.Hosting.Threading.MainThread.CheckSynchronize(int timeoutMilliseconds) -> bool +static CheatEngine.SDK.Hosting.Threading.MainThread.Invoke(System.Func! function, TState state) -> TResult +static CheatEngine.SDK.Hosting.Threading.MainThread.Invoke(System.Action! action, TState state) -> void +static CheatEngine.SDK.Hosting.Threading.MainThread.IsMainThread.get -> bool +static CheatEngine.SDK.Hosting.Threading.MainThread.ProcessMessages() -> void diff --git a/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..fea204f1 --- /dev/null +++ b/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +#nullable enable +const CheatEngine.SDK.Hosting.Diagnostics.HostLog.IdentifyOnEnableEnvironmentVariable = "CHEATENGINE_SDK_IDENTIFY_ON_ENABLE" -> string! +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.IdentifyOnEnable.get -> bool +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.IdentifyOnEnable.set -> void diff --git a/libs/CheatEngine.SDK.Hosting/README.md b/libs/CheatEngine.SDK.Hosting/README.md index 5d241986..c738364f 100644 --- a/libs/CheatEngine.SDK.Hosting/README.md +++ b/libs/CheatEngine.SDK.Hosting/README.md @@ -34,7 +34,11 @@ not established whether it is a size, version, or another discriminator, so Host `PluginInitRecord` from [`CheatEngine.SDK.Abi`](../CheatEngine.SDK.Abi/README.md). It copies the plugin name once into native memory that is never freed. ASCII is copied exactly. Other characters go through the process ANSI code page, and an unrepresentable -one becomes `?`. A plugin that wants the same name on every machine keeps it ASCII. The library also builds on [ +one becomes `?`. A plugin that wants the same name on every machine keeps it ASCII. **Unqualified (2.0):** whether +Cheat Engine 7.7 really reads a non-ASCII name as the process ANSI code page rather than UTF-8 has not been confirmed +by a host-based qualification run — no local Cheat Engine qualification observation is available on this branch. The +ANSI encoding is kept unchanged pending a future host-based qualification run (audit A02-22, A04-14, A20-Q05-3). The +library also builds on [ `CheatEngine.SDK.Lua`](../CheatEngine.SDK.Lua/README.md) and ships inside the `CheatEngine.SDK` package. | Type | Role | @@ -43,7 +47,7 @@ one becomes `?`. A plugin that wants the same name on every machine keeps it ASC | `PluginHost` (`CheatEngine.SDK.Hosting.Bootstrap`) | `InitializeManaged`, the three native callbacks, and the lock-free readers `Phase`, `IsInitialized`, `IsEnabled` and `Context` | | `PluginContext` (`CheatEngine.SDK.Hosting.Context`) | Immutable facts of one enable: `PluginId`, `Epoch`, `MainThreadId`, `ShutdownToken`, `IsMainThread`, `IsCurrent`, `ReportedExportsSize`, `HasProcessMessages`, `HasCheckSynchronize` | | `MainThread` (`CheatEngine.SDK.Hosting.Threading`) | `IsMainThread`, `ProcessMessages()`, `CheckSynchronize(int)` and `Invoke` | -| `HostLog`, `IHostLogSink`, `HostLogLevel`, `DebugOutputLogSink` (`CheatEngine.SDK.Hosting.Diagnostics`) | The logging seam | +| `HostLog`, `IHostLogSink`, `HostLogLevel`, `DebugOutputLogSink` (`CheatEngine.SDK.Hosting.Diagnostics`) | The logging seam, plus the opt-in `HostLog.IdentifyOnEnable` load identification diagnostic | The lifecycle state machine is `Uninitialized → Registered → Enabling → Enabled → Disabling → Registered`. `PluginHost.IsEnabled` is true only in stable `Enabled`. `Context` is deliberately available during `Enabling` and @@ -110,8 +114,8 @@ all CE/Lua participants are serialized. The opt-in [two-plugin live fixture](../../tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md) logs the exact plugin, Hosting-assembly and runtime `AssemblyLoadContext` identities for a controlled host run. It is an observation protocol, not a CI test or a portability promise. It must be run and recorded before a supported coexistence profile is -claimed. The related architecture-review scenarios R25/T049–T050, R26/T051–T052 and R34/T067–T068/T076 remain -specified, not executed. +claimed. The related qualification scenarios Q09 and Q10 (coexistence), Q19 (first calls from two workers) and Q30 +(target switch) remain not executed. The current supported route is the managed, framework-dependent plugin route. The standalone Native AOT probe checks library publication constraints; it does not establish that Cheat Engine can load, disable, unload or remove a Native @@ -119,10 +123,95 @@ AOT plugin. Microsoft documents that Native AOT libraries cannot be unloaded thr plugin mode needs an explicit resident-core/adapter design and separate exact-host qualification. See [Native AOT libraries](https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries). +## Threading and Lua concurrency contract (ADR-07) + +**Unqualified pending a future host-based validation run: no local Cheat Engine qualification is currently +available.** This section states the SDK 2.0 default and its rationale; it is not a claim that Cheat Engine's own +scheduling has been observed. + +1. **The 2.0 default admission rule.** `CheatEngine.SDK.Lua.Runtime.LuaRuntime` starts no Lua work on a thread the host + is not already running Lua on. Concretely: a new `LuaRuntime.AcquireOperation()`-family call is refused with + `LuaAdmissionStatus.ThreadNotAdmitted` — as an exception from the throwing overloads, as `false` from the `Try*` + overloads, or as that enum value from `TryAcquireOperationWithOutcome` — **before** the host's Lua state provider + (`GetLuaState`) ever runs, so refusing a worker never creates a coroutine of the shared heap. This holds unless the + calling thread is the host's captured main thread, the call is nested inside a Lua operation or callback the host + already admitted on that thread, or it is the single documented default exception below. +2. **Admission protects this SDK copy's transitions, not the shared heap.** Distinct `lua_State*` pointers Cheat Engine + hands out per OS thread can be coroutines of one shared Lua universe. Two plugins, or two SDK copies in the same + process, are never serialized by this SDK (A05-07, A08-05, F04); admission only makes attach, detach and state + replacement exclusive for this loaded `CheatEngine.SDK.Lua` assembly instance. +3. **Never store a `LuaState`.** `LuaRuntimeOperation` is a `ref struct` and cannot cross `await` + (`typeof(LuaRuntimeOperation).IsByRefLike` is `true`). A captured `LuaState` outlives its admission and is not a + substitute for one. +4. **pluginCS.** Cheat Engine holds its plugin critical section on callback paths, so a blocking callback that waits + for the GUI thread can deadlock it. Adding a C# lock around Lua work is not a fix (A02-12): it adds contention + without removing the native section CE already holds. +5. **`processMessages` re-enters.** `MainThread.ProcessMessages()` pumps the host's message loop, which can run queued + work that calls back into the plugin. It is not a synchronization primitive (A16-20): never assume it drains + exactly what you just queued and nothing else. +6. **CE object lifetime and GUI work stay host-affine.** Creation, destruction and property access on a Cheat Engine + object happen on the thread the host expects; workers compute on managed copies only. `Task.Run` never authorizes + driving the GUI. `[MainThreadOnly]` is applied only where the SDK has evidence for it (A16-18, A16-21). +7. **A `[LuaFunction]` invoked by the host on any thread runs under the host's admission for that thread.** The + generated thunk's nested `AcquireOperation()` call succeeds because `t_operationDepth` is already non-zero on that + thread, whichever thread CE chose to run the callback on. +8. **The single documented default exception: the worker-side `synchronize` hand-off.** `MainThread.Invoke` from a + worker performs exactly one Lua global read, one closure and one protected call on the worker's own coroutine, + through `LuaRuntime.AcquireOperationForMainThreadDispatch()`. This is Cheat Engine's designed cross-thread + primitive, fixed and SDK-owned — never arbitrary plugin Lua — and the only route a worker has to the GUI thread + (see [`exemples/09-main-thread`](../../exemples/09-main-thread/README.md)). It stays admitted under the + conservative default; making it experimental would break the shipped 1.0.0 `MainThread.Invoke` contract. Its heap + safety is in the Q19 C3/C4 evidence scope, not its admission. +9. **The experimental worker-thread opt-in.** `LuaRuntime.AdmitWorkerThreads()`, marked + `[Experimental("CESDK5001")]` (see [`analyzers/docs/CESDK5001.md`](../../analyzers/docs/CESDK5001.md)), admits + worker-thread `AcquireOperation()`-family calls that are not already nested in admitted work, until the next + `LuaRuntime.Attach`/`Detach`. It does not serialize the shared heap and does not by itself make concurrent Lua + calls from two threads safe. It stays gated until Q19 passes at **both** C3 (exact host, one plugin) **and** C4 + (exact host, two SDK copies) — no local qualification run is currently available for either level. +10. **Not qualified (A22-14).** The following remain open regardless of the conservative default: + - Q19 at C4 (two simultaneous SDK copies, both opted in); + - the worker `synchronize` hand-off's heap safety under real concurrent Lua traffic; + - multi-plugin concurrency (see "Plugin identity and coexistence" above); + - detection of an external Lua-state reset under live host conditions beyond the C1/C2 evidence in + [`CheatEngine.SDK.Lua`](../CheatEngine.SDK.Lua/README.md#lua-state-replacement-20-decision) (Q17/Q18); + - Q10. + +`LuaRuntime` reports two one-shot diagnostics through `HostLog`, each at most once per attachment: a stable +`LuaWorkerThreadRefused:` warning naming the refused managed thread id, and a stable `LuaStateReplacedExternally:` +error when an external reset is detected (see the Lua README). Neither category token depends on Cheat Engine's UI +language. + `HostLog` receives every failure that a callback turns into `FALSE` or 0. The default sink writes to `OutputDebugStringW`, so a debugger attached to Cheat Engine or DebugView shows the entries. Set `HostLog.Sink` to route entries elsewhere and `HostLog.MinimumLevel` (default `Information`) to filter. `Trace` adds every lifecycle call. +A sink must not block (`HostLog.Write` can run from inside a native callback). It may throw: `HostLog` swallows a +sink exception so a logging failure never escapes to native code. It must not re-enter `HostLog.Write` from its own +`Write`, directly or through a path that logs: a re-entrant write on the same thread is dropped and counted instead +of recursing towards an uncatchable `StackOverflowException` (A24-21, SRC02-06). It must not re-enter a lifecycle +callback (`EnablePlugin`/`DisablePlugin`) or acquire a Lua operation while a lifecycle transition owns admission: +both are refused immediately, without waiting for the sink. + +## Opt-in load identification + +Set `HostLog.IdentifyOnEnable = true`, or the environment variable `CHEATENGINE_SDK_IDENTIFY_ON_ENABLE=1`, before an +enable attempt (a `[ModuleInitializer]` method applies it to the very first one). Every enable attempt that reaches +`EnablePlugin` then writes at most one `Information` entry, **before** the exports record is copied, so it appears +even when the Lua bind or plugin construction later fails: + +``` +CheatEngineSdkIdentification: sdk.version=…; sdk.commit=…; sdk.consistent=…; hosting.mvid=…; hosting.alc=…; plugin.id=…; plugin.assembly=…; host.argument=…; exports.size=…; bridge.fingerprint=…; bridge.sha256=…; lua.module=…; lua.sha256=…; ce.file=…; ce.fileVersion=…; runtime=…; arch=… +``` + +Fixed key order, each value at most 128 characters (the fixed-shape `bridge.fingerprint`, `<64 hex>:<64 hex>`, is the +one 129-character exception; it is validated by its own pattern instead of the general bound), the whole entry at +most 1024. `bridge.fingerprint`, +`bridge.sha256` and `lua.sha256` read `unavailable` when the bridge or the Lua module cannot be located or hashed; +`sdk.commit` reads `unknown` when no 40-hex commit can be parsed from the informational version. Nothing here is a +directory path, a drive root or a user name: `lua.module` and `ce.file` are file names only, and `hosting.alc` keeps +only the load context's kind and file token, never the isolated component's absolute path. Building the line calls no +Lua API and constructs no plugin; only the finished line is written to `HostLog`, once. + ```csharp using System; using CheatEngine.SDK.Annotations.Plugin; @@ -169,6 +258,22 @@ from a simulated host record. (`MainThreadTests`). 8. The native boundary uses no delegate marshalling, structure marshalling or reflection activation: `eng/BannedSymbols.txt` makes each a build error. +9. `MainThread.Invoke` from a worker keeps working, unopted-in, under the 2.0 conservative default; a worker calling + `LuaRuntime.AcquireOperation()` directly is refused before the state provider runs (`MainThreadTests`). +10. An external Lua-state reset is logged once as `LuaStateReplacedExternally:` and does not survive into the next + enable (`ExternalResetLifecycleTests`). +11. A throwing or re-entrant `HostLog` sink is contained during every native callback, and a second-factory + rejection is logged outside the registration lock (`HostLogContainmentTests`). +12. The `CheatEngineSdkIdentification` diagnostic is silent unless opted in (programmatically or through the + environment seam), is emitted at most once per enable attempt before the exports record is copied (so a later bind + or construction failure does not suppress it), keeps a fixed key order within its 1024-character bound, never + contains a directory separator, a drive root or the current user name, and calls no Lua API and constructs no + plugin while it builds (`LoadIdentificationTests`). +13. The bootstrap's second raw argument never changes the 36-byte record write for any value, including `int.MinValue` + and `int.MaxValue` (`InitializeManagedTests`); repeated enabling never accumulates a Lua-module loader reference, + because every already-bound lookup releases exactly the one reference it added (`LuaModuleLocatorTests`); and + `Disable` pumps a worker genuinely blocked inside the host's real Lua `synchronize` call, not only a dispatch + override, before it detaches (`DisablePluginTests`). ## Run the tests diff --git a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs index a230f642..e4d4c5ca 100644 --- a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs +++ b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs @@ -94,7 +94,9 @@ internal static void Dispatch(MainThreadWorkItem item) return; } - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + // The single documented default exception to the 2.0 conservative admission policy (ADR-07): this worker's + // own half of the synchronize hand-off is always admitted, whatever LuaRuntime.ThreadAdmission is set to. + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperationForMainThreadDispatch(); LuaState l = operation.State; using LuaFrame frame = new(l); @@ -102,7 +104,7 @@ internal static void Dispatch(MainThreadWorkItem item) if (!status.IsOk) { throw new InvalidOperationException("The host's 'synchronize' global could not be read: " + - LuaError.FromStack(l, status).Message); + LuaError.FromStack(l, status).Message); } if (!l.IsFunction(-1)) @@ -116,7 +118,7 @@ internal static void Dispatch(MainThreadWorkItem item) if (!status.IsOk) { throw new InvalidOperationException("The dispatch callback could not be created: " + - LuaError.FromStack(l, status).Message); + LuaError.FromStack(l, status).Message); } using (callback) @@ -132,7 +134,7 @@ internal static void Dispatch(MainThreadWorkItem item) if (!status.IsOk) { throw new InvalidOperationException("The host's 'synchronize' call failed: " + - LuaError.FromStack(l, status).Message); + LuaError.FromStack(l, status).Message); } } diff --git a/libs/CheatEngine.SDK.Hosting/packages.lock.json b/libs/CheatEngine.SDK.Hosting/packages.lock.json new file mode 100644 index 00000000..6aaef2a2 --- /dev/null +++ b/libs/CheatEngine.SDK.Hosting/packages.lock.json @@ -0,0 +1,53 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj b/libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj index 90b5446b..ac3825a3 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj +++ b/libs/CheatEngine.SDK.Lua.Interop/CheatEngine.SDK.Lua.Interop.csproj @@ -17,12 +17,9 @@ - + - + diff --git a/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs b/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs index 0a23d4bc..9ecce11a 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs @@ -4,20 +4,19 @@ // // Scalars (luaconf.h of a default 64-bit build: LUA_INT_LONGLONG + LUA_REAL_DOUBLE, which is what Cheat Engine ships). -global using lua_Integer = long; -global using lua_KContext = nint; -global using lua_Number = double; -global using size_t = nuint; - // Function-pointer typedefs. Lua is cdecl; the convention is a no-op on x64 but matters on x86, so it is spelled out. global using unsafe lua_Alloc = delegate* unmanaged[Cdecl]; global using unsafe lua_CFunction = delegate* unmanaged[Cdecl]; global using unsafe lua_Hook = delegate* unmanaged[Cdecl]< CheatEngine.SDK.Lua.Interop.Types.lua_State*, CheatEngine.SDK.Lua.Interop.Types.lua_Debug*, void>; +global using lua_Integer = long; +global using lua_KContext = nint; global using unsafe lua_KFunction = delegate* unmanaged[Cdecl]; +global using lua_Number = double; global using unsafe lua_Reader = delegate* unmanaged[Cdecl]; global using unsafe lua_Writer = delegate* unmanaged[Cdecl]; +global using size_t = nuint; diff --git a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs index ec24718c..918b1510 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs @@ -38,15 +38,15 @@ internal struct LuaBridgeContract internal readonly bool IsCompatible() { return Magic == ExpectedMagic && - ContractSize == (uint) Unsafe.SizeOf() && - AbiMajor == ExpectedMajor && - AbiMinor >= MinimumMinor && - PointerSize == (byte) lua_KContext.Size && - LuaIntegerSize == (byte) Unsafe.SizeOf() && - SizeTSize == (byte) Unsafe.SizeOf() && - ExportTableSize == (uint) Unsafe.SizeOf() && - Reserved == 0 && - (SupportedOperations & LuaProtectedOperationContract.RequiredBitmap) == - LuaProtectedOperationContract.RequiredBitmap; + ContractSize == (uint) Unsafe.SizeOf() && + AbiMajor == ExpectedMajor && + AbiMinor >= MinimumMinor && + PointerSize == (byte) lua_KContext.Size && + LuaIntegerSize == (byte) Unsafe.SizeOf() && + SizeTSize == (byte) Unsafe.SizeOf() && + ExportTableSize == (uint) Unsafe.SizeOf() && + Reserved == 0 && + (SupportedOperations & LuaProtectedOperationContract.RequiredBitmap) == + LuaProtectedOperationContract.RequiredBitmap; } } diff --git a/eng/lua-bridge/protected-operations.json b/libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json similarity index 93% rename from eng/lua-bridge/protected-operations.json rename to libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json index cc994a25..2c50b3e4 100644 --- a/eng/lua-bridge/protected-operations.json +++ b/libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json @@ -1,5 +1,4 @@ { - "$schema": "./protected-operations.schema.json", "schemaVersion": 1, "catalogId": "cheatengine-sdk-lua-protected-operations", "host": { @@ -75,6 +74,12 @@ "verifiedOn": "2026-09-20", "verification": "The manual annotation is [-0,+1,m]; m means a memory error may be raised." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK PushBytes protected failure recovered" + } ] }, { @@ -117,6 +122,12 @@ "verifiedOn": "2026-09-20", "verification": "The manual annotation is [-0,+1,m]." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK CreateTable protected failure recovered" + } ] }, { @@ -159,6 +170,12 @@ "verifiedOn": "2026-09-20", "verification": "The manual annotation is [-0,+1,m]." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK NewUserdata protected failure recovered" + } ] }, { @@ -202,6 +219,16 @@ "verifiedOn": "2026-09-20", "verification": "The general manual annotation is [-n,+1,m]; the Lua 5.3.6 source has a distinct n == 0 light-C-function fast path." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK TryPushGeneratedFunction PushClosure status, stack, and ownership recovered" + }, + { + "kind": "FailureProbeMarker", + "marker": "MARK PushClosure protected failure recovered" + } ] }, { @@ -244,6 +271,12 @@ "verifiedOn": "2026-09-20", "verification": "The manual annotation is [-2,+0,m]; raw access disables metamethods but not allocation failure." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK RawSet protected failure recovered" + } ] }, { @@ -286,6 +319,12 @@ "verifiedOn": "2026-09-20", "verification": "The manual annotation is [-1,+0,m]." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK RawSetIndex protected failure recovered" + } ] }, { @@ -328,6 +367,12 @@ "verifiedOn": "2026-09-20", "verification": "The manual annotation is [-1,+0,m]." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK RawSetPointer protected failure recovered" + } ] }, { @@ -371,6 +416,12 @@ "verifiedOn": "2026-09-20", "verification": "luaL_ref is annotated [-1,+0,m]; the lazy private-table path also calls lua_createtable and lua_rawset, both m." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK CreateReference protected failure recovered" + } ] }, { @@ -422,6 +473,14 @@ "verifiedOn": "2026-09-20", "verification": "The normal raw-get path has no error annotation; the current absent-table branch intentionally raises through lua_error after allocating its message." } + ], + "failureEvidence": [ + { + "kind": "ManagedTest", + "project": "tests/CheatEngine.SDK.Lua.Interop.Tests/CheatEngine.SDK.Lua.Interop.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs", + "test": "LuaProtectedApiTests.Push_private_reference_without_its_table_returns_an_error_and_preserves_the_caller_stack" + } ] }, { @@ -472,6 +531,12 @@ "verifiedOn": "2026-09-20", "verification": "Lua 5.3.6 source shows luaL_unref calling lua_rawseti twice; the current absent-table branch intentionally raises with lua_error." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK LuaRef.Release protected allocator boundary recovered" + } ] }, { @@ -513,9 +578,15 @@ "status": "PinnedUpstream", "source": "https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaClass.pas", "verifiedOn": "2026-09-20", - "verification": "The pusher delegates to userdata and metatable creation; its exact CE 7.7 failure behavior remains a P0-LIVE-005 probe.", + "verification": "The pusher delegates to userdata and metatable creation; its exact CE 7.7 failure behavior is qualification scenario Q23, not yet executed on the host.", "note": "Host behavior beyond the pinned implementation is Unknown until live verification." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK PushHostObject native pusher longjmp observed" + } ] }, { @@ -559,6 +630,12 @@ "verifiedOn": "2026-09-20", "verification": "lua_createtable is annotated [-0,+1,m] and lua_rawseti is annotated [-1,+0,m], so the whole table construction remains under one native lua_pcallk boundary." } + ], + "failureEvidence": [ + { + "kind": "FailureProbeMarker", + "marker": "MARK PushByteTable protected allocator boundary recovered" + } ] } ], diff --git a/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Shipped.txt new file mode 100644 index 00000000..2010bb9b --- /dev/null +++ b/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Shipped.txt @@ -0,0 +1,259 @@ +#nullable enable +CheatEngine.SDK.Lua.Interop.Api.LuaApi +CheatEngine.SDK.Lua.Interop.Loading.LuaModule +CheatEngine.SDK.Lua.Interop.Types.luaL_Reg +CheatEngine.SDK.Lua.Interop.Types.luaL_Reg.func -> delegate* unmanaged[Cdecl] +CheatEngine.SDK.Lua.Interop.Types.luaL_Reg.luaL_Reg() -> void +CheatEngine.SDK.Lua.Interop.Types.luaL_Reg.name -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.currentline -> int +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.event -> int +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.i_ci -> void* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.istailcall -> sbyte +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.isvararg -> sbyte +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.lastlinedefined -> int +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.linedefined -> int +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.lua_Debug() -> void +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.name -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.namewhat -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.nparams -> byte +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.nups -> byte +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.short_src -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.source -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.what -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_State +CheatEngine.SDK.Lua.Interop.Types.lua_State.lua_State() -> void +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUAI_FIRSTPSEUDOIDX = -1001000 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUAI_MAXSTACK = 1000000 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUAL_NUMSIZES = 136 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRERR = 6 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRFILE = 7 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRGCMM = 5 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRMEM = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRRUN = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRSYNTAX = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCCOLLECT = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCCOUNT = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCCOUNTB = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCISRUNNING = 9 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCRESTART = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCSETPAUSE = 6 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCSETSTEPMUL = 7 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCSTEP = 5 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCSTOP = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKCALL = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKCOUNT = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKLINE = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKRET = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKTAILCALL = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_IDSIZE = 60 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MASKCALL = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MASKCOUNT = 8 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MASKLINE = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MASKRET = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MAXINTEGER = 9223372036854775807 -> long +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MININTEGER = -9223372036854775808 -> long +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MINSTACK = 20 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MULTRET = -1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_NOREF = -2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_NUMTAGS = 9 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OK = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPADD = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPBAND = 7 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPBNOT = 13 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPBOR = 8 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPBXOR = 9 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPDIV = 5 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPEQ = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPIDIV = 6 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPLE = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPLT = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPMOD = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPMUL = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPPOW = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPSHL = 10 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPSHR = 11 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPSUB = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPUNM = 12 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_REFNIL = -1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_REGISTRYINDEX = -1001000 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_RIDX_GLOBALS = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_RIDX_LAST = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_RIDX_MAINTHREAD = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TBOOLEAN = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TFUNCTION = 6 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TLIGHTUSERDATA = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TNIL = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TNONE = -1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TNUMBER = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TSTRING = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TTABLE = 5 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TTHREAD = 8 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TUSERDATA = 7 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_VERSION_NUM = 503 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_YIELD = 1 -> int +const CheatEngine.SDK.Lua.Interop.Loading.LuaModule.CheatEngine64ModuleName = "lua53-64.dll" -> string! +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.GetMissingExports(nint moduleHandle) -> System.Collections.Generic.IReadOnlyList! +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.Initialize(nint moduleHandle) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.IsInitialized.get -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_COLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_DBLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_EXTRASPACE.get -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_IOLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_LOADLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MATHLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OSLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_SIGNATURE.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_STRLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TABLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_UTF8LIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.ModuleHandle.get -> nint +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.TryInitialize(nint moduleHandle, out string? failure) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_callmeta(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int obj, byte* e) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_dofile(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* fn) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_dostring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_getmetafield(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int obj, byte* e) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_getmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_getsubtable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, byte* fname) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_len(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> long +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadbuffer(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s, nuint sz, byte* n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadbufferx(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* buff, nuint sz, byte* name, byte* mode) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadfile(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* f) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadfilex(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* filename, byte* mode) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_newmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* tname) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_newstate() -> CheatEngine.SDK.Lua.Interop.Types.lua_State* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_openlibs(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_ref(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int t) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_requiref(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* modname, delegate* unmanaged[Cdecl] openf, int glb) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_setfuncs(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.luaL_Reg* l, int nup) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_setmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* tname) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_testudata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int ud, byte* tname) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_tolstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, nuint* len) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_traceback(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.lua_State* L1, byte* msg, int level) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_typename(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int i) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_unref(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int t, int ref) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_absindex(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_arith(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int op) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_atpanic(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] panicf) -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_call(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n, int r) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_callk(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int nargs, int nresults, nint ctx, delegate* unmanaged[Cdecl] k) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_checkstack(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_close(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_compare(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx1, int idx2, int op) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_concat(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_copy(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int fromidx, int toidx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_createtable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int narr, int nrec) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_dump(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] writer, void* data, int strip) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_error(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gc(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int what, int data) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getallocf(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, void** ud) -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getextraspace(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getfield(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, byte* k) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getglobal(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* name) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gethook(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gethookcount(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gethookmask(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_geti(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, long n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getinfo(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* what, CheatEngine.SDK.Lua.Interop.Types.lua_Debug* ar) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getlocal(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.lua_Debug* ar, int n) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int objindex) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getstack(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int level, CheatEngine.SDK.Lua.Interop.Types.lua_Debug* ar) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gettable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gettop(CheatEngine.SDK.Lua.Interop.Types.lua_State* l) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getupvalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int funcindex, int n) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getuservalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_insert(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isboolean(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_iscfunction(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isfunction(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isinteger(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_islightuserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isnil(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isnone(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isnoneornil(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isnumber(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_istable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isthread(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isuserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isyieldable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_len(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_load(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] reader, void* dt, byte* chunkname, byte* mode) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_newstate(delegate* unmanaged[Cdecl] f, void* ud) -> CheatEngine.SDK.Lua.Interop.Types.lua_State* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_newtable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_newthread(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> CheatEngine.SDK.Lua.Interop.Types.lua_State* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_newuserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, nuint sz) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_next(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pcall(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n, int r, int f) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pcallk(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int nargs, int nresults, int errfunc, nint ctx, delegate* unmanaged[Cdecl] k) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pop(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushboolean(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int b) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushcclosure(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] fn, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushcfunction(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] f) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushglobaltable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushinteger(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, long n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushlightuserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, void* p) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushliteral(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, System.ReadOnlySpan s) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushlstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s, nuint len) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushnil(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushnumber(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, double n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushthread(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushvalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawequal(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx1, int idx2) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawget(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawgeti(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, long n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawgetp(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, void* p) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawlen(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> nuint +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawset(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawseti(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, long n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawsetp(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, void* p) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_register(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* n, delegate* unmanaged[Cdecl] f) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_remove(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_replace(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_resume(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.lua_State* from, int narg) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rotate(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setallocf(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] f, void* ud) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setfield(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, byte* k) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setglobal(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, byte* name) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_sethook(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] func, int mask, int count) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_seti(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, long n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setlocal(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.lua_Debug* ar, int n) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int objindex) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_settable(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_settop(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setupvalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int funcindex, int n) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setuservalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_status(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_stringtonumber(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s) -> nuint +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_toboolean(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tocfunction(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tointeger(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int i) -> long +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tointegerx(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, int* isnum) -> long +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tolstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, nuint* len) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tonumber(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int i) -> double +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tonumberx(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, int* isnum) -> double +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_topointer(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tostring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int i) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tothread(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> CheatEngine.SDK.Lua.Interop.Types.lua_State* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_touserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_type(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_typename(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int tp) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_upvalueid(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int fidx, int n) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_upvalueindex(int i) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_upvaluejoin(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int fidx1, int n1, int fidx2, int n2) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_version(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> double* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_xmove(CheatEngine.SDK.Lua.Interop.Types.lua_State* from, CheatEngine.SDK.Lua.Interop.Types.lua_State* to, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_base.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_coroutine.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_debug.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_io.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_math.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_os.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_package.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_string.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_table.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_utf8.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Loading.LuaModule.TryGetLoaded(out nint moduleHandle) -> bool +static CheatEngine.SDK.Lua.Interop.Loading.LuaModule.TryGetLoaded(string! moduleName, out nint moduleHandle) -> bool diff --git a/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.SDK.Lua.Interop/README.md b/libs/CheatEngine.SDK.Lua.Interop/README.md index 90fbbd5e..ff3854ae 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/README.md +++ b/libs/CheatEngine.SDK.Lua.Interop/README.md @@ -55,6 +55,18 @@ when the operands are plain. `memory` also covers a failing `__gc` finalizer tha bound but forbidden. The `luaL_check*` functions, `lua_yieldk`, the `luaL_Buffer` family and the C varargs functions are not bound. They unwind with `longjmp` or cannot be blittable pointers. +Memory corruption and invalid native pointers are never recoverable through `pcall`: the protected boundary contains +Lua errors, not undefined behaviour. Every public static `LuaApi` member is classified this way — its Lua 5.3 manual +error marker, side effects and the route SDK production code may take to it (`Never`/`Memory`/`Any`/`Always`, and a +decision such as `DirectAllowed`, `BridgeRequired` or `CallerProtected`) — in the committed primitive matrix +`tests/CheatEngine.SDK.Repository.Tests/LuaBridge/TestData/lua-interop-primitives.json`, verified against this class by +reflection in `tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaInteropPrimitiveMatrixTests.cs`. Cheat Engine's +`lua53-64.dll` is not a standard Lua build, so every marker is measured on the bundled fixture, never read from a +plugin kit's `lua.h`/`lauxlib.h`. .NET does not support unwinding managed frames with `longjmp` +(https://learn.microsoft.com/dotnet/standard/native-interop/exceptions-interoperability#setjmplongjmp-behaviors); the +host-object pusher `LuaPushClassInstance` is the one exception whose exact Cheat Engine 7.7 failure behavior is +qualification scenario Q23, not yet executed on the host. + `LuaModule.TryGetLoaded` calls `GetModuleHandleExW`, which answers from the loader's module list. It never loads a library, unlike a bare-name `LoadLibrary`, which loads whatever file its search path finds first. It returns `false` off Windows. @@ -95,6 +107,9 @@ static unsafe class Example (`TryGetLoaded_unknown_module_returns_false_and_loads_nothing`). - `lua_Debug` matches the C layout on x64 and fits the record a real library writes (`NativeStructLayoutTests`, `Native_debug_record_fits_the_managed_struct`). +- Every public static member has exactly one row in the committed primitive matrix, and adding an allocating or + metamethod-running member without a matching row fails the run (`LuaInteropPrimitiveMatrixTests`, + `LuaInteropPrimitiveMatrixDocumentTests`). ## Run the tests diff --git a/libs/CheatEngine.SDK.Lua.Interop/packages.lock.json b/libs/CheatEngine.SDK.Lua.Interop/packages.lock.json new file mode 100644 index 00000000..75a512c2 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua.Interop/packages.lock.json @@ -0,0 +1,37 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + } + } + } +} \ No newline at end of file diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs index 5a2f4670..66626f81 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs @@ -159,10 +159,13 @@ protected virtual void Dispose(bool disposing) return; } - if (result == LuaRuntime.LuaCallbackDisposeOperationResult.AdmissionClosed) + if (result is LuaRuntime.LuaCallbackDisposeOperationResult.AdmissionClosed + or LuaRuntime.LuaCallbackDisposeOperationResult.ThreadNotAdmitted + or LuaRuntime.LuaCallbackDisposeOperationResult.ExternalStateReset) { - // This result was observed atomically with the closed gate. A later failed Detach can reopen admission, - // but cannot make it safe to abandon the closure before a transition-owned state neutralizes its upvalue. + // This result was observed atomically with the closed gate, the thread-admission refusal or the detected + // external reset. A later failed Detach can reopen admission, but cannot make it safe to abandon the + // closure before a transition-owned state neutralizes its upvalue: defer to that transition in every case. Volatile.Read(ref s_disposeAdmissionRefusedForTesting)?.Invoke(); return; } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallbackRegistry.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallbackRegistry.cs index 1b2ae7b5..5007b14d 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallbackRegistry.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallbackRegistry.cs @@ -112,4 +112,25 @@ internal static unsafe void DetachAll(LuaHostServices services) } } } + + /// + /// Marks every live callback released without calling or any Lua + /// function: used only after an external Lua state reset was detected, when the provider would hand out the + /// replacement VM's state and neutralizing the closure or releasing its s + /// would unref into a registry this SDK copy never created (A08-22). The managed handle is kept alive, which + /// leaks but cannot crash: the same safe failure already uses when no state + /// is available. + /// + internal static void AbandonAll() + { + lock (Gate) + { + // Mirror DetachAll's explicit re-read: Release unlinks the head it is called on. + for (LuaCallback? head = s_head; head is not null; head = s_head) + { + head.Release(default); + Volatile.Read(ref AfterReleaseForTesting)?.Invoke(); + } + } + } } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs index ba33fe99..ff6ee0fc 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs @@ -108,10 +108,13 @@ public void Dispose() return; } - if (result == LuaRuntime.LuaCallbackDisposeOperationResult.AdmissionClosed) + if (result is LuaRuntime.LuaCallbackDisposeOperationResult.AdmissionClosed + or LuaRuntime.LuaCallbackDisposeOperationResult.ThreadNotAdmitted + or LuaRuntime.LuaCallbackDisposeOperationResult.ExternalStateReset) { // The lifecycle transition still owns the linked registration and has the only state allowed to unregister - // it. In particular, do not consume the action here: a failed transition can reopen the old binding. + // it. In particular, do not consume the action here: a failed transition can reopen the old binding, and a + // detected external reset must never unregister into the replacement registry (A08-22). return; } @@ -188,6 +191,17 @@ internal void ReleaseFromLifecycle(LuaState state) ReleaseWithState(state); } + /// + /// Called only by after an external reset was detected: + /// marks this owner released and keeps its managed state alive, without calling the host's unregister action. + /// Unregistering it would touch the replacement Lua universe's registry, which this SDK copy never created + /// (A08-22); leaking the managed state instead is the safe failure. + /// + internal void Abandon() + { + AbandonWithoutState(); + } + private void Dispatch() { if (!LuaRuntime.TryEnterCallbackOperation(out LuaRuntimeOperation operation)) @@ -226,9 +240,9 @@ private bool TryEnterCallback(out Action callback) { callback = null!; if (!_acceptCallbacks - || !LuaRuntime.IsAttached - || LuaRuntime.CurrentStateIdentity != Identity - || _callback is null) + || !LuaRuntime.IsAttached + || LuaRuntime.CurrentStateIdentity != Identity + || _callback is null) { return false; } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs index 98513af0..eef08c4a 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs @@ -47,8 +47,8 @@ internal static bool TryAdd(LuaHostSubscription subscription) lock (Gate) { if (!s_acceptRegistrations - || !LuaRuntime.IsAttached - || LuaRuntime.CurrentStateIdentity != subscription.Identity) + || !LuaRuntime.IsAttached + || LuaRuntime.CurrentStateIdentity != subscription.Identity) { return false; } @@ -130,6 +130,25 @@ internal static void DetachAll(LuaState state) } } + /// + /// Closes admission and marks every owner released without unregistering it: used only after an external Lua + /// state reset was detected, when the provider would hand out the replacement VM's state and any unregister + /// attempt would touch a registry this SDK copy never created (A08-22). Managed state is kept alive; nothing + /// ever runs on the replacement state. + /// + internal static void AbandonAll() + { + lock (Gate) + { + s_acceptRegistrations = false; + } + + while (TryTakeHead(out LuaHostSubscription subscription)) + { + subscription.Abandon(); + } + } + internal static void OpenRegistrationAdmission() { lock (Gate) diff --git a/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatus.cs b/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatus.cs index 63e66600..56365a71 100644 --- a/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatus.cs +++ b/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatus.cs @@ -6,9 +6,16 @@ namespace CheatEngine.SDK.Lua.Calls; /// A compact, allocation-free outcome for an opt-in generated Lua global binding. /// -/// The status deliberately does not capture Lua's error text: extracting it reads the transient Lua stack and -/// allocates a managed string. Callers can classify a protected failure by without exposing -/// a LuaState or parsing a localized exception message. +/// +/// The status deliberately does not capture Lua's error text: extracting it reads the transient Lua stack and +/// allocates a managed string. Callers can classify a protected failure by without +/// exposing a LuaState or parsing a localized exception message. +/// +/// +/// default(LuaOperationStatus) has and +/// : an unassigned status never reads as success. Use the named +/// statics, never , to express an outcome. +/// /// [StructLayout(LayoutKind.Sequential)] public readonly struct LuaOperationStatus : IEquatable @@ -35,7 +42,8 @@ public LuaStatus LuaStatus } /// Gets a successful status. - public static LuaOperationStatus Success => default; + /// Distinct from , which is . + public static LuaOperationStatus Success => new(LuaOperationStatusKind.Success, LuaStatus.Ok); /// Gets a status for an absent or non-callable global. public static LuaOperationStatus GlobalUnavailable => new(LuaOperationStatusKind.GlobalUnavailable, LuaStatus.Ok); @@ -49,6 +57,13 @@ public LuaStatus LuaStatus /// Gets a status for a stack-capacity failure before the call begins. public static LuaOperationStatus StackUnavailable => new(LuaOperationStatusKind.StackUnavailable, LuaStatus.Ok); + /// Gets a status for a call that returned fewer values than the declaration requires. + public static LuaOperationStatus MissingResult => new(LuaOperationStatusKind.MissingResult, LuaStatus.Ok); + + /// Gets a status for more variadic results than the destination can hold. + public static LuaOperationStatus ResultCapacityExceeded => + new(LuaOperationStatusKind.ResultCapacityExceeded, LuaStatus.Ok); + /// Gets a status for a protected Lua failure. /// The unmodified status returned by the protected Lua primitive. public static LuaOperationStatus LuaFailure(LuaStatus luaStatus) @@ -56,7 +71,10 @@ public static LuaOperationStatus LuaFailure(LuaStatus luaStatus) return new LuaOperationStatus(LuaOperationStatusKind.LuaFailure, luaStatus); } - /// Gets whether the call and result conversions completed successfully. + /// + /// Gets whether the call and result conversions completed successfully; for + /// . + /// public bool IsSuccess => Kind == LuaOperationStatusKind.Success; /// diff --git a/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatusKind.cs b/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatusKind.cs index 162b78cd..a956509e 100644 --- a/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatusKind.cs +++ b/libs/CheatEngine.SDK.Lua/Calls/LuaOperationStatusKind.cs @@ -3,25 +3,46 @@ namespace CheatEngine.SDK.Lua.Calls; /// The factual outcome of one generated, protected Lua global call. /// /// This describes the binding boundary, not application policy. In particular, is the raw -/// Lua nil result and is not a claim that a domain object was not found. +/// Lua nil result and is not a claim that a domain object was not found. The zero value is +/// , never : a status that was never assigned cannot read as success. +/// The numeric values are part of the contract and never change. /// public enum LuaOperationStatusKind { + /// + /// No binding outcome was recorded: the value of default(LuaOperationStatus), or an operation that made no + /// binding call. Never success. + /// + Unknown = 0, + /// The global call and every declared result conversion succeeded. - Success, + Success = 1, /// The required global was absent or was not a callable Lua function. - GlobalUnavailable, + GlobalUnavailable = 2, /// A protected Lua operation failed. - LuaFailure, + LuaFailure = 3, /// The call completed but returned Lua nil where the declaration requires a value. - NilResult, + NilResult = 4, /// The call completed but returned a non-nil value the declared marshaller cannot represent. - InvalidResult, + InvalidResult = 5, /// The Lua stack could not grow enough to begin the declared call. - StackUnavailable + StackUnavailable = 6, + + /// + /// The call completed but returned fewer values than the declaration requires. Only a declaration that reads the + /// factual result count (an optional or variadic result) can tell this apart from : Lua + /// pads a fixed-count call with nil. + /// + MissingResult = 7, + + /// + /// The call completed but returned more variadic values than the destination span can hold. Nothing was copied; + /// the count output holds the number of values Lua returned. + /// + ResultCapacityExceeded = 8 } diff --git a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs index fbdad622..e1208da5 100644 --- a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs +++ b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs @@ -1,9 +1,12 @@ +using System; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text; +using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; namespace CheatEngine.SDK.Lua.CompilerServices; @@ -97,6 +100,153 @@ public static LuaOperationStatus Fail(LuaState state, int top, LuaOpera return status; } + /// + /// Pushes an optional argument that a generated wrapper decided to pass: its value through + /// , or nil for . A wrapper never + /// calls this for an omitted argument: it computes the argument count first and pushes only up to the last + /// argument that is not omitted. + /// + /// The value type. + /// The marshaller of . + /// The state to push on. + /// The value or nil. + /// is omitted: an omitted argument is never pushed. + [LuaStackEffect(1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void PushOptional(LuaState state, LuaOptional value) + where T : notnull + where TMarshaller : ILuaMarshaller + { + if (value.TryGetValue(out T? present)) + { + TMarshaller.Push(state, present); + } + else if (value.IsNil) + { + state.PushNil(); + } + else + { + ThrowOmittedArgument(nameof(value)); + } + } + + /// + /// Reads an optional value at a positive, absolute stack without changing the stack: a + /// position above the top is (the caller or the callee passed fewer + /// values), nil is , and a value reads + /// is present. Generated thunks read optional arguments and generated wrappers read optional results with it. + /// + /// The value type. + /// The marshaller of . + /// The state to read from. + /// A positive, absolute stack index; never touched when it is above the top. + /// The optional value; omitted when the read fails. + /// + /// only when a non-nil value is present that + /// cannot read (a value of another kind). + /// + [LuaStackEffect(0)] + public static bool TryReadOptional(LuaState state, int index, out LuaOptional value) + where T : notnull + where TMarshaller : ILuaMarshaller + { + if (index <= 0) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "An optional value is read at a positive, absolute index."); + } + + if (index > state.Top) + { + value = default; + return true; + } + + if (state.IsNil(index)) + { + value = LuaOptional.Nil(); + return true; + } + + if (TMarshaller.TryRead(state, index, out T? read)) + { + value = LuaOptional.FromValue(read); + return true; + } + + value = default; + return false; + } + + /// + /// Copies every value from the positive, absolute stack index to the top into + /// through , without changing the stack: the + /// variadic tail of a generated Outcome wrapper. + /// + /// The unmanaged element type. + /// The marshaller of . + /// The state to read from. + /// The absolute index of the first value; above the top means no value. + /// Receives the values, in stack order. + /// + /// The number of values copied on success; on the + /// number of values Lua returned (the capacity needed); otherwise 0. + /// + /// + /// ; when there + /// are more values than holds (nothing copied); + /// or for the first + /// value that cannot be read ( is cleared up to that position). No error text is + /// read. + /// + [LuaStackEffect(0)] + public static LuaOperationStatus ReadResults(LuaState state, int firstIndex, Span destination, + out int count) + where T : unmanaged + where TMarshaller : ILuaMarshaller + { + if (firstIndex <= 0) + { + throw new ArgumentOutOfRangeException(nameof(firstIndex), firstIndex, + "Results are read from a positive, absolute index."); + } + + int available = state.Top - firstIndex + 1; + if (available <= 0) + { + count = 0; + return LuaOperationStatus.Success; + } + + if (available > destination.Length) + { + count = available; + return LuaOperationStatus.ResultCapacityExceeded; + } + + for (int i = 0; i < available; i++) + { + int index = firstIndex + i; + if (!TMarshaller.TryRead(state, index, out destination[i])) + { + destination[..(i + 1)].Clear(); + count = 0; + return state.IsNil(index) ? LuaOperationStatus.NilResult : LuaOperationStatus.InvalidResult; + } + } + + count = available; + return LuaOperationStatus.Success; + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowOmittedArgument(string parameterName) + { + throw new ArgumentException("An omitted optional Lua argument is never pushed; the wrapper stops before it.", + parameterName); + } + /// /// Restores the stack to and throws a describing the error value /// that was on top of the stack: the exit of a generated throwing wrapper when the protected call failed. @@ -148,6 +298,6 @@ public static void ThrowUnexpectedResult(LuaState state, int top, int index, str string typeName = Encoding.UTF8.GetString(state.TypeName(index)); state.SetTop(top); throw new LuaException("The Lua global '" + globalName + "' returned a " + typeName + " value, not " + - expected + "."); + expected + "."); } } diff --git a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushOutcome.cs b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushOutcome.cs index 7624e306..135bc31d 100644 --- a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushOutcome.cs +++ b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushOutcome.cs @@ -6,6 +6,10 @@ namespace CheatEngine.SDK.Lua.CompilerServices; /// The detailed result of resolving a Lua global for an opt-in generated binding. +/// +/// default(LuaGlobalPushOutcome) is : it never reads as a pushed +/// function, and projects it to . +/// [EditorBrowsable(EditorBrowsableState.Never)] [StructLayout(LayoutKind.Sequential)] public readonly struct LuaGlobalPushOutcome @@ -32,7 +36,8 @@ public LuaStatus LuaStatus public bool IsSuccess => Status == LuaGlobalPushStatus.Success; /// Gets a successful resolution. - public static LuaGlobalPushOutcome Success => default; + /// Distinct from , which is . + public static LuaGlobalPushOutcome Success => new(LuaGlobalPushStatus.Success, LuaStatus.Ok); /// Creates an unavailable resolution. public static LuaGlobalPushOutcome Unavailable => new(LuaGlobalPushStatus.Unavailable, LuaStatus.Ok); @@ -45,13 +50,20 @@ public static LuaGlobalPushOutcome LuaFailure(LuaStatus luaStatus) } /// Projects this resolution into a generated binding status. + /// + /// , or + /// with the preserved ; an + /// resolution projects to + /// (), never to a failure with an Ok Lua status. + /// public LuaOperationStatus ToOperationStatus() { return Status switch { LuaGlobalPushStatus.Success => LuaOperationStatus.Success, LuaGlobalPushStatus.Unavailable => LuaOperationStatus.GlobalUnavailable, - _ => LuaOperationStatus.LuaFailure(LuaStatus) + LuaGlobalPushStatus.LuaFailure => LuaOperationStatus.LuaFailure(LuaStatus), + _ => default }; } } diff --git a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushStatus.cs b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushStatus.cs index 5b33c105..6421f1ad 100644 --- a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushStatus.cs +++ b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaGlobalPushStatus.cs @@ -3,15 +3,25 @@ namespace CheatEngine.SDK.Lua.CompilerServices; /// Outcome of resolving a Lua global function for SDK-internal failure classification. +/// +/// The zero value is , never : a resolution that was never performed +/// cannot read as a pushed function. The numeric values are part of the contract and never change. +/// [EditorBrowsable(EditorBrowsableState.Never)] public enum LuaGlobalPushStatus { + /// + /// No resolution was recorded: the value of default(LuaGlobalPushOutcome). Never success, and nothing + /// was pushed. + /// + Unknown = 0, + /// The function was pushed. - Success, + Success = 1, /// The global was absent or was not a function. - Unavailable, + Unavailable = 2, /// A protected global lookup or reference creation failed. - LuaFailure + LuaFailure = 3 } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/AddressMarshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/AddressMarshaller.cs index 95ea2039..48631b07 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/AddressMarshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/AddressMarshaller.cs @@ -12,13 +12,16 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// comes back intact. /// /// -/// Reading is strict about the Lua type: only a number is accepted (an integer, or a float with an exact integral -/// value). A string is refused even when Lua could convert it, because Cheat Engine returns some addresses as -/// hexadecimal text and Lua's own string-to-number rule would read "10" as ten and "00400000" as -/// four hundred thousand: a wrong address rather than a failure. The number-or-hex-string convention is decoded one -/// layer up (CheatEngine.SDK.Engine's address reader: a number through this marshaller, a string through -/// and a hexadecimal parse). On a 32-bit process a value that does not fit is -/// reported as . One C API call to push, two to read; allocates nothing. +/// Reading is strict about the Lua type: only a number is accepted, an integer subtype (every bit kept, an address +/// above 4 GiB or above included) or a float with an exact integral value of magnitude +/// below 2^53. A float at or above 2^53 is refused: a 64-bit address never passes through a lossy +/// (audit A07-02, Q21). A string is refused even when Lua could convert it, because Cheat Engine +/// returns some addresses as hexadecimal text and Lua's own string-to-number rule would read "10" as ten +/// and "00400000" as four hundred thousand: a wrong address rather than a failure. The number-or-hex-string +/// convention is decoded one layer up (CheatEngine.SDK.Engine's address reader: a number through this +/// marshaller, a string through and a hexadecimal parse). On a 32-bit process a +/// value that does not fit is reported as . One C API call to push, two to read an integer +/// subtype; allocates nothing. /// public readonly struct AddressMarshaller : ILuaMarshaller { @@ -35,7 +38,7 @@ public static void Push(LuaState state, nuint value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool TryRead(LuaState state, int index, out nuint value) { - if (state.TypeOf(index) != LuaType.Number || !state.TryReadInteger(index, out long bits)) + if (!LuaIntegerReader.TryRead(state, index, false, out long bits)) { value = 0; return false; diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/Int32Marshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/Int32Marshaller.cs index 64b477c0..2a9187e5 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/Int32Marshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/Int32Marshaller.cs @@ -10,8 +10,11 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// out-of-range values are reported as , never truncated. /// /// -/// Conversion rules for the Lua side are those of . One C API call each way plus a -/// range check; allocates nothing. +/// Conversion rules for the Lua side are those of : an integer subtype, an integral +/// float below 2^53 or an integer numeral string, never a value rounded through a ; the 64-bit +/// result must then fit 32 bits signed. An unsigned 32-bit value above is refused rather +/// than reinterpreted: read it as . Two C API calls to read an integer subtype plus a range +/// check; allocates nothing. /// public readonly struct Int32Marshaller : ILuaMarshaller { @@ -28,7 +31,7 @@ public static void Push(LuaState state, int value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool TryRead(LuaState state, int index, out int value) { - if (state.TryReadInteger(index, out long wide) && wide >= int.MinValue && wide <= int.MaxValue) + if (LuaIntegerReader.TryRead(state, index, true, out long wide) && wide >= int.MinValue && wide <= int.MaxValue) { value = (int) wide; return true; diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/Int64Marshaller.cs b/libs/CheatEngine.SDK.Lua/Marshalling/Int64Marshaller.cs index 571a28d8..44d2a1d7 100644 --- a/libs/CheatEngine.SDK.Lua/Marshalling/Int64Marshaller.cs +++ b/libs/CheatEngine.SDK.Lua/Marshalling/Int64Marshaller.cs @@ -9,10 +9,18 @@ namespace CheatEngine.SDK.Lua.Marshalling; /// as a Lua integer (lua_Integer is 64-bit in Lua 5.3): the lossless scalar marshaller. /// /// -/// Reading follows Lua's own conversion (lua_tointegerx): an integer, a float with an exact integral value -/// (3.0), or a string Lua can convert ("42", "0x10") all succeed; 2.5, nil, a -/// boolean or a table fail. Use when the representation itself matters. -/// One C API call each way; allocates nothing. +/// +/// Reading never lets a value pass through a lossy : an integer subtype reads exactly; a float +/// reads only when it is integral and of magnitude below 2^53 (3.0 succeeds, 9007199254740992.0, +/// 1e19 and 2.5 fail); a string reads only when it is a Lua integer numeral that fits 64 bits +/// ("42", " 0x10 ", "-9223372036854775808" succeed; "3.0", "1e3", +/// "9223372036854775808" and more than 16 significant hexadecimal digits fail). nil, a boolean or a +/// table fail. Use when the representation itself matters. +/// +/// +/// This is stricter than Lua's own lua_tointegerx, which rounds float numerals and floats of any magnitude +/// (audit Q21). One C API call to push, two to read an integer subtype; allocates nothing. +/// /// public readonly struct Int64Marshaller : ILuaMarshaller { @@ -29,6 +37,6 @@ public static void Push(LuaState state, long value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool TryRead(LuaState state, int index, out long value) { - return state.TryReadInteger(index, out value); + return LuaIntegerReader.TryRead(state, index, true, out value); } } diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/LuaIntegerReader.cs b/libs/CheatEngine.SDK.Lua/Marshalling/LuaIntegerReader.cs new file mode 100644 index 00000000..0e3440b3 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/Marshalling/LuaIntegerReader.cs @@ -0,0 +1,155 @@ +using System; +using System.Runtime.CompilerServices; + +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Lua.Marshalling; + +/// +/// The integer conversion policy of , and +/// : no value reaches a 64-bit integer through a lossy (audit +/// A07-02, A07-08, A12-08, A18-18, Q21). +/// +/// +/// +/// An integer subtype is exact. A float subtype is accepted only when it is finite, integral and of magnitude +/// strictly below 2^53: from 2^53 on, a double no longer tells neighbouring integers apart, so +/// 9007199254740993 written as a float would silently read as 9007199254740992. A string is accepted +/// (by the integer marshallers only) when its bytes are a Lua 5.3 integer numeral (optional spaces, an optional +/// sign, decimal digits or 0x and at most 16 significant hexadecimal digits) that fits a 64-bit integer: +/// a float numeral ("3.0", "1e3", "0x1p4") or an out-of-range decimal is refused even where +/// Lua's own lua_tointegerx would round it through a float. +/// +/// +/// The hot path, an integer subtype, costs two C API calls (lua_tointegerx, lua_isinteger); floats +/// and strings take a cold path. Nothing is allocated and the stack slot is never converted in place. +/// +/// +internal static class LuaIntegerReader +{ + // 2^53: the first magnitude at which a double cannot represent every integer. + private const long FloatExactLimit = 1L << 53; + + // Lua 5.3 l_str2int: the decimal overflow guard ('a >= maxby10 && (a > maxby10 || d > maxlastd + neg)'). + private const ulong MaxIntegerDividedBy10 = (ulong) long.MaxValue / 10; + private const int MaxIntegerLastDigit = (int) ((ulong) long.MaxValue % 10); + + /// + /// Reads the value at as a 64-bit integer under the policy above, without changing + /// the stack. + /// + /// The state to read from. + /// An acceptable index. + /// Whether a string holding an integer numeral is accepted. + /// The integer, or 0 when refused. + /// when holds the exact integer. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryRead(LuaState state, int index, bool acceptIntegerNumerals, out long value) + { + if (!state.TryReadInteger(index, out value)) + { + return false; + } + + return state.IsInteger(index) || AcceptConverted(state, index, acceptIntegerNumerals, ref value); + } + + /// + /// Whether is exactly a Lua 5.3 integer numeral that fits a 64-bit integer: optional + /// whitespace, an optional sign, then decimal digits (no overflow) or 0x/0X and 1 to 16 significant + /// hexadecimal digits, then optional whitespace. Anything else (a fraction, an exponent, an embedded NUL) is not. + /// + public static bool IsIntegerNumeral(ReadOnlySpan text) + { + int i = SkipSpaces(text, 0); + bool negative = false; + if (i < text.Length && (text[i] == (byte) '-' || text[i] == (byte) '+')) + { + negative = text[i] == (byte) '-'; + i++; + } + + bool valid = i + 1 < text.Length && text[i] == (byte) '0' && (text[i + 1] | 0x20) == (byte) 'x' + ? TryScanHexadecimal(text, ref i) + : TryScanDecimal(text, negative, ref i); + return valid && SkipSpaces(text, i) == text.Length; + } + + // A value lua_tointegerx converted from a float or a string: exact only under the policy. + [MethodImpl(MethodImplOptions.NoInlining)] + private static bool AcceptConverted(LuaState state, int index, bool acceptIntegerNumerals, ref long value) + { + bool accepted = state.TypeOf(index) switch + { + // lua_tointegerx already refused a non-integral, infinite or NaN float and one outside the long range. + LuaType.Number => value > -FloatExactLimit && value < FloatExactLimit, + LuaType.String => acceptIntegerNumerals && state.TryReadUtf8(index, out ReadOnlySpan text) && + IsIntegerNumeral(text), + _ => false + }; + + if (!accepted) + { + value = 0; + } + + return accepted; + } + + private static bool TryScanHexadecimal(ReadOnlySpan text, ref int i) + { + i += 2; + int start = i; + int significant = 0; + while (i < text.Length && IsHexadecimalDigit(text[i])) + { + if (significant > 0 || text[i] != (byte) '0') + { + significant++; + } + + i++; + } + + // Lua wraps a longer hexadecimal numeral modulo 2^64: more than 16 significant digits lose bits. + return i > start && significant <= 16; + } + + private static bool TryScanDecimal(ReadOnlySpan text, bool negative, ref int i) + { + int start = i; + ulong accumulated = 0; + int lastDigitLimit = MaxIntegerLastDigit + (negative ? 1 : 0); + while (i < text.Length && text[i] >= (byte) '0' && text[i] <= (byte) '9') + { + int digit = text[i] - (byte) '0'; + if (accumulated >= MaxIntegerDividedBy10 && + (accumulated > MaxIntegerDividedBy10 || digit > lastDigitLimit)) + { + return false; + } + + accumulated = (accumulated * 10) + (ulong) digit; + i++; + } + + return i > start; + } + + // Lua's lisspace: space, \t, \n, \v, \f, \r. + private static int SkipSpaces(ReadOnlySpan text, int i) + { + while (i < text.Length && (text[i] == (byte) ' ' || (text[i] >= (byte) '\t' && text[i] <= (byte) '\r'))) + { + i++; + } + + return i; + } + + private static bool IsHexadecimalDigit(byte character) + { + return (character >= (byte) '0' && character <= (byte) '9') || ((character | 0x20) >= (byte) 'a' && + (character | 0x20) <= (byte) 'f'); + } +} diff --git a/libs/CheatEngine.SDK.Lua/Marshalling/LuaOptional.cs b/libs/CheatEngine.SDK.Lua/Marshalling/LuaOptional.cs new file mode 100644 index 00000000..a2084e29 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/Marshalling/LuaOptional.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace CheatEngine.SDK.Lua.Marshalling; + +/// +/// Creates values. The factories live on this non-generic type so that a call site +/// infers the type argument (LuaOptional.Of(5)) instead of naming it on the generic type. +/// +public static class LuaOptional +{ + /// + /// An omitted optional value: nothing is pushed for an argument, or Lua returned no value at a result position. + /// Equal to default(LuaOptional<T>). + /// + /// The value type. + public static LuaOptional Omitted() + where T : notnull + { + return default; + } + + /// An explicit Lua nil: pushed as nil for an argument, or a nil result value. + /// The value type. + public static LuaOptional Nil() + where T : notnull + { + return new LuaOptional(LuaOptional.NilState, default!); + } + + /// A present value. + /// The value type. + /// The value; never : nil is . + /// is a reference. + public static LuaOptional Of(T value) + where T : notnull + { + return LuaOptional.FromValue(value); + } +} + +/// +/// One optional Lua value in exactly one of three states: (no value at all: an argument that +/// is not pushed, or a result position Lua did not return), (an explicit Lua nil) or +/// . Cheat Engine functions can behave differently for an omitted argument and a nil +/// one, and a function that returns nothing is not one that returns nil: the three states are never merged. +/// +/// +/// The value type. is allowed, ? is not: nil is +/// , never a string. +/// +/// +/// +/// default(LuaOptional<T>) is , so a caller writes +/// to omit an argument. There is no implicit conversion from : the state is always +/// chosen explicitly with , or +/// . +/// +/// +/// The type is an ordinary (non-) struct, so a span cannot be optional; an optional text value +/// is a LuaOptional<string>. Creating one allocates nothing beyond the value itself. +/// +/// +public readonly struct LuaOptional : IEquatable> + where T : notnull +{ + internal const byte OmittedState = 0; + internal const byte NilState = 1; + internal const byte ValueState = 2; + + // Read before the null test so that a value type never reaches 'value is null', which boxes in unoptimized code. + private static readonly bool s_canBeNull = !typeof(T).IsValueType; + + private readonly T _value; + private readonly byte _state; + + internal LuaOptional(byte state, T value) + { + _state = state; + _value = value; + } + + internal static LuaOptional FromValue(T value) + { + if (s_canBeNull && value is null) + { + throw new ArgumentNullException(nameof(value), + "A LuaOptional value cannot be null; use LuaOptional.Nil() to pass Lua nil."); + } + + return new LuaOptional(ValueState, value); + } + + /// Gets whether no value is present at all (the state). + public bool IsOmitted => _state == OmittedState; + + /// Gets whether the value is an explicit Lua nil. + public bool IsNil => _state == NilState; + + /// Gets whether a value is present. + public bool HasValue => _state == ValueState; + + /// Gets the present value. + /// The value is omitted or nil. + public T Value => _state == ValueState + ? _value + : throw new InvalidOperationException(_state == NilState + ? "The optional Lua value is nil, not a value." + : "The optional Lua value is omitted, not a value."); + + /// Compares the state and, when both hold a value, the values. + /// The first value. + /// The second value. + public static bool operator ==(LuaOptional left, LuaOptional right) + { + return left.Equals(right); + } + + /// Compares the state and, when both hold a value, the values. + /// The first value. + /// The second value. + public static bool operator !=(LuaOptional left, LuaOptional right) + { + return !left.Equals(right); + } + + /// Gets the value when one is present. + /// The value, or when omitted or nil. + /// exactly when . + public bool TryGetValue([MaybeNullWhen(false)] out T value) + { + value = _value; + return _state == ValueState; + } + + /// + public bool Equals(LuaOptional other) + { + return _state == other._state + && (_state != ValueState || EqualityComparer.Default.Equals(_value, other._value)); + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is LuaOptional other && Equals(other); + } + + /// + public override int GetHashCode() + { + return _state == ValueState ? HashCode.Combine(_state, _value) : _state; + } + + /// Returns <omitted>, nil, or the value formatted with the invariant culture. + public override string ToString() + { + return _state switch + { + ValueState => _value is IFormattable formattable + ? formattable.ToString(null, CultureInfo.InvariantCulture) + : _value.ToString() ?? string.Empty, + NilState => "nil", + _ => "" + }; + } +} diff --git a/libs/CheatEngine.SDK.Lua/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Lua/PublicAPI.Shipped.txt new file mode 100644 index 00000000..591309ec --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/PublicAPI.Shipped.txt @@ -0,0 +1,276 @@ +#nullable enable +CheatEngine.SDK.Lua.Callbacks.LuaCallback +CheatEngine.SDK.Lua.Callbacks.LuaCallback.Dispose() -> void +CheatEngine.SDK.Lua.Callbacks.LuaCallback.IsCurrent.get -> bool +CheatEngine.SDK.Lua.Callbacks.LuaCallback.IsReleased.get -> bool +CheatEngine.SDK.Lua.Callbacks.LuaCallback.Release(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Lua.Callbacks.LuaCallback.StateObject.get -> object? +CheatEngine.SDK.Lua.Callbacks.LuaCallback.TryPush(CheatEngine.SDK.Lua.State.LuaState state) -> bool +CheatEngine.SDK.Lua.Callbacks.LuaCallback.TryRegister(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan globalName) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Callbacks.LuaCallback +CheatEngine.SDK.Lua.Callbacks.LuaCallback.State.get -> TState? +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.Address.get -> nint +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.Equals(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction other) -> bool +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.IsNull.get -> bool +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.LuaNativeFunction() -> void +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.LuaNativeFunction(delegate* unmanaged[Cdecl] function) -> void +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.LuaNativeFunction(nint address) -> void +CheatEngine.SDK.Lua.Callbacks.LuaThunk +CheatEngine.SDK.Lua.Calls.LuaComparison +CheatEngine.SDK.Lua.Calls.LuaComparison.Equal = 0 -> CheatEngine.SDK.Lua.Calls.LuaComparison +CheatEngine.SDK.Lua.Calls.LuaComparison.Less = 1 -> CheatEngine.SDK.Lua.Calls.LuaComparison +CheatEngine.SDK.Lua.Calls.LuaComparison.LessOrEqual = 2 -> CheatEngine.SDK.Lua.Calls.LuaComparison +CheatEngine.SDK.Lua.Calls.LuaError +CheatEngine.SDK.Lua.Calls.LuaError.Equals(CheatEngine.SDK.Lua.Calls.LuaError other) -> bool +CheatEngine.SDK.Lua.Calls.LuaError.LuaError() -> void +CheatEngine.SDK.Lua.Calls.LuaError.LuaError(CheatEngine.SDK.Lua.Calls.LuaStatus status, string! message) -> void +CheatEngine.SDK.Lua.Calls.LuaError.Message.get -> string! +CheatEngine.SDK.Lua.Calls.LuaError.Status.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Calls.LuaException +CheatEngine.SDK.Lua.Calls.LuaException.LuaException(CheatEngine.SDK.Lua.Calls.LuaError error) -> void +CheatEngine.SDK.Lua.Calls.LuaException.LuaException(string! message) -> void +CheatEngine.SDK.Lua.Calls.LuaException.LuaException(string! message, System.Exception! innerException) -> void +CheatEngine.SDK.Lua.Calls.LuaException.Status.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Calls.LuaStatus.Code.get -> int +CheatEngine.SDK.Lua.Calls.LuaStatus.Equals(CheatEngine.SDK.Lua.Calls.LuaStatus other) -> bool +CheatEngine.SDK.Lua.Calls.LuaStatus.IsOk.get -> bool +CheatEngine.SDK.Lua.Calls.LuaStatus.LuaStatus() -> void +CheatEngine.SDK.Lua.Calls.LuaStatus.LuaStatus(int code) -> void +CheatEngine.SDK.Lua.Calls.LuaStatus.ThrowIfFailed(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions +CheatEngine.SDK.Lua.Marshalling.AddressMarshaller +CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.AddressMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller +CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.BooleanMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller +CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller.DoubleMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller +CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, T value) -> void +CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out T value) -> bool +CheatEngine.SDK.Lua.Marshalling.Int32Marshaller +CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.Int32Marshaller() -> void +CheatEngine.SDK.Lua.Marshalling.Int64Marshaller +CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.Int64Marshaller() -> void +CheatEngine.SDK.Lua.Marshalling.SingleMarshaller +CheatEngine.SDK.Lua.Marshalling.SingleMarshaller.SingleMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.StringMarshaller +CheatEngine.SDK.Lua.Marshalling.StringMarshaller.StringMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller +CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.Utf8Marshaller() -> void +CheatEngine.SDK.Lua.References.LuaRef +CheatEngine.SDK.Lua.References.LuaRef.Dispose() -> void +CheatEngine.SDK.Lua.References.LuaRef.Epoch.get -> int +CheatEngine.SDK.Lua.References.LuaRef.Identity.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Lua.References.LuaRef.IsCurrent.get -> bool +CheatEngine.SDK.Lua.References.LuaRef.IsResolved.get -> bool +CheatEngine.SDK.Lua.References.LuaRef.LuaRef() -> void +CheatEngine.SDK.Lua.References.LuaRef.Reference.get -> int +CheatEngine.SDK.Lua.References.LuaRef.Release(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Lua.References.LuaRef.StateGeneration.get -> int +CheatEngine.SDK.Lua.Runtime.LuaHostBinding +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.Equals(CheatEngine.SDK.Lua.Runtime.LuaHostBinding other) -> bool +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.HostObjectPusher.get -> nint +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.IsValid.get -> bool +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.LuaHostBinding() -> void +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.LuaHostBinding(delegate* unmanaged[Stdcall] stateProvider, delegate* unmanaged[Stdcall] hostObjectPusher, int mainThreadId) -> void +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.LuaHostBinding(nint stateProvider, nint hostObjectPusher, int mainThreadId) -> void +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.MainThreadId.get -> int +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.StateProvider.get -> nint +CheatEngine.SDK.Lua.Runtime.LuaRuntime +CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation +CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation.Dispose() -> void +CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation.LuaRuntimeOperation() -> void +CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation.State.get -> CheatEngine.SDK.Lua.State.LuaState +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.AttachEpoch.get -> int +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.Equals(CheatEngine.SDK.Lua.Runtime.LuaStateIdentity other) -> bool +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.LuaStateIdentity() -> void +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.StateGeneration.get -> int +CheatEngine.SDK.Lua.State.LuaFrame +CheatEngine.SDK.Lua.State.LuaFrame.AssertBalanced() -> void +CheatEngine.SDK.Lua.State.LuaFrame.Count.get -> int +CheatEngine.SDK.Lua.State.LuaFrame.Dispose() -> void +CheatEngine.SDK.Lua.State.LuaFrame.LuaFrame() -> void +CheatEngine.SDK.Lua.State.LuaFrame.LuaFrame(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Lua.State.LuaFrame.State.get -> CheatEngine.SDK.Lua.State.LuaState +CheatEngine.SDK.Lua.State.LuaFrame.Top.get -> int +CheatEngine.SDK.Lua.State.LuaState +CheatEngine.SDK.Lua.State.LuaState.AbsoluteIndex(int index) -> int +CheatEngine.SDK.Lua.State.LuaState.Copy(int fromIndex, int toIndex) -> void +CheatEngine.SDK.Lua.State.LuaState.CreateRef() -> CheatEngine.SDK.Lua.References.LuaRef! +CheatEngine.SDK.Lua.State.LuaState.CreateTable(int arraySlots = 0, int recordSlots = 0) -> void +CheatEngine.SDK.Lua.State.LuaState.Equals(CheatEngine.SDK.Lua.State.LuaState other) -> bool +CheatEngine.SDK.Lua.State.LuaState.Handle.get -> nint +CheatEngine.SDK.Lua.State.LuaState.Insert(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.IsFunction(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsInteger(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsLightUserdata(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNil(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNone(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNoneOrNil(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNull.get -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNumberConvertible(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsTable(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsUserdata(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.LuaState() -> void +CheatEngine.SDK.Lua.State.LuaState.LuaState(nint handle) -> void +CheatEngine.SDK.Lua.State.LuaState.NewUserdata(nuint size) -> nint +CheatEngine.SDK.Lua.State.LuaState.Pop(int count) -> void +CheatEngine.SDK.Lua.State.LuaState.PushBoolean(bool value) -> void +CheatEngine.SDK.Lua.State.LuaState.PushByteTable(System.ReadOnlySpan bytes) -> void +CheatEngine.SDK.Lua.State.LuaState.PushGlobalTable() -> void +CheatEngine.SDK.Lua.State.LuaState.PushInteger(long value) -> void +CheatEngine.SDK.Lua.State.LuaState.PushLightUserdata(nint address) -> void +CheatEngine.SDK.Lua.State.LuaState.PushNil() -> void +CheatEngine.SDK.Lua.State.LuaState.PushNumber(double value) -> void +CheatEngine.SDK.Lua.State.LuaState.PushString(System.ReadOnlySpan utf8) -> void +CheatEngine.SDK.Lua.State.LuaState.PushString(System.ReadOnlySpan text) -> void +CheatEngine.SDK.Lua.State.LuaState.PushUncheckedFunction(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk) -> void +CheatEngine.SDK.Lua.State.LuaState.PushValue(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.RawEquals(int index1, int index2) -> bool +CheatEngine.SDK.Lua.State.LuaState.RawGet(int tableIndex) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaState.RawGetIndex(int tableIndex, long key) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaState.RawGetPointer(int tableIndex, nint key) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaState.RawLength(int index) -> nuint +CheatEngine.SDK.Lua.State.LuaState.RawSetIndex(int tableIndex, long key) -> void +CheatEngine.SDK.Lua.State.LuaState.RawSetPointer(int tableIndex, nint key) -> void +CheatEngine.SDK.Lua.State.LuaState.Remove(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.Replace(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.Rotate(int index, int count) -> void +CheatEngine.SDK.Lua.State.LuaState.SetMetatable(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.SetTop(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.ToBoolean(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.ToPointer(int index) -> nint +CheatEngine.SDK.Lua.State.LuaState.ToUserdata(int index) -> nint +CheatEngine.SDK.Lua.State.LuaState.Top.get -> int +CheatEngine.SDK.Lua.State.LuaState.TryCall(int argumentCount, int resultCount) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryCall(int argumentCount, int resultCount, int messageHandlerIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryCompare(int index1, int index2, CheatEngine.SDK.Lua.Calls.LuaComparison comparison, out bool result) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryCopyUtf8(int index, System.Span destination, out int written) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryEnsureStack(int extraSlots) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryExecute(System.ReadOnlySpan source, int resultCount, System.ReadOnlySpan chunkName = default(System.ReadOnlySpan)) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryGetField(int index, System.ReadOnlySpan key) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryGetGlobal(System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryGetIndex(int index, long key) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryGetMetatable(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryGetTable(int index) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryLength(int index) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryLoad(System.ReadOnlySpan source, System.ReadOnlySpan chunkName = default(System.ReadOnlySpan)) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryNext(int index, out bool hasNext) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryPushFunction(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryPushRef(CheatEngine.SDK.Lua.References.LuaRef! reference) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryPushString(System.ReadOnlySpan utf8) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryRawSet(int tableIndex) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryReadInteger(int index, out long value) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryReadNumber(int index, out double value) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryReadString(int index, out string? value) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryReadUtf8(int index, out System.ReadOnlySpan utf8) -> bool +CheatEngine.SDK.Lua.State.LuaState.TrySetField(int index, System.ReadOnlySpan key) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TrySetGlobal(System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TrySetIndex(int index, long key) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TrySetTable(int index) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryToString(int index) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TypeName(CheatEngine.SDK.Lua.State.LuaType type) -> System.ReadOnlySpan +CheatEngine.SDK.Lua.State.LuaState.TypeName(int index) -> System.ReadOnlySpan +CheatEngine.SDK.Lua.State.LuaState.TypeOf(int index) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Boolean = 1 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Function = 6 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.LightUserdata = 2 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Nil = 0 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.None = -1 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Number = 3 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.String = 4 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Table = 5 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Thread = 8 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Userdata = 7 -> CheatEngine.SDK.Lua.State.LuaType +const CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailureResultCount = 2 -> int +const CheatEngine.SDK.Lua.State.LuaState.MinimumFreeSlots = 20 -> int +const CheatEngine.SDK.Lua.State.LuaState.MultipleResults = -1 -> int +const CheatEngine.SDK.Lua.State.LuaState.RegistryIndex = -1001000 -> int +override CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.GetHashCode() -> int +override CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.ToString() -> string! +override CheatEngine.SDK.Lua.Calls.LuaError.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Calls.LuaError.GetHashCode() -> int +override CheatEngine.SDK.Lua.Calls.LuaError.ToString() -> string! +override CheatEngine.SDK.Lua.Calls.LuaStatus.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Calls.LuaStatus.GetHashCode() -> int +override CheatEngine.SDK.Lua.Calls.LuaStatus.ToString() -> string! +override CheatEngine.SDK.Lua.References.LuaRef.ToString() -> string! +override CheatEngine.SDK.Lua.Runtime.LuaHostBinding.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Runtime.LuaHostBinding.GetHashCode() -> int +override CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.GetHashCode() -> int +override CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.ToString() -> string! +override CheatEngine.SDK.Lua.State.LuaState.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.State.LuaState.GetHashCode() -> int +override CheatEngine.SDK.Lua.State.LuaState.ToString() -> string! +static CheatEngine.SDK.Lua.Callbacks.LuaCallback.TryCreate(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk, TState! stateObject, out CheatEngine.SDK.Lua.Callbacks.LuaCallback? callback) -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.operator !=(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction left, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction right) -> bool +static CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.operator ==(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction left, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction right) -> bool +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(CheatEngine.SDK.Lua.State.LuaState state, System.Exception? exception) -> int +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan message) -> int +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan message) -> int +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(CheatEngine.SDK.Lua.State.LuaState state, int argument, System.ReadOnlySpan expected) -> int +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.TryGetState(CheatEngine.SDK.Lua.State.LuaState state, out TState? value) -> bool +static CheatEngine.SDK.Lua.Calls.LuaError.FromStack(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Calls.LuaStatus status) -> CheatEngine.SDK.Lua.Calls.LuaError +static CheatEngine.SDK.Lua.Calls.LuaError.operator !=(CheatEngine.SDK.Lua.Calls.LuaError left, CheatEngine.SDK.Lua.Calls.LuaError right) -> bool +static CheatEngine.SDK.Lua.Calls.LuaError.operator ==(CheatEngine.SDK.Lua.Calls.LuaError left, CheatEngine.SDK.Lua.Calls.LuaError right) -> bool +static CheatEngine.SDK.Lua.Calls.LuaException.Throw(CheatEngine.SDK.Lua.Calls.LuaError error) -> void +static CheatEngine.SDK.Lua.Calls.LuaException.ThrowFromStack(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Calls.LuaStatus status) -> void +static CheatEngine.SDK.Lua.Calls.LuaStatus.FileError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.GcMetamethodError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.MemoryError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.MessageHandlerError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.Ok.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.RuntimeError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.SyntaxError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.Yield.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.operator !=(CheatEngine.SDK.Lua.Calls.LuaStatus left, CheatEngine.SDK.Lua.Calls.LuaStatus right) -> bool +static CheatEngine.SDK.Lua.Calls.LuaStatus.operator ==(CheatEngine.SDK.Lua.Calls.LuaStatus left, CheatEngine.SDK.Lua.Calls.LuaStatus right) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(CheatEngine.SDK.Lua.State.LuaState state, int top) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(CheatEngine.SDK.Lua.State.LuaState state, int top, out TResult result) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(CheatEngine.SDK.Lua.State.LuaState state, int top, CheatEngine.SDK.Lua.Calls.LuaStatus status) -> void +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnexpectedResult(CheatEngine.SDK.Lua.State.LuaState state, int top, int index, string! globalName, string! expected) -> void +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(CheatEngine.SDK.Lua.State.LuaState state, int top, string! globalName) -> void +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.References.LuaRef! cache, System.ReadOnlySpan name) -> bool +static CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, nuint value) -> void +static CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out nuint value) -> bool +static CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, bool value) -> void +static CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out bool value) -> bool +static CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, double value) -> void +static CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out double value) -> bool +static CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, int value) -> void +static CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out int value) -> bool +static CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, long value) -> void +static CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out long value) -> bool +static CheatEngine.SDK.Lua.Marshalling.SingleMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, float value) -> void +static CheatEngine.SDK.Lua.Marshalling.SingleMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out float value) -> bool +static CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, string? value) -> void +static CheatEngine.SDK.Lua.Marshalling.StringMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out string! value) -> bool +static CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan value) -> void +static CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out System.ReadOnlySpan value) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaHostBinding.operator !=(CheatEngine.SDK.Lua.Runtime.LuaHostBinding left, CheatEngine.SDK.Lua.Runtime.LuaHostBinding right) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaHostBinding.operator ==(CheatEngine.SDK.Lua.Runtime.LuaHostBinding left, CheatEngine.SDK.Lua.Runtime.LuaHostBinding right) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation() -> CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(CheatEngine.SDK.Lua.State.LuaState state) -> CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState() -> CheatEngine.SDK.Lua.State.LuaState +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.Attach(in CheatEngine.SDK.Lua.Runtime.LuaHostBinding binding) -> void +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.CurrentBinding.get -> CheatEngine.SDK.Lua.Runtime.LuaHostBinding +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.CurrentStateIdentity.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.Detach() -> void +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.Epoch.get -> int +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.IsAttached.get -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.IsMainThread.get -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.PushHostObject(CheatEngine.SDK.Lua.State.LuaState state, nint nativeObject) -> void +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.StateGeneration.get -> int +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryAcquireOperation(out CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryAcquireState(out CheatEngine.SDK.Lua.State.LuaState state) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.operator !=(CheatEngine.SDK.Lua.Runtime.LuaStateIdentity left, CheatEngine.SDK.Lua.Runtime.LuaStateIdentity right) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.operator ==(CheatEngine.SDK.Lua.Runtime.LuaStateIdentity left, CheatEngine.SDK.Lua.Runtime.LuaStateIdentity right) -> bool +static CheatEngine.SDK.Lua.State.LuaState.operator !=(CheatEngine.SDK.Lua.State.LuaState left, CheatEngine.SDK.Lua.State.LuaState right) -> bool +static CheatEngine.SDK.Lua.State.LuaState.operator ==(CheatEngine.SDK.Lua.State.LuaState left, CheatEngine.SDK.Lua.State.LuaState right) -> bool diff --git a/libs/CheatEngine.SDK.Lua/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Lua/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..f19f8240 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/PublicAPI.Unshipped.txt @@ -0,0 +1,170 @@ +#nullable enable +CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Equals(CheatEngine.SDK.Lua.Calls.LuaOperationStatus other) -> bool +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.IsSuccess.get -> bool +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Kind.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.LuaOperationStatus() -> void +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.GlobalUnavailable = 2 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.InvalidResult = 5 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.LuaFailure = 3 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.MissingResult = 7 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.NilResult = 4 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.ResultCapacityExceeded = 8 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.StackUnavailable = 6 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.Success = 1 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.Unknown = 0 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.IsSuccess.get -> bool +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.LuaGlobalPushOutcome() -> void +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.Status.get -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.ToOperationStatus() -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus.LuaFailure = 3 -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus.Success = 1 -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus.Unavailable = 2 -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus.Unknown = 0 -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.Marshalling.LuaOptional +CheatEngine.SDK.Lua.Marshalling.LuaOptional +CheatEngine.SDK.Lua.Marshalling.LuaOptional.Equals(CheatEngine.SDK.Lua.Marshalling.LuaOptional other) -> bool +CheatEngine.SDK.Lua.Marshalling.LuaOptional.HasValue.get -> bool +CheatEngine.SDK.Lua.Marshalling.LuaOptional.IsNil.get -> bool +CheatEngine.SDK.Lua.Marshalling.LuaOptional.IsOmitted.get -> bool +CheatEngine.SDK.Lua.Marshalling.LuaOptional.LuaOptional() -> void +CheatEngine.SDK.Lua.Marshalling.LuaOptional.TryGetValue(out T value) -> bool +CheatEngine.SDK.Lua.Marshalling.LuaOptional.Value.get -> T +CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy +CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting = 0 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy +CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.ReplaceExisting = 1 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry.Function.get -> CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry.LuaRegistrationEntry() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry.LuaRegistrationEntry(string! name, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction function) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry.Name.get -> string! +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Deconstruct(out string! Name, out CheatEngine.SDK.Lua.Calls.LuaStatus LuaStatus) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Equals(CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure other) -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.LuaRegistrationFailure() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.LuaRegistrationFailure(string! Name, CheatEngine.SDK.Lua.Calls.LuaStatus LuaStatus) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.LuaStatus.init -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Name.get -> string! +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Name.init -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.Dispose() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.Identity.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.IsDisposed.get -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.LastReleaseOutcome.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.ReleaseWithOutcome() -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.ReleaseWithOutcome(CheatEngine.SDK.Lua.State.LuaState state) -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Deconstruct(out string! Name, out CheatEngine.SDK.Lua.Calls.LuaStatus LuaStatus) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Equals(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure other) -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.LuaRegistrationReleaseFailure() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.LuaRegistrationReleaseFailure(string! Name, CheatEngine.SDK.Lua.Calls.LuaStatus LuaStatus) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.LuaStatus.init -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Name.get -> string! +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Name.init -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.AlreadyReleased = 4 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.NotAttempted = 0 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.PartiallyReleased = 2 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Released = 1 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Stale = 3 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.Equals(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome other) -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.Failures.get -> System.Collections.Generic.IReadOnlyList! +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.IsComplete.get -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.Kind.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.LuaRegistrationReleaseOutcome() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.RemainingCount.get -> int +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.RemovedCount.get -> int +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.ReplacementCount.get -> int +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.RestoredCount.get -> int +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Equals(CheatEngine.SDK.Lua.Registration.LuaRegistrationResult other) -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Failure.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure? +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.IsSuccess.get -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Kind.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Lease.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationLease? +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.LuaRegistrationResult() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Rollback.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Collision = 2 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.PreflightFailed = 3 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.PublicationFailed = 4 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Succeeded = 1 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Unspecified = 0 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationSet +CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Admitted = 1 -> CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Detached = 2 -> CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.ExternalStateReset = 6 -> CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.NoStateForThread = 4 -> CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.ThreadNotAdmitted = 5 -> CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.TransitionInProgress = 3 -> CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus.Unknown = 0 -> CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +CheatEngine.SDK.Lua.Runtime.LuaThreadAdmission +CheatEngine.SDK.Lua.Runtime.LuaThreadAdmission.MainThreadOnly = 0 -> CheatEngine.SDK.Lua.Runtime.LuaThreadAdmission +CheatEngine.SDK.Lua.Runtime.LuaThreadAdmission.WorkerThreads = 1 -> CheatEngine.SDK.Lua.Runtime.LuaThreadAdmission +CheatEngine.SDK.Lua.State.LuaState.TryPushGeneratedFunction(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk, CheatEngine.SDK.Lua.Runtime.LuaStateIdentity identity, bool requiresAttachedRuntime) -> CheatEngine.SDK.Lua.Calls.LuaStatus +[CESDK5001]static CheatEngine.SDK.Lua.Runtime.LuaRuntime.AdmitWorkerThreads() -> void +override CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Calls.LuaOperationStatus.GetHashCode() -> int +override CheatEngine.SDK.Lua.Marshalling.LuaOptional.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Marshalling.LuaOptional.GetHashCode() -> int +override CheatEngine.SDK.Lua.Marshalling.LuaOptional.ToString() -> string! +override CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.GetHashCode() -> int +override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.GetHashCode() -> int +override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.GetHashCode() -> int +override CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.GetHashCode() -> int +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.GlobalUnavailable.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.InvalidResult.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.LuaFailure(CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.MissingResult.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.NilResult.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.ResultCapacityExceeded.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.StackUnavailable.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Success.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.operator !=(CheatEngine.SDK.Lua.Calls.LuaOperationStatus left, CheatEngine.SDK.Lua.Calls.LuaOperationStatus right) -> bool +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.operator ==(CheatEngine.SDK.Lua.Calls.LuaOperationStatus left, CheatEngine.SDK.Lua.Calls.LuaOperationStatus right) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(CheatEngine.SDK.Lua.State.LuaState state, int top, CheatEngine.SDK.Lua.Calls.LuaOperationStatus status) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(CheatEngine.SDK.Lua.State.LuaState state, int top, CheatEngine.SDK.Lua.Calls.LuaOperationStatus status, out TResult result) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.PushOptional(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Marshalling.LuaOptional value) -> void +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ReadResults(CheatEngine.SDK.Lua.State.LuaState state, int firstIndex, System.Span destination, out int count) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.TryReadOptional(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Lua.Marshalling.LuaOptional value) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPushWithOutcome(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.References.LuaRef! cache, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPushWithStatus(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.References.LuaRef! cache, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.LuaFailure(CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus) -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.Success.get -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.Unavailable.get -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +static CheatEngine.SDK.Lua.Marshalling.LuaOptional.Nil() -> CheatEngine.SDK.Lua.Marshalling.LuaOptional +static CheatEngine.SDK.Lua.Marshalling.LuaOptional.Of(T value) -> CheatEngine.SDK.Lua.Marshalling.LuaOptional +static CheatEngine.SDK.Lua.Marshalling.LuaOptional.Omitted() -> CheatEngine.SDK.Lua.Marshalling.LuaOptional +static CheatEngine.SDK.Lua.Marshalling.LuaOptional.operator !=(CheatEngine.SDK.Lua.Marshalling.LuaOptional left, CheatEngine.SDK.Lua.Marshalling.LuaOptional right) -> bool +static CheatEngine.SDK.Lua.Marshalling.LuaOptional.operator ==(CheatEngine.SDK.Lua.Marshalling.LuaOptional left, CheatEngine.SDK.Lua.Marshalling.LuaOptional right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.operator !=(CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure left, CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.operator ==(CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure left, CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.operator !=(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure left, CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.operator ==(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure left, CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.operator !=(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome left, CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.operator ==(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome left, CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.operator !=(CheatEngine.SDK.Lua.Registration.LuaRegistrationResult left, CheatEngine.SDK.Lua.Registration.LuaRegistrationResult right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.operator ==(CheatEngine.SDK.Lua.Registration.LuaRegistrationResult left, CheatEngine.SDK.Lua.Registration.LuaRegistrationResult right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationSet.Register(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan entries, CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy collisionPolicy = CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting) -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResult +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.AdmitMainThreadOnly() -> void +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.ExternalStateResetDetected.get -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.ThreadAdmission.get -> CheatEngine.SDK.Lua.Runtime.LuaThreadAdmission +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryAcquireOperationWithOutcome(out CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation) -> CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryPushGeneratedFunction(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk) -> CheatEngine.SDK.Lua.Calls.LuaStatus +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Equals(object obj) -> bool +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.ToString() -> string +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Equals(object obj) -> bool +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.ToString() -> string +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.ToString() -> string +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Equals(object obj) -> bool +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.ToString() -> string diff --git a/libs/CheatEngine.SDK.Lua/README.md b/libs/CheatEngine.SDK.Lua/README.md index 55c867fa..3f5df16a 100644 --- a/libs/CheatEngine.SDK.Lua/README.md +++ b/libs/CheatEngine.SDK.Lua/README.md @@ -18,16 +18,16 @@ balanced, and keeps the hot paths free of allocations. ## How it works -| Namespace | Types | Role | -|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| -| `CheatEngine.SDK.Lua.State` | `LuaState`, `LuaFrame`, `LuaType` | Borrowed view of a Lua state, stack guard, type tags | -| `CheatEngine.SDK.Lua.Runtime` | `LuaRuntime`, `LuaRuntimeOperation`, `LuaHostBinding`, `LuaStateIdentity` | The host binding, lifecycle admission, attachment epoch and state generation | -| `CheatEngine.SDK.Lua.Calls` | `LuaStatus`, `LuaError`, `LuaException`, `LuaComparison` | Results of protected operations, opt-in exceptions | -| `CheatEngine.SDK.Lua.Marshalling` | `ILuaMarshaller`, `Int32Marshaller`, `Int64Marshaller`, `SingleMarshaller`, `DoubleMarshaller`, `BooleanMarshaller`, `AddressMarshaller`, `Utf8Marshaller`, `StringMarshaller` | Push and read one managed type each, both by interface and static contract | -| `CheatEngine.SDK.Lua.References` | `LuaRef` | Registry reference stamped with attachment epoch and state generation | -| `CheatEngine.SDK.Lua.Callbacks` | `LuaNativeFunction`, `LuaCallback`, `LuaCallback`, `LuaThunk` | Managed functions that Lua can call | -| `CheatEngine.SDK.Lua.CompilerServices` | `LuaGlobalFunctions`, `LuaCallSupport` | Called by generated code, hidden from IntelliSense | -| `CheatEngine.SDK.Lua.Registration` | `LuaRegistrationSet`, `LuaRegistrationLease`, `LuaRegistrationResult` | Ownership-aware generated-global publication and cleanup outcomes | +| Namespace | Types | Role | +|----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| `CheatEngine.SDK.Lua.State` | `LuaState`, `LuaFrame`, `LuaType` | Borrowed view of a Lua state, stack guard, type tags | +| `CheatEngine.SDK.Lua.Runtime` | `LuaRuntime`, `LuaRuntimeOperation`, `LuaHostBinding`, `LuaStateIdentity` | The host binding, lifecycle admission, attachment epoch and state generation | +| `CheatEngine.SDK.Lua.Calls` | `LuaStatus`, `LuaError`, `LuaException`, `LuaComparison`, `LuaOperationStatus`, `LuaOperationStatusKind` | Results of protected operations and generated calls, opt-in exceptions | +| `CheatEngine.SDK.Lua.Marshalling` | `ILuaMarshaller`, `Int32Marshaller`, `Int64Marshaller`, `SingleMarshaller`, `DoubleMarshaller`, `BooleanMarshaller`, `AddressMarshaller`, `Utf8Marshaller`, `StringMarshaller`, `LuaOptional`, `LuaOptional` | Push and read one managed type each, both by interface and static contract; optional values | +| `CheatEngine.SDK.Lua.References` | `LuaRef` | Registry reference stamped with attachment epoch and state generation | +| `CheatEngine.SDK.Lua.Callbacks` | `LuaNativeFunction`, `LuaCallback`, `LuaCallback`, `LuaThunk` | Managed functions that Lua can call | +| `CheatEngine.SDK.Lua.CompilerServices` | `LuaGlobalFunctions`, `LuaCallSupport` | Called by generated code, hidden from IntelliSense | +| `CheatEngine.SDK.Lua.Registration` | `LuaRegistrationSet`, `LuaRegistrationLease`, `LuaRegistrationResult` | Ownership-aware generated-global publication and cleanup outcomes | `LuaState` is a pointer-sized `readonly struct` over a borrowed `lua_State*`. Raw members make one or two C calls and never run Lua code. Protected members (`TryCall`, `TryLoad`, `TryExecute`, `TryGetGlobal`, `TryGetField`, `TryLength`, @@ -85,12 +85,46 @@ coroutine through lua_newthread and roots it in the main registry. That coroutin shares the main virtual machine, heap and registry. A different worker pointer therefore does not establish an independent Lua heap or a safe concurrent-execution policy. -Cheat Engine's `resetLuaState` must not be called outside the SDK-owned reset protocol. An external, unnotified reset is -unsupported: the SDK cannot safely infer whether the old registry, callbacks, CE userdata or thread-local state still -exist, so it deliberately does not attempt best-effort cleanup against a potentially replacement state. The -deterministic -fixture tests below prove the managed invalidation ordering only; CE 7.7 reset/thread/userdata behavior remains subject -to the opt-in live probe. +### Threading and admission (ADR-07) + +`LuaRuntime`'s lifecycle admission (`AcquireOperation()` and its family) is not a process-wide Lua mutex and never the +shared Lua heap: it serializes only this SDK copy's own attach/reset/detach transitions. Two plugins, or two SDK +copies, are never serialized against each other by this SDK. The 2.0 default (`LuaThreadAdmission.MainThreadOnly`) +therefore refuses a worker thread's `AcquireOperation()`-family call — with `LuaAdmissionStatus.ThreadNotAdmitted` — +**before** the host's state provider runs, unless the calling thread is the captured main thread, the call is nested +inside an already-admitted operation or host-invoked callback, or it is the single documented default exception, the +worker-side `synchronize` hand-off behind `MainThreadDispatcher`/`MainThread.Invoke`. `LuaRuntime.AdmitWorkerThreads()` +(`[Experimental("CESDK5001")]`) opts a worker thread in until the next `Attach`/`Detach`; it is unqualified until Q19 +passes at C3 **and** C4 — no local Cheat Engine qualification is currently available for either level. See +[`CheatEngine.SDK.Hosting`'s README](../CheatEngine.SDK.Hosting/README.md#threading-and-lua-concurrency-contract-adr-07) +for the complete written contract (pluginCS, `processMessages` re-entrancy, the "not qualified" list). + +### Lua state replacement (2.0 decision) + +There is no public SDK reset API in 2.0: `BeginStateReset`/`CompleteStateReset` stay internal, because an SDK-owned +reset triggered by one plugin cannot neutralize another plugin's, or another SDK copy's, owners of the same shared Lua +universe. A supported replacement still goes only through that internal protocol: it closes admission, drains every +`LuaRuntimeOperation`, neutralizes rooted callbacks while the old state is reachable, then advances only the state +generation. + +Calling Cheat Engine's `resetLuaState` outside that protocol remains unsupported, but it is now **detected** instead of +silently accepted. Every attachment stamps a private-registry marker (a light userdata keyed by a type-associated +address unique to this SDK copy) on the first admitted provider acquisition, and re-checks that raw, allocation-free +stamp on every later one. A mismatch or absence is deterministic detection of an external reset: +`LuaRuntime.ExternalStateResetDetected` becomes `true`, a `LuaStateReplacedExternally:` diagnostic is reported once, +the state generation advances so every `LuaRef` and generated global cache from before the reset reads as stale, and +**every** admission path (provider acquisition, a supplied-state operation, callback admission, the `synchronize` +hand-off) refuses with `LuaAdmissionStatus.ExternalStateReset` until the next `Attach`. `Detach` re-checks the stamp on +the state it would otherwise use, and — when a reset is detected — abandons every live callback and host subscription +instead of unregistering into the replacement registry: nothing is ever released by number into a registry this SDK +copy never created (A08-22). The managed state of an abandoned callback or subscription is kept alive, which leaks but +cannot crash. + +This is deterministic detection, never a guess at whether an old registry slot or callback closure remains valid: the +SDK does not attempt best-effort cleanup against a state it did not stamp. C1/C2 fixture tests below prove the managed +invalidation and abandonment ordering only; CE 7.7's own `resetLuaState` behavior (whether the main thread's +`GetLuaState` keeps returning the old state after a reset, for example) is unqualified pending a future host-based +validation run — no local Cheat Engine qualification is currently available. SDK-012 exercises this ordering with a deterministic native fixture: a worker first has no provider state, then receives a rooted coroutine with a pointer distinct from the main state; it can read the shared global and private-registry @@ -142,9 +176,10 @@ static class MemoryReads Generated bodies restore the stack in `finally`, including a managed exception from a marshaller. A body also handles: the global is unresolved, protected global lookup itself failed, the call raised, or the result is `nil` or of the wrong -type. A `Try*` form returns `false` through `LuaCallSupport.Fail`. A throwing form calls `ThrowUnresolvedGlobal`, -`Throw` -or `ThrowUnexpectedResult`, which restore the stack and throw `LuaException`. +type. A `Try*` form returns `false` through `LuaCallSupport.Fail`; an Outcome form returns the matching +`LuaOperationStatus`. A throwing form calls `ThrowUnresolvedGlobal`, `Throw` or `ThrowUnexpectedResult`, which +restore the stack and throw `LuaException`. A binding with an optional or variadic result calls with +`LuaState.MultipleResults` and reads the factual number of values instead of padding them with `nil`. ### String results @@ -152,6 +187,36 @@ A generated body pops its results before it returns, so a `ReadOnlySpan` r are argument-only. A string result is copied into a caller `Span` with `TryCopyUtf8`, or decoded into a `string` with `StringMarshaller`, which allocates. +### Integer marshalling policy + +`Int64Marshaller`, `Int32Marshaller` and `AddressMarshaller` never let a value reach a 64-bit integer through a lossy +`double` (audit A07-02, qualification Q21). Reading: + +- an integer subtype is exact: every bit is kept, including an address above 4 GiB or above `long.MaxValue`; +- a float is accepted only when it is integral and of magnitude below 2^53. From 2^53 on, a `double` no longer tells + neighbouring integers apart, so `9007199254740992.0` and `1e19` are refused rather than rounded; +- a string is accepted by `Int64Marshaller` and `Int32Marshaller` only when its bytes are a Lua integer numeral that + fits 64 bits: optional spaces and sign, then decimal digits or `0x` and at most 16 significant hexadecimal digits. + Float numerals (`"3.0"`, `"1e3"`, `"0x1p4"`), out-of-range decimals and embedded NULs are refused. `AddressMarshaller` + refuses every string, because Cheat Engine's hexadecimal address text is decoded one layer up; +- `Int32Marshaller` then requires the signed 32-bit range: `4294967295` is refused, not reinterpreted as `-1`. + +This is stricter than Lua's own `lua_tointegerx`, which `LuaState.TryReadInteger` still exposes unchanged. The +integer-subtype path costs two C API calls and allocates nothing; floats and strings take a cold path. + +### Optional values and call status + +`LuaOptional` tells three Lua situations apart: an omitted value (`default`, `IsOmitted`), an explicit `nil` +(`LuaOptional.Nil()`, `IsNil`) and a value (`LuaOptional.Of(value)`, `HasValue`). `Value` throws unless a value is +present; the factories allocate nothing for value types. Generated code pushes one with `LuaCallSupport.PushOptional`, +which refuses an omitted value, and reads one with `LuaCallSupport.TryReadOptional`, which reads an index above the top +as omitted. + +`LuaOperationStatusKind` and `LuaGlobalPushStatus` start at `Unknown = 0`, so `default(LuaOperationStatus)` never reads +as success. Two kinds report the factual result count of a call made with `LuaState.MultipleResults`: +`MissingResult` (the global returned fewer values than the binding requires) and `ResultCapacityExceeded` (a variadic +tail does not fit the caller's span; `LuaCallSupport.ReadResults` then reports the needed count and copies nothing). + ### Custom marshalling Generated bindings select a concrete marshaller for every value by type: scalar types use an SDK marshaller directly, @@ -178,6 +243,12 @@ binding parameter or result is evaluated only once per method symbol at generati early return, exception and failed call (`LuaFrameTests`). - Hot paths allocate nothing: scalar pushes and reads, protected calls, callbacks and the generated call shape (`ZeroAllocationTests`, `ReadIntegerBindingTests`, `StringBindingTests`). +- Integer and address reads keep every bit or refuse: a float at or above 2^53, a float numeral and an + out-of-range value are never rounded (`MarshallerRoundTripTests`, qualification Q21). +- `LuaOptional` keeps omitted, `nil` and a value distinct, and its factories and the optional and variadic + helpers allocate nothing (`LuaOptionalTests`, `LuaCallSupportOptionalTests`). +- A default status is `Unknown`, never success, and every status value is pinned (`LuaOperationStatusTests`, + `LuaGlobalPushOutcomeTests`). - Stale references are detected. A `LuaRef` from an earlier attachment epoch or state generation is never pushed or released into the current registry (`References_are_invalidated_by_detach_and_reattach`, `A_reference_from_the_pre_reset_state_never_releases_a_current_generation_slot`). @@ -188,6 +259,12 @@ binding parameter or result is evaluated only once per method symbol at generati (`Helpers_survive_a_script_that_redefines_error_and_tostring`). - A detached or transitioning runtime fails cleanly: `AcquireOperation` refuses to hand out a state, and the transition waits for admitted work before it invalidates the old Lua universe (`LuaRuntimeTests`). +- A worker thread is refused before the state provider ever runs, under the 2.0 conservative default; the opt-in + `[Experimental("CESDK5001")]` `AdmitWorkerThreads()` and a host-invoked nested callback are the only ways past it, + and the admission check itself allocates nothing (`LuaThreadAdmissionTests`, `ZeroAllocationTests`). +- An external Lua-state reset is detected deterministically: the stamp check is raw and allocation-free, old owners are + refused, nothing is released into the replacement registry, and the diagnostic is reported exactly once per + attachment (`LuaExternalResetDetectionTests`). ## Run the tests diff --git a/libs/CheatEngine.SDK.Lua/References/LuaRef.cs b/libs/CheatEngine.SDK.Lua/References/LuaRef.cs index c873039e..e8b04e94 100644 --- a/libs/CheatEngine.SDK.Lua/References/LuaRef.cs +++ b/libs/CheatEngine.SDK.Lua/References/LuaRef.cs @@ -162,8 +162,8 @@ public void Release(LuaState state) { LuaRefBinding? binding = Interlocked.Exchange(ref _binding, null); if (binding is not null && binding.Reference != NoReference && - binding.Identity == LuaRuntime.CurrentStateIdentity && - !state.IsNull) + binding.Identity == LuaRuntime.CurrentStateIdentity && + !state.IsNull) { LuaReferences.Release(state, binding.Reference); } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs index 03750711..8ee670fb 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs @@ -35,7 +35,7 @@ public static LuaRegistrationResult Register(LuaState state, ReadOnlySpanThe factual reason did, or did not, admit an operation. +/// +/// The zero value is ; a default value never reads as admitted. Callers must switch on this +/// enum instead of parsing an exception message: throws a +/// reason-specific message built from the same categories, but the category itself is never derived from text. +/// +public enum LuaAdmissionStatus +{ + /// No outcome has been observed. + Unknown = 0, + + /// The operation was admitted; the host provided a Lua state for the calling thread. + Admitted = 1, + + /// No host binding is attached: the plugin is not enabled. + Detached = 2, + + /// A lifecycle transition (attach, detach or state reset) has closed admission and is draining. + TransitionInProgress = 3, + + /// A binding is attached and admission is open, but the host returned no Lua state for this thread. + NoStateForThread = 4, + + /// + /// The calling thread is not the host's captured main thread, is not inside a host-invoked callback, and the + /// conservative default () refused it before the state provider + /// ran. See . + /// + ThreadNotAdmitted = 5, + + /// + /// An external reset of the host's Lua state was detected: this SDK copy's universe stamp no longer matches. Every + /// admission path refuses until the next . + /// + ExternalStateReset = 6 +} diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaHostBinding.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaHostBinding.cs index faf0bfff..318baa78 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaHostBinding.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaHostBinding.cs @@ -113,7 +113,7 @@ public int MainThreadId public bool Equals(LuaHostBinding other) { return StateProvider == other.StateProvider && HostObjectPusher == other.HostObjectPusher && - MainThreadId == other.MainThreadId; + MainThreadId == other.MainThreadId; } /// diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs index 3de7baa4..9ed0d4a7 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs @@ -20,9 +20,10 @@ namespace CheatEngine.SDK.Lua.Runtime; /// -/// The ambient binding between this SDK copy and its host: one per assembly load context (one per plugin), owned by -/// this assembly so that CheatEngine.SDK.Engine and generated code can reach the host's Lua state without -/// referencing CheatEngine.SDK.Hosting or the ABI. +/// The ambient binding between this SDK copy and its host: one per loaded CheatEngine.SDK.Lua assembly +/// instance, owned by this assembly so that CheatEngine.SDK.Engine and generated code can reach the host's Lua +/// state without referencing CheatEngine.SDK.Hosting or the ABI. Whether that equals one per plugin is decided +/// by the host loader profile, not assumed here (Q09: C2 host-emulator facts, C4 receipts). /// /// /// @@ -58,10 +59,32 @@ namespace CheatEngine.SDK.Lua.Runtime; /// policy from pointer inequality nor claims a live concurrency qualification. /// /// -/// State replacement. A reset must first call the SDK-controlled preparation path while the old state is -/// reachable. That path neutralizes rooted callbacks and invalidates state-bound resources before the host -/// replaces the state. Calling Cheat Engine's resetLuaState outside that path is unsupported: this SDK -/// intentionally does not guess whether an old registry slot or callback closure remains valid. +/// Threading and Lua concurrency contract (ADR-07, unqualified pending a future host-based validation run; no +/// local CE qualification is currently available). The 2.0 default is : +/// a new -family call is refused with +/// before the host's state provider ever runs, unless the calling thread is the host's captured main thread, the +/// call is nested inside a Lua operation or callback already admitted on that thread, or it is the single +/// documented default exception: the worker-side synchronize hand-off behind +/// CheatEngine.SDK.Hosting.Threading.MainThread.Invoke. A +/// plugin opts a worker thread in with the experimental +/// (gate id CESDK5001), unqualified until Q19 passes at C3 and C4. Admission protects only +/// this SDK copy's attach/reset/detach transitions, never the shared Lua heap: two plugins, or two SDK copies, are +/// never serialized by the SDK (A05-07, A08-05, F04). See CheatEngine.SDK.Hosting's README, section +/// "Threading and Lua concurrency contract (ADR-07)", for the complete written contract, including the pluginCS +/// hazard, processMessages re-entrancy, and the "not qualified" list. +/// +/// +/// State replacement (2.0 decision). A reset must first call the SDK-controlled preparation path +/// () while the old state is reachable. That path neutralizes rooted callbacks and +/// invalidates state-bound resources before the host replaces the state. Calling Cheat Engine's +/// resetLuaState outside that path remains unsupported, but is no longer silently accepted: the runtime +/// stamps every attachment with a private registry marker and re-checks it on every admitted provider +/// acquisition. A mismatch is deterministic detection of an external reset — never a guess at whether an +/// old registry slot or callback closure remains valid: becomes +/// , every admission path refuses with +/// until the next , old owners are refused deterministically, and nothing is ever released +/// into the replacement registry (A08-22). There is no public reset API in 2.0 (O2): an SDK-owned reset in one +/// plugin cannot neutralize another plugin's owners of the same shared Lua universe. /// /// public static unsafe class LuaRuntime @@ -80,6 +103,22 @@ public static unsafe class LuaRuntime // from different lifecycle transitions. private static long s_identity; + // 2.0 conservative-by-default worker-thread admission policy (F04, ADR-07). Reset to MainThreadOnly by every Attach + // and Detach, and by the first external-reset detection, so every enable starts conservative. + private static int s_threadAdmission; + + // The private-registry universe stamp (WI-3 / A08-21, A08-22, A20-Q17-3, A20-Q18-2). Key is unique per SDK copy and + // never moves; s_stamped guards the one-time write per attachment so later admissions only verify it. + private static readonly nint s_stampKey = RuntimeHelpers.AllocateTypeAssociatedMemory(typeof(LuaRuntime), 1); + private static int s_stamped; + private static int s_externalResetDetected; + + // "Raised at most once per attachment" gates for the one-shot diagnostics (A24 category tokens), reset by Attach. + private static int s_workerThreadRefusedRaised; + + // Set by CheatEngine.SDK.Hosting while attached; cleared in its cleanup. Invoked outside every runtime lock. + internal static Action? DiagnosticObserver; + // A transition owner is allowed to release callbacks and references after admission has closed. An active Lua // operation is never allowed to start a transition: doing so would wait for itself and deadlock. [ThreadStatic] private static int t_operationDepth; @@ -90,6 +129,9 @@ public static unsafe class LuaRuntime // closed and before the drain wait, outside every runtime lock. internal static Action? OperationAdmissionClosedForTesting; + /// The universe stamp's private-registry key. For tests only (distinctness from other private keys). + internal static nint StampKeyForTests => s_stampKey; + /// Gets a value indicating whether a host binding is attached. Lock-free; any thread. public static bool IsAttached => Read(ref s_services) is not null; @@ -148,6 +190,27 @@ public static bool IsMainThread /// Gets the attached binding, or while detached. public static LuaHostBinding CurrentBinding => Read(ref s_services)?.Binding ?? default; + /// + /// Gets the current worker-thread admission policy. Reading this is harmless and never gated; only + /// is [Experimental]. Lock-free; any thread. + /// + public static LuaThreadAdmission ThreadAdmission + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => (LuaThreadAdmission) Read(ref s_threadAdmission); + } + + /// + /// Gets a value indicating whether this SDK copy detected that the host replaced its Lua state outside the + /// SDK-controlled reset path (). Sticky until the next . + /// Lock-free; any thread. + /// + public static bool ExternalStateResetDetected + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Read(ref s_externalResetDetected) != 0; + } + /// /// Acquires a Lua state together with a lifecycle admission that spans the whole synchronous operation. /// @@ -164,34 +227,27 @@ public static bool IsMainThread [RequiresPluginEnabled] public static LuaRuntimeOperation AcquireOperation() { - if (TryEnterProviderOperation(out LuaState state) == LuaCallbackDisposeOperationResult.Acquired) + LuaAdmissionStatus status = TryEnterProviderOperationWithOutcome(out LuaState state, false); + if (status == LuaAdmissionStatus.Admitted) { return new LuaRuntimeOperation(state, true); } - if (Read(ref s_services) is null) - { - ThrowDetached(); - } - - if (!IsOperationAdmissionOpen()) - { - ThrowOperationAdmissionClosed(); - } - - throw new InvalidOperationException("The host returned no Lua state for the calling thread."); + ThrowForAdmissionStatus(status); + return default; } /// Non-throwing . /// The admitted operation on success; default otherwise. /// - /// when a binding is attached, admission is open and the host provided a state for the - /// calling thread. + /// when a binding is attached, admission is open, the calling thread is admitted and the + /// host provided a state for it. /// [RequiresPluginEnabled] public static bool TryAcquireOperation(out LuaRuntimeOperation operation) { - if (TryEnterProviderOperation(out LuaState state) == LuaCallbackDisposeOperationResult.Acquired) + LuaAdmissionStatus status = TryEnterProviderOperationWithOutcome(out LuaState state, false); + if (status == LuaAdmissionStatus.Admitted) { operation = new LuaRuntimeOperation(state, true); return true; @@ -202,23 +258,84 @@ public static bool TryAcquireOperation(out LuaRuntimeOperation operation) } /// - /// Acquires an operation for and reports whether an attached lifecycle - /// transition, rather than an unavailable state, rejected it. + /// Non-throwing that reports the factual admission reason instead of a boolean. + /// + /// The admitted operation when the result is ; default otherwise. + /// The factual admission outcome. Never derive a reason from an exception message instead of this enum. + [RequiresPluginEnabled] + public static LuaAdmissionStatus TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation) + { + LuaAdmissionStatus status = TryEnterProviderOperationWithOutcome(out LuaState state, false); + operation = status == LuaAdmissionStatus.Admitted ? new LuaRuntimeOperation(state, true) : default; + return status; + } + + /// + /// Acquires an operation for and reports the transition or admission reason + /// that refused it, rather than a raw unavailable state. /// /// /// The result is selected while SOperationGate is held. In particular, an /// result cannot be reinterpreted as detached /// after an unsuccessful transition reopens admission: callback disposal must leave registry ownership with that - /// transition until a state has neutralized the Lua closure. + /// transition until a state has neutralized the Lua closure. + /// and are treated the same way by callers: + /// never abandon the closure early from a thread or a universe the SDK does not currently trust. /// internal static LuaCallbackDisposeOperationResult TryAcquireOperationForCallbackDispose( out LuaRuntimeOperation operation) { - LuaCallbackDisposeOperationResult result = TryEnterProviderOperation(out LuaState state); - operation = result == LuaCallbackDisposeOperationResult.Acquired - ? new LuaRuntimeOperation(state, true) - : default; - return result; + LuaAdmissionStatus status = TryEnterProviderOperationWithOutcome(out LuaState state, false); + switch (status) + { + case LuaAdmissionStatus.Admitted: + operation = new LuaRuntimeOperation(state, true); + return LuaCallbackDisposeOperationResult.Acquired; + case LuaAdmissionStatus.TransitionInProgress: + operation = default; + return LuaCallbackDisposeOperationResult.AdmissionClosed; + case LuaAdmissionStatus.ThreadNotAdmitted: + operation = default; + return LuaCallbackDisposeOperationResult.ThreadNotAdmitted; + case LuaAdmissionStatus.ExternalStateReset: + operation = default; + return LuaCallbackDisposeOperationResult.ExternalStateReset; + default: + operation = default; + return LuaCallbackDisposeOperationResult.Unavailable; + } + } + + /// + /// Opts the calling attachment into admitting Lua operations on worker threads that are not already running + /// admitted Lua work. Reverted to by every + /// , and external-reset detection, so call it again from + /// OnEnable every time. + /// + /// No binding is attached. + /// + /// Unqualified; ADR-07. Q19 must pass at C3 and C4 before this gate is lifted — no local CE qualification + /// is currently available, so both remain in this SDK copy today. + /// Opting in admits worker-thread -family calls; it does not serialize the + /// shared Lua heap, and it does not qualify the worker-side synchronize hand-off heap safety or + /// multi-plugin concurrency (see the Hosting README "Not qualified" list). + /// + [Experimental("CESDK5001", + UrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md")] + public static void AdmitWorkerThreads() + { + if (Read(ref s_services) is null) + { + ThrowDetached(); + } + + Write(ref s_threadAdmission, (int) LuaThreadAdmission.WorkerThreads); + } + + /// Reverts to the conservative default. + public static void AdmitMainThreadOnly() + { + Write(ref s_threadAdmission, (int) LuaThreadAdmission.MainThreadOnly); } /// @@ -249,6 +366,11 @@ public static LuaRuntimeOperation AcquireOperation(LuaState state) ThrowDetached(); } + if (Read(ref s_externalResetDetected) != 0) + { + ThrowExternalStateReset(); + } + // Only the lifecycle code itself may use a state while it has made the transition exclusive. A public // generated binding must never inherit that privilege merely because it happened to run synchronously from // host cleanup code. @@ -271,6 +393,33 @@ public static LuaRuntimeOperation AcquireOperation(LuaState state) return default; } + /// + /// The single documented default exception to : the worker-side + /// half of CE's synchronize hand-off (CheatEngine.SDK.Hosting.Threading.MainThreadDispatcher.Dispatch). + /// Admits the calling thread regardless of the current policy. + /// + /// + /// No binding is attached, the host returned no state for the calling thread, a lifecycle transition has closed + /// admission, or an external reset was detected. + /// + /// + /// This is CE's designed cross-thread primitive, fixed and SDK-owned, never arbitrary plugin Lua: one Lua global + /// read, one closure, one protected call on the worker's own coroutine. It is why MainThread.Invoke from a + /// worker keeps working unchanged under the 2.0 conservative default (Q19 C3/C4 scope includes this hand-off's + /// heap safety, not its admission). + /// + internal static LuaRuntimeOperation AcquireOperationForMainThreadDispatch() + { + LuaAdmissionStatus status = TryEnterProviderOperationWithOutcome(out LuaState state, true); + if (status == LuaAdmissionStatus.Admitted) + { + return new LuaRuntimeOperation(state, true); + } + + ThrowForAdmissionStatus(status); + return default; + } + /// /// Pushes one generated [LuaFunction] closure and captures the current attachment identity in that /// closure. Stack: +1 (the function) on success; +1 (the error value) on failure. @@ -322,20 +471,8 @@ public static void Attach(in LuaHostBinding binding) BeginTransition(); try { - LuaHostServices? previous = s_services; - if (previous is not null) - { - LuaHostSubscriptionRegistry.DetachAll(new LuaState(previous.Provider())); - LuaCallbackRegistry.DetachAll(previous); - } - - lock (LuaReferences.Gate) - { - LuaStateIdentity identity = CurrentStateIdentity; - PublishIdentity(unchecked(identity.AttachEpoch + 1), identity.StateGeneration); - Write(ref s_services, new LuaHostServices(binding)); - LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); - } + ReleasePreviousBindingForAttach(s_services); + PublishNewBindingForAttach(in binding); } finally { @@ -351,6 +488,47 @@ public static void Attach(in LuaHostBinding binding) } } + // Only called from inside Attach's SGate, while replacing an already-attached binding. + private static void ReleasePreviousBindingForAttach(LuaHostServices? previous) + { + if (previous is null) + { + return; + } + + if (DetectExternalResetForDetach(previous, out LuaState previousState)) + { + LuaHostSubscriptionRegistry.AbandonAll(); + LuaCallbackRegistry.AbandonAll(); + } + else + { + LuaHostSubscriptionRegistry.DetachAll(previousState); + LuaCallbackRegistry.DetachAll(previous); + } + } + + // Only called from inside Attach's SGate. + private static void PublishNewBindingForAttach(in LuaHostBinding binding) + { + LuaHostServices newServices = new(in binding); + lock (LuaReferences.Gate) + { + LuaStateIdentity identity = CurrentStateIdentity; + PublishIdentity(unchecked(identity.AttachEpoch + 1), identity.StateGeneration); + Write(ref s_services, newServices); + LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); + } + + // Every enable starts conservative and with a clean slate: the previous attachment's one-shot diagnostics, + // universe stamp and external-reset flag never leak into the new one. + Write(ref s_threadAdmission, (int) LuaThreadAdmission.MainThreadOnly); + Write(ref s_workerThreadRefusedRaised, 0); + Write(ref s_externalResetDetected, 0); + Write(ref s_stamped, 0); + StampIfStateAvailable(newServices); + } + /// /// Begins the exclusive SDK side of a supported Lua-state replacement. The returned transition keeps operation /// admission closed until its method completes. @@ -379,8 +557,17 @@ internal static LuaStateResetTransition BeginStateReset() BeginTransition(); try { - LuaHostSubscriptionRegistry.DetachAll(new LuaState(services.Provider())); - LuaCallbackRegistry.DetachAll(services); + if (DetectExternalResetForDetach(services, out LuaState state)) + { + LuaHostSubscriptionRegistry.AbandonAll(); + LuaCallbackRegistry.AbandonAll(); + } + else + { + LuaHostSubscriptionRegistry.DetachAll(state); + LuaCallbackRegistry.DetachAll(services); + } + lock (LuaReferences.Gate) { LuaStateIdentity identity = CurrentStateIdentity; @@ -422,9 +609,23 @@ public static void Detach() bool detachSucceeded = false; try { - LuaHostSubscriptionRegistry.DetachAll(new LuaState(services.Provider())); - LuaCallbackRegistry.DetachAll(services); + // After a detected external reset, services.Provider() would hand out the replacement VM's state: any + // unref or unregister against it would corrupt a registry this SDK copy never created (A08-22). Abandon + // makes no Lua call at all instead. + if (DetectExternalResetForDetach(services, out LuaState state)) + { + LuaHostSubscriptionRegistry.AbandonAll(); + LuaCallbackRegistry.AbandonAll(); + } + else + { + LuaHostSubscriptionRegistry.DetachAll(state); + LuaCallbackRegistry.DetachAll(services); + } + Write(ref s_services, null); + // The next enable starts conservative: a detached runtime never remembers an opt-in from before. + Write(ref s_threadAdmission, (int) LuaThreadAdmission.MainThreadOnly); detachSucceeded = true; } finally @@ -557,6 +758,12 @@ internal static LuaRuntimeOperation EnterStateOperation(LuaState state) /// internal static bool TryEnterCallbackOperation(out LuaRuntimeOperation operation) { + if (Read(ref s_externalResetDetected) != 0) + { + operation = default; + return false; + } + // A callback that is re-entered by an already admitted Lua operation shares that outer lease. The lifecycle // transition owner is different: admitting plugin code there would let a finalizer/metamethod re-enter after // CloseOperationAdmissionAndDrain has established exclusive cleanup. @@ -604,8 +811,8 @@ internal static void CloseHostSubscriptionAdmissionAndDrain() internal static bool IsGeneratedFunctionRegistrationCurrent(int attachEpoch, int stateGeneration) { return Read(ref s_services) is not null - && Read(ref s_identity) == PackIdentity(attachEpoch, stateGeneration) - && IsOperationAdmissionOpen(); + && Read(ref s_identity) == PackIdentity(attachEpoch, stateGeneration) + && IsOperationAdmissionOpen(); } /// @@ -642,9 +849,15 @@ internal static void CompleteStateReset() s_resetTransitionActive = false; EndTransition(); OpenOperationAdmission(); - if (Read(ref s_services) is not null) + LuaHostServices? services = Read(ref s_services); + if (services is not null) { LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); + + // The replacement state is a fresh registry: re-stamp it so later admissions verify against this + // generation, not the one the old universe carried. + Write(ref s_stamped, 0); + StampIfStateAvailable(services); } } } @@ -667,47 +880,219 @@ internal static void ExitOperation() } } - private static LuaCallbackDisposeOperationResult TryEnterProviderOperation(out LuaState state) + // Evaluates the 2.0 admission policy under SOperationGate, entirely before services.Provider() ever runs on a + // refused thread (pitfall 2): detached, external reset, transitioning and thread-not-admitted are all decided + // without creating a coroutine. Only then is the provider called and the universe stamp verified. + private static unsafe LuaAdmissionStatus TryEnterProviderOperationWithOutcome(out LuaState state, + bool bypassThreadAdmission) + { + LuaAdmissionStatus gateStatus = TryEnterAdmissionGate(bypassThreadAdmission, out LuaHostServices? services); + if (gateStatus != LuaAdmissionStatus.Admitted) + { + state = default; + if (gateStatus == LuaAdmissionStatus.ThreadNotAdmitted) + { + RaiseWorkerThreadRefusedOnce(); + } + + return gateStatus; + } + + try + { + lua_State* l = services!.Provider(); + if (l is null) + { + ExitOperation(); + state = default; + return LuaAdmissionStatus.NoStateForThread; + } + + LuaState acquired = new(l); + if (!TryStampOrVerify(acquired)) + { + ExitOperation(); + state = default; + ReportExternalReset(); + return LuaAdmissionStatus.ExternalStateReset; + } + + state = acquired; + return LuaAdmissionStatus.Admitted; + } + catch + { + ExitOperation(); + throw; + } + } + + // The admission decision, entirely under SOperationGate and entirely before services.Provider() ever runs + // (pitfall 2): detached, external reset, transitioning and thread-not-admitted are all decided here. Returns + // Admitted only after IncrementActiveOperation has already run; the caller must ExitOperation on every later + // failure path. + private static LuaAdmissionStatus TryEnterAdmissionGate(bool bypassThreadAdmission, + out LuaHostServices? services) { - LuaHostServices? services; lock (SOperationGate) { services = s_services; if (services is null) { - state = default; - return LuaCallbackDisposeOperationResult.Unavailable; + return LuaAdmissionStatus.Detached; + } + + if (Read(ref s_externalResetDetected) != 0) + { + return LuaAdmissionStatus.ExternalStateReset; } if (!s_acceptOperations) { - state = default; - return LuaCallbackDisposeOperationResult.AdmissionClosed; + return LuaAdmissionStatus.TransitionInProgress; + } + + if (!bypassThreadAdmission + && Read(ref s_threadAdmission) == (int) LuaThreadAdmission.MainThreadOnly + && Environment.CurrentManagedThreadId != services.MainThreadId + && t_operationDepth == 0) + { + return LuaAdmissionStatus.ThreadNotAdmitted; } IncrementActiveOperation(); + return LuaAdmissionStatus.Admitted; + } + } + + // The one-time write on the first admitted acquisition of an attachment (pitfall 6b); every later acquisition + // only verifies. Interlocked so two racing first operations cannot both attempt the protected write. + private static bool TryStampOrVerify(LuaState state) + { + if (Interlocked.CompareExchange(ref s_stamped, 1, 0) == 0) + { + state.PushLightUserdata(s_stampKey); + state.RawSetPointer(LuaState.RegistryIndex, s_stampKey); + return true; + } + + return CheckStamp(state); + } + + // Raw, non-allocating, no metamethod (pitfall 6c): a plain rawgetp/touserdata/pop. ZeroAllocationTests gates this. + private static bool CheckStamp(LuaState state) + { + LuaType type = state.RawGetPointer(LuaState.RegistryIndex, s_stampKey); + bool matches = type == LuaType.LightUserdata && state.ToUserdata(-1) == s_stampKey; + state.Pop(1); + return matches; + } + + // Best-effort eager stamp at Attach (or after CompleteStateReset) time, on the state the binding already + // provides. Deferred to the first admitted acquisition when the provider yields no state yet (pitfall 6a: tests + // may attach with a provider returning null). + private static unsafe void StampIfStateAvailable(LuaHostServices services) + { + lua_State* l = services.Provider(); + if (l is null) + { + return; } + LuaState state = new(l); try { - lua_State* l = services.Provider(); - if (l is null) + if (Interlocked.CompareExchange(ref s_stamped, 1, 0) == 0) { - ExitOperation(); - state = default; - return LuaCallbackDisposeOperationResult.Unavailable; + state.PushLightUserdata(s_stampKey); + state.RawSetPointer(LuaState.RegistryIndex, s_stampKey); } - - state = new LuaState(l); - return LuaCallbackDisposeOperationResult.Acquired; } catch { - ExitOperation(); + // A stamp failure in Attach is an attach failure: revert to detached instead of publishing a binding + // this SDK copy could never safely admit work on. + Write(ref s_services, null); throw; } } + // Re-checks the universe stamp on the provider state a lifecycle transition (Detach, a replacing Attach, or + // BeginStateReset) would otherwise unref and neutralize against. Never mutates anything and never releases a + // reference: a mismatch means the whole registry belongs to a different Lua universe (A08-22), so the caller + // must abandon instead of unregistering into it. + private static unsafe bool DetectExternalResetForDetach(LuaHostServices services, out LuaState state) + { + if (Read(ref s_externalResetDetected) != 0) + { + state = default; + return true; + } + + lua_State* l = services.Provider(); + state = new LuaState(l); + if (l is null || Read(ref s_stamped) == 0 || CheckStamp(state)) + { + return false; + } + + ReportExternalReset(); + state = default; + return true; + } + + // First detector wins: advances the state generation (under LuaReferences.Gate, never held by a draining + // transition), reverts to the conservative thread-admission default, and raises the one-shot diagnostic exactly + // once, outside every runtime lock. + private static void ReportExternalReset() + { + if (Interlocked.CompareExchange(ref s_externalResetDetected, 1, 0) != 0) + { + return; + } + + lock (LuaReferences.Gate) + { + LuaStateIdentity identity = CurrentStateIdentity; + PublishIdentity(identity.AttachEpoch, unchecked(identity.StateGeneration + 1)); + } + + Write(ref s_threadAdmission, (int) LuaThreadAdmission.MainThreadOnly); + Read(ref DiagnosticObserver)?.Invoke(LuaRuntimeDiagnostic.ExternalStateReset); + } + + private static void RaiseWorkerThreadRefusedOnce() + { + if (Interlocked.CompareExchange(ref s_workerThreadRefusedRaised, 1, 0) == 0) + { + Read(ref DiagnosticObserver)?.Invoke(LuaRuntimeDiagnostic.WorkerThreadRefused); + } + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowForAdmissionStatus(LuaAdmissionStatus status) + { + switch (status) + { + case LuaAdmissionStatus.Detached: + ThrowDetached(); + break; + case LuaAdmissionStatus.TransitionInProgress: + ThrowOperationAdmissionClosed(); + break; + case LuaAdmissionStatus.ThreadNotAdmitted: + ThrowThreadNotAdmitted(); + break; + case LuaAdmissionStatus.ExternalStateReset: + ThrowExternalStateReset(); + break; + default: + ThrowNoState(); + break; + } + } + private static bool TryEnterOperation() { lock (SOperationGate) @@ -807,6 +1192,26 @@ private static void ThrowOperationAdmissionClosed() "The Lua runtime is transitioning, so it is not accepting a new Lua operation."); } + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowThreadNotAdmitted() + { + throw new InvalidOperationException( + "The calling thread is not admitted for Lua work: the SDK's 2.0 conservative default (ADR-07) refuses a " + + "worker thread before calling the host's state provider. Use CheatEngine.SDK.Hosting.Threading.MainThread.Invoke " + + "to hop to the captured main thread, or opt in from OnEnable with the unqualified " + + "LuaRuntime.AdmitWorkerThreads() [Experimental(\"CESDK5001\")]."); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowExternalStateReset() + { + throw new InvalidOperationException( + "The host replaced its Lua state outside this SDK's controlled reset path (LuaStateReplacedExternally). " + + "Every owner from before the replacement is refused; disable and re-enable the plugin to recover."); + } + [DoesNotReturn] [MethodImpl(MethodImplOptions.NoInlining)] private static void ThrowTransitionFromOperation() @@ -847,6 +1252,12 @@ internal enum LuaCallbackDisposeOperationResult { Acquired, Unavailable, - AdmissionClosed + AdmissionClosed, + + /// The calling thread is not admitted under the current policy. + ThreadNotAdmitted, + + /// An external Lua-state reset was detected; every owner from before it is refused. + ExternalStateReset } } diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntimeDiagnostic.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntimeDiagnostic.cs new file mode 100644 index 00000000..5dee2bf4 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntimeDiagnostic.cs @@ -0,0 +1,22 @@ +namespace CheatEngine.SDK.Lua.Runtime; + +/// +/// A one-shot fact reports through , at most +/// once per attachment, always outside every runtime lock. CheatEngine.SDK.Hosting is the only subscriber: +/// it turns each kind into one stable-category HostLog entry (A24: never localized, never keyed off CE's UI +/// language). +/// +internal enum LuaRuntimeDiagnostic +{ + /// + /// A worker thread's -family call was refused by the conservative + /// default before the host's state provider ran. Hosting logs this as LuaWorkerThreadRefused:. + /// + WorkerThreadRefused, + + /// + /// just became . Hosting logs this + /// as LuaStateReplacedExternally:. + /// + ExternalStateReset +} diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaThreadAdmission.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaThreadAdmission.cs new file mode 100644 index 00000000..c8022b97 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaThreadAdmission.cs @@ -0,0 +1,32 @@ +namespace CheatEngine.SDK.Lua.Runtime; + +/// The SDK's policy for admitting a Lua operation on a thread other than the host's captured main thread. +/// +/// +/// This is a lifecycle-admission policy, not a claim about the shared Lua heap: Cheat Engine hands out one Lua +/// thread per OS thread, and distinct lua_State* values can be coroutines of one universe (ADR-07). The +/// policy decides only whether this SDK copy starts new Lua work on a given thread; it never infers, and never +/// provides, cross-thread serialization of that shared universe. +/// +/// +/// is the conservative 2.0 default: reads +/// it, and every attach or detach restores it. Read the policy freely; only +/// is gated, because opting into is the +/// action with an unqualified consequence (Q19), not observing which policy is active. +/// +/// +public enum LuaThreadAdmission +{ + /// + /// The conservative default: a new -family call is admitted only on + /// the host's captured main thread, inside a host-invoked callback already running on the calling thread, or + /// through the single documented synchronize hand-off (). + /// + MainThreadOnly = 0, + + /// + /// Set only by : a worker thread that is not already running admitted + /// Lua work is also admitted. Unqualified until Q19 passes at C3 and C4 (ADR-07). + /// + WorkerThreads = 1 +} diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs index 8728ead4..1ccc62b1 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs @@ -86,7 +86,7 @@ public bool TryRawSet(int tableIndex) { int keyType = lua_type(Pointer, -2); if (keyType == LUA_TNIL || (keyType == LUA_TNUMBER && lua_isinteger(Pointer, -2) == 0 && - double.IsNaN(lua_tonumberx(Pointer, -2, null)))) + double.IsNaN(lua_tonumberx(Pointer, -2, null)))) { lua_settop(Pointer, -3); return false; diff --git a/libs/CheatEngine.SDK.Lua/packages.lock.json b/libs/CheatEngine.SDK.Lua/packages.lock.json new file mode 100644 index 00000000..a1e55139 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/packages.lock.json @@ -0,0 +1,43 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/native/cheatengine-sdk-lua-bridge/README.md b/native/cheatengine-sdk-lua-bridge/README.md index c277b15c..56434e1b 100644 --- a/native/cheatengine-sdk-lua-bridge/README.md +++ b/native/cheatengine-sdk-lua-bridge/README.md @@ -21,7 +21,7 @@ protected operation. That contract has a magic value, major/minor version, contr `lua_Integer` and `size_t` widths, plus the exact supported-operation bitmap. Managed code checks it with `Unsafe.SizeOf` and also checks the fixed native export list. If the exported surface or operation contract changes, update `cheatengine_sdk_lua_bridge.c`, the versioned -[`protected-operations.json`](../../eng/lua-bridge/protected-operations.json) catalogue, the generated managed +[`protected-operations.json`](../../libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json) catalogue, the generated managed projection from `source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract` and its `tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests` contract tests, and the managed adapter as applicable; then rebuild the DLL and commit the matching assets together. The C11 bridge remains the owner of its @@ -55,15 +55,29 @@ the repository `.gitattributes`, so their raw hashes are identical on Windows an with the checked-in DLL; when it is stale, the job fails after uploading the corrected `lua-protection-bridge` artifact for a maintainer to commit. -Only rebuild it locally after changing `cheatengine_sdk_lua_bridge.c` or `xmake.lua`: +[`bridge-audit-manifest.json`](bridge-audit-manifest.json) records the committed bridge: the SHA-256 of +`cheatengine_sdk_lua_bridge.c` and `xmake.lua`, the fingerprint the DLL embeds, the DLL SHA-256, its PE facts (PE32+, +AMD64, DLL, exports, import modules, no delay-import directory) and the pinned xmake version. `BridgeAuditManifestTests` +in `tests/CheatEngine.SDK.Tests` compares it with the committed blob (`git cat-file`), never with the working-tree DLL, +because CI replaces that file with the bridge it builds before building the SDK. The CI-built and committed DLL bytes +may differ: the committed DLL was not necessarily built by the runner's pinned MSVC toolset. The `native` job reports +such a difference as a drift notice, `build-info.json` records both hashes, and the release run repeats the notice for +the bridge it packed; it is never a failure. When a manifest test fails, its message prints the complete expected +manifest, and replacing the file with it is the regeneration procedure. -```powershell -$output = 'artifacts/native/cheatengine-sdk-lua-bridge' -$outputPath = Join-Path $PWD $output -xmake f -P native/cheatengine-sdk-lua-bridge -o $output -p windows -a x64 -m release -y -xmake -P native/cheatengine-sdk-lua-bridge -y -Copy-Item (Join-Path $outputPath 'cheatengine-sdk-lua-bridge.dll') native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll -``` +Never commit a locally built DLL: a local Visual Studio toolset is not the runner's pinned one, so a local build has +different bytes than the bridge CI builds, tests and packs. After changing `cheatengine_sdk_lua_bridge.c` or +`xmake.lua`, use the CI round trip: + +1. Push the change. The `native` job builds the bridge three times (a second output directory and a copy of the + build inputs outside the repository), requires identical bytes, uploads it as the `lua-protection-bridge` + artifact, and then fails its fingerprint check because the committed DLL is now stale. +2. Download that run's artifact over the checked-in asset: + `gh run download -n lua-protection-bridge -D native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native`. +3. Commit the DLL, run + `dotnet test --project tests/CheatEngine.SDK.Tests --filter-class CheatEngine.SDK.Tests.Packaging.BridgeAuditManifestTests`, + replace `bridge-audit-manifest.json` with the manifest its failure prints, and commit it in the same pull request, so + the squash merge lands the DLL, its sources and its manifest together. The xmake target requires MSVC, C11, Windows x64, static CRT (`/MT`) and reproducible linking (`/Brepro`). `/MT` is both declared through xmake and passed explicitly to prevent an MSVC/UCRT runtime DLL dependency in the CE host. The diff --git a/native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json b/native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json index dea0cfe0..5a49b914 100644 --- a/native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json +++ b/native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json @@ -25,13 +25,13 @@ }, "source": { "hashes": { - "cheatengine_sdk_lua_bridge.c": "8a63e00c7dd941212e7ef8c13d8c97f73142c5154bfbe5dbc5459e7131bb789b", + "cheatengine_sdk_lua_bridge.c": "3342be23f88976d9209a24bc0d8b9db512482a24d8db90381a836ea4f5595a56", "xmake.lua": "2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8" }, - "fingerprint": "8a63e00c7dd941212e7ef8c13d8c97f73142c5154bfbe5dbc5459e7131bb789b:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8" + "fingerprint": "3342be23f88976d9209a24bc0d8b9db512482a24d8db90381a836ea4f5595a56:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8" }, "nativeAsset": { - "sha256": "da08c2ba03019da3a8c432ef061d5d6133fd2169ba3a6a8e9ac903353856d994", + "sha256": "889dc4c231d182f9b7baa9e29880555aad949f42023dde232fe327542c3c5387", "pe": { "format": "PE32+", "machine": "AMD64", @@ -44,7 +44,9 @@ "cheatengine_sdk_lua_protected" ], "delayImports": [], - "imports": ["KERNEL32.dll"] + "imports": [ + "KERNEL32.dll" + ] }, "reproducibility": { "doubleBuildSha256Comparison": true, diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/AnalyzerReleases.Unshipped.md b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/AnalyzerReleases.Unshipped.md index 17eeb032..7b32c473 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/AnalyzerReleases.Unshipped.md +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/AnalyzerReleases.Unshipped.md @@ -4,6 +4,9 @@ ### New Rules Rule ID | Category | Severity | Notes ------------|---------------------------|----------|------------------------------------------------------- - CESDK3001 | CheatEngine.SDK.EngineApi | Error | EngineApiGenerator, invalid curated API specification - CESDK3002 | CheatEngine.SDK.EngineApi | Error | EngineApiGenerator, generated-identity conflict +-----------|---------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------ + CESDK3001 | CheatEngine.SDK.EngineApi | Error | EngineApiGenerator, invalid curated API specification, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK3001.md) + CESDK3002 | CheatEngine.SDK.EngineApi | Error | EngineApiGenerator, generated-identity conflict, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK3002.md) + CESDK3003 | CheatEngine.SDK.EngineApi | Error | EngineApiGenerator, missing ce77 contract, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK3003.md) + CESDK3004 | CheatEngine.SDK.EngineApi | Error | EngineApiGenerator, invalid optional argument, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK3004.md) + CESDK3005 | CheatEngine.SDK.EngineApi | Error | EngineApiGenerator, invalid optional or variadic result, [Documentation](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK3005.md) diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs index f158860c..4251e4ee 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs @@ -30,6 +30,9 @@ internal static class EngineApiFileEmitter // See EmitAddressTypedWrapper. private const string EngineAddressTypeName = "global::CheatEngine.SDK.Engine.Values.Address"; + // An optional target address on the public surface: LuaOptional
, converted to LuaOptional for the core. + private const string OptionalAddressTypeName = LuaApiNames.LuaOptional + "<" + EngineAddressTypeName + ">"; + // Computed once: reads this generator assembly's name and version. private static readonly string GeneratedCodeAttribute = GeneratedCodeText.CreateGeneratedCodeAttribute(typeof(EngineApiFileEmitter)); @@ -137,6 +140,11 @@ private static string ReturnSemantics(LuaGlobalCallModel call) return "bool with out results"; } + if (call.Form == LuaCallForm.Outcome) + { + return "LuaOperationStatus with out results"; + } + if (call.ReturnKind is null) { return "void or Lua exception"; @@ -179,7 +187,8 @@ private static void EmitAddressTypedWrapper(SourceWriter writer, SpecCallModel e LuaGlobalCallModel call = entry.Call; LuaGlobalCallModel core = call with { - Modifiers = "private static", MethodName = CoreMethodName(call.MethodName) + Modifiers = "private static", + MethodName = CoreMethodName(call.MethodName) }; writer.Write("// Raw core of '"); @@ -215,7 +224,8 @@ private static string CoreMethodName(string methodName) } // '( name, ..., out name, ...)': identical to LuaGlobalCallEmitter.WriteParameterList except every - // target Address is typed CheatEngine.SDK.Engine.Values.Address instead of the raw nuint used in the private core. + // target Address is typed CheatEngine.SDK.Engine.Values.Address (LuaOptional
when optional) instead of the + // raw nuint used in the private core. private static void WriteAddressTypedParameterList(SourceWriter writer, LuaGlobalCallModel call) { writer.Write('('); @@ -233,56 +243,83 @@ private static void WriteAddressTypedParameterList(SourceWriter writer, LuaGloba } first = false; - writer.Write(argument.Kind == LuaValueKind.Address - ? EngineAddressTypeName - : LuaValueKinds.TypeName(argument.Kind, argument.IsNullable)); + writer.Write(argument.Kind != LuaValueKind.Address + ? argument.GeneratedTypeName + : argument.IsOptional + ? OptionalAddressTypeName + : EngineAddressTypeName); writer.Write(' '); writer.Write(argument.Name); } - if (call.Form == LuaCallForm.Try) + if (call.IsTryLike) { for (int i = 0; i < call.Results.Length; i++) { - LuaResultModel result = call.Results[i]; if (!first) { writer.Write(", "); } first = false; - writer.Write("out "); - writer.Write(result.Kind == LuaValueKind.Address - ? EngineAddressTypeName - : LuaValueKinds.TypeName(result.Kind, result.IsNullable)); - writer.Write(' '); - writer.Write(result.Name); + WriteAddressTypedResultParameter(writer, call.Results[i]); } } writer.Write(')'); } - // The raw core can write nuint only. Try wrappers with Address out values receive raw locals then convert every - // result (also on false, where the raw core deterministically assigned zero); throwing returns convert the raw - // value after the protected call succeeded. The simple argument-only forms retain their allocation-free direct - // forwarding body. + private static void WriteAddressTypedResultParameter(SourceWriter writer, LuaResultModel result) + { + if (result.Shape == LuaResultShape.Variadic) + { + writer.Write(LuaApiNames.Span); + writer.Write('<'); + writer.Write(LuaValueKinds.TypeName(result.Kind)); + writer.Write("> "); + writer.Write(result.DestinationName); + writer.Write(", out int "); + writer.Write(result.Name); + return; + } + + writer.Write("out "); + writer.Write(result.Kind != LuaValueKind.Address + ? result.GeneratedTypeName + : result.Shape == LuaResultShape.Optional + ? OptionalAddressTypeName + : EngineAddressTypeName); + writer.Write(' '); + writer.Write(result.Name); + } + + // The raw core can write nuint only. Try and Outcome wrappers with Address out values receive raw locals then + // convert every result (also on failure, where the raw core deterministically assigned zero or Omitted); an optional + // Address keeps its state (omitted, nil or value). Throwing returns convert the raw value after the protected call + // succeeded. The simple argument-only forms retain their allocation-free direct forwarding body. private static void WriteAddressTypedForwardingBody(SourceWriter writer, LuaGlobalCallModel call, LuaGlobalCallModel core) { - if (call.Form == LuaCallForm.Try && HasAddressResult(call)) + if (call.IsTryLike && HasAddressResult(call)) { for (int i = 0; i < call.Results.Length; i++) { if (call.Results[i].Kind == LuaValueKind.Address) { - writer.Write("nuint "); + writer.Write(call.Results[i].Shape == LuaResultShape.Optional + ? LuaValueKinds.OptionalTypeName(LuaValueKind.Address) + : "nuint"); + writer.Write(' '); writer.Write(RawResultName(i)); writer.WriteLine(";"); } } - writer.Write("bool __engineApiSucceeded = "); + string outcomeLocal = call.IsOutcome ? "__engineApiStatus" : "__engineApiSucceeded"; + writer.Write(call.IsOutcome ? LuaApiNames.LuaOperationStatus : "bool"); + writer.Write(' '); + writer.Write(outcomeLocal); + writer.Write(" = "); WriteAddressTypedInvocation(writer, call, core, true); writer.WriteLine(";"); @@ -290,16 +327,13 @@ private static void WriteAddressTypedForwardingBody(SourceWriter writer, LuaGlob { if (call.Results[i].Kind == LuaValueKind.Address) { - writer.Write(call.Results[i].Name); - writer.Write(" = new "); - writer.Write(EngineAddressTypeName); - writer.Write("(unchecked((ulong)"); - writer.Write(RawResultName(i)); - writer.WriteLine("));"); + WriteAddressResultConversion(writer, call.Results[i], RawResultName(i)); } } - writer.WriteLine("return __engineApiSucceeded;"); + writer.Write("return "); + writer.Write(outcomeLocal); + writer.WriteLine(";"); return; } @@ -324,6 +358,38 @@ private static void WriteAddressTypedForwardingBody(SourceWriter writer, LuaGlob writer.WriteLine(";"); } + // 'name = new Address(unchecked((ulong)raw));', or for an optional Address the same state with the value converted: + // 'name = raw.HasValue ? LuaOptional.Of(new Address(...)) : raw.IsNil ? LuaOptional.Nil
() : default;'. + private static void WriteAddressResultConversion(SourceWriter writer, LuaResultModel result, string raw) + { + writer.Write(result.Name); + if (result.Shape != LuaResultShape.Optional) + { + writer.Write(" = new "); + writer.Write(EngineAddressTypeName); + writer.Write("(unchecked((ulong)"); + writer.Write(raw); + writer.WriteLine("));"); + return; + } + + writer.Write(" = "); + writer.Write(raw); + writer.Write(".HasValue ? "); + writer.Write(LuaApiNames.LuaOptional); + writer.Write(".Of(new "); + writer.Write(EngineAddressTypeName); + writer.Write("(unchecked((ulong)"); + writer.Write(raw); + writer.Write(".Value))) : "); + writer.Write(raw); + writer.Write(".IsNil ? "); + writer.Write(LuaApiNames.LuaOptional); + writer.Write(".Nil<"); + writer.Write(EngineAddressTypeName); + writer.WriteLine(">() : default;"); + } + private static void WriteAddressTypedInvocation(SourceWriter writer, LuaGlobalCallModel call, LuaGlobalCallModel core, bool useRawAddressResults) { @@ -343,19 +409,10 @@ private static void WriteAddressTypedInvocation(SourceWriter writer, LuaGlobalCa } first = false; - if (argument.Kind == LuaValueKind.Address) - { - writer.Write("unchecked((nuint)"); - writer.Write(argument.Name); - writer.Write(".ToUInt64())"); - } - else - { - writer.Write(argument.Name); - } + WriteAddressTypedArgument(writer, argument); } - if (call.Form == LuaCallForm.Try) + if (call.IsTryLike) { for (int i = 0; i < call.Results.Length; i++) { @@ -366,6 +423,12 @@ private static void WriteAddressTypedInvocation(SourceWriter writer, LuaGlobalCa } first = false; + if (result.Shape == LuaResultShape.Variadic) + { + writer.Write(result.DestinationName); + writer.Write(", "); + } + writer.Write("out "); writer.Write(useRawAddressResults && result.Kind == LuaValueKind.Address ? RawResultName(i) @@ -376,6 +439,36 @@ private static void WriteAddressTypedInvocation(SourceWriter writer, LuaGlobalCa writer.Write(')'); } + // A target Address becomes the core's nuint; an optional one keeps its state, inline and allocation-free. + private static void WriteAddressTypedArgument(SourceWriter writer, LuaArgumentModel argument) + { + if (argument.Kind != LuaValueKind.Address) + { + writer.Write(argument.Name); + return; + } + + if (!argument.IsOptional) + { + writer.Write("unchecked((nuint)"); + writer.Write(argument.Name); + writer.Write(".ToUInt64())"); + return; + } + + writer.Write('('); + writer.Write(argument.Name); + writer.Write(".HasValue ? "); + writer.Write(LuaApiNames.LuaOptional); + writer.Write(".Of(unchecked((nuint)"); + writer.Write(argument.Name); + writer.Write(".Value.ToUInt64())) : "); + writer.Write(argument.Name); + writer.Write(".IsNil ? "); + writer.Write(LuaApiNames.LuaOptional); + writer.Write(".Nil() : default)"); + } + private static bool HasAddressResult(LuaGlobalCallModel call) { foreach (LuaResultModel result in call.Results) diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiDiagnostics.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiDiagnostics.cs index 30cf9aa8..286727b3 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiDiagnostics.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/EngineApiDiagnostics.cs @@ -10,23 +10,67 @@ internal static class EngineApiDiagnostics { private const string Category = "CheatEngine.SDK.EngineApi"; - private static readonly DiagnosticDescriptor InvalidSpec = new( + // One markdown page per rule, named after the identifier, like the CheatEngine.SDK.Analyzers rules. + private const string HelpLinkBase = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/"; + + /// CESDK3001: a malformed header, entry, key, name or kind. + internal static readonly DiagnosticDescriptor InvalidSpec = new( "CESDK3001", "Engine API specification is invalid", "Engine API specification: {0}", Category, DiagnosticSeverity.Error, true, - "Correct the listed Engine API spec grammar error; invalid specs cannot remove generated API silently."); + "Correct the listed Engine API spec grammar error; invalid specs cannot remove generated API silently.", + HelpLinkBase + "CESDK3001.md"); - private static readonly DiagnosticDescriptor ConflictingSpec = new( + /// CESDK3002: two spec files would generate the same type, member or cache field. + internal static readonly DiagnosticDescriptor ConflictingSpec = new( "CESDK3002", "Engine API specification has a generated-identity conflict", "Engine API specification: {0}", Category, DiagnosticSeverity.Error, true, - "One Engine API spec file owns each generated type and its members and cache fields."); + "One Engine API spec file owns each generated type and its members and cache fields.", + HelpLinkBase + "CESDK3002.md"); + + /// CESDK3003: a spec file with entries has no contract: ce77 header. + internal static readonly DiagnosticDescriptor MissingContract = new( + "CESDK3003", + "Engine API specification does not declare the ce77 contract", + "Engine API specification: {0}", + Category, + DiagnosticSeverity.Error, + true, + "Every Engine API spec file with entries declares 'contract: ce77' with its provenance, minimum version, architecture, " + + "thread and ownership facts, and a 'nil' contract on every entry. A file without it generates nothing.", + HelpLinkBase + "CESDK3003.md"); + + /// CESDK3004: an optional argument is not trailing, or has a kind that cannot be optional. + internal static readonly DiagnosticDescriptor InvalidOptionalArgument = new( + "CESDK3004", + "Engine API optional argument is invalid", + "Engine API specification: {0}", + Category, + DiagnosticSeverity.Error, + true, + "An 'opt:' argument becomes a LuaOptional parameter that can be omitted, so no 'arg:' or 'fixed:' argument may follow " + + "it, and its kind is one that LuaOptional supports (not 'utf8' or 'string?'). The entry generates nothing.", + HelpLinkBase + "CESDK3004.md"); + + /// CESDK3005: an optional or variadic result is out of order, on the wrong form, or of an unsupported kind. + internal static readonly DiagnosticDescriptor InvalidResultShape = new( + "CESDK3005", + "Engine API optional or variadic result is invalid", + "Engine API specification: {0}", + Category, + DiagnosticSeverity.Error, + true, + "Results are read in order: 'result:' values, then 'opt-result:' values, then at most one 'rest:' tail, which only the " + + "'outcome' form can declare. An 'opt-result:' kind is one LuaOptional supports; a 'rest:' kind is int32, int64, " + + "single, double or boolean. The entry generates nothing.", + HelpLinkBase + "CESDK3005.md"); /// Creates the compiler diagnostic for one Roslyn-free parsed issue. public static Diagnostic Create(SpecFileModel spec, SpecIssue issue) @@ -39,9 +83,19 @@ public static Diagnostic Create(SpecFileModel spec, SpecIssue issue) new TextSpan(0, 0), new LinePositionSpan(position, position)); - return Diagnostic.Create( - issue.Kind == SpecIssueKind.Conflict ? ConflictingSpec : InvalidSpec, - location, - issue.Message); + return Diagnostic.Create(DescriptorFor(issue.Kind), location, issue.Message); + } + + /// The descriptor of one issue family. + internal static DiagnosticDescriptor DescriptorFor(SpecIssueKind kind) + { + return kind switch + { + SpecIssueKind.Conflict => ConflictingSpec, + SpecIssueKind.MissingContract => MissingContract, + SpecIssueKind.OptionalArgument => InvalidOptionalArgument, + SpecIssueKind.ResultShape => InvalidResultShape, + _ => InvalidSpec + }; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs index f6e7986f..777ac885 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs @@ -56,7 +56,10 @@ private static void AssignUniqueHintNames(List specs) } } - specs[i] = specs[i] with { HintName = hintName }; + specs[i] = specs[i] with + { + HintName = hintName + }; } } @@ -95,7 +98,10 @@ private static void SuppressCrossFileConflicts(List specs) foreach (int index in indices) { - specs[index] = specs[index] with { IsSuppressed = true }; + specs[index] = specs[index] with + { + IsSuppressed = true + }; } } } @@ -116,12 +122,14 @@ private static void AppendMemberConflictIssues(List specs, List> owners = new(StringComparer.Ordinal); foreach (int index in indices) - foreach (SpecCallModel call in specs[index].Calls) { - AddOwner(owners, call.Call.MethodName, (index, call.MethodLine, call.MethodColumn)); - if (UsesAddressFacade(call.Call)) + foreach (SpecCallModel call in specs[index].Calls) { - AddOwner(owners, CoreMethodName(call.Call.MethodName), (index, call.MethodLine, call.MethodColumn)); + AddOwner(owners, call.Call.MethodName, (index, call.MethodLine, call.MethodColumn)); + if (UsesAddressFacade(call.Call)) + { + AddOwner(owners, CoreMethodName(call.Call.MethodName), (index, call.MethodLine, call.MethodColumn)); + } } } @@ -194,7 +202,10 @@ private static void AddConflict(ref SpecFileModel spec, int line, int column, st { List issues = [.. spec.Issues]; issues.Add(new SpecIssue(line, message, column, SpecIssueKind.Conflict)); - spec = spec with { Issues = new EquatableArray([.. issues]) }; + spec = spec with + { + Issues = new EquatableArray([.. issues]) + }; } private static string QualifiedTypeName(SpecFileModel spec) diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssueKind.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssueKind.cs index f5f834f5..27339dee 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssueKind.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecIssueKind.cs @@ -1,11 +1,23 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Model; -/// The diagnostic family a belongs to. +/// The diagnostic family a belongs to; each family is one CESDK3xxx identifier. internal enum SpecIssueKind { - /// The source text does not meet the curated spec grammar. + /// The source text does not meet the curated spec grammar (CESDK3001). Grammar, - /// Two otherwise valid specs would generate the same C# identity. - Conflict + /// Two otherwise valid specs would generate the same C# identity (CESDK3002). + Conflict, + + /// A spec file with entries does not declare the contract: ce77 evidence header (CESDK3003). + MissingContract, + + /// An opt: argument is followed by a required one, or has a kind that cannot be optional (CESDK3004). + OptionalArgument, + + /// + /// An opt-result: or rest: result is out of order, on the wrong form, or of a kind it cannot have + /// (CESDK3005). + /// + ResultShape } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs index c0489457..ed0183a6 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs @@ -61,7 +61,16 @@ public static SpecFileModel Parse(string filePath, string? text) out SpecFileContract? fileContract); List parsed = []; - if (headerOk) + if (headerOk && fileContract is null && blocks.Count > 1) + { + // Header-only reservations stay readable without the contract; a file that would generate API may not. + issues.Add(new SpecIssue(blocks[0].StartLine, + "The spec file declares entries but no 'contract: ce77' header: add 'contract: ce77' with its provenance, " + + "minimum-ce, architecture, thread and ownership keys, and a 'nil' key on every entry. No entry was generated.", + blocks[0].StartColumn, SpecIssueKind.MissingContract)); + headerOk = false; + } + else if (headerOk) { for (int i = 1; i < blocks.Count; i++) { @@ -199,13 +208,13 @@ private static bool ParseHeader(List blocks, List issues, out } if (!ReadHeaderFields( - header, - issues, - out string? namespaceValue, - out string? typeValue, - out typeLine, - out typeColumn, - out contract)) + header, + issues, + out string? namespaceValue, + out string? typeValue, + out typeLine, + out typeColumn, + out contract)) { return false; } @@ -356,10 +365,10 @@ private static bool TryCreateCe77Contract(HeaderFields fields, Block header, Lis { contract = null; if (!TryRequireContractField(fields.Provenance, "provenance", header, issues, out SpecField provenance) - || !TryRequireContractField(fields.MinimumCe, "minimum-ce", header, issues, out SpecField minimumCe) - || !TryRequireContractField(fields.Architecture, "architecture", header, issues, out SpecField architecture) - || !TryRequireContractField(fields.Thread, "thread", header, issues, out SpecField thread) - || !TryRequireContractField(fields.Ownership, "ownership", header, issues, out SpecField ownership)) + || !TryRequireContractField(fields.MinimumCe, "minimum-ce", header, issues, out SpecField minimumCe) + || !TryRequireContractField(fields.Architecture, "architecture", header, issues, out SpecField architecture) + || !TryRequireContractField(fields.Thread, "thread", header, issues, out SpecField thread) + || !TryRequireContractField(fields.Ownership, "ownership", header, issues, out SpecField ownership)) { return false; } @@ -493,11 +502,11 @@ private static bool IsValidProvenance(string value) string status = value[..colon]; return string.Equals(status, "ExactBinary", StringComparison.Ordinal) - || string.Equals(status, "ExactInstalledFile", StringComparison.Ordinal) - || string.Equals(status, "PinnedUpstream", StringComparison.Ordinal) - || string.Equals(status, "ObservedLive", StringComparison.Ordinal) - || string.Equals(status, "Inferred", StringComparison.Ordinal) - || string.Equals(status, "Unknown", StringComparison.Ordinal); + || string.Equals(status, "ExactInstalledFile", StringComparison.Ordinal) + || string.Equals(status, "PinnedUpstream", StringComparison.Ordinal) + || string.Equals(status, "ObservedLive", StringComparison.Ordinal) + || string.Equals(status, "Inferred", StringComparison.Ordinal) + || string.Equals(status, "Unknown", StringComparison.Ordinal); } private static bool IsFourPartVersion(string value) @@ -532,23 +541,23 @@ private static bool IsFourPartVersion(string value) private static bool IsThreadAffinity(string value) { return string.Equals(value, "any", StringComparison.Ordinal) - || string.Equals(value, "main", StringComparison.Ordinal) - || string.Equals(value, "unknown", StringComparison.Ordinal); + || string.Equals(value, "main", StringComparison.Ordinal) + || string.Equals(value, "unknown", StringComparison.Ordinal); } private static bool IsOwnership(string value) { return string.Equals(value, "none", StringComparison.Ordinal) - || string.Equals(value, "borrowed", StringComparison.Ordinal) - || string.Equals(value, "owned", StringComparison.Ordinal); + || string.Equals(value, "borrowed", StringComparison.Ordinal) + || string.Equals(value, "owned", StringComparison.Ordinal); } private static bool IsNilSemantics(string value) { return string.Equals(value, "none", StringComparison.Ordinal) - || string.Equals(value, "absence", StringComparison.Ordinal) - || string.Equals(value, "expected-failure", StringComparison.Ordinal) - || string.Equals(value, "lua-error", StringComparison.Ordinal); + || string.Equals(value, "absence", StringComparison.Ordinal) + || string.Equals(value, "expected-failure", StringComparison.Ordinal) + || string.Equals(value, "lua-error", StringComparison.Ordinal); } private static SpecCallModel? ParseEntry(Block block, List issues, SpecFileContract? fileContract) @@ -565,44 +574,35 @@ private static bool IsNilSemantics(string value) return null; } - if (!ValidateRequiredText(fields, block.StartLine, fileContract is not null, issues, out bool isTry, - out bool isThrowing)) + if (!ValidateRequiredText(fields, block.StartLine, fileContract is not null, issues, out LuaCallForm form)) { return null; } - if (!ValidateResultShape(fields, isTry, isThrowing, block.StartLine, issues)) + if (!ValidateResultShape(fields, form, block.StartLine, issues)) { return null; } - List? arguments = ParseArguments(fields.ArgTokens, issues); + List? arguments = ParseArguments(fields.ArgumentTokens, issues); if (arguments is null) { return null; } - List? fixedArguments = ParseFixedArguments(fields.FixedTokens, issues); - if (fixedArguments is null) - { - return null; - } - - arguments.AddRange(fixedArguments); - List? results = ParseResults(fields.ResultTokens, issues); if (results is null) { return null; } - if (!TryParseReturnKind(fields, isThrowing, block.StartLine, issues, out LuaValueKind? returnKind, - out bool returnIsNullable)) + if (!TryParseReturnKind(fields, form == LuaCallForm.Throwing, block.StartLine, issues, + out LuaValueKind? returnKind, out bool returnIsNullable)) { return null; } - LuaGlobalCallModel call = CreateLuaGlobalCall(fields, arguments, isTry, results, returnKind, returnIsNullable); + LuaGlobalCallModel call = CreateLuaGlobalCall(fields, arguments, form, results, returnKind, returnIsNullable); if (!ValidateParameterAndLocalIdentities(arguments, results, call, block.StartLine, issues)) { @@ -613,7 +613,7 @@ private static bool IsNilSemantics(string value) } private static LuaGlobalCallModel CreateLuaGlobalCall(EntryFields fields, List arguments, - bool isTry, List results, LuaValueKind? returnKind, bool returnIsNullable) + LuaCallForm form, List results, LuaValueKind? returnKind, bool returnIsNullable) { string globalName = fields.Global!; return new LuaGlobalCallModel( @@ -623,7 +623,7 @@ private static LuaGlobalCallModel CreateLuaGlobalCall(EntryFields fields, List([.. arguments]), - isTry ? LuaCallForm.Try : LuaCallForm.Throwing, + form, new EquatableArray([.. results]), returnKind, returnIsNullable); @@ -668,20 +668,35 @@ private static bool TrySetEntryField(EntryFields fields, HashSet singula { switch (field.Key) { - case "global": return TrySetGlobal(fields, singular, field, issues); - case "method": return TrySetMethod(fields, singular, field, issues); - case "form": return TrySetForm(fields, singular, field, issues); - case "doc": return TrySetDoc(fields, singular, field, issues); - case "nil": return TrySetNil(fields, singular, field, issues); - case "return": return TrySetReturn(fields, singular, field, issues); + case "global": + return TrySetGlobal(fields, singular, field, issues); + case "method": + return TrySetMethod(fields, singular, field, issues); + case "form": + return TrySetForm(fields, singular, field, issues); + case "doc": + return TrySetDoc(fields, singular, field, issues); + case "nil": + return TrySetNil(fields, singular, field, issues); + case "return": + return TrySetReturn(fields, singular, field, issues); case "arg": - fields.ArgTokens.Add((field.Line, field.ValueColumn, field.Value)); + fields.ArgumentTokens.Add(new SpecToken(field.Line, field.ValueColumn, field.Value, TokenRole.Argument)); + return true; + case "opt": + fields.ArgumentTokens.Add(new SpecToken(field.Line, field.ValueColumn, field.Value, TokenRole.Optional)); return true; case "fixed": - fields.FixedTokens.Add((field.Line, field.ValueColumn, field.Value)); + fields.ArgumentTokens.Add(new SpecToken(field.Line, field.ValueColumn, field.Value, TokenRole.Fixed)); return true; case "result": - fields.ResultTokens.Add((field.Line, field.ValueColumn, field.Value)); + fields.ResultTokens.Add(new SpecToken(field.Line, field.ValueColumn, field.Value, TokenRole.Argument)); + return true; + case "opt-result": + fields.ResultTokens.Add(new SpecToken(field.Line, field.ValueColumn, field.Value, TokenRole.Optional)); + return true; + case "rest": + fields.ResultTokens.Add(new SpecToken(field.Line, field.ValueColumn, field.Value, TokenRole.Rest)); return true; default: issues.Add(new SpecIssue(field.Line, "Unknown entry key '" + field.Key + "'.", field.KeyColumn)); @@ -741,13 +756,12 @@ private static bool TrySetReturn(EntryFields fields, HashSet singular, S } private static bool ValidateRequiredText(EntryFields fields, int startLine, bool requiresCe77Contract, - List issues, out bool isTry, out bool isThrowing) + List issues, out LuaCallForm form) { - isTry = false; - isThrowing = false; + form = LuaCallForm.Throwing; if (!ValidateRequiredPresence(fields, startLine, issues) - || !ValidateNilContract(fields, startLine, requiresCe77Contract, issues)) + || !ValidateNilContract(fields, startLine, requiresCe77Contract, issues)) { return false; } @@ -766,40 +780,84 @@ private static bool ValidateRequiredText(EntryFields fields, int startLine, bool return false; } - isTry = string.Equals(fields.Form, "try", StringComparison.Ordinal); - isThrowing = string.Equals(fields.Form, "throwing", StringComparison.Ordinal); - if (!isTry && !isThrowing) + switch (fields.Form) { - issues.Add(new SpecIssue(fields.FormLine, - "'" + fields.Form + "' is not a valid form: expected 'try' or 'throwing'.", fields.FormColumn)); - return false; + case "try": + form = LuaCallForm.Try; + return true; + case "throwing": + form = LuaCallForm.Throwing; + return true; + case "outcome": + form = LuaCallForm.Outcome; + return true; + default: + issues.Add(new SpecIssue(fields.FormLine, + "'" + fields.Form + "' is not a valid form: expected 'try', 'throwing' or 'outcome'.", + fields.FormColumn)); + return false; } - - return true; } - private static bool ValidateResultShape(EntryFields fields, bool isTry, bool isThrowing, int startLine, + private static bool ValidateResultShape(EntryFields fields, LuaCallForm form, int startLine, List issues) { - if (isThrowing && fields.ResultTokens.Count > 0) + if (form == LuaCallForm.Throwing && fields.ResultTokens.Count > 0) { issues.Add(new SpecIssue(startLine, - "A 'throwing' entry must not declare 'result' (its value, if any, is 'return').")); + "A 'throwing' entry must not declare 'result', 'opt-result' or 'rest' (its value, if any, is 'return').")); return false; } - if (isTry && fields.SawReturn) + if (form != LuaCallForm.Throwing && fields.SawReturn) { - issues.Add(new SpecIssue(startLine, "A 'try' entry must not declare 'return' (its values are 'result').")); + issues.Add(new SpecIssue(startLine, + "A '" + fields.Form + "' entry must not declare 'return' (its values are 'result').")); return false; } - if (isTry && fields.ResultTokens.Count == 0) + if (form == LuaCallForm.Try && fields.ResultTokens.Count == 0) { - issues.Add(new SpecIssue(startLine, "A 'try' entry needs at least one 'result'.")); + issues.Add(new SpecIssue(startLine, "A 'try' entry needs at least one 'result' or 'opt-result'.")); return false; } + return ValidateResultOrder(fields.ResultTokens, form, issues); + } + + // Results are read in order: required, then optional, then at most one variadic tail, which only the outcome form + // can report failures of. + private static bool ValidateResultOrder(List tokens, LuaCallForm form, List issues) + { + bool sawOptional = false; + bool sawRest = false; + foreach (SpecToken token in tokens) + { + string? problem = null; + if (sawRest) + { + problem = "must be declared before the 'rest' result, which is last"; + } + else if (token.Role == TokenRole.Argument && sawOptional) + { + problem = "is a required 'result' after an 'opt-result': optional results come after every required one"; + } + else if (token.Role == TokenRole.Rest && form != LuaCallForm.Outcome) + { + problem = "is a 'rest' result, which only the 'outcome' form can declare"; + } + + if (problem is not null) + { + issues.Add(new SpecIssue(token.Line, "'" + token.Value + "' " + problem + ".", token.Column, + SpecIssueKind.ResultShape)); + return false; + } + + sawOptional |= token.Role == TokenRole.Optional; + sawRest |= token.Role == TokenRole.Rest; + } + return true; } @@ -838,80 +896,148 @@ private static bool TryParseReturnKind( return true; } - private static List? ParseArguments(List<(int Line, int Column, string Value)> tokens, - List issues) + // 'arg', 'fixed' and 'opt' in their textual order, which is the push order. An 'opt' argument becomes a + // LuaOptional parameter and may be omitted, so only more 'opt' arguments may follow it. + private static List? ParseArguments(List tokens, List issues) { List arguments = new(tokens.Count); - foreach ((int line, int column, string value) in tokens) + bool sawOptional = false; + foreach (SpecToken token in tokens) { - if (!TryParseNamedValue(value, out string name, out string kindToken) - || !SpecIdentifiers.IsValidIdentifier(name) - || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) + if (sawOptional && token.Role != TokenRole.Optional) + { + issues.Add(new SpecIssue(token.Line, + "'" + token.Value + "' follows an 'opt' argument: only more 'opt' arguments may follow one, because Lua " + + "cannot receive an argument after an omitted one.", token.Column, SpecIssueKind.OptionalArgument)); + return null; + } + + LuaArgumentModel? argument = token.Role == TokenRole.Fixed + ? ParseFixedArgument(token, issues) + : ParseValueArgument(token, issues); + if (argument is null) { - issues.Add(new SpecIssue(line, "'" + value + "' is not a valid 'name:kind' argument.", column)); return null; } - arguments.Add(new LuaArgumentModel(SpecIdentifiers.Escape(name), kind, nullable)); + sawOptional |= token.Role == TokenRole.Optional; + arguments.Add(argument); } return arguments; } + private static LuaArgumentModel? ParseValueArgument(SpecToken token, List issues) + { + if (!TryParseNamedValue(token.Value, out string name, out string kindToken) + || !SpecIdentifiers.IsValidIdentifier(name) + || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) + { + issues.Add(new SpecIssue(token.Line, "'" + token.Value + "' is not a valid 'name:kind' argument.", + token.Column)); + return null; + } + + if (token.Role != TokenRole.Optional) + { + return new LuaArgumentModel(SpecIdentifiers.Escape(name), kind, nullable); + } + + if (nullable || !LuaValueKinds.CanBeOptional(kind)) + { + issues.Add(new SpecIssue(token.Line, + "'" + kindToken + "' cannot be an 'opt' kind: nil is the Nil state of LuaOptional, and a span cannot be " + + "optional; use 'string' for optional text.", token.Column, SpecIssueKind.OptionalArgument)); + return null; + } + + return LuaArgumentModel.Optional(SpecIdentifiers.Escape(name), kind); + } + // A fixed argument has the narrow, host-facing grammar 'kind:value'. It is pushed in call order but deliberately // omitted from the generated C# signature. Only boolean literals are needed by the curated CE surface today; keep // that vocabulary explicit rather than accepting arbitrary C# expressions in a repository text file. - private static List? ParseFixedArguments(List<(int Line, int Column, string Value)> tokens, - List issues) + private static LuaArgumentModel? ParseFixedArgument(SpecToken token, List issues) { - List arguments = new(tokens.Count); - foreach ((int line, int column, string value) in tokens) - { - if (!TryParseNamedValue(value, out string kindToken, out string literal) - || !string.Equals(kindToken, "boolean", StringComparison.Ordinal) - || !(string.Equals(literal, "true", StringComparison.Ordinal) - || string.Equals(literal, "false", StringComparison.Ordinal))) - { - issues.Add(new SpecIssue(line, - "'" + value + "' is not a valid fixed argument: expected 'boolean:true' or 'boolean:false'.", - column)); - return null; - } - - arguments.Add(new LuaArgumentModel(literal, LuaValueKind.Boolean, false, FixedValue: literal)); + if (!TryParseNamedValue(token.Value, out string kindToken, out string literal) + || !string.Equals(kindToken, "boolean", StringComparison.Ordinal) + || !(string.Equals(literal, "true", StringComparison.Ordinal) + || string.Equals(literal, "false", StringComparison.Ordinal))) + { + issues.Add(new SpecIssue(token.Line, + "'" + token.Value + "' is not a valid fixed argument: expected 'boolean:true' or 'boolean:false'.", + token.Column)); + return null; } - return arguments; + return new LuaArgumentModel(literal, LuaValueKind.Boolean, false, FixedValue: literal); } - private static List? ParseResults(List<(int Line, int Column, string Value)> tokens, - List issues) + private static List? ParseResults(List tokens, List issues) { List results = new(tokens.Count); - foreach ((int line, int column, string value) in tokens) + foreach (SpecToken token in tokens) { - if (!TryParseNamedValue(value, out string name, out string kindToken) - || !SpecIdentifiers.IsValidIdentifier(name) - || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) + if (!TryParseNamedValue(token.Value, out string name, out string kindToken) + || !SpecIdentifiers.IsValidIdentifier(name) + || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) { - issues.Add(new SpecIssue(line, "'" + value + "' is not a valid 'name:kind' result.", column)); + issues.Add(new SpecIssue(token.Line, "'" + token.Value + "' is not a valid 'name:kind' result.", + token.Column)); return null; } if (!LuaValueKinds.CanBeResult(kind)) { - issues.Add(new SpecIssue(line, + issues.Add(new SpecIssue(token.Line, "'" + kindToken + "' cannot be a result: the span would dangle once the stack is restored.", - column)); + token.Column)); + return null; + } + + LuaResultModel? result = CreateResult(token, SpecIdentifiers.Escape(name), kind, nullable, kindToken, + issues); + if (result is null) + { return null; } - results.Add(LuaResultModel.Value(kind, SpecIdentifiers.Escape(name), nullable)); + results.Add(result); } return results; } + private static LuaResultModel? CreateResult(SpecToken token, string name, LuaValueKind kind, bool nullable, + string kindToken, List issues) + { + switch (token.Role) + { + case TokenRole.Optional when nullable: + issues.Add(new SpecIssue(token.Line, + "'string?' cannot be an 'opt-result' kind: nil is the Nil state of LuaOptional; use 'string'.", + token.Column, SpecIssueKind.ResultShape)); + return null; + case TokenRole.Optional: + return LuaResultModel.Optional(kind, name); + case TokenRole.Rest when !LuaValueKinds.CanBeVariadicElement(kind) || kind == LuaValueKind.Address: + issues.Add(new SpecIssue(token.Line, + "'" + kindToken + "' cannot be a 'rest' kind: expected 'int32', 'int64', 'single', 'double' or 'boolean'.", + token.Column, SpecIssueKind.ResultShape)); + return null; + case TokenRole.Rest: + return LuaResultModel.Variadic(kind, name, RestCountName(name)); + default: + return LuaResultModel.Value(kind, name, nullable); + } + } + + // 'rest: values:int64' yields 'Span values, out int valuesCount'. + private static string RestCountName(string name) + { + return (name[0] == '@' ? name.Substring(1) : name) + "Count"; + } + // "name:kind" (or "name:string?"): split on the FIRST colon, so the '?' of a nullable string kind is part of // the kind token, not mistaken for another separator. private static bool TryParseNamedValue(string raw, out string name, out string kind) @@ -1011,14 +1137,20 @@ private static bool ValidateParameterAndLocalIdentities( foreach (LuaResultModel result in results) { - if (parameters.ContainsKey(result.Name)) + string[] names = result.Shape == LuaResultShape.Variadic + ? [result.DestinationName, result.Name] + : [result.Name]; + foreach (string name in names) { - issues.Add(new SpecIssue(line, - "Generated parameter '" + result.Name + "' is declared more than once in this entry.")); - return false; - } + if (parameters.ContainsKey(name)) + { + issues.Add(new SpecIssue(line, + "Generated parameter '" + name + "' is declared more than once in this entry.")); + return false; + } - parameters.Add(result.Name, 0); + parameters.Add(name, 0); + } } foreach (string name in parameters.Keys) @@ -1095,12 +1227,7 @@ private static bool ValidateNilContract(EntryFields fields, int startLine, bool private static bool IsReservedBodyLocal(string name, LuaGlobalCallModel call) { - if (string.Equals(name, "__L", StringComparison.Ordinal) - || string.Equals(name, "__operation", StringComparison.Ordinal) - || string.Equals(name, "__top", StringComparison.Ordinal) - || string.Equals(name, "__ok", StringComparison.Ordinal) - || string.Equals(name, "__status", StringComparison.Ordinal) - || string.Equals(name, "__result", StringComparison.Ordinal)) + if (LuaGlobalCallEmitter.IsReservedLocal(name)) { return true; } @@ -1111,12 +1238,13 @@ private static bool IsReservedBodyLocal(string name, LuaGlobalCallModel call) } if (string.Equals(name, "__engineApiSucceeded", StringComparison.Ordinal) - || string.Equals(name, "__engineApiRawResult", StringComparison.Ordinal)) + || string.Equals(name, "__engineApiStatus", StringComparison.Ordinal) + || string.Equals(name, "__engineApiRawResult", StringComparison.Ordinal)) { return true; } - if (call.Form != LuaCallForm.Try) + if (call.Form == LuaCallForm.Throwing) { return false; } @@ -1124,7 +1252,7 @@ private static bool IsReservedBodyLocal(string name, LuaGlobalCallModel call) for (int i = 0; i < call.Results.Length; i++) { if (call.Results[i].Kind == LuaValueKind.Address - && string.Equals(name, RawResultName(i), StringComparison.Ordinal)) + && string.Equals(name, RawResultName(i), StringComparison.Ordinal)) { return true; } @@ -1192,8 +1320,8 @@ private static List DropCacheMemberCollisions(List foreach (SpecCallModel entry in entries) { bool conflicts = cacheFields.Contains(entry.Call.MethodName) - || (UsesAddressFacade(entry.Call) && - cacheFields.Contains(CoreMethodName(entry.Call.MethodName))); + || (UsesAddressFacade(entry.Call) && + cacheFields.Contains(CoreMethodName(entry.Call.MethodName))); if (!conflicts) { valid.Add(entry); @@ -1219,9 +1347,9 @@ private static List DropTypeMemberCollisions(List foreach (SpecCallModel entry in entries) { bool conflicts = string.Equals(entry.Call.MethodName, typeName, StringComparison.Ordinal) - || (UsesAddressFacade(entry.Call) - && string.Equals(CoreMethodName(entry.Call.MethodName), typeName, - StringComparison.Ordinal)); + || (UsesAddressFacade(entry.Call) + && string.Equals(CoreMethodName(entry.Call.MethodName), typeName, + StringComparison.Ordinal)); if (!conflicts) { valid.Add(entry); @@ -1311,9 +1439,8 @@ private sealed class HeaderFields // The raw fields of one entry block, read once by ReadEntryFields and consumed by the validators below. private sealed class EntryFields { - public readonly List<(int Line, int Column, string Value)> ArgTokens = []; - public readonly List<(int Line, int Column, string Value)> FixedTokens = []; - public readonly List<(int Line, int Column, string Value)> ResultTokens = []; + public readonly List ArgumentTokens = []; + public readonly List ResultTokens = []; public string? Doc; public string? Form; public int FormColumn; @@ -1334,4 +1461,16 @@ private sealed class EntryFields } private readonly record struct SpecField(int Line, int KeyColumn, int ValueColumn, string Key, string Value); + + // One 'arg'/'opt'/'fixed' or 'result'/'opt-result'/'rest' value with its source position and role. + private readonly record struct SpecToken(int Line, int Column, string Value, TokenRole Role); + + // Argument: 'arg' or 'result'. Optional: 'opt' or 'opt-result'. Fixed: 'fixed'. Rest: 'rest'. + private enum TokenRole + { + Argument, + Optional, + Fixed, + Rest + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/README.md b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/README.md index f499a1c5..b2328a38 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/README.md +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/README.md @@ -35,18 +35,32 @@ additional file: ``` -Only files explicitly included as `AdditionalFiles` participate in one generator invocation. At present the shipping -Engine project includes `memory-scalars.cheatengine-sdk-api.txt`. The adjacent CE 7.7 runtime, inspection, allocation, -AOB, scan and address-list manifests are provenance/reservation documents for contracts the scalar grammar cannot yet -represent, or candidates awaiting an explicit reviewed inclusion. They are not silently generated, and their presence -does not imply a wrapper exists. Their manual vertical-slice APIs document their own evidence and ownership/thread -boundaries. +Only files explicitly included as `AdditionalFiles` participate in one generator invocation. Every production spec +declares `contract: ce77` and parses without an issue, and they fall in three groups: + +| Spec | Status | +|---------------------------------------------------|------------------------------------------------------------------------------------------------------------| +| `memory-scalars.cheatengine-sdk-api.txt` | Wired: generates `CheatEngine.SDK.Engine.Generated.MemoryScalars`. | +| `runtime-capabilities.cheatengine-sdk-api.txt` | Reviewed, not wired yet: runtime capability probes (the runtime lot wires it). | +| `allocation-protection.cheatengine-sdk-api.txt` | Reviewed, not wired: the public `Address`/`TargetAllocationRequest` contract is hand-written in Engine. | +| `modules-symbols-regions.cheatengine-sdk-api.txt` | Reviewed, not wired: the public inspection wrapper uses the same global directly. | +| `addresslist-memoryrecord.cheatengine-sdk-api.txt`| Header-only reservation: needs the object grammar. | +| `aob-stringlist.cheatengine-sdk-api.txt` | Header-only reservation: needs the object-result grammar. | +| `memscan-foundlist.cheatengine-sdk-api.txt` | Header-only reservation: needs the object-method grammar. | + +A reviewed or reserved spec is not silently generated, and its presence does not imply that a wrapper exists. The +number of entries is not a coverage measure: the four `MemoryScalars` entries are four explicit contracts, not the Lua +surface of `CheatEngine.SDK.Engine`, most of which is hand-written (audit AX06-18; coverage is measured by the Lua +surface catalogue, not by this generator's spec count). `Parsing/ProductionSpecsTests.cs` reads the committed specs +and the Engine project file and fails when a spec with entries is neither wired nor in the reviewed list. 1. The generator keeps additional files whose name ends in `.cheatengine-sdk-api.txt`, ignoring case, and parses each one into a model of strings and enums. 2. It reports every malformed header or entry as a localized `CESDK3001` error on the originating additional file, then - drops only that invalid entry. A valid sibling entry still generates normally. + drops only that invalid entry. A valid sibling entry still generates normally. A file with entries and no + `contract: ce77` is `CESDK3003` and generates nothing; an invalid optional argument is `CESDK3004`; an invalid + optional or variadic result is `CESDK3005`. 3. It gives each generated type exactly one spec-file owner. A duplicate target type, wrapper member, or cache field reports `CESDK3002` on every participating file and emits none of the conflicting files. 4. Each remaining file becomes one generated source that compiles into `CheatEngine.SDK.Engine.dll`. @@ -66,7 +80,7 @@ CRLF line endings are tolerated. |----------------|--------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------| | `namespace` | header | 1 | Namespace of the generated type. An empty value means the global namespace. | | `type` | header | 1 | Name of the one `public static partial class` the file emits. | -| `contract` | header | 0 or 1 | `ce77` opts into the mandatory, machine-validated CE 7.7 evidence contract below. | +| `contract` | header | 1 with entries | `ce77`: the mandatory, machine-validated CE 7.7 evidence contract below (`CESDK3003` when a file with entries omits it). | | `provenance` | header | with `contract` | A proof status followed by `: ` and an immutable source reference. | | `minimum-ce` | header | with `contract` | Exact four-part minimum CE version, for example `7.7.0.10621`. | | `architecture` | header | with `contract` | `x64`; this generator makes no public target-address claim on another architecture. | @@ -74,30 +88,34 @@ CRLF line endings are tolerated. | `ownership` | header | with `contract` | `none`, `borrowed`, or `owned`. Object contracts remain deliberately outside the scalar grammar. | | `global` | entry | 1 | The Lua global to call: an ASCII identifier that is not a Lua 5.3 reserved word. | | `method` | entry | 1 | The C# method name. A C# reserved word gets an `@` prefix. | -| `form` | entry | 1 | `try` returns `bool` and writes `out` results. `throwing` returns the value, or `void`, and raises `LuaException` when the Lua call fails. | +| `form` | entry | 1 | `try` returns `bool` and writes `out` results. `outcome` returns `LuaOperationStatus` and writes `out` results. `throwing` returns the value, or `void`, and raises `LuaException` when the Lua call fails. | | `doc` | entry | 1 | One line of original English, emitted as the XML ``. Never copy Cheat Engine documentation. | -| `arg` | entry | 0 or more | `name:kind`, one pushed argument, in order. | -| `fixed` | entry | 0 or more | `boolean:true` or `boolean:false`, one host-required Lua argument omitted from the C# signature, after all `arg` values. | +| `arg` | entry | 0 or more | `name:kind`, one pushed argument. `arg`, `fixed` and `opt` keep their textual order, which is the push order. | +| `fixed` | entry | 0 or more | `boolean:true` or `boolean:false`, one host-required Lua argument omitted from the C# signature. | +| `opt` | entry | 0 or more, last | `name:kind`, one `LuaOptional` argument: omitted (not pushed), `Nil` (pushed as `nil`) or a value. Only `opt` may follow (`CESDK3004`). | | `result` | entry | `try`: 1 or more | `name:kind`, one `out` result, in read order. Not allowed in a `throwing` entry. | +| `opt-result` | entry | 0 or more | `name:kind`, one `out LuaOptional` result after the `result` values: a position Lua did not return is omitted (`CESDK3005`). | +| `rest` | entry | `outcome`: 0 or 1 | `name:kind`, the variadic tail `Span name, out int nameCount`, last; kinds `int32`, `int64`, `single`, `double`, `boolean`. | | `return` | entry | `throwing`: 0 or 1 | The kind of the returned value. Omit it for `void`. Not allowed in a `try` entry. | | `nil` | entry | with `contract` | `none`, `absence`, `expected-failure`, or `lua-error`: CE result semantics after a protected call succeeds. | The kinds are `int32`, `int64`, `single`, `double`, `boolean`, `address`, `utf8`, `string` and `string?`. A `utf8` value -is valid only as an argument, because a span result would dangle once the wrapper restores the stack. Two entries may +is valid only as an argument, because a span result would dangle once the wrapper restores the stack. `utf8` and +`string?` cannot be optional: `nil` is the `Nil` state of `LuaOptional`, never a `null` string. An entry with +`opt-result` or `rest` calls Lua with `LUA_MULTRET` and reads the factual result count: fewer values than the `result` +entries is `LuaOperationStatusKind.MissingResult` (a `false` Try result), never `nil`. Two entries may bind the same `global`, for example a `try` and a `throwing` form. They share one cache field. A `method` name used by more than one entry, a parameter that collides with an emitted local, or an identity that collides with a generated raw core/cache field is rejected with `CESDK3001` before code generation. ### CE 7.7 evidence contract -All shipping and newly authored EngineApi specs use `contract: ce77`. The header carries shared facts; each entry adds -`nil`, while the existing `form` and `return` fields remain the public return contract. The parser copies these facts to -every `SpecCallModel`, validates them at the actual field location, and emits them in XML ``. This makes -provenance, minimum version, architecture, thread-affinity, ownership, return form and nil/absence/error semantics -inspectable without treating a comment as an API contract. - -Legacy fixtures without `contract` remain readable only to keep the repository migration incremental. They cannot use -contract fields or `nil`; new production specs must not use that compatibility path. +Every spec file with entries uses `contract: ce77` (`CESDK3003` otherwise); only a header-only reservation may omit +it. The header carries shared facts; each entry adds `nil`, while the existing `form` and `return` fields remain the +public return contract. The parser copies these facts to every `SpecCallModel`, validates them at the actual field +location, and emits them in XML ``. This makes provenance, minimum version, architecture, thread-affinity, +ownership, return form and nil/absence/error semantics inspectable without treating a comment as an API contract. +`thread` stays `unknown` until a CE 7.7 host probe proves an affinity (audit SRC02-02). ```text namespace: CheatEngine.SDK.Engine.Generated @@ -129,8 +147,10 @@ Every entry that uses `address` yields two methods. A private `__Raw` co the public wrapper exposes `global::CheatEngine.SDK.Engine.Values.Address` for arguments, `try` outputs, and throwing returns. It converts arguments with `unchecked((nuint)address.ToUInt64())` and raw results with `new Address(unchecked((ulong)raw))`. The example above yields the public -`bool TryReadInt32(Address address, out int value)`. A spec with an `address` contract compiles only where -`CheatEngine.SDK.Engine.Values.Address` exists. +`bool TryReadInt32(Address address, out int value)`. An optional address is `LuaOptional
` on the public side +and `LuaOptional` in the core; the facade keeps its state (omitted, nil or value) inline and allocation-free. The +`outcome` form returns the core's `LuaOperationStatus` after converting its address results. A spec with an `address` +contract compiles only where `CheatEngine.SDK.Engine.Values.Address` exists. `Specs/memory-scalars.cheatengine-sdk-api.txt` is the one spec currently wired into the Engine build, and it also shows the `throwing` form. Its entries produce `CheatEngine.SDK.Engine.Generated.MemoryScalars` with `TryReadInt32`, @@ -138,12 +158,28 @@ the `throwing` form. Its entries produce `CheatEngine.SDK.Engine.Generated.Memor it. Adding another manifest is an API change: first extend the grammar with a localized diagnostic for every unsupported shape, then include that one file and add semantic, fixture and live-opt-in coverage appropriate to its contract. +### Contract, public projection and emitted code are checked separately + +A formatting change is not a contract change, and a textually stable file can still change the public API (audit +A19-23). Three independent checks therefore exist: + +- **Contract**: the parsed spec model (`Parsing/ProductionSpecsTests.cs`, and + `Reformatting_a_spec_keeps_its_contract_and_call_model`, which reformats a spec and requires an equal contract and + call model); +- **Public projection**: `libs/CheatEngine.SDK.Engine/PublicAPI.*.txt`, enforced by the build (RS0016/RS0017); +- **Emitted code**: the committed `MemoryScalars` text (`Memory_scalars_output_is_unchanged_by_the_new_grammar`), + compiled clean and executed against stand-in globals (`EndToEnd/`). + ## Promise | You can rely on | Backed by | |---------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| | The generator never ships. | `src/CheatEngine.SDK/CheatEngine.SDK.csproj` packs only the analyzer references it marks `PackAsAnalyzer`, and it does not reference this project. | | Every invalid spec issue is a localized `CESDK3001` error; an unrelated valid entry still emits. | `Parsing/SpecFileParserTests.cs`, `Generator/DiagnosticsTests.cs` | +| A spec with entries must declare `contract: ce77` (`CESDK3003`); every production spec parses without an issue, cites the pinned `celua.txt` SHA-256, claims no unproven thread affinity, and is wired or reviewed. | `Parsing/ProductionSpecsTests.cs`, `Spec_with_entries_without_contract_reports_the_missing_ce77_contract` | +| `opt` arguments are omitted, `nil` or pushed exactly as declared; `opt-result` distinguishes zero results from `nil`; `rest` copies every value or reports the needed capacity; an absent global is `GlobalUnavailable` in the `outcome` form. | `EndToEnd/EngineApiOptionalEndToEndTests.cs`, `Parsing/SpecFileParserTests.cs` (`CESDK3004`/`CESDK3005` rows) | +| The wired `MemoryScalars` output is byte-identical to its committed text; a reformatted spec (spaces, comments, CRLF) keeps the same contract and call model. | `Memory_scalars_output_is_unchanged_by_the_new_grammar`, `Reformatting_a_spec_keeps_its_contract_and_call_model` | +| Every EngineApi diagnostic has a documentation page, a help link and a release-tracking row. | `Generator/EngineApiDiagnosticCatalogTests.cs` | | A ce77 spec carries validated provenance/version/architecture/thread/ownership/return/nil facts on every entry and projects them into XML documentation. | `Parsing/SpecFileParserTests.cs`, `Generator/EmissionTests.cs` | | A spec file exclusively owns its generated type; duplicate type/member/cache identities are `CESDK3002` errors at each exact field and emit neither file. | `Generator/DiagnosticsTests.cs` | | Emitted code compiles without errors or warnings against the real `CheatEngine.SDK.Annotations`, `CheatEngine.SDK.Lua.Interop` and `CheatEngine.SDK.Lua`. | `Generator/EmissionTests.cs` | diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/addresslist-memoryrecord.cheatengine-sdk-api.txt b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/addresslist-memoryrecord.cheatengine-sdk-api.txt index aad6f7fd..d235d5b6 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/addresslist-memoryrecord.cheatengine-sdk-api.txt +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/addresslist-memoryrecord.cheatengine-sdk-api.txt @@ -16,3 +16,10 @@ namespace: CheatEngine.SDK.Engine.AddressLists type: AddressListBindings +contract: ce77 +provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00 +minimum-ce: 7.7.0.10621 +architecture: x64 +# The MainThreadOnly inference above is not evidence: `unknown` until the CE 7.7 dispatcher probe confirms it. +thread: unknown +ownership: borrowed diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/allocation-protection.cheatengine-sdk-api.txt b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/allocation-protection.cheatengine-sdk-api.txt index 5dd5e809..57b5952d 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/allocation-protection.cheatengine-sdk-api.txt +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/allocation-protection.cheatengine-sdk-api.txt @@ -3,28 +3,38 @@ # AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00, lines 682-683. # PinnedUpstream comparison: cheat-engine/cheat-engine commit ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37. # Architecture: CE 7.7.0.10621 x64. Both globals require the enabled plugin; CE's catalog does not establish GUI-thread affinity. -# This file is intentionally not yet an AdditionalFile of CheatEngine.SDK.Engine: current EngineApi output exposes -# address results as nuint and cannot represent CE optional arguments as the public Address/TargetAllocationRequest contract. -# The future generator integration must retain this provenance and map a nil allocation result to an expected CE failure. +# The grammar now represents CE's optional arguments ('opt:', LuaOptional: omitted, nil or a value), so an omitted +# base address or protection is not pushed at all. This file is still intentionally not an AdditionalFile of +# CheatEngine.SDK.Engine: the remaining blocker is the hand-written public contract (Address and +# TargetAllocationRequest, Engine/Allocation, owned by S-RES), which a generated wrapper must not duplicate. +# The future integration must retain this provenance and map a nil allocation result to an expected CE failure. # No CE 7.7 celua.txt global for changing protection after allocation is asserted here; Protection is an allocateMemory input. namespace: CheatEngine.SDK.Engine.Generated type: TargetAllocationLua +contract: ce77 +provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00 +minimum-ce: 7.7.0.10621 +architecture: x64 +thread: unknown +ownership: owned # allocateMemory(size, BaseAddress OPTIONAL, Protection OPTIONAL): allocation address, or an expected operation failure. global: allocateMemory method: TryAllocate form: try arg: size:int64 -arg: preferredBaseAddress:address -arg: protection:int32 +opt: preferredBaseAddress:address +opt: protection:int32 result: address:address -doc: Attempts to allocate target-process memory with an explicit base preference and page protection. +nil: expected-failure +doc: Attempts to allocate target-process memory, optionally at a preferred base address and with a page protection. # deAlloc(address, size OPTIONAL): releases target memory created by allocateMemory. global: deAlloc method: Deallocate form: throwing arg: address:address -arg: size:int64 -doc: Releases a target-process allocation using the original request size. +opt: size:int64 +nil: none +doc: Releases a target-process allocation, optionally stating the original request size. diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/aob-stringlist.cheatengine-sdk-api.txt b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/aob-stringlist.cheatengine-sdk-api.txt index 78fc2147..97692d77 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/aob-stringlist.cheatengine-sdk-api.txt +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/aob-stringlist.cheatengine-sdk-api.txt @@ -11,3 +11,9 @@ namespace: CheatEngine.SDK.Engine.Generated type: AobStringListBindings +contract: ce77 +provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00 +minimum-ce: 7.7.0.10621 +architecture: x64 +thread: unknown +ownership: owned diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/memscan-foundlist.cheatengine-sdk-api.txt b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/memscan-foundlist.cheatengine-sdk-api.txt index 5de83578..2f84e033 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/memscan-foundlist.cheatengine-sdk-api.txt +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/memscan-foundlist.cheatengine-sdk-api.txt @@ -13,3 +13,9 @@ namespace: CheatEngine.SDK.Engine.Scanning.Values type: MemScanFoundListBindings +contract: ce77 +provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00 +minimum-ce: 7.7.0.10621 +architecture: x64 +thread: unknown +ownership: owned diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/modules-symbols-regions.cheatengine-sdk-api.txt b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/modules-symbols-regions.cheatengine-sdk-api.txt index 8aed33aa..44649192 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/modules-symbols-regions.cheatengine-sdk-api.txt +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/modules-symbols-regions.cheatengine-sdk-api.txt @@ -9,6 +9,12 @@ namespace: CheatEngine.SDK.Engine.Generated type: InspectionPrimitives +contract: ce77 +provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00 +minimum-ce: 7.7.0.10621 +architecture: x64 +thread: unknown +ownership: none # The two optional flags are deliberately emitted as fixed false values. The # public EngineInspection wrapper has a richer typed options contract and uses @@ -20,4 +26,5 @@ arg: expression:utf8 fixed: boolean:false fixed: boolean:false result: address:address +nil: absence doc: Resolves a Cheat Engine symbol expression with the default safe lookup flags. diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/runtime-capabilities.cheatengine-sdk-api.txt b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/runtime-capabilities.cheatengine-sdk-api.txt index b8427c6c..97729949 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/runtime-capabilities.cheatengine-sdk-api.txt +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/runtime-capabilities.cheatengine-sdk-api.txt @@ -1,21 +1,39 @@ -# Cheat Engine 7.7 scalar runtime capability probes. +# Cheat Engine 7.7 raw runtime capability probes, generated into CheatEngine.SDK.Engine.Generated.RuntimeCapabilityProbes. # -# Provenance: CE 7.7.0.10621 x64 celua.txt (SHA-256 -# AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00). -# Names and result shapes follow that catalog. The version-table global is intentionally absent because the current -# EngineApi grammar has no table result kind; do not reconstruct it from getCEVersion's floating-point result. +# These wrappers return Cheat Engine's raw facts, one global per method, and raise LuaException when a call fails or +# returns a value the result marshaller cannot read. They read results with the EngineApi marshallers, so an int32 +# result also accepts what lua_tointegerx converts (an integral float such as 4.0, or an integer numeral string such as +# '4'), whereas the structured API requires the Lua integer subtype Cheat Engine pushes and reports anything else as +# InvalidResult. The structured, outcome-returning API is RuntimeHostOperations, RuntimeProcessOperations and +# RuntimeObservations (CheatEngine.SDK.Engine.Processes); prefer it. In particular, with no target selected Cheat Engine +# reports x64-like target facts (x86 family, 64-bit, pointer size 8; spike C3 D2), so read getOpenedProcessID first and +# ignore every target fact while it is 0. # -# This file is a curated input only. CheatEngine.SDK.Engine.csproj must list it as an AdditionalFiles item before the -# EngineApi generator will emit RuntimeCapabilityProbes. +# Read-only on purpose: setPointerSize, setAssemblerMode, openProcess, openFileAsProcess and every dbk_* or dbvm_* global +# change Cheat Engine or the target, or load a driver, and are deliberately absent (audit A17-18, Q45). +# getCheatEngineFileVersion stays hand-written (RuntimeHostOperations.TryGetCheatEngineFileVersion) because it returns a +# packed integer and a table; the current EngineApi grammar has no table result kind, and the value must never be +# reconstructed from getCEVersion's floating-point result. +# +# The celua.txt line of each global is given as a locator only; the descriptions are written independently. +# Format: source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/README.md. namespace: CheatEngine.SDK.Engine.Generated type: RuntimeCapabilityProbes +contract: ce77 +provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00 +minimum-ce: 7.7.0.10621 +architecture: x64 +# No CE GUI-thread proof exists for these probes: `unknown` grants no thread-safety claim (audit SRC02-02). +thread: unknown +ownership: none # getCEVersion(): CE's legacy floating-point display version. global: getCEVersion method: GetCheatEngineVersion form: throwing return: double +nil: none doc: Gets Cheat Engine's legacy floating-point version value; it is not a complete file version. # getSystemArchitecture(): 0=i386, 1=x86_64, 2=arm32, 3=arm64 in CE 7.7. @@ -23,6 +41,7 @@ global: getSystemArchitecture method: GetSystemArchitectureCode form: throwing return: int32 +nil: none doc: Gets Cheat Engine's documented host architecture code for the current runtime. # getABI(): 0=Windows, 1=Unix/Linux target calling-convention family in CE 7.7. @@ -30,28 +49,65 @@ global: getABI method: GetTargetAbiCode form: throwing return: int32 +nil: none doc: Gets Cheat Engine's documented target ABI family code for the current runtime. global: cheatEngineIs64Bit method: IsCheatEngine64Bit form: throwing return: boolean +nil: none doc: Gets whether Cheat Engine reports itself as a 64-bit process. global: targetIs64Bit method: IsTarget64Bit form: throwing return: boolean +nil: none doc: Gets whether Cheat Engine reports the selected target as a 64-bit process. global: targetIsX86 method: IsTargetX86 form: throwing return: boolean +nil: none doc: Gets whether Cheat Engine reports the selected target as x86 based. global: targetIsArm method: IsTargetArm form: throwing return: boolean +nil: none doc: Gets whether Cheat Engine reports the selected target as ARM based. + +# targetIsAndroid(), celua.txt:715. +global: targetIsAndroid +method: IsTargetAndroid +form: throwing +return: boolean +nil: none +doc: Gets whether Cheat Engine reports an Android target. + +# getPointerSize(), celua.txt:501: the configured pointer size; any integer that setPointerSize stored. +global: getPointerSize +method: GetConfiguredPointerSizeBytes +form: throwing +return: int32 +nil: none +doc: Gets Cheat Engine's raw configured pointer size, which is not the target bitness; RuntimeProcessOperations.TryGetConfiguredPointerSize validates it. + +# getOperatingSystem(), celua.txt:14: 0=Windows, 1=macOS, 2=Linux. +global: getOperatingSystem +method: GetOperatingSystemCode +form: throwing +return: int32 +nil: none +doc: Gets Cheat Engine's raw operating-system code; RuntimeHostOperations.TryGetOperatingSystem decodes it. + +# isConnectedToCEServer(), celua.txt:4415. +global: isConnectedToCEServer +method: IsConnectedToCEServer +form: throwing +return: boolean +nil: none +doc: Gets whether Cheat Engine is connected to CEServer, in which case a local process identity does not describe the target. diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/packages.lock.json b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/packages.lock.json new file mode 100644 index 00000000..64d57ef4 --- /dev/null +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/packages.lock.json @@ -0,0 +1,141 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.16.0, )", + "resolved": "1.16.0", + "contentHash": "3kdIIceBPumwjw279FuiVMfVENT2cGASXJgcigdySsbX2dJB8ofUgG6i47yqF/k1qu6fvNR3csrSekZPviR6kQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", + "dependencies": { + "System.Collections.Immutable": "10.0.1" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs index ffbb1dcd..6641af32 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs @@ -95,8 +95,8 @@ public static SourceText Emit(BootstrapModel model) internal static string ChooseFactoryName(string fullyQualifiedPluginTypeName) { bool collides = fullyQualifiedPluginTypeName.StartsWith(QualifiedFactoryName, StringComparison.Ordinal) - && (fullyQualifiedPluginTypeName.Length == QualifiedFactoryName.Length - || fullyQualifiedPluginTypeName[QualifiedFactoryName.Length] == '.'); + && (fullyQualifiedPluginTypeName.Length == QualifiedFactoryName.Length + || fullyQualifiedPluginTypeName[QualifiedFactoryName.Length] == '.'); return collides ? AlternateFactoryName : FactoryName; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs index 7832ce47..8e008a51 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs @@ -93,7 +93,7 @@ public static EntryPointContractSymbols Resolve(Compilation compilation) foreach (MetadataReference reference in compilation.References) { if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly - && string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) + && string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) { return assembly.GetTypeByMetadataName(metadataName); } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs index 5ba484e2..1e77ee26 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs @@ -82,8 +82,8 @@ private static void CollectFrom( foreach (AttributeData attribute in attributes) { if (ReadDeclaredId(attribute, experimentalAttribute, obsoleteAttribute) is { } id - && IsUsableInPragma(id) - && (ids is null || !ids.Contains(id))) + && IsUsableInPragma(id) + && (ids is null || !ids.Contains(id))) { (ids ??= []).Add(id); } @@ -96,24 +96,24 @@ private static void CollectFrom( INamedTypeSymbol? obsoleteAttribute) { if (experimentalAttribute is not null - && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, experimentalAttribute)) - // [Experimental(string diagnosticId)] + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, experimentalAttribute)) + // [Experimental(string diagnosticId)] { return attribute.ConstructorArguments.Length == 1 - && attribute.ConstructorArguments[0] is - { Kind: TypedConstantKind.Primitive, Value: string experimentalId } + && attribute.ConstructorArguments[0] is + { Kind: TypedConstantKind.Primitive, Value: string experimentalId } ? experimentalId : null; } if (obsoleteAttribute is not null - && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute)) - // [Obsolete(..., DiagnosticId = "ID")] + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute)) + // [Obsolete(..., DiagnosticId = "ID")] { foreach (KeyValuePair argument in attribute.NamedArguments) { if (string.Equals(argument.Key, "DiagnosticId", StringComparison.Ordinal) - && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) + && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) { return obsoleteId; } @@ -129,7 +129,7 @@ private static void CollectFrom( private static bool IsUsableInPragma(string id) { return SyntaxFacts.IsValidIdentifier(id) - && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None - && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; + && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None + && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs index 4620c2ca..b32aaa9d 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs @@ -29,8 +29,8 @@ public static bool HasEntryPointTypeCollision(Compilation compilation) foreach (INamedTypeSymbol type in @namespace.GetTypeMembers(ManagedEntryPointNames.TypeName)) { if (type.Arity == 0 - && !type.IsFileLocal - && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilation.Assembly)) + && !type.IsFileLocal + && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilation.Assembly)) { return true; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/packages.lock.json b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/packages.lock.json new file mode 100644 index 00000000..64d57ef4 --- /dev/null +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/packages.lock.json @@ -0,0 +1,141 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.16.0, )", + "resolved": "1.16.0", + "contentHash": "3kdIIceBPumwjw279FuiVMfVENT2cGASXJgcigdySsbX2dJB8ofUgG6i47yqF/k1qu6fvNR3csrSekZPviR6kQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", + "dependencies": { + "System.Collections.Immutable": "10.0.1" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs index 7af12ef8..11eef56a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs @@ -215,7 +215,7 @@ private static void WriteStackCheck(SourceWriter writer, LuaObjectMethodModel mo writer.Write('('); writer.Write(CSharpLiteral.ToStringLiteral( "The Lua stack could not grow by " + requiredSlots.ToString(CultureInfo.InvariantCulture) - + " slots to call '" + model.LuaName + "'.")); + + " slots to call '" + model.LuaName + "'.")); writer.WriteLine(");"); } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs index 41dc2aa4..4a6510f0 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs @@ -35,5 +35,5 @@ internal sealed record LuaFunctionModel( /// group). ///
public bool IsValid => Issues == LuaFunctionShapeIssues.None && ContainingTypeIssues == ContainingTypeIssues.None && - Thunk is not null && !HasGeneratedIdentityCollision; + Thunk is not null && !HasGeneratedIdentityCollision; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs index 203502fc..d6b50a7f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs @@ -67,7 +67,10 @@ private static List AssignHintNames(List when a body can be emitted for this method.
public bool IsValid => Issues == LuaGlobalShapeIssues.None && ContainingTypeIssues == ContainingTypeIssues.None && - Call is not null && !HasGeneratedIdentityCollision; + Call is not null && !HasGeneratedIdentityCollision; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs index 8b4cf4b5..d865efd9 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs @@ -85,7 +85,10 @@ private static List AssignHintNames(List tables) for (int i = 0; i < tables.Count; i++) { string baseName = tables[i].ContainingType.HintBaseName; - tables[i] = tables[i] with { HintName = HintNames.AllocateUnique(baseName, HintSuffix, used) }; + tables[i] = tables[i] with + { + HintName = HintNames.AllocateUnique(baseName, HintSuffix, used) + }; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs index 94a58a4f..fa885b88 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs @@ -75,7 +75,7 @@ private static bool IsReadOnly(INamedTypeSymbol type) foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) { if (reference.GetSyntax() is TypeDeclarationSyntax declaration - && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) + && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) { return true; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingSymbols.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingSymbols.cs index 576f7286..eadeb10a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingSymbols.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingSymbols.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Immutable; +using CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; + using Microsoft.CodeAnalysis; namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; @@ -10,6 +12,13 @@ namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Parsing; /// key: matching it structurally would let an unrelated source or referenced assembly impersonate an SDK annotation /// or LuaState. ///
+/// +/// Every lookup keeps the candidate defined by the expected SDK assembly among all types with the metadata name +/// (Compilation.GetTypesByMetadataName). GetTypeByMetadataName would return a same-named source type +/// first, or on ambiguity, and so let a look-alike hide the real type +/// (https://learn.microsoft.com/dotnet/api/microsoft.codeanalysis.compilation.gettypebymetadataname). The analyzer +/// resolves the same symbols per referenced assembly (SdkSymbolResolver). +/// internal static class LuaBindingSymbols { private const string AnnotationsAssemblyName = "CheatEngine.SDK.Annotations"; @@ -60,6 +69,18 @@ public static bool ContainsSdkAttribute(ImmutableArray attributes return ResolveSdkType(compilation, LuaMarshallerContractMetadataName, LuaAssemblyName); } + /// Gets the actual LuaOptional<T>, or when it is unavailable. + public static INamedTypeSymbol? ResolveLuaOptional(Compilation compilation) + { + return ResolveSdkType(compilation, LuaContractTypes.LuaOptionalMetadataName, LuaAssemblyName); + } + + /// Gets the actual LuaOperationStatus, or when it is unavailable. + public static INamedTypeSymbol? ResolveLuaOperationStatus(Compilation compilation) + { + return ResolveSdkType(compilation, LuaContractTypes.LuaOperationStatusMetadataName, LuaAssemblyName); + } + /// /// Reads the name argument belonging to the resolved SDK attribute. An unrelated attribute with the same /// metadata name is ignored even when Roslyn's discovery predicate delivered it. @@ -91,9 +112,14 @@ public static bool ContainsSdkAttribute(ImmutableArray attributes private static INamedTypeSymbol? ResolveSdkType(Compilation compilation, string metadataName, string assemblyName) { - INamedTypeSymbol? type = compilation.GetTypeByMetadataName(metadataName); - return type is not null && string.Equals(type.ContainingAssembly.Name, assemblyName, StringComparison.Ordinal) - ? type - : null; + foreach (INamedTypeSymbol type in compilation.GetTypesByMetadataName(metadataName)) + { + if (string.Equals(type.ContainingAssembly?.Name, assemblyName, StringComparison.Ordinal)) + { + return type; + } + } + + return null; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs index ea7f0b3f..8d08bae3 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs @@ -81,24 +81,24 @@ public static string Collect(IMethodSymbol method) } if (string.Equals(attributeClass.Name, "ExperimentalAttribute", StringComparison.Ordinal)) - // [Experimental(string diagnosticId)] + // [Experimental(string diagnosticId)] { return IsNamespace(attributeClass.ContainingNamespace, "System", "Diagnostics", "CodeAnalysis") - && attribute.ConstructorArguments.Length == 1 - && attribute.ConstructorArguments[0] is - { Kind: TypedConstantKind.Primitive, Value: string experimentalId } + && attribute.ConstructorArguments.Length == 1 + && attribute.ConstructorArguments[0] is + { Kind: TypedConstantKind.Primitive, Value: string experimentalId } ? experimentalId : null; } if (string.Equals(attributeClass.Name, "ObsoleteAttribute", StringComparison.Ordinal) - && IsNamespace(attributeClass.ContainingNamespace, "System")) - // [Obsolete(..., DiagnosticId = "ID")] + && IsNamespace(attributeClass.ContainingNamespace, "System")) + // [Obsolete(..., DiagnosticId = "ID")] { foreach (KeyValuePair argument in attribute.NamedArguments) { if (string.Equals(argument.Key, "DiagnosticId", StringComparison.Ordinal) - && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) + && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) { return obsoleteId; } @@ -130,7 +130,7 @@ private static bool IsNamespace(INamespaceSymbol? @namespace, params string[] na private static bool IsUsableInPragma(string id) { return SyntaxFacts.IsValidIdentifier(id) - && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None - && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; + && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None + && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs index bf4789c0..43a5d565 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs @@ -27,7 +27,7 @@ public static LuaClassModel Parse(GeneratorAttributeSyntaxContext context, Cance string? luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, LuaBindingsGenerator.LuaClassAttributeMetadataName); bool isValid = isSdkAttribute && LuaNames.IsValidName(luaName) - && IsBorrowedHandleShape(type, compilation, cancellationToken); + && IsBorrowedHandleShape(type, compilation, cancellationToken); return new LuaClassModel(ContainingTypeParser.Parse(type), luaName ?? string.Empty, isValid); } @@ -71,7 +71,7 @@ internal static bool IsGeneratedHandle(INamedTypeSymbol type, Compilation compil { ImmutableArray attributes = ImmutableArray.Create(attribute); if (LuaBindingSymbols.ContainsSdkAttribute(attributes, compilation, - LuaBindingsGenerator.LuaClassAttributeMetadataName)) + LuaBindingsGenerator.LuaClassAttributeMetadataName)) { return LuaNames.IsValidName(LuaBindingSymbols.ReadSdkAttributeName( attributes, @@ -89,7 +89,7 @@ private static bool IsReadOnlyStruct(INamedTypeSymbol type, CancellationToken ca { cancellationToken.ThrowIfCancellationRequested(); if (reference.GetSyntax(cancellationToken) is StructDeclarationSyntax declaration - && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) + && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) { return true; } @@ -103,10 +103,10 @@ private static bool IsReadOnlyStruct(INamedTypeSymbol type, CancellationToken ca private static bool HasGeneratedIdentityCollision(INamedTypeSymbol type, INamedTypeSymbol? ceObject) { return LuaClassGeneratedNames.IsGeneratedType(type.Name) - || HasGeneratedMember(type, ceObject) - || HasMember(type, "op_Equality") - || HasMember(type, "op_Inequality") - || HasCEObjectConstructor(type, ceObject); + || HasGeneratedMember(type, ceObject) + || HasMember(type, "op_Equality") + || HasMember(type, "op_Inequality") + || HasCEObjectConstructor(type, ceObject); } private static bool HasCEObjectConstructor(INamedTypeSymbol type, INamedTypeSymbol? ceObject) @@ -125,7 +125,7 @@ private static bool HasCEObjectConstructor(INamedTypeSymbol type, INamedTypeSymb IParameterSymbol parameter = constructor.Parameters[0]; if (parameter.RefKind == RefKind.None - && SymbolEqualityComparer.Default.Equals(parameter.Type, ceObject)) + && SymbolEqualityComparer.Default.Equals(parameter.Type, ceObject)) { return true; } @@ -139,7 +139,7 @@ private static bool HasGeneratedMember(INamedTypeSymbol type, INamedTypeSymbol? foreach (ISymbol member in type.GetMembers()) { if (LuaClassGeneratedNames.IsGeneratedMember(member.Name) - || LuaClassGeneratedNames.IsGeneratedAccessorCollision(member, ceObject)) + || LuaClassGeneratedNames.IsGeneratedAccessorCollision(member, ceObject)) { return true; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs index ad538b28..650f51ab 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs @@ -31,7 +31,8 @@ public static LuaFunctionModel Parse(GeneratorAttributeSyntaxContext context, Ca LuaFunctionShapeIssues issues = LuaFunctionShape.Inspect(compilation, method, LuaBindingSymbols.ResolveLuaState(compilation), LuaBindingSymbols.ResolveLuaMarshallerAttribute(compilation), - LuaBindingSymbols.ResolveLuaMarshallerContract(compilation), out LuaFunctionSignature signature); + LuaBindingSymbols.ResolveLuaMarshallerContract(compilation), + LuaBindingSymbols.ResolveLuaOptional(compilation), out LuaFunctionSignature signature); if (!isSdkAttribute || !LuaNames.IsValidName(luaName)) { issues |= LuaFunctionShapeIssues.InvalidName; @@ -65,13 +66,13 @@ private static bool HasGeneratedIdentityCollision(IMethodSymbol method, string? { INamedTypeSymbol type = method.ContainingType; if (type.GetMembers(LuaRegistrationEmitter.RegisterMethodName).Length != 0 - || type.GetMembers(LuaRegistrationEmitter.RegisterLeaseMethodName).Length != 0 - || type.GetMembers(LuaRegistrationEmitter.UnregisterMethodName).Length != 0) + || type.GetMembers(LuaRegistrationEmitter.RegisterLeaseMethodName).Length != 0 + || type.GetMembers(LuaRegistrationEmitter.UnregisterMethodName).Length != 0) { return true; } return LuaNames.IsValidName(luaName) - && type.GetMembers(LuaThunkModel.ThunkNameFor(luaName!)).Length != 0; + && type.GetMembers(LuaThunkModel.ThunkNameFor(luaName!)).Length != 0; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs index 4faf5d75..9e1f92e2 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs @@ -33,7 +33,9 @@ public static LuaGlobalModel Parse(GeneratorAttributeSyntaxContext context, Canc LuaGlobalShapeIssues issues = LuaGlobalShape.Inspect(compilation, method, LuaBindingSymbols.ResolveLuaState(compilation), LuaBindingSymbols.ResolveLuaMarshallerAttribute(compilation), - LuaBindingSymbols.ResolveLuaMarshallerContract(compilation), out LuaGlobalSignature signature); + LuaBindingSymbols.ResolveLuaMarshallerContract(compilation), + LuaBindingSymbols.ResolveLuaOptional(compilation), + LuaBindingSymbols.ResolveLuaOperationStatus(compilation), out LuaGlobalSignature signature); if (!isSdkAttribute || !LuaNames.IsValidName(luaName)) { issues |= LuaGlobalShapeIssues.InvalidName; @@ -150,14 +152,13 @@ private static bool HasGeneratedIdentityCollision(IMethodSymbol method, string? { foreach (IParameterSymbol parameter in method.Parameters) { - if (parameter.Name is "__L" or "__operation" or "__top" or "__ok" or "__status" or "__result" or - "__resolution" or "__exception") + if (LuaGlobalCallEmitter.IsReservedLocal(parameter.Name)) { return true; } } return LuaNames.IsValidName(luaName) - && method.ContainingType.GetMembers(LuaGlobalCallModel.CacheFieldFor(luaName!)).Length != 0; + && method.ContainingType.GetMembers(LuaGlobalCallModel.CacheFieldFor(luaName!)).Length != 0; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs index 7f6a79af..cc8147cd 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs @@ -38,13 +38,17 @@ public static LuaObjectMethodModel Parse(GeneratorAttributeSyntaxContext context bool described = TryDescribe(method, context.TargetNode as MethodDeclarationSyntax, out LuaObjectMethodModel model); bool valid = isSdkAttribute && LuaNames.IsValidName(luaName) - && LuaClassParser.IsGeneratedHandle(method.ContainingType, compilation, - cancellationToken) - && described; + && LuaClassParser.IsGeneratedHandle(method.ContainingType, compilation, + cancellationToken) + && described; if (valid) { - return model with { LuaName = luaName!, IsValid = true }; + return model with + { + LuaName = luaName!, + IsValid = true + }; } return new LuaObjectMethodModel( @@ -68,11 +72,11 @@ private static bool TryDescribe(IMethodSymbol method, MethodDeclarationSyntax? d ImmutableArray.CreateBuilder(method.Parameters.Length); ImmutableArray.Builder results = ImmutableArray.CreateBuilder(); bool valid = method.MethodKind == MethodKind.Ordinary - && !method.IsStatic - && method.IsPartialDefinition - && method.PartialImplementationPart is null - && !method.IsGenericMethod - && !method.IsAsync; + && !method.IsStatic + && method.IsPartialDefinition + && method.PartialImplementationPart is null + && !method.IsGenericMethod + && !method.IsAsync; valid &= DescribeParameters(method.Parameters, arguments, results); LuaCallForm form = results.Count == 0 ? LuaCallForm.Throwing : LuaCallForm.Try; @@ -103,7 +107,7 @@ private static bool DescribeParameters(ImmutableArray paramete foreach (IParameterSymbol parameter in parameters) { if (IsReserved(parameter.Name) || parameter.IsParams || parameter.IsOptional - || parameter.HasExplicitDefaultValue) + || parameter.HasExplicitDefaultValue) { valid = false; } @@ -112,7 +116,7 @@ private static bool DescribeParameters(ImmutableArray paramete { seenResult = true; if (!LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool nullable) - || !LuaValueKinds.CanBeResult(kind)) + || !LuaValueKinds.CanBeResult(kind)) { valid = false; } @@ -125,9 +129,9 @@ private static bool DescribeParameters(ImmutableArray paramete } if (parameter.RefKind != RefKind.None || seenResult - || !LuaValueKindMapper.TryMap(parameter.Type, - out LuaValueKind argumentKind, - out bool argumentNullable)) + || !LuaValueKindMapper.TryMap(parameter.Type, + out LuaValueKind argumentKind, + out bool argumentNullable)) { valid = false; } @@ -149,7 +153,7 @@ private static bool TryDescribeReturn(IMethodSymbol method, LuaCallForm form, ou if (form == LuaCallForm.Try) { return method.ReturnType.SpecialType == SpecialType.System_Boolean && !method.ReturnsByRef - && !method.ReturnsByRefReadonly; + && !method.ReturnsByRefReadonly; } if (method.ReturnsVoid) @@ -158,9 +162,9 @@ private static bool TryDescribeReturn(IMethodSymbol method, LuaCallForm form, ou } if (method.ReturnsByRef || method.ReturnsByRefReadonly - || !LuaValueKindMapper.TryMap(method.ReturnType, out LuaValueKind kind, - out returnNullable) - || !LuaValueKinds.CanBeResult(kind)) + || !LuaValueKindMapper.TryMap(method.ReturnType, out LuaValueKind kind, + out returnNullable) + || !LuaValueKinds.CanBeResult(kind)) { return false; } @@ -172,10 +176,10 @@ private static bool TryDescribeReturn(IMethodSymbol method, LuaCallForm form, ou private static bool IsReserved(string name) { return string.Equals(name, StateLocal, StringComparison.Ordinal) - || string.Equals(name, OperationLocal, StringComparison.Ordinal) - || string.Equals(name, TopLocal, StringComparison.Ordinal) - || string.Equals(name, StatusLocal, StringComparison.Ordinal) - || string.Equals(name, ResultLocal, StringComparison.Ordinal); + || string.Equals(name, OperationLocal, StringComparison.Ordinal) + || string.Equals(name, TopLocal, StringComparison.Ordinal) + || string.Equals(name, StatusLocal, StringComparison.Ordinal) + || string.Equals(name, ResultLocal, StringComparison.Ordinal); } private static string Modifiers(MethodDeclarationSyntax? declaration) diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs index 1efea72f..5e3513fa 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs @@ -29,13 +29,17 @@ public static LuaObjectPropertyModel Parse(GeneratorAttributeSyntaxContext conte LuaBindingsGenerator.LuaPropertyAttributeMetadataName); bool described = TryDescribe(property, declaration, out LuaObjectPropertyModel model); bool valid = isSdkAttribute && LuaNames.IsValidName(luaName) - && LuaClassParser.IsGeneratedHandle(property.ContainingType, compilation, - cancellationToken) - && described; + && LuaClassParser.IsGeneratedHandle(property.ContainingType, compilation, + cancellationToken) + && described; if (valid) { - return model with { LuaName = luaName!, IsValid = true }; + return model with + { + LuaName = luaName!, + IsValid = true + }; } return new LuaObjectPropertyModel( @@ -60,7 +64,7 @@ private static bool TryDescribe(IPropertySymbol property, PropertyDeclarationSyn bool isNullable = false; string modifiers = string.Empty; bool typeIsSupported = LuaValueKindMapper.TryMap(property.Type, out kind, out isNullable) - && LuaValueKinds.CanBeResult(kind); + && LuaValueKinds.CanBeResult(kind); bool definitionIsSupported = IsSupportedDefinition(property, declaration, typeIsSupported, out modifiers); bool accessorsAreSupported = TryDescribeAccessors(declaration, out bool hasGetter, out string getterModifiers, @@ -88,15 +92,15 @@ private static bool IsSupportedDefinition(IPropertySymbol property, PropertyDecl { modifiers = string.Empty; return declaration is not null - && !property.IsStatic - && !property.IsIndexer - && property.RefKind == RefKind.None - && declaration.Modifiers.Any(SyntaxKind.PartialKeyword) - && declaration.AccessorList is not null - && declaration.ExplicitInterfaceSpecifier is null - && property.PartialImplementationPart is null - && typeIsSupported - && TryModifiers(declaration, out modifiers); + && !property.IsStatic + && !property.IsIndexer + && property.RefKind == RefKind.None + && declaration.Modifiers.Any(SyntaxKind.PartialKeyword) + && declaration.AccessorList is not null + && declaration.ExplicitInterfaceSpecifier is null + && property.PartialImplementationPart is null + && typeIsSupported + && TryModifiers(declaration, out modifiers); } private static bool TryDescribeAccessors(PropertyDeclarationSyntax? declaration, out bool hasGetter, @@ -114,7 +118,7 @@ private static bool TryDescribeAccessors(PropertyDeclarationSyntax? declaration, foreach (AccessorDeclarationSyntax accessor in accessorList.Accessors) { if (!TryDescribeAccessor(accessor, ref hasGetter, ref getterModifiers, ref hasSetter, - ref setterModifiers)) + ref setterModifiers)) { return false; } @@ -127,7 +131,7 @@ private static bool TryDescribeAccessor(AccessorDeclarationSyntax accessor, ref ref string getterModifiers, ref bool hasSetter, ref string setterModifiers) { if (accessor.Body is not null || accessor.ExpressionBody is not null - || !TryAccessorModifiers(accessor, out string modifiers)) + || !TryAccessorModifiers(accessor, out string modifiers)) { return false; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/README.md b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/README.md index 806f090f..fe493eb5 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/README.md +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/README.md @@ -72,22 +72,57 @@ per function. `MyTrainer.Bindings.LuaGlobals.g.cs` holds one cached `LuaRef` per ## Supported shapes -| Lua type | C# types | Allowed as | -|----------|------------------------|----------------------------------------| -| integer | `int`, `long`, `nuint` | arguments and results | -| number | `float`, `double` | arguments and results | -| boolean | `bool` | arguments and results | -| string | `string`, `string?` | arguments and results | -| string | `ReadOnlySpan` | arguments, and `[LuaFunction]` results | - -A `[LuaGlobal]` method has one of two forms. A Try form returns `bool` and ends with `out` results, or with -`Span destination, out int written`. It returns `false` and defaults the results when the global is missing, -raises or returns the wrong kind. A throwing form returns `void` or one value and throws `LuaException` in those cases. -A `bool` return without `out` results is a throwing form that reads a Lua boolean. - -Cheat Engine-specific optional arguments remain ordinary binding parameters. For example, request a signed -`readInteger` result through a private generated raw binding and pass `true` from a public helper, as above. That keeps -the public `int` API from receiving an unsigned value that cannot represent negative 32-bit results. +| Lua type | C# types | Allowed as | +|-----------------------------|------------------------------------------------------------|--------------------------------------------------------------------------------| +| integer | `int`, `long`, `nuint` | arguments and results | +| number | `float`, `double` | arguments and results | +| boolean | `bool` | arguments and results | +| string | `string`, `string?` | arguments and results | +| string | `ReadOnlySpan` | arguments, and `[LuaFunction]` results | +| any of the above, or absent | `LuaOptional`, `T` one of the types above except spans | trailing `[LuaGlobal]` and `[LuaFunction]` arguments; trailing `out` results | +| several integers/numbers | `Span values, out int count`, `T` a scalar type above | the last results of an Outcome-form `[LuaGlobal]` | + +A `[LuaGlobal]` method has one of three forms: + +- A **Try** form returns `bool` and ends with `out` results, or with `Span destination, out int written`. It + returns `false` and defaults the results when the global is missing, raises or returns the wrong kind. +- An **Outcome** form returns `LuaOperationStatus` with the same results and says which of those happened: + `GlobalUnavailable`, `LuaFailure` (with the `LuaStatus` of the call), `NilResult`, `InvalidResult`, `MissingResult` + or `ResultCapacityExceeded`. `default(LuaOperationStatus)` is `Unknown`, never `Success`. +- A **throwing** form returns `void` or one value and throws `LuaException` in those cases. A `bool` return without + `out` results is a throwing form that reads a Lua boolean. + +### Try, Outcome and throwing semantics + +- A Try form's `bool` is the success of the binding (global resolved, call returned, every declared result read), not + a Lua value. A Lua `false` read into `out bool` is a successful call that stores `false` (audit A07-21). +- Categories come from the call status and the stack, never from Lua error text. A raise is `LuaFailure` with + `LuaStatus.RuntimeError`; the throwing form keeps that status even when the error object is a table whose + `__tostring` raises, and its message then names the object's type (no metamethod runs). +- A value of the wrong kind after a successful call is `InvalidResult` (throwing form: `LuaException` with + `LuaStatus.Ok` and a message that names the Lua type received). A custom `[LuaMarshaller]` that refuses a value + produces the same category; an exception thrown by the marshaller propagates unchanged, with the stack restored. +- A binding whose results are all required asks Lua for exactly that many values: a global that returns nothing reads + as `nil` there (`NilResult`). A binding with an optional or variadic result reads the factual number of values + (`LUA_MULTRET`): zero results leave a `LuaOptional` result omitted, an explicit `nil` makes it `Nil`, a missing + required result is `MissingResult`, and extra values are ignored unless a variadic tail takes them. A variadic tail + larger than `values` is `ResultCapacityExceeded` with the needed count in `count`; nothing is copied then. + +### Optional arguments + +A trailing `LuaOptional` argument is omitted (`default`), `nil` (`LuaOptional.Nil()`) or a value +(`LuaOptional.Of(value)`). A `[LuaGlobal]` wrapper pushes the arguments up to the last present one, so the global sees +the shorter list (`select('#', ...)`) exactly as a Lua caller would write it; an omitted argument followed by a present +one throws `ArgumentException` before any Lua call. A `[LuaFunction]` thunk accepts between its required and its total +argument count, reads an absent trailing argument as omitted and `nil` as `Nil`, and raises +`wrong number of arguments to 'name' (1 to 3 expected)` outside that range. + +A Cheat Engine optional parameter can still be bound as an ordinary parameter when a fixed value is wanted. For +example, request a signed `readInteger` result through a private generated raw binding and pass `true` from a public +helper, as above. That keeps the public `int` API from receiving an unsigned value that cannot represent negative +32-bit results. + +### Rules and limits - The containing type, and every type around it, is `partial`, non-generic, a class, struct or record, and not `file` -local. @@ -100,6 +135,15 @@ the public `int` API from receiving an unsigned value that cannot represent nega - A `[LuaGlobal]` method is the defining declaration of a `static partial` method without a body. Arguments come first and `out` results last. A leading `LuaState` is admitted through `LuaRuntime.AcquireOperation(state)` rather than bypassing the lifecycle gate. +- `LuaOptional` arguments and results are trailing ([`CESDK2010`](../../analyzers/docs/CESDK2010.md), + [`CESDK2011`](../../analyzers/docs/CESDK2011.md)). A variadic `Span values, out int count` pair is the last result + of an Outcome form, one per method (`CESDK2011`). `LuaOptional` and `LuaOperationStatus` must be the SDK types; a + look-alike with the same name is refused ([`CESDK2012`](../../analyzers/docs/CESDK2012.md)). +- `LuaOptional` is not supported on `[LuaMethod]` and `[LuaProperty]` members yet + ([`CESDK2013`](../../analyzers/docs/CESDK2013.md)), nor with `ReadOnlySpan`. +- Integer, 64-bit and address values never pass through a `double`: a float at or above 2^53 is refused, not rounded + (see the marshaller policy of [`CheatEngine.SDK.Lua`](../../libs/CheatEngine.SDK.Lua/README.md)). +- There are no table or object results: a table where a scalar is declared is `InvalidResult`. - A `[LuaClass]` declaration is a non-generic `readonly partial struct`, including partial enclosing types. It is a borrowed handle only: the generated type implements `ICEObject` and `ILuaMarshaller` around `CEObject`. `Owned` is the sole representation of plugin ownership. @@ -113,7 +157,8 @@ the public `int` API from receiving an unsigned value that cannot represent nega retains its historical later-write behavior. - Several `[LuaGlobal]` methods can bind one global and share one cache. - `[LuaGlobal]` targets methods only. Lua global variables require a separate future contract. -- A `string?` argument rejects `nil` like a `string` argument. A `null` string result becomes `nil`. +- A `string?` argument of a `[LuaGlobal]` pushes `null` as `nil`; a `string?` argument of a `[LuaFunction]` rejects + `nil` like a `string` argument. The asymmetry is kept on purpose. A `null` string result becomes `nil`. - A Try-form `out string` result needs `[MaybeNullWhen(false)]` or `string?`. Without it the compiler reports `CS8601` in the generated file. @@ -149,6 +194,24 @@ Registration takes the address of each thunk, so only `[LuaFunction]` needs `All - The generator emits no conflicting source for a member it cannot bind, while the linked analyzer reports the localized contract diagnostic and healthy siblings continue to generate (`LuaObjectOutputTests`, `NoOutputTests`). - Editing one type re-emits only that type's file, and an unrelated edit re-emits nothing (`IncrementalityTests`). +- Reordering or editing optional bindings keeps hint names and member names, and re-runs only the global output + (`IncrementalityTests`). +- Omitted, `nil` and present `LuaOptional` arguments reach Lua as a shorter argument list, an explicit `nil` and a + value (`LuaGlobalOptionalArgumentEndToEndTests`, `LuaFunctionEndToEndTests`). +- Optional and variadic results read the factual number of values: zero results, `nil`, a missing required result and + a variadic tail above capacity stay distinct (`LuaGlobalResultCountEndToEndTests`). +- `nil`, `false`, 0, `''`, `{}`, no value and a raise stay distinguishable in every form, and the next call on the same + state succeeds after each failure (`LuaGlobalQ22MatrixEndToEndTests`, qualification Q22). +- Integer and address values at the 32-bit, 2^53 and 64-bit boundaries keep every bit or are refused, never rounded + (`LuaGlobalNumericBoundaryEndToEndTests`, `LuaValueKindsTests`, qualification Q21). +- Strings keep embedded NULs and their exact byte length; the UTF-16 forms decode invalid UTF-8 to U+FFFD while the + byte forms keep the raw bytes, and no span outlives the call frame (`LuaGlobalStringFidelityEndToEndTests`, + `LuaGlobalOutputTests`, qualification Q20). + +Contract, projection and emitted code are checked separately. The contract a declaration states is checked by the shape +rules and their analyzer diagnostics (`NoOutputTests`, `LuaBindingAnalyzerTests`); the public projection of a library +that ships generated members is checked by its PublicAPI analyzers (RS0016/RS0017); the emitted code is pinned by +snapshot tests (`LuaFunctionOutputTests`, `LuaGlobalOutputTests`) and executed by the end-to-end suites. ## Run the tests diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/packages.lock.json b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/packages.lock.json new file mode 100644 index 00000000..64d57ef4 --- /dev/null +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/packages.lock.json @@ -0,0 +1,141 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.16.0, )", + "resolved": "1.16.0", + "contentHash": "3kdIIceBPumwjw279FuiVMfVENT2cGASXJgcigdySsbX2dJB8ofUgG6i47yqF/k1qu6fvNR3csrSekZPviR6kQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", + "dependencies": { + "System.Collections.Immutable": "10.0.1" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs index dcf133dc..52ab476a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs @@ -149,7 +149,7 @@ private static void ParseOperation( ref ulong bitmap) { if (!TryReadOperationFields(value, reader, diagnostics, out JsonString idValue, out JsonNumber opcodeValue, - out JsonString? managedConstant)) + out JsonString? managedConstant)) { return; } @@ -162,7 +162,7 @@ private static void ParseOperation( } if (managedConstant is not null && - !string.Equals(managedConstant.Text, idValue.Text + "Operation", StringComparison.Ordinal)) + !string.Equals(managedConstant.Text, idValue.Text + "Operation", StringComparison.Ordinal)) { diagnostics.Add(reader.CreateDiagnostic(managedConstant.Span, "Property 'managed.constant' must be '" + idValue.Text + "Operation' for operation '" + idValue.Text + @@ -170,8 +170,8 @@ private static void ParseOperation( } if (!int.TryParse(opcodeValue.Text, NumberStyles.None, CultureInfo.InvariantCulture, out int opcode) - || opcode < 0 - || opcode > 63) + || opcode < 0 + || opcode > 63) { diagnostics.Add(reader.CreateDiagnostic(opcodeValue.Span, "Operation 'opcode' must be an integer between 0 and 63.")); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/README.md b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/README.md index 7445597f..6b4f7cc4 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/README.md +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/README.md @@ -5,7 +5,7 @@ Repository-only incremental Roslyn generator for the managed operation selector ## Objective -`eng/lua-bridge/protected-operations.json` is passed explicitly as an `AdditionalFile` to +`libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json` is passed explicitly as an `AdditionalFile` to `CheatEngine.SDK.Lua.Interop`. This component validates the bridge-relevant parts of that catalogue and emits the internal `LuaProtectedOperation` enum plus `LuaProtectedOperationContract.Count`, `RequiredBitmap` and `IsDefined`. The enum is ordered by numeric opcode, never by JSON-array position; the bitmap is independently derived and must diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/packages.lock.json b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/packages.lock.json new file mode 100644 index 00000000..b52c3444 --- /dev/null +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/packages.lock.json @@ -0,0 +1,138 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.16.0, )", + "resolved": "1.16.0", + "contentHash": "3kdIIceBPumwjw279FuiVMfVENT2cGASXJgcigdySsbX2dJB8ofUgG6i47yqF/k1qu6fvNR3csrSekZPviR6kQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", + "dependencies": { + "System.Collections.Immutable": "10.0.1" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + } + } +} \ No newline at end of file diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs index a2925b84..eb75fc03 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs @@ -76,9 +76,9 @@ public static string CreateGeneratedCodeAttribute(Type generatorType) AssemblyName assembly = generatorType.Assembly.GetName(); string version = (assembly.Version ?? new Version(0, 0, 0, 0)).ToString(); return "[global::System.CodeDom.Compiler.GeneratedCode(" - + CSharpLiteral.ToStringLiteral(assembly.Name ?? string.Empty) - + ", " - + CSharpLiteral.ToStringLiteral(version) - + ")]"; + + CSharpLiteral.ToStringLiteral(assembly.Name ?? string.Empty) + + ", " + + CSharpLiteral.ToStringLiteral(version) + + ")]"; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs index fc7a6c50..78e634dd 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs @@ -103,7 +103,7 @@ public static string AllocateUnique(string typeName, string suffix, HashSetLuaBindingAnalyzer's compilation-end pass /// (LuaFunctionDuplicateState) sets this flag once every sibling member has been seen. /// - DuplicateName = 1 << 10 + DuplicateName = 1 << 10, + + /// + /// A required parameter follows a LuaOptional<T> parameter: optional Lua arguments form a trailing + /// run, and an absent position reads as omitted (CESDK2010). + /// + OptionalArgumentNotTrailing = 1 << 12, + + /// + /// A parameter or return type has the metadata name of LuaOptional`1 but is not the + /// CheatEngine.SDK.Lua type (CESDK2012). + /// + LookAlikeContractType = 1 << 13, + + /// + /// LuaOptional<T> is used where it is not supported: as the return type, with a T that is + /// string?, custom-marshalled, unmarshalled or another LuaOptional, or with an explicit + /// [LuaMarshaller] (CESDK2013). + /// + OptionalNotSupportedHere = 1 << 14 } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalShapeIssues.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalShapeIssues.cs index bf94b876..1e2be4f0 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalShapeIssues.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Model/LuaGlobalShapeIssues.cs @@ -95,5 +95,49 @@ internal enum LuaGlobalShapeIssues /// The method has results but returns neither nor the SDK's /// LuaOperationStatus: the throwing form has no parameter. ///
- TryFormReturnNotBool = 1 << 16 + TryFormReturnNotBool = 1 << 16, + + /// + /// A required argument follows a LuaOptional<T> argument: optional arguments form a trailing run, + /// because Lua cannot receive an argument after an absent one (CESDK2010). + /// + OptionalArgumentNotTrailing = 1 << 17, + + /// + /// A required result (a value or copy-out) follows an LuaOptional<T> result: + /// optional results come after every required one (CESDK2011). + /// + OptionalResultNotTrailing = 1 << 18, + + /// A result follows the variadic Span<T> values, out int count pair, which must be last (CESDK2011). + VariadicResultNotLast = 1 << 19, + + /// + /// A variadic Span<T> values, out int count pair is declared on a form that does not return + /// LuaOperationStatus: only the Outcome form can report a capacity or element failure (CESDK2011). + /// + VariadicResultOutsideOutcome = 1 << 20, + + /// + /// A variadic span's element type is not , , + /// , , or + /// (CESDK2011). + /// + UnsupportedVariadicElement = 1 << 21, + + /// More than one variadic Span<T> values, out int count pair is declared (CESDK2011). + MultipleVariadicResults = 1 << 22, + + /// + /// A parameter or return type has the metadata name of LuaOptional`1 or LuaOperationStatus but is + /// not the CheatEngine.SDK.Lua type: it is never taken as the SDK contract (CESDK2012). + /// + LookAlikeContractType = 1 << 23, + + /// + /// LuaOptional<T> is used where it is not supported: as the throwing form's return, with a + /// T that is string?, custom-marshalled, unmarshalled or another LuaOptional, or with an + /// explicit [LuaMarshaller] (CESDK2013). + /// + OptionalNotSupportedHere = 1 << 24 } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/ContainingTypeShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/ContainingTypeShape.cs index ef0fb3bf..22fdeb27 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/ContainingTypeShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/ContainingTypeShape.cs @@ -61,7 +61,7 @@ private static bool IsPartial(INamedTypeSymbol type, CancellationToken cancellat foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) { if (reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax declaration - && declaration.Modifiers.Any(SyntaxKind.PartialKeyword)) + && declaration.Modifiers.Any(SyntaxKind.PartialKeyword)) { return true; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/Identifiers.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/Identifiers.cs index bd68372a..71173812 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/Identifiers.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/Identifiers.cs @@ -12,7 +12,7 @@ internal static class Identifiers public static string Escape(string name) { return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || - SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None + SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None ? "@" + name : name; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaClassGeneratedNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaClassGeneratedNames.cs index 9c3397e8..e78f1a1f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaClassGeneratedNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaClassGeneratedNames.cs @@ -28,12 +28,12 @@ public static bool IsGeneratedAccessorCollision(ISymbol member, INamedTypeSymbol } return method.MethodKind == MethodKind.Ordinary - && ((string.Equals(method.Name, "get_Handle", StringComparison.Ordinal) - && method.Parameters.Length == 0) - || (string.Equals(method.Name, "set_Handle", StringComparison.Ordinal) - && ceObject is not null - && method.Parameters.Length == 1 - && method.Parameters[0].RefKind == RefKind.None - && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, ceObject))); + && ((string.Equals(method.Name, "get_Handle", StringComparison.Ordinal) + && method.Parameters.Length == 0) + || (string.Equals(method.Name, "set_Handle", StringComparison.Ordinal) + && ceObject is not null + && method.Parameters.Length == 1 + && method.Parameters[0].RefKind == RefKind.None + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, ceObject))); } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaContractTypes.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaContractTypes.cs new file mode 100644 index 00000000..2a23ffac --- /dev/null +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaContractTypes.cs @@ -0,0 +1,119 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using Microsoft.CodeAnalysis; + +namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; + +/// +/// The CheatEngine.SDK.Lua contract types a binding signature can name besides the marshalled scalars: +/// LuaOptional<T> and LuaOperationStatus. They are recognised by symbol identity only, resolved +/// from the CheatEngine.SDK.Lua assembly itself; a type that merely has the same namespace, name and arity (in +/// the consumer's source or in another assembly) is a look-alike, reported and never taken as the contract. +/// +/// +/// Compilation.GetTypeByMetadataName is not used: it returns the compilation's own type first and +/// when several references define the name, so a source look-alike would hide the real +/// type. Compilation.GetTypesByMetadataName returns every candidate and the defining assembly decides +/// (https://learn.microsoft.com/dotnet/api/microsoft.codeanalysis.compilation.gettypesbymetadataname). +/// +[SuppressMessage( + "Meziantou.Analyzer", + "MA0182", + Justification = + "This shared internal helper is consumed by the designated friend generator and analyzer assemblies.")] +internal static class LuaContractTypes +{ + /// The assembly that defines every Lua contract type. + public const string LuaAssemblyName = "CheatEngine.SDK.Lua"; + + /// Metadata name of CheatEngine.SDK.Lua.Marshalling.LuaOptional<T>. + public const string LuaOptionalMetadataName = "CheatEngine.SDK.Lua.Marshalling.LuaOptional`1"; + + /// Metadata name of CheatEngine.SDK.Lua.Calls.LuaOperationStatus. + public const string LuaOperationStatusMetadataName = "CheatEngine.SDK.Lua.Calls.LuaOperationStatus"; + + /// + /// The type named defined by CheatEngine.SDK.Lua (a reference or the + /// compilation itself), or when that assembly does not define it. + /// + public static INamedTypeSymbol? Resolve(Compilation compilation, string metadataName) + { + if (compilation is null) + { + throw new ArgumentNullException(nameof(compilation)); + } + + foreach (INamedTypeSymbol type in compilation.GetTypesByMetadataName(metadataName)) + { + if (string.Equals(type.ContainingAssembly?.Name, LuaAssemblyName, StringComparison.Ordinal)) + { + return type; + } + } + + return null; + } + + /// Whether is (a construction of) the resolved . + public static bool Is(ITypeSymbol type, INamedTypeSymbol? expected) + { + return expected is not null + && type is INamedTypeSymbol named + && SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, expected); + } + + /// + /// Whether has the namespace, name and arity of but is not + /// the resolved (also when the real type could not be resolved at all). + /// + public static bool IsLookAlike(ITypeSymbol type, INamedTypeSymbol? expected, string metadataName) + { + return type is INamedTypeSymbol named + && !Is(type, expected) + && HasMetadataName(named.OriginalDefinition, metadataName); + } + + /// Whether a top-level has the full metadata name . + public static bool HasMetadataName(INamedTypeSymbol type, string metadataName) + { + if (type.ContainingType is not null) + { + return false; + } + + int end = metadataName.Length; + int dot = metadataName.LastIndexOf('.'); + if (!NameEquals(type.MetadataName, metadataName, dot + 1, end)) + { + return false; + } + + INamespaceSymbol? current = type.ContainingNamespace; + end = dot; + while (current is not null && !current.IsGlobalNamespace) + { + if (end <= 0) + { + return false; + } + + dot = metadataName.LastIndexOf('.', end - 1); + if (!NameEquals(current.Name, metadataName, dot + 1, end)) + { + return false; + } + + end = dot; + current = current.ContainingNamespace; + } + + return end == -1; + } + + private static bool NameEquals(string name, string metadataName, int start, int end) + { + return name.Length == end - start + && string.CompareOrdinal(name, 0, metadataName, start, name.Length) == 0; + } +} diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaFunctionShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaFunctionShape.cs index 20172c40..ff76412a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaFunctionShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaFunctionShape.cs @@ -47,22 +47,44 @@ public static LuaFunctionShapeIssues Inspect(Compilation compilation, IMethodSym out LuaFunctionSignature signature) { return InspectCore(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, - out signature); + LuaContractTypes.Resolve(compilation, LuaContractTypes.LuaOptionalMetadataName), out signature); + } + + /// Inspects against resolved SDK Lua binding contracts. + /// The consumer compilation that must be able to call a custom marshaller directly. + /// The attributed method. + /// The real Lua runtime state symbol, or when it is unavailable. + /// The real SDK marshaller annotation, or . + /// The real SDK static marshaller contract, or . + /// The real SDK LuaOptional<T>, or . + /// + /// What could be classified; complete only when the result is + /// . + /// + public static LuaFunctionShapeIssues Inspect(Compilation compilation, IMethodSymbol method, + INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, INamedTypeSymbol? luaOptional, + out LuaFunctionSignature signature) + { + return InspectCore(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, + luaOptional, out signature); } /// /// Compatibility overload for consumers that only validate the built-in scalar contract. The LuaBindings - /// generator and its analyzer call the overload that resolves [LuaMarshaller] explicitly. + /// generator and its analyzer call the overload that resolves [LuaMarshaller] and + /// LuaOptional<T> explicitly; without a compilation every LuaOptional is a look-alike. /// public static LuaFunctionShapeIssues Inspect(IMethodSymbol method, INamedTypeSymbol? luaState, out LuaFunctionSignature signature) { - return InspectCore(null, method, luaState, null, null, out signature); + return InspectCore(null, method, luaState, null, null, null, out signature); } private static LuaFunctionShapeIssues InspectCore(Compilation? compilation, IMethodSymbol method, INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, - INamedTypeSymbol? luaMarshallerContract, out LuaFunctionSignature signature) + INamedTypeSymbol? luaMarshallerContract, INamedTypeSymbol? luaOptional, + out LuaFunctionSignature signature) { LuaFunctionShapeIssues issues = LuaFunctionShapeIssues.None; @@ -87,8 +109,8 @@ private static LuaFunctionShapeIssues InspectCore(Compilation? compilation, IMet } issues |= InspectParameters(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, - out bool passesState, out EquatableArray arguments); - issues |= InspectReturn(compilation, method, luaMarshallerAttribute, luaMarshallerContract, + luaOptional, out bool passesState, out EquatableArray arguments); + issues |= InspectReturn(compilation, method, luaMarshallerAttribute, luaMarshallerContract, luaOptional, out LuaValueKind? returnKind, out LuaCustomMarshallerModel? returnMarshaller); @@ -99,31 +121,19 @@ private static LuaFunctionShapeIssues InspectCore(Compilation? compilation, IMet private static LuaFunctionShapeIssues InspectParameters(Compilation? compilation, IMethodSymbol method, INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, + INamedTypeSymbol? luaOptional, out bool passesState, out EquatableArray arguments) { LuaFunctionShapeIssues issues = LuaFunctionShapeIssues.None; + bool sawOptional = false; passesState = false; ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(method.Parameters.Length); for (int i = 0; i < method.Parameters.Length; i++) { IParameterSymbol parameter = method.Parameters[i]; - if (parameter.RefKind != RefKind.None) - { - issues |= LuaFunctionShapeIssues.ByRefParameter; - } - - if (parameter.IsParams) - { - issues |= LuaFunctionShapeIssues.ParamsParameter; - } - - if (parameter.IsOptional || parameter.HasExplicitDefaultValue) - { - issues |= LuaFunctionShapeIssues.OptionalParameter; - } - + issues |= InspectModifiers(parameter); if (LuaValueKindMapper.IsLuaState(parameter.Type, luaState)) { if (i == 0) @@ -134,35 +144,92 @@ private static LuaFunctionShapeIssues InspectParameters(Compilation? compilation { issues |= LuaFunctionShapeIssues.StateParameterNotFirst; } + + continue; } - else if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, parameter.Type, - parameter.GetAttributes(), luaMarshallerAttribute, luaMarshallerContract, - out LuaCustomMarshallerModel? customMarshaller, out _)) - { - issues |= LuaFunctionShapeIssues.UnsupportedParameterType; - } - else if (customMarshaller is not null) - { - builder.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), LuaValueKind.Int32, - false, CustomMarshaller: customMarshaller)); - } - else if (LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool isNullable)) + + LuaOptionalUse optional = LuaValueKindMapper.ClassifyOptional(parameter.Type, luaOptional, + out LuaValueKind optionalKind); + if (optional != LuaOptionalUse.NotOptional) { - builder.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), kind, isNullable)); + sawOptional = true; + issues |= optional switch + { + LuaOptionalUse.LookAlike => LuaFunctionShapeIssues.LookAlikeContractType, + LuaOptionalUse.Supported when !HasMarshallerAttribute(parameter, luaMarshallerAttribute) => + LuaFunctionShapeIssues.None, + _ => LuaFunctionShapeIssues.OptionalNotSupportedHere + }; + builder.Add(LuaArgumentModel.Optional(Identifiers.Escape(parameter.Name), optionalKind)); + continue; } - else + + if (sawOptional) { - issues |= LuaFunctionShapeIssues.UnsupportedParameterType; + issues |= LuaFunctionShapeIssues.OptionalArgumentNotTrailing; } + + issues |= AddRequiredArgument(compilation, method, parameter, luaMarshallerAttribute, + luaMarshallerContract, builder); } arguments = new EquatableArray(builder.ToImmutable()); return issues; } + // By-reference, params and C# default values are refused on every parameter, optional or not. + private static LuaFunctionShapeIssues InspectModifiers(IParameterSymbol parameter) + { + LuaFunctionShapeIssues issues = LuaFunctionShapeIssues.None; + if (parameter.RefKind != RefKind.None) + { + issues |= LuaFunctionShapeIssues.ByRefParameter; + } + + if (parameter.IsParams) + { + issues |= LuaFunctionShapeIssues.ParamsParameter; + } + + if (parameter.IsOptional || parameter.HasExplicitDefaultValue) + { + issues |= LuaFunctionShapeIssues.OptionalParameter; + } + + return issues; + } + + // A required argument: an explicitly marshalled value or a built-in kind. + private static LuaFunctionShapeIssues AddRequiredArgument(Compilation? compilation, IMethodSymbol method, + IParameterSymbol parameter, INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, ImmutableArray.Builder builder) + { + if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, parameter.Type, + parameter.GetAttributes(), luaMarshallerAttribute, luaMarshallerContract, + out LuaCustomMarshallerModel? customMarshaller, out _)) + { + return LuaFunctionShapeIssues.UnsupportedParameterType; + } + + if (customMarshaller is not null) + { + builder.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), LuaValueKind.Int32, + false, CustomMarshaller: customMarshaller)); + return LuaFunctionShapeIssues.None; + } + + if (!LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool isNullable)) + { + return LuaFunctionShapeIssues.UnsupportedParameterType; + } + + builder.Add(new LuaArgumentModel(Identifiers.Escape(parameter.Name), kind, isNullable)); + return LuaFunctionShapeIssues.None; + } + private static LuaFunctionShapeIssues InspectReturn(Compilation? compilation, IMethodSymbol method, INamedTypeSymbol? luaMarshallerAttribute, - INamedTypeSymbol? luaMarshallerContract, out LuaValueKind? returnKind, + INamedTypeSymbol? luaMarshallerContract, INamedTypeSymbol? luaOptional, out LuaValueKind? returnKind, out LuaCustomMarshallerModel? returnMarshaller) { returnKind = null; @@ -172,15 +239,25 @@ private static LuaFunctionShapeIssues InspectReturn(Compilation? compilation, IM return LuaFunctionShapeIssues.None; } + switch (LuaValueKindMapper.ClassifyOptional(method.ReturnType, luaOptional, out _)) + { + case LuaOptionalUse.LookAlike: + return LuaFunctionShapeIssues.LookAlikeContractType; + case LuaOptionalUse.Supported: + case LuaOptionalUse.Unsupported: + // A thunk returns one value or none: an optional return would be two contracts in one declaration. + return LuaFunctionShapeIssues.OptionalNotSupportedHere; + } + if (method is not { ReturnsByRef: false, ReturnsByRefReadonly: false }) { return LuaFunctionShapeIssues.UnsupportedReturnType; } if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, method.ReturnType, - method.GetReturnTypeAttributes(), luaMarshallerAttribute, luaMarshallerContract, - out returnMarshaller, out _) - || (returnMarshaller is not null && method.ReturnType.IsRefLikeType)) + method.GetReturnTypeAttributes(), luaMarshallerAttribute, luaMarshallerContract, + out returnMarshaller, out _) + || (returnMarshaller is not null && method.ReturnType.IsRefLikeType)) { return LuaFunctionShapeIssues.UnsupportedReturnType; } @@ -198,4 +275,23 @@ private static LuaFunctionShapeIssues InspectReturn(Compilation? compilation, IM returnKind = kind; return LuaFunctionShapeIssues.None; } + + // Whether one of the parameter's attributes is the resolved SDK [LuaMarshaller]. + private static bool HasMarshallerAttribute(IParameterSymbol parameter, INamedTypeSymbol? luaMarshallerAttribute) + { + if (luaMarshallerAttribute is null) + { + return false; + } + + foreach (AttributeData attribute in parameter.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, luaMarshallerAttribute)) + { + return true; + } + } + + return false; + } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaGlobalShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaGlobalShape.cs index eda3869b..75182c9f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaGlobalShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaGlobalShape.cs @@ -20,9 +20,12 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// The rules: an ordinary, static, non-generic, non-async method that is the defining declaration of a partial /// method without an implementing part. Parameters, in order: an optional leading LuaState (the body then /// uses it instead of acquiring a state), then the arguments (by value, of a built-in marshalled kind or an -/// explicitly selected valid [LuaMarshaller], none optional or ), then the -/// results: parameters of the same conversion contract other than -/// ReadOnlySpan<byte>, or a copy-out pair Span<byte> destination, out int written. +/// explicitly selected valid [LuaMarshaller], none with a C# default value or ; +/// a trailing run of them may be LuaOptional<T>), then the results: +/// parameters of the same conversion contract other than ReadOnlySpan<byte>, or a copy-out pair +/// Span<byte> destination, out int written; after them, +/// LuaOptional<T> results; last, on the Outcome form only, one variadic pair +/// Span<T> values, out int count. /// /// /// A LuaOperationStatus return always makes the method a non-throwing Outcome form, whether or not it @@ -34,6 +37,11 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; /// results is therefore a throwing wrapper that reads a Lua boolean). A Try form without a result cannot be /// written. /// +/// +/// LuaOptional<T> and LuaOperationStatus are recognised by symbol identity (the types +/// CheatEngine.SDK.Lua defines, ). A same-named type from the consumer's +/// source or another assembly is a look-alike: flagged, never taken as the contract. +/// /// [SuppressMessage( "Meziantou.Analyzer", @@ -48,6 +56,31 @@ internal static class LuaGlobalShape /// The real Lua runtime state symbol, or when it is unavailable. /// The real SDK marshaller annotation, or . /// The real SDK static marshaller contract, or . + /// The real SDK LuaOptional<T>, or . + /// The real SDK LuaOperationStatus, or . + /// + /// What could be classified; complete only when the result is + /// . + /// + public static LuaGlobalShapeIssues Inspect(Compilation compilation, IMethodSymbol method, + INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, INamedTypeSymbol? luaOptional, + INamedTypeSymbol? luaOperationStatus, out LuaGlobalSignature signature) + { + return InspectCore(compilation, method, + new Contracts(luaState, luaMarshallerAttribute, luaMarshallerContract, luaOptional, luaOperationStatus), + out signature); + } + + /// + /// Inspects , resolving LuaOptional<T> and LuaOperationStatus from + /// CheatEngine.SDK.Lua in . + /// + /// The consumer compilation that must be able to call a custom marshaller directly. + /// The attributed method. + /// The real Lua runtime state symbol, or when it is unavailable. + /// The real SDK marshaller annotation, or . + /// The real SDK static marshaller contract, or . /// /// What could be classified; complete only when the result is /// . @@ -57,28 +90,48 @@ public static LuaGlobalShapeIssues Inspect(Compilation compilation, IMethodSymbo INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, out LuaGlobalSignature signature) { - return InspectCore(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, - out signature); + return Inspect(compilation, method, luaState, luaMarshallerAttribute, luaMarshallerContract, + LuaContractTypes.Resolve(compilation, LuaContractTypes.LuaOptionalMetadataName), + LuaContractTypes.Resolve(compilation, LuaContractTypes.LuaOperationStatusMetadataName), out signature); + } + + /// + /// Compatibility overload for validation that accepts only built-in scalar marshallers and has no compilation: + /// LuaOptional<T> and LuaOperationStatus cannot be resolved, so every use of them is reported as + /// a look-alike. The LuaBindings generator and its analyzer pass the resolved contracts. + /// + public static LuaGlobalShapeIssues Inspect(IMethodSymbol method, INamedTypeSymbol? luaState, + out LuaGlobalSignature signature) + { + return InspectCore(null, method, new Contracts(luaState, null, null, null, null), out signature); } private static LuaGlobalShapeIssues InspectCore(Compilation? compilation, IMethodSymbol method, - INamedTypeSymbol? luaState, INamedTypeSymbol? luaMarshallerAttribute, - INamedTypeSymbol? luaMarshallerContract, out LuaGlobalSignature signature) + Contracts contracts, out LuaGlobalSignature signature) { LuaGlobalShapeIssues issues = InspectMethod(method); - ParameterWalk walk = new(); - issues |= walk.Run(compilation, method.ContainingType, method.Parameters, luaState, luaMarshallerAttribute, - luaMarshallerContract); + ParameterWalk walk = new(compilation, method.ContainingType, contracts); + issues |= walk.Run(method.Parameters); EquatableArray results = new(walk.Results.ToImmutable()); - LuaCallForm form = IsLuaOperationStatus(method.ReturnType) - ? LuaCallForm.Outcome - : results.IsEmpty - ? LuaCallForm.Throwing - : LuaCallForm.Try; - issues |= InspectReturn(compilation, method, form, luaMarshallerAttribute, luaMarshallerContract, - out LuaValueKind? returnKind, out bool returnIsNullable, out LuaCustomMarshallerModel? returnMarshaller); + LuaCallForm form; + if (LuaContractTypes.Is(method.ReturnType, contracts.LuaOperationStatus)) + { + form = LuaCallForm.Outcome; + } + else + { + form = results.IsEmpty ? LuaCallForm.Throwing : LuaCallForm.Try; + } + + if (walk.HasVariadicResult && form != LuaCallForm.Outcome) + { + issues |= LuaGlobalShapeIssues.VariadicResultOutsideOutcome; + } + + issues |= InspectReturn(compilation, method, form, contracts, out LuaValueKind? returnKind, + out bool returnIsNullable, out LuaCustomMarshallerModel? returnMarshaller); signature = new LuaGlobalSignature( walk.StateParameterName, @@ -91,16 +144,6 @@ private static LuaGlobalShapeIssues InspectCore(Compilation? compilation, IMetho return issues; } - /// - /// Compatibility overload for validation that accepts only built-in scalar marshallers. The LuaBindings - /// generator and its analyzer pass the resolved custom-marshaller contracts. - /// - public static LuaGlobalShapeIssues Inspect(IMethodSymbol method, INamedTypeSymbol? luaState, - out LuaGlobalSignature signature) - { - return InspectCore(null, method, luaState, null, null, out signature); - } - private static LuaGlobalShapeIssues InspectMethod(IMethodSymbol method) { LuaGlobalShapeIssues issues = LuaGlobalShapeIssues.None; @@ -137,15 +180,25 @@ private static LuaGlobalShapeIssues InspectMethod(IMethodSymbol method) } private static LuaGlobalShapeIssues InspectReturn(Compilation? compilation, IMethodSymbol method, - LuaCallForm form, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract, - out LuaValueKind? returnKind, out bool returnIsNullable, out LuaCustomMarshallerModel? returnMarshaller) + LuaCallForm form, Contracts contracts, out LuaValueKind? returnKind, out bool returnIsNullable, + out LuaCustomMarshallerModel? returnMarshaller) { returnKind = null; returnIsNullable = false; returnMarshaller = null; bool byRef = method.ReturnsByRef || method.ReturnsByRefReadonly; + if (form == LuaCallForm.Outcome) + { + return !byRef ? LuaGlobalShapeIssues.None : LuaGlobalShapeIssues.TryFormReturnNotBool; + } + + if (LuaContractTypes.IsLookAlike(method.ReturnType, contracts.LuaOperationStatus, + LuaContractTypes.LuaOperationStatusMetadataName)) + { + return LuaGlobalShapeIssues.LookAlikeContractType; + } + if (form == LuaCallForm.Try) { return method.ReturnType.SpecialType == SpecialType.System_Boolean && !byRef @@ -153,14 +206,27 @@ private static LuaGlobalShapeIssues InspectReturn(Compilation? compilation, IMet : LuaGlobalShapeIssues.TryFormReturnNotBool; } - if (form == LuaCallForm.Outcome) - { - return !byRef ? LuaGlobalShapeIssues.None : LuaGlobalShapeIssues.TryFormReturnNotBool; - } + return method.ReturnsVoid + ? LuaGlobalShapeIssues.None + : InspectThrowingReturn(compilation, method, contracts, byRef, out returnKind, out returnIsNullable, + out returnMarshaller); + } - if (method.ReturnsVoid) + // The throwing form's single Lua result, read into the return value. + private static LuaGlobalShapeIssues InspectThrowingReturn(Compilation? compilation, IMethodSymbol method, + Contracts contracts, bool byRef, out LuaValueKind? returnKind, out bool returnIsNullable, + out LuaCustomMarshallerModel? returnMarshaller) + { + returnKind = null; + returnIsNullable = false; + returnMarshaller = null; + switch (LuaValueKindMapper.ClassifyOptional(method.ReturnType, contracts.LuaOptional, out _)) { - return LuaGlobalShapeIssues.None; + case LuaOptionalUse.LookAlike: + return LuaGlobalShapeIssues.LookAlikeContractType; + case LuaOptionalUse.Supported: + case LuaOptionalUse.Unsupported: + return LuaGlobalShapeIssues.OptionalNotSupportedHere; } if (byRef) @@ -174,9 +240,9 @@ private static LuaGlobalShapeIssues InspectReturn(Compilation? compilation, IMet } if (!LuaMarshallerResolver.TryResolve(compilation, method.ContainingType, method.ReturnType, - method.GetReturnTypeAttributes(), luaMarshallerAttribute, luaMarshallerContract, - out returnMarshaller, out _) - || (returnMarshaller is not null && method.ReturnType.IsRefLikeType)) + method.GetReturnTypeAttributes(), contracts.LuaMarshallerAttribute, contracts.LuaMarshallerContract, + out returnMarshaller, out _) + || (returnMarshaller is not null && method.ReturnType.IsRefLikeType)) { return LuaGlobalShapeIssues.UnsupportedReturnType; } @@ -187,7 +253,7 @@ private static LuaGlobalShapeIssues InspectReturn(Compilation? compilation, IMet } if (!LuaValueKindMapper.TryMap(method.ReturnType, out LuaValueKind kind, out returnIsNullable) || - !LuaValueKinds.CanBeResult(kind)) + !LuaValueKinds.CanBeResult(kind)) { return LuaGlobalShapeIssues.UnsupportedReturnType; } @@ -196,18 +262,66 @@ private static LuaGlobalShapeIssues InspectReturn(Compilation? compilation, IMet return LuaGlobalShapeIssues.None; } - private static bool IsLuaOperationStatus(ITypeSymbol type) + // Whether one of the parameter's attributes is the resolved SDK [LuaMarshaller]. + private static bool HasMarshallerAttribute(IParameterSymbol parameter, INamedTypeSymbol? luaMarshallerAttribute) + { + if (luaMarshallerAttribute is null) + { + return false; + } + + foreach (AttributeData attribute in parameter.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, luaMarshallerAttribute)) + { + return true; + } + } + + return false; + } + + // The resolved SDK symbols a signature is checked against. + private sealed class Contracts( + INamedTypeSymbol? luaState, + INamedTypeSymbol? luaMarshallerAttribute, + INamedTypeSymbol? luaMarshallerContract, + INamedTypeSymbol? luaOptional, + INamedTypeSymbol? luaOperationStatus) { - return string.Equals( - type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - "global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus", - StringComparison.Ordinal); + public INamedTypeSymbol? LuaState + { + get; + } = luaState; + + public INamedTypeSymbol? LuaMarshallerAttribute + { + get; + } = luaMarshallerAttribute; + + public INamedTypeSymbol? LuaMarshallerContract + { + get; + } = luaMarshallerContract; + + public INamedTypeSymbol? LuaOptional + { + get; + } = luaOptional; + + public INamedTypeSymbol? LuaOperationStatus + { + get; + } = luaOperationStatus; } - // The parameter list, left to right: the leading state, the arguments, then the results. - private sealed class ParameterWalk + // The parameter list, left to right: the leading state, the arguments (optional ones last), then the results + // (required, optional, variadic). + private sealed class ParameterWalk(Compilation? compilation, INamedTypeSymbol bindingType, Contracts contracts) { private bool _inResults; + private bool _sawOptionalArgument; + private bool _sawOptionalResult; public string StateParameterName { @@ -215,6 +329,12 @@ public string StateParameterName private set; } = string.Empty; + public bool HasVariadicResult + { + get; + private set; + } + public ImmutableArray.Builder Arguments { get; @@ -226,9 +346,7 @@ public ImmutableArray.Builder Results get; } = ImmutableArray.CreateBuilder(); - public LuaGlobalShapeIssues Run(Compilation? compilation, INamedTypeSymbol bindingType, - ImmutableArray parameters, INamedTypeSymbol? luaState, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) + public LuaGlobalShapeIssues Run(ImmutableArray parameters) { LuaGlobalShapeIssues issues = LuaGlobalShapeIssues.None; for (int i = 0; i < parameters.Length; i++) @@ -246,8 +364,7 @@ public LuaGlobalShapeIssues Run(Compilation? compilation, INamedTypeSymbol bindi if (parameter.RefKind == RefKind.Out) { - issues |= AddOutResult(compilation, bindingType, parameter, luaMarshallerAttribute, - luaMarshallerContract); + issues |= AddOutResult(parameter); } else if (parameter.RefKind != RefKind.None) { @@ -257,76 +374,140 @@ public LuaGlobalShapeIssues Run(Compilation? compilation, INamedTypeSymbol bindi { issues |= AddCopyOutResult(parameters, ref i); } + else if (LuaValueKindMapper.IsSpanOfOther(parameter.Type, out ITypeSymbol? element)) + { + issues |= AddVariadicResult(parameters, element, ref i); + } else { - issues |= AddArgument(compilation, bindingType, parameter, i, luaState, luaMarshallerAttribute, - luaMarshallerContract); + issues |= AddArgument(parameter, i); } } return issues; } - private LuaGlobalShapeIssues AddOutResult(Compilation? compilation, INamedTypeSymbol bindingType, - IParameterSymbol parameter, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) + // A result after the variadic pair, or a required result after an optional one. + private LuaGlobalShapeIssues EnterResult(bool isOptional) { _inResults = true; + LuaGlobalShapeIssues issues = HasVariadicResult + ? LuaGlobalShapeIssues.VariadicResultNotLast + : LuaGlobalShapeIssues.None; + if (!isOptional && _sawOptionalResult) + { + issues |= LuaGlobalShapeIssues.OptionalResultNotTrailing; + } + + return issues; + } + + private LuaGlobalShapeIssues AddOutResult(IParameterSymbol parameter) + { + LuaOptionalUse optional = + LuaValueKindMapper.ClassifyOptional(parameter.Type, contracts.LuaOptional, out LuaValueKind inner); + LuaGlobalShapeIssues issues = EnterResult(optional != LuaOptionalUse.NotOptional); + switch (optional) + { + case LuaOptionalUse.LookAlike: + return issues | LuaGlobalShapeIssues.LookAlikeContractType; + case LuaOptionalUse.Unsupported: + return issues | LuaGlobalShapeIssues.OptionalNotSupportedHere; + case LuaOptionalUse.Supported: + _sawOptionalResult = true; + if (HasMarshallerAttribute(parameter, contracts.LuaMarshallerAttribute)) + { + return issues | LuaGlobalShapeIssues.OptionalNotSupportedHere; + } + + Results.Add(LuaResultModel.Optional(inner, Identifiers.Escape(parameter.Name))); + return issues; + } + if (LuaValueKindMapper.IsReadOnlySpanOfByte(parameter.Type)) { - return LuaGlobalShapeIssues.SpanResult; + return issues | LuaGlobalShapeIssues.SpanResult; } if (!LuaMarshallerResolver.TryResolve(compilation, bindingType, parameter.Type, parameter.GetAttributes(), - luaMarshallerAttribute, luaMarshallerContract, out LuaCustomMarshallerModel? customMarshaller, - out _) - || (customMarshaller is not null && parameter.Type.IsRefLikeType)) + contracts.LuaMarshallerAttribute, contracts.LuaMarshallerContract, + out LuaCustomMarshallerModel? customMarshaller, out _) + || (customMarshaller is not null && parameter.Type.IsRefLikeType)) { - return LuaGlobalShapeIssues.UnsupportedResultType; + return issues | LuaGlobalShapeIssues.UnsupportedResultType; } if (customMarshaller is not null) { Results.Add(LuaResultModel.Custom(customMarshaller, Identifiers.Escape(parameter.Name))); - return LuaGlobalShapeIssues.None; + return issues; } if (!LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool isNullable) || - !LuaValueKinds.CanBeResult(kind)) + !LuaValueKinds.CanBeResult(kind)) { - return LuaGlobalShapeIssues.UnsupportedResultType; + return issues | LuaGlobalShapeIssues.UnsupportedResultType; } Results.Add(LuaResultModel.Value(kind, Identifiers.Escape(parameter.Name), isNullable)); - return LuaGlobalShapeIssues.None; + return issues; } // A copy-out result is the destination and the count together: 'Span destination, out int written'. private LuaGlobalShapeIssues AddCopyOutResult(ImmutableArray parameters, ref int index) { - _inResults = true; + LuaGlobalShapeIssues issues = EnterResult(false); if (index + 1 >= parameters.Length - || parameters[index + 1] is - not { RefKind: RefKind.Out, Type.SpecialType: SpecialType.System_Int32 } written) + || parameters[index + 1] is + not { RefKind: RefKind.Out, Type.SpecialType: SpecialType.System_Int32 } written) { - return LuaGlobalShapeIssues.UnsupportedResultType; + return issues | LuaGlobalShapeIssues.UnsupportedResultType; } bool destinationIsScoped = parameters[index].ScopedKind != ScopedKind.None; Results.Add(LuaResultModel.CopyOut(Identifiers.Escape(parameters[index].Name), Identifiers.Escape(written.Name), destinationIsScoped)); index++; - return LuaGlobalShapeIssues.None; + return issues; + } + + // The variadic tail is the span and the count together: 'Span values, out int count'. + private LuaGlobalShapeIssues AddVariadicResult(ImmutableArray parameters, + ITypeSymbol element, ref int index) + { + LuaGlobalShapeIssues issues = EnterResult(true); + if (HasVariadicResult) + { + issues |= LuaGlobalShapeIssues.MultipleVariadicResults; + } + + HasVariadicResult = true; + if (index + 1 >= parameters.Length + || parameters[index + 1] is + not { RefKind: RefKind.Out, Type.SpecialType: SpecialType.System_Int32 } count) + { + return issues | LuaGlobalShapeIssues.UnsupportedResultType; + } + + IParameterSymbol values = parameters[index]; + index++; + if (!LuaValueKindMapper.TryMap(element, out LuaValueKind kind, out _) + || !LuaValueKinds.CanBeVariadicElement(kind)) + { + return issues | LuaGlobalShapeIssues.UnsupportedVariadicElement; + } + + Results.Add(LuaResultModel.Variadic(kind, Identifiers.Escape(values.Name), Identifiers.Escape(count.Name), + values.ScopedKind != ScopedKind.None)); + return issues; } // A by-value parameter: an argument, or the leading state. - private LuaGlobalShapeIssues AddArgument(Compilation? compilation, INamedTypeSymbol bindingType, - IParameterSymbol parameter, int index, INamedTypeSymbol? luaState, - INamedTypeSymbol? luaMarshallerAttribute, INamedTypeSymbol? luaMarshallerContract) + private LuaGlobalShapeIssues AddArgument(IParameterSymbol parameter, int index) { LuaGlobalShapeIssues issues = _inResults ? LuaGlobalShapeIssues.ResultBeforeArgument : LuaGlobalShapeIssues.None; - if (LuaValueKindMapper.IsLuaState(parameter.Type, luaState)) + if (LuaValueKindMapper.IsLuaState(parameter.Type, contracts.LuaState)) { if (index != 0) { @@ -337,9 +518,32 @@ private LuaGlobalShapeIssues AddArgument(Compilation? compilation, INamedTypeSym return issues; } + switch (LuaValueKindMapper.ClassifyOptional(parameter.Type, contracts.LuaOptional, out LuaValueKind inner)) + { + case LuaOptionalUse.LookAlike: + return issues | LuaGlobalShapeIssues.LookAlikeContractType; + case LuaOptionalUse.Unsupported: + _sawOptionalArgument = true; + return issues | LuaGlobalShapeIssues.OptionalNotSupportedHere; + case LuaOptionalUse.Supported: + _sawOptionalArgument = true; + if (HasMarshallerAttribute(parameter, contracts.LuaMarshallerAttribute)) + { + return issues | LuaGlobalShapeIssues.OptionalNotSupportedHere; + } + + Arguments.Add(LuaArgumentModel.Optional(Identifiers.Escape(parameter.Name), inner)); + return issues; + } + + if (_sawOptionalArgument) + { + issues |= LuaGlobalShapeIssues.OptionalArgumentNotTrailing; + } + if (!LuaMarshallerResolver.TryResolve(compilation, bindingType, parameter.Type, parameter.GetAttributes(), - luaMarshallerAttribute, luaMarshallerContract, out LuaCustomMarshallerModel? customMarshaller, - out _)) + contracts.LuaMarshallerAttribute, contracts.LuaMarshallerContract, + out LuaCustomMarshallerModel? customMarshaller, out _)) { return issues | LuaGlobalShapeIssues.UnsupportedParameterType; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs index fe7cd52d..b866c2d5 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs @@ -57,7 +57,7 @@ public static bool TryResolve(Compilation? compilation, INamedTypeSymbol binding } if (!Implements(type, marshallerContract, valueType) - || !HasCallableStaticContract(compilation, bindingType, type, marshallerContract, valueType)) + || !HasCallableStaticContract(compilation, bindingType, type, marshallerContract, valueType)) { return false; } @@ -74,7 +74,7 @@ private static bool Implements(INamedTypeSymbol candidate, INamedTypeSymbol cont foreach (INamedTypeSymbol implementation in candidate.AllInterfaces) { if (!SymbolEqualityComparer.Default.Equals(implementation.OriginalDefinition, contract) - || implementation.TypeArguments.Length != 1) + || implementation.TypeArguments.Length != 1) { continue; } @@ -101,7 +101,7 @@ private static bool HasCallableStaticContract(Compilation? compilation, INamedTy INamedTypeSymbol closedContract = contract.Construct(valueType); return HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "Push") - && HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "TryRead"); + && HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "TryRead"); } private static bool HasCallableStaticMethod(Compilation? compilation, INamedTypeSymbol bindingType, @@ -125,12 +125,12 @@ private static bool HasCallableStaticMethod(Compilation? compilation, INamedType foreach (ISymbol member in candidate.GetMembers(name)) { if (member is not IMethodSymbol method - || method.MethodKind != MethodKind.Ordinary - || !method.IsStatic - || method.IsAbstract - || method.Arity != 0 - || (compilation is not null && !compilation.IsSymbolAccessibleWithin(method, bindingType)) - || !HasMatchingSignature(method, required)) + || method.MethodKind != MethodKind.Ordinary + || !method.IsStatic + || method.IsAbstract + || method.Arity != 0 + || (compilation is not null && !compilation.IsSymbolAccessibleWithin(method, bindingType)) + || !HasMatchingSignature(method, required)) { continue; } @@ -144,8 +144,8 @@ private static bool HasCallableStaticMethod(Compilation? compilation, INamedType private static bool HasMatchingSignature(IMethodSymbol candidate, IMethodSymbol required) { if (candidate.ReturnsVoid != required.ReturnsVoid - || !SymbolEqualityComparer.Default.Equals(candidate.ReturnType, required.ReturnType) - || candidate.Parameters.Length != required.Parameters.Length) + || !SymbolEqualityComparer.Default.Equals(candidate.ReturnType, required.ReturnType) + || candidate.Parameters.Length != required.Parameters.Length) { return false; } @@ -155,7 +155,7 @@ private static bool HasMatchingSignature(IMethodSymbol candidate, IMethodSymbol IParameterSymbol actual = candidate.Parameters[i]; IParameterSymbol expected = required.Parameters[i]; if (actual.RefKind != expected.RefKind - || !SymbolEqualityComparer.Default.Equals(actual.Type, expected.Type)) + || !SymbolEqualityComparer.Default.Equals(actual.Type, expected.Type)) { return false; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaOptionalUse.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaOptionalUse.cs new file mode 100644 index 00000000..a056a3dd --- /dev/null +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaOptionalUse.cs @@ -0,0 +1,20 @@ +namespace CheatEngine.SDK.SourceGenerators.Shared.LuaBindings.Parsing; + +/// How a binding signature uses LuaOptional<T> at one parameter, result or return position. +internal enum LuaOptionalUse +{ + /// The type is not a LuaOptional<T> of any origin. + NotOptional, + + /// The resolved SDK LuaOptional<T> of a built-in kind that can be optional. + Supported, + + /// + /// The resolved SDK LuaOptional<T>, but T cannot be optional here: string?, a + /// custom-marshalled or unmarshalled type, or a nested LuaOptional. + /// + Unsupported, + + /// A type with the SDK's LuaOptional`1 metadata name that is not the CheatEngine.SDK.Lua type. + LookAlike +} diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs index 721e5a20..793432c6 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; @@ -69,6 +70,53 @@ public static bool TryMap(ITypeSymbol type, out LuaValueKind kind, out bool isNu return false; } + /// + /// Classifies as a LuaOptional<T> value: only for the + /// resolved SDK LuaOptional<T> whose T maps to a built-in kind that can be optional + /// (), never for string?. itself never accepts + /// an optional, so object members ([LuaMethod], [LuaProperty]) keep refusing it. + /// + /// The parameter, result or return type. + /// The resolved CheatEngine.SDK.Lua.Marshalling.LuaOptional`1, or . + /// The kind of T when the result is . + public static LuaOptionalUse ClassifyOptional(ITypeSymbol type, INamedTypeSymbol? luaOptional, + out LuaValueKind inner) + { + inner = default; + if (LuaContractTypes.IsLookAlike(type, luaOptional, LuaContractTypes.LuaOptionalMetadataName)) + { + return LuaOptionalUse.LookAlike; + } + + if (!LuaContractTypes.Is(type, luaOptional)) + { + return LuaOptionalUse.NotOptional; + } + + ITypeSymbol argument = ((INamedTypeSymbol) type).TypeArguments[0]; + return TryMap(argument, out inner, out bool isNullable) && !isNullable && LuaValueKinds.CanBeOptional(inner) + ? LuaOptionalUse.Supported + : LuaOptionalUse.Unsupported; + } + + /// Whether is System.Span<T> for a T other than . + /// The parameter type. + /// The element type when the result is . + public static bool IsSpanOfOther(ITypeSymbol type, [NotNullWhen(true)] out ITypeSymbol? element) + { + if (type is INamedTypeSymbol { Arity: 1, ContainingType: null } named + && string.Equals(named.Name, "Span", StringComparison.Ordinal) + && named.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true } + && named.TypeArguments[0].SpecialType != SpecialType.System_Byte) + { + element = named.TypeArguments[0]; + return true; + } + + element = null; + return false; + } + /// Whether is the resolved SDK LuaState symbol. public static bool IsLuaState(ITypeSymbol type, INamedTypeSymbol? expectedLuaState) { @@ -90,8 +138,8 @@ public static bool IsSpanOfByte(ITypeSymbol type) private static bool IsSystemSpanOfByte(ITypeSymbol type, string name) { return type is INamedTypeSymbol { Arity: 1, ContainingType: null } named - && string.Equals(named.Name, name, StringComparison.Ordinal) - && named.TypeArguments[0].SpecialType == SpecialType.System_Byte - && named.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true }; + && string.Equals(named.Name, name, StringComparison.Ordinal) + && named.TypeArguments[0].SpecialType == SpecialType.System_Byte + && named.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true }; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaApiNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaApiNames.cs index f7f1efe7..5e579fb7 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaApiNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaApiNames.cs @@ -70,6 +70,15 @@ internal static class LuaApiNames /// The static Lua marshaller contract implemented by generated class wrappers. public const string ILuaMarshaller = "global::CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller"; + /// + /// The explicit optional value (omitted, nil or a value) and, without type arguments, its non-generic + /// factory class. Generated code writes LuaOptional<T> for an optional argument or result. + /// + public const string LuaOptional = "global::CheatEngine.SDK.Lua.Marshalling.LuaOptional"; + + /// The exception a wrapper throws before touching Lua when optional arguments leave a gap. + public const string ArgumentException = "global::System.ArgumentException"; + /// The attribute every thunk carries, with its cdecl convention. public const string UnmanagedCallersOnlyCdecl = "[global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })]"; @@ -83,6 +92,9 @@ internal static class LuaApiNames /// The destination of a copy-out string result. public const string SpanOfByte = "global::System.Span"; + /// The generic span a variadic result pair copies into, without its type argument. + public const string Span = "global::System.Span"; + /// Int32Marshaller. public const string Int32Marshaller = "global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller"; diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaArgumentModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaArgumentModel.cs index b4f439c6..e8e5ae51 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaArgumentModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaArgumentModel.cs @@ -30,13 +30,21 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// An explicit marshaller selected with [LuaMarshaller], or for one of the SDK /// scalar marshallers represented by . /// +/// +/// The value is a LuaOptional<T> of : a wrapper pushes it only when it is not +/// omitted (and pushes nil for Nil), a thunk reads an absent position as omitted. Optional arguments +/// form a trailing run, after every required and fixed argument; is then a built-in kind +/// other than , is and no +/// custom marshaller is set. +/// internal sealed record LuaArgumentModel( string Name, LuaValueKind Kind, bool IsNullable, bool IsScoped = false, string? FixedValue = null, - LuaCustomMarshallerModel? CustomMarshaller = null) + LuaCustomMarshallerModel? CustomMarshaller = null, + bool IsOptional = false) { /// Initializes a built-in scalar argument model with the pre-custom-marshaller binary shape. public LuaArgumentModel(string name, LuaValueKind kind, bool isNullable, bool isScoped, string? fixedValue) @@ -44,6 +52,19 @@ public LuaArgumentModel(string name, LuaValueKind kind, bool isNullable, bool is { } + /// Initializes an argument model with the pre-optional binary shape. + public LuaArgumentModel(string name, LuaValueKind kind, bool isNullable, bool isScoped, string? fixedValue, + LuaCustomMarshallerModel? customMarshaller) + : this(name, kind, isNullable, isScoped, fixedValue, customMarshaller, false) + { + } + + /// An optional LuaOptional<T> argument of a built-in kind. + public static LuaArgumentModel Optional(string name, LuaValueKind kind) + { + return new LuaArgumentModel(name, kind, false, IsOptional: true); + } + /// Whether this value is pushed directly instead of being supplied by a wrapper parameter. public bool IsFixed => FixedValue is not null; @@ -51,8 +72,13 @@ public LuaArgumentModel(string name, LuaValueKind kind, bool isNullable, bool is public string GeneratedMarshallerTypeName => CustomMarshaller?.MarshallerTypeName ?? LuaValueKinds.MarshallerTypeName(Kind); - /// The C# type spelling used in an emitted parameter or local. - public string GeneratedTypeName => CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(Kind, IsNullable); + /// + /// The C# type spelling used in an emitted parameter or local: LuaOptional<T> for an optional + /// argument, the value type otherwise. + /// + public string GeneratedTypeName => IsOptional + ? LuaValueKinds.OptionalTypeName(Kind) + : CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(Kind, IsNullable); /// The Lua-facing expected type in a generated bad-argument message. public string ExpectedArgumentTypeName => diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs index 70f2df48..873f06f4 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs @@ -55,6 +55,18 @@ internal static class LuaGlobalCallEmitter private const string Ok = "__ok"; private const string Status = "__status"; private const string Result = "__result"; + private const string ArgumentCount = "__argc"; + private const string Rest = "__rest"; + + /// + /// Whether is a local of a generated body: a parameter with that name would collide with it, + /// so the LuaBindings generator skips such a declaration and CESDK2007 names the parameter. + /// + public static bool IsReservedLocal(string name) + { + return name is State or Operation or Top or Ok or Status or Result or "__resolution" or "__exception" + or ArgumentCount or Rest; + } /// Writes the method, signature and body, at the writer's current indentation. public static void Emit(SourceWriter writer, LuaGlobalCallModel model) @@ -150,10 +162,10 @@ private static void WriteArgumentParameter(SourceWriter writer, LuaArgumentModel writer.Write(argument.Name); } - // 'scoped Span destination, out int written' or 'out name'. + // 'scoped Span destination, out int written', 'Span values, out int count' or 'out name'. private static void WriteResultParameter(SourceWriter writer, LuaResultModel result, bool isExtensionReceiver) { - if (result.Shape == LuaResultShape.CopyOut) + if (result.Shape is LuaResultShape.CopyOut or LuaResultShape.Variadic) { if (isExtensionReceiver) { @@ -165,7 +177,9 @@ private static void WriteResultParameter(SourceWriter writer, LuaResultModel res writer.Write("scoped "); } - writer.Write(LuaApiNames.SpanOfByte); + writer.Write(result.Shape == LuaResultShape.CopyOut + ? LuaApiNames.SpanOfByte + : LuaApiNames.Span + "<" + LuaValueKinds.TypeName(result.Kind) + ">"); writer.Write(' '); writer.Write(result.DestinationName); writer.Write(", out int "); @@ -221,6 +235,11 @@ private static void WriteBody(SourceWriter writer, LuaGlobalCallModel model) int argumentCount = model.Arguments.Length; int resultCount = model.ResultCount; + if (model.HasOptionalArguments) + { + WriteArgumentCount(writer, model); + } + WriteStateAndTop(writer, model); WriteProtectedBody(writer, model, argumentCount, resultCount); WriteExceptionHandler(writer, model); @@ -233,8 +252,9 @@ private static void WriteProtectedBody(SourceWriter writer, LuaGlobalCallModel m writer.WriteLine("try"); writer.OpenBlock(); - // Only bodies that would exceed the guaranteed free slots check the stack. - int slots = Math.Max(1 + argumentCount, resultCount); + // Only bodies that would exceed the guaranteed free slots check the stack. A call that keeps every result + // (LUA_MULTRET) needs no room for them in advance: Lua makes the returned values fit. + int slots = model.HasDynamicResults ? 1 + argumentCount : Math.Max(1 + argumentCount, resultCount); if (slots > StackCheckThreshold) { WriteStackCheck(writer, model, slots); @@ -248,8 +268,15 @@ private static void WriteProtectedBody(SourceWriter writer, LuaGlobalCallModel m private static void WriteArguments(SourceWriter writer, LuaGlobalCallModel model) { - foreach (LuaArgumentModel argument in model.Arguments) + for (int i = 0; i < model.Arguments.Length; i++) { + LuaArgumentModel argument = model.Arguments[i]; + if (argument.IsOptional) + { + WriteOptionalArgument(writer, argument, i); + continue; + } + writer.Write(argument.GeneratedMarshallerTypeName); writer.Write(".Push("); writer.Write(State); @@ -259,12 +286,125 @@ private static void WriteArguments(SourceWriter writer, LuaGlobalCallModel model } } + // An optional argument is pushed only when the computed count reaches its position: a present value through its + // marshaller, Nil as nil. The count already stops before the first omitted argument. + private static void WriteOptionalArgument(SourceWriter writer, LuaArgumentModel argument, int position) + { + writer.Write("if ("); + writer.Write(ArgumentCount); + writer.Write(" > "); + writer.Write(position.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(")"); + writer.OpenBlock(); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".PushOptional<"); + writer.Write(LuaValueKinds.TypeName(argument.Kind)); + writer.Write(", "); + writer.Write(argument.GeneratedMarshallerTypeName); + writer.Write(">("); + writer.Write(State); + writer.Write(", "); + writer.Write(argument.Name); + writer.WriteLine(");"); + writer.CloseBlock(); + } + + // Before the state is acquired: the number of arguments to push is the position after the last optional argument + // that is not omitted. An omitted argument followed by a present one cannot be expressed in Lua, so it is a caller + // error, thrown before anything touches Lua (also while no runtime is attached). + private static void WriteArgumentCount(SourceWriter writer, LuaGlobalCallModel model) + { + int required = 0; + while (required < model.Arguments.Length && !model.Arguments[required].IsOptional) + { + required++; + } + + writer.Write("int "); + writer.Write(ArgumentCount); + writer.Write(" = "); + for (int i = model.Arguments.Length - 1; i >= required; i--) + { + writer.Write('!'); + writer.Write(model.Arguments[i].Name); + writer.Write(".IsOmitted ? "); + writer.Write((i + 1).ToString(CultureInfo.InvariantCulture)); + writer.Write(" : "); + } + + writer.Write(required.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(";"); + for (int i = required; i < model.Arguments.Length - 1; i++) + { + string name = UnescapedName(model.Arguments[i].Name); + writer.Write("if ("); + writer.Write(ArgumentCount); + writer.Write(" > "); + writer.Write((i + 1).ToString(CultureInfo.InvariantCulture)); + writer.Write(" && "); + writer.Write(model.Arguments[i].Name); + writer.WriteLine(".IsOmitted)"); + writer.OpenBlock(); + writer.Write("throw new "); + writer.Write(LuaApiNames.ArgumentException); + writer.Write('('); + writer.Write(CSharpLiteral.ToStringLiteral(OmittedBeforePresentMessage(model.GlobalName, name))); + writer.Write(", "); + writer.Write(CSharpLiteral.ToStringLiteral(name)); + writer.WriteLine(");"); + writer.CloseBlock(); + } + + writer.WriteLine(); + } + + /// + /// The message of the ArgumentException a wrapper throws, before touching Lua, when an optional argument is + /// omitted while a later one is present. + /// + public static string OmittedBeforePresentMessage(string globalName, string parameterName) + { + return "The optional argument '" + parameterName + "' of the Lua global '" + globalName + + "' is omitted while a later optional argument is present; Lua cannot receive an argument after an absent one. Pass LuaOptional.Nil() to send nil in its place."; + } + + private static string UnescapedName(string name) + { + return name.Length > 0 && name[0] == '@' ? name.Substring(1) : name; + } + + private static string ArgumentCountText(LuaGlobalCallModel model, int argumentCount) + { + return model.HasOptionalArguments ? ArgumentCount : argumentCount.ToString(CultureInfo.InvariantCulture); + } + + private static string ResultCountText(LuaGlobalCallModel model, int resultCount) + { + return model.HasDynamicResults + ? LuaApiNames.LuaState + ".MultipleResults" + : resultCount.ToString(CultureInfo.InvariantCulture); + } + + // The absolute stack index of result 'position' after a LUA_MULTRET call: the function was pushed at __top + 1 and + // the call replaced it, with its arguments, by the results. + private static string AbsoluteResultIndex(int position) + { + return Top + " + " + (position + 1).ToString(CultureInfo.InvariantCulture); + } + private static void WriteCallAndResults(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, int resultCount) { if (model.Form == LuaCallForm.Try) { - WriteTryCallAndResults(writer, model, argumentCount, resultCount); + if (model.HasDynamicResults) + { + WriteDynamicTryCallAndResults(writer, model, argumentCount); + } + else + { + WriteTryCallAndResults(writer, model, argumentCount, resultCount); + } } else if (model.Form == LuaCallForm.Outcome) { @@ -272,7 +412,7 @@ private static void WriteCallAndResults(SourceWriter writer, LuaGlobalCallModel } else { - WriteThrowingCall(writer, argumentCount, resultCount); + WriteThrowingCall(writer, model, argumentCount, resultCount); WriteThrowingResult(writer, model); } } @@ -418,7 +558,7 @@ private static void WriteTryCallAndResults(SourceWriter writer, LuaGlobalCallMod writer.Write("if (!"); writer.Write(State); writer.Write(".TryCall("); - writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); + writer.Write(ArgumentCountText(model, argumentCount)); writer.Write(", "); writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); writer.WriteLine(").IsOk)"); @@ -456,11 +596,99 @@ private static void WriteTryCallAndResults(SourceWriter writer, LuaGlobalCallMod writer.WriteLine(";"); } + // A Try form with optional results: the call keeps every result, a required result Lua did not return is a failure + // (never read as nil), and each optional result is read at its absolute index or left omitted. + private static void WriteDynamicTryCallAndResults(SourceWriter writer, LuaGlobalCallModel model, + int argumentCount) + { + writer.Write("if (!"); + writer.Write(State); + writer.Write(".TryCall("); + writer.Write(ArgumentCountText(model, argumentCount)); + writer.Write(", "); + writer.Write(ResultCountText(model, 0)); + writer.WriteLine(").IsOk)"); + writer.OpenBlock(); + WriteTryFailure(writer, model, 0); + writer.CloseBlock(); + writer.WriteLine(); + + int required = model.RequiredResultCount; + if (required > 0) + { + WriteMissingResultCheck(writer, required); + writer.OpenBlock(); + WriteTryFailure(writer, model, 0); + writer.CloseBlock(); + writer.WriteLine(); + } + + for (int i = 0; i < model.Results.Length; i++) + { + LuaResultModel result = model.Results[i]; + writer.Write("if (!"); + if (result.Shape == LuaResultShape.Optional) + { + WriteOptionalResultRead(writer, result, i); + } + else + { + WriteResultRead(writer, result, AbsoluteResultIndex(i)); + } + + writer.WriteLine(")"); + writer.OpenBlock(); + WriteTryFailure(writer, model, i); + writer.CloseBlock(); + writer.WriteLine(); + } + + writer.WriteLine("return true;"); + } + + // 'if (__L.Top - __top < n)': fewer results than the required ones. Lua pads a fixed-count call with nil, so only a + // multiple-results call can tell a missing result from a nil one. + private static void WriteMissingResultCheck(SourceWriter writer, int required) + { + writer.Write("if ("); + writer.Write(State); + writer.Write(".Top - "); + writer.Write(Top); + writer.Write(" < "); + writer.Write(required.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(")"); + } + + // 'LuaCallSupport.TryReadOptional(__L, __top + n, out name)': a position beyond the top is omitted. + private static void WriteOptionalResultRead(SourceWriter writer, LuaResultModel result, int position) + { + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".TryReadOptional<"); + writer.Write(LuaValueKinds.TypeName(result.Kind)); + writer.Write(", "); + writer.Write(result.GeneratedMarshallerTypeName); + writer.Write(">("); + writer.Write(State); + writer.Write(", "); + writer.Write(AbsoluteResultIndex(position)); + writer.Write(", out "); + writer.Write(result.Name); + writer.Write(')'); + } + private static void WriteOutcomeCallAndResults(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, int resultCount) { WriteOutcomeCall(writer, model, argumentCount, resultCount); - WriteOutcomeResults(writer, model, resultCount); + if (model.HasDynamicResults) + { + WriteDynamicOutcomeResults(writer, model); + } + else + { + WriteOutcomeResults(writer, model, resultCount); + } + writer.Write("return "); writer.Write(LuaApiNames.LuaOperationStatus); writer.WriteLine(".Success;"); @@ -475,9 +703,9 @@ private static void WriteOutcomeCall(SourceWriter writer, LuaGlobalCallModel mod writer.Write(" = "); writer.Write(State); writer.Write(".TryCall("); - writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); + writer.Write(ArgumentCountText(model, argumentCount)); writer.Write(", "); - writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); + writer.Write(ResultCountText(model, resultCount)); writer.WriteLine(");"); writer.Write("if (!"); writer.Write(Status); @@ -496,13 +724,124 @@ private static void WriteOutcomeResults(SourceWriter writer, LuaGlobalCallModel } } + // The results of an Outcome form with optional or variadic results: fewer results than the required ones is + // MissingResult, never NilResult; optional results are read or left omitted; the variadic tail is copied by one + // helper call that classifies its own failures. + private static void WriteDynamicOutcomeResults(SourceWriter writer, LuaGlobalCallModel model) + { + int required = model.RequiredResultCount; + if (required > 0) + { + WriteMissingResultCheck(writer, required); + writer.OpenBlock(); + WriteOutcomeFailure(writer, model, LuaApiNames.LuaOperationStatus + ".MissingResult", 0); + writer.CloseBlock(); + writer.WriteLine(); + } + + for (int i = 0; i < model.Results.Length; i++) + { + switch (model.Results[i].Shape) + { + case LuaResultShape.Optional: + writer.Write("if (!"); + WriteOptionalResultRead(writer, model.Results[i], i); + writer.WriteLine(")"); + writer.OpenBlock(); + WriteOutcomeFailure(writer, model, LuaApiNames.LuaOperationStatus + ".InvalidResult", i); + writer.CloseBlock(); + writer.WriteLine(); + break; + case LuaResultShape.Variadic: + WriteVariadicResults(writer, model, i); + break; + default: + WriteOutcomeResult(writer, model, i, AbsoluteResultIndex(i)); + break; + } + } + } + + // 'LuaCallSupport.ReadResults(__L, __top + n, values, out count)' copies every value from that index to the + // top, or reports ResultCapacityExceeded with the needed count, NilResult or InvalidResult. On failure the other + // results are defaulted and the count keeps what the helper wrote. + private static void WriteVariadicResults(SourceWriter writer, LuaGlobalCallModel model, int position) + { + LuaResultModel result = model.Results[position]; + writer.Write(LuaApiNames.LuaOperationStatus); + writer.Write(' '); + writer.Write(Rest); + writer.Write(" = "); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".ReadResults<"); + writer.Write(LuaValueKinds.TypeName(result.Kind)); + writer.Write(", "); + writer.Write(LuaValueKinds.MarshallerTypeName(result.Kind)); + writer.Write(">("); + writer.Write(State); + writer.Write(", "); + writer.Write(AbsoluteResultIndex(position)); + writer.Write(", "); + writer.Write(result.DestinationName); + writer.Write(", out "); + writer.Write(result.Name); + writer.WriteLine(");"); + writer.Write("if (!"); + writer.Write(Rest); + writer.WriteLine(".IsSuccess)"); + writer.OpenBlock(); + for (int i = 0; i < model.Results.Length; i++) + { + if (i == position) + { + continue; + } + + LuaResultModel other = model.Results[i]; + writer.Write(other.Name); + writer.WriteLine(other.IsReferenceType ? " = default!;" : " = default;"); + } + + writer.Write("return "); + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".Fail("); + writer.Write(State); + writer.Write(", "); + writer.Write(Top); + writer.Write(", "); + writer.Write(Rest); + writer.WriteLine(");"); + writer.CloseBlock(); + writer.WriteLine(); + } + private static void WriteOutcomeResult(SourceWriter writer, LuaGlobalCallModel model, int resultIndex, int stackIndex) + { + WriteOutcomeResult(writer, model, resultIndex, stackIndex.ToString(CultureInfo.InvariantCulture)); + } + + private static void WriteOutcomeResult(SourceWriter writer, LuaGlobalCallModel model, int resultIndex, + string stackIndex) { writer.Write("if (!"); WriteResultRead(writer, model.Results[resultIndex], stackIndex); writer.WriteLine(")"); writer.OpenBlock(); + // Every other result is defaulted too (all read or none, like the Try form): a declaration with a single result + // writes nothing here. + for (int i = 0; i < model.Results.Length; i++) + { + if (i == resultIndex) + { + continue; + } + + LuaResultModel other = model.Results[i]; + writer.Write(other.Name); + writer.WriteLine(other.IsReferenceType ? " = default!;" : " = default;"); + } + writer.Write("return "); writer.Write(LuaApiNames.LuaCallSupport); writer.Write(".Fail("); @@ -512,7 +851,7 @@ private static void WriteOutcomeResult(SourceWriter writer, LuaGlobalCallModel m writer.Write(", "); writer.Write(State); writer.Write(".IsNil("); - writer.Write(stackIndex.ToString(CultureInfo.InvariantCulture)); + writer.Write(stackIndex); writer.Write(") ? "); writer.Write(LuaApiNames.LuaOperationStatus); writer.Write(".NilResult : "); @@ -525,7 +864,8 @@ private static void WriteOutcomeResult(SourceWriter writer, LuaGlobalCallModel m } // Exit 2: the call raised; the status travels to the throw helper, which reads the error value. - private static void WriteThrowingCall(SourceWriter writer, int argumentCount, int resultCount) + private static void WriteThrowingCall(SourceWriter writer, LuaGlobalCallModel model, int argumentCount, + int resultCount) { writer.Write(LuaApiNames.LuaStatus); writer.Write(' '); @@ -533,7 +873,7 @@ private static void WriteThrowingCall(SourceWriter writer, int argumentCount, in writer.Write(" = "); writer.Write(State); writer.Write(".TryCall("); - writer.Write(argumentCount.ToString(CultureInfo.InvariantCulture)); + writer.Write(ArgumentCountText(model, argumentCount)); writer.Write(", "); writer.Write(resultCount.ToString(CultureInfo.InvariantCulture)); writer.WriteLine(");"); @@ -556,7 +896,7 @@ private static void WriteThrowingCall(SourceWriter writer, int argumentCount, in private static void WriteThrowingResult(SourceWriter writer, LuaGlobalCallModel model) { if (!model.HasReturn) - // A void call keeps no result: the successful call already left the stack at its recorded top. + // A void call keeps no result: the successful call already left the stack at its recorded top. { return; } @@ -593,12 +933,19 @@ private static void WriteThrowingResult(SourceWriter writer, LuaGlobalCallModel // The read of one result at a (negative) stack index, as a boolean expression. private static void WriteResultRead(SourceWriter writer, LuaResultModel result, int index) + { + WriteResultRead(writer, result, index.ToString(CultureInfo.InvariantCulture)); + } + + // The read of one result at a stack index expression: a negative constant after a fixed-count call, an absolute + // '__top + n' after a multiple-results call. + private static void WriteResultRead(SourceWriter writer, LuaResultModel result, string index) { if (result.Shape == LuaResultShape.CopyOut) { writer.Write(State); writer.Write(".TryCopyUtf8("); - writer.Write(index.ToString(CultureInfo.InvariantCulture)); + writer.Write(index); writer.Write(", "); writer.Write(result.DestinationName); writer.Write(", out "); @@ -611,7 +958,7 @@ private static void WriteResultRead(SourceWriter writer, LuaResultModel result, writer.Write(".TryRead("); writer.Write(State); writer.Write(", "); - writer.Write(index.ToString(CultureInfo.InvariantCulture)); + writer.Write(index); writer.Write(", out "); writer.Write(result.Name); writer.Write(')'); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs index c2a976c7..4efdd097 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs @@ -80,6 +80,64 @@ public LuaGlobalCallModel(string globalName, string cacheFieldName, string modif ///
public int ResultCount => IsTryLike ? Results.Length : ReturnKind is null && ReturnMarshaller is null ? 0 : 1; + /// + /// Whether an argument is a LuaOptional<T>: the body then computes the pushed argument count before + /// acquiring the state and calls with it instead of a constant. + /// + public bool HasOptionalArguments + { + get + { + foreach (LuaArgumentModel argument in Arguments) + { + if (argument.IsOptional) + { + return true; + } + } + + return false; + } + } + + /// + /// Whether a result is optional or variadic: the body then calls with LUA_MULTRET, reads the factual result + /// count and addresses results by absolute index. + /// + public bool HasDynamicResults + { + get + { + foreach (LuaResultModel result in Results) + { + if (result.IsDynamic) + { + return true; + } + } + + return false; + } + } + + /// The number of leading results that Lua must return: the value and copy-out results. + public int RequiredResultCount + { + get + { + int count = 0; + foreach (LuaResultModel result in Results) + { + if (!result.IsDynamic) + { + count++; + } + } + + return count; + } + } + /// Gets whether this shape returns its Lua values through parameters. public bool IsTryLike => Form is LuaCallForm.Try or LuaCallForm.Outcome; @@ -91,15 +149,15 @@ public LuaGlobalCallModel(string globalName, string cacheFieldName, string modif /// The concrete static marshaller for the throwing-form return value. public string ReturnMarshallerTypeName => ReturnMarshaller?.MarshallerTypeName ?? - LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); + LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); /// The C# type spelling for the generated return and result local. public string ReturnTypeName => ReturnMarshaller?.ValueTypeName ?? - LuaValueKinds.TypeName(ReturnKind!.Value, ReturnIsNullable); + LuaValueKinds.TypeName(ReturnKind!.Value, ReturnIsNullable); /// The Lua-facing expected type for a throwing-form result failure. public string ExpectedReturnTypeName => ReturnMarshaller?.ExpectedTypeName ?? - LuaValueKinds.ExpectedResult(ReturnKind!.Value); + LuaValueKinds.ExpectedResult(ReturnKind!.Value); /// Whether the body reads the state from rather than from the runtime. public bool TakesState => StateParameterName.Length > 0; diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultModel.cs index 7f177ce7..a2bf7deb 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultModel.cs @@ -1,19 +1,20 @@ namespace CheatEngine.SDK.SourceGenerators.Shared.LuaEmit; /// One result of a Try-form wrapper, as it appears in the signature and is read after the call. -/// Value or copy-out. +/// Value, copy-out, optional value or variadic tail. /// -/// For : the kind the marshaller reads (never ). -/// For : , the type of the written count. +/// For and : the kind the marshaller reads +/// (never ). For : +/// , the type of the written count. For +/// : the element kind of the span. /// /// -/// The name of the parameter (the value, or the written count), -/// keyword-escaped. +/// The name of the parameter (the value, the written count or the variadic +/// count), keyword-escaped. /// /// For a value: the declaration wrote out string?. /// -/// For copy-out: the name of the Span<byte> parameter that precedes the count; -/// empty otherwise. +/// For copy-out and variadic: the name of the span parameter that precedes the count; empty otherwise. /// /// /// For copy-out: the declaration wrote scoped Span<byte> destination. Reproduced in a wrapper's @@ -47,14 +48,26 @@ public LuaResultModel(LuaResultShape shape, LuaValueKind kind, string name, bool public string GeneratedMarshallerTypeName => CustomMarshaller?.MarshallerTypeName ?? LuaValueKinds.MarshallerTypeName(Kind); - /// The C# type spelling used in an emitted parameter or local. - public string GeneratedTypeName => CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(Kind, IsNullable); + /// + /// The C# type spelling used in an emitted parameter or local: LuaOptional<T> + /// for an optional result, the value type for a value result. + /// + public string GeneratedTypeName => Shape == LuaResultShape.Optional + ? LuaValueKinds.OptionalTypeName(Kind) + : CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(Kind, IsNullable); /// The Lua-facing expected type in a generated failure message. public string ExpectedResultTypeName => CustomMarshaller?.ExpectedTypeName ?? LuaValueKinds.ExpectedResult(Kind); - /// Whether the generated default assignment needs the null-forgiving operator. - public bool IsReferenceType => CustomMarshaller?.IsReferenceType ?? LuaValueKinds.IsReferenceType(Kind); + /// + /// Whether the generated default assignment needs the null-forgiving operator: only for a value result of a + /// reference type (an optional result is a struct, a copy-out or variadic count is an ). + /// + public bool IsReferenceType => Shape == LuaResultShape.Value && + (CustomMarshaller?.IsReferenceType ?? LuaValueKinds.IsReferenceType(Kind)); + + /// Whether the protected call must keep every result and read the factual count. + public bool IsDynamic => Shape is LuaResultShape.Optional or LuaResultShape.Variadic; /// A value result: out <type> name. public static LuaResultModel Value(LuaValueKind kind, string name, bool isNullable = false) @@ -69,6 +82,20 @@ public static LuaResultModel Custom(LuaCustomMarshallerModel marshaller, string string.Empty, CustomMarshaller: marshaller); } + /// An optional result: out LuaOptional<T> name. + public static LuaResultModel Optional(LuaValueKind kind, string name) + { + return new LuaResultModel(LuaResultShape.Optional, kind, name, false, string.Empty); + } + + /// A variadic tail: Span<T> values, out int count. + public static LuaResultModel Variadic(LuaValueKind elementKind, string valuesName, string countName, + bool valuesIsScoped = false) + { + return new LuaResultModel(LuaResultShape.Variadic, elementKind, countName, false, valuesName, + valuesIsScoped); + } + /// A copy-out string result: Span<byte> destination, out int written. public static LuaResultModel CopyOut(string destinationName, string writtenName, bool destinationIsScoped = false) { diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultShape.cs index 6f6523d7..4241de24 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaResultShape.cs @@ -14,5 +14,18 @@ internal enum LuaResultShape /// Span<byte> destination, written ( /// LuaState.TryCopyUtf8). /// - CopyOut + CopyOut, + + /// + /// An LuaOptional<T> after every required result: a position Lua did not + /// return is Omitted, a nil is Nil, a readable value is present. A declaration with such a + /// result calls with LUA_MULTRET and reads the factual result count. + /// + Optional, + + /// + /// The last result of an Outcome form: Span<T> values, out int count, every value Lua returned after + /// the fixed and optional results, copied while still on the stack (LuaCallSupport.ReadResults). + /// + Variadic } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs index 9d66bc6d..ae08b987 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs @@ -101,17 +101,46 @@ public static void Emit(SourceWriter writer, LuaThunkModel model) public static string WrongArgumentCountMessage(string luaName, int expected) { return "wrong number of arguments to '" + luaName + "' (" + expected.ToString(CultureInfo.InvariantCulture) + - " expected)"; + " expected)"; } - // The count check first, so that a missing argument and a surplus one get the same, complete message. + /// + /// The message a thunk with optional trailing arguments reports when it is called with fewer arguments than the + /// required ones or more than the declared ones: wrong number of arguments to 'f' (1 to 3 expected). Equal + /// to when nothing is optional. + /// + public static string WrongArgumentCountMessage(string luaName, int minimum, int maximum) + { + return minimum == maximum + ? WrongArgumentCountMessage(luaName, maximum) + : "wrong number of arguments to '" + luaName + "' (" + minimum.ToString(CultureInfo.InvariantCulture) + + " to " + maximum.ToString(CultureInfo.InvariantCulture) + " expected)"; + } + + // The count check first, so that a missing argument and a surplus one get the same, complete message. With optional + // trailing arguments any count from the required ones to all of them is accepted; an absent position reads as + // omitted. private static void WriteArgumentCountCheck(SourceWriter writer, LuaThunkModel model) { int count = model.Arguments.Length; + int required = model.RequiredArgumentCount; writer.Write("if ("); writer.Write(State); - writer.Write(".Top != "); - writer.Write(count.ToString(CultureInfo.InvariantCulture)); + if (required == count) + { + writer.Write(".Top != "); + writer.Write(count.ToString(CultureInfo.InvariantCulture)); + } + else + { + writer.Write(".Top < "); + writer.Write(required.ToString(CultureInfo.InvariantCulture)); + writer.Write(" || "); + writer.Write(State); + writer.Write(".Top > "); + writer.Write(count.ToString(CultureInfo.InvariantCulture)); + } + writer.WriteLine(")"); writer.OpenBlock(); writer.Write("return "); @@ -119,7 +148,7 @@ private static void WriteArgumentCountCheck(SourceWriter writer, LuaThunkModel m writer.Write(".Fail("); writer.Write(State); writer.Write(", "); - writer.Write(CSharpLiteral.ToUtf8Literal(WrongArgumentCountMessage(model.LuaName, count))); + writer.Write(CSharpLiteral.ToUtf8Literal(WrongArgumentCountMessage(model.LuaName, required, count))); writer.WriteLine(");"); writer.CloseBlock(); } @@ -129,15 +158,32 @@ private static void WriteArgumentRead(SourceWriter writer, LuaArgumentModel argu { string position = (index + 1).ToString(CultureInfo.InvariantCulture); writer.Write("if (!"); - writer.Write(argument.GeneratedMarshallerTypeName); - writer.Write(".TryRead("); + if (argument.IsOptional) + { + // Absent (beyond the top) is omitted, nil is Nil, a value of the right kind is present; anything else is a + // bad argument exactly like a required one. + writer.Write(LuaApiNames.LuaCallSupport); + writer.Write(".TryReadOptional<"); + writer.Write(LuaValueKinds.TypeName(argument.Kind)); + writer.Write(", "); + writer.Write(argument.GeneratedMarshallerTypeName); + writer.Write(">("); + } + else + { + writer.Write(argument.GeneratedMarshallerTypeName); + writer.Write(".TryRead("); + } + writer.Write(State); writer.Write(", "); writer.Write(position); writer.Write(", out "); // A string local is declared nullable: the marshaller's out parameter is [MaybeNullWhen(false)], and the // flow analysis knows it is not null once the read succeeded, so it flows into a 'string' parameter. - writer.Write(argument.CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(argument.Kind, true)); + writer.Write(argument.IsOptional + ? argument.GeneratedTypeName + : argument.CustomMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(argument.Kind, true)); writer.Write(' '); writer.Write(ArgumentPrefix); writer.Write(index.ToString(CultureInfo.InvariantCulture)); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs index aca4152d..1d9a6209 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs @@ -53,9 +53,27 @@ public LuaThunkModel(string luaName, string thunkMethodName, string targetMethod /// Whether the target returns one Lua value. public bool HasReturn => ReturnKind is not null || ReturnMarshaller is not null; + /// + /// The number of Lua arguments a call must pass: every argument before the trailing run of optional ones (all of + /// them when none is optional). + /// + public int RequiredArgumentCount + { + get + { + int count = 0; + while (count < Arguments.Length && !Arguments[count].IsOptional) + { + count++; + } + + return count; + } + } + /// The concrete static marshaller for the return value. public string ReturnMarshallerTypeName => ReturnMarshaller?.MarshallerTypeName ?? - LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); + LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); /// The C# type spelling for the generated result local. public string ReturnTypeName => ReturnMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(ReturnKind!.Value, true); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKinds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKinds.cs index 2fcc7cf9..0e8ada36 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKinds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaValueKinds.cs @@ -60,6 +60,36 @@ public static string TypeName(LuaValueKind kind, bool isNullable = false) }; } + /// + /// The global::-qualified LuaOptional<T> spelling of an optional value of + /// : global::CheatEngine.SDK.Lua.Marshalling.LuaOptional<long>. The type + /// argument is never nullable: nil is a state of the optional, not a value. + /// + public static string OptionalTypeName(LuaValueKind kind) + { + return LuaApiNames.LuaOptional + "<" + TypeName(kind) + ">"; + } + + /// + /// Whether a value of this kind can be a LuaOptional<T> argument or result: every built-in kind + /// except , a ReadOnlySpan<byte> that cannot be a type argument. + /// + public static bool CanBeOptional(LuaValueKind kind) + { + return kind != LuaValueKind.Utf8; + } + + /// + /// Whether a value of this kind can be the element of a variadic Span<T> values, out int count result: + /// the unmanaged scalar kinds. and ReadOnlySpan<byte> are not, and a + /// Span<byte> destination stays the UTF-8 copy-out pair. + /// + public static bool CanBeVariadicElement(LuaValueKind kind) + { + return kind is LuaValueKind.Int32 or LuaValueKind.Int64 or LuaValueKind.Single or LuaValueKind.Double + or LuaValueKind.Boolean or LuaValueKind.Address; + } + /// /// The Lua type a thunk expects for an argument of this kind, in Lua's own words /// (integer, number, boolean, diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs index 98d6924b..1dde5b55 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs @@ -124,8 +124,8 @@ public static PluginShapeIssues Inspect( parameterlessConstructor = null; if (type.IsStatic) - // A static class is also abstract and sealed in metadata, has no base class and no instance - // constructor: one message instead of four. + // A static class is also abstract and sealed in metadata, has no base class and no instance + // constructor: one message instead of four. { return issues | PluginShapeIssues.Static; } @@ -229,8 +229,8 @@ private static bool DerivesFromPluginBase(INamedTypeSymbol type, INamedTypeSymbo for (INamedTypeSymbol? current = type.BaseType; current is not null; current = current.BaseType) { if (pluginBase is null - ? IsPluginBaseFallback(current) - : SymbolEqualityComparer.Default.Equals(current, pluginBase)) + ? IsPluginBaseFallback(current) + : SymbolEqualityComparer.Default.Equals(current, pluginBase)) { return true; } @@ -329,7 +329,7 @@ private static bool HasRequiredMembers(INamedTypeSymbol type) for (INamedTypeSymbol? current = type; current is not null; current = current.BaseType) { if (current.GetMembers().Any(static member => - member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true })) + member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true })) { return true; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/packages.lock.json b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/packages.lock.json new file mode 100644 index 00000000..b52c3444 --- /dev/null +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/packages.lock.json @@ -0,0 +1,138 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.16.0, )", + "resolved": "1.16.0", + "contentHash": "3kdIIceBPumwjw279FuiVMfVENT2cGASXJgcigdySsbX2dJB8ofUgG6i47yqF/k1qu6fvNR3csrSekZPviR6kQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", + "dependencies": { + "System.Collections.Immutable": "10.0.1" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + } + } +} \ No newline at end of file diff --git a/src/CheatEngine.SDK/CheatEngine.SDK.csproj b/src/CheatEngine.SDK/CheatEngine.SDK.csproj index 73b4cd4e..06b41cd2 100644 --- a/src/CheatEngine.SDK/CheatEngine.SDK.csproj +++ b/src/CheatEngine.SDK/CheatEngine.SDK.csproj @@ -7,9 +7,21 @@ MIT https://github.com/CheatEngineNet/CheatEngine.SDK git + + true cheat-engine;cheat-engine-plugin;sdk;lua;source-generator;roslyn-analyzer;reverse-engineering $(PackageProjectUrl)/blob/main/CHANGELOG.md true + + 1.0.0 $(TargetsForTfmSpecificBuildOutput);_CheatEngineSdkEmbedLibraries $(TargetsForTfmSpecificContentInPackage);_CheatEngineSdkPackRoslynComponents @@ -19,6 +31,24 @@ false + + + true + CheatEngineNet + https://github.com/CheatEngineNet/CheatEngine.SDK + $(MSBuildProjectDirectory) + false + + + + + diff --git a/src/CheatEngine.SDK/CompatibilitySuppressions.xml b/src/CheatEngine.SDK/CompatibilitySuppressions.xml new file mode 100644 index 00000000..bbb20b27 --- /dev/null +++ b/src/CheatEngine.SDK/CompatibilitySuppressions.xml @@ -0,0 +1,67 @@ + + + + + CP0002 + F:CheatEngine.SDK.Abi.Native.AddressListPluginInit.Callback + lib/net10.0/CheatEngine.SDK.Abi.dll + lib/net10.0/CheatEngine.SDK.Abi.dll + true + + + CP0002 + F:CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Callback + lib/net10.0/CheatEngine.SDK.Abi.dll + lib/net10.0/CheatEngine.SDK.Abi.dll + true + + + CP0002 + M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.#ctor(System.Boolean,System.Boolean) + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0002 + M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Deconstruct(System.Boolean@,System.Boolean@) + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0002 + M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.get_UseHostSymbolTable + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0002 + M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.set_UseHostSymbolTable(System.Boolean) + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0011 + F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0011 + F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0011 + F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + \ No newline at end of file diff --git a/src/CheatEngine.SDK/README.md b/src/CheatEngine.SDK/README.md index 00b90603..6c9f6aeb 100644 --- a/src/CheatEngine.SDK/README.md +++ b/src/CheatEngine.SDK/README.md @@ -67,7 +67,7 @@ because the parts are built and packed together. > framework request can be prescribed for every CE 7.7 installation. The inspected host follows the > `nethost`/`hostfxr` route, and Microsoft documents that route for framework-dependent components. Do not overwrite an > installed Cheat Engine runtime configuration from this package guide. Establish and record the host runtime policy in -> the controlled environment that performs the opt-in live verification. +> the controlled environment that performs the opt-in live verification, including its observed hash. The [live plugin guide](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/tests/CheatEngine.SDK.LivePlugin/README.md#run-it-in-cheat-engine) has the controlled observation procedure; it does not establish a runtime-configuration recipe for arbitrary CE @@ -120,17 +120,28 @@ Windows x64-only. These build assets never flow through an intermediate NuGet pa logged, cleanup still completes, and Cheat Engine receives success to record the disabled state. Neither propagates into Cheat Engine (`EnablePluginTests` and `DisablePluginTests`, which run against Cheat Engine's own Lua DLL). -## AOT status +## Load profiles and limits The shipping libraries set `IsAotCompatible=true` and verify that their runtime references carry equivalent AOT metadata. `tests/CheatEngine.SDK.AotProbe` is a standalone Windows x64 executable that publishes the complete shipping graph with Native AOT. A successful probe establishes only the analysed graph and that publish invocation; it is not a -Cheat Engine plugin and says nothing about whether CE can host or unload a Native AOT artifact. In particular, Native -AOT class-library exports require explicit `UnmanagedCallersOnly` exports and Native AOT DLLs do not support -`FreeLibrary` -unloading. [Microsoft's Native AOT library guidance](https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries) -and [single-file deployment guidance](https://learn.microsoft.com/dotnet/core/deploying/single-file/overview) describe -different deployment models from this framework-dependent plugin folder. +Cheat Engine plugin and says nothing about whether CE can host or unload a Native AOT artifact. **A NativeAOT publish +success is never a Cheat Engine load success.** + +| Profile | Status | +|---|---| +| Historical CLR route (`MSCorEE.dll` hosting, string entry point) | Documentary only, not supported | +| Managed hostfxr route (`CESDK.CESDK.CEPluginInitialize(IntPtr, int)`) | The only supported profile | +| NativeAOT plugin DLL | Not supported | +| Classic native plugin exporting `CEPlugin_*` | Not provided by the SDK | +| x86 or ARM64 host | Not supported | + +Cheat Engine's native loader removes a plugin with +`FreeLibrary`, and [Microsoft's Native AOT library guidance](https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries) +states that .NET does not support unloading a NativeAOT library that way; only `UnmanagedCallersOnly` methods of the +published assembly become native exports, so the package itself can never add classic `CEPlugin_*` exports for a +consumer. See the [full restrictions, evidence and diagnostics](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/libs/CheatEngine.SDK.Abi/README.md) +(`CESDK9102`, `CESDK0006`). ## Requirements @@ -140,6 +151,9 @@ different deployment models from this framework-dependent plugin folder. | .NET runtimes | .NET 10 `Microsoft.NETCore.App`, `Microsoft.WindowsDesktop.App` and `Microsoft.AspNetCore.App` (see `dotnet --list-runtimes`) | | Cheat Engine | 7.7, Windows, x64 | +The qualifiable host profile is `ce-7.7.0.10621-x64-managed-hostfxr`. Host qualification is tracked per scenario +against the audit's Q01-Q48 register; a scenario without gathered evidence is not executed. + The analyzers and generators are built against Roslyn 5.9.0. An older SDK reports `CS9057` and skips them, so no entry point is generated. diff --git a/src/CheatEngine.SDK/build/CheatEngine.SDK.targets b/src/CheatEngine.SDK/build/CheatEngine.SDK.targets index 189a08e3..b8304914 100644 --- a/src/CheatEngine.SDK/build/CheatEngine.SDK.targets +++ b/src/CheatEngine.SDK/build/CheatEngine.SDK.targets @@ -10,4 +10,22 @@ + + + + + diff --git a/src/CheatEngine.SDK/packages.lock.json b/src/CheatEngine.SDK/packages.lock.json new file mode 100644 index 00000000..79b82719 --- /dev/null +++ b/src/CheatEngine.SDK/packages.lock.json @@ -0,0 +1,70 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "Microsoft.Sbom.Targets": { + "type": "Direct", + "requested": "[4.1.13, )", + "resolved": "4.1.13", + "contentHash": "l9NiCqVmBBY06Lrxv61xWtiLvU1feto6j7QmMsaARBopO+QLTFDFLEIbYe8pYGjk1INJ2eWE/GGjToqQpokYAw==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.engine": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Abi.Tests/AbiBoundaryValueTests.cs b/tests/CheatEngine.SDK.Abi.Tests/AbiBoundaryValueTests.cs new file mode 100644 index 00000000..df43b2a7 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/AbiBoundaryValueTests.cs @@ -0,0 +1,170 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Abi.Native; +using CheatEngine.SDK.Abi.Tests.Support; + +namespace CheatEngine.SDK.Abi.Tests; + +/// +/// Boundary values at the ABI edge (audit analyses/03 exit tests, A03-21 and A03-22): non-canonical booleans produced +/// by a host (2, all bits set) read as true through every lifecycle signature and record field, and addresses above +/// 32 bits survive every address-carrying field and callback parameter. The host side is played by +/// [UnmanagedCallersOnly] stdcall functions whose raw results are chosen per test. +/// +public sealed unsafe class AbiBoundaryValueTests +{ + private const ulong HighAddress = 0xFFFF_8000_0000_0000; + + [ThreadStatic] private static int t_rawResult; + + [Theory] + [InlineData(2)] + [InlineData(-1)] + public void Bool32_results_2_and_0xFFFFFFFF_read_as_true_through_every_lifecycle_signature(int raw) + { + t_rawResult = raw; + PluginVersion version = default; + ManagedExportedFunctions managedExports = default; + ExportedFunctionsPrefix classicExports = default; + + // Managed bootstrap record: the three callbacks CE calls through TPluginDotNetInitResult. + PluginInitRecord record = default; + record.GetVersion = (delegate* unmanaged[Stdcall]) + (delegate* unmanaged[Stdcall]) &RawGetVersion; + record.EnablePlugin = (delegate* unmanaged[Stdcall]) + (delegate* unmanaged[Stdcall]) &RawEnablePlugin; + record.DisablePlugin = (delegate* unmanaged[Stdcall]) (delegate* unmanaged[Stdcall]) &RawDisablePlugin; + + AssertTrueWithRawBits(record.GetVersion(&version, sizeof(PluginVersion)), raw); + AssertTrueWithRawBits(record.EnablePlugin(&managedExports, 7u), raw); + AssertTrueWithRawBits(record.DisablePlugin(), raw); + + // Classic native exports (NativeExportNames): same results, classic argument shapes. + delegate* unmanaged[Stdcall] nativeGetVersion = + (delegate* unmanaged[Stdcall]) + (delegate* unmanaged[Stdcall]) &RawGetVersion; + delegate* unmanaged[Stdcall] nativeInitialize = + (delegate* unmanaged[Stdcall]) + (delegate* unmanaged[Stdcall]) &RawInitializePlugin; + delegate* unmanaged[Stdcall] nativeDisable = + (delegate* unmanaged[Stdcall]) (delegate* unmanaged[Stdcall]) &RawDisablePlugin; + + AssertTrueWithRawBits(nativeGetVersion(&version, sizeof(PluginVersion)), raw); + AssertTrueWithRawBits(nativeInitialize(&classicExports, 0x1020_3040), raw); + AssertTrueWithRawBits(nativeDisable(), raw); + } + + [Theory] + [InlineData(2)] + [InlineData(-1)] + public void PluginType0Record_IsPointer_2_and_0xFFFFFFFF_read_as_true(int raw) + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + byte* buffer = stackalloc byte[48]; + new Span(buffer, 48).Clear(); + Unsafe.WriteUnaligned(buffer + 16, raw); + + PluginType0Record* record = (PluginType0Record*) buffer; + + AssertTrueWithRawBits(record->IsPointer, raw); + Assert.Equal(0, record->CountOffsets); + } + + [Fact] + public void Address_0xFFFF800000000000_round_trips_through_every_address_field() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + ulong high = HighAddress; + nuint address = (nuint) high; + + // Selection record: the host writes the address, the SDK reads it through the record. + byte* selection = stackalloc byte[48]; + new Span(selection, 48).Clear(); + Unsafe.WriteUnaligned(selection + 8, high); + Assert.Equal(address, ((PluginType0Record*) selection)->Address); + Assert.Equal(0, ((PluginType0Record*) selection)->IsPointer.RawValue); + + // Register change request: the address and a replacement register value land at their byte offsets. + RegisterModificationInfo info = default; + info.Address = address; + info.NewR15 = address + 8; + Assert.Equal(high, Unsafe.ReadUnaligned((byte*) &info)); + Assert.Equal(high + 8, Unsafe.ReadUnaligned((byte*) &info + 232)); + + // Memory view callback: three addresses by reference, in and out. + MemoryViewPluginInit memoryView = default; + memoryView.Callback = &SwapMemoryViewAddresses; + nuint disassembler = address; + nuint selected = address + 1; + nuint hexView = address + 2; + Assert.True(memoryView.Callback(&disassembler, &selected, &hexView).IsTrue); + Assert.Equal(address + 2, disassembler); + Assert.Equal(address, hexView); + Assert.Equal(address + 1, selected); + + // Classic ChangeRegistersAtAddress: the address argument and the NewR15 written back by the host. + ExportedFunctionsPrefix exports = default; + exports.ChangeRegistersAtAddress = &FakeChangeRegistersAtAddress; + RegisterModificationInfo request = default; + request.ChangeR15 = Bool32.True; + Assert.True(exports.ChangeRegistersAtAddress(address, &request).IsTrue); + Assert.Equal(address + 0x10, request.NewR15); + Assert.Equal(address, request.Address); + } + + private static void AssertTrueWithRawBits(Bool32 value, int raw) + { + Assert.True(value.IsTrue); + Assert.True(value == Bool32.True, "Equality is on truthiness, so a non-canonical true equals Bool32.True."); + Assert.Equal(raw, value.RawValue); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int RawGetVersion(PluginVersion* version, int size) + { + _ = version; + _ = size; + return t_rawResult; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int RawEnablePlugin(ManagedExportedFunctions* exports, uint pluginId) + { + _ = exports; + _ = pluginId; + return t_rawResult; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int RawInitializePlugin(ExportedFunctionsPrefix* exports, int pluginId) + { + _ = exports; + _ = pluginId; + return t_rawResult; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static int RawDisablePlugin() + { + return t_rawResult; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 SwapMemoryViewAddresses(nuint* disassemblerAddress, nuint* selectedAddress, nuint* hexViewAddress) + { + _ = selectedAddress; + (*disassemblerAddress, *hexViewAddress) = (*hexViewAddress, *disassemblerAddress); + return Bool32.True; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static Bool32 FakeChangeRegistersAtAddress(nuint address, RegisterModificationInfo* changes) + { + changes->Address = address; + changes->NewR15 = address + 0x10; + return Bool32.True; + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/AssemblyConformanceTests.cs b/tests/CheatEngine.SDK.Abi.Tests/AssemblyConformanceTests.cs index 46df847b..fbabec3c 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/AssemblyConformanceTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/AssemblyConformanceTests.cs @@ -12,35 +12,47 @@ namespace CheatEngine.SDK.Abi.Tests; /// Assembly-wide gates. They exist so that a structure or enumeration added to CheatEngine.SDK.Abi without a /// row in the expected-size table, or with a shape the ABI does not allow (see : forbidden /// field types at any depth, function pointers that are managed, not Stdcall, or carry a forbidden type in -/// their signature), fails the test run instead of slipping through. +/// their signature), fails the test run instead of slipping through. The per-field offset and width of every one of +/// these structures is gated by FieldLayout.FieldLayoutContractTests. /// /// /// What reflection cannot see, and therefore stays a review rule: whether [StructLayout] was written out -/// (C# structures are sequential by default, the metadata is identical) and whether a new structure also got its -/// per-field offset test. +/// (C# structures are sequential by default, the metadata is identical). /// public sealed class AssemblyConformanceTests { - private static readonly Assembly AbiAssembly = typeof(PluginInitRecord).Assembly; + private const string Abi = "CheatEngine.SDK.Abi."; + private const string Managed = Abi + "Managed."; + private const string Native = Abi + "Native."; - /// Every public structure of the assembly with its 64-bit size. Adding a structure means adding a row. + private static readonly Assembly AbiAssembly = AbiStructures.AbiAssembly; + + /// + /// Every structure of the assembly (public, internal and nested private) with its 64-bit size, keyed by full name. + /// Adding a structure means adding a row here and its field rows in . + /// private static readonly Dictionary ExpectedSizesOn64Bit = new(StringComparer.Ordinal) { - [nameof(Bool32)] = 4, - [nameof(Bool8)] = 1, - [nameof(PluginInitRecord)] = 36, - [nameof(ManagedExportedFunctions)] = 48, - [nameof(PluginVersion)] = 16, - [nameof(AddressListPluginInit)] = 16, - [nameof(MemoryViewPluginInit)] = 24, - [nameof(DebugEventPluginInit)] = 8, - [nameof(DebugEventObservation)] = 24, - [nameof(ProcessWatcherPluginInit)] = 8, - [nameof(FunctionPointerChangePluginInit)] = 8, - [nameof(MainMenuPluginInit)] = 24, - [nameof(DisassemblerContextPluginInit)] = 32, - [nameof(DisassemblerRenderLinePluginInit)] = 8, - [nameof(AutoAssemblerPluginInit)] = 8 + [Abi + nameof(Bool32)] = 4, + [Abi + nameof(Bool8)] = 1, + [Managed + nameof(PluginInitRecord)] = 36, + [Managed + nameof(ManagedExportedFunctions)] = 48, + [Native + nameof(PluginVersion)] = 16, + [Native + nameof(AddressListPluginInit)] = 16, + [Native + nameof(MemoryViewPluginInit)] = 24, + [Native + nameof(DebugEventPluginInit)] = 8, + [Native + nameof(DebugEventObservation)] = 24, + [Native + nameof(ProcessWatcherPluginInit)] = 8, + [Native + nameof(FunctionPointerChangePluginInit)] = 8, + [Native + nameof(MainMenuPluginInit)] = 24, + [Native + nameof(DisassemblerContextPluginInit)] = 32, + [Native + nameof(DisassemblerRenderLinePluginInit)] = 8, + [Native + nameof(AutoAssemblerPluginInit)] = 8, + [Native + nameof(ExportedFunctionsPrefix)] = 144, + [Native + nameof(PluginType0Record)] = 48, + [Native + nameof(RegisterModificationInfo)] = 264, + [Native + nameof(ClassicSlotObservation)] = 16, + [Native + nameof(ClassicDebugEventDispatcher) + "+DebugEventHeader"] = 12 }; [Fact] @@ -61,33 +73,40 @@ public void Assembly_references_no_other_cheatengine_sdk_assembly() } [Fact] - public void Every_public_structure_is_listed_in_the_expected_size_table() + public void Every_structure_is_listed_in_the_expected_size_table() { - string[] actual = PublicStructures().Select(static type => type.Name).Order(StringComparer.Ordinal).ToArray(); + string[] actual = AbiStructures.All().Select(static type => type.FullName!).Order(StringComparer.Ordinal).ToArray(); string[] expected = ExpectedSizesOn64Bit.Keys.Order(StringComparer.Ordinal).ToArray(); Assert.Equal(expected, actual); } [Fact] - public void Every_public_structure_on_64_bit_has_the_expected_size() + public void Every_structure_including_internal_and_nested_ones_has_the_expected_size() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); Assert.All( - PublicStructures(), + AbiStructures.All(), static type => { - Assert.True(ExpectedSizesOn64Bit.TryGetValue(type.Name, out int expected), - $"No expected size for {type.Name}."); + Assert.True(ExpectedSizesOn64Bit.TryGetValue(type.FullName!, out int expected), + $"No expected size for {type.FullName}."); Assert.Equal(expected, RuntimeHelpers.SizeOf(type.TypeHandle)); }); } + [Fact] + public void Public_structures_are_a_subset_of_the_gated_structures() + { + Assert.All(PublicStructures(), + static type => Assert.True(ExpectedSizesOn64Bit.ContainsKey(type.FullName!), $"{type.FullName} is not gated.")); + } + [Fact] public void Every_structure_is_sequential_and_blittable_at_any_depth() { - Type[] structures = AllStructures().ToArray(); + Type[] structures = AbiStructures.All().ToArray(); Assert.NotEmpty(structures); Assert.All(structures, static type => Assert.Null(AbiShape.FindViolation(type, AbiAssembly))); @@ -97,7 +116,7 @@ public void Every_structure_is_sequential_and_blittable_at_any_depth() public void Only_the_init_record_overrides_the_default_packing() { Assert.All( - AllStructures(), + AbiStructures.All(), static type => { // Reflection reports the default either as 0 or as the runtime's default of 8. @@ -127,27 +146,4 @@ private static IEnumerable PublicStructures() { return AbiAssembly.GetExportedTypes().Where(static type => type.IsValueType && !type.IsEnum); } - - /// - /// Public and non-public structures alike: an internal helper structure is as much part of a layout as the - /// public structure that embeds it. Compiler-generated types (static data blobs) are not ours to judge. - /// - private static IEnumerable AllStructures() - { - return AbiAssembly.GetTypes() - .Where(static type => type.IsValueType && !type.IsEnum && !IsCompilerGenerated(type)); - } - - private static bool IsCompilerGenerated(Type type) - { - for (Type? current = type; current is not null; current = current.DeclaringType) - { - if (current.Name.StartsWith('<') || current.IsDefined(typeof(CompilerGeneratedAttribute), false)) - { - return true; - } - } - - return false; - } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/BoolCallBoundaryTests.cs b/tests/CheatEngine.SDK.Abi.Tests/BoolCallBoundaryTests.cs index a1d0d6db..c5f62288 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/BoolCallBoundaryTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/BoolCallBoundaryTests.cs @@ -13,6 +13,7 @@ public sealed unsafe class BoolCallBoundaryTests [Theory] [InlineData(0)] [InlineData(1)] + [InlineData(2)] [InlineData(-1)] [InlineData(0x100)] public void Bool32_result_of_a_callee_returning_int_carries_the_same_bits(int raw) @@ -28,6 +29,7 @@ public void Bool32_result_of_a_callee_returning_int_carries_the_same_bits(int ra [Theory] [InlineData(0)] [InlineData(1)] + [InlineData(2)] [InlineData(-1)] [InlineData(0x100)] public void Bool32_returned_by_a_callee_reads_as_the_same_int(int raw) @@ -41,6 +43,7 @@ public void Bool32_returned_by_a_callee_reads_as_the_same_int(int raw) [Theory] [InlineData(0)] [InlineData(1)] + [InlineData(2)] [InlineData(-1)] public void Bool32_argument_arrives_as_the_same_int(int raw) { diff --git a/tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj b/tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj index f215494a..f080cec6 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj +++ b/tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj @@ -8,4 +8,11 @@ + + + + + diff --git a/tests/CheatEngine.SDK.Abi.Tests/FieldLayout/FieldLayoutContractTests.cs b/tests/CheatEngine.SDK.Abi.Tests/FieldLayout/FieldLayoutContractTests.cs new file mode 100644 index 00000000..a0646729 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/FieldLayout/FieldLayoutContractTests.cs @@ -0,0 +1,278 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Abi.Native; +using CheatEngine.SDK.Abi.Tests.Support; + +namespace CheatEngine.SDK.Abi.Tests.FieldLayout; + +/// +/// Mechanical per-field layout gate: every instance field of every structure of CheatEngine.SDK.Abi (public, +/// internal and nested private, see ) has a literal row in +/// , and its x64 offset, width and kind equal that row. Adding, removing, +/// moving or retyping a field without editing the table fails here, so a new structure can no longer pass with a size +/// row but no per-field test. +/// +/// +/// Offsets are measured by with the IL instruction ldflda (the managed +/// layout a plugin really uses, not the marshaller's unmanaged view); +/// +/// proves on the packed init record, the managed exports, the selection record and the classic prefix that this +/// measurement equals the C# address-of arithmetic of . +/// +public sealed unsafe class FieldLayoutContractTests +{ + [Fact] + public void Every_instance_field_of_every_abi_structure_has_a_layout_row() + { + HashSet<(string, string)> rows = FieldLayoutExpectations.Rows + .Select(static row => (row.TypeFullName, row.FieldName)).ToHashSet(); + List missing = []; + + foreach (Type structure in AbiStructures.All()) + { + foreach (FieldInfo field in structure.GetFields(FieldLayoutGate.InstanceFields)) + { + if (!rows.Contains((structure.FullName!, field.Name))) + { + missing.Add($"{structure.FullName}.{field.Name}"); + } + } + } + + Assert.True(missing.Count == 0, "Fields without a layout row: " + string.Join(", ", missing)); + Assert.Equal(20, AbiStructures.All().Count()); + } + + [Fact] + public void Every_layout_row_names_an_existing_field() + { + Dictionary structures = AbiStructures.All().ToDictionary(static type => type.FullName!, StringComparer.Ordinal); + List stale = []; + HashSet<(string, string)> seen = []; + + foreach (FieldLayoutRow row in FieldLayoutExpectations.Rows) + { + if (!seen.Add((row.TypeFullName, row.FieldName))) + { + stale.Add($"{row.TypeFullName}.{row.FieldName} (duplicate)"); + } + else if (!structures.TryGetValue(row.TypeFullName, out Type? structure) || + structure.GetField(row.FieldName, FieldLayoutGate.InstanceFields) is null) + { + stale.Add($"{row.TypeFullName}.{row.FieldName}"); + } + } + + Assert.True(stale.Count == 0, "Layout rows naming no field: " + string.Join(", ", stale)); + } + + [Fact] + [Trait("Qualification", "Q01")] + public void Every_field_has_the_expected_offset_on_64_bit() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + List wrong = []; + foreach (FieldLayoutRow row in FieldLayoutExpectations.Rows) + { + FieldInfo field = Field(row); + int offset = FieldLayoutGate.OffsetOf(field); + if (offset != row.Offset) + { + wrong.Add($"{row.TypeFullName}.{row.FieldName}: {offset} (expected {row.Offset})"); + } + } + + Assert.True(wrong.Count == 0, "Wrong offsets: " + string.Join(", ", wrong)); + } + + [Fact] + [Trait("Qualification", "Q01")] + public void Every_field_has_the_expected_width_on_64_bit() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + List wrong = []; + foreach (FieldLayoutRow row in FieldLayoutExpectations.Rows) + { + FieldInfo field = Field(row); + int width = FieldLayoutGate.WidthOf(field); + if (width != row.Width) + { + wrong.Add($"{row.TypeFullName}.{row.FieldName}: {width} (expected {row.Width})"); + } + } + + Assert.True(wrong.Count == 0, "Wrong widths: " + string.Join(", ", wrong)); + } + + [Fact] + public void Every_field_has_the_expected_kind() + { + List wrong = []; + foreach (FieldLayoutRow row in FieldLayoutExpectations.Rows) + { + FieldKind kind = FieldLayoutGate.KindOf(Field(row)); + if (kind != row.Kind) + { + wrong.Add($"{row.TypeFullName}.{row.FieldName}: {kind} (expected {row.Kind})"); + } + } + + Assert.True(wrong.Count == 0, "Wrong kinds: " + string.Join(", ", wrong)); + } + + [Fact] + public void Every_abi_structure_passes_the_gate() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + List violations = []; + foreach (Type structure in AbiStructures.All()) + { + violations.AddRange(FieldLayoutGate.FindViolations(structure, FieldLayoutExpectations.Rows)); + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void Reflected_offsets_agree_with_address_of_offsets_for_the_packed_init_record_and_the_managed_exports() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + PluginInitRecord init = default; + AssertSameOffset(nameof(PluginInitRecord.Name), Layout.OffsetOf(&init, &init.Name)); + AssertSameOffset(nameof(PluginInitRecord.GetVersion), Layout.OffsetOf(&init, &init.GetVersion)); + AssertSameOffset(nameof(PluginInitRecord.EnablePlugin), + Layout.OffsetOf(&init, &init.EnablePlugin)); + AssertSameOffset(nameof(PluginInitRecord.DisablePlugin), + Layout.OffsetOf(&init, &init.DisablePlugin)); + AssertSameOffset(nameof(PluginInitRecord.Version), Layout.OffsetOf(&init, &init.Version)); + + ManagedExportedFunctions exports = default; + AssertSameOffset(nameof(ManagedExportedFunctions.SizeOfExportedFunctions), + Layout.OffsetOf(&exports, &exports.SizeOfExportedFunctions)); + AssertSameOffset(nameof(ManagedExportedFunctions.GetLuaState), + Layout.OffsetOf(&exports, &exports.GetLuaState)); + AssertSameOffset(nameof(ManagedExportedFunctions.LuaRegister), + Layout.OffsetOf(&exports, &exports.LuaRegister)); + AssertSameOffset(nameof(ManagedExportedFunctions.LuaPushClassInstance), + Layout.OffsetOf(&exports, &exports.LuaPushClassInstance)); + AssertSameOffset(nameof(ManagedExportedFunctions.ProcessMessages), + Layout.OffsetOf(&exports, &exports.ProcessMessages)); + AssertSameOffset(nameof(ManagedExportedFunctions.CheckSynchronize), + Layout.OffsetOf(&exports, &exports.CheckSynchronize)); + + PluginType0Record record = default; + AssertSameOffset(nameof(PluginType0Record.InterpretedAddress), + Layout.OffsetOf(&record, &record.InterpretedAddress)); + AssertSameOffset(nameof(PluginType0Record.Address), Layout.OffsetOf(&record, &record.Address)); + AssertSameOffset(nameof(PluginType0Record.IsPointer), + Layout.OffsetOf(&record, &record.IsPointer)); + AssertSameOffset(nameof(PluginType0Record.CountOffsets), + Layout.OffsetOf(&record, &record.CountOffsets)); + AssertSameOffset(nameof(PluginType0Record.Offsets), Layout.OffsetOf(&record, &record.Offsets)); + AssertSameOffset(nameof(PluginType0Record.Description), + Layout.OffsetOf(&record, &record.Description)); + AssertSameOffset(nameof(PluginType0Record.ValueType), + Layout.OffsetOf(&record, &record.ValueType)); + AssertSameOffset(nameof(PluginType0Record.Size), Layout.OffsetOf(&record, &record.Size)); + + ExportedFunctionsPrefix prefix = default; + AssertSameOffset(nameof(ExportedFunctionsPrefix.SizeOfExportedFunctions), + Layout.OffsetOf(&prefix, &prefix.SizeOfExportedFunctions)); + AssertSameOffset(nameof(ExportedFunctionsPrefix.OpenedProcessId), + Layout.OffsetOf(&prefix, &prefix.OpenedProcessId)); + AssertSameOffset(nameof(ExportedFunctionsPrefix.FixMemory), + Layout.OffsetOf(&prefix, &prefix.FixMemory)); + AssertSameOffset(nameof(ExportedFunctionsPrefix.GetAddressFromPointer), + Layout.OffsetOf(&prefix, &prefix.GetAddressFromPointer)); + } + + [Fact] + public void Gate_reports_a_missing_row_an_extra_row_a_wrong_offset_and_a_wrong_width() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + string probe = typeof(Probe).FullName!; + FieldLayoutRow first = new(probe, nameof(Probe.First), 0, 4, FieldKind.Integer); + FieldLayoutRow second = new(probe, nameof(Probe.Second), 8, 8, FieldKind.Integer); + FieldLayoutRow third = new(probe, nameof(Probe.Third), 16, 8, FieldKind.OpaquePointer); + + Assert.Empty(FieldLayoutGate.FindViolations(typeof(Probe), [first, second, third])); + + Assert.Contains(FieldLayoutGate.FindViolations(typeof(Probe), [first, second]), + static violation => violation.EndsWith("Probe.Third: missing layout row for this field.", StringComparison.Ordinal)); + Assert.Contains(FieldLayoutGate.FindViolations(typeof(Probe), + [first, second, third, new FieldLayoutRow(probe, "Fourth", 24, 4, FieldKind.Integer)]), + static violation => violation.EndsWith("Probe.Fourth: extra layout row names no field of the structure.", + StringComparison.Ordinal)); + Assert.Contains(FieldLayoutGate.FindViolations(typeof(Probe), [first, second with { Offset = 4 }, third]), + static violation => violation.EndsWith("Probe.Second: offset 8, expected 4.", StringComparison.Ordinal)); + Assert.Contains(FieldLayoutGate.FindViolations(typeof(Probe), [first, second with { Width = 4 }, third]), + static violation => violation.EndsWith("Probe.Second: width 8, expected 4.", StringComparison.Ordinal)); + Assert.Contains(FieldLayoutGate.FindViolations(typeof(Probe), [first, second, third with { Kind = FieldKind.Pointer }]), + static violation => violation.EndsWith("Probe.Third: kind OpaquePointer, expected Pointer.", StringComparison.Ordinal)); + Assert.Contains(FieldLayoutGate.FindViolations(typeof(Probe), [first, first, second, third]), + static violation => violation.EndsWith("Probe.First: duplicate layout row.", StringComparison.Ordinal)); + } + + [Fact] + public void Gate_catches_a_size_preserving_retype_that_a_size_check_misses() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + string mirror = typeof(ShiftedMirror).FullName!; + FieldLayoutRow[] hostRows = + [ + new(mirror, nameof(ShiftedMirror.First), 0, 4, FieldKind.Integer), + new(mirror, nameof(ShiftedMirror.Second), 8, 8, FieldKind.Integer), + new(mirror, nameof(ShiftedMirror.Third), 16, 8, FieldKind.OpaquePointer) + ]; + + Assert.Equal(Layout.SizeOf(), Layout.SizeOf()); + IReadOnlyList violations = FieldLayoutGate.FindViolations(typeof(ShiftedMirror), hostRows); + Assert.Contains(violations, static violation => violation.Contains("ShiftedMirror.Second: offset 4, expected 8.", + StringComparison.Ordinal)); + Assert.Contains(violations, static violation => violation.Contains("ShiftedMirror.Second: width 4, expected 8.", + StringComparison.Ordinal)); + } + + private static FieldInfo Field(FieldLayoutRow row) + { + Type structure = AbiStructures.AbiAssembly.GetType(row.TypeFullName, true, false)!; + return structure.GetField(row.FieldName, FieldLayoutGate.InstanceFields) + ?? throw new InvalidOperationException($"{row.TypeFullName} has no field {row.FieldName}."); + } + + private static void AssertSameOffset(string fieldName, int addressOfOffset) + where T : unmanaged + { + FieldInfo field = typeof(T).GetField(fieldName, FieldLayoutGate.InstanceFields) + ?? throw new InvalidOperationException($"{typeof(T).Name} has no field {fieldName}."); + Assert.Equal(addressOfOffset, FieldLayoutGate.OffsetOf(field)); + } + + /// A conforming three-field structure the gate is exercised against. + [StructLayout(LayoutKind.Sequential)] + public struct Probe + { + public int First; + public long Second; + public void* Third; + } + + /// + /// Same 24 bytes as , but the second field is 32-bit: every later offset moves, exactly the + /// defect of a Pascal mirror that declares an address as dword. + /// + [StructLayout(LayoutKind.Sequential)] + public struct ShiftedMirror + { + public int First; + public int Second; + public long Padding; + public void* Third; + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs index 5439e8b8..ee6b1dd6 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs @@ -13,6 +13,7 @@ namespace CheatEngine.SDK.Abi.Tests.Fixture; public sealed class NativeAbiFixtureContractTests { [Fact] + [Trait("Qualification", "Q01")] public void Header_derived_classic_records_have_the_fixture_x64_sizes() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -33,6 +34,7 @@ public void Header_derived_classic_records_have_the_fixture_x64_sizes() } [Fact] + [Trait("Qualification", "Q01")] public void Header_derived_classic_records_have_the_fixture_x64_alignments() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -56,8 +58,8 @@ public void Header_derived_classic_records_have_the_fixture_x64_alignments() public void Header_and_pinned_Pascal_popup_contract_conflict_keeps_the_slot_opaque_until_a_live_canary() { FieldInfo popup = typeof(DisassemblerContextPluginInit).GetField( - nameof(DisassemblerContextPluginInit.CallbackOnPopup)) - ?? throw new InvalidOperationException("The popup callback field was not found."); + nameof(DisassemblerContextPluginInit.CallbackOnPopup)) + ?? throw new InvalidOperationException("The popup callback field was not found."); Type fieldType = popup.GetModifiedFieldType().UnderlyingSystemType; diff --git a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureFacts.cs b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureFacts.cs new file mode 100644 index 00000000..47061a53 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureFacts.cs @@ -0,0 +1,75 @@ +namespace CheatEngine.SDK.Abi.Tests.Fixture; + +/// +/// Locates and parses the key=value facts emitted by tests/native-abi-fixture/build.ps1. The native CI +/// job builds them and the Debug build-test job passes their path with +/// and sets to true; an ordinary local run has neither and the +/// fixture-dependent tests return after asserting that documented opt-out (never Assert.Skip). +/// +internal static class NativeAbiFixtureFacts +{ + /// Name of the CI-provided absolute path to the validated native fixture facts file. + internal const string FactsPathEnvironmentVariable = "CE77_NATIVE_ABI_FACTS_PATH"; + + /// Name of the opt-in gate that makes the native fixture facts mandatory. + internal const string RequiredEnvironmentVariable = "CE77_NATIVE_ABI_REQUIRED"; + + /// The fixture schema this test assembly compares against. + internal const string ExpectedSchema = "3"; + + /// + /// Loads the facts named by the environment, or returns when no path is supplied and + /// required mode is off. Throws when required mode is on without a path, or when a supplied file is missing. + /// + internal static Dictionary? LoadFromEnvironment() + { + string? factsPath = ResolveFactsPath( + Environment.GetEnvironmentVariable(FactsPathEnvironmentVariable), + Environment.GetEnvironmentVariable(RequiredEnvironmentVariable)); + return factsPath is null ? null : ReadFacts(factsPath); + } + + /// Applies the opt-in rule: a supplied path wins; required mode without a path is an error. + internal static string? ResolveFactsPath(string? factsPath, string? requiredMode) + { + if (!string.IsNullOrWhiteSpace(factsPath)) + { + return factsPath; + } + + if (string.Equals(requiredMode, "true", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"'{RequiredEnvironmentVariable}=true' requires '{FactsPathEnvironmentVariable}' to name a validated native ABI fixture facts file."); + } + + return null; + } + + /// Parses a facts file: one key=value per non-blank line, keys unique. + internal static Dictionary ReadFacts(string factsPath) + { + if (!File.Exists(factsPath)) + { + throw new FileNotFoundException($"The native ABI fixture facts file '{factsPath}' was not found.", + factsPath); + } + + Dictionary facts = new(StringComparer.Ordinal); + foreach (string line in File.ReadLines(factsPath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + int separator = line.IndexOf('=', StringComparison.Ordinal); + Assert.True(separator > 0, $"The native ABI fixture fact '{line}' is not key=value."); + string key = line[..separator]; + string value = line[(separator + 1)..]; + Assert.True(facts.TryAdd(key, value), $"The native ABI fixture emitted duplicate fact '{key}'."); + } + + return facts; + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs index 509142ec..8a011961 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs @@ -1,51 +1,100 @@ using System.Globalization; +using System.Reflection; +using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Abi.Native; using CheatEngine.SDK.Abi.Tests.Support; namespace CheatEngine.SDK.Abi.Tests.Fixture; /// -/// Compares the x64 facts emitted by the independently compiled C++ fixture with measurements from the managed -/// ABI records. The native CI job supplies the facts file; ordinary managed runs deliberately have no C++ fixture -/// filesystem dependency. +/// Compares the x64 facts emitted by the independently compiled C++ fixture (schema 3) with measurements of the +/// managed ABI records: size, alignment, and every field's offset and width. The native CI job supplies the facts +/// file; ordinary managed runs deliberately have no C++ fixture filesystem dependency (see +/// ). /// -public sealed unsafe class NativeAbiFixtureManagedComparisonTests +/// +/// The fixture transcribes the C header records and, since schema 3, the pinned host Pascal types of the managed +/// route (TPluginDotNetInitResult, packed, and TExportedFunctionsDotNetV1). Field names in the fact keys +/// are the C# field names, so every field of a compared managed record is looked up by reflection and must have +/// a native counterpart, and every native field of a compared record must have a managed one. +/// +public sealed class NativeAbiFixtureManagedComparisonTests { - /// Name of the CI-provided absolute path to the validated native fixture facts file. - internal const string FactsPathEnvironmentVariable = "CE77_NATIVE_ABI_FACTS_PATH"; - - /// Name of the opt-in gate that makes the native fixture facts mandatory. - internal const string RequiredEnvironmentVariable = "CE77_NATIVE_ABI_REQUIRED"; - [Fact] public void Native_fixture_layout_facts_match_the_managed_x64_measurements_when_CI_supplies_them() { - string? factsPath = ResolveFactsPath( - Environment.GetEnvironmentVariable(FactsPathEnvironmentVariable), - Environment.GetEnvironmentVariable(RequiredEnvironmentVariable)); - if (factsPath is null) + Dictionary? nativeFacts = NativeAbiFixtureFacts.LoadFromEnvironment(); + if (nativeFacts is null) { - Assert.Null(factsPath); + Assert.Null(nativeFacts); return; } Assert.True(Layout.Is64BitProcess, Layout.Requires64BitProcess); - Dictionary nativeFacts = ReadFacts(factsPath); - Dictionary managedFacts = CreateManagedLayoutFacts(); + Dictionary managedFacts = CreateManagedLayoutFacts(out IReadOnlyList comparedRecords); foreach (KeyValuePair managedFact in managedFacts) { Assert.True(nativeFacts.TryGetValue(managedFact.Key, out string? nativeValue), $"The native ABI fixture omitted '{managedFact.Key}'."); - Assert.Equal(managedFact.Value, nativeValue); + Assert.True(string.Equals(managedFact.Value, nativeValue, StringComparison.Ordinal), + $"'{managedFact.Key}': managed {managedFact.Value}, native {nativeValue}."); + } + + foreach (string nativeKey in nativeFacts.Keys) + { + foreach (string record in comparedRecords) + { + if (nativeKey.StartsWith("offsetof." + record + ".", StringComparison.Ordinal) || + nativeKey.StartsWith("fieldsize." + record + ".", StringComparison.Ordinal)) + { + Assert.True(managedFacts.ContainsKey(nativeKey), + $"The native fixture transcribes '{nativeKey}', which has no managed field."); + } + } } } + [Fact] + public void Native_fixture_facts_declare_schema_3_when_CI_supplies_them() + { + Dictionary? nativeFacts = NativeAbiFixtureFacts.LoadFromEnvironment(); + if (nativeFacts is null) + { + Assert.Null(nativeFacts); + return; + } + + Assert.Equal(NativeAbiFixtureFacts.ExpectedSchema, nativeFacts["fixture.schema"]); + Assert.Equal("ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37", nativeFacts["source.upstream_commit"]); + Assert.Equal("Cheat Engine/plugin/cepluginsdk.h", nativeFacts["source.path"]); + Assert.Equal("Cheat Engine/plugin.pas", nativeFacts["source.host_path"]); + Assert.Equal("Cheat Engine/plugin/cepluginsdk.pas", nativeFacts["source.mirror_path"]); + Assert.Equal("transcribed-pinned-header-and-host-pascal-subset", nativeFacts["source.contract"]); + } + + [Fact] + public void Native_fixture_proves_the_packed_init_record_write_leaves_the_tail_guard_intact_when_CI_supplies_them() + { + Dictionary? nativeFacts = NativeAbiFixtureFacts.LoadFromEnvironment(); + if (nativeFacts is null) + { + Assert.Null(nativeFacts); + return; + } + + Assert.Equal("passed", nativeFacts["sentinel.managed_plugin_init_record.tail_guard"]); + Assert.Equal("36", nativeFacts["sizeof.managed_plugin_init_record"]); + Assert.Equal("1", nativeFacts["alignof.managed_plugin_init_record"]); + Assert.Equal("4", nativeFacts["fieldsize.managed_plugin_init_record.Version"]); + Assert.Equal("48", nativeFacts["sizeof.managed_exported_functions"]); + } + [Fact] public void Native_fixture_facts_path_is_optional_when_required_mode_is_not_enabled() { - Assert.Null(ResolveFactsPath(null, null)); + Assert.Null(NativeAbiFixtureFacts.ResolveFactsPath(null, null)); } [Theory] @@ -55,10 +104,10 @@ public void Native_fixture_facts_path_is_optional_when_required_mode_is_not_enab public void Native_fixture_required_mode_rejects_an_absent_facts_path(string? factsPath) { InvalidOperationException exception = - Assert.Throws(() => ResolveFactsPath(factsPath, "true")); + Assert.Throws(() => NativeAbiFixtureFacts.ResolveFactsPath(factsPath, "true")); Assert.Equal( - $"'{RequiredEnvironmentVariable}=true' requires '{FactsPathEnvironmentVariable}' to name a validated native ABI fixture facts file.", + $"'{NativeAbiFixtureFacts.RequiredEnvironmentVariable}=true' requires '{NativeAbiFixtureFacts.FactsPathEnvironmentVariable}' to name a validated native ABI fixture facts file.", exception.Message); } @@ -67,196 +116,72 @@ public void Native_fixture_comparison_rejects_a_supplied_missing_facts_file() { string factsPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.txt"); - FileNotFoundException exception = Assert.Throws(() => ReadFacts(factsPath)); + FileNotFoundException exception = + Assert.Throws(() => NativeAbiFixtureFacts.ReadFacts(factsPath)); Assert.Equal(factsPath, exception.FileName); } - private static string? ResolveFactsPath(string? factsPath, string? requiredMode) - { - if (!string.IsNullOrWhiteSpace(factsPath)) - { - return factsPath; - } - - if (string.Equals(requiredMode, "true", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"'{RequiredEnvironmentVariable}=true' requires '{FactsPathEnvironmentVariable}' to name a validated native ABI fixture facts file."); - } - - return null; - } - - private static Dictionary ReadFacts(string factsPath) + [Fact] + public void Managed_fact_set_names_every_field_of_every_compared_record() { - if (!File.Exists(factsPath)) - { - throw new FileNotFoundException($"The native ABI fixture facts file '{factsPath}' was not found.", - factsPath); - } - - Dictionary facts = new(StringComparer.Ordinal); - foreach (string line in File.ReadLines(factsPath)) - { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - int separator = line.IndexOf('='); - Assert.True(separator > 0, $"The native ABI fixture fact '{line}' is not key=value."); - string key = line[..separator]; - string value = line[(separator + 1)..]; - Assert.True(facts.TryAdd(key, value), $"The native ABI fixture emitted duplicate fact '{key}'."); - } - - return facts; + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Dictionary facts = CreateManagedLayoutFacts(out IReadOnlyList records); + + Assert.Equal(15, records.Count); + Assert.Equal("36", facts["sizeof.managed_plugin_init_record"]); + Assert.Equal("1", facts["alignof.managed_plugin_init_record"]); + Assert.Equal("32", facts["offsetof.managed_plugin_init_record.Version"]); + Assert.Equal("8", facts["fieldsize.managed_exported_functions.CheckSynchronize"]); + Assert.Equal("4", facts["fieldsize.plugin_type0_record.IsPointer"]); + Assert.Equal("260", facts["offsetof.register_modification_info.NewOf"]); + Assert.Equal("8", facts["fieldsize.exported_functions_prefix.GetAddressFromPointer"]); } - private static Dictionary CreateManagedLayoutFacts() + /// + /// The managed side of the comparison: for each compared record, size and alignment (generic address-of + /// arithmetic) and every field's offset and width (, the helper of the WI-1 gate). + /// + internal static Dictionary CreateManagedLayoutFacts(out IReadOnlyList comparedRecords) { Dictionary facts = new(StringComparer.Ordinal); - AddLayout(facts, "plugin_version"); - AddLayout(facts, "plugin_type0_record"); - AddLayout(facts, "plugin_type0_init"); - AddLayout(facts, "plugin_type1_init"); - AddLayout(facts, "plugin_type2_init"); - AddLayout(facts, "plugin_type3_init"); - AddLayout(facts, "plugin_type4_init"); - AddLayout(facts, "plugin_type5_init"); - AddLayout(facts, "plugin_type6_init"); - AddLayout(facts, "plugin_type7_init"); - AddLayout(facts, "plugin_type8_init"); - AddLayout(facts, "register_modification_info"); - AddLayout(facts, "exported_functions_prefix"); - - PluginVersion pluginVersion = default; - void* pluginVersionOrigin = &pluginVersion; - AddOffset(facts, "plugin_version.Version", Layout.OffsetOf(pluginVersionOrigin, &pluginVersion.Version)); - AddOffset(facts, "plugin_version.PluginName", Layout.OffsetOf(pluginVersionOrigin, &pluginVersion.PluginName)); - - PluginType0Record pluginType0Record = default; - void* pluginType0RecordOrigin = &pluginType0Record; - AddOffset(facts, "plugin_type0_record.InterpretedAddress", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.InterpretedAddress)); - AddOffset(facts, "plugin_type0_record.Address", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Address)); - AddOffset(facts, "plugin_type0_record.IsPointer", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.IsPointer)); - AddOffset(facts, "plugin_type0_record.CountOffsets", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.CountOffsets)); - AddOffset(facts, "plugin_type0_record.Offsets", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Offsets)); - AddOffset(facts, "plugin_type0_record.Description", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Description)); - AddOffset(facts, "plugin_type0_record.ValueType", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.ValueType)); - AddOffset(facts, "plugin_type0_record.Size", Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Size)); - - AddPluginInitOffsets(facts); - AddRegisterModificationOffsets(facts); - AddExportedFunctionsPrefixOffsets(facts); + List records = []; + AddRecord(facts, records, "plugin_version"); + AddRecord(facts, records, "plugin_type0_record"); + AddRecord(facts, records, "plugin_type0_init"); + AddRecord(facts, records, "plugin_type1_init"); + AddRecord(facts, records, "plugin_type2_init"); + AddRecord(facts, records, "plugin_type3_init"); + AddRecord(facts, records, "plugin_type4_init"); + AddRecord(facts, records, "plugin_type5_init"); + AddRecord(facts, records, "plugin_type6_init"); + AddRecord(facts, records, "plugin_type7_init"); + AddRecord(facts, records, "plugin_type8_init"); + AddRecord(facts, records, "register_modification_info"); + AddRecord(facts, records, "exported_functions_prefix"); + AddRecord(facts, records, "managed_plugin_init_record"); + AddRecord(facts, records, "managed_exported_functions"); + comparedRecords = records; return facts; } - private static void AddPluginInitOffsets(Dictionary facts) - { - AddressListPluginInit type0 = default; - void* type0Origin = &type0; - AddOffset(facts, "plugin_type0_init.Name", Layout.OffsetOf(type0Origin, &type0.Name)); - AddOffset(facts, "plugin_type0_init.Callback", Layout.OffsetOf(type0Origin, &type0.Callback)); - - MemoryViewPluginInit type1 = default; - void* type1Origin = &type1; - AddOffset(facts, "plugin_type1_init.Name", Layout.OffsetOf(type1Origin, &type1.Name)); - AddOffset(facts, "plugin_type1_init.Callback", Layout.OffsetOf(type1Origin, &type1.Callback)); - AddOffset(facts, "plugin_type1_init.Shortcut", Layout.OffsetOf(type1Origin, &type1.Shortcut)); - - DebugEventPluginInit type2 = default; - AddOffset(facts, "plugin_type2_init.Callback", Layout.OffsetOf(&type2, &type2.Callback)); - - ProcessWatcherPluginInit type3 = default; - AddOffset(facts, "plugin_type3_init.Callback", Layout.OffsetOf(&type3, &type3.Callback)); - - FunctionPointerChangePluginInit type4 = default; - AddOffset(facts, "plugin_type4_init.Callback", Layout.OffsetOf(&type4, &type4.Callback)); - - MainMenuPluginInit type5 = default; - void* type5Origin = &type5; - AddOffset(facts, "plugin_type5_init.Name", Layout.OffsetOf(type5Origin, &type5.Name)); - AddOffset(facts, "plugin_type5_init.Callback", Layout.OffsetOf(type5Origin, &type5.Callback)); - AddOffset(facts, "plugin_type5_init.Shortcut", Layout.OffsetOf(type5Origin, &type5.Shortcut)); - - DisassemblerContextPluginInit type6 = default; - void* type6Origin = &type6; - AddOffset(facts, "plugin_type6_init.Name", Layout.OffsetOf(type6Origin, &type6.Name)); - AddOffset(facts, "plugin_type6_init.Callback", Layout.OffsetOf(type6Origin, &type6.Callback)); - AddOffset(facts, "plugin_type6_init.CallbackOnPopup", Layout.OffsetOf(type6Origin, &type6.CallbackOnPopup)); - AddOffset(facts, "plugin_type6_init.Shortcut", Layout.OffsetOf(type6Origin, &type6.Shortcut)); - - DisassemblerRenderLinePluginInit type7 = default; - AddOffset(facts, "plugin_type7_init.Callback", Layout.OffsetOf(&type7, &type7.Callback)); - - AutoAssemblerPluginInit type8 = default; - AddOffset(facts, "plugin_type8_init.Callback", Layout.OffsetOf(&type8, &type8.Callback)); - } - - private static void AddRegisterModificationOffsets(Dictionary facts) - { - RegisterModificationInfo info = default; - void* origin = &info; - AddOffset(facts, "register_modification_info.Address", Layout.OffsetOf(origin, &info.Address)); - AddOffset(facts, "register_modification_info.ChangeEax", Layout.OffsetOf(origin, &info.ChangeEax)); - AddOffset(facts, "register_modification_info.ChangeR15", Layout.OffsetOf(origin, &info.ChangeR15)); - AddOffset(facts, "register_modification_info.ChangeOf", Layout.OffsetOf(origin, &info.ChangeOf)); - AddOffset(facts, "register_modification_info.NewEax", Layout.OffsetOf(origin, &info.NewEax)); - AddOffset(facts, "register_modification_info.NewR15", Layout.OffsetOf(origin, &info.NewR15)); - AddOffset(facts, "register_modification_info.NewCf", Layout.OffsetOf(origin, &info.NewCf)); - AddOffset(facts, "register_modification_info.NewOf", Layout.OffsetOf(origin, &info.NewOf)); - } - - private static void AddExportedFunctionsPrefixOffsets(Dictionary facts) - { - ExportedFunctionsPrefix exports = default; - void* origin = &exports; - AddOffset(facts, "exported_functions_prefix.SizeOfExportedFunctions", - Layout.OffsetOf(origin, &exports.SizeOfExportedFunctions)); - AddOffset(facts, "exported_functions_prefix.ShowMessage", Layout.OffsetOf(origin, &exports.ShowMessage)); - AddOffset(facts, "exported_functions_prefix.RegisterFunction", - Layout.OffsetOf(origin, &exports.RegisterFunction)); - AddOffset(facts, "exported_functions_prefix.UnregisterFunction", - Layout.OffsetOf(origin, &exports.UnregisterFunction)); - AddOffset(facts, "exported_functions_prefix.OpenedProcessId", - Layout.OffsetOf(origin, &exports.OpenedProcessId)); - AddOffset(facts, "exported_functions_prefix.OpenedProcessHandle", - Layout.OffsetOf(origin, &exports.OpenedProcessHandle)); - AddOffset(facts, "exported_functions_prefix.GetMainWindowHandle", - Layout.OffsetOf(origin, &exports.GetMainWindowHandle)); - AddOffset(facts, "exported_functions_prefix.AutoAssemble", Layout.OffsetOf(origin, &exports.AutoAssemble)); - AddOffset(facts, "exported_functions_prefix.Assembler", Layout.OffsetOf(origin, &exports.Assembler)); - AddOffset(facts, "exported_functions_prefix.Disassembler", Layout.OffsetOf(origin, &exports.Disassembler)); - AddOffset(facts, "exported_functions_prefix.ChangeRegistersAtAddress", - Layout.OffsetOf(origin, &exports.ChangeRegistersAtAddress)); - AddOffset(facts, "exported_functions_prefix.InjectDll", Layout.OffsetOf(origin, &exports.InjectDll)); - AddOffset(facts, "exported_functions_prefix.FreezeMemory", Layout.OffsetOf(origin, &exports.FreezeMemory)); - AddOffset(facts, "exported_functions_prefix.UnfreezeMemory", Layout.OffsetOf(origin, &exports.UnfreezeMemory)); - AddOffset(facts, "exported_functions_prefix.FixMemory", Layout.OffsetOf(origin, &exports.FixMemory)); - AddOffset(facts, "exported_functions_prefix.ProcessList", Layout.OffsetOf(origin, &exports.ProcessList)); - AddOffset(facts, "exported_functions_prefix.ReloadSettings", Layout.OffsetOf(origin, &exports.ReloadSettings)); - AddOffset(facts, "exported_functions_prefix.GetAddressFromPointer", - Layout.OffsetOf(origin, &exports.GetAddressFromPointer)); - } - - private static void AddLayout(Dictionary facts, string key) + /// Adds the size, alignment and per-field facts of under . + internal static void AddRecord(Dictionary facts, List records, string key) where T : unmanaged { - facts.Add($"sizeof.{key}", Layout.SizeOf().ToString(CultureInfo.InvariantCulture)); - facts.Add($"alignof.{key}", Layout.AlignmentOf().ToString(CultureInfo.InvariantCulture)); + records.Add(key); + facts.Add($"sizeof.{key}", Text(Layout.SizeOf())); + facts.Add($"alignof.{key}", Text(Layout.AlignmentOf())); + foreach (FieldInfo field in typeof(T).GetFields(FieldLayoutGate.InstanceFields)) + { + facts.Add($"offsetof.{key}.{field.Name}", Text(FieldLayoutGate.OffsetOf(field))); + facts.Add($"fieldsize.{key}.{field.Name}", Text(FieldLayoutGate.WidthOf(field))); + } } - private static void AddOffset(Dictionary facts, string key, int value) + private static string Text(int value) { - facts.Add($"offsetof.{key}", value.ToString(CultureInfo.InvariantCulture)); + return value.ToString(CultureInfo.InvariantCulture); } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs index 395d218b..e7230545 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs @@ -16,6 +16,7 @@ public sealed unsafe class PluginInitRecordTests private static int s_disableCalls; [Fact] + [Trait("Qualification", "Q02")] public void Size_on_64_bit_is_36_bytes_packed() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -24,6 +25,7 @@ public void Size_on_64_bit_is_36_bytes_packed() } [Fact] + [Trait("Qualification", "Q02")] public void Field_offsets_on_64_bit_match_the_host_record() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -45,6 +47,7 @@ public void Field_offsets_on_64_bit_match_the_host_record() [Theory] [InlineData(16)] [InlineData(13)] + [Trait("Qualification", "Q02")] public void Write_through_a_pointer_on_64_bit_touches_exactly_36_bytes(int start) { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -117,7 +120,7 @@ private static Bool32 FakeGetVersion(PluginVersion* version, int size) private static Bool32 FakeEnablePlugin(ManagedExportedFunctions* exports, uint pluginId) { return exports is not null && exports->SizeOfExportedFunctions == sizeof(ManagedExportedFunctions) && - pluginId == 0xFFFF_FFF0u; + pluginId == 0xFFFF_FFF0u; } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicDebugEventDispatcherTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicDebugEventDispatcherTests.cs index 46fe08b6..109796ba 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicDebugEventDispatcherTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicDebugEventDispatcherTests.cs @@ -11,12 +11,32 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// stdcall function pointer that a classic host stores from ; it is not live /// Cheat Engine qualification. /// -public sealed unsafe class ClassicDebugEventDispatcherTests : IDisposable +/// +/// +/// Every dispatcher test lives in this one class on purpose: the dispatcher's single active registration and the +/// fake host's seams are process statics, and a second test class would run in parallel and collide. +/// +/// +/// The Q38 tests call the registered thunk from threads created with Win32 CreateThread, which the CLR did +/// not create and has never seen before the call (the situation of a debug event delivered by a host thread). They +/// are C1 evidence of the dispatcher's thread contract only: on the managed-hostfxr profile no route reaches the +/// classic RegisterFunction, so Q38 at C3 stays NotApplicable (see libs/CheatEngine.SDK.Abi/README.md). +/// +/// +public sealed unsafe partial class ClassicDebugEventDispatcherTests : IDisposable { + private const uint WaitObject0 = 0; + private const uint NativeThreadTimeoutMilliseconds = 30_000; + private const int DefaultFunctionId = 901; + private static readonly TimeSpan ReleaseTaskTimeout = TimeSpan.FromSeconds(30); + private static delegate* unmanaged[Stdcall] s_callback; private static int s_unregisterCalls; private static bool s_unregisterSucceeds; private static ManualResetEventSlim? s_unregisterEntered; + private static int s_registerCalls; + private static int s_functionIdToReturn = DefaultFunctionId; + private static int s_lastUnregisteredFunctionId = -1; private ClassicDebugEventDispatcher? _dispatcher; /// @@ -28,6 +48,9 @@ public void Dispose() _dispatcher = null; s_callback = null; s_unregisterCalls = 0; + s_registerCalls = 0; + s_functionIdToReturn = DefaultFunctionId; + s_lastUnregisteredFunctionId = -1; } [Fact] @@ -219,6 +242,8 @@ public void Release_waits_for_an_admitted_callback_and_rejects_a_late_callback() } finally { + // The release task starts right after the handler signals that it entered. + FinishBlockedWork(allowHandlerToReturn, callbackCompleted, handlerEntered.IsSet ? releaseCompleted : null); NativeMemory.Free(eventMemory); } } @@ -241,6 +266,269 @@ public void Release_reentered_from_a_handler_is_refused_without_deadlocking_or_f Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); } + [Fact] + [Trait("Qualification", "Q38")] + public void Callback_from_a_native_os_thread_is_admitted_copied_and_returns_the_CheatEngine_fallback() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + uint handlerNativeThreadId = 0; + int handlerManagedThreadId = 0; + int handlerCalls = 0; + DebugEventObservation received = default; + BoundedDebugEventObservationBuffer observations = new(4, DebugEventObservationOverflowPolicy.DropNewest); + ClassicDebugEventDispatcher dispatcher = Register((in observation) => + { + handlerCalls++; + received = observation; + handlerNativeThreadId = GetCurrentThreadId(); + handlerManagedThreadId = Environment.CurrentManagedThreadId; + return DebugEventDecision.PluginOwnsContinuation; + }, observations); + NativeThreadWork* work = AllocateWork(new NativeDebugEvent(0x0000_0005, 1234, 5678), 1); + + try + { + uint nativeThreadId = RunOnNativeThread(work); + + Assert.Equal(1, handlerCalls); + Assert.Equal(1, work->ZeroResults); + Assert.Equal(0, work->NonZeroResults); + Assert.Equal(nativeThreadId, handlerNativeThreadId); + Assert.NotEqual(GetCurrentThreadId(), handlerNativeThreadId); + Assert.NotEqual(Environment.CurrentManagedThreadId, handlerManagedThreadId); + Assert.Equal(5u, received.EventCode); + Assert.Equal(1234u, received.ProcessId); + Assert.Equal(5678u, received.ThreadId); + Assert.True(observations.TryRead(out DebugEventObservation copied)); + Assert.Equal(received.SequenceNumber, copied.SequenceNumber); + Assert.Equal(1, dispatcher.UnsupportedContinuationRequestCount); + Assert.Equal(0, dispatcher.ActiveCallbackCount); + } + finally + { + NativeMemory.Free(work); + } + + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + [Fact] + [Trait("Qualification", "Q38")] + public void Release_drains_callbacks_running_on_native_threads_and_refuses_late_ones() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + using ManualResetEventSlim handlerEntered = new(); + using ManualResetEventSlim allowHandlerToReturn = new(); + using ManualResetEventSlim unregisterEntered = new(); + using ManualResetEventSlim releaseCompleted = new(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + s_unregisterEntered = unregisterEntered; + int handlerCalls = 0; + ClassicDebugEventReleaseStatus releaseResult = default; + ClassicDebugEventDispatcher dispatcher = Register((in _) => + { + Interlocked.Increment(ref handlerCalls); + handlerEntered.Set(); + allowHandlerToReturn.Wait(cancellationToken); + return DebugEventDecision.ContinueWithCheatEngine; + }); + NativeThreadWork* blocked = AllocateWork(new NativeDebugEvent(1, 2, 3), 1); + NativeThreadWork* lateDuringRelease = AllocateWork(new NativeDebugEvent(4, 5, 6), 3); + NativeThreadWork* lateAfterRelease = AllocateWork(new NativeDebugEvent(7, 8, 9), 3); + nint blockedThread = 0; + + try + { + blockedThread = StartNativeThread(blocked, out _); + handlerEntered.Wait(cancellationToken); + _ = Task.Run(() => + { + releaseResult = dispatcher.TryRelease(); + releaseCompleted.Set(); + }, cancellationToken); + unregisterEntered.Wait(cancellationToken); + + // Admission is closed: a native callback arriving now never reaches the handler. + AssertLateCallbacksReturnZeroWithoutTheHandler(lateDuringRelease, ref handlerCalls); + Assert.False(releaseCompleted.IsSet); + Assert.Equal(1, dispatcher.ActiveCallbackCount); + + allowHandlerToReturn.Set(); + Assert.Equal(WaitObject0, WaitForSingleObject(blockedThread, NativeThreadTimeoutMilliseconds)); + releaseCompleted.Wait(cancellationToken); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, releaseResult); + Assert.Equal(1, blocked->ZeroResults); + + // After the release completed: the thunk is still a valid address, but nothing is admitted. + AssertLateCallbacksReturnZeroWithoutTheHandler(lateAfterRelease, ref handlerCalls); + } + finally + { + // The release task starts right after the handler signals that it entered. + FinishBlockedWork(allowHandlerToReturn, handlerEntered.IsSet ? releaseCompleted : null); + JoinAndClose(blockedThread); + FreeWork(blocked, lateDuringRelease, lateAfterRelease); + } + } + + [Fact] + [Trait("Qualification", "Q38")] + public void Concurrent_native_thread_callbacks_publish_bounded_observations_without_changing_the_native_result() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + const int ThreadCount = 8; + const int CallbacksPerThread = 200; + const int Capacity = 16; + int handlerCalls = 0; + BoundedDebugEventObservationBuffer observations = new(Capacity, DebugEventObservationOverflowPolicy.DropNewest); + ClassicDebugEventDispatcher dispatcher = Register((in _) => + { + Interlocked.Increment(ref handlerCalls); + return DebugEventDecision.ContinueWithCheatEngine; + }, observations); + NativeThreadWork*[] work = new NativeThreadWork*[ThreadCount]; + for (int index = 0; index < ThreadCount; index++) + { + work[index] = AllocateWork(new NativeDebugEvent((uint) index, 100u + (uint) index, 200u + (uint) index), + CallbacksPerThread); + } + + try + { + RunConcurrentlyOnNativeThreads(work); + + int total = ThreadCount * CallbacksPerThread; + Assert.Equal(total, Volatile.Read(ref handlerCalls)); + Assert.Equal(total, Enumerable.Range(0, ThreadCount).Sum(index => work[index]->ZeroResults)); + Assert.All(Enumerable.Range(0, ThreadCount), index => Assert.Equal(0, work[index]->NonZeroResults)); + Assert.Equal(Capacity, observations.Count); + Assert.Equal(total - Capacity, observations.DroppedObservationCount); + HashSet sequences = []; + while (observations.TryRead(out DebugEventObservation observation)) + { + Assert.True(sequences.Add(observation.SequenceNumber)); + Assert.Equal(observation.EventCode + 100u, observation.ProcessId); + } + + Assert.Equal(Capacity, sequences.Count); + Assert.Equal(0, dispatcher.ActiveCallbackCount); + Assert.Equal(0, dispatcher.CallbackFailureCount); + } + finally + { + FreeWork(work); + } + + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + } + + [Fact] + public void Second_registration_while_one_is_active_is_refused_before_calling_the_host() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + ClassicDebugEventDispatcher first = Register(static (in _) => DebugEventDecision.ContinueWithCheatEngine); + Assert.Equal(1, s_registerCalls); + + ClassicDebugEventRegistrationStatus status = TryRegisterWithHost(out ClassicDebugEventDispatcher? second); + + Assert.Equal(ClassicDebugEventRegistrationStatus.AnotherRegistrationIsActive, status); + Assert.Null(second); + Assert.Equal(1, s_registerCalls); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, first.TryRelease()); + } + + [Fact] + public void Negative_function_id_is_a_host_refusal_that_frees_the_record_and_allows_a_new_registration() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + s_functionIdToReturn = -1; + + ClassicDebugEventRegistrationStatus refused = TryRegisterWithHost(out ClassicDebugEventDispatcher? none); + + Assert.Equal(ClassicDebugEventRegistrationStatus.HostRejectedRegistration, refused); + Assert.Null(none); + Assert.Equal(1, s_registerCalls); + Assert.Equal(0, s_unregisterCalls); + // The host kept the thunk address it was given; after the refusal it no longer reaches any handler. + NativeDebugEvent stray = new(1, 2, 3); + Assert.Equal(0, Invoke(&stray)); + + s_functionIdToReturn = DefaultFunctionId; + int handlerCalls = 0; + ClassicDebugEventDispatcher dispatcher = Register((in _) => + { + handlerCalls++; + return DebugEventDecision.ContinueWithCheatEngine; + }); + NativeDebugEvent nativeEvent = new(4, 5, 6); + Assert.Equal(0, Invoke(&nativeEvent)); + Assert.Equal(1, handlerCalls); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + Assert.Equal(DefaultFunctionId, s_lastUnregisteredFunctionId); + } + + [Fact] + public void Function_id_zero_is_a_valid_identifier_passed_back_on_unregister() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + s_functionIdToReturn = 0; + + ClassicDebugEventDispatcher dispatcher = Register(static (in _) => DebugEventDecision.ContinueWithCheatEngine); + + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + Assert.Equal(1, s_unregisterCalls); + Assert.Equal(0, s_lastUnregisteredFunctionId); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void Missing_register_or_unregister_slot_is_refused_before_any_host_call(bool withRegister, bool withUnregister) + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + s_registerCalls = 0; + ExportedFunctionsPrefix exports = default; + if (withRegister) + { + exports.RegisterFunction = &RegisterFunction; + } + + if (withUnregister) + { + exports.UnregisterFunction = &UnregisterFunction; + } + + ClassicDebugEventRegistrationStatus status = ClassicDebugEventDispatcher.TryRegister(in exports, 77, + static (in _) => DebugEventDecision.ContinueWithCheatEngine, null, TestContext.Current.CancellationToken, + out ClassicDebugEventDispatcher? dispatcher); + + Assert.Equal(ClassicDebugEventRegistrationStatus.MissingHostFunction, status); + Assert.Null(dispatcher); + Assert.Equal(0, s_registerCalls); + Assert.Equal(0, s_unregisterCalls); + } + + [Fact] + public void After_release_a_late_callback_returns_zero_without_reaching_the_handler() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + int handlerCalls = 0; + ClassicDebugEventDispatcher dispatcher = Register((in _) => + { + handlerCalls++; + return DebugEventDecision.ContinueWithCheatEngine; + }); + delegate* unmanaged[Stdcall] retainedByHost = s_callback; + + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + NativeDebugEvent late = new(1, 2, 3); + Assert.Equal(0, retainedByHost(&late)); + Assert.Equal(0, handlerCalls); + Assert.Equal(ClassicDebugEventReleaseStatus.Released, dispatcher.TryRelease()); + Assert.Equal(1, s_unregisterCalls); + } + private ClassicDebugEventDispatcher Register( DebugEventDecisionHandler handler, BoundedDebugEventObservationBuffer? observations = null) @@ -271,6 +559,157 @@ private ClassicDebugEventDispatcher RegisterWithCancellation( return dispatcher; } + private ClassicDebugEventRegistrationStatus TryRegisterWithHost(out ClassicDebugEventDispatcher? dispatcher) + { + ExportedFunctionsPrefix exports = default; + exports.RegisterFunction = &RegisterFunction; + exports.UnregisterFunction = &UnregisterFunction; + ClassicDebugEventRegistrationStatus status = ClassicDebugEventDispatcher.TryRegister(in exports, 77, + static (in _) => DebugEventDecision.ContinueWithCheatEngine, null, TestContext.Current.CancellationToken, + out dispatcher); + if (dispatcher is not null) + { + _dispatcher = dispatcher; + } + + return status; + } + + private static NativeThreadWork* AllocateWork(NativeDebugEvent nativeEvent, int iterations) + { + NativeThreadWork* work = (NativeThreadWork*) NativeMemory.AllocZeroed((nuint) sizeof(NativeThreadWork)); + work->Event = nativeEvent; + work->Iterations = iterations; + return work; + } + + /// Creates a Win32 thread that calls the registered thunk; the CLR has never seen this thread. + private static nint StartNativeThread(NativeThreadWork* work, out uint threadId) + { + uint id = 0; + nint handle = CreateThread(0, 0, &RunCallbacksOnNativeThread, work, 0, &id); + Assert.NotEqual(0, handle); + threadId = id; + return handle; + } + + /// Runs on a new native thread to completion and returns that thread's id. + private static uint RunOnNativeThread(NativeThreadWork* work) + { + nint handle = StartNativeThread(work, out uint threadId); + try + { + Assert.Equal(WaitObject0, WaitForSingleObject(handle, NativeThreadTimeoutMilliseconds)); + } + finally + { + _ = CloseHandle(handle); + } + + return threadId; + } + + /// Starts one native thread per work item, all at once, and waits for every one of them. + private static void RunConcurrentlyOnNativeThreads(NativeThreadWork*[] work) + { + nint[] threads = new nint[work.Length]; + try + { + for (int index = 0; index < work.Length; index++) + { + threads[index] = StartNativeThread(work[index], out _); + } + + foreach (nint thread in threads) + { + Assert.Equal(WaitObject0, WaitForSingleObject(thread, NativeThreadTimeoutMilliseconds)); + } + } + finally + { + foreach (nint thread in threads) + { + JoinAndClose(thread); + } + } + } + + /// Runs on a new native thread; exactly one earlier callback reached the handler. + private static void AssertLateCallbacksReturnZeroWithoutTheHandler(NativeThreadWork* late, ref int handlerCalls) + { + RunOnNativeThread(late); + Assert.Equal(late->Iterations, late->ZeroResults); + Assert.Equal(1, Volatile.Read(ref handlerCalls)); + } + + /// + /// Cleanup of a test that blocks a handler and releases on a Task.Run thread: lets the handler return, then + /// waits (bounded) for each started completion. After an early assertion failure, a release still running would + /// call , which sets the test's using-scoped unregister event from an + /// [UnmanagedCallersOnly] frame, where an ends the process; and a + /// callback still running would read event memory the test is about to free. + /// + /// The event the blocked handler waits for. + /// The completion events of the work started so far; when not started. + private static void FinishBlockedWork(ManualResetEventSlim allowHandlerToReturn, + params ReadOnlySpan completions) + { + allowHandlerToReturn.Set(); + foreach (ManualResetEventSlim? completion in completions) + { + _ = completion?.Wait(ReleaseTaskTimeout, CancellationToken.None); + } + } + + private static void JoinAndClose(nint thread) + { + if (thread != 0) + { + _ = WaitForSingleObject(thread, NativeThreadTimeoutMilliseconds); + _ = CloseHandle(thread); + } + } + + private static void FreeWork(params NativeThreadWork*[] work) + { + foreach (NativeThreadWork* item in work) + { + NativeMemory.Free(item); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static uint RunCallbacksOnNativeThread(void* parameter) + { + NativeThreadWork* work = (NativeThreadWork*) parameter; + for (int iteration = 0; iteration < work->Iterations; iteration++) + { + if (s_callback(&work->Event) == 0) + { + work->ZeroResults++; + } + else + { + work->NonZeroResults++; + } + } + + return 0; + } + + [LibraryImport("kernel32.dll")] + private static partial nint CreateThread(nint threadAttributes, nuint stackSize, + delegate* unmanaged[Stdcall] startAddress, void* parameter, uint creationFlags, uint* threadId); + + [LibraryImport("kernel32.dll")] + private static partial uint WaitForSingleObject(nint handle, uint milliseconds); + + [LibraryImport("kernel32.dll")] + private static partial int CloseHandle(nint handle); + + [LibraryImport("kernel32.dll")] + private static partial uint GetCurrentThreadId(); + private static int Invoke(NativeDebugEvent* nativeEvent) { return s_callback(nativeEvent); @@ -287,15 +726,17 @@ private static int RegisterFunction(int pluginId, PluginType functionType, void* Assert.Equal(77, pluginId); Assert.Equal(PluginType.OnDebugEvent, functionType); Assert.NotEqual(0, (nint) initialization); + s_registerCalls++; s_callback = ((DebugEventPluginInit*) initialization)->Callback; - return 901; + return s_functionIdToReturn; } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] private static Bool32 UnregisterFunction(int pluginId, int functionId) { Assert.Equal(77, pluginId); - Assert.Equal(901, functionId); + Assert.Equal(s_functionIdToReturn, functionId); + s_lastUnregisteredFunctionId = functionId; s_unregisterCalls++; s_unregisterEntered?.Set(); return s_unregisterSucceeds; @@ -308,4 +749,14 @@ private readonly struct NativeDebugEvent(uint eventCode, uint processId, uint th public readonly uint ProcessId = processId; public readonly uint ThreadId = threadId; } + + /// What a native thread does and what it observed; written only by that thread until it ends. + [StructLayout(LayoutKind.Sequential)] + private struct NativeThreadWork + { + public NativeDebugEvent Event; + public int Iterations; + public int ZeroResults; + public int NonZeroResults; + } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs index 23427dc6..a2144537 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs @@ -12,6 +12,7 @@ namespace CheatEngine.SDK.Abi.Tests.Native; public sealed unsafe class ClassicExportedFunctionsPrefixReaderTests { [Fact] + [Trait("Qualification", "Q39")] public void TryCopy_rejects_an_empty_table_representation() { bool copied = @@ -22,6 +23,7 @@ public void TryCopy_rejects_an_empty_table_representation() } [Fact] + [Trait("Qualification", "Q39")] public void TryCopy_rejects_a_buffer_that_cannot_contain_the_declared_size_field() { Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DeclaredSizeByteCount - 1]; @@ -36,6 +38,7 @@ public void TryCopy_rejects_a_buffer_that_cannot_contain_the_declared_size_field [InlineData(0)] [InlineData(-1)] [InlineData(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount - 1)] + [Trait("Qualification", "Q39")] public void TryCopy_rejects_a_truncated_declared_table(int declaredSize) { Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount]; @@ -48,6 +51,7 @@ public void TryCopy_rejects_a_truncated_declared_table(int declaredSize) } [Fact] + [Trait("Qualification", "Q39")] public void TryCopy_rejects_a_physically_truncated_table_even_when_its_size_claim_is_sufficient() { Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount - 1]; @@ -62,6 +66,7 @@ public void TryCopy_rejects_a_physically_truncated_table_even_when_its_size_clai [Theory] [InlineData(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount)] [InlineData(int.MaxValue)] + [Trait("Qualification", "Q39")] public void TryCopy_copies_exactly_the_qualified_prefix_without_overflow(int declaredSize) { uint processId = 0x2468u; @@ -88,18 +93,78 @@ public void TryCopy_copies_exactly_the_qualified_prefix_without_overflow(int dec Assert.Equal(0x55AA, (nint) actual.GetAddressFromPointer); } + /// + /// For every slot N of the prefix, a table whose declared size stops one byte short of slot N's end + /// (8 * (N + 1) - 1; slot 0 is 4 bytes wide, so 7 covers it) is refused as a whole: the prefix is never + /// partially copied. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(11)] + [InlineData(12)] + [InlineData(13)] + [InlineData(14)] + [InlineData(15)] + [InlineData(16)] + [InlineData(17)] + [Trait("Qualification", "Q39")] + public void TryCopy_rejects_every_declared_size_below_each_prefix_slot_boundary(int slot) + { + Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount]; + table.Fill(0xA5); + WriteDeclaredSize(table, (8 * (slot + 1)) - 1); + + bool copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out ExportedFunctionsPrefix prefix); + + Assert.False(copied); + Assert.Equal(default, prefix); + } + + /// + /// A table that declares 64 bytes covers slots 0-7 exactly, yet the prefix reader copies nothing: it is all or + /// nothing by design, and per-slot reads go through ClassicExportedFunctionsSlotReader. + /// + [Theory] + [InlineData(64)] + [InlineData(136)] + [InlineData(143)] + [Trait("Qualification", "Q39")] + public void TryCopy_is_all_or_nothing_between_slot_boundaries(int declaredSize) + { + Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount]; + table.Fill(0x5A); + WriteDeclaredSize(table, declaredSize); + + bool copied = ClassicExportedFunctionsPrefixReader.TryCopy(table, out ExportedFunctionsPrefix prefix); + + Assert.False(copied); + Assert.Equal(default, prefix); + Assert.Equal(0, (nint) prefix.ShowMessage); + Assert.Equal(0, prefix.SizeOfExportedFunctions); + } + [Fact] public void Prefix_distinguishes_direct_function_slots_value_cells_and_opaque_null_slots_without_invocation() { FieldInfo showMessage = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.ShowMessage)) - ?? throw new InvalidOperationException("The ShowMessage field was not found."); + ?? throw new InvalidOperationException("The ShowMessage field was not found."); FieldInfo processId = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.OpenedProcessId)) - ?? throw new InvalidOperationException("The OpenedProcessId field was not found."); + ?? throw new InvalidOperationException("The OpenedProcessId field was not found."); FieldInfo processHandle = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.OpenedProcessHandle)) ?? throw new InvalidOperationException("The OpenedProcessHandle field was not found."); FieldInfo fixMemory = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.FixMemory)) - ?? throw new InvalidOperationException("The FixMemory field was not found."); + ?? throw new InvalidOperationException("The FixMemory field was not found."); FieldInfo getAddress = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.GetAddressFromPointer)) ?? throw new InvalidOperationException("The GetAddressFromPointer field was not found."); diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsSlotReaderTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsSlotReaderTests.cs new file mode 100644 index 00000000..70bd6b40 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsSlotReaderTests.cs @@ -0,0 +1,195 @@ +using System.Runtime.InteropServices; +using System.Text.Json; + +using CheatEngine.SDK.Abi.Native; +using CheatEngine.SDK.Abi.Tests.Support; + +namespace CheatEngine.SDK.Abi.Tests.Native; + +/// +/// Q39 at C1, driven by the committed classic slot registry: a reduced or mutated classic table never lets a slot +/// beyond its declared or physical size be read, a nil slot is reported as nil and never dereferenced, cells are +/// observed afresh on every read, and 64-bit addresses survive (audit A03-21, A03-23..26, AX04-03/09/10, A20-Q39-1). +/// There is no invoking API at all: only observes. +/// +public sealed class ClassicExportedFunctionsSlotReaderTests +{ + private const ulong HighAddress = 0xFFFF_8000_0000_0000; + + /// Every readable slot (1-158) with its registry minimum declared size. + public static TheoryData RegistrySlots() + { + TheoryData data = []; + foreach (JsonElement slot in ClassicSlotRegistry.Slots[1..]) + { + data.Add(slot.GetProperty("slot").GetInt32(), slot.GetProperty("minDeclaredSize").GetInt32()); + } + + return data; + } + + [Theory] + [MemberData(nameof(RegistrySlots))] + [Trait("Qualification", "Q39")] + public void Slot_is_refused_when_the_declared_size_is_one_byte_short(int slot, int minDeclaredSize) + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + byte[] table = Table(minDeclaredSize - 1, ClassicExportedFunctionsSlotReader.TableByteCount); + WriteSlot(table, slot, 0x1234_5678_9ABC_DEF0); + + Assert.False(ClassicExportedFunctionsSlotReader.TryReadSlot(table, slot, out ClassicSlotObservation observation)); + Assert.Equal(default, observation); + } + + [Theory] + [MemberData(nameof(RegistrySlots))] + [Trait("Qualification", "Q39")] + public void Slot_is_read_when_declared_and_physical_sizes_cover_it_exactly(int slot, int minDeclaredSize) + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + Assert.Equal(8 * (slot + 1), minDeclaredSize); + byte[] table = Table(minDeclaredSize, minDeclaredSize); + ulong value = 0x0100_0000_0000_0000UL + (ulong) slot; + WriteSlot(table, slot, value); + + Assert.True(ClassicExportedFunctionsSlotReader.TryReadSlot(table, slot, out ClassicSlotObservation observation)); + Assert.Equal(slot, observation.Slot); + Assert.Equal((nint) value, observation.RawValue); + Assert.False(observation.IsNull); + } + + [Theory] + [InlineData(ClassicExportedFunctionsSlotReader.TableByteCount + 8)] + [InlineData(4096)] + [InlineData(int.MaxValue)] + [Trait("Qualification", "Q39")] + public void Oversized_declared_table_is_accepted_for_the_known_slots(int declaredSize) + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + byte[] table = Table(declaredSize, ClassicExportedFunctionsSlotReader.TableByteCount + 64); + for (int slot = 1; slot < ClassicExportedFunctionsSlotReader.SlotCount; slot++) + { + WriteSlot(table, slot, (ulong) slot * 0x10); + } + + for (int slot = 1; slot < ClassicExportedFunctionsSlotReader.SlotCount; slot++) + { + Assert.True(ClassicExportedFunctionsSlotReader.TryReadSlot(table, slot, out ClassicSlotObservation observation)); + Assert.Equal((nint) (slot * 0x10), observation.RawValue); + } + + // A future suffix is compatible with the known slots, but no slot past the known table is ever read. + Assert.False(ClassicExportedFunctionsSlotReader.TryReadSlot(table, ClassicExportedFunctionsSlotReader.SlotCount, out _)); + } + + [Theory] + [MemberData(nameof(RegistrySlots))] + [Trait("Qualification", "Q39")] + public void Physically_truncated_table_is_refused_even_when_the_declared_size_is_large(int slot, int minDeclaredSize) + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + byte[] table = Table(ClassicExportedFunctionsSlotReader.TableByteCount, minDeclaredSize - 1); + + Assert.False(ClassicExportedFunctionsSlotReader.TryReadSlot(table, slot, out ClassicSlotObservation observation)); + Assert.Equal(default, observation); + } + + [Fact] + [Trait("Qualification", "Q39")] + public void Null_slot_is_reported_as_null_and_never_dereferenced() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + byte[] table = Table(ClassicExportedFunctionsSlotReader.TableByteCount, ClassicExportedFunctionsSlotReader.TableByteCount); + int[] nilSlots = [.. ClassicSlotRegistry.Slots + .Where(static slot => string.Equals(slot.GetProperty("nullability").GetString(), "NilAssigned", StringComparison.Ordinal)) + .Select(static slot => slot.GetProperty("slot").GetInt32())]; + // A non-nil but invalid address in a neighbouring slot: reading it must not touch the address either. + WriteSlot(table, 15, 0x1); + + Assert.Equal(11, nilSlots.Length); + foreach (int slot in nilSlots) + { + Assert.True(ClassicExportedFunctionsSlotReader.TryReadSlot(table, slot, out ClassicSlotObservation observation)); + Assert.True(observation.IsNull); + Assert.Equal(0, observation.RawValue); + } + + Assert.True(ClassicExportedFunctionsSlotReader.TryReadSlot(table, 15, out ClassicSlotObservation invalid)); + Assert.Equal(1, invalid.RawValue); + Assert.False(invalid.IsNull); + } + + [Fact] + [Trait("Qualification", "Q39")] + public void Cell_contents_changed_between_two_reads_are_two_distinct_observations() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + const int ReadProcessMemorySlot = 18; + Assert.Equal("FunctionPointerCell", + ClassicSlotRegistry.Slots[ReadProcessMemorySlot].GetProperty("nature").GetString()); + byte[] table = Table(ClassicExportedFunctionsSlotReader.TableByteCount, ClassicExportedFunctionsSlotReader.TableByteCount); + + WriteSlot(table, ReadProcessMemorySlot, 0x0000_7FF0_0000_1000); + Assert.True(ClassicExportedFunctionsSlotReader.TryReadSlot(table, ReadProcessMemorySlot, out ClassicSlotObservation first)); + WriteSlot(table, ReadProcessMemorySlot, 0x0000_7FF0_0000_2000); + Assert.True(ClassicExportedFunctionsSlotReader.TryReadSlot(table, ReadProcessMemorySlot, out ClassicSlotObservation second)); + + Assert.Equal(0x0000_7FF0_0000_1000, first.RawValue); + Assert.Equal(0x0000_7FF0_0000_2000, second.RawValue); + Assert.NotEqual(first, second); + } + + [Fact] + [Trait("Qualification", "Q39")] + public void Address_above_32_bits_round_trips_through_a_slot() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + byte[] table = Table(ClassicExportedFunctionsSlotReader.TableByteCount, ClassicExportedFunctionsSlotReader.TableByteCount); + WriteSlot(table, 158, HighAddress); + + Assert.True(ClassicExportedFunctionsSlotReader.TryReadSlot(table, 158, out ClassicSlotObservation observation)); + Assert.Equal(HighAddress, (ulong) observation.RawValue); + Assert.Equal(unchecked((nint) HighAddress), observation.RawValue); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(ClassicExportedFunctionsSlotReader.SlotCount)] + [InlineData(int.MaxValue)] + [InlineData(int.MinValue)] + [Trait("Qualification", "Q39")] + public void Slot_zero_and_out_of_range_indices_are_refused(int slot) + { + byte[] table = Table(int.MaxValue, ClassicExportedFunctionsSlotReader.TableByteCount + 64); + + Assert.False(ClassicExportedFunctionsSlotReader.TryReadSlot(table, slot, out ClassicSlotObservation observation)); + Assert.Equal(default, observation); + } + + [Fact] + public void Reader_constants_equal_the_registry_contract() + { + JsonElement contract = ClassicSlotRegistry.Root.GetProperty("contract"); + + Assert.Equal(ClassicExportedFunctionsSlotReader.SlotCount, contract.GetProperty("fieldCount").GetInt32()); + Assert.Equal(ClassicExportedFunctionsSlotReader.TableByteCount, contract.GetProperty("x64TableSize").GetInt32()); + Assert.Equal(ClassicExportedFunctionsSlotReader.TableByteCount, ClassicSlotRegistry.MinDeclaredSize(158)); + } + + private static byte[] Table(int declaredSize, int physicalLength) + { + byte[] table = new byte[physicalLength]; + MemoryMarshal.Write(table, in declaredSize); + return table; + } + + private static void WriteSlot(byte[] table, int slot, ulong value) + { + int offset = 8 * slot; + if (offset + 8 <= table.Length) + { + MemoryMarshal.Write(table.AsSpan(offset, 8), in value); + } + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicSlotRegistryPrefixTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicSlotRegistryPrefixTests.cs new file mode 100644 index 00000000..3879d9cf --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicSlotRegistryPrefixTests.cs @@ -0,0 +1,139 @@ +using System.Reflection; +using System.Text.Json; + +using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Abi.Native; +using CheatEngine.SDK.Abi.Tests.Support; + +namespace CheatEngine.SDK.Abi.Tests.Native; + +/// +/// Keeps the internal and the committed classic slot registry in lockstep: +/// the prefix is exactly registry slots 0-17, typed only where the registry says so, opaque where the host assigns +/// nil or the declarations diverge, and no ABI type maps a slot beyond it (audit A00-24, A03-14, A03-15, A18-08, +/// A23-DT-03, AX04-01, AX05-15). +/// +public sealed class ClassicSlotRegistryPrefixTests +{ + private static readonly FieldInfo[] PrefixFields = + typeof(ExportedFunctionsPrefix).GetFields(FieldLayoutGate.InstanceFields); + + [Fact] + public void Prefix_fields_are_registry_slots_0_to_17_in_order_with_offsets_and_widths() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + JsonElement[] slots = ClassicSlotRegistry.Slots; + + Assert.Equal(18, PrefixFields.Length); + for (int slot = 0; slot < PrefixFields.Length; slot++) + { + FieldInfo field = PrefixFields[slot]; + JsonElement entry = slots[slot]; + Assert.Equal(entry.GetProperty("sdkField").GetString(), field.Name); + Assert.Equal(entry.GetProperty("x64Offset").GetInt32(), FieldLayoutGate.OffsetOf(field)); + Assert.Equal(entry.GetProperty("width").GetInt32(), FieldLayoutGate.WidthOf(field)); + } + + Assert.All(slots[18..], static entry => Assert.Equal(JsonValueKind.Null, entry.GetProperty("sdkField").ValueKind)); + } + + [Fact] + public void Direct_prefix_byte_count_is_the_min_declared_size_of_slot_17() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Assert.Equal(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount, ClassicSlotRegistry.MinDeclaredSize(17)); + Assert.Equal(ClassicSlotRegistry.MinDeclaredSize(17), Layout.SizeOf()); + Assert.Equal(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount, + ClassicSlotRegistry.Root.GetProperty("contract").GetProperty("sdkDirectPrefixSize").GetInt32()); + Assert.True(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount < ClassicSlotRegistry.MinDeclaredSize(18)); + } + + [Fact] + public void Typed_prefix_slots_are_exactly_the_registry_prefix_typed_slots() + { + JsonElement[] slots = ClassicSlotRegistry.Slots; + + for (int slot = 0; slot < PrefixFields.Length; slot++) + { + bool opaque = FieldLayoutGate.KindOf(PrefixFields[slot]) == FieldKind.OpaquePointer; + string expected = opaque ? "PrefixOpaque" : "PrefixTyped"; + Assert.True(string.Equals(expected, slots[slot].GetProperty("sdkExposure").GetString(), StringComparison.Ordinal), + $"Slot {slot} ({PrefixFields[slot].Name}) is {expected} in the SDK but {slots[slot].GetProperty("sdkExposure").GetString()} in the registry."); + } + } + + [Fact] + public void Nil_or_divergent_prefix_slots_are_opaque_void_pointers() + { + JsonElement[] slots = ClassicSlotRegistry.Slots; + List guarded = []; + + for (int slot = 0; slot < PrefixFields.Length; slot++) + { + JsonElement entry = slots[slot]; + bool nil = string.Equals(entry.GetProperty("nullability").GetString(), "NilAssigned", StringComparison.Ordinal); + bool divergent = entry.GetProperty("divergenceRefs").GetArrayLength() > 0; + if (nil || divergent) + { + guarded.Add(slot); + Type fieldType = PrefixFields[slot].GetModifiedFieldType().UnderlyingSystemType; + Assert.True(fieldType.IsPointer && fieldType.GetElementType() == typeof(void), + $"Slot {slot} ({PrefixFields[slot].Name}) is nil or divergent in the registry but typed in the SDK."); + } + } + + Assert.Equal([14, 17], guarded); + } + + [Fact] + public void No_abi_type_declares_a_field_for_a_suffix_slot() + { + HashSet suffixNames = new(StringComparer.OrdinalIgnoreCase); + foreach (JsonElement entry in ClassicSlotRegistry.Slots[18..]) + { + suffixNames.Add(entry.GetProperty("hostField").GetProperty("name").GetString()!); + suffixNames.Add(entry.GetProperty("mirrors").GetProperty("headerC").GetProperty("name").GetString()!); + suffixNames.Add(entry.GetProperty("mirrors").GetProperty("pascalMirror").GetProperty("name").GetString()!); + } + + foreach (Type structure in AbiStructures.All()) + { + // The managed exports table is a different route (ADR-02); its own GetLuaState is not classic slot 157. + if (structure == typeof(ManagedExportedFunctions)) + { + continue; + } + + foreach (FieldInfo field in structure.GetFields(FieldLayoutGate.InstanceFields)) + { + Assert.False(suffixNames.Contains(field.Name), + $"{structure.FullName}.{field.Name} maps a classic suffix slot; the registry keeps slots 18-158 unexposed."); + } + } + + Assert.Contains("GetLuaState", suffixNames); + } + + [Fact] + public void Plugin_type_values_equal_the_registry_callback_categories() + { + JsonElement[] categories = ClassicSlotRegistry.CallbackCategories; + PluginType[] members = Enum.GetValues(); + + Assert.Equal(categories.Length, members.Length); + foreach (JsonElement category in categories) + { + int value = category.GetProperty("pluginType").GetInt32(); + PluginType member = (PluginType) value; + Assert.True(Enum.IsDefined(member), $"Plugin type {value} has no PluginType member."); + Assert.Equal("pt" + member, category.GetProperty("name").GetString(), StringComparer.OrdinalIgnoreCase); + + string recordName = category.GetProperty("sdkRecord").GetString()!; + Type record = AbiStructures.AbiAssembly.GetType("CheatEngine.SDK.Abi.Native." + recordName, true, false)!; + FieldInfo callback = record.GetField("Callback", FieldLayoutGate.InstanceFields)!; + Assert.Equal(category.GetProperty("sdkCallbackTyped").GetBoolean(), + FieldLayoutGate.KindOf(callback) == FieldKind.FunctionPointer); + } + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs index 56f29eea..6d800d08 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs @@ -91,13 +91,13 @@ public void Prefix_stops_before_the_pointer_to_pointer_hook_suffix() public void Historically_null_and_conflicting_slots_stay_opaque() { FieldInfo fixMemory = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.FixMemory), - BindingFlags.Instance | BindingFlags.Public) - ?? throw new InvalidOperationException("The FixMemory field was not found."); + BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException("The FixMemory field was not found."); FieldInfo getAddressFromPointer = typeof(ExportedFunctionsPrefix).GetField( - nameof(ExportedFunctionsPrefix.GetAddressFromPointer), - BindingFlags.Instance | BindingFlags.Public) - ?? throw new InvalidOperationException( - "The GetAddressFromPointer field was not found."); + nameof(ExportedFunctionsPrefix.GetAddressFromPointer), + BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException( + "The GetAddressFromPointer field was not found."); Type fixMemoryType = fixMemory.GetModifiedFieldType().UnderlyingSystemType; Type getAddressFromPointerType = getAddressFromPointer.GetModifiedFieldType().UnderlyingSystemType; diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginType0RecordTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginType0RecordTests.cs index 4bd2b909..e1d82fd2 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginType0RecordTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginType0RecordTests.cs @@ -4,8 +4,10 @@ namespace CheatEngine.SDK.Abi.Tests.Native; /// -/// Structural regression tests for the C-header PLUGINTYPE0_RECORD from the installed CE 7.7.0.10621 x64 -/// SDK (SHA-256 9C0E31BB753D782CE20710D19828F4E97B4371C8733ABD0C5C6F7F485306FB28). +/// Structural regression tests for the selection record of a classic type-0 callback. The oracle is the host type +/// TPlugin0_SelectedRecord of the pinned plugin.pas (L726-735), which agrees field by field with the C +/// header PLUGINTYPE0_RECORD (cepluginsdk.h L27-37); the two divergent Pascal kit mirrors are covered +/// by SelectedRecordOracleTests. /// public sealed unsafe class PluginType0RecordTests { @@ -28,9 +30,34 @@ public void PluginType0Record_on_64_bit_matches_the_installed_C_header_layout() } [Fact] - public void PluginType0Record_preserves_the_header_boolean_width_and_32_bit_offset_element_width() + public void PluginType0Record_fields_have_the_host_widths() { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + PluginType0Record record = default; + + Assert.Equal(8, sizeof(nuint)); + Assert.Equal(typeof(nuint), TypeOf(nameof(PluginType0Record.Address))); Assert.Equal(4, Layout.SizeOf()); + Assert.Equal(typeof(Bool32), TypeOf(nameof(PluginType0Record.IsPointer))); + Assert.Equal(typeof(int), TypeOf(nameof(PluginType0Record.CountOffsets))); + Assert.Equal(8, sizeof(uint*)); + Assert.Equal(typeof(uint*), TypeOf(nameof(PluginType0Record.Offsets))); Assert.Equal(4, sizeof(uint)); + Assert.Equal(typeof(byte), TypeOf(nameof(PluginType0Record.ValueType))); + Assert.Equal(typeof(byte), TypeOf(nameof(PluginType0Record.Size))); + + // Width by address arithmetic: each field ends where the next begins (or at the 42-byte data end). + void* origin = &record; + Assert.Equal(8, Layout.OffsetOf(origin, &record.IsPointer) - Layout.OffsetOf(origin, &record.Address)); + Assert.Equal(4, Layout.OffsetOf(origin, &record.CountOffsets) - Layout.OffsetOf(origin, &record.IsPointer)); + Assert.Equal(4, Layout.OffsetOf(origin, &record.Offsets) - Layout.OffsetOf(origin, &record.CountOffsets)); + Assert.Equal(8, Layout.OffsetOf(origin, &record.Description) - Layout.OffsetOf(origin, &record.Offsets)); + Assert.Equal(1, Layout.OffsetOf(origin, &record.Size) - Layout.OffsetOf(origin, &record.ValueType)); + } + + private static Type TypeOf(string fieldName) + { + return (typeof(PluginType0Record).GetField(fieldName) + ?? throw new InvalidOperationException($"PluginType0Record has no field {fieldName}.")).FieldType; } } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs index 2814d030..322ab11f 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs @@ -8,6 +8,7 @@ namespace CheatEngine.SDK.Abi.Tests.Native; public sealed unsafe class PluginVersionTests { [Fact] + [Trait("Qualification", "Q01")] public void Size_on_64_bit_is_16_bytes() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -16,6 +17,7 @@ public void Size_on_64_bit_is_16_bytes() } [Fact] + [Trait("Qualification", "Q01")] public void Field_offsets_on_64_bit_match_the_c_structure() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -27,6 +29,7 @@ public void Field_offsets_on_64_bit_match_the_c_structure() } [Fact] + [Trait("Qualification", "Q01")] public void Write_through_a_pointer_on_64_bit_leaves_the_padding_alone_and_places_the_name_at_8() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/SelectedRecordOracleTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/SelectedRecordOracleTests.cs new file mode 100644 index 00000000..749e82ee --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/SelectedRecordOracleTests.cs @@ -0,0 +1,233 @@ +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Abi.Native; +using CheatEngine.SDK.Abi.Tests.Fixture; +using CheatEngine.SDK.Abi.Tests.Support; + +namespace CheatEngine.SDK.Abi.Tests.Native; + +/// +/// The type-0 selection record against its oracle and its two known-wrong mirrors. The oracle is the host type +/// actually called, TPlugin0_SelectedRecord of the pinned plugin.pas (lines 726-735, +/// address: ptrUint; ispointer: BOOL), which the C header agrees with. The Pascal kit unit +/// cepluginsdk.pas carries a same-named record with address: dword (lines 161-170) and a +/// TSelectedRecord with ispointer: boolean (lines 147-156). All three are 48 bytes on x64, so only a +/// per-field check separates them (audit annex 05, A03-31, AX05-07). +/// +/// +/// The mirrors are modelled here as nested C# structures, never in CheatEngine.SDK.Abi. The CI-only fixture test +/// compares the same three layouts as compiled by MSVC from the transcriptions in +/// tests/native-abi-fixture/ce77_plugin_abi_contract.h. Nothing here observes the CE 7.7 binary. +/// +public sealed class SelectedRecordOracleTests +{ + private const string HostType = "CheatEngine.SDK.Abi.Native.PluginType0Record"; + + /// The host type of plugin.pas as audit annex 05 tabulates it: field, offset, width. + private static readonly (string Field, int Offset, int Width)[] HostLayout = + [ + ("InterpretedAddress", 0, 8), + ("Address", 8, 8), + ("IsPointer", 16, 4), + ("CountOffsets", 20, 4), + ("Offsets", 24, 8), + ("Description", 32, 8), + ("ValueType", 40, 1), + ("Size", 41, 1) + ]; + + [Fact] + public void PluginType0Record_matches_the_pinned_host_type_field_by_field() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Assert.Equal(48, Layout.SizeOf()); + Assert.Empty(FieldLayoutGate.FindViolations(typeof(PluginType0Record), HostRows(HostType, FieldKindsOfSdkRecord()))); + Assert.Equal(HostLayout.Select(static row => row.Field), + typeof(PluginType0Record).GetFields(FieldLayoutGate.InstanceFields).Select(static field => field.Name), + StringComparer.Ordinal); + } + + [Fact] + public void Pascal_dword_mirror_has_the_same_size_but_different_offsets() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Assert.Equal(48, Layout.SizeOf()); + Assert.Equal(4, Width("Address")); + Assert.Equal(12, Offset("IsPointer")); + Assert.Equal(16, Offset("CountOffsets")); + Assert.Equal(24, Offset("Offsets")); + + Assert.NotEqual(Offset("IsPointer"), Offset("IsPointer")); + Assert.NotEqual(Offset("CountOffsets"), + Offset("CountOffsets")); + Assert.NotEqual(Width("Address"), Width("Address")); + } + + [Fact] + public void Pascal_boolean_mirror_has_the_same_offsets_but_a_one_byte_IsPointer() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Assert.Equal(48, Layout.SizeOf()); + foreach ((string field, int offset, int _) in HostLayout) + { + Assert.Equal(offset, Offset(field)); + Assert.Equal(Offset(field), Offset(field)); + } + + Assert.Equal(1, Width("IsPointer")); + Assert.Equal(4, Width("IsPointer")); + } + + [Fact] + public void Size_only_check_cannot_tell_the_host_type_from_either_mirror() + { + Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + // What a size-only gate sees: three identical numbers. + Assert.Equal(Layout.SizeOf(), Layout.SizeOf()); + Assert.Equal(Layout.SizeOf(), Layout.SizeOf()); + + // What the per-field gate sees against the host oracle: the SDK record passes, both mirrors fail. + Assert.Empty(FieldLayoutGate.FindViolations(typeof(PluginType0Record), HostRows(HostType, FieldKindsOfSdkRecord()))); + Assert.Contains(FieldLayoutGate.FindViolations(typeof(PascalDwordMirrorSelectedRecord), + HostRows(typeof(PascalDwordMirrorSelectedRecord).FullName!, null)), IsOffsetOrWidth); + Assert.Contains(FieldLayoutGate.FindViolations(typeof(PascalBooleanMirrorSelectedRecord), + HostRows(typeof(PascalBooleanMirrorSelectedRecord).FullName!, null)), IsOffsetOrWidth); + } + + [Fact] + public void Native_fixture_host_and_mirror_transcriptions_match_the_managed_oracle_when_CI_supplies_them() + { + Dictionary? facts = NativeAbiFixtureFacts.LoadFromEnvironment(); + if (facts is null) + { + Assert.Null(facts); + return; + } + + Assert.True(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + AssertFixtureEquals(facts, "host_plugin0_selected_record"); + AssertFixtureEquals(facts, "pascal_dword_mirror_selected_record"); + AssertFixtureEquals(facts, "pascal_boolean_mirror_selected_record"); + + // The SDK record differs from each compiled mirror exactly where annex 05 says it does. + Assert.NotEqual(facts["offsetof.pascal_dword_mirror_selected_record.IsPointer"], + Text(Offset("IsPointer")), StringComparer.Ordinal); + Assert.NotEqual(facts["fieldsize.pascal_dword_mirror_selected_record.Address"], + Text(Width("Address")), StringComparer.Ordinal); + Assert.Equal(facts["offsetof.pascal_boolean_mirror_selected_record.IsPointer"], + Text(Offset("IsPointer")), StringComparer.Ordinal); + Assert.NotEqual(facts["fieldsize.pascal_boolean_mirror_selected_record.IsPointer"], + Text(Width("IsPointer")), StringComparer.Ordinal); + } + + private static void AssertFixtureEquals(Dictionary facts, string key) + where T : unmanaged + { + Dictionary managed = new(StringComparer.Ordinal); + NativeAbiFixtureManagedComparisonTests.AddRecord(managed, [], key); + foreach (KeyValuePair fact in managed) + { + Assert.True(facts.TryGetValue(fact.Key, out string? native), $"The native fixture omitted '{fact.Key}'."); + Assert.True(string.Equals(fact.Value, native, StringComparison.Ordinal), + $"'{fact.Key}': managed {fact.Value}, native {native}."); + } + } + + private static FieldLayoutRow[] HostRows(string typeFullName, Dictionary? kinds) + { + return + [ + .. HostLayout.Select(row => new FieldLayoutRow(typeFullName, row.Field, row.Offset, row.Width, + kinds?[row.Field] ?? KindOf(typeFullName, row.Field))) + ]; + } + + private static Dictionary FieldKindsOfSdkRecord() + { + return new Dictionary(StringComparer.Ordinal) + { + ["InterpretedAddress"] = FieldKind.Pointer, + ["Address"] = FieldKind.Integer, + ["IsPointer"] = FieldKind.AbiBoolean, + ["CountOffsets"] = FieldKind.Integer, + ["Offsets"] = FieldKind.Pointer, + ["Description"] = FieldKind.Pointer, + ["ValueType"] = FieldKind.Integer, + ["Size"] = FieldKind.Integer + }; + } + + private static FieldKind KindOf(string typeFullName, string fieldName) + { + Type type = typeof(SelectedRecordOracleTests).Assembly.GetType(typeFullName, true, false)!; + return FieldLayoutGate.KindOf(type.GetField(fieldName, FieldLayoutGate.InstanceFields)!); + } + + private static bool IsOffsetOrWidth(string violation) + { + return violation.Contains(": offset ", StringComparison.Ordinal) || + violation.Contains(": width ", StringComparison.Ordinal); + } + + private static int Offset(string fieldName) + { + return FieldLayoutGate.OffsetOf(FieldOf(fieldName)); + } + + private static int Width(string fieldName) + { + return FieldLayoutGate.WidthOf(FieldOf(fieldName)); + } + + private static FieldInfo FieldOf(string fieldName) + { + return typeof(T).GetField(fieldName, FieldLayoutGate.InstanceFields) + ?? throw new InvalidOperationException($"{typeof(T).Name} has no field {fieldName}."); + } + + private static string Text(int value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + + /// + /// C# model of the kit mirror TPlugin0_SelectedRecord (cepluginsdk.pas lines 161-170): a 32-bit + /// address moves IsPointer and CountOffsets 4 bytes down; natural alignment keeps 48 bytes. + /// + [StructLayout(LayoutKind.Sequential)] + public unsafe struct PascalDwordMirrorSelectedRecord + { + public byte* InterpretedAddress; + public uint Address; + public Bool32 IsPointer; + public int CountOffsets; + public uint* Offsets; + public byte* Description; + public byte ValueType; + public byte Size; + } + + /// + /// C# model of the kit mirror TSelectedRecord (cepluginsdk.pas lines 147-156): the host offsets, but + /// a one-byte Pascal boolean pointer flag. + /// + [StructLayout(LayoutKind.Sequential)] + public unsafe struct PascalBooleanMirrorSelectedRecord + { + public byte* InterpretedAddress; + public nuint Address; + public byte IsPointer; + public int CountOffsets; + public uint* Offsets; + public byte* Description; + public byte ValueType; + public byte Size; + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/README.md b/tests/CheatEngine.SDK.Abi.Tests/README.md index 5b9487e0..0f0c4f75 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/README.md +++ b/tests/CheatEngine.SDK.Abi.Tests/README.md @@ -17,16 +17,18 @@ Engine itself. ## How it works -Three independent techniques check the layout, so one mistake cannot hide behind another. `Managed/` and `Native/` -mirror the `CheatEngine.SDK.Abi` namespaces. `Support/` holds `Layout`, the `AbiShape` gate and its tests. Expected -numbers are literals next to the assertion, never derived from the code under test. +Independent techniques check the layout, so one mistake cannot hide behind another. `Managed/` and `Native/` mirror +the `CheatEngine.SDK.Abi` namespaces. `Support/` holds `Layout`, the `AbiShape` gate, the per-field `FieldLayoutGate` +with its literal `FieldLayoutExpectations` table, and their tests. Expected numbers are literals next to the assertion or +in that table, never derived from the code under test. | Technique | What it does | |------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Address-of arithmetic | `Layout.SizeOf()` and `Layout.OffsetOf` measure the size and every field offset. | | Raw bytes | The packed 36-byte init record is written into a guard-filled buffer at an aligned and an odd address. The 48-byte exports record is built as raw bytes, then read through the struct. | | Host simulation | `&Method` of a real `[UnmanagedCallersOnly]` `Stdcall` function is stored in every typed function-pointer slot, then called through the field. | -| Native-fact comparison | The native CI job builds the checked `ce77-native-abi-facts.txt`; the Debug build-test job passes it in and sets the required gate. A compiled managed test measures every fixture-covered layout and compares its size, alignment, and offsets to that output. | +| Per-field gate | `FieldLayout/FieldLayoutContractTests` walks every instance field of every struct (public, internal, nested private, compiler-generated backing fields) and compares its offset (measured with the IL `ldflda` instruction), width and kind with one literal row of `Support/FieldLayoutExpectations.cs`, in both directions. | +| Native-fact comparison | The native CI job builds the checked `ce77-native-abi-facts.txt`; the Debug build-test job passes it in and sets the required gate. A compiled managed test measures every fixture-covered layout (C-header records and, since schema 3, the packed managed bootstrap record and the managed exports) and compares its size, alignment, and every field's offset and width to that output. | The test assembly applies `[assembly: DisableRuntimeMarshalling]`, so calls take the path a plugin takes. `BoolCallBoundaryTests` calls through pointers whose signature differs by one substitution (`int` for `Bool32`, `byte` @@ -37,21 +39,39 @@ facts the native job built, supplies `CE77_NATIVE_ABI_FACTS_PATH` and sets `CE77 solution test run, which makes a missing facts path fail the comparison gate. Ordinary managed runs omit the required mode and can omit the facts path; that local opt-out is intentional and is not a substitute for an exact-host test. -- `AssemblyConformanceTests` compares an expected-size table with the public structs in both directions, then runs the - `AbiShape` reflection gate over every struct. +- `AssemblyConformanceTests` compares an expected-size table with every struct of the assembly (public, internal and + nested, keyed by full name) in both directions, then runs the `AbiShape` reflection gate over every struct. +- `FieldLayoutContractTests` is the per-field gate: a field without a row, a row without a field, or a wrong offset, + width or kind fails the run. `Reflected_offsets_agree_with_address_of_offsets_for_the_packed_init_record_and_the_managed_exports` + proves that the reflected offsets equal the C# address-of arithmetic, and two wrong-on-purpose nested structs prove the + gate reports each class of error (including a size-preserving retype that a size check misses). - The gate rejects `bool`, `char`, references, foreign value types, managed or non-`Stdcall` function pointers and by-reference parameters at any depth. It exists because `delegate* unmanaged[Cdecl]` compiles without a diagnostic under `DisableRuntimeMarshalling`. - `AbiShapeTests` proves each rule against a wrong-on-purpose fixture. The fixtures are nested in that class, never in `CheatEngine.SDK.Abi`. -- Two cases stay a review rule. An implicit `[StructLayout(LayoutKind.Sequential)]` has identical metadata. A new struct - with a size row but no per-field offset test passes. +- One case stays a review rule: an implicit `[StructLayout(LayoutKind.Sequential)]` has identical metadata. ## Promise -- A public struct without a row in the expected-size table fails the run. +- A struct, public or not, without a row in the expected-size table fails the run + (`AssemblyConformanceTests.Every_structure_is_listed_in_the_expected_size_table`, + `Every_structure_including_internal_and_nested_ones_has_the_expected_size`). +- Adding, removing, moving or retyping a field of any struct without editing `Support/FieldLayoutExpectations.cs` fails + the run (`FieldLayoutContractTests.Every_instance_field_of_every_abi_structure_has_a_layout_row`, + `Every_layout_row_names_an_existing_field`, `Every_field_has_the_expected_offset_on_64_bit`, + `Every_field_has_the_expected_width_on_64_bit`, `Every_field_has_the_expected_kind`). - Only `PluginInitRecord` is packed. A stray `Pack` on another struct fails, even when size and offsets do not change. An unpacked mirror is 40 bytes and writes 4 bytes past the host's 36-byte variable. +- The type-0 selection record matches the host type of `plugin.pas` field by field, and both same-size Pascal mirrors + are told apart by a per-field check (`SelectedRecordOracleTests`). +- The embedded [classic slot registry](../CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json) agrees with `ExportedFunctionsPrefix` + on slots 0-17, their offsets, widths and opacity (`ClassicSlotRegistryPrefixTests`). +- A classic slot is observed only when the declared and the physical sizes both reach `8 * (slot + 1)` bytes, for every + slot and boundary, and the prefix copy is all or nothing between slot boundaries + (`ClassicExportedFunctionsSlotReaderTests`, `ClassicExportedFunctionsPrefixReaderTests`). +- The classic debug dispatcher admits callbacks from native OS threads, returns the Cheat Engine fallback, drains the + running callbacks on release and refuses late ones (`ClassicDebugEventDispatcherTests`, Q38 C1). - Layout tests skip in a 32-bit process. Every other test runs, so a default run never ends with zero tests. ## Run the tests diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/AbiStructures.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/AbiStructures.cs new file mode 100644 index 00000000..ae1aa571 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/AbiStructures.cs @@ -0,0 +1,48 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +using CheatEngine.SDK.Abi.Managed; + +namespace CheatEngine.SDK.Abi.Tests.Support; + +/// +/// The set of structures the assembly-wide gates inspect: every value type of CheatEngine.SDK.Abi, public, +/// internal or nested private alike, minus enumerations and compiler-generated types. Shared by +/// (sizes and shape) and FieldLayout.FieldLayoutContractTests (per-field +/// offsets and widths), so both gates always look at exactly the same structures. +/// +internal static class AbiStructures +{ + /// The assembly under test. + public static Assembly AbiAssembly + { + get; + } = typeof(PluginInitRecord).Assembly; + + /// + /// Public and non-public structures alike: an internal helper structure is as much part of a layout as the + /// public structure that embeds it. Compiler-generated types (static data blobs) are not ours to judge. + /// + public static IEnumerable All() + { + return AbiAssembly.GetTypes() + .Where(static type => type.IsValueType && !type.IsEnum && !IsCompilerGenerated(type)); + } + + /// + /// Whether or one of its declaring types was emitted by the compiler (name starting with + /// < or ). + /// + public static bool IsCompilerGenerated(Type type) + { + for (Type? current = type; current is not null; current = current.DeclaringType) + { + if (current.Name.StartsWith('<') || current.IsDefined(typeof(CompilerGeneratedAttribute), false)) + { + return true; + } + } + + return false; + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/ClassicSlotRegistry.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/ClassicSlotRegistry.cs new file mode 100644 index 00000000..c591e2d8 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/ClassicSlotRegistry.cs @@ -0,0 +1,38 @@ +using System.Text.Json; + +namespace CheatEngine.SDK.Abi.Tests.Support; + +/// +/// Read access to the committed tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json, embedded in this test assembly. The +/// registry's authority is the pinned host source plugin.pas; the SDK types must agree with it, never the +/// other way round. +/// +internal static class ClassicSlotRegistry +{ + private const string ResourceName = "CheatEngine.SDK.Abi.Tests.ClassicSlotRegistry.json"; + + private static readonly Lazy SRoot = new(Load); + + /// The registry document. + public static JsonElement Root => SRoot.Value; + + /// The 159 slot records, in slot order. + public static JsonElement[] Slots => [.. Root.GetProperty("slots").EnumerateArray()]; + + /// The nine callback categories, in plugin-type order. + public static JsonElement[] CallbackCategories => [.. Root.GetProperty("callbackCategories").EnumerateArray()]; + + /// The minimum declared size of : 8 * (slot + 1) for a pointer slot. + public static int MinDeclaredSize(int slot) + { + return Slots[slot].GetProperty("minDeclaredSize").GetInt32(); + } + + private static JsonElement Load() + { + using Stream stream = typeof(ClassicSlotRegistry).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"The embedded resource {ResourceName} is missing."); + using JsonDocument document = JsonDocument.Parse(stream); + return document.RootElement.Clone(); + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/FieldKind.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/FieldKind.cs new file mode 100644 index 00000000..0e12d9e5 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/FieldKind.cs @@ -0,0 +1,23 @@ +namespace CheatEngine.SDK.Abi.Tests.Support; + +/// What a field of an ABI structure is, as far as the layout gate is concerned. +internal enum FieldKind +{ + /// A fixed-width or pointer-sized integer (int, uint, long, byte, nuint, an enumeration). + Integer, + + /// One of the two ABI booleans, or . + AbiBoolean, + + /// A typed data pointer (byte*, uint*, void**). + Pointer, + + /// An unmanaged function pointer (delegate* unmanaged[Stdcall]<…>). + FunctionPointer, + + /// A deliberately untyped void*: a slot the SDK preserves physically but never invokes. + OpaquePointer, + + /// Any other value type embedded by value. + Struct +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutExpectations.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutExpectations.cs new file mode 100644 index 00000000..22dcafc3 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutExpectations.cs @@ -0,0 +1,169 @@ +namespace CheatEngine.SDK.Abi.Tests.Support; + +/// +/// The x64 offset, width and kind of every instance field of every structure of +/// CheatEngine.SDK.Abi (public, internal and nested private alike, including the compiler-generated backing +/// fields of and ). +/// +/// +/// +/// Literals only, never derived from the code under test. Adding, removing, moving or retyping a field without +/// editing this table fails FieldLayout.FieldLayoutContractTests in both directions (a field without a row, a row +/// without a field, a wrong offset, width or kind). +/// +/// +/// Sources of the numbers: the host Pascal types of the pinned plugin.pas (TPluginVersion L22-25, +/// TPluginDotNetInitResult L29-36 packed, TExportedFunctionsDotNetV1 L38-45, +/// TPlugin0_SelectedRecord L726-735, TExportedFunctions5 L47-226), the C header +/// cepluginsdk.h for the registration records and REGISTERMODIFICATIONINFO, and the x64 natural +/// alignment rule; see libs/CheatEngine.SDK.Abi/README.md. The PluginType0Record rows are identical to the host +/// type table of the audit's annex 05 (the oracle of Native.SelectedRecordOracleTests). +/// +/// +internal static class FieldLayoutExpectations +{ + private const string Abi = "CheatEngine.SDK.Abi."; + private const string Managed = Abi + "Managed."; + private const string Native = Abi + "Native."; + + /// Every expected field, grouped by structure in declaration order. + public static IReadOnlyList Rows + { + get; + } = + [ + new(Abi + "Bool32", "k__BackingField", 0, 4, FieldKind.Integer), + + new(Abi + "Bool8", "k__BackingField", 0, 1, FieldKind.Integer), + + new(Managed + "PluginInitRecord", "Name", 0, 8, FieldKind.Pointer), + new(Managed + "PluginInitRecord", "GetVersion", 8, 8, FieldKind.FunctionPointer), + new(Managed + "PluginInitRecord", "EnablePlugin", 16, 8, FieldKind.FunctionPointer), + new(Managed + "PluginInitRecord", "DisablePlugin", 24, 8, FieldKind.FunctionPointer), + new(Managed + "PluginInitRecord", "Version", 32, 4, FieldKind.Integer), + + new(Managed + "ManagedExportedFunctions", "SizeOfExportedFunctions", 0, 4, FieldKind.Integer), + new(Managed + "ManagedExportedFunctions", "GetLuaState", 8, 8, FieldKind.FunctionPointer), + new(Managed + "ManagedExportedFunctions", "LuaRegister", 16, 8, FieldKind.OpaquePointer), + new(Managed + "ManagedExportedFunctions", "LuaPushClassInstance", 24, 8, FieldKind.FunctionPointer), + new(Managed + "ManagedExportedFunctions", "ProcessMessages", 32, 8, FieldKind.FunctionPointer), + new(Managed + "ManagedExportedFunctions", "CheckSynchronize", 40, 8, FieldKind.FunctionPointer), + + new(Native + "PluginVersion", "Version", 0, 4, FieldKind.Integer), + new(Native + "PluginVersion", "PluginName", 8, 8, FieldKind.Pointer), + + new(Native + "AddressListPluginInit", "Name", 0, 8, FieldKind.Pointer), + new(Native + "AddressListPluginInit", "Callback", 8, 8, FieldKind.OpaquePointer), + + new(Native + "MemoryViewPluginInit", "Name", 0, 8, FieldKind.Pointer), + new(Native + "MemoryViewPluginInit", "Callback", 8, 8, FieldKind.FunctionPointer), + new(Native + "MemoryViewPluginInit", "Shortcut", 16, 8, FieldKind.Pointer), + + new(Native + "DebugEventPluginInit", "Callback", 0, 8, FieldKind.FunctionPointer), + + new(Native + "DebugEventObservation", "SequenceNumber", 0, 8, FieldKind.Integer), + new(Native + "DebugEventObservation", "EventCode", 8, 4, FieldKind.Integer), + new(Native + "DebugEventObservation", "ProcessId", 12, 4, FieldKind.Integer), + new(Native + "DebugEventObservation", "ThreadId", 16, 4, FieldKind.Integer), + + new(Native + "ProcessWatcherPluginInit", "Callback", 0, 8, FieldKind.OpaquePointer), + + new(Native + "FunctionPointerChangePluginInit", "Callback", 0, 8, FieldKind.OpaquePointer), + + new(Native + "MainMenuPluginInit", "Name", 0, 8, FieldKind.Pointer), + new(Native + "MainMenuPluginInit", "Callback", 8, 8, FieldKind.FunctionPointer), + new(Native + "MainMenuPluginInit", "Shortcut", 16, 8, FieldKind.Pointer), + + new(Native + "DisassemblerContextPluginInit", "Name", 0, 8, FieldKind.Pointer), + new(Native + "DisassemblerContextPluginInit", "Callback", 8, 8, FieldKind.OpaquePointer), + new(Native + "DisassemblerContextPluginInit", "CallbackOnPopup", 16, 8, FieldKind.OpaquePointer), + new(Native + "DisassemblerContextPluginInit", "Shortcut", 24, 8, FieldKind.Pointer), + + new(Native + "DisassemblerRenderLinePluginInit", "Callback", 0, 8, FieldKind.FunctionPointer), + + new(Native + "AutoAssemblerPluginInit", "Callback", 0, 8, FieldKind.FunctionPointer), + + new(Native + "ExportedFunctionsPrefix", "SizeOfExportedFunctions", 0, 4, FieldKind.Integer), + new(Native + "ExportedFunctionsPrefix", "ShowMessage", 8, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "RegisterFunction", 16, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "UnregisterFunction", 24, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "OpenedProcessId", 32, 8, FieldKind.Pointer), + new(Native + "ExportedFunctionsPrefix", "OpenedProcessHandle", 40, 8, FieldKind.Pointer), + new(Native + "ExportedFunctionsPrefix", "GetMainWindowHandle", 48, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "AutoAssemble", 56, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "Assembler", 64, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "Disassembler", 72, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "ChangeRegistersAtAddress", 80, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "InjectDll", 88, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "FreezeMemory", 96, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "UnfreezeMemory", 104, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "FixMemory", 112, 8, FieldKind.OpaquePointer), + new(Native + "ExportedFunctionsPrefix", "ProcessList", 120, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "ReloadSettings", 128, 8, FieldKind.FunctionPointer), + new(Native + "ExportedFunctionsPrefix", "GetAddressFromPointer", 136, 8, FieldKind.OpaquePointer), + + new(Native + "PluginType0Record", "InterpretedAddress", 0, 8, FieldKind.Pointer), + new(Native + "PluginType0Record", "Address", 8, 8, FieldKind.Integer), + new(Native + "PluginType0Record", "IsPointer", 16, 4, FieldKind.AbiBoolean), + new(Native + "PluginType0Record", "CountOffsets", 20, 4, FieldKind.Integer), + new(Native + "PluginType0Record", "Offsets", 24, 8, FieldKind.Pointer), + new(Native + "PluginType0Record", "Description", 32, 8, FieldKind.Pointer), + new(Native + "PluginType0Record", "ValueType", 40, 1, FieldKind.Integer), + new(Native + "PluginType0Record", "Size", 41, 1, FieldKind.Integer), + + new(Native + "RegisterModificationInfo", "Address", 0, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "ChangeEax", 8, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeEbx", 12, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeEcx", 16, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeEdx", 20, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeEsi", 24, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeEdi", 28, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeEbp", 32, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeEsp", 36, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeEip", 40, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeR8", 44, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeR9", 48, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeR10", 52, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeR11", 56, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeR12", 60, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeR13", 64, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeR14", 68, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeR15", 72, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeCf", 76, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangePf", 80, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeAf", 84, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeZf", 88, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeSf", 92, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "ChangeOf", 96, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "NewEax", 104, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewEbx", 112, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewEcx", 120, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewEdx", 128, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewEsi", 136, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewEdi", 144, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewEbp", 152, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewEsp", 160, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewEip", 168, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewR8", 176, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewR9", 184, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewR10", 192, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewR11", 200, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewR12", 208, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewR13", 216, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewR14", 224, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewR15", 232, 8, FieldKind.Integer), + new(Native + "RegisterModificationInfo", "NewCf", 240, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "NewPf", 244, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "NewAf", 248, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "NewZf", 252, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "NewSf", 256, 4, FieldKind.AbiBoolean), + new(Native + "RegisterModificationInfo", "NewOf", 260, 4, FieldKind.AbiBoolean), + + new(Native + "ClassicSlotObservation", "Slot", 0, 4, FieldKind.Integer), + new(Native + "ClassicSlotObservation", "RawValue", 8, 8, FieldKind.Integer), + + new(Native + "ClassicDebugEventDispatcher+DebugEventHeader", "EventCode", 0, 4, FieldKind.Integer), + new(Native + "ClassicDebugEventDispatcher+DebugEventHeader", "ProcessId", 4, 4, FieldKind.Integer), + new(Native + "ClassicDebugEventDispatcher+DebugEventHeader", "ThreadId", 8, 4, FieldKind.Integer) + ]; +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutGate.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutGate.cs new file mode 100644 index 00000000..35e83986 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutGate.cs @@ -0,0 +1,162 @@ +using System.Globalization; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace CheatEngine.SDK.Abi.Tests.Support; + +/// +/// The per-field layout gate behind FieldLayout.FieldLayoutContractTests: compares every instance field of a +/// structure with its literal (offset, width and kind) in both directions. +/// +/// +/// +/// Offset is measured, not computed: a applies the IL instruction +/// ldflda for the field to the address of a zeroed native buffer of the structure's size and subtracts the +/// buffer address. That is exactly the arithmetic the JIT performs for &record->Field in a plugin, it +/// works for private and compiler-generated fields, and it does not go through +/// , whose unmanaged view is what CA1421 warns about in an +/// assembly with runtime marshalling disabled. The field is never read or written. +/// FieldLayoutContractTests.Reflected_offsets_agree_with_address_of_offsets_for_the_packed_init_record_and_the_managed_exports +/// re-measures the four most important records with C# address-of arithmetic. +/// +/// +/// Width is for a data or function pointer and +/// otherwise. Kind is derived from the modified +/// field type, the only reflection view that still distinguishes a function pointer from a data pointer. +/// +/// Pure: no state, no I/O; the tests exercise it against wrong-on-purpose structures. +/// +internal static class FieldLayoutGate +{ + /// Every instance field, public or not, including compiler-generated backing fields. + public const BindingFlags InstanceFields = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + /// + /// Returns one message per violation of against the rows of + /// that name it: a field without a row, a row without a field, a duplicate row, + /// or a wrong offset, width or kind. Empty when the structure matches. + /// + public static IReadOnlyList FindViolations(Type structure, IReadOnlyList expectations) + { + ArgumentNullException.ThrowIfNull(structure); + ArgumentNullException.ThrowIfNull(expectations); + + string typeName = structure.FullName ?? structure.Name; + Dictionary rows = new(StringComparer.Ordinal); + List violations = []; + foreach (FieldLayoutRow row in expectations) + { + if (string.Equals(row.TypeFullName, typeName, StringComparison.Ordinal) && !rows.TryAdd(row.FieldName, row)) + { + violations.Add($"{typeName}.{row.FieldName}: duplicate layout row."); + } + } + + foreach (FieldInfo field in structure.GetFields(InstanceFields)) + { + if (!rows.Remove(field.Name, out FieldLayoutRow row)) + { + violations.Add($"{typeName}.{field.Name}: missing layout row for this field."); + continue; + } + + int offset = OffsetOf(field); + if (offset != row.Offset) + { + violations.Add($"{typeName}.{field.Name}: offset {Text(offset)}, expected {Text(row.Offset)}."); + } + + int width = WidthOf(field); + if (width != row.Width) + { + violations.Add($"{typeName}.{field.Name}: width {Text(width)}, expected {Text(row.Width)}."); + } + + FieldKind kind = KindOf(field); + if (kind != row.Kind) + { + violations.Add($"{typeName}.{field.Name}: kind {kind}, expected {row.Kind}."); + } + } + + foreach (string extra in rows.Keys.Order(StringComparer.Ordinal)) + { + violations.Add($"{typeName}.{extra}: extra layout row names no field of the structure."); + } + + return violations; + } + + /// Offset of in its declaring structure, see the class remarks. + public static unsafe int OffsetOf(FieldInfo field) + { + ArgumentNullException.ThrowIfNull(field); + Type declaringType = field.DeclaringType ?? throw new ArgumentException("A field has a declaring type.", nameof(field)); + if (!declaringType.IsValueType || field.IsStatic) + { + throw new ArgumentException("Only instance fields of structures have a layout offset.", nameof(field)); + } + + // (nint) &((T*) base)->Field - base, with the managed pointer converted before the subtraction. + DynamicMethod method = new("FieldOffset", typeof(nint), [typeof(nint)], typeof(FieldLayoutGate).Module, true); + ILGenerator il = method.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldflda, field); + il.Emit(OpCodes.Conv_U); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Sub); + il.Emit(OpCodes.Ret); + Func measure = method.CreateDelegate>(); + + nuint size = (nuint) Math.Max(1, RuntimeHelpers.SizeOf(declaringType.TypeHandle)); + void* buffer = NativeMemory.AllocZeroed(size); + try + { + return checked((int) measure((nint) buffer)); + } + finally + { + NativeMemory.Free(buffer); + } + } + + /// Byte width of : pointer size for any pointer, the managed size otherwise. + public static int WidthOf(FieldInfo field) + { + ArgumentNullException.ThrowIfNull(field); + Type plain = field.GetModifiedFieldType().UnderlyingSystemType; + return plain.IsPointer || plain.IsFunctionPointer + ? IntPtr.Size + : RuntimeHelpers.SizeOf(field.FieldType.TypeHandle); + } + + /// Classifies into a . + public static FieldKind KindOf(FieldInfo field) + { + ArgumentNullException.ThrowIfNull(field); + Type plain = field.GetModifiedFieldType().UnderlyingSystemType; + if (plain.IsFunctionPointer) + { + return FieldKind.FunctionPointer; + } + + if (plain.IsPointer) + { + return plain.GetElementType() == typeof(void) ? FieldKind.OpaquePointer : FieldKind.Pointer; + } + + if (plain == typeof(Bool32) || plain == typeof(Bool8)) + { + return FieldKind.AbiBoolean; + } + + return plain.IsPrimitive || plain.IsEnum ? FieldKind.Integer : FieldKind.Struct; + } + + private static string Text(int value) + { + return value.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutRow.cs b/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutRow.cs new file mode 100644 index 00000000..7715b140 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutRow.cs @@ -0,0 +1,9 @@ +namespace CheatEngine.SDK.Abi.Tests.Support; + +/// One literal expectation for one instance field of one ABI structure. +/// of the structure (nested types use +). +/// Metadata name of the instance field, including compiler-generated backing fields. +/// Byte offset of the field on x64. +/// Byte width of the field on x64 (a pointer or function pointer is 8). +/// What the field is; a retyped void* keeps its width but changes its kind. +internal readonly record struct FieldLayoutRow(string TypeFullName, string FieldName, int Offset, int Width, FieldKind Kind); diff --git a/tests/CheatEngine.SDK.Abi.Tests/packages.lock.json b/tests/CheatEngine.SDK.Abi.Tests/packages.lock.json new file mode 100644 index 00000000..0729acf9 --- /dev/null +++ b/tests/CheatEngine.SDK.Abi.Tests/packages.lock.json @@ -0,0 +1,214 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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.sdk.abi": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs index 59e14150..99ca6888 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs @@ -17,7 +17,7 @@ namespace CheatEngine.SDK.Analyzers.Tests.Architecture; public sealed class LuaDirectApiBoundaryGuardTests { private const string LuaApiQualifiedName = "CheatEngine.SDK.Lua.Interop.Api.LuaApi"; - private const string CataloguePath = "eng/lua-bridge/protected-operations.json"; + private const string CataloguePath = "libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json"; private const string LibrariesPath = "libs"; private const string RawApiPath = "libs/CheatEngine.SDK.Lua.Interop/Api/"; private const string LightCFunctionFastPathSourcePath = "libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs"; @@ -429,12 +429,12 @@ private static void AddViolations(List violations, LuaDirectApiP foreach (SyntaxNode node in root.DescendantNodes()) { if (node is not InvocationExpressionSyntax invocation || - !TryGetLuaApiMemberName(invocation, aliases, hasStaticLuaApiImport, hasLuaApiNamespaceImport, - shadowedNames, out string memberName) || - !policy.TryGet(memberName, out LuaDirectApiPolicyEntry entry) || - !entry.RequiresBridge || - entry.AllowedDirectly || - IsConditionallyAllowed(entry, invocation, path)) + !TryGetLuaApiMemberName(invocation, aliases, hasStaticLuaApiImport, hasLuaApiNamespaceImport, + shadowedNames, out string memberName) || + !policy.TryGet(memberName, out LuaDirectApiPolicyEntry entry) || + !entry.RequiresBridge || + entry.AllowedDirectly || + IsConditionallyAllowed(entry, invocation, path)) { continue; } @@ -450,7 +450,7 @@ private static HashSet CollectLuaApiAliases(CompilationUnitSyntax root) foreach (SyntaxNode node in root.DescendantNodesAndSelf()) { if (node is not UsingDirectiveSyntax directive || directive.Alias is null || directive.Name is null || - !IsExactLuaApiTypeName(directive.Name.ToString())) + !IsExactLuaApiTypeName(directive.Name.ToString())) { continue; } @@ -466,8 +466,8 @@ private static bool HasStaticLuaApiImport(CompilationUnitSyntax root) foreach (SyntaxNode node in root.DescendantNodesAndSelf()) { if (node is UsingDirectiveSyntax { Name: not null } directive && - directive.StaticKeyword.RawKind != 0 && - IsExactLuaApiTypeName(directive.Name.ToString())) + directive.StaticKeyword.RawKind != 0 && + IsExactLuaApiTypeName(directive.Name.ToString())) { return true; } @@ -481,8 +481,8 @@ private static bool HasLuaApiNamespaceImport(CompilationUnitSyntax root) foreach (SyntaxNode node in root.DescendantNodesAndSelf()) { if (node is UsingDirectiveSyntax { Alias: null, Name: not null } directive && - directive.StaticKeyword.RawKind == 0 && - IsLuaApiNamespaceName(directive.Name.ToString())) + directive.StaticKeyword.RawKind == 0 && + IsLuaApiNamespaceName(directive.Name.ToString())) { return true; } @@ -527,14 +527,14 @@ private static bool TryGetLuaApiMemberName(InvocationExpressionSyntax invocation switch (invocation.Expression) { case IdentifierNameSyntax identifier when hasStaticLuaApiImport && - !shadowedNames.Contains(identifier.Identifier.ValueText): + !shadowedNames.Contains(identifier.Identifier.ValueText): memberName = identifier.Identifier.ValueText; return true; case MemberAccessExpressionSyntax { Name: IdentifierNameSyntax name } memberAccess: string typeName = memberAccess.Expression.ToString(); if (IsExactLuaApiTypeName(typeName) || aliases.Contains(typeName) || - (hasLuaApiNamespaceImport && string.Equals(typeName, "LuaApi", StringComparison.Ordinal))) + (hasLuaApiNamespaceImport && string.Equals(typeName, "LuaApi", StringComparison.Ordinal))) { memberName = name.Identifier.ValueText; return true; @@ -551,15 +551,15 @@ private static bool IsConditionallyAllowed(LuaDirectApiPolicyEntry entry, Invoca string path) { if (!entry.ConditionalDirectUse || !entry.ConditionalAllowed || - !string.Equals(entry.MemberName, "lua_pushcclosure", StringComparison.Ordinal) || - !string.Equals(path, LightCFunctionFastPathSourcePath, StringComparison.Ordinal)) + !string.Equals(entry.MemberName, "lua_pushcclosure", StringComparison.Ordinal) || + !string.Equals(path, LightCFunctionFastPathSourcePath, StringComparison.Ordinal)) { return false; } SeparatedSyntaxList arguments = invocation.ArgumentList.Arguments; if (arguments.Count != 3 || !IsIdentifier(arguments[0].Expression, "Pointer") || - !IsIntegerZero(arguments[2].Expression)) + !IsIntegerZero(arguments[2].Expression)) { return false; } @@ -567,8 +567,8 @@ private static bool IsConditionallyAllowed(LuaDirectApiPolicyEntry entry, Invoca ExpressionStatementSyntax? pushStatement = invocation.FirstAncestorOrSelf(); MethodDeclarationSyntax? method = invocation.FirstAncestorOrSelf(); if (pushStatement is null || method is null || - !string.Equals(method.Identifier.ValueText, "PushUncheckedFunction", StringComparison.Ordinal) || - method.Body is null) + !string.Equals(method.Identifier.ValueText, "PushUncheckedFunction", StringComparison.Ordinal) || + method.Body is null) { return false; } @@ -590,20 +590,20 @@ private static bool IsConditionallyAllowed(LuaDirectApiPolicyEntry entry, Invoca private static bool IsImmediateOneSlotCheckStackGuard(StatementSyntax statement) { if (statement is not IfStatementSyntax condition || - !ContainsOnlyThrow(condition.Statement) || - condition.Condition is not BinaryExpressionSyntax equals || - !equals.IsKind(SyntaxKind.EqualsExpression) || - !IsIntegerZero(equals.Right)) + !ContainsOnlyThrow(condition.Statement) || + condition.Condition is not BinaryExpressionSyntax equals || + !equals.IsKind(SyntaxKind.EqualsExpression) || + !IsIntegerZero(equals.Right)) { return false; } if (equals.Left is not InvocationExpressionSyntax - { - Expression: IdentifierNameSyntax { Identifier.ValueText: "lua_checkstack" }, - ArgumentList.Arguments: var arguments - } || arguments.Count != 2 || !IsIdentifier(arguments[0].Expression, "Pointer") || - !IsIntegerOne(arguments[1].Expression)) + { + Expression: IdentifierNameSyntax { Identifier.ValueText: "lua_checkstack" }, + ArgumentList.Arguments: var arguments + } || arguments.Count != 2 || !IsIdentifier(arguments[0].Expression, "Pointer") || + !IsIntegerOne(arguments[1].Expression)) { return false; } @@ -614,7 +614,7 @@ condition.Condition is not BinaryExpressionSyntax equals || private static bool ContainsOnlyThrow(StatementSyntax statement) { return statement is ThrowStatementSyntax || - (statement is BlockSyntax { Statements.Count: 1 } block && block.Statements[0] is ThrowStatementSyntax); + (statement is BlockSyntax { Statements.Count: 1 } block && block.Statements[0] is ThrowStatementSyntax); } private static bool IsIntegerZero(ExpressionSyntax expression) @@ -662,7 +662,7 @@ private static bool IsIntegerOne(ExpressionSyntax expression) private static bool IsIdentifier(ExpressionSyntax expression, string identifier) { return expression is IdentifierNameSyntax name && - string.Equals(name.Identifier.ValueText, identifier, StringComparison.Ordinal); + string.Equals(name.Identifier.ValueText, identifier, StringComparison.Ordinal); } private static string[] ReadProvenance(JsonElement entry) @@ -691,7 +691,7 @@ private static string GetRepositoryPath(string sourcePath) private static bool IsGeneratedPath(string repositoryPath) { return repositoryPath.Contains("/bin/", StringComparison.Ordinal) || - repositoryPath.Contains("/obj/", StringComparison.Ordinal); + repositoryPath.Contains("/obj/", StringComparison.Ordinal); } private static bool IsExactLuaApiTypeName(string typeName) diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs index e490baa6..a8eec0b5 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs @@ -38,24 +38,31 @@ public void Catalog_contains_exactly_the_documented_identifiers() DiagnosticIds.InvalidManualBootstrap, DiagnosticIds.ReservedNamespace, DiagnosticIds.GeneratedEntryPointCollision, + DiagnosticIds.ClassicNativePluginExport, DiagnosticIds.RequiresPluginEnabledTooEarly, DiagnosticIds.DisposeBorrowedValue, DiagnosticIds.UnguardedUnmanagedCallersOnly, DiagnosticIds.AsyncPluginLifecycle, + DiagnosticIds.HostWidthPointerSize, DiagnosticIds.UnsafeBlocksRequired, DiagnosticIds.InvalidLuaBindingContainingType, DiagnosticIds.InvalidLuaFunction, DiagnosticIds.InvalidLuaGlobal, DiagnosticIds.DuplicateLuaName, DiagnosticIds.InvalidLuaAnnotationTarget, - DiagnosticIds.GeneratedLuaIdentityCollision + DiagnosticIds.GeneratedLuaIdentityCollision, + DiagnosticIds.NonTrailingOptionalLuaArgument, + DiagnosticIds.InvalidOptionalOrVariadicLuaResult, + DiagnosticIds.LookAlikeLuaContractType, + DiagnosticIds.UnsupportedLuaOptionalPosition ]; Assert.Equal(expected, SortedIds(AllDescriptors()), StringComparer.Ordinal); Assert.Equal( [ - "CESDK0001", "CESDK0002", "CESDK0003", "CESDK0004", "CESDK0005", "CESDK1001", "CESDK1003", "CESDK1004", - "CESDK1005", "CESDK2001", "CESDK2002", "CESDK2003", "CESDK2004", "CESDK2005", "CESDK2006", "CESDK2007" + "CESDK0001", "CESDK0002", "CESDK0003", "CESDK0004", "CESDK0005", "CESDK0006", "CESDK1001", "CESDK1003", + "CESDK1004", "CESDK1005", "CESDK1020", "CESDK2001", "CESDK2002", "CESDK2003", "CESDK2004", "CESDK2005", + "CESDK2006", "CESDK2007", "CESDK2010", "CESDK2011", "CESDK2012", "CESDK2013" ], expected, StringComparer.Ordinal); @@ -117,21 +124,23 @@ public void Release_tracking_rows_are_unique_across_shipped_and_unshipped_files( ]; foreach (string trackingFile in trackingFiles) - foreach (string line in File.ReadLines(trackingFile)) { - string[] cells = line.Split('|'); - if (cells.Length < 3) + foreach (string line in File.ReadLines(trackingFile)) { - continue; + string[] cells = line.Split('|'); + if (cells.Length < 3) + { + continue; + } + + string id = cells[0].Trim(); + if (!id.StartsWith("CESDK", StringComparison.Ordinal)) + { + continue; + } + + Assert.True(ids.Add(id), $"Release tracking contains duplicate diagnostic id '{id}'."); } - - string id = cells[0].Trim(); - if (!id.StartsWith("CESDK", StringComparison.Ordinal)) - { - continue; - } - - Assert.True(ids.Add(id), $"Release tracking contains duplicate diagnostic id '{id}'."); } Assert.Equal(SortedIds(AllDescriptors()), ids.Order(StringComparer.Ordinal), StringComparer.Ordinal); @@ -145,9 +154,9 @@ private static bool HasRow(string tracking, DiagnosticDescriptor descriptor) { string[] cells = line.Split('|'); if (cells.Length >= 3 - && string.Equals(cells[0].Trim(), descriptor.Id, StringComparison.Ordinal) - && string.Equals(cells[1].Trim(), descriptor.Category, StringComparison.Ordinal) - && string.Equals(cells[2].Trim(), descriptor.DefaultSeverity.ToString(), StringComparison.Ordinal)) + && string.Equals(cells[0].Trim(), descriptor.Id, StringComparison.Ordinal) + && string.Equals(cells[1].Trim(), descriptor.Category, StringComparison.Ordinal) + && string.Equals(cells[2].Trim(), descriptor.DefaultSeverity.ToString(), StringComparison.Ordinal)) { return true; } @@ -162,10 +171,12 @@ private static bool HasRow(string tracking, DiagnosticDescriptor descriptor) [InlineData(DiagnosticIds.InvalidManualBootstrap, true)] [InlineData(DiagnosticIds.ReservedNamespace, true)] [InlineData(DiagnosticIds.GeneratedEntryPointCollision, true)] + [InlineData(DiagnosticIds.ClassicNativePluginExport, false)] [InlineData(DiagnosticIds.RequiresPluginEnabledTooEarly, false)] [InlineData(DiagnosticIds.DisposeBorrowedValue, false)] [InlineData(DiagnosticIds.UnguardedUnmanagedCallersOnly, false)] [InlineData(DiagnosticIds.AsyncPluginLifecycle, false)] + [InlineData(DiagnosticIds.HostWidthPointerSize, false)] [InlineData(DiagnosticIds.UnsafeBlocksRequired, false)] [InlineData(DiagnosticIds.InvalidLuaBindingContainingType, false)] [InlineData(DiagnosticIds.InvalidLuaFunction, false)] @@ -173,6 +184,10 @@ private static bool HasRow(string tracking, DiagnosticDescriptor descriptor) [InlineData(DiagnosticIds.DuplicateLuaName, true)] [InlineData(DiagnosticIds.InvalidLuaAnnotationTarget, false)] [InlineData(DiagnosticIds.GeneratedLuaIdentityCollision, false)] + [InlineData(DiagnosticIds.NonTrailingOptionalLuaArgument, false)] + [InlineData(DiagnosticIds.InvalidOptionalOrVariadicLuaResult, false)] + [InlineData(DiagnosticIds.LookAlikeLuaContractType, false)] + [InlineData(DiagnosticIds.UnsupportedLuaOptionalPosition, false)] public void Compilation_end_tag_is_on_the_rules_reported_at_compilation_end(string id, bool compilationEnd) { Assert.Equal(compilationEnd, @@ -185,10 +200,12 @@ public void Every_descriptor_is_reported_by_exactly_one_analyzer() DiagnosticAnalyzer[] analyzers = [ new CheatEnginePluginAnalyzer(), + new ClassicNativeExportAnalyzer(), new UnmanagedCallersOnlyGuardAnalyzer(), new LuaBindingAnalyzer(), new PluginLifecycleAndOwnershipAnalyzer(), - new LuaObjectBindingAnalyzer() + new LuaObjectBindingAnalyzer(), + new HostWidthPointerSizeAnalyzer() ]; IEnumerable supported = SortedIds(analyzers.SelectMany(analyzer => analyzer.SupportedDiagnostics)); diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs index bffcf14c..fbb07768 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs @@ -36,6 +36,9 @@ namespace CheatEngine.SDK.Analyzers.Tests.Generation; public sealed class LuaBindingAnalyzerTests { private const string ShapeUsings = "using CheatEngine.SDK.Annotations.Lua;\nnamespace Demo;\n"; + + private const string OptionalUsings = + "using System;\nusing CheatEngine.SDK.Annotations.Lua;\nusing CheatEngine.SDK.Lua.Calls;\nusing CheatEngine.SDK.Lua.Marshalling;\nnamespace Demo;\n"; private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); // The real SDK assemblies the shape checks are written against, taken from the copies loaded in this test @@ -58,32 +61,32 @@ public sealed class LuaBindingAnalyzerTests { { "valid function", ShapeUsings + - "public static partial class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", + "public static partial class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", true }, { "instance method", ShapeUsings + - "public partial class Functions { [LuaFunction(\"add\")] public long Add(long a, long b) => a + b; }", + "public partial class Functions { [LuaFunction(\"add\")] public long Add(long a, long b) => a + b; }", false }, { "not partial container", ShapeUsings + - "public static class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", + "public static class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", false }, { "invalid lua name", ShapeUsings + - "public static partial class Functions { [LuaFunction(\"end\")] public static long Add(long a, long b) => a + b; }", + "public static partial class Functions { [LuaFunction(\"end\")] public static long Add(long a, long b) => a + b; }", false }, { "valid global try form", ShapeUsings + - "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial bool TryReadInt32(nuint address, out int value); }", + "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial bool TryReadInt32(nuint address, out int value); }", true }, { "global try form returning int instead of bool", ShapeUsings + - "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial int TryReadInt32(nuint address, out int value); }", + "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial int TryReadInt32(nuint address, out int value); }", false }, { @@ -103,6 +106,56 @@ public static partial class Memory } }; + // The LuaOptional, optional-result and variadic shapes (CESDK2010 to CESDK2013), kept apart from Shapes only for length. + public static TheoryData OptionalShapes => new() + { + { + "optional arguments of a global", OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"load\")] public static partial void Load(string path, LuaOptional merge, LuaOptional flags); }", + true + }, + { + "optional results of a global", OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"read\")] public static partial LuaOperationStatus Read(int mode, out long first, out LuaOptional second); }", + true + }, + { + "variadic outcome of a global", OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"seq\")] public static partial LuaOperationStatus Seq(int n, Span values, out int count); }", + true + }, + { + "optional parameter of a function", OptionalUsings + + "public static partial class Functions { [LuaFunction(\"f\")] public static int F(int a, LuaOptional b) => a; }", + true + }, + { + "optional argument before a required one", OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"g\")] public static partial int G(LuaOptional a, int b); }", + false + }, + { + "required result after an optional one", OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"g\")] public static partial bool TryG(out LuaOptional a, out int b); }", + false + }, + { + "variadic pair on the try form", OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"g\")] public static partial bool TryG(Span v, out int c); }", + false + }, + { + "optional nullable string", OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"g\")] public static partial int G(LuaOptional a); }", + false + }, + { + "optional throwing return", OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"g\")] public static partial LuaOptional G(int a); }", + false + } + }; + [Fact] public async Task Binding_without_allow_unsafe_reports_CESDK2001() { @@ -421,10 +474,10 @@ public static class Functions static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal)); Assert.Contains(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("reserved word", StringComparison.Ordinal)); + d.GetMessage(CultureInfo.InvariantCulture).Contains("reserved word", StringComparison.Ordinal)); Assert.Contains(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("default value", StringComparison.Ordinal)); + d.GetMessage(CultureInfo.InvariantCulture).Contains("default value", StringComparison.Ordinal)); } [Fact] @@ -462,15 +515,16 @@ public static partial class Globals ImmutableArray diagnostics = await GetDiagnosticsAsync(compilation); Assert.Contains(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture) - .Contains("parameter type", StringComparison.Ordinal)); + d.GetMessage(CultureInfo.InvariantCulture) + .Contains("parameter type", StringComparison.Ordinal)); Assert.Contains(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("argument type", StringComparison.Ordinal)); + d.GetMessage(CultureInfo.InvariantCulture).Contains("argument type", StringComparison.Ordinal)); } [Theory] [MemberData(nameof(Shapes))] + [MemberData(nameof(OptionalShapes))] public async Task Generator_and_analyzer_agree_on_every_shape(string shape, string source, bool expectedValid) { CSharpCompilation compilation = CreateCompilation(source, true); @@ -480,7 +534,11 @@ public async Task Generator_and_analyzer_agree_on_every_shape(string shape, stri bool analyzerReportsShapeProblem = diagnostics.Any(static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal) || string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) - || string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal)); + || string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal) + || string.Equals(d.Id, DiagnosticIds.NonTrailingOptionalLuaArgument, StringComparison.Ordinal) + || string.Equals(d.Id, DiagnosticIds.InvalidOptionalOrVariadicLuaResult, StringComparison.Ordinal) + || string.Equals(d.Id, DiagnosticIds.LookAlikeLuaContractType, StringComparison.Ordinal) + || string.Equals(d.Id, DiagnosticIds.UnsupportedLuaOptionalPosition, StringComparison.Ordinal)); Assert.True(generatorEmits == expectedValid, $"'{shape}': the generator {(generatorEmits ? "emitted" : "stayed silent")}, expected {(expectedValid ? "output" : "silence")}."); @@ -489,6 +547,207 @@ public async Task Generator_and_analyzer_agree_on_every_shape(string shape, stri $"'{shape}': the analyzer {(analyzerReportsShapeProblem ? "reported" : "stayed silent")} a shape problem, expected it to {(expectedValid ? "stay silent" : "report")}."); } + [Fact] + public async Task Non_trailing_optional_argument_reports_CESDK2010() + { + ImmutableArray diagnostics = await AnalyzeAsync(OptionalUsings + """ + public static partial class Bindings + { + [LuaGlobal("g")] + public static partial int G(LuaOptional a, int b); + + [LuaFunction("f")] + public static int F(LuaOptional a, long b) => 0; + } + """, true); + + Diagnostic[] reported = + [.. diagnostics.Where(static d => string.Equals(d.Id, DiagnosticIds.NonTrailingOptionalLuaArgument, StringComparison.Ordinal))]; + Assert.Equal(2, reported.Length); + Assert.Contains(reported, static d => d.GetMessage(CultureInfo.InvariantCulture).StartsWith("Lua binding 'G' must declare every LuaOptional argument after the required ones", StringComparison.Ordinal)); + Assert.Contains(reported, static d => d.GetMessage(CultureInfo.InvariantCulture).StartsWith("Lua binding 'F' must declare every LuaOptional parameter after the required ones", StringComparison.Ordinal)); + Assert.DoesNotContain(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal) + || string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal)); + } + + [Fact] + public async Task Required_result_after_an_optional_result_reports_CESDK2011() + { + ImmutableArray diagnostics = await AnalyzeAsync(OptionalUsings + + "public static partial class Bindings { [LuaGlobal(\"g\")] public static partial bool TryG(out LuaOptional a, out int b); }", + true); + + Diagnostic diagnostic = Assert.Single(diagnostics); + Assert.Equal(DiagnosticIds.InvalidOptionalOrVariadicLuaResult, diagnostic.Id); + Assert.Contains("after the required results", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task Variadic_pair_outside_the_outcome_form_reports_CESDK2011() + { + ImmutableArray diagnostics = await AnalyzeAsync(OptionalUsings + """ + public static partial class Bindings + { + [LuaGlobal("g")] + public static partial bool TryG(Span values, out int count); + + [LuaGlobal("h")] + public static partial LuaOperationStatus H(Span values, out int count, Span more, out int moreCount); + + [LuaGlobal("k")] + public static partial LuaOperationStatus K(Span values, out int count); + } + """, true); + + string[] messages = + [ + .. diagnostics + .Where(static d => string.Equals(d.Id, DiagnosticIds.InvalidOptionalOrVariadicLuaResult, StringComparison.Ordinal)) + .Select(static d => d.GetMessage(CultureInfo.InvariantCulture)) + ]; + Assert.Contains(messages, static m => m.StartsWith("Lua global binding 'TryG' must return LuaOperationStatus", StringComparison.Ordinal)); + Assert.Contains(messages, static m => m.StartsWith("Lua global binding 'H' must declare at most one variadic", StringComparison.Ordinal)); + Assert.Contains(messages, static m => m.StartsWith("Lua global binding 'H' must declare the variadic", StringComparison.Ordinal)); + Assert.Contains(messages, static m => m.StartsWith("Lua global binding 'K' must use int, long, float, double, bool or nuint", StringComparison.Ordinal)); + } + + [Fact] + public async Task Same_named_LuaOptional_from_source_reports_CESDK2012_and_the_generator_emits_nothing() + { + const string Source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Lua.Marshalling + { + public readonly struct LuaOptional + { + } + } + + namespace Demo + { + using CheatEngine.SDK.Lua.Marshalling; + + public static partial class Bindings + { + [LuaGlobal("g")] + public static partial int G(int a, LuaOptional b); + + [LuaFunction("f")] + public static int F(LuaOptional b) => 0; + } + } + """; + CSharpCompilation compilation = CreateCompilation(Source, true); + + Assert.False(RunGenerator(compilation)); + Diagnostic[] reported = + [ + .. (await GetDiagnosticsAsync(compilation)) + .Where(static d => string.Equals(d.Id, DiagnosticIds.LookAlikeLuaContractType, StringComparison.Ordinal)) + ]; + Assert.Equal(2, reported.Length); + Assert.All(reported, static d => Assert.Contains("not a same-named type", d.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal)); + } + + [Fact] + public async Task Same_named_LuaOperationStatus_from_source_reports_CESDK2012_instead_of_selecting_the_outcome_form() + { + const string Source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Lua.Calls + { + public readonly struct LuaOperationStatus + { + } + } + + namespace Demo + { + using CheatEngine.SDK.Lua.Calls; + + public static partial class Bindings + { + [LuaGlobal("readInteger")] + public static partial LuaOperationStatus TryReadInt32(nuint address, out int value); + } + } + """; + CSharpCompilation compilation = CreateCompilation(Source, true); + + Assert.False(RunGenerator(compilation)); + ImmutableArray diagnostics = await GetDiagnosticsAsync(compilation); + Diagnostic diagnostic = Assert.Single(diagnostics); + Assert.Equal(DiagnosticIds.LookAlikeLuaContractType, diagnostic.Id); + Assert.StartsWith("Lua binding 'TryReadInt32' must use the LuaOptional and LuaOperationStatus types of CheatEngine.SDK.Lua", + diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task LuaOptional_of_nullable_string_reports_CESDK2013() + { + ImmutableArray diagnostics = await AnalyzeAsync(OptionalUsings + """ + public static partial class Bindings + { + [LuaGlobal("g")] + public static partial int G(LuaOptional a); + + [LuaGlobal("h")] + public static partial bool TryH(out LuaOptional a); + + [LuaFunction("f")] + public static int F(LuaOptional> a) => 0; + } + """, true); + + Assert.Equal(["F", "G", "TryH"], + diagnostics.Where(static d => string.Equals(d.Id, DiagnosticIds.UnsupportedLuaOptionalPosition, StringComparison.Ordinal)) + .Select(static d => d.GetMessage(CultureInfo.InvariantCulture).Split('\'')[1]).Order(StringComparer.Ordinal), + StringComparer.Ordinal); + } + + [Fact] + public async Task LuaOptional_return_reports_CESDK2013() + { + ImmutableArray diagnostics = await AnalyzeAsync(OptionalUsings + """ + public static partial class Bindings + { + [LuaGlobal("g")] + public static partial LuaOptional G(int a); + + [LuaFunction("f")] + public static LuaOptional F(int a) => default; + } + """, true); + + Diagnostic[] reported = + [.. diagnostics.Where(static d => string.Equals(d.Id, DiagnosticIds.UnsupportedLuaOptionalPosition, StringComparison.Ordinal))]; + Assert.Equal(2, reported.Length); + Assert.Contains(reported, static d => d.GetMessage(CultureInfo.InvariantCulture).Contains("the throwing form cannot return one", StringComparison.Ordinal)); + Assert.Contains(reported, static d => d.GetMessage(CultureInfo.InvariantCulture).Contains("a thunk cannot return one", StringComparison.Ordinal)); + } + + [Fact] + public async Task Valid_optional_and_variadic_shapes_report_nothing() + { + ImmutableArray diagnostics = await AnalyzeAsync(OptionalUsings + """ + public static partial class Bindings + { + [LuaGlobal("load")] + public static partial void Load(string path, LuaOptional merge); + + [LuaGlobal("read")] + public static partial LuaOperationStatus Read(int mode, out long first, out LuaOptional second, Span rest, out int restCount); + + [LuaFunction("f")] + public static int F(int a, LuaOptional b) => a; + } + """, true); + + Assert.Empty(diagnostics); + } + private static CSharpCompilation CreateCompilation(string source, bool allowUnsafe) { return CreateCompilation(source, allowUnsafe, SdkReferences); diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaObjectBindingAnalyzerTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaObjectBindingAnalyzerTests.cs index db5218fb..0d621edf 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaObjectBindingAnalyzerTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaObjectBindingAnalyzerTests.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Globalization; +using CheatEngine.SDK.Analyzers.Diagnostics; using CheatEngine.SDK.Analyzers.Generation; using CheatEngine.SDK.Analyzers.Tests.Infrastructure; @@ -15,6 +16,14 @@ public sealed class LuaObjectBindingAnalyzerTests { private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14); + // The real SDK assemblies: LuaOptional is recognised only as the type CheatEngine.SDK.Lua defines. + private static readonly ImmutableArray RealSdkReferences = + [ + MetadataReference.CreateFromFile(typeof(CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(CheatEngine.SDK.Lua.Interop.Api.LuaApi).Assembly.Location), + MetadataReference.CreateFromFile(typeof(CheatEngine.SDK.Lua.State.LuaState).Assembly.Location) + ]; + [Fact] public async Task Invalid_lua_class_name_reserved_method_parameter_and_property_shape_report_CESDK2006() { @@ -162,10 +171,72 @@ public static partial class Globals [LuaGlobal("readResult")] static partial void ReadResult(int {|CESDK2007:__result|}); + + [LuaGlobal("readResolution")] + static partial void ReadResolution(int {|CESDK2007:__resolution|}); + + [LuaGlobal("readException")] + static partial void ReadException(int {|CESDK2007:__exception|}); + + [LuaGlobal("readArgc")] + static partial void ReadArgc(int {|CESDK2007:__argc|}); + + [LuaGlobal("readRest")] + static partial void ReadRest(int {|CESDK2007:__rest|}); } """); } + [Fact] + public async Task LuaOptional_on_a_lua_method_reports_CESDK2013_until_object_members_support_it() + { + const string Source = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Marshalling; + + namespace Demo; + + [LuaClass("Object")] + public readonly partial struct Handle + { + [LuaMethod("load")] + public partial void Load(int path, LuaOptional merge); + + [LuaMethod("read")] + public partial bool TryRead(out LuaOptional value); + + [LuaProperty("Value")] + public partial LuaOptional Value { get; } + + [LuaMethod("count")] + public partial int Count(int first); + } + """; + CSharpCompilation compilation = CSharpCompilation.Create( + "LuaObjectOptionalTestAssembly", + [ + CSharpSyntaxTree.ParseText(TestText.Normalize(Source), ParseOptions, "Test.cs", + cancellationToken: TestContext.Current.CancellationToken) + ], + LocalFrameworkReferences.References.AddRange(RealSdkReferences), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, allowUnsafe: true)); + + ImmutableArray diagnostics = await compilation + .WithAnalyzers([new LuaObjectBindingAnalyzer(), new LuaBindingAnalyzer()], options: null) + .GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(["Load", "TryRead", "Value"], + diagnostics.Where(static d => string.Equals(d.Id, DiagnosticIds.UnsupportedLuaOptionalPosition, StringComparison.Ordinal)) + .Select(static d => d.GetMessage(CultureInfo.InvariantCulture).Split('\'')[1]).Order(StringComparer.Ordinal), + StringComparer.Ordinal); + Assert.All(diagnostics.Where(static d => string.Equals(d.Id, DiagnosticIds.UnsupportedLuaOptionalPosition, StringComparison.Ordinal)), + static d => Assert.Contains("[LuaMethod] and [LuaProperty] members do not support optional values yet", + d.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal)); + // The optional position is explained once, by CESDK2013, not a second time as an unsupported type (CESDK2006). + Assert.DoesNotContain(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaAnnotationTarget, StringComparison.Ordinal)); + } + [Fact] public async Task Generated_handle_constructor_collision_reports_CESDK2007() { diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs index c0127865..ab0bfb7e 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs @@ -37,7 +37,8 @@ public static Task VerifyAsync( { CheatEngineSdkCodeFixTest test = new() { - CodeActionEquivalenceKey = equivalenceKey, NumberOfFixAllIterations = fixAllIterations + CodeActionEquivalenceKey = equivalenceKey, + NumberOfFixAllIterations = fixAllIterations }; // The default drops fixable ids from the markup of the fixed state, assuming a fix always removes them all. diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs index d681851c..503606c6 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -76,8 +76,8 @@ public static ImmutableArray FromTargetingPack() { string candidate = Path.Combine(pack, "ref", TargetFramework); if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out Version? version) - && (bestVersion is null || version > bestVersion)) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) { best = candidate; bestVersion = version; diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs index f3392bd5..d0653902 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs @@ -42,7 +42,7 @@ public void References_are_existing_local_files_and_include_the_core_facade() foreach (MetadataReference reference in LocalFrameworkReferences.References) { string path = Assert.IsType(reference, false).FilePath - ?? throw new InvalidOperationException("A framework reference without a file path."); + ?? throw new InvalidOperationException("A framework reference without a file path."); Assert.True(File.Exists(path), path); hasSystemRuntime |= string.Equals(Path.GetFileName(path), "System.Runtime.dll", StringComparison.OrdinalIgnoreCase); diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs index 7b8fe539..7262e163 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs @@ -20,8 +20,8 @@ public static string PathOf(string relativePath) private static string FindRoot() { for (DirectoryInfo? directory = new(AppContext.BaseDirectory); - directory is not null; - directory = directory.Parent) + directory is not null; + directory = directory.Parent) { if (File.Exists(Path.Combine(directory.FullName, SolutionFileName))) { diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/ClassicNativeExportAnalyzerTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/ClassicNativeExportAnalyzerTests.cs new file mode 100644 index 00000000..7acb26a2 --- /dev/null +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Plugin/ClassicNativeExportAnalyzerTests.cs @@ -0,0 +1,138 @@ +using CheatEngine.SDK.Abi.Native; +using CheatEngine.SDK.Analyzers.Diagnostics; +using CheatEngine.SDK.Analyzers.Plugin; +using CheatEngine.SDK.Analyzers.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +using Verifier = CheatEngine.SDK.Analyzers.Tests.Infrastructure.AnalyzerVerifier< + CheatEngine.SDK.Analyzers.Plugin.ClassicNativeExportAnalyzer>; + +namespace CheatEngine.SDK.Analyzers.Tests.Plugin; + +/// +/// CESDK0006: an [UnmanagedCallersOnly] export named CEPlugin_*, the classic native plugin entry +/// points that only a NativeAOT publication of the consumer's own assembly can produce, is flagged; that NativeAOT +/// plugin DLL route is not a supported CheatEngine.SDK profile (Cheat Engine unloads with FreeLibrary, audit +/// F02). Other entry points and the historical unprefixed names stay silent. +/// +public sealed class ClassicNativeExportAnalyzerTests +{ + [Theory] + [InlineData("CEPlugin_GetVersion")] + [InlineData("CEPlugin_InitializePlugin")] + [InlineData("CEPlugin_DisablePlugin")] + [InlineData("CEPlugin_Custom")] + [Trait("Qualification", "Q41")] + public async Task UnmanagedCallersOnly_entry_point_with_the_CEPlugin_prefix_reports_CESDK0006(string entryPoint) + { + await Verifier.VerifyAsync(Exports($$""" + [{|#0:UnmanagedCallersOnly(EntryPoint = "{{entryPoint}}", CallConvs = new[] { typeof(CallConvStdcall) })|}] + private static int Export(nint argument, int size) => 0; + """), + Verifier.Diagnostic(DiagnosticDescriptors.ClassicNativePluginExport).WithLocation(0) + .WithArguments("Export", entryPoint)); + } + + [Fact] + [Trait("Qualification", "Q41")] + public async Task NativeExportNames_constant_as_entry_point_reports_CESDK0006() + { + CheatEngineSdkAnalyzerTest test = new(); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(NativeExportNames).Assembly.Location)); + test.TestState.Sources.Add(("Test0.cs", TestText.Normalize(Exports(""" + [{|#0:UnmanagedCallersOnly(EntryPoint = NativeExportNames.GetVersion)|}] + private static int GetVersion(nint version, int size) => 0; + + [{|#1:UnmanagedCallersOnly(EntryPoint = NativeExportNames.InitializePlugin)|}] + private static int Initialize(nint exports, int pluginId) => 0; + + [{|#2:UnmanagedCallersOnly(EntryPoint = NativeExportNames.DisablePlugin)|}] + private static int Disable() => 0; + """, "using CheatEngine.SDK.Abi.Native;")))); + test.ExpectedDiagnostics.Add(Verifier.Diagnostic(DiagnosticDescriptors.ClassicNativePluginExport).WithLocation(0) + .WithArguments("GetVersion", NativeExportNames.GetVersion)); + test.ExpectedDiagnostics.Add(Verifier.Diagnostic(DiagnosticDescriptors.ClassicNativePluginExport).WithLocation(1) + .WithArguments("Initialize", NativeExportNames.InitializePlugin)); + test.ExpectedDiagnostics.Add(Verifier.Diagnostic(DiagnosticDescriptors.ClassicNativePluginExport).WithLocation(2) + .WithArguments("Disable", NativeExportNames.DisablePlugin)); + + await test.RunAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Local_function_export_with_the_CEPlugin_prefix_reports_CESDK0006() + { + await Verifier.VerifyAsync(Exports(""" + private static void Host() + { + [{|#0:UnmanagedCallersOnly(EntryPoint = "CEPlugin_Local")|}] + static int Local() => 0; + } + """), + Verifier.Diagnostic(DiagnosticDescriptors.ClassicNativePluginExport).WithLocation(0) + .WithArguments("Local", "CEPlugin_Local")); + } + + [Theory] + [InlineData("[UnmanagedCallersOnly]")] + [InlineData("[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]")] + [InlineData("[UnmanagedCallersOnly(EntryPoint = \"probe_name_query\")]")] + [InlineData("[UnmanagedCallersOnly(EntryPoint = \"ceplugin_GetVersion\")]")] + [InlineData("[UnmanagedCallersOnly(EntryPoint = \"MyCEPlugin_GetVersion\")]")] + [InlineData("[UnmanagedCallersOnly(EntryPoint = \"CEPlugin\")]")] + public async Task Other_entry_points_and_UnmanagedCallersOnly_without_entry_point_report_nothing(string attribute) + { + await Verifier.VerifyAsync(Exports($$""" + {{attribute}} + private static int Export(nint argument) => 0; + """)); + } + + [Theory] + [InlineData("GetVersion")] + [InlineData("InitializePlugin")] + [InlineData("DisablePlugin")] + public async Task Unprefixed_historical_names_report_nothing(string entryPoint) + { + await Verifier.VerifyAsync(Exports($$""" + [UnmanagedCallersOnly(EntryPoint = "{{entryPoint}}")] + private static int Export(nint argument) => 0; + """)); + } + + [Fact] + public async Task Project_without_a_cheatengine_sdk_reference_is_not_analysed() + { + await Verifier.VerifyWithoutCheatEngineSdkAsync(Exports(""" + [UnmanagedCallersOnly(EntryPoint = "CEPlugin_GetVersion")] + private static int Export(nint argument) => 0; + """)); + } + + [Fact] + public async Task Export_in_generated_code_is_not_analysed() + { + await Verifier.VerifyAsync([("Exports.g.cs", "// \n" + Exports(""" + [UnmanagedCallersOnly(EntryPoint = "CEPlugin_GetVersion")] + private static int Export(nint argument) => 0; + """))]); + } + + private static string Exports(string members, string extraUsing = "") + { + return $$""" + using System.Runtime.CompilerServices; + using System.Runtime.InteropServices; + {{extraUsing}} + + namespace Sample; + + public static class NativePluginExports + { + {{members}} + } + """; + } +} diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/README.md b/tests/CheatEngine.SDK.Analyzers.Tests/README.md index 47f28549..9e9f6548 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/README.md +++ b/tests/CheatEngine.SDK.Analyzers.Tests/README.md @@ -46,6 +46,9 @@ breaks a build. None of this needs Cheat Engine or a Lua DLL. package-consumer shape—stays silent rather than assigning bootstrap ownership. - CESDK0001 and CESDK1004 stay silent in generated code and in projects without a CheatEngine.SDK reference. - Every code fix, Fix All included, is compared with its expected source, and diagnostics that remain must be stated. +- CESDK2010 to CESDK2013 report a non-trailing optional argument, an invalid optional or variadic result, a look-alike + `LuaOptional` or `LuaOperationStatus`, and `LuaOptional` on `[LuaMethod]`/`[LuaProperty]`, each from one + analyzer only (`LuaBindingAnalyzerTests`, `LuaObjectBindingAnalyzerTests`, `DiagnosticCatalogTests`). ## Run the tests diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Usage/HostWidthPointerSizeAnalyzerTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Usage/HostWidthPointerSizeAnalyzerTests.cs new file mode 100644 index 00000000..acbb02bc --- /dev/null +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Usage/HostWidthPointerSizeAnalyzerTests.cs @@ -0,0 +1,218 @@ +using CheatEngine.SDK.Analyzers.Diagnostics; +using CheatEngine.SDK.Analyzers.Tests.Infrastructure; +using CheatEngine.SDK.Analyzers.Usage; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.CodeAnalysis.Testing; + +namespace CheatEngine.SDK.Analyzers.Tests.Usage; + +/// +/// CESDK1020: a PointerSize built from the plugin process width. The Engine type is supplied by an in-memory +/// assembly named CheatEngine.SDK.Engine (this test project does not reference the Engine), so the tests also +/// prove that the rule matches the defining assembly and ignores a lookalike. +/// +public sealed class HostWidthPointerSizeAnalyzerTests +{ + private const string EngineStubSource = """ + namespace CheatEngine.SDK.Engine.Runtime + { + public readonly struct PointerSize + { + public PointerSize(int bytes) { Bytes = bytes; } + public int Bytes { get; } + public static PointerSize Unknown => default; + public static PointerSize Bit32 => new PointerSize(4); + public static PointerSize Bit64 => new PointerSize(8); + } + } + """; + + private static readonly Lazy SEngineStub = new(() => Compile("CheatEngine.SDK.Engine")); + private static readonly Lazy SForeignStub = new(() => Compile("Foreign.Engine")); + + [Fact] + public async Task host_width_sources_passed_to_the_pointer_size_constructor_report_CESDK1020() + { + await VerifyAsync( + """ + using System; + using System.Runtime.CompilerServices; + using System.Runtime.InteropServices; + using CheatEngine.SDK.Engine.Runtime; + + namespace Demo; + + internal static unsafe class Widths + { + public static PointerSize[] All() => + [ + {|CESDK1020:new PointerSize(IntPtr.Size)|}, + {|CESDK1020:new PointerSize(UIntPtr.Size)|}, + {|CESDK1020:new PointerSize(nint.Size)|}, + {|CESDK1020:new PointerSize(nuint.Size)|}, + {|CESDK1020:new PointerSize((int) (long) IntPtr.Size)|}, + {|CESDK1020:new PointerSize(sizeof(nint))|}, + {|CESDK1020:new PointerSize(sizeof(nuint))|}, + {|CESDK1020:new PointerSize(sizeof(void*))|}, + {|CESDK1020:new PointerSize(sizeof(byte*))|}, + {|CESDK1020:new PointerSize(Unsafe.SizeOf())|}, + {|CESDK1020:new PointerSize(Unsafe.SizeOf())|}, + {|CESDK1020:new PointerSize(Marshal.SizeOf())|}, + ]; + } + """); + } + + [Fact] + public async Task host_width_conditionals_selecting_bit64_or_bit32_report_CESDK1020() + { + await VerifyAsync( + """ + using System; + using CheatEngine.SDK.Engine.Runtime; + + namespace Demo; + + internal static class Widths + { + public static PointerSize FromProcess() => + {|CESDK1020:Environment.Is64BitProcess ? PointerSize.Bit64 : PointerSize.Bit32|}; + + public static PointerSize FromIntPtr() => + {|CESDK1020:IntPtr.Size == 8 ? PointerSize.Bit64 : PointerSize.Bit32|}; + + public static PointerSize Reversed() => + {|CESDK1020:nint.Size != 8 ? PointerSize.Bit32 : PointerSize.Bit64|}; + } + """); + } + + [Fact] + public async Task the_message_names_the_host_width_expression() + { + await VerifyAsync( + """ + using System; + using CheatEngine.SDK.Engine.Runtime; + + namespace Demo; + + internal static class Widths + { + public static PointerSize Width() => {|#0:new PointerSize(IntPtr.Size)|}; + } + """, + new DiagnosticResult(DiagnosticDescriptors.HostWidthPointerSize).WithLocation(0).WithArguments("IntPtr.Size")); + } + + [Fact] + public async Task observed_widths_constants_host_address_uses_and_copied_values_stay_silent() + { + await VerifyAsync( + """ + using System; + using CheatEngine.SDK.Engine.Runtime; + + namespace Demo; + + internal static class Widths + { + public static PointerSize Constant() => new PointerSize(4); + + public static PointerSize Named() => PointerSize.Bit64; + + // No dataflow: a width copied into a local first is not followed. + public static PointerSize Copied() + { + int width = IntPtr.Size; + return new PointerSize(width); + } + + // The plugin width used for a host value is not a PointerSize. + public static string FormatHostAddress(nint address) => address.ToString(IntPtr.Size == 8 ? "X16" : "X8"); + + public static byte[] HostBuffer() => new byte[IntPtr.Size]; + + public static PointerSize FromObservation(bool is64Bit) => is64Bit ? PointerSize.Bit64 : PointerSize.Bit32; + + public static PointerSize NotAWidthChoice() => + Environment.Is64BitProcess ? PointerSize.Unknown : PointerSize.Bit32; + } + """); + } + + [Fact] + public async Task a_lookalike_pointer_size_outside_the_engine_assembly_stays_silent() + { + CheatEngineSdkAnalyzerTest source = new(); + source.TestState.Sources.Add(("Test0.cs", TestText.Normalize( + """ + using System; + + namespace CheatEngine.SDK.Engine.Runtime + { + public readonly struct PointerSize + { + public PointerSize(int bytes) { } + } + } + + namespace Demo + { + internal static class Widths + { + public static CheatEngine.SDK.Engine.Runtime.PointerSize Width() => + new CheatEngine.SDK.Engine.Runtime.PointerSize(IntPtr.Size); + } + } + """))); + await source.RunAsync(TestContext.Current.CancellationToken); + + CheatEngineSdkAnalyzerTest foreign = new(); + foreign.TestState.AdditionalReferences.Add(SForeignStub.Value); + foreign.TestState.Sources.Add(("Test0.cs", TestText.Normalize( + """ + using System; + using CheatEngine.SDK.Engine.Runtime; + + namespace Demo; + + internal static class Widths + { + public static PointerSize Width() => new PointerSize(IntPtr.Size); + } + """))); + await foreign.RunAsync(TestContext.Current.CancellationToken); + } + + private static Task VerifyAsync(string source, params DiagnosticResult[] expected) + { + CheatEngineSdkAnalyzerTest test = new(); + test.TestState.AdditionalReferences.Add(SEngineStub.Value); + test.TestState.Sources.Add(("Test0.cs", TestText.Normalize(source))); + test.SolutionTransforms.Add(static (solution, projectId) => + { + Project project = solution.GetProject(projectId)!; + CSharpCompilationOptions options = (CSharpCompilationOptions) project.CompilationOptions!; + return solution.WithProjectCompilationOptions(projectId, options.WithAllowUnsafe(true)); + }); + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(TestContext.Current.CancellationToken); + } + + private static PortableExecutableReference Compile(string assemblyName) + { + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName, + [CSharpSyntaxTree.ParseText(EngineStubSource, new CSharpParseOptions(LanguageVersion.CSharp14))], + LocalFrameworkReferences.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using MemoryStream image = new(); + EmitResult result = compilation.Emit(image); + Assert.True(result.Success, "The PointerSize stub did not compile:\n" + string.Join('\n', result.Diagnostics)); + return MetadataReference.CreateFromImage([.. image.ToArray()], filePath: assemblyName + ".dll"); + } +} diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/packages.lock.json b/tests/CheatEngine.SDK.Analyzers.Tests/packages.lock.json new file mode 100644 index 00000000..b3a73fef --- /dev/null +++ b/tests/CheatEngine.SDK.Analyzers.Tests/packages.lock.json @@ -0,0 +1,518 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Analyzer.Testing": { + "type": "Direct", + "requested": "[1.1.4, )", + "resolved": "1.1.4", + "contentHash": "7VhTi95r0MA4XtOHJZS7ZtLbjL8GGRjQYAa9h7gq2msyE9GaI9wOh/9Obw8ObPGIrGt0ii2KEWGILuwz9drcDQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzer.Testing": "[1.1.4]", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "1.0.1" + } + }, + "Microsoft.CodeAnalysis.CSharp.CodeFix.Testing": { + "type": "Direct", + "requested": "[1.1.4, )", + "resolved": "1.1.4", + "contentHash": "6UpQICoDkhXAUH1DL9we4iS/yNbs9OVanym45QeS5naiUzm5VJXXZnr+BUMd08hXwX4R4vSYi90QcmlkLOG8Gg==", + "dependencies": { + "Microsoft.CodeAnalysis.CSharp.Workspaces": "1.0.1", + "Microsoft.CodeAnalysis.CodeFix.Testing": "[1.1.4]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "D2zqK/k16fto0yMz0hcXMTkzOxEwMDJyA1mu/KXF9Befwz4zub3MpHQD8FeRxJtVSSsC3dQFYBw7zu7r/pfO7g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.CSharp": "[5.9.0]", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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]" + } + }, + "DiffPlex": { + "type": "Transitive", + "resolved": "1.7.2", + "contentHash": "qJEjdxEDBWSFZGB8paBB9HDeJXHGlHlOXeGX3kbTuXWuOsgv2iSAEOOzo5V1/B39Vcxr9IVVrNKewRcX+rsn4g==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "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.CodeAnalysis.Analyzer.Testing": { + "type": "Transitive", + "resolved": "1.1.4", + "contentHash": "IQoZbcXE7uVLngTtFFkSnh813ItZj4+nDfW7L0F1p9YaTlpefyL+poge3J1+eRSgreiFpBmTK4HI/kZhGJMVNg==", + "dependencies": { + "DiffPlex": "1.7.2", + "Microsoft.CodeAnalysis.Workspaces.Common": "1.0.1", + "Microsoft.VisualStudio.Composition": "16.1.8", + "NuGet.Common": "7.0.3", + "NuGet.Packaging": "7.0.3", + "NuGet.Protocol": "7.0.3", + "NuGet.Resolver": "7.0.3" + } + }, + "Microsoft.CodeAnalysis.CodeFix.Testing": { + "type": "Transitive", + "resolved": "1.1.4", + "contentHash": "sOvisb7+jr8/vVku7vpfExqjzD8QyRlbWf6moypgQFaM85d6E/lOkDKNn+iCOIitgadboWEBa+4odt8KYvmteg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzer.Testing": "[1.1.4]", + "Microsoft.CodeAnalysis.Workspaces.Common": "1.0.1" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "1A6jz50NG4nOEW8tX5+h+MyHqjWL0mPGwrUdwu+OlTfyknLo0GfxSqj4zEks8uVUdHdo9v8Ir9dHxACf8iYNEA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.VisualStudio.Composition": { + "type": "Transitive", + "resolved": "16.1.8", + "contentHash": "N+thv3dcT7kjn0Xz3U0uBm2CH4uoaMvH8wC6Gy2HWx7HLNdEpqGoMraLyoBdizmypD1owLCJQIa2uKmWe4/o8A==", + "dependencies": { + "Microsoft.VisualStudio.Composition.NetFxAttributes": "16.1.8", + "Microsoft.VisualStudio.Validation": "15.0.82", + "System.Composition": "1.0.31" + } + }, + "Microsoft.VisualStudio.Composition.NetFxAttributes": { + "type": "Transitive", + "resolved": "16.1.8", + "contentHash": "EbwZWTvdzit68qZSuTI8nd1PZ87pYjhpCwtsis8lrUKJ7XLdbE5rxY6YrY7OFze+YUsguzqZlNjX4Yn5nL9qBw==", + "dependencies": { + "System.ComponentModel.Composition": "4.5.0" + } + }, + "Microsoft.VisualStudio.Validation": { + "type": "Transitive", + "resolved": "15.0.82", + "contentHash": "XwZyVCsHuEtnd6nYScJnA8XkXPzy4Ok0DV5/hqqAe5ccgOhJ6yap7Qh/sU/i6QxEzuYyECPYDQ7IOyEQ3yRQgQ==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Common": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "vFBP1TkmeFxUjOqlGn7M3QbZfsg+hyFcCUFy8hPCdmk9VPhFQPwUMH4zKZnd8Bf8x89CFnpRXb8EC6tmWfDgNA==", + "dependencies": { + "NuGet.Frameworks": "7.0.3" + } + }, + "NuGet.Configuration": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "DZC5/eP6X0qDU4FK1X318nT9gBmjbBOQxphEFnn67B5kKi1/ODRANGOlnNn0tUkZDFtPtLl7gC6bfnf/cM7j5Q==", + "dependencies": { + "NuGet.Common": "7.0.3", + "System.Security.Cryptography.ProtectedData": "9.0.6" + } + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "JsV24AwAS93mQhlJr1Fx9zGHO6dVkexYp8ZZoDuoIBLyN9KwPEZOqo2SJVq0k2EPYO2oomj96Ue2yrO0hMOHrw==" + }, + "NuGet.Packaging": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "eMdAeZU4ugSC5WwO2u9CcY+jBjt/E6YunQ25oO6M5mM5A5+5QM1AdfoZPtFfwCuIgb/tbY52ciGOAXpBAqLVDA==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "NuGet.Configuration": "7.0.3", + "NuGet.Versioning": "7.0.3", + "System.Security.Cryptography.Pkcs": "9.0.6" + } + }, + "NuGet.Protocol": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "B9BwrQ0sOsSuCFijIL1LmX/hVVIhDf1WmI86neWak1yVvQ2r13TuFbMok6LXUM5htU8890XinB5ouE5hMHaxTA==", + "dependencies": { + "NuGet.Packaging": "7.0.3" + } + }, + "NuGet.Resolver": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "3L5FNJmHSBhHQms7YKrrMjPn5Q1iTgi0Ws0nyYN0cPeGKpA4p4Ey3X9oWWqz9FspTI7IqiPEu79NB7ipBYafvw==", + "dependencies": { + "NuGet.Protocol": "7.0.3" + } + }, + "NuGet.Versioning": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "jwZmJzau0kUTxUvtmjbEXyvhx0ui9wGPByDT8qV3qDSVK9rChgsSeZqb1Bybu258G9urdEcE9HqZCdRbi7h9BA==" + }, + "System.ComponentModel.Composition": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "+iB9FoZnfdqMEGq6np28X6YNSUrse16CakmIhV3h6PxEWt7jYxUN3Txs1D8MZhhf4QmyvK0F/EcIN0f4gGN0dA==", + "dependencies": { + "System.Security.Permissions": "4.5.0" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DJkqaWQfho/ReTzKcZD3zJJ6K4GcS154k+T0UCPMBNIOZ2U/lNpyiiWZ6Etw0onWyTH1K+yhICsdmwA5xy2aPQ==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Convention": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1", + "System.Composition.TypedParts": "10.0.1" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "mRxYvpCVPAeuLEk0c0kxWJVjbW1/HUoxCgYotOj9eDeQiYcTDOMdCQApsTrHYMN3pHBA8WoF00KGolG632Etaw==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "dYynUByfVBzYDheNPGxS8UN8AvG/4tXf/coSs1odHOyoh4etv1kad/FrLWLMq4f8NO49NV20Xu+0/y613woTUA==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4UGmyBdKWEN1nkqspJlji/nV7XIVm6KGlOC2So0mtM/gKvaNgLz+tUkcbY+6Zpr7dr6ohX1S5yl0RLID5otRHw==", + "dependencies": { + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "TVfys1gnUIhmXuYfFzyez0fOkDyELe9UwlxYeVlq6FmqmWmt1ouF0OQJ+6ozkHbkaop7uBUaXw7Qb+/o0m+nMg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "koSfjkdQZfgQr3SyiSIBboDn+GiR0vZ3x9Uek9FJbXK0w5AiATV8KrnMEP8B8OAlO+Y3zQf0CPCNzwH+VIYDKg==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "Gny8p2mX0jc5rjh+PA4Gx5GG66sj2C+e+ro7+j/3IsKT/bmQ84tGRV+XKaG+5/CTCdwkSSKDWEQ1rJd0J5jE0Q==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "yErfw/3pZkJE/VKza/Cm5idTpIKOy/vsmVi59Ta5SruPVtubzxb8CtnE8tyUpzs5pr0Y28GUFfSVzAhCLN3F/Q==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "9gdyuARhUR7H+p5CjyUB/zPk7/Xut3wUSP8NJQB6iZr8L3XUXTMdoLeVAg9N4rqF8oIpE7MpdqHdDHQ7XgJe0g==" + }, + "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.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.analyzers": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.SourceGenerators.Shared": "[1.0.0, )" + } + }, + "cheatengine.sdk.analyzers.codefixes": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Analyzers": "[1.0.0, )" + } + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.sourcegenerators.entrypoint": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.SourceGenerators.Shared": "[1.0.0, )" + } + }, + "cheatengine.sdk.sourcegenerators.luabindings": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.SourceGenerators.Shared": "[1.0.0, )" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.AotProbe/Program.cs b/tests/CheatEngine.SDK.AotProbe/Program.cs index a303097a..d9d63f02 100644 --- a/tests/CheatEngine.SDK.AotProbe/Program.cs +++ b/tests/CheatEngine.SDK.AotProbe/Program.cs @@ -1,8 +1,9 @@ using System.Diagnostics.CodeAnalysis; -using CheatEngine.SDK.Engine.Assembly; -using CheatEngine.SDK.Engine.Runtime; + using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Assembly; using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Lua.Callbacks; @@ -13,35 +14,39 @@ namespace CheatEngine.SDK.AotProbe; internal static class Program { - public static void Main() - { - Console.WriteLine($"CheatEngine.SDK Native AOT publication probe: {ProbeRepresentativePaths()}, {typeof(PluginHost).Assembly.GetName().Name}"); - } - - [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(LuaCallback))] - private static string ProbeRepresentativePaths() - { - if (!Address.TryParse("140001000", out Address address) || address.ToUInt64() != 0x140001000) - throw new InvalidOperationException("Address probe failed."); - - LuaStatus status = KeepGenericPath(LuaStatus.Ok); - _ = typeof(LuaCallback); - LuaRegistrationCollisionPolicy collisionPolicy = KeepGenericPath(LuaRegistrationCollisionPolicy.RejectExisting); - _ = typeof(LuaRegistrationSet); - _ = typeof(LuaRegistrationLease); - if (!InstructionProfile.X64.IsValid || InstructionProfile.X86.AddressWidth != PointerSize.Bit32) - throw new InvalidOperationException("Instruction profile probe failed."); - - _ = default(InstructionDisassembly); - _ = default(InstructionTargetProfile); - _ = typeof(AddressListMutations); - _ = typeof(MemoryRecordMutationOutcome); - _ = typeof(SymbolRegistrationLease); - _ = typeof(SymbolRegistrationReleaseOutcome); - return $"{typeof(Address).Assembly.GetName().Name}, {status}, {collisionPolicy}, {InstructionOperationStatus.Success}"; - } - - private static T KeepGenericPath(T value) where T : struct => value; - - private sealed class ProbeState; + public static void Main() + { + Console.WriteLine($"CheatEngine.SDK Native AOT publication probe: {ProbeRepresentativePaths()}, {typeof(PluginHost).Assembly.GetName().Name}"); + } + + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(LuaCallback))] + private static string ProbeRepresentativePaths() + { + if (!Address.TryParse("140001000", out Address address) || address.ToUInt64() != 0x140001000) + { + throw new InvalidOperationException("Address probe failed."); + } + + LuaStatus status = KeepGenericPath(LuaStatus.Ok); + _ = typeof(LuaCallback); + LuaRegistrationCollisionPolicy collisionPolicy = KeepGenericPath(LuaRegistrationCollisionPolicy.RejectExisting); + _ = typeof(LuaRegistrationSet); + _ = typeof(LuaRegistrationLease); + if (!InstructionProfile.X64.IsValid || InstructionProfile.X86.AddressWidth != PointerSize.Bit32) + { + throw new InvalidOperationException("Instruction profile probe failed."); + } + + _ = default(InstructionDisassembly); + _ = default(InstructionTargetProfile); + _ = typeof(AddressListMutations); + _ = typeof(MemoryRecordMutationOutcome); + _ = typeof(SymbolRegistrationLease); + _ = typeof(SymbolRegistrationReleaseOutcome); + return $"{typeof(Address).Assembly.GetName().Name}, {status}, {collisionPolicy}, {InstructionOperationStatus.Success}"; + } + + private static T KeepGenericPath(T value) where T : struct => value; + + private sealed class ProbeState; } diff --git a/tests/CheatEngine.SDK.AotProbe/packages.lock.json b/tests/CheatEngine.SDK.AotProbe/packages.lock.json new file mode 100644 index 00000000..3b2f244a --- /dev/null +++ b/tests/CheatEngine.SDK.AotProbe/packages.lock.json @@ -0,0 +1,80 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.engine": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + }, + "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==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs index b5d0e195..f7068a7c 100644 --- a/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs @@ -64,7 +64,7 @@ public void Setup() if (!defined.IsOk) { throw new InvalidOperationException("Defining cheatengine_sdk_bench_loop failed: " + - LuaError.FromStack(_l, defined)); + LuaError.FromStack(_l, defined)); } } diff --git a/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs index dc3560c6..a2944adf 100644 --- a/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs @@ -183,7 +183,7 @@ private BenchmarkAdditionalText Second() private BenchmarkAdditionalText EditedFirst() { return _editedFirstSpec ?? - throw new InvalidOperationException("Benchmark setup did not create the edited spec."); + throw new InvalidOperationException("Benchmark setup did not create the edited spec."); } private GeneratorDriver WarmDriver() diff --git a/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs index 394c262d..b239430e 100644 --- a/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs @@ -37,7 +37,7 @@ public void Setup() if (!defined.IsOk) { throw new InvalidOperationException("Defining cheatengine_sdk_bench_add failed: " + - LuaError.FromStack(l, defined)); + LuaError.FromStack(l, defined)); } } diff --git a/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs index 815c97b1..d3c4fedc 100644 --- a/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs @@ -81,7 +81,7 @@ public void Setup() if (!defined.IsOk) { throw new InvalidOperationException("Defining scalar-memory fixture globals failed: " + - LuaError.FromStack(state, defined)); + LuaError.FromStack(state, defined)); } if (!TargetMemory.TryWriteInt32(Address32, -42, out _)) @@ -95,15 +95,15 @@ public void Setup() } if (!HostMemory.TryWriteInt32(HostAddress32, -42, out _) || - !HostMemory.TryWriteInt64(HostAddress64, 0x1_0000_0000L, out _)) + !HostMemory.TryWriteInt64(HostAddress64, 0x1_0000_0000L, out _)) { throw new InvalidOperationException("Warming local scalar writes failed."); } if (!TargetMemory.TryReadInt32(Address32, out _, out _) || - !TargetMemory.TryReadInt64(Address64, out _, out _) || - !HostMemory.TryReadInt32(HostAddress32, out _, out _) || - !HostMemory.TryReadInt64(HostAddress64, out _, out _)) + !TargetMemory.TryReadInt64(Address64, out _, out _) || + !HostMemory.TryReadInt32(HostAddress32, out _, out _) || + !HostMemory.TryReadInt64(HostAddress64, out _, out _)) { throw new InvalidOperationException("Warming scalar reads failed."); } diff --git a/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs b/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs index 71d2a5f9..352cea17 100644 --- a/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs +++ b/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs @@ -72,7 +72,7 @@ private static void InstallMetatable(LuaState state) if (!status.IsOk) { throw new InvalidOperationException("Installing the fake host metatable failed: " + - LuaError.FromStack(state, status)); + LuaError.FromStack(state, status)); } state.RawSetPointer(LuaState.RegistryIndex, s_metatableKey); diff --git a/tests/CheatEngine.SDK.Benchmarks/packages.lock.json b/tests/CheatEngine.SDK.Benchmarks/packages.lock.json new file mode 100644 index 00000000..9614c691 --- /dev/null +++ b/tests/CheatEngine.SDK.Benchmarks/packages.lock.json @@ -0,0 +1,221 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "BenchmarkDotNet": { + "type": "Direct", + "requested": "[0.15.8, )", + "resolved": "0.15.8", + "contentHash": "paCfrWxSeHqn3rUZc0spYXVFnHCF0nzRhG0nOLnyTjZYs8spsimBaaNmb3vwqvALKIplbYq/TF393vYiYSnh/Q==", + "dependencies": { + "BenchmarkDotNet.Annotations": "0.15.8", + "CommandLineParser": "2.9.1", + "Gee.External.Capstone": "2.3.0", + "Iced": "1.21.0", + "Microsoft.CodeAnalysis.CSharp": "4.14.0", + "Microsoft.Diagnostics.Runtime": "3.1.512801", + "Microsoft.Diagnostics.Tracing.TraceEvent": "3.1.21", + "Microsoft.DotNet.PlatformAbstractions": "3.1.6", + "Perfolizer": "[0.6.1]", + "System.Management": "9.0.5" + } + }, + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "BenchmarkDotNet.Annotations": { + "type": "Transitive", + "resolved": "0.15.8", + "contentHash": "hfucY0ycAsB0SsoaZcaAp9oq5wlWBJcylvEJb9pmvdYUx6PD6S4mDiYnZWjdjAlLhIpe/xtGCwzORfzAzPqvzA==" + }, + "CommandLineParser": { + "type": "Transitive", + "resolved": "2.9.1", + "contentHash": "OE0sl1/sQ37bjVsPKKtwQlWDgqaxWgtme3xZz7JssWUzg5JpMIyHgCTY9MVMxOg48fJ1AgGT3tgdH5m/kQ5xhA==" + }, + "Gee.External.Capstone": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "2ap/rYmjtzCOT8hxrnEW/QeiOt+paD8iRrIcdKX0cxVwWLFa1e+JDBNeECakmccXrSFeBQuu5AV8SNkipFMMMw==" + }, + "Iced": { + "type": "Transitive", + "resolved": "1.21.0", + "contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + } + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.510501", + "contentHash": "juoqJYMDs+lRrrZyOkXXMImJHneCF23cuvO4waFRd2Ds7j+ZuGIPbJm0Y/zz34BdeaGiiwGWraMUlln05W1PCQ==", + "dependencies": { + "Microsoft.Extensions.Logging": "6.0.0" + } + }, + "Microsoft.Diagnostics.Runtime": { + "type": "Transitive", + "resolved": "3.1.512801", + "contentHash": "0lMUDr2oxNZa28D6NH5BuSQEe5T9tZziIkvkD44YkkCGQXPJqvFjLq5ZQq1hYLl3RjQJrY+hR0jFgap+EWPDTw==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.410101" + } + }, + "Microsoft.Diagnostics.Tracing.TraceEvent": { + "type": "Transitive", + "resolved": "3.1.21", + "contentHash": "/OrJFKaojSR6TkUKtwh8/qA9XWNtxLrXMqvEb89dBSKCWjaGVTbKMYodIUgF5deCEtmd6GXuRerciXGl5bhZ7Q==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.510501", + "System.Reflection.TypeExtensions": "4.7.0" + } + }, + "Microsoft.DotNet.PlatformAbstractions": { + "type": "Transitive", + "resolved": "3.1.6", + "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "k6PWQMuoBDGGHOQTtyois2u4AwyVcIwL2LaSLlTZQm2CYcJ1pxbt6jfAnpWmzENA/wfrYRI/X9DTLoUkE4AsLw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "xlzi2IYREJH3/m6+lUrQlujzX8wDitm4QGnUu6kUXTQAWPuZY8i+ticFJbzfqaetLA6KR/rO6Ew/HuYD+bxifg==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "eIbyj40QDg1NDz0HBW0S5f3wrLVnKWnDJ/JtZ+yJDFnDj90VoPuoPmFkeaXrtu+0cKm5GRAwoDf+dBWXK0TUdg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "6.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Microsoft.Extensions.Options": "6.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/HggWBbTwy8TgebGSX5DBZ24ndhzi93sHUBDvP1IxbZD7FDokYzdAr6+vbWGjw2XAfR2EJ1sfKUotpjHnFWPxA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "dzXN0+V1AyjOe2xcJ86Qbo233KHuLEY0njf/P2Kw8SfJU+d45HNS2ctJdnEnrWbM9Ye2eFgaC5Mj9otRMU6IsQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", + "Microsoft.Extensions.Primitives": "6.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "9+PnzmQFfEFNR9J2aDTfJGGupShHjOuGw4VUv+JB044biSHrnmCIMD+mJHmb2H7YryrfBEXDurxQ47gJZdCKNQ==" + }, + "Perfolizer": { + "type": "Transitive", + "resolved": "0.6.1", + "contentHash": "CR1QmWg4XYBd1Pb7WseP+sDmV8nGPwvmowKynExTqr3OuckIGVMhvmN4LC5PGzfXqDlR295+hz/T7syA1CxEqA==", + "dependencies": { + "Pragmastat": "3.2.4" + } + }, + "Pragmastat": { + "type": "Transitive", + "resolved": "3.2.4", + "contentHash": "I5qFifWw/gaTQT52MhzjZpkm/JPlfjSeO/DTZJjO7+hTKI+0aGRgOgZ3NN6D96dDuuqbIAZSeA5RimtHjqrA2A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "9.0.5", + "contentHash": "cuzLM2MWutf9ZBEMPYYfd0DXwYdvntp7VCT6a/wvbKCa2ZuvGmW74xi+YBa2mrfEieAXqM4TNKlMmSnfAfpUoQ==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "9.0.5", + "contentHash": "n6o9PZm9p25+zAzC3/48K0oHnaPKTInRrxqFq1fi/5TPbMLjuoCm/h//mS3cUmSy+9AO1Z+qsC/Ilt/ZFatv5Q==", + "dependencies": { + "System.CodeDom": "9.0.5" + } + }, + "System.Reflection.TypeExtensions": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "VybpaOQQhqE6siHppMktjfGBw1GCwvCqiufqmP8F1nj7fTUNtW35LOEt3UZTEsECfo+ELAl/9o9nJx3U91i7vA==" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.engine": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.sourcegenerators.engineapi": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.SourceGenerators.Shared": "[1.0.0, )" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + }, + "cheatengine.sdk.tests.shared": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListExitTests.cs b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListExitTests.cs new file mode 100644 index 00000000..af98277a --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListExitTests.cs @@ -0,0 +1,182 @@ +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Tables; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.SDK.Engine.Tests.AddressList; + +/// +/// The exit battery of audit chapter 14 for address-list records: identifiers after a reload, index bounds, symbolic +/// addresses, destroyed parents, double deletes, mutations during a table load, and the runtime identity. +/// +[Trait("Category", "NativeLua")] +public sealed class AddressListExitTests +{ + [Fact] + [Trait("Qualification", "Q34")] + public void Record_id_is_not_found_after_a_table_reload() + { + using AddressListTestHost host = new(); + CEObject before = host.AddRecord(1); + MemoryRecord borrowed = new(before); + + // A reload replaces every record: the old identifier is gone. + host.Execute("records = {}"); + MemoryRecordMutationOutcome missing = AddressListMutations.Delete(new MemoryRecordId(1)); + MemoryRecordActivationOutcome notActivated = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordMutationProblem.RecordNotFound, missing.Problem); + Assert.Equal(MemoryRecordMutationProblem.RecordNotFound, notActivated.Problem); + + // The reloaded table reuses the identifier for a new record: a command resolves the new record, never the + // old borrowed handle. + CEObject after = host.AddRecord(1); + MemoryRecordMutationOutcome deleted = AddressListMutations.Delete(new MemoryRecordId(1)); + + Assert.True(deleted.IsCompleted); + host.Execute("assert(delete_calls == 1)"); + FakeHost.RunOnObject(host.State, after, "assert(o.gone == true)"); + FakeHost.RunOnObject(host.State, before, "assert(o.gone == nil)"); + Assert.NotEqual(new MemoryRecord(after), borrowed); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void Child_index_out_of_bounds_returns_false_without_a_lua_error() + { + using AddressListTestHost host = new(); + CEObject child = host.AddRecord(2); + CEObject parent = host.AddRecord(1); + FakeHost.SetGlobalObject(host.State, "only_child", child); + FakeHost.RunOnObject(host.State, parent, "o.items[1] = only_child"); + MemoryRecord record = new(parent); + + Assert.True(record.TryGetChild(0, out MemoryRecord first)); + Assert.Equal(new MemoryRecord(child), first); + Assert.False(record.TryGetChild(1, out MemoryRecord missing)); + Assert.True(missing.IsNull); + Assert.False(record.TryGetChild(int.MaxValue, out _)); + Assert.Throws(() => record.TryGetChild(-1, out _)); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void Symbolic_address_expression_stays_distinct_from_the_resolved_current_address() + { + using AddressListTestHost host = new(); + CEObject handle = host.AddRecord(1, extra: """ + o.props.Address = "game.exe+10" + o.getters.getCurrentAddress = function() return function() return 0x140000010 end end + """); + MemoryRecord record = new(handle); + + Assert.True(record.TryGetAddressExpression(out string? expression)); + Assert.True(record.TryGetCurrentAddress(out Address current)); + + Assert.Equal("game.exe+10", expression); + Assert.Equal(new Address(0x140000010), current); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void Set_parent_to_a_deleted_parent_reports_parent_not_found() + { + using AddressListTestHost host = new(); + CEObject child = host.AddRecord(1); + host.AddRecord(2); + + Assert.True(AddressListMutations.Delete(new MemoryRecordId(2)).IsCompleted); + MemoryRecordMutationOutcome outcome = + AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2)); + + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, outcome.Effect); + Assert.Equal(MemoryRecordMutationProblem.ParentNotFound, outcome.Problem); + FakeHost.RunOnObject(host.State, child, "assert(o.props.Parent == nil)"); + Assert.Equal(0, host.State.Top); + } + + [Fact] + [Trait("Qualification", "Q34")] + public void Double_delete_reports_not_found_and_destroys_once() + { + using AddressListTestHost host = new(); + host.AddRecord(1); + + MemoryRecordMutationOutcome first = AddressListMutations.Delete(new MemoryRecordId(1)); + MemoryRecordMutationOutcome second = AddressListMutations.Delete(new MemoryRecordId(1)); + + Assert.True(first.IsCompleted); + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, second.Effect); + Assert.Equal(MemoryRecordMutationProblem.RecordNotFound, second.Problem); + Assert.Equal(1, host.ReadInteger("delete_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void Mutation_during_a_table_load_is_refused() + { + using AddressListTestHost host = new(); + host.AddRecord(1); + host.AddRecord(2); + List mutations = []; + List activations = []; + using (FakeHost.InstallReentrantHook(host.State, "table_script", () => + { + mutations.Add(AddressListMutations.Delete(new MemoryRecordId(1))); + mutations.Add(AddressListMutations.SetParent(new MemoryRecordId(1), new MemoryRecordId(2))); + activations.Add(AddressListMutations.SetActive(new MemoryRecordId(1), true)); + })) + { + host.Execute("loadTable = function(path, merge) table_script() end"); + + LuaOperationStatus load = CheatTableFiles.TryLoad("scripted.ct", false); + + Assert.True(load.IsSuccess); + } + + Assert.Null(FakeHost.TakeReentrantHookFailure()); + Assert.Equal(2, mutations.Count); + Assert.All(mutations, static outcome => + { + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, outcome.Effect); + Assert.Equal(MemoryRecordMutationProblem.TableLoadInProgress, outcome.Problem); + }); + MemoryRecordActivationOutcome activation = Assert.Single(activations); + Assert.Equal(MemoryRecordActivationOutcomeKind.NotAttempted, activation.Kind); + Assert.Equal(MemoryRecordMutationProblem.TableLoadInProgress, activation.Problem); + Assert.Equal(0, host.ReadInteger("delete_calls")); + Assert.Equal(0, host.ReadInteger("active_set_calls")); + + // The load scope ended with the load: the same commands now run. + Assert.True(AddressListMutations.Delete(new MemoryRecordId(1)).IsCompleted); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void A_state_replacement_cannot_interleave_with_an_admitted_mutation() + { + using AddressListTestHost host = new(); + host.AddRecord(1); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + using (FakeHost.InstallReentrantHook(host.State, "replace_state", FakeHost.ReplaceStateGeneration)) + { + // The record lookup runs Lua inside the command's admitted operation and tries to replace the state there. + host.Execute(""" + local lookup = list.getMemoryRecordByID + list.getMemoryRecordByID = function(id) replace_state() return lookup(id) end + """); + + MemoryRecordMutationOutcome outcome = AddressListMutations.Delete(new MemoryRecordId(1)); + + Assert.True(outcome.IsCompleted); + } + + Assert.IsType(FakeHost.TakeReentrantHookFailure()); + Assert.Equal(before, LuaRuntime.CurrentStateIdentity); + Assert.Equal(1, host.ReadInteger("delete_calls")); + Assert.Equal(0, host.State.Top); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListTestHost.cs b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListTestHost.cs new file mode 100644 index 00000000..cd64d577 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/AddressList/AddressListTestHost.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using System.Text; + +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.AddressList; + +/// +/// A Cheat Engine address list double whose records are found by identifier through a Lua records table, as +/// getMemoryRecordByID does. A record's destroy removes it from the list and counts in +/// delete_calls; its Active setter counts in active_set_calls and follows the record's +/// mode: apply, refuse, raise, async, retry-request or +/// post-read-fails. +/// +internal sealed class AddressListTestHost : IDisposable +{ + private readonly NativeLuaState _nativeState; + private readonly HostScope _scope; + + public AddressListTestHost() + { + EngineTest.RequireNativeLua(); + _nativeState = new NativeLuaState(); + _scope = new HostScope(_nativeState); + State = _scope.State; + CEObject list = FakeHost.CreateObject(State, "Probe"); + FakeHost.SetGlobalObject(State, "list", list); + EngineTest.Run(State, """ + records = {} + delete_calls = 0 + active_set_calls = 0 + retry_requested = 0 + function getAddressList() return list end + list.getMemoryRecordByID = function(id) return records[id] end + """u8); + } + + public LuaState State + { + get; + } + + public HostScope Scope => _scope; + + /// Creates a record with , adds it to records and returns its handle. + public CEObject AddRecord(int id, string mode = "apply", bool active = false, string extra = "") + { + string initializer = string.Create(CultureInfo.InvariantCulture, $$""" + o.props.ID = {{id}} + o.props.Active = {{(active ? "true" : "false")}} + o.props.AsyncProcessing = false + o.props.Async = false + o.mode = "{{mode}}" + o.getters.destroy = function(o) + return function() + delete_calls = delete_calls + 1 + if o.gone then error("object already destroyed") end + o.gone = true + records[o.props.ID] = nil + end + end + o.setters.Active = function(o, value) + active_set_calls = active_set_calls + 1 + if o.mode == "refuse" then return end + if o.mode == "retry-request" then retry_requested = retry_requested + 1 return end + o.props.Active = value + if o.mode == "raise" then error("activation raised after it started") end + if o.mode == "async" then o.props.AsyncProcessing = true end + if o.mode == "post-read-fails" then o.getters.Active = function() error("record vanished") end end + end + {{extra}} + """); + CEObject record = FakeHost.CreateObject(State, "Probe", initializer); + FakeHost.SetGlobalObject(State, "new_record", record); + EngineTest.Run(State, Encoding.UTF8.GetBytes( + "records[" + id.ToString(CultureInfo.InvariantCulture) + "] = new_record; new_record = nil")); + return record; + } + + /// Runs a Lua chunk against the fixture state. + public void Execute(string source) + { + EngineTest.Run(State, Encoding.UTF8.GetBytes(source)); + } + + /// Reads an integer global. + public long ReadInteger(string name) + { + using LuaFrame frame = new(State); + Assert.True(State.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + return EngineTest.ReadInteger(State, -1); + } + + public void Dispose() + { + _scope.Dispose(); + _nativeState.Dispose(); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/AddressList/MemoryRecordActivationTests.cs b/tests/CheatEngine.SDK.Engine.Tests/AddressList/MemoryRecordActivationTests.cs new file mode 100644 index 00000000..7f2fa535 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/AddressList/MemoryRecordActivationTests.cs @@ -0,0 +1,208 @@ +using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Tests.AddressList; + +/// +/// : one setter call at most, the before and after state, host refusal, +/// pending asynchronous activation and indeterminate effects, never a retry. +/// +[Trait("Category", "NativeLua")] +public sealed class MemoryRecordActivationTests +{ + [Fact] + [Trait("Qualification", "Q35")] + public void SetActive_applied_reports_the_before_and_after_state() + { + using AddressListTestHost host = new(); + host.AddRecord(1); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.Applied, outcome.Kind); + Assert.Equal(EngineEffectState.Applied, outcome.Effect); + Assert.Equal(MemoryRecordMutationProblem.None, outcome.Problem); + Assert.True(outcome.RequestedActive); + Assert.False(outcome.ActiveBefore); + Assert.True(outcome.ActiveAfter); + Assert.False(outcome.AsyncProcessingAfter); + Assert.Equal(1, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + + MemoryRecordActivationOutcome deactivated = AddressListMutations.SetActive(new MemoryRecordId(1), false); + Assert.Equal(MemoryRecordActivationOutcomeKind.Applied, deactivated.Kind); + Assert.True(deactivated.ActiveBefore); + Assert.False(deactivated.ActiveAfter); + Assert.Equal(2, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void SetActive_refused_by_the_host_reports_unknown_effect() + { + using AddressListTestHost host = new(); + host.AddRecord(1, "refuse"); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.RefusedByHost, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.False(outcome.ActiveBefore); + Assert.False(outcome.ActiveAfter); + Assert.Equal(1, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void SetActive_raise_after_the_setter_started_is_indeterminate() + { + using AddressListTestHost host = new(); + host.AddRecord(1, "raise"); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.Indeterminate, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Equal(MemoryRecordMutationProblem.LuaFailure, outcome.Problem); + Assert.Equal(LuaStatus.RuntimeError, outcome.LuaStatus); + Assert.False(outcome.ActiveBefore); + Assert.Null(outcome.ActiveAfter); + Assert.Equal(1, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void SetActive_post_read_failure_after_the_effect_is_indeterminate_with_an_unknown_after_state() + { + using AddressListTestHost host = new(); + host.AddRecord(1, "post-read-fails"); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.Indeterminate, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Equal(MemoryRecordMutationProblem.LuaFailure, outcome.Problem); + Assert.False(outcome.ActiveBefore); + Assert.Null(outcome.ActiveAfter); + Assert.Null(outcome.AsyncProcessingAfter); + Assert.Equal(1, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void SetActive_async_record_still_processing_reports_pending() + { + using AddressListTestHost host = new(); + host.AddRecord(1, "async", extra: "o.props.Async = true"); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.Pending, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.True(outcome.AsyncProcessingAfter); + Assert.True(outcome.ActiveAfter); + Assert.Equal(1, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void SetActive_in_the_requested_state_does_not_call_the_setter() + { + using AddressListTestHost host = new(); + host.AddRecord(1, active: true); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.Unchanged, outcome.Kind); + Assert.Equal(EngineEffectState.NotStarted, outcome.Effect); + Assert.True(outcome.ActiveBefore); + Assert.Null(outcome.ActiveAfter); + Assert.Equal(0, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + [Trait("Qualification", "Q34")] + public void SetActive_unknown_id_reports_record_not_found() + { + using AddressListTestHost host = new(); + host.AddRecord(1); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(99), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.NotAttempted, outcome.Kind); + Assert.Equal(EngineEffectState.NotStarted, outcome.Effect); + Assert.Equal(MemoryRecordMutationProblem.RecordNotFound, outcome.Problem); + Assert.Null(outcome.ActiveBefore); + Assert.Equal(0, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void SetActive_never_retries_even_when_the_host_asks_for_a_retry() + { + using AddressListTestHost host = new(); + host.AddRecord(1, "retry-request"); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.RefusedByHost, outcome.Kind); + Assert.Equal(1, host.ReadInteger("retry_requested")); + Assert.Equal(1, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void SetActive_with_a_non_boolean_active_state_is_not_attempted() + { + using AddressListTestHost host = new(); + host.AddRecord(1, extra: "o.props.Active = 'yes'"); + + MemoryRecordActivationOutcome outcome = AddressListMutations.SetActive(new MemoryRecordId(1), true); + + Assert.Equal(MemoryRecordActivationOutcomeKind.NotAttempted, outcome.Kind); + Assert.Equal(MemoryRecordMutationProblem.InvalidResult, outcome.Problem); + Assert.Equal(0, host.ReadInteger("active_set_calls")); + Assert.Equal(0, host.State.Top); + } + + [Fact] + public void Default_activation_outcome_is_unknown_and_not_an_effect() + { + MemoryRecordActivationOutcome outcome = default; + + Assert.Equal(MemoryRecordActivationOutcomeKind.Unknown, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Equal(MemoryRecordMutationProblem.Uninitialized, outcome.Problem); + } + + [Fact] + public void Memory_record_active_script_and_offset_count_reads_restore_the_stack() + { + using AddressListTestHost host = new(); + CEObject handle = host.AddRecord(1, active: true, extra: """ + o.props.Script = "[ENABLE]\n[DISABLE]" + o.props.OffsetCount = 2 + o.props.Async = true + """); + CEObject plain = host.AddRecord(2, extra: "o.props.OffsetCount = 'many'"); + MemoryRecord record = new(handle); + MemoryRecord other = new(plain); + + Assert.True(record.TryGetActive(out bool active)); + Assert.True(active); + Assert.True(record.TryGetAsync(out bool isAsync)); + Assert.True(isAsync); + Assert.True(record.TryGetAsyncProcessing(out bool processing)); + Assert.False(processing); + Assert.True(record.TryGetScript(out string? script)); + Assert.Equal("[ENABLE]\n[DISABLE]", script); + Assert.True(record.TryGetOffsetCount(out int offsets)); + Assert.Equal(2, offsets); + Assert.False(other.TryGetScript(out _)); + Assert.False(other.TryGetOffsetCount(out _)); + Assert.Equal(0, host.State.Top); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs index 506d047b..e7669a44 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs @@ -14,7 +14,10 @@ public sealed class AllocatedRegionTests [Fact] public void Dispose_releases_the_original_target_address_and_size_exactly_once() { - AllocationOperationsFake operations = new() { AllocatedAddress = new Address(0x7FF6_3000_0000) }; + AllocationOperationsFake operations = new() + { + AllocatedAddress = new Address(0x7FF6_3000_0000) + }; AllocatedRegion region = Allocate(operations, 12288); region.Dispose(); @@ -31,7 +34,10 @@ public void Dispose_releases_the_original_target_address_and_size_exactly_once() [Fact] public void Dispose_when_CE_reports_failure_is_no_throw_and_consumes_ownership() { - AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocationOperationsFake operations = new() + { + DeallocationResult = false + }; AllocatedRegion region = Allocate(operations, 4096); region.Dispose(); @@ -44,7 +50,10 @@ public void Dispose_when_CE_reports_failure_is_no_throw_and_consumes_ownership() [Fact] public void Release_when_CE_reports_failure_throws_the_expected_failure_and_never_retries() { - AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocationOperationsFake operations = new() + { + DeallocationResult = false + }; AllocatedRegion region = Allocate(operations, 4096); EngineOperationFailedException exception = Assert.Throws(region.Release); @@ -60,7 +69,10 @@ public void Release_when_CE_reports_failure_throws_the_expected_failure_and_neve public void Release_when_the_protected_lua_call_fails_preserves_the_failure_and_consumes_ownership() { EngineLuaException failure = new("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); EngineLuaException thrown = Assert.Throws(region.Release); @@ -75,7 +87,10 @@ public void Release_when_the_protected_lua_call_fails_preserves_the_failure_and_ public void Release_when_the_required_global_is_unavailable_preserves_the_distinct_failure() { EngineGlobalUnavailableException failure = new("TargetMemoryDeallocate"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); EngineGlobalUnavailableException thrown = Assert.Throws(region.Release); @@ -91,7 +106,10 @@ public void Release_when_the_binding_contract_fails_preserves_the_failure_and_co { EngineBindingException failure = new("TargetMemoryDeallocate", "the generated binding returned an incompatible result"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); EngineBindingException thrown = Assert.Throws(region.Release); @@ -106,7 +124,10 @@ public void Release_when_the_result_cannot_be_marshalled_preserves_the_failure_a { EngineMarshallingException failure = new("TargetMemoryDeallocate", EngineMarshallingDirection.Result, "a boolean", "a table"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); EngineMarshallingException thrown = Assert.Throws(region.Release); @@ -137,7 +158,10 @@ public void Dispose_when_the_binding_fails_is_no_throw_and_consumes_ownership() public void Dispose_when_an_EngineException_occurs_preserves_the_structured_failure_kind_and_consumes_ownership() { EngineException failure = new EngineLuaException("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); region.Dispose(); @@ -171,11 +195,11 @@ public void Release_when_a_non_engine_deallocator_exception_occurs_records_an_un { AllocationOperationsFake operations = new() { - DeallocationException = new InvalidOperationException("injected non-Engine deallocation failure") + DeallocationException = new NotSupportedException("injected non-Engine deallocation failure") }; AllocatedRegion region = Allocate(operations, 4096); - Assert.Throws(region.Release); + Assert.Throws(region.Release); Assert.True(region.IsDisposed); Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, region.LastReleaseOutcome.Status); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationLifecycleTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationLifecycleTests.cs new file mode 100644 index 00000000..543b533a --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationLifecycleTests.cs @@ -0,0 +1,230 @@ +using System.Text; + +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Allocation; + +/// +/// Allocation owners across the plugin lifecycle, through the production binding on the Lua fixture: detach between +/// allocation and publication, release after detach, release after re-enable or a state replacement, and a later +/// allocation that reuses a released address. +/// +[Trait("Category", "NativeLua")] +public sealed class AllocationLifecycleTests +{ + private static readonly TargetAllocationRequest SRequest = new(new TargetAllocationSize(4096)); + + [Fact] + [Trait("Qualification", "Q08.a")] + public void Detach_between_allocation_and_publication_compensates_once_and_reports_not_invoked() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + TargetMemoryAllocator allocator = new(); + + TargetAllocationAcquireOutcome outcome = allocator.TryAllocateCore(SRequest, static (_, _, _, _, _) => + { + LuaRuntime.Detach(); + throw new InvalidOperationException("the plugin was disabled before the owner was published"); + }, out AllocatedRegion? region); + + Assert.False(LuaRuntime.IsAttached); + Assert.Null(region); + Assert.False(outcome.HasOwner); + Assert.Equal(EngineEffectState.Applied, outcome.Effect); + Assert.Equal(new Address(0x7FF6_1000_0000), outcome.Allocation.Address); + TargetReleaseOutcome compensation = Assert.NotNull(outcome.Compensation); + Assert.Equal(TargetReleaseStatus.NotInvoked, compensation.Status); + Assert.Equal(EngineFailureKind.BindingFailure, compensation.FailureKind); + Assert.True(compensation.RequiresManualRecovery); + AssertCounts(L, 1, 0); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Allocate_with_a_detach_before_publication_throws_the_handoff_with_a_not_invoked_compensation() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + TargetMemoryAllocator allocator = new(); + + EngineResourceHandoffException exception = Assert.Throws(() => + allocator.AllocateCore(SRequest, static (_, _, _, _, _) => + { + LuaRuntime.Detach(); + throw new InvalidOperationException("the plugin was disabled before the owner was published"); + })); + + Assert.Equal(TargetReleaseStatus.NotInvoked, exception.CleanupOutcome.Status); + Assert.IsType(exception.InnerException); + AssertCounts(L, 1, 0); + } + + [Fact] + public void Dispose_after_detach_reports_not_invoked_and_consumes_ownership() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + AllocatedRegion region = new TargetMemoryAllocator().Allocate(SRequest); + + LuaRuntime.Detach(); + region.Dispose(); + region.Dispose(); + + Assert.True(region.IsDisposed); + Assert.Equal(TargetReleaseStatus.NotInvoked, region.LastReleaseOutcome.Status); + Assert.Equal(EngineFailureKind.BindingFailure, region.LastReleaseOutcome.FailureKind); + AssertCounts(L, 1, 0); + } + + [Fact] + public void ReleaseWithTargetOutcome_after_detach_reports_not_invoked() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + AllocatedRegion region = new TargetMemoryAllocator().Allocate(SRequest); + + LuaRuntime.Detach(); + TargetReleaseOutcome outcome = region.ReleaseWithTargetOutcome(); + + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.Status); + Assert.Equal(EngineFailureKind.BindingFailure, outcome.FailureKind); + Assert.True(region.IsDisposed); + Assert.Throws(() => region.ReleaseWithTargetOutcome()); + AssertCounts(L, 1, 0); + } + + [Fact] + public void Release_after_detach_throws_the_lifecycle_failure_and_records_not_invoked() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + AllocatedRegion region = new TargetMemoryAllocator().Allocate(SRequest); + + LuaRuntime.Detach(); + + Assert.Throws(region.Release); + Assert.True(region.IsDisposed); + Assert.Equal(TargetReleaseStatus.NotInvoked, region.LastReleaseOutcome.Status); + AssertCounts(L, 1, 0); + } + + [Fact] + public void Dispose_after_reattach_refuses_cleanup_in_the_new_runtime() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + AllocatedRegion region = new TargetMemoryAllocator().Allocate(SRequest); + EngineResourceOrigin origin = region.Origin; + + LuaRuntime.Detach(); + LuaRuntime.Attach(scope.Binding); + region.Dispose(); + + Assert.True(region.IsDisposed); + Assert.Equal(origin, region.Origin); + Assert.False(region.Origin.IsCurrentRuntime); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, region.LastReleaseOutcome.Status); + Assert.True(region.LastReleaseOutcome.RequiresManualRecovery); + AssertCounts(L, 1, 0); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Release_after_a_controlled_state_replacement_refuses_cleanup_and_throws_the_lifecycle_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + AllocatedRegion region = new TargetMemoryAllocator().Allocate(SRequest); + + FakeHost.ReplaceStateGeneration(); + + Assert.Throws(region.Release); + Assert.True(region.IsDisposed); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, region.LastReleaseOutcome.Status); + AssertCounts(L, 1, 0); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q30.e")] + public void Released_region_address_cannot_free_a_later_allocation_at_the_same_address() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + InstallAllocationGlobals(L); + TargetMemoryAllocator allocator = new(); + AllocatedRegion first = allocator.Allocate(SRequest); + first.Release(); + + AllocatedRegion second = allocator.Allocate(SRequest); + Assert.Equal(new Address(0x7FF6_1000_0000), second.Address); + first.Dispose(); + Assert.Throws(first.Release); + Assert.Throws(() => first.ReleaseWithOutcome()); + Assert.Throws(() => first.ReleaseWithTargetOutcome()); + + Assert.False(second.IsDisposed); + AssertCounts(L, 2, 1); + second.Release(); + AssertCounts(L, 2, 2); + Assert.Equal(TargetReleaseStatus.Released, first.LastReleaseOutcome.Status); + Assert.Equal(TargetReleaseStatus.Released, second.LastReleaseOutcome.Status); + Assert.Equal(0, L.Top); + } + + private static void InstallAllocationGlobals(LuaState state) + { + FakeHost.InstallQualifiedLocalTarget(state); + EngineTest.Run(state, """ + allocation_calls = 0 + deallocation_calls = 0 + function allocateMemory(size) + allocation_calls = allocation_calls + 1 + return 0x7FF610000000 + end + function deAlloc(address, size) + deallocation_calls = deallocation_calls + 1 + return true + end + """u8); + } + + private static void AssertCounts(LuaState state, long allocations, long deallocations) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes( + "assert(allocation_calls == " + allocations + ", 'allocation calls: ' .. allocation_calls)\n" + + "assert(deallocation_calls == " + deallocations + ", 'deallocation calls: ' .. deallocation_calls)")); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationOperationsFake.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationOperationsFake.cs index 21349b3f..cf48ed81 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationOperationsFake.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocationOperationsFake.cs @@ -86,12 +86,22 @@ public TargetMemoryAllocationOutcome? BoundAllocationOutcomeOverride set; } + /// + /// Makes the fake allocate although its observation is unqualified: a binding that broke its contract, so the + /// allocator's own post-effect refusal can be observed. + /// + public bool AllocateEvenWhenUnqualified + { + get; + set; + } + public TargetMemoryAllocationOutcome AllocateBoundWithOutcome(TargetAllocationRequest request, out TargetProcessIncarnation incarnation, out TargetSelectionObservation observation) { observation = TargetObservation; incarnation = observation.Incarnation.GetValueOrDefault(); - if (!observation.IsQualified) + if (!observation.IsQualified && !AllocateEvenWhenUnqualified) { return TargetMemoryAllocationOutcome.Failed(TargetMemoryOperationOutcome.Failed( EngineFailureKind.TargetIdentityUnavailable)); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs index 40c41ce9..7b1238b5 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs @@ -94,7 +94,7 @@ public void AllocateWithOutcome_classifies_documented_result_and_execution_categ LuaState L = scope.State; EngineTest.Run(L, Encoding.UTF8.GetBytes(fixture)); - TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( + TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.AllocateWithOutcome( new TargetAllocationRequest(new TargetAllocationSize(4096))); Assert.Equal(expectedKind, outcome.Operation.Kind); @@ -111,7 +111,7 @@ public void AllocateWithOutcome_missing_global_is_distinct_from_a_present_global using HostScope scope = new(state); LuaState L = scope.State; - TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( + TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.AllocateWithOutcome( new TargetAllocationRequest(new TargetAllocationSize(4096))); Assert.Equal(TargetMemoryOperationOutcomeKind.GlobalUnavailable, outcome.Operation.Kind); @@ -136,7 +136,7 @@ public void AllocateWithOutcome_global_resolution_failure_preserves_the_protecte }) """u8); - TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome( + TargetMemoryAllocationOutcome outcome = LuaTargetMemoryAllocationOperations.AllocateWithOutcome( new TargetAllocationRequest(new TargetAllocationSize(4096))); Assert.Equal(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, outcome.Operation.Kind); @@ -161,7 +161,7 @@ public void AllocateWithOutcome_expected_nil_result_is_allocation_free_after_war AllocationGate.AssertZero(() => { TargetMemoryAllocationOutcome outcome = - LuaTargetMemoryAllocationOperations.Instance.AllocateWithOutcome(request); + LuaTargetMemoryAllocationOperations.AllocateWithOutcome(request); kind = outcome.Operation.Kind; address = outcome.Address; }); @@ -307,7 +307,7 @@ function deAlloc() TargetAllocationSize size = rawSize == 0 ? default : new TargetAllocationSize(rawSize); TargetMemoryOperationOutcome outcome = - LuaTargetMemoryAllocationOperations.Instance.DeallocateWithOutcome(new Address(rawAddress), size); + LuaTargetMemoryAllocationOperations.DeallocateWithOutcome(new Address(rawAddress), size); Assert.Equal(TargetMemoryOperationOutcomeKind.MarshallingFailure, outcome.Kind); Assert.Equal(EngineFailureKind.MarshallingFailure, outcome.FailureKind); @@ -424,7 +424,8 @@ function deAlloc(address, size) private static void InstallCurrentTarget(LuaState state) { EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + - Environment.ProcessId + " end")); + Environment.ProcessId + " end")); + EngineTest.Run(state, FakeHost.LocalTargetBackendChunk); } private static void AssertLuaInteger(LuaState state, string name, long expected) diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetBoundAllocationTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetBoundAllocationTests.cs index 90efda69..d73b989c 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetBoundAllocationTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetBoundAllocationTests.cs @@ -1,5 +1,6 @@ using CheatEngine.SDK.Engine.Allocation; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; @@ -27,6 +28,8 @@ public void Release_on_the_original_qualified_target_routes_once_to_that_target( } [Fact] + [Trait("Qualification", "Q30.a")] + [Trait("Qualification", "Q30.b")] public void Dispose_after_an_external_target_switch_refuses_cleanup_without_selecting_or_deallocating() { TargetContext first = new(4101, 1001); @@ -48,6 +51,8 @@ public void Dispose_after_an_external_target_switch_refuses_cleanup_without_sele } [Fact] + [Trait("Qualification", "Q30.a")] + [Trait("Qualification", "Q30.b")] public void Release_after_an_external_target_switch_reports_the_refusal_without_deallocating_either_target() { TargetContext first = new(4101, 1001); @@ -67,6 +72,7 @@ public void Release_after_an_external_target_switch_reports_the_refusal_without_ } [Fact] + [Trait("Qualification", "Q30.a")] public void Dispose_after_an_external_A_to_B_to_A_switch_uses_only_the_current_original_incarnation() { TargetContext first = new(4101, 1001); @@ -87,6 +93,7 @@ public void Dispose_after_an_external_A_to_B_to_A_switch_uses_only_the_current_o } [Fact] + [Trait("Qualification", "Q30.b")] public void Release_after_target_termination_refuses_cleanup_and_records_no_target() { TargetContext first = new(4101, 1001); @@ -105,6 +112,7 @@ public void Release_after_target_termination_refuses_cleanup_and_records_no_targ } [Fact] + [Trait("Qualification", "Q30.a")] public void Release_after_PID_reuse_refuses_the_new_incarnation() { TargetContext original = new(4101, 1001); @@ -130,10 +138,15 @@ public void Allocate_when_target_identity_is_unavailable_refuses_before_the_effe EngineTargetIdentityException exception = Assert.Throws(() => allocator.Allocate(CreateRequest())); - TargetMemoryAllocationOutcome outcome = allocator.AllocateWithOutcome(CreateRequest()); + TargetAllocationAcquireOutcome outcome = allocator.TryAllocate(CreateRequest(), out AllocatedRegion? region); Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, exception.Check.Kind); - Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Operation.Kind); + Assert.Null(region); + Assert.False(outcome.HasOwner); + Assert.Equal(EngineEffectState.NotStarted, outcome.Effect); + Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, outcome.TargetObservation.Status); + Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Allocation.Operation.Kind); + Assert.Null(outcome.Compensation); Assert.Equal(0, operations.TotalAllocationCalls); Assert.Equal(0, operations.TotalDeallocationCalls); } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs index 8464908d..bd647e2a 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs @@ -5,9 +5,11 @@ using CheatEngine.SDK.Engine.Allocation; using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; namespace CheatEngine.SDK.Engine.Tests.Allocation; @@ -26,7 +28,7 @@ public void Allocate_when_owner_publication_fails_compensates_once_and_exposes_t EngineResourceHandoffException exception = Assert.Throws(() => allocator.AllocateCore( new TargetAllocationRequest(new TargetAllocationSize(4096)), - (_, _, _, _) => throw cause)); + (_, _, _, _, _) => throw cause)); Assert.Same(cause, exception.InnerException); Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); @@ -38,13 +40,16 @@ public void Allocate_when_owner_publication_fails_compensates_once_and_exposes_t [Fact] public void Allocate_when_owner_publication_and_compensation_fail_reports_an_unconfirmed_effect_without_retrying() { - AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocationOperationsFake operations = new() + { + DeallocationResult = false + }; TargetMemoryAllocator allocator = new(operations); EngineResourceHandoffException exception = Assert.Throws(() => allocator.AllocateCore( new TargetAllocationRequest(new TargetAllocationSize(4096)), - static (_, _, _, _) => throw new InvalidOperationException("injected region publication failure"))); + static (_, _, _, _, _) => throw new InvalidOperationException("injected region publication failure"))); Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); @@ -57,14 +62,17 @@ public void Allocate_when_owner_publication_and_compensation_raise_keeps_the_primary_cause_and_marks_the_effect_unknown() { EngineLuaException cleanupFailure = new("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = cleanupFailure }; + AllocationOperationsFake operations = new() + { + DeallocationException = cleanupFailure + }; TargetMemoryAllocator allocator = new(operations); InvalidOperationException cause = new("injected region publication failure"); EngineResourceHandoffException exception = Assert.Throws(() => allocator.AllocateCore( new TargetAllocationRequest(new TargetAllocationSize(4096)), - (_, _, _, _) => throw cause)); + (_, _, _, _, _) => throw cause)); Assert.Same(cause, exception.InnerException); Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); @@ -82,7 +90,7 @@ public void Allocate_when_owner_publication_observes_a_replacement_target_refuse EngineResourceHandoffException exception = Assert.Throws(() => allocator.AllocateCore( new TargetAllocationRequest(new TargetAllocationSize(4096)), - (_, _, _, _) => + (_, _, _, _, _) => { operations.TargetObservation = TargetSelectionObservation.Qualified(replacement); throw new InvalidOperationException("injected region publication failure"); @@ -95,7 +103,10 @@ public void Allocate_when_owner_publication_observes_a_replacement_target_refuse [Fact] public void Allocate_on_success_returns_an_owned_region_and_forwards_the_full_request() { - AllocationOperationsFake operations = new() { AllocatedAddress = new Address(0x7FF6_1234_0000) }; + AllocationOperationsFake operations = new() + { + AllocatedAddress = new Address(0x7FF6_1234_0000) + }; TargetMemoryAllocator allocator = new(operations); TargetAllocationRequest request = new(new TargetAllocationSize(8192), new Address(0x7FF6_1200_0000), MemoryProtection.ExecuteReadWrite); @@ -111,7 +122,11 @@ public void Allocate_on_success_returns_an_owned_region_and_forwards_the_full_re [Fact] public void Allocate_when_CE_reports_expected_failure_throws_the_stable_expected_failure() { - AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = Address.Zero }; + AllocationOperationsFake operations = new() + { + AllocationResult = false, + AllocatedAddress = Address.Zero + }; TargetMemoryAllocator allocator = new(operations); EngineOperationFailedException exception = Assert.Throws(() => @@ -125,7 +140,10 @@ public void Allocate_when_CE_reports_expected_failure_throws_the_stable_expected [Fact] public void Allocate_when_CE_reports_success_without_an_address_preserves_the_unknown_effect_diagnostic() { - AllocationOperationsFake operations = new() { AllocatedAddress = Address.Zero }; + AllocationOperationsFake operations = new() + { + AllocatedAddress = Address.Zero + }; TargetMemoryAllocator allocator = new(operations); EngineResourceHandoffException exception = Assert.Throws(() => @@ -142,7 +160,11 @@ public void Allocate_when_CE_reports_success_without_an_address_preserves_the_un public void Allocate_when_CE_reports_failure_with_an_address_throws_marshalling() { AllocationOperationsFake operations = - new() { AllocationResult = false, AllocatedAddress = new Address(0x1234) }; + new() + { + AllocationResult = false, + AllocatedAddress = new Address(0x1234) + }; TargetMemoryAllocator allocator = new(operations); EngineMarshallingException exception = Assert.Throws(() => @@ -157,7 +179,10 @@ public void Allocate_when_the_binding_fails_preserves_the_binding_exception() { EngineBindingException failure = new("TargetMemoryAllocate", "the generated binding returned an incompatible result"); - AllocationOperationsFake operations = new() { AllocationException = failure }; + AllocationOperationsFake operations = new() + { + AllocationException = failure + }; TargetMemoryAllocator allocator = new(operations); EngineBindingException thrown = Assert.Throws(() => @@ -170,7 +195,10 @@ public void Allocate_when_the_binding_fails_preserves_the_binding_exception() public void Allocate_when_the_required_global_is_unavailable_preserves_that_distinct_failure() { EngineGlobalUnavailableException failure = new("TargetMemoryAllocate"); - AllocationOperationsFake operations = new() { AllocationException = failure }; + AllocationOperationsFake operations = new() + { + AllocationException = failure + }; TargetMemoryAllocator allocator = new(operations); EngineGlobalUnavailableException thrown = Assert.Throws(() => @@ -184,7 +212,10 @@ public void Allocate_when_the_required_global_is_unavailable_preserves_that_dist public void Allocate_when_the_protected_lua_call_fails_preserves_the_EngineLuaException() { EngineLuaException failure = new("TargetMemoryAllocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { AllocationException = failure }; + AllocationOperationsFake operations = new() + { + AllocationException = failure + }; TargetMemoryAllocator allocator = new(operations); EngineLuaException thrown = Assert.Throws(() => @@ -193,40 +224,6 @@ public void Allocate_when_the_protected_lua_call_fails_preserves_the_EngineLuaEx Assert.Same(failure, thrown); } - [Fact] - public void AllocateWithOutcome_adapts_the_legacy_bool_seam_without_parsing_exception_text() - { - EngineLuaException failure = new("TargetMemoryAllocate", LuaStatus.SyntaxError, - "A deliberately irrelevant localized message."); - AllocationOperationsFake operations = new() { AllocationException = failure }; - TargetMemoryAllocator allocator = new(operations); - - TargetMemoryAllocationOutcome outcome = - allocator.AllocateWithOutcome(new TargetAllocationRequest(new TargetAllocationSize(4096))); - - Assert.Equal(TargetMemoryOperationOutcomeKind.ProtectedLuaFailure, outcome.Operation.Kind); - Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.Operation.FailureKind); - Assert.Equal(LuaStatus.SyntaxError, outcome.Operation.LuaStatus); - Assert.Equal(Address.Zero, outcome.Address); - Assert.Equal(1, operations.AllocateCalls); - } - - [Fact] - public void AllocateWithOutcome_adapts_legacy_expected_failure_without_creating_an_owner() - { - AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = Address.Zero }; - TargetMemoryAllocator allocator = new(operations); - - TargetMemoryAllocationOutcome outcome = - allocator.AllocateWithOutcome(new TargetAllocationRequest(new TargetAllocationSize(4096))); - - Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, outcome.Operation.Kind); - Assert.Equal(EngineFailureKind.ExpectedOperationFailure, outcome.Operation.FailureKind); - Assert.Equal(Address.Zero, outcome.Address); - Assert.False(outcome.IsSuccess); - Assert.Equal(1, operations.AllocateCalls); - } - [Fact] public void Allocate_with_only_the_compatibility_seam_refuses_an_unqualified_owner_before_an_effectful_call() { @@ -236,11 +233,9 @@ public void Allocate_with_only_the_compatibility_seam_refuses_an_unqualified_own EngineTargetIdentityException exception = Assert.Throws(() => allocator.Allocate(request)); - TargetMemoryAllocationOutcome outcome = allocator.AllocateWithOutcome(request); Assert.Equal(TargetIdentityCheckKind.CurrentTargetUnqualified, exception.Check.Kind); Assert.Equal(EngineFailureKind.TargetIdentityUnavailable, exception.Kind); - Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Operation.Kind); Assert.Equal(0, operations.AllocateCalls); Assert.Equal(0, operations.DeallocateCalls); } @@ -287,7 +282,10 @@ public void Allocate_with_a_target_bound_boundary_failure_throws_its_stable_publ [Fact] public void ReleaseWithOutcome_adapts_the_legacy_bool_seam_and_consumes_ownership() { - AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocationOperationsFake operations = new() + { + DeallocationResult = false + }; TargetMemoryAllocator allocator = new(operations); AllocatedRegion region = allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))); @@ -304,29 +302,290 @@ public void Public_target_memory_operations_carry_enabled_lifecycle_metadata_wit { MethodInfo allocate = typeof(TargetMemoryAllocator) .GetMethod(nameof(TargetMemoryAllocator.Allocate))!; + MethodInfo tryAllocateOwned = typeof(TargetMemoryAllocator) + .GetMethod(nameof(TargetMemoryAllocator.TryAllocate))!; MethodInfo release = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.Release))!; MethodInfo dispose = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.Dispose))!; MethodInfo tryAllocate = typeof(ITargetMemoryAllocationOperations) .GetMethod(nameof(ITargetMemoryAllocationOperations.TryAllocate))!; MethodInfo tryDeallocate = typeof(ITargetMemoryAllocationOperations).GetMethod( nameof(ITargetMemoryAllocationOperations.TryDeallocate))!; - MethodInfo allocateWithOutcome = typeof(ITargetMemoryAllocationOutcomeOperations).GetMethod( - nameof(ITargetMemoryAllocationOutcomeOperations.AllocateWithOutcome))!; - MethodInfo deallocateWithOutcome = typeof(ITargetMemoryAllocationOutcomeOperations).GetMethod( - nameof(ITargetMemoryAllocationOutcomeOperations.DeallocateWithOutcome))!; - MethodInfo facadeOutcome = - typeof(TargetMemoryAllocator).GetMethod(nameof(TargetMemoryAllocator.AllocateWithOutcome))!; MethodInfo releaseWithOutcome = typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.ReleaseWithOutcome))!; + MethodInfo releaseWithTargetOutcome = + typeof(AllocatedRegion).GetMethod(nameof(AllocatedRegion.ReleaseWithTargetOutcome))!; AssertHasLifecycleMetadata(allocate); + AssertHasLifecycleMetadata(tryAllocateOwned); AssertHasLifecycleMetadata(release); AssertHasLifecycleMetadata(dispose); AssertHasLifecycleMetadata(tryAllocate); AssertHasLifecycleMetadata(tryDeallocate); - AssertHasLifecycleMetadata(allocateWithOutcome); - AssertHasLifecycleMetadata(deallocateWithOutcome); - AssertHasLifecycleMetadata(facadeOutcome); AssertHasLifecycleMetadata(releaseWithOutcome); + AssertHasLifecycleMetadata(releaseWithTargetOutcome); + } + + [Fact] + public void No_public_allocation_entry_point_returns_an_address_without_an_owner() + { + Assert.False(typeof(LuaTargetMemoryAllocationOperations).IsPublic); + Assert.Null(typeof(TargetMemoryAllocator).Assembly.GetType( + "CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOutcomeOperations")); + Assert.Null(typeof(TargetMemoryAllocator).GetMethod("AllocateWithOutcome")); + Assert.DoesNotContain(typeof(ITargetMemoryAllocationOperations).GetMethods(), static method => + method.Name is not (nameof(ITargetMemoryAllocationOperations.TryAllocate) or + nameof(ITargetMemoryAllocationOperations.TryDeallocate))); + } + + [Fact] + public void TryAllocate_success_returns_an_owner_with_its_incarnation_and_runtime_origin() + { + AllocationOperationsFake operations = new() + { + AllocatedAddress = new Address(0x7FF6_1234_0000) + }; + TargetMemoryAllocator allocator = new(operations); + TargetAllocationRequest request = new(new TargetAllocationSize(8192)); + + TargetAllocationAcquireOutcome outcome = allocator.TryAllocate(request, out AllocatedRegion? region); + + Assert.True(outcome.HasOwner); + Assert.Equal(EngineEffectState.Applied, outcome.Effect); + Assert.Null(outcome.Compensation); + Assert.True(outcome.Allocation.IsSuccess); + Assert.Equal(new Address(0x7FF6_1234_0000), outcome.Allocation.Address); + Assert.True(outcome.TargetObservation.IsQualified); + AllocatedRegion owner = Assert.IsType(region); + Assert.Equal(new Address(0x7FF6_1234_0000), owner.Address); + Assert.Equal(outcome.TargetObservation.Incarnation, owner.Origin.Target); + Assert.Equal(owner.TargetIncarnation, owner.Origin.Target); + Assert.Equal(LuaRuntime.CurrentStateIdentity, owner.Origin.Runtime); + Assert.True(owner.Origin.IsTargetBound); + Assert.Equal(request, operations.LastRequest); + owner.Dispose(); + Assert.Equal(TargetReleaseStatus.Released, owner.LastReleaseOutcome.Status); + Assert.Equal(1, operations.AllocateCalls); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void TryAllocate_expected_failure_reports_not_applied_without_an_owner() + { + AllocationOperationsFake operations = new() + { + AllocationResult = false, + AllocatedAddress = Address.Zero + }; + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = + allocator.TryAllocate(new TargetAllocationRequest(new TargetAllocationSize(4096)), out AllocatedRegion? region); + + Assert.Null(region); + Assert.False(outcome.HasOwner); + Assert.Equal(EngineEffectState.NotApplied, outcome.Effect); + Assert.Equal(TargetMemoryOperationOutcomeKind.ExpectedFailure, outcome.Allocation.Operation.Kind); + Assert.Equal(Address.Zero, outcome.Allocation.Address); + Assert.Null(outcome.Compensation); + Assert.Equal(1, operations.AllocateCalls); + Assert.Equal(0, operations.DeallocateCalls); + } + + [Fact] + public void TryAllocate_unqualified_target_reports_not_started_before_the_effectful_call() + { + AllocationOperationsFake operations = new() + { + TargetObservation = TargetSelectionObservation.Unqualified(4242) + }; + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = + allocator.TryAllocate(new TargetAllocationRequest(new TargetAllocationSize(4096)), out AllocatedRegion? region); + + Assert.Null(region); + Assert.False(outcome.HasOwner); + Assert.Equal(EngineEffectState.NotStarted, outcome.Effect); + Assert.Equal(TargetSelectionObservationStatus.CurrentTargetUnqualified, outcome.TargetObservation.Status); + Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Allocation.Operation.Kind); + Assert.Null(outcome.Compensation); + Assert.Equal(0, operations.AllocateCalls); + Assert.Equal(0, operations.DeallocateCalls); + } + + [Fact] + [Trait("Qualification", "Q08.a")] + public void TryAllocate_publication_failure_reports_exactly_one_compensation() + { + AllocationOperationsFake operations = new(); + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = allocator.TryAllocateCore( + new TargetAllocationRequest(new TargetAllocationSize(4096)), + static (_, _, _, _, _) => throw new InvalidOperationException("injected region publication failure"), + out AllocatedRegion? region); + + Assert.Null(region); + Assert.False(outcome.HasOwner); + Assert.Equal(EngineEffectState.Applied, outcome.Effect); + Assert.Equal(TargetReleaseStatus.Released, outcome.Compensation.GetValueOrDefault().Status); + Assert.Equal(operations.AllocatedAddress, outcome.Allocation.Address); + Assert.Equal(1, operations.AllocateCalls); + Assert.Equal(1, operations.DeallocateCalls); + Assert.Equal(operations.AllocatedAddress, operations.LastDeallocatedAddress); + } + + [Fact] + public void TryAllocate_publication_and_compensation_failure_keeps_the_allocated_address_for_manual_recovery() + { + AllocationOperationsFake operations = new() + { + AllocatedAddress = new Address(0x7FF6_5555_0000), + DeallocationResult = false + }; + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = allocator.TryAllocateCore( + new TargetAllocationRequest(new TargetAllocationSize(4096)), + static (_, _, _, _, _) => throw new InvalidOperationException("injected region publication failure"), + out AllocatedRegion? region); + + Assert.Null(region); + Assert.Equal(EngineEffectState.Applied, outcome.Effect); + TargetReleaseOutcome compensation = Assert.NotNull(outcome.Compensation); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, compensation.Status); + Assert.Equal(EngineFailureKind.ExpectedOperationFailure, compensation.FailureKind); + Assert.True(compensation.RequiresManualRecovery); + Assert.Equal(new Address(0x7FF6_5555_0000), outcome.Allocation.Address); + Assert.Equal(1, operations.DeallocateCalls); + } + + [Fact] + public void TryAllocate_with_only_the_compatibility_seam_refuses_without_an_effect() + { + DirectOnlyAllocationOperations operations = new(); + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = + allocator.TryAllocate(new TargetAllocationRequest(new TargetAllocationSize(4096)), out AllocatedRegion? region); + + Assert.Null(region); + Assert.False(outcome.HasOwner); + Assert.Equal(EngineEffectState.NotStarted, outcome.Effect); + Assert.Equal(TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable, outcome.Allocation.Operation.Kind); + Assert.Equal(TargetSelectionObservationStatus.Unspecified, outcome.TargetObservation.Status); + Assert.Null(outcome.Compensation); + Assert.Equal(0, operations.AllocateCalls); + Assert.Equal(0, operations.DeallocateCalls); + } + + [Fact] + public void TryAllocate_success_without_an_address_is_a_marshalling_failure_with_unknown_effect() + { + AllocationOperationsFake operations = new() + { + AllocatedAddress = Address.Zero + }; + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = + allocator.TryAllocate(new TargetAllocationRequest(new TargetAllocationSize(4096)), out AllocatedRegion? region); + + Assert.Null(region); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Equal(TargetMemoryOperationOutcomeKind.MarshallingFailure, outcome.Allocation.Operation.Kind); + Assert.Null(outcome.Compensation); + Assert.Equal(0, operations.DeallocateCalls); + } + + [Fact] + public void TryAllocate_failure_that_still_carries_an_address_keeps_it_and_reports_unknown_effect() + { + AllocationOperationsFake operations = new() + { + AllocationResult = false, + AllocatedAddress = new Address(0x1234) + }; + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = + allocator.TryAllocate(new TargetAllocationRequest(new TargetAllocationSize(4096)), out AllocatedRegion? region); + + Assert.Null(region); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Equal(TargetMemoryOperationOutcomeKind.MarshallingFailure, outcome.Allocation.Operation.Kind); + Assert.Equal(new Address(0x1234), outcome.Allocation.Address); + } + + [Fact] + public void TryAllocate_on_an_unqualified_target_after_the_effect_refuses_compensation_and_keeps_the_address() + { + AllocationOperationsFake operations = new() + { + AllocatedAddress = new Address(0x7FF6_7777_0000), + TargetObservation = TargetSelectionObservation.Unqualified(4242), + AllocateEvenWhenUnqualified = true + }; + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = + allocator.TryAllocate(new TargetAllocationRequest(new TargetAllocationSize(4096)), out AllocatedRegion? region); + + Assert.Null(region); + Assert.False(outcome.HasOwner); + Assert.Equal(EngineEffectState.Applied, outcome.Effect); + TargetReleaseOutcome compensation = Assert.NotNull(outcome.Compensation); + Assert.Equal(TargetReleaseStatus.RefusedIdentityUnavailable, compensation.Status); + Assert.Equal(new Address(0x7FF6_7777_0000), outcome.Allocation.Address); + Assert.Equal(1, operations.AllocateCalls); + Assert.Equal(0, operations.DeallocateCalls); + } + + [Theory] + [InlineData(EngineFailureKind.GlobalUnavailable, EngineEffectState.NotStarted)] + [InlineData(EngineFailureKind.TargetIdentityUnavailable, EngineEffectState.NotStarted)] + [InlineData(EngineFailureKind.ProtectedLuaFailure, EngineEffectState.Unknown)] + [InlineData(EngineFailureKind.BindingFailure, EngineEffectState.Unknown)] + [InlineData(EngineFailureKind.MarshallingFailure, EngineEffectState.Unknown)] + public void TryAllocate_turns_a_binding_exception_into_an_outcome_without_parsing_its_text( + EngineFailureKind failureKind, EngineEffectState expectedEffect) + { + EngineException failure = failureKind switch + { + EngineFailureKind.GlobalUnavailable => new EngineGlobalUnavailableException("TargetMemoryAllocate"), + EngineFailureKind.TargetIdentityUnavailable => new EngineTargetIdentityException("TargetMemoryAllocate", + TargetSelection.CreateUnavailableCheck(TargetSelectionObservation.NoTarget())), + EngineFailureKind.ProtectedLuaFailure => new EngineLuaException("TargetMemoryAllocate", LuaStatus.SyntaxError, + "A deliberately irrelevant localized message."), + EngineFailureKind.BindingFailure => new EngineBindingException("TargetMemoryAllocate", "incompatible"), + _ => new EngineMarshallingException("TargetMemoryAllocate", EngineMarshallingDirection.Result, "a", "b") + }; + AllocationOperationsFake operations = new() + { + AllocationException = failure + }; + TargetMemoryAllocator allocator = new(operations); + + TargetAllocationAcquireOutcome outcome = + allocator.TryAllocate(new TargetAllocationRequest(new TargetAllocationSize(4096)), out AllocatedRegion? region); + + Assert.Null(region); + Assert.Equal(expectedEffect, outcome.Effect); + Assert.Equal(failureKind, outcome.Allocation.Operation.FailureKind); + Assert.Equal(Address.Zero, outcome.Allocation.Address); + if (failureKind == EngineFailureKind.ProtectedLuaFailure) + { + Assert.Equal(LuaStatus.SyntaxError, outcome.Allocation.Operation.LuaStatus); + } + } + + [Fact] + public void TryAllocate_rejects_an_invalid_request_before_any_call() + { + AllocationOperationsFake operations = new(); + TargetMemoryAllocator allocator = new(operations); + + Assert.Throws(() => allocator.TryAllocate(default, out _)); + Assert.Equal(0, operations.AllocateCalls); } private static void AssertHasLifecycleMetadata(MethodInfo method) diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerDisableInfoSnapshotTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerDisableInfoSnapshotTests.cs new file mode 100644 index 00000000..13ea8eec --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerDisableInfoSnapshotTests.cs @@ -0,0 +1,248 @@ +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Assembly; + +/// +/// The bounded, sorted, exact copy of Cheat Engine's Auto Assembler disable information, and the rule that a +/// diagnostic copy that failed never fails the activation. +/// +[Trait("Category", "NativeLua")] +public sealed class AutoAssemblerDisableInfoSnapshotTests +{ + [Fact] + public void Snapshot_projects_allocations_symbols_exception_ranges_and_registered_symbols() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + EngineTest.Run(L, """ + aa_disable_info = { + allocs = { + newmem = { address = 0x140000000, size = 4096, prefered = 0x13FFF0000 }, + code = { address = 0x140001000, size = 16 }, + }, + registeredsymbols = { "player_base", "ammo" }, + exceptionlist = { 0x140000010, 0x140000020 }, + symbols = { newmem = 0x140000000, return_here = 0x140001005 }, + compileinfo = { ignored = true }, + } + """u8); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + + AutoAssemblerDisableInfoSnapshot snapshot = Assert.IsType(outcome.DisableInfo); + Assert.Equal(AutoAssemblerDisableInfoSnapshotStatus.Complete, snapshot.Status); + Assert.Collection(snapshot.Allocations, + static code => + { + Assert.Equal("code", code.Name); + Assert.Equal(new Address(0x140001000), code.Address); + Assert.Equal(16, code.Size); + Assert.Null(code.PreferredAddress); + }, + static newmem => + { + Assert.Equal("newmem", newmem.Name); + Assert.Equal(new Address(0x140000000), newmem.Address); + Assert.Equal(4096, newmem.Size); + Assert.Equal(new Address(0x13FFF0000), newmem.PreferredAddress); + }); + Assert.Equal(["player_base", "ammo"], snapshot.RegisteredSymbols); + Assert.Equal([new Address(0x140000010), new Address(0x140000020)], snapshot.ExceptionRanges); + Assert.Equal( + [new AutoAssemblerSymbolInfo("newmem", new Address(0x140000000)), new AutoAssemblerSymbolInfo("return_here", new Address(0x140001005))], + snapshot.Symbols); + Assert.False(snapshot.HasCCodeSymbolList); + Assert.IsNotType(snapshot.Allocations); + patch!.Dispose(); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Snapshot_sorts_entries_by_ordinal_name() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + EngineTest.Run(L, """ + aa_disable_info = { + allocs = { + b = { address = 2, size = 1 }, B = { address = 1, size = 1 }, + a = { address = 3, size = 1 }, _z = { address = 4, size = 1 }, + }, + symbols = { b = 2, B = 1, a = 3, _z = 4 }, + } + """u8); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + + AutoAssemblerDisableInfoSnapshot snapshot = outcome.DisableInfo!; + Assert.Equal(["B", "_z", "a", "b"], snapshot.Allocations.Select(static allocation => allocation.Name), + StringComparer.Ordinal); + Assert.Equal(["B", "_z", "a", "b"], snapshot.Symbols.Select(static symbol => symbol.Name), StringComparer.Ordinal); + patch!.Dispose(); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Snapshot_keeps_addresses_above_2_pow_63_exactly() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + EngineTest.Run(L, """ + aa_disable_info = { + allocs = { high = { address = 0x8000000000000001, size = 8, prefered = 0xFFFFFFFFFFFFFFFF } }, + exceptionlist = { 0x8000000000000000 }, + symbols = { top = 0xFFFFFFFFFFFFFFFE }, + } + """u8); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + + AutoAssemblerDisableInfoSnapshot snapshot = outcome.DisableInfo!; + Assert.Equal(AutoAssemblerDisableInfoSnapshotStatus.Complete, snapshot.Status); + AutoAssemblerAllocationInfo high = Assert.Single(snapshot.Allocations); + Assert.Equal(new Address(0x8000_0000_0000_0001UL), high.Address); + Assert.Equal(new Address(0xFFFF_FFFF_FFFF_FFFFUL), high.PreferredAddress); + Assert.Equal(new Address(0x8000_0000_0000_0000UL), Assert.Single(snapshot.ExceptionRanges)); + Assert.Equal(new Address(0xFFFF_FFFF_FFFF_FFFEUL), Assert.Single(snapshot.Symbols).Address); + patch!.Dispose(); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Snapshot_truncates_at_the_entry_and_name_limits() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + EngineTest.Run(L, """ + aa_disable_info = { + allocs = { a = { address = 1, size = 1 }, b = { address = 2, size = 1 }, c = { address = 3, size = 1 } }, + registeredsymbols = { "first", "second", "third" }, + symbols = { short = 1, far_too_long_a_name = 2 }, + } + """u8); + AutoAssemblerOptions options = new() + { + MaxDisableInfoEntries = 2, + MaxDisableInfoNameBytes = 8 + }; + + AutoAssemblerApplyOutcome outcome = + AutoAssemblerPatcher.TryApplyWithOutcome("success", options, out AutoAssemblerPatch? patch); + + AutoAssemblerDisableInfoSnapshot snapshot = outcome.DisableInfo!; + Assert.Equal(AutoAssemblerApplyOutcomeKind.Applied, outcome.Kind); + Assert.Equal(AutoAssemblerDisableInfoSnapshotStatus.Truncated, snapshot.Status); + Assert.Equal(2, snapshot.Allocations.Count); + Assert.Equal(["first", "second"], snapshot.RegisteredSymbols); + Assert.Equal("short", Assert.Single(snapshot.Symbols).Name); + patch!.Dispose(); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_disable_count")); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Malformed_disable_info_publishes_the_patch_with_a_malformed_snapshot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + EngineTest.Run(L, """ + aa_disable_info = { + allocs = { + good = { address = 0x1000, size = 16 }, + notatable = 42, + badsize = { address = 0x2000, size = "big" }, + [7] = { address = 0x3000, size = 1 }, + }, + registeredsymbols = { "ok", 5 }, + exceptionlist = "not a table", + symbols = { fine = 0x10, broken = "zz" }, + ccodesymbols = "not a symbol list", + } + """u8); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.Applied, outcome.Kind); + Assert.Equal(EngineEffectState.Applied, outcome.Effect); + Assert.True(outcome.HasPatch); + AutoAssemblerDisableInfoSnapshot snapshot = outcome.DisableInfo!; + Assert.Equal(AutoAssemblerDisableInfoSnapshotStatus.Malformed, snapshot.Status); + Assert.Equal("good", Assert.Single(snapshot.Allocations).Name); + Assert.Equal(["ok"], snapshot.RegisteredSymbols); + Assert.Empty(snapshot.ExceptionRanges); + Assert.Equal("fine", Assert.Single(snapshot.Symbols).Name); + Assert.False(snapshot.HasCCodeSymbolList); + Assert.Equal(0, L.Top); + + // The rooted table, not the snapshot, stays the disable authority. + Assert.Equal(TargetReleaseStatus.Released, patch!.ReleaseWithTargetOutcome().Status); + Assert.True(AutoAssemblerTestHost.ReadBoolean(L, "auto_assembler_disable_received_info")); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Ccode_symbol_list_is_reported_but_never_owned_or_destroyed() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + FakeHost.InstallSymbolListClass(L); + AutoAssemblerTestHost.Install(L); + CEObject ccode = FakeHost.CreateSymbolList(L, "o.registered = true"); + FakeHost.SetGlobalObject(L, "aa_ccode_symbols", ccode); + EngineTest.Run(L, "aa_disable_info = { ccodesymbols = aa_ccode_symbols }"u8); + long destroyed = FakeHost.DestroyedCount(L); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + patch!.Dispose(); + + Assert.True(outcome.DisableInfo!.HasCCodeSymbolList); + Assert.Equal(AutoAssemblerDisableInfoSnapshotStatus.Complete, outcome.DisableInfo.Status); + Assert.False(FakeHost.IsDestroyed(L, ccode)); + Assert.Equal(destroyed, FakeHost.DestroyedCount(L)); + EngineTest.Run(L, "assert(symbol_list_register_calls == 0 and symbol_list_unregister_calls == 0)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Snapshot_restores_the_stack_and_leaves_the_table_unchanged() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using LuaFrame frame = new(L); + EngineTest.Run(L, "return { allocs = { x = { address = 1, size = 2 } }, symbols = { x = 1 } }"u8, 1); + int top = L.Top; + + AutoAssemblerDisableInfoSnapshot snapshot = + AutoAssemblerDisableInfoSnapshot.Read(L, -1, AutoAssemblerOptions.Default); + + Assert.Equal(top, L.Top); + Assert.Equal(AutoAssemblerDisableInfoSnapshotStatus.Complete, snapshot.Status); + Assert.Single(snapshot.Allocations); + Assert.Single(snapshot.Symbols); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerOutcomeTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerOutcomeTests.cs new file mode 100644 index 00000000..11aa453d --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerOutcomeTests.cs @@ -0,0 +1,484 @@ +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Assembly; + +/// +/// and +/// against the Lua double: factual +/// categories and effect states, opt-in bounded host text that never drives a category, and a published patch only +/// after Cheat Engine applied the script. +/// +[Trait("Category", "NativeLua")] +public sealed class AutoAssemblerOutcomeTests +{ + private static readonly AutoAssemblerOptions SCapture = new() { CaptureHostText = true }; + + [Fact] + [Trait("Qualification", "Q35")] + public void TryApplyWithOutcome_applied_publishes_the_patch_with_its_origin_and_snapshot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.Applied, outcome.Kind); + Assert.Equal(EngineEffectState.Applied, outcome.Effect); + Assert.True(outcome.HasPatch); + Assert.Null(outcome.Compensation); + Assert.True(outcome.TargetObservation.IsQualified); + Assert.True(outcome.PostEffectTargetCheck.GetValueOrDefault().IsCurrent); + AutoAssemblerDisableInfoSnapshot snapshot = Assert.IsType(outcome.DisableInfo); + Assert.Equal(AutoAssemblerDisableInfoSnapshotStatus.Complete, snapshot.Status); + AutoAssemblerPatch owner = Assert.IsType(patch); + Assert.Same(snapshot, owner.DisableInfo); + Assert.Equal(LuaRuntime.CurrentStateIdentity, owner.Origin.Runtime); + Assert.Equal(outcome.TargetObservation.Incarnation, owner.Origin.Target); + Assert.Equal(owner.TargetIncarnation, owner.Origin.Target); + Assert.True(owner.PostApplyTargetCheck.GetValueOrDefault().IsCurrent); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_apply_arguments")); + Assert.Equal(0, L.Top); + + Assert.Equal(TargetReleaseStatus.Released, owner.ReleaseWithTargetOutcome().Status); + Assert.Equal(2, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_disable_arguments")); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void TryApplyWithOutcome_rejected_reports_unknown_effect_and_no_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("apply-false", out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.Rejected, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Null(patch); + Assert.False(outcome.HasPatch); + Assert.Null(outcome.DisableInfo); + Assert.Null(outcome.PostEffectTargetCheck); + Assert.Null(outcome.Compensation); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_disable_count")); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_rejected_keeps_a_bounded_diagnostic_only_when_opted_in() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome silent = AutoAssemblerPatcher.TryApplyWithOutcome("apply-false", out _); + AutoAssemblerApplyOutcome captured = AutoAssemblerPatcher.TryApplyWithOutcome("apply-false", SCapture, out _); + + Assert.Null(silent.HostText); + Assert.False(silent.HostTextTruncated); + Assert.Equal(AutoAssemblerTestHost.RejectionDetail, captured.HostText); + Assert.False(captured.HostTextTruncated); + Assert.Equal(silent.Kind, captured.Kind); + Assert.Equal(silent.Effect, captured.Effect); + Assert.Equal(0, L.Top); + } + + [Theory] + [InlineData("\\195\\169\\195\\169\\195\\169", 5, "éé", true)] + [InlineData("\\240\\159\\152\\128\\240\\159\\152\\128", 6, "😀", true)] + [InlineData("\\240\\159\\152\\128\\240\\159\\152\\128", 8, "😀😀", false)] + [InlineData("a\\0b", 16, "a\0b", false)] + [InlineData("abcdef", 3, "abc", true)] + public void TryApplyWithOutcome_diagnostic_is_truncated_at_a_scalar_boundary(string luaEscapedDetail, + int maxBytes, string expected, bool truncated) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + EngineTest.Run(L, System.Text.Encoding.UTF8.GetBytes("aa_long_detail = \"" + luaEscapedDetail + "\"")); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("apply-reject-long", + new AutoAssemblerOptions { CaptureHostText = true, MaxHostTextBytes = maxBytes }, out _); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.Rejected, outcome.Kind); + Assert.Equal(expected, outcome.HostText); + Assert.Equal(truncated, outcome.HostTextTruncated); + Assert.DoesNotContain('�', outcome.HostText!); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_non_string_rejection_detail_keeps_the_rejected_category() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome outcome = + AutoAssemblerPatcher.TryApplyWithOutcome("apply-reject-nonstring", SCapture, out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.Rejected, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Null(outcome.HostText); + Assert.Null(patch); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q35")] + public void TryApplyWithOutcome_allocation_impossible_is_rejected_with_unknown_effect() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerCheckOutcome check = AutoAssemblerPatcher.TryCheck("apply-alloc-impossible", true); + AutoAssemblerApplyOutcome outcome = + AutoAssemblerPatcher.TryApplyWithOutcome("apply-alloc-impossible", SCapture, out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerCheckOutcomeKind.Accepted, check.Kind); + Assert.Equal(AutoAssemblerApplyOutcomeKind.Rejected, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Equal("allocation failure for newmem", outcome.HostText); + Assert.Null(patch); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_disable_count")); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_keeps_compilation_warnings_and_secondary_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + EngineTest.Run(L, "aa_warnings = 'warning: label never used'"u8); + + AutoAssemblerApplyOutcome silent = + AutoAssemblerPatcher.TryApplyWithOutcome("apply-warnings", out AutoAssemblerPatch? first); + AutoAssemblerApplyOutcome captured = + AutoAssemblerPatcher.TryApplyWithOutcome("apply-warnings", SCapture, out AutoAssemblerPatch? second); + EngineTest.Run(L, "aa_warnings = { 'not text' }"u8); + AutoAssemblerApplyOutcome nonString = + AutoAssemblerPatcher.TryApplyWithOutcome("apply-warnings", SCapture, out AutoAssemblerPatch? third); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.Applied, silent.Kind); + Assert.True(silent.HasHostWarnings); + Assert.Null(silent.HostWarnings); + Assert.True(captured.HasHostWarnings); + Assert.Equal("warning: label never used", captured.HostWarnings); + Assert.False(captured.HostWarningsTruncated); + Assert.True(nonString.HasHostWarnings); + Assert.Null(nonString.HostWarnings); + Assert.Equal(AutoAssemblerApplyOutcomeKind.Applied, nonString.Kind); + Assert.All(new[] { first, second, third }, static patch => Assert.NotNull(patch)); + first!.Dispose(); + second!.Dispose(); + third!.Dispose(); + Assert.Equal(3, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_disable_count")); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_success_without_warnings_reports_none() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", SCapture, out AutoAssemblerPatch? patch); + + Assert.False(outcome.HasHostWarnings); + Assert.Null(outcome.HostWarnings); + Assert.Null(outcome.HostText); + patch!.Dispose(); + Assert.Equal(0, L.Top); + } + + [Theory] + [InlineData("apply-true-no-table")] + [InlineData("apply-non-boolean")] + public void TryApplyWithOutcome_success_without_a_table_is_invalid_result_with_unknown_effect(string script) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome(script, out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.InvalidResult, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Null(patch); + Assert.Null(outcome.DisableInfo); + Assert.Null(outcome.Compensation); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q30.b")] + public void TryApplyWithOutcome_target_changed_after_the_effect_keeps_the_token_and_reports_unknown() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome outcome = + AutoAssemblerPatcher.TryApplyWithOutcome("apply-switch-target", out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.AppliedTargetChanged, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.True(outcome.HasPatch); + TargetIdentityCheck postCheck = Assert.NotNull(outcome.PostEffectTargetCheck); + Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, postCheck.Kind); + AutoAssemblerPatch owner = Assert.IsType(patch); + Assert.Equal(Environment.ProcessId, owner.TargetIncarnation.ProcessId); + Assert.Equal(postCheck, owner.PostApplyTargetCheck); + Assert.Equal(0, L.Top); + + // The token is kept, bound to the original incarnation: it disables only once that target is current again. + AutoAssemblerTestHost.SelectTarget(L, Environment.ProcessId); + Assert.Equal(TargetReleaseStatus.Released, owner.ReleaseWithTargetOutcome().Status); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_disable_count")); + Assert.True(AutoAssemblerTestHost.ReadBoolean(L, "auto_assembler_disable_received_info")); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_publication_failure_reports_one_compensation_and_the_snapshot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + EngineTest.Run(L, "aa_disable_info = { symbols = { newmem = 0x140000000 } }"u8); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.ApplyCore("success", AutoAssemblerOptions.Default, + AutoAssemblerTestHost.CreateDisableInfo, + static (_, _, _, _, _) => throw new InvalidOperationException("injected patch publication failure"), false, + out AutoAssemblerPatch? patch, out Exception? cause); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.HandoffFailed, outcome.Kind); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Null(patch); + Assert.False(outcome.HasPatch); + Assert.IsType(cause); + Assert.Equal(TargetReleaseStatus.Released, outcome.Compensation.GetValueOrDefault().Status); + AutoAssemblerDisableInfoSnapshot snapshot = Assert.IsType(outcome.DisableInfo); + Assert.Equal("newmem", Assert.Single(snapshot.Symbols).Name); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_disable_count")); + Assert.True(AutoAssemblerTestHost.ReadBoolean(L, "auto_assembler_disable_received_info")); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_missing_global_reports_not_started_and_restores_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + FakeHost.InstallQualifiedLocalTarget(L); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + AutoAssemblerCheckOutcome check = AutoAssemblerPatcher.TryCheck("success", true); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.GlobalUnavailable, outcome.Kind); + Assert.Equal(EngineEffectState.NotStarted, outcome.Effect); + Assert.Null(patch); + Assert.Equal(AutoAssemblerCheckOutcomeKind.GlobalUnavailable, check.Kind); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_global_resolution_failure_reports_the_protected_status() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + FakeHost.InstallQualifiedLocalTarget(L); + EngineTest.Run(L, """ + setmetatable(_G, { + __index = function(_, name) + if name == "autoAssemble" or name == "autoAssembleCheck" then error("global lookup failure") end + end + }) + """u8); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out _); + AutoAssemblerCheckOutcome check = AutoAssemblerPatcher.TryCheck("success", true); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure, outcome.Kind); + Assert.Equal(LuaStatus.RuntimeError, outcome.LuaStatus); + Assert.Equal(EngineEffectState.Unknown, outcome.Effect); + Assert.Equal(AutoAssemblerCheckOutcomeKind.ProtectedLuaFailure, check.Kind); + Assert.Equal(LuaStatus.RuntimeError, check.LuaStatus); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_unqualified_target_reports_not_started_before_any_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + AutoAssemblerTestHost.SelectTarget(L, 0); + + AutoAssemblerApplyOutcome outcome = AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.TargetIdentityUnavailable, outcome.Kind); + Assert.Equal(EngineEffectState.NotStarted, outcome.Effect); + Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, outcome.TargetObservation.Status); + Assert.Null(patch); + Assert.Equal(-1, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_apply_arguments")); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_protected_failure_reports_unknown_effect_and_recovers_on_the_next_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome failed = AutoAssemblerPatcher.TryApplyWithOutcome("apply-raise", out AutoAssemblerPatch? none); + Assert.Equal(0, L.Top); + AutoAssemblerApplyOutcome recovered = + AutoAssemblerPatcher.TryApplyWithOutcome("success", out AutoAssemblerPatch? patch); + + Assert.Equal(AutoAssemblerApplyOutcomeKind.ProtectedLuaFailure, failed.Kind); + Assert.Equal(LuaStatus.RuntimeError, failed.LuaStatus); + Assert.Equal(EngineEffectState.Unknown, failed.Effect); + Assert.Null(none); + Assert.Equal(AutoAssemblerApplyOutcomeKind.Applied, recovered.Kind); + patch!.Dispose(); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_localized_error_text_never_changes_the_category() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + AutoAssemblerApplyOutcome english = AutoAssemblerPatcher.TryApplyWithOutcome("apply-false", SCapture, out _); + AutoAssemblerApplyOutcome french = AutoAssemblerPatcher.TryApplyWithOutcome("apply-reject-french", SCapture, out _); + + Assert.Equal(english.Kind, french.Kind); + Assert.Equal(english.Effect, french.Effect); + Assert.Equal(english.HasPatch, french.HasPatch); + Assert.Equal(english.LuaStatus, french.LuaStatus); + Assert.Equal("rejeté par l'hôte", french.HostText); + Assert.NotEqual(english.HostText, french.HostText, StringComparer.Ordinal); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryApplyWithOutcome_refuses_out_of_range_options_before_any_lua_work() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + int providerCalls = FakeHost.ProviderCalls; + + Assert.Throws(() => AutoAssemblerPatcher.TryApplyWithOutcome("success", + new AutoAssemblerOptions { MaxHostTextBytes = 0 }, out _)); + Assert.Throws(() => AutoAssemblerPatcher.TryApplyWithOutcome("success", + new AutoAssemblerOptions { MaxDisableInfoEntries = 65537 }, out _)); + Assert.Throws(() => AutoAssemblerPatcher.TryCheck("success", true, + new AutoAssemblerOptions { MaxDisableInfoNameBytes = 4097 })); + Assert.Throws(() => AutoAssemblerPatcher.TryApplyWithOutcome(" ", out _)); + + Assert.Equal(providerCalls, FakeHost.ProviderCalls); + Assert.Equal(-1, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_apply_arguments")); + } + + [Fact] + public void TryCheck_success_and_failure_never_create_an_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + long destroyedBefore = FakeHost.DestroyedCount(L); + + AutoAssemblerCheckOutcome accepted = AutoAssemblerPatcher.TryCheck("success", true); + AutoAssemblerCheckOutcome rejected = AutoAssemblerPatcher.TryCheck("check-false", false, SCapture); + AutoAssemblerCheckOutcome silent = AutoAssemblerPatcher.TryCheck("check-false", false); + AutoAssemblerCheckOutcome raised = AutoAssemblerPatcher.TryCheck("check-raise", true); + AutoAssemblerCheckOutcome invalid = AutoAssemblerPatcher.TryCheck("check-non-boolean", true); + + Assert.Equal(AutoAssemblerCheckOutcomeKind.Accepted, accepted.Kind); + Assert.True(accepted.IsAccepted); + Assert.Equal(AutoAssemblerCheckOutcomeKind.Rejected, rejected.Kind); + Assert.Equal("syntax error at line 1", rejected.HostText); + Assert.Null(silent.HostText); + Assert.Equal(AutoAssemblerCheckOutcomeKind.ProtectedLuaFailure, raised.Kind); + Assert.Equal(LuaStatus.RuntimeError, raised.LuaStatus); + Assert.Equal(AutoAssemblerCheckOutcomeKind.InvalidResult, invalid.Kind); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_apply_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_disable_count")); + Assert.Equal(destroyedBefore, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + Assert.All(typeof(AutoAssemblerCheckOutcome).GetProperties(), static property => + Assert.False(typeof(IDisposable).IsAssignableFrom(property.PropertyType), property.Name)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TryCheck_passes_exactly_the_script_and_the_enable_flag(bool enable) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AutoAssemblerTestHost.Install(L); + + _ = AutoAssemblerPatcher.TryCheck("success", enable); + + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_check_count")); + Assert.Equal(2, AutoAssemblerTestHost.ReadCounter(L, "auto_assembler_check_arguments")); + Assert.Equal(enable, AutoAssemblerTestHost.ReadBoolean(L, "auto_assembler_check_enable")); + Assert.Equal(0, L.Top); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs index b3be42d0..e68c3c3b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs @@ -1,12 +1,13 @@ using System.Reflection; -using System.Text; using CheatEngine.SDK.Engine.Assembly; using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.References; +using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -24,23 +25,23 @@ public void Apply_on_success_retains_the_disable_info_until_Release_completes() EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); Assert.True(patch.IsEnabled); Assert.False(patch.IsDisposed); Assert.False(patch.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); patch.Release(); Assert.True(patch.IsDisposed); Assert.False(patch.IsEnabled); Assert.False(patch.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(AutoAssemblerTestHost.ReadBoolean(scope.State, "auto_assembler_disable_received_info")); Assert.Equal(0, scope.State.Top); } @@ -50,14 +51,14 @@ public void TryApply_when_CE_rejects_the_script_returns_false_without_an_owner() EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); bool applied = AutoAssemblerPatcher.TryApply("apply-false", out AutoAssemblerPatch? patch); Assert.False(applied); Assert.Null(patch); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } @@ -67,7 +68,7 @@ public void Apply_when_CE_rejects_the_script_throws_the_stable_expected_failure( EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); EngineOperationFailedException exception = Assert.Throws(() => AutoAssemblerPatcher.Apply("apply-false")); @@ -82,7 +83,7 @@ public void Apply_when_the_protected_CE_call_fails_preserves_the_Lua_failure_and EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); EngineLuaException exception = Assert.Throws(() => AutoAssemblerPatcher.Apply("apply-raise")); @@ -97,19 +98,19 @@ public void TryApply_when_patch_publication_fails_compensates_once_with_the_root EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); InvalidOperationException cause = new("injected patch publication failure"); EngineResourceHandoffException exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( - "success", out _, CreateDisableInfo, - (_, _, _) => throw cause)); + "success", out _, AutoAssemblerTestHost.CreateDisableInfo, + (_, _, _, _, _) => throw cause)); Assert.Same(cause, exception.InnerException); Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(AutoAssemblerTestHost.ReadBoolean(scope.State, "auto_assembler_disable_received_info")); Assert.Equal(0, scope.State.Top); } @@ -119,28 +120,29 @@ public void TryApply_when_disable_info_tracking_fails_compensates_once_with_the_ EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); EngineResourceHandoffException exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( "success", out _, FailDisableInfoTracking, - static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); + static (_, _, _, _, _) => throw new InvalidOperationException("patch factory must not be called"))); Assert.Equal(TargetReleaseStatus.Released, exception.CleanupOutcome.Status); Assert.IsType(exception.InnerException); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(AutoAssemblerTestHost.ReadBoolean(scope.State, "auto_assembler_disable_received_info")); Assert.Equal(0, scope.State.Top); } [Fact] + [Trait("Qualification", "Q35")] public void Dispose_after_a_successful_disable_is_idempotent_and_never_replays_disable() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); patch.Dispose(); @@ -148,7 +150,7 @@ public void Dispose_after_a_successful_disable_is_idempotent_and_never_replays_d Assert.True(patch.IsDisposed); Assert.False(patch.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } @@ -158,7 +160,7 @@ public void ReleaseWithTargetOutcome_after_a_successful_disable_reports_released EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); TargetReleaseOutcome outcome = patch.ReleaseWithTargetOutcome(); @@ -168,23 +170,27 @@ public void ReleaseWithTargetOutcome_after_a_successful_disable_reports_released Assert.False(patch.RequiresManualRecovery); Assert.True(patch.IsDisposed); Assert.Equal(outcome, patch.LastReleaseOutcome); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } [Fact] + [Trait("Qualification", "Q30.b")] public void ReleaseWithTargetOutcome_when_the_current_target_differs_refuses_without_disabling() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); using LuaFrame frame = new(scope.State); scope.State.CreateTable(); + AutoAssemblerDisableInfoSnapshot snapshot = + AutoAssemblerDisableInfoSnapshot.Read(scope.State, -1, AutoAssemblerOptions.Default); LuaRef disableInfo = scope.State.CreateRef(); int originalTargetId = Environment.ProcessId == 1 ? 2 : 1; AutoAssemblerPatch patch = new("success", disableInfo, - new TargetProcessIncarnation(originalTargetId, 1)); + new EngineResourceOrigin(LuaRuntime.CurrentStateIdentity, new TargetProcessIncarnation(originalTargetId, 1)), + snapshot, null); TargetReleaseOutcome outcome = patch.ReleaseWithTargetOutcome(); @@ -194,7 +200,7 @@ public void ReleaseWithTargetOutcome_when_the_current_target_differs_refuses_wit Assert.True(patch.IsDisposed); Assert.True(patch.RequiresManualRecovery); Assert.False(disableInfo.IsResolved); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } @@ -204,7 +210,7 @@ public void ReleaseWithTargetOutcome_when_disable_fails_reports_an_unconfirmed_e EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("disable-false"); LuaRef disableInfo = GetDisableInfo(patch); @@ -218,7 +224,7 @@ public void ReleaseWithTargetOutcome_when_disable_fails_reports_an_unconfirmed_e Assert.True(patch.IsDisposed); Assert.True(patch.RequiresManualRecovery); Assert.False(disableInfo.IsResolved); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } @@ -228,7 +234,7 @@ public void ReleaseWithTargetOutcome_when_disable_raises_reports_the_protected_f EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("disable-raise"); LuaRef disableInfo = GetDisableInfo(patch); @@ -240,7 +246,7 @@ public void ReleaseWithTargetOutcome_when_disable_raises_reports_the_protected_f Assert.True(patch.IsDisposed); Assert.True(patch.RequiresManualRecovery); Assert.False(disableInfo.IsResolved); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } @@ -250,7 +256,7 @@ public void ReleaseWithTargetOutcome_when_the_attached_host_cannot_provide_a_sta EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); LuaRef disableInfo = GetDisableInfo(patch); @@ -267,7 +273,7 @@ public void ReleaseWithTargetOutcome_when_the_attached_host_cannot_provide_a_sta Assert.True(patch.IsDisposed); Assert.True(patch.RequiresManualRecovery); Assert.False(disableInfo.IsResolved); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } @@ -277,7 +283,7 @@ public void Release_when_the_attached_host_cannot_provide_a_state_preserves_the_ EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); LuaRef disableInfo = GetDisableInfo(patch); @@ -291,7 +297,7 @@ public void Release_when_the_attached_host_cannot_provide_a_state_preserves_the_ Assert.True(patch.RequiresManualRecovery); Assert.True(patch.IsDisposed); Assert.False(disableInfo.IsResolved); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } @@ -301,7 +307,7 @@ public void Release_paths_after_Dispose_throw_without_replaying_disable() EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); patch.Dispose(); @@ -311,17 +317,18 @@ public void Release_paths_after_Dispose_throw_without_replaying_disable() { _ = patch.ReleaseWithTargetOutcome(); }); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } [Fact] + [Trait("Qualification", "Q35")] public void Release_when_CE_returns_false_marks_manual_recovery_releases_the_LuaRef_and_never_retries() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("disable-false"); LuaRef disableInfo = GetDisableInfo(patch); @@ -332,11 +339,11 @@ public void Release_when_CE_returns_false_marks_manual_recovery_releases_the_Lua Assert.False(patch.IsEnabled); Assert.True(patch.RequiresManualRecovery); Assert.False(disableInfo.IsResolved); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); patch.Dispose(); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } @@ -346,21 +353,21 @@ public void TryApply_when_patch_publication_and_compensation_fail_reports_the_un EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); InvalidOperationException cause = new("injected patch publication failure"); EngineResourceHandoffException exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( - "disable-false", out _, CreateDisableInfo, - (_, _, _) => throw cause)); + "disable-false", out _, AutoAssemblerTestHost.CreateDisableInfo, + (_, _, _, _, _) => throw cause)); Assert.Same(cause, exception.InnerException); Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); Assert.True(exception.CleanupOutcome.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(AutoAssemblerTestHost.ReadBoolean(scope.State, "auto_assembler_disable_received_info")); Assert.Equal(0, scope.State.Top); } @@ -370,20 +377,20 @@ public void TryApply_when_tracking_and_compensation_fail_reports_the_unconfirmed EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); EngineResourceHandoffException exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( "disable-raise", out _, FailDisableInfoTracking, - static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); + static (_, _, _, _, _) => throw new InvalidOperationException("patch factory must not be called"))); Assert.IsType(exception.InnerException); Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); Assert.Equal(EngineFailureKind.ProtectedLuaFailure, exception.CleanupOutcome.FailureKind); Assert.True(exception.CleanupOutcome.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(AutoAssemblerTestHost.ReadBoolean(scope.State, "auto_assembler_disable_received_info")); Assert.Equal(0, scope.State.Top); } @@ -393,20 +400,20 @@ public void TryApply_when_tracking_fails_and_compensation_returns_false_reports_ EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); EngineResourceHandoffException exception = Assert.Throws(() => AutoAssemblerPatcher.TryApplyCore( "disable-false", out _, FailDisableInfoTracking, - static (_, _, _) => throw new InvalidOperationException("patch factory must not be called"))); + static (_, _, _, _, _) => throw new InvalidOperationException("patch factory must not be called"))); Assert.IsType(exception.InnerException); Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, exception.CleanupOutcome.Status); Assert.Equal(EngineFailureKind.ExpectedOperationFailure, exception.CleanupOutcome.FailureKind); Assert.True(exception.CleanupOutcome.RequiresManualRecovery); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_apply_count")); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); - Assert.True(ReadBoolean(scope.State, "auto_assembler_disable_received_info")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_apply_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.True(AutoAssemblerTestHost.ReadBoolean(scope.State, "auto_assembler_disable_received_info")); Assert.Equal(0, scope.State.Top); } @@ -416,7 +423,7 @@ public void Dispose_when_the_disable_call_raises_marks_manual_recovery_releases_ EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("disable-raise"); LuaRef disableInfo = GetDisableInfo(patch); @@ -427,31 +434,32 @@ public void Dispose_when_the_disable_call_raises_marks_manual_recovery_releases_ Assert.True(patch.RequiresManualRecovery); Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, patch.LastReleaseOutcome.Status); Assert.False(disableInfo.IsResolved); - Assert.Equal(1, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(1, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } [Fact] + [Trait("Qualification", "Q30.b")] public void Release_after_an_external_target_termination_refuses_without_disabling_the_patch() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); - InstallAutoAssembler(scope.State); + AutoAssemblerTestHost.Install(scope.State); AutoAssemblerPatch patch = AutoAssemblerPatcher.Apply("success"); - EngineTest.Run(scope.State, "auto_assembler_target_process_id = 0"u8); + AutoAssemblerTestHost.SelectTarget(scope.State, 0); EngineTargetIdentityException exception = Assert.Throws(patch.Release); Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, exception.Check.Kind); Assert.Equal(TargetReleaseStatus.RefusedNoTarget, patch.LastReleaseOutcome.Status); Assert.True(patch.RequiresManualRecovery); - Assert.Equal(0, ReadCounter(scope.State, "auto_assembler_disable_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(scope.State, "auto_assembler_disable_count")); Assert.Equal(0, scope.State.Top); } [Fact] - public void Dispose_after_disable_and_reenable_does_not_route_a_stale_disable_info_into_the_new_lifecycle() + public void Dispose_after_disable_and_reenable_refuses_the_stale_disable_info_as_a_runtime_change() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); @@ -460,7 +468,7 @@ public void Dispose_after_disable_and_reenable_does_not_route_a_stale_disable_in HostScope firstScope = new(state); try { - InstallAutoAssembler(firstScope.State); + AutoAssemblerTestHost.Install(firstScope.State); patch = AutoAssemblerPatcher.Apply("success"); disableInfo = GetDisableInfo(patch); } @@ -476,9 +484,10 @@ public void Dispose_after_disable_and_reenable_does_not_route_a_stale_disable_in Assert.True(patch.IsDisposed); Assert.True(patch.RequiresManualRecovery); - Assert.Equal(TargetReleaseStatus.NotInvoked, patch.LastReleaseOutcome.Status); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, patch.LastReleaseOutcome.Status); + Assert.False(patch.Origin.IsCurrentRuntime); Assert.False(disableInfo.IsResolved); - Assert.Equal(0, ReadCounter(secondScope.State, "auto_assembler_disable_count")); + Assert.Equal(0, AutoAssemblerTestHost.ReadCounter(secondScope.State, "auto_assembler_disable_count")); Assert.Equal(0, secondScope.State.Top); } @@ -490,55 +499,8 @@ private static LuaRef GetDisableInfo(AutoAssemblerPatch patch) return Assert.IsType(field.GetValue(patch)); } - private static LuaRef CreateDisableInfo(LuaState state) - { - return state.CreateRef(); - } - private static LuaRef FailDisableInfoTracking(LuaState _) { throw new EngineLuaException("AutoAssemblerApply", LuaStatus.MemoryError); } - - private static void InstallAutoAssembler(LuaState state) - { - EngineTest.Run(state, Encoding.UTF8.GetBytes("auto_assembler_target_process_id = " + - Environment.ProcessId + - "\nfunction getOpenedProcessID() return auto_assembler_target_process_id end")); - EngineTest.Run(state, """ - auto_assembler_apply_count = 0 - auto_assembler_disable_count = 0 - auto_assembler_disable_received_info = false - - autoAssemble = function(script, disableInfo) - if disableInfo == nil then - if script == "apply-false" then return false, nil end - if script == "apply-raise" then error("apply failure") end - auto_assembler_apply_count = auto_assembler_apply_count + 1 - return true, { sequence = auto_assembler_apply_count } - end - - auto_assembler_disable_count = auto_assembler_disable_count + 1 - auto_assembler_disable_received_info = type(disableInfo) == "table" - if script == "disable-false" then return false end - if script == "disable-raise" then error("disable failure") end - return true - end - """u8); - } - - private static long ReadCounter(LuaState state, string name) - { - using LuaFrame frame = new(state); - Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); - return EngineTest.ReadInteger(state, -1); - } - - private static bool ReadBoolean(LuaState state, string name) - { - using LuaFrame frame = new(state); - Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); - Assert.Equal(LuaType.Boolean, state.TypeOf(-1)); - return state.ToBoolean(-1); - } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerTestHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerTestHost.cs new file mode 100644 index 00000000..94bdb3d4 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerTestHost.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using System.Text; + +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.References; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Tests.Assembly; + +/// +/// A Lua double of Cheat Engine's autoAssemble and autoAssembleCheck that keeps CE 7.7's documented +/// shapes: apply returns (true, disableInfo[, warnings]) or (false, detail), disable takes the script +/// and the disable-info table, and the check returns (true) or (false, message). The script text selects +/// the behavior; argument counts and calls are recorded in globals. +/// +/// +/// Scripts: success, apply-false (rejected with a detail string), apply-reject-french, +/// apply-reject-long (the detail in aa_long_detail), apply-reject-nonstring, +/// apply-alloc-impossible (syntax accepted, allocation refused), apply-raise, +/// apply-warnings (warnings in aa_warnings), apply-true-no-table, apply-non-boolean, +/// apply-switch-target (the target changes during the call), disable-false, disable-raise. +/// A successful apply returns aa_disable_info when it is set, otherwise a fresh table. +/// +internal static class AutoAssemblerTestHost +{ + public const string RejectionDetail = "rejected by host"; + + private static ReadOnlySpan Model => """ + auto_assembler_apply_count = 0 + auto_assembler_disable_count = 0 + auto_assembler_disable_received_info = false + auto_assembler_apply_arguments = -1 + auto_assembler_disable_arguments = -1 + auto_assembler_check_count = 0 + auto_assembler_check_arguments = -1 + auto_assembler_check_enable = nil + aa_warnings = nil + aa_disable_info = nil + aa_long_detail = nil + + autoAssemble = function(...) + local count = select("#", ...) + local script, disableInfo = ... + if count == 1 then + auto_assembler_apply_arguments = count + if script == "apply-false" then return false, "rejected by host" end + if script == "apply-reject-french" then return false, "rejeté par l'hôte" end + if script == "apply-reject-long" then return false, aa_long_detail end + if script == "apply-reject-nonstring" then return false, 42 end + if script == "apply-alloc-impossible" then return false, "allocation failure for newmem" end + if script == "apply-raise" then error("apply failure") end + if script == "apply-non-boolean" then return 1, {} end + auto_assembler_apply_count = auto_assembler_apply_count + 1 + if script == "apply-true-no-table" then return true, nil end + if script == "apply-switch-target" then fake_target_pid = 0 end + local info = aa_disable_info or { sequence = auto_assembler_apply_count } + if script == "apply-warnings" then return true, info, aa_warnings end + return true, info + end + + auto_assembler_disable_arguments = count + auto_assembler_disable_count = auto_assembler_disable_count + 1 + auto_assembler_disable_received_info = type(disableInfo) == "table" + if script == "disable-false" then return false end + if script == "disable-raise" then error("disable failure") end + return true + end + + autoAssembleCheck = function(...) + auto_assembler_check_count = auto_assembler_check_count + 1 + auto_assembler_check_arguments = select("#", ...) + local script, enable = ... + auto_assembler_check_enable = enable + if script == "check-false" then return false, "syntax error at line 1" end + if script == "check-raise" then error("check failure") end + if script == "check-non-boolean" then return "yes" end + return true + end + """u8; + + /// Installs a qualified local target, then the Auto Assembler double. + public static void Install(LuaState state) + { + FakeHost.InstallQualifiedLocalTarget(state); + EngineTest.Run(state, Model); + } + + /// Sets the current target PID the fixture reports (0 means no target). + public static void SelectTarget(LuaState state, int processId) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes( + "fake_target_pid = " + processId.ToString(CultureInfo.InvariantCulture))); + } + + /// Reads an integer global. + public static long ReadCounter(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + return EngineTest.ReadInteger(state, -1); + } + + /// Reads a boolean global. + public static bool ReadBoolean(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + Assert.Equal(LuaType.Boolean, state.TypeOf(-1)); + return state.ToBoolean(-1); + } + + /// The default disable-info tracker: roots the table on top. + public static LuaRef CreateDisableInfo(LuaState state) + { + return state.CreateRef(); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionAssemblerTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionAssemblerTests.cs new file mode 100644 index 00000000..2d134755 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionAssemblerTests.cs @@ -0,0 +1,280 @@ +using System.Text; + +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Assembly; + +/// +/// Native-Lua tests for the assembler's call shape: argument count per overload, the echoed preference and +/// range-check option, rejection shapes and exact text bytes. The assemble stand-in records what it received; +/// it is not Cheat Engine's assembler. +/// +[Trait("Category", "NativeLua")] +public sealed class InstructionAssemblerTests +{ + private static ReadOnlySpan AssembleRecorder => """ + assemble_argument_count = -1 + assemble_line_length = -1 + assemble_address_type = 'none' + assemble_address = 0 + assemble_preference = 'none' + assemble_skip_range_check = 'none' + assemble_message_reads = 0 + assemble_mode = 'bytes' + local message = setmetatable({}, { + __tostring = function() assemble_message_reads = assemble_message_reads + 1 return 'message' end, + __index = function() assemble_message_reads = assemble_message_reads + 1 end, + __len = function() assemble_message_reads = assemble_message_reads + 1 return 0 end, + }) + function assemble(...) + local line, address, preference, skip = ... + assemble_argument_count = select('#', ...) + assemble_line_length = #line + assemble_address_type = math.type(address) + assemble_address = address + assemble_preference = preference == nil and 'nil' or preference + if assemble_argument_count >= 4 then assemble_skip_range_check = skip end + if assemble_mode == 'rejected' then return nil end + if assemble_mode == 'rejected-with-message' then return nil, 'unknown opcode: frobnicate' end + if assemble_mode == 'rejected-with-probe' then return nil, message end + if assemble_mode == 'nothing' then return end + if assemble_mode == 'string' then return 'E9' end + return { 0xEB, 0xFE } + end + """u8; + + [Theory] + [InlineData(AssemblePreference.None, false)] + [InlineData(AssemblePreference.None, true)] + [InlineData(AssemblePreference.Short, false)] + [InlineData(AssemblePreference.Short, true)] + [InlineData(AssemblePreference.Long, false)] + [InlineData(AssemblePreference.Long, true)] + [InlineData(AssemblePreference.Far, false)] + [InlineData(AssemblePreference.Far, true)] + public void assemble_with_a_preference_and_range_check_option_passes_four_arguments_and_echoes_them( + AssemblePreference preference, bool skipRangeCheck) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstructionTargetProfile profile = Install(scope.State); + Span destination = stackalloc byte[2]; + Address origin = 0x1_0000_0000UL; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(profile, "jmp short $", origin, + preference, skipRangeCheck, destination, out InstructionAssembly assembly); + + Assert.Equal(InstructionOperationStatus.Success, status); + Assert.Equal(new byte[] { 0xEB, 0xFE }, destination.ToArray()); + Assert.Equal(new InstructionAssembly(profile.Target, profile.Profile, origin, preference, skipRangeCheck, 2, + 2), assembly); + Assert.Equal(4, ReadInteger(scope.State, "assemble_argument_count")); + Assert.Equal((long) preference, ReadInteger(scope.State, "assemble_preference")); + Assert.Equal(skipRangeCheck, ReadBoolean(scope.State, "assemble_skip_range_check")); + Assert.Equal("integer", ReadString(scope.State, "assemble_address_type")); + Assert.Equal(0x1_0000_0000L, ReadInteger(scope.State, "assemble_address")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void legacy_assemble_overload_still_passes_exactly_two_arguments() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstructionTargetProfile profile = Install(scope.State); + Span destination = stackalloc byte[2]; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(profile, "jmp short $", 0x401000UL, + destination, out int written, out int requiredLength); + + Assert.Equal(InstructionOperationStatus.Success, status); + Assert.Equal(2, written); + Assert.Equal(2, requiredLength); + // The legacy overload omits the preference and the option (no pushed nil), so CE applies its own defaults. + Assert.Equal(2, ReadInteger(scope.State, "assemble_argument_count")); + Assert.Equal("nil", ReadString(scope.State, "assemble_preference")); + Assert.Equal("none", ReadString(scope.State, "assemble_skip_range_check")); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData((AssemblePreference) 4)] + [InlineData((AssemblePreference) 255)] + public void assemble_rejects_an_undefined_preference_before_entering_lua(AssemblePreference preference) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstructionTargetProfile profile = Install(scope.State); + byte[] destination = new byte[2]; + + Assert.Throws(() => InstructionAssembler.TryAssemble(profile, "nop", 0x401000UL, + preference, false, destination, out _)); + Assert.Equal(-1, ReadInteger(scope.State, "assemble_argument_count")); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("rejected", "frobnicate eax")] + [InlineData("rejected-with-message", "frobnicate eax")] + [InlineData("rejected", "jmp missing_symbol")] + [InlineData("rejected-with-message", "jmp missing_symbol")] + [InlineData("rejected-with-probe", "jmp missing_symbol")] + public void assemble_rejection_with_or_without_a_host_message_is_instruction_rejected_without_reading_the_message( + string mode, string instruction) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstructionTargetProfile profile = Install(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("assemble_mode = '" + mode + "'")); + Span destination = stackalloc byte[2]; + destination.Fill(0xA5); + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(profile, instruction, 0x401000UL, + AssemblePreference.Long, false, destination, out InstructionAssembly assembly); + + Assert.Equal(InstructionOperationStatus.InstructionRejected, status); + Assert.Equal(0, assembly.Written); + Assert.Equal(0, assembly.RequiredLength); + Assert.Equal(AssemblePreference.Long, assembly.Preference); + Assert.Equal(new byte[] { 0xA5, 0xA5 }, destination.ToArray()); + Assert.Equal(0, ReadInteger(scope.State, "assemble_message_reads")); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("nothing")] + [InlineData("string")] + public void assemble_without_a_result_or_with_a_non_table_result_is_an_invalid_result(string mode) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstructionTargetProfile profile = Install(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("assemble_mode = '" + mode + "'")); + Span destination = stackalloc byte[2]; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(profile, "nop", 0x401000UL, + AssemblePreference.None, false, destination, out InstructionAssembly assembly); + + Assert.Equal(InstructionOperationStatus.InvalidResult, status); + Assert.Equal(0, assembly.Written); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void assemble_with_a_short_destination_reports_the_required_length_and_echoes_the_context() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstructionTargetProfile profile = Install(scope.State); + Span destination = stackalloc byte[1]; + destination[0] = 0xA5; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(profile, "jmp short $", 0x401000UL, + AssemblePreference.Short, true, destination, out InstructionAssembly assembly); + + Assert.Equal(InstructionOperationStatus.DestinationTooSmall, status); + Assert.Equal(0, assembly.Written); + Assert.Equal(2, assembly.RequiredLength); + Assert.True(assembly.SkipRangeCheck); + Assert.Equal(new Address(0x401000UL), assembly.Origin); + Assert.Equal(0xA5, destination[0]); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void assemble_keeps_embedded_nul_bytes_of_the_instruction_text() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstructionTargetProfile profile = Install(scope.State); + const string Instruction = "db 'a\0b' é"; + Span destination = stackalloc byte[2]; + + InstructionOperationStatus legacy = InstructionAssembler.TryAssemble(profile, Instruction, 0x401000UL, + destination, out _, out _); + long legacyLength = ReadInteger(scope.State, "assemble_line_length"); + InstructionOperationStatus detailed = InstructionAssembler.TryAssemble(profile, Instruction, 0x401000UL, + AssemblePreference.None, false, destination, out _); + + Assert.Equal(InstructionOperationStatus.Success, legacy); + Assert.Equal(InstructionOperationStatus.Success, detailed); + Assert.Equal(Encoding.UTF8.GetByteCount(Instruction), legacyLength); + Assert.Equal(Encoding.UTF8.GetByteCount(Instruction), ReadInteger(scope.State, "assemble_line_length")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void assemble_on_an_x86_profile_refuses_an_origin_above_4_gib_before_entering_lua_and_echoes_it() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X86TargetFacts(scope.State, 21544); + EngineTest.Run(scope.State, AssembleRecorder); + Assert.Equal(InstructionOperationStatus.Success, + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile x86)); + Span destination = stackalloc byte[2]; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(x86, "nop", 0x1_0000_0000UL, + AssemblePreference.Far, false, destination, out InstructionAssembly assembly); + + Assert.Equal(InstructionOperationStatus.AddressExceedsProfileWidth, status); + Assert.Equal(InstructionProfile.X86, assembly.Profile); + Assert.Equal(new Address(0x1_0000_0000UL), assembly.Origin); + Assert.Equal(-1, ReadInteger(scope.State, "assemble_argument_count")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void assemble_preference_values_equal_the_ce_constants() + { + // TassemblerPreference = (apNone, apShort, apLong, apFar): Assemblerunit.pas:2712 at ec45d5f; celua.txt:236-238. + Assert.Equal(0, (byte) AssemblePreference.None); + Assert.Equal(1, (byte) AssemblePreference.Short); + Assert.Equal(2, (byte) AssemblePreference.Long); + Assert.Equal(3, (byte) AssemblePreference.Far); + Assert.Equal(4, Enum.GetValues().Length); + } + + private static InstructionTargetProfile Install(LuaState state) + { + FakeHost.InstallCe77X64TargetFacts(state, 45052); + EngineTest.Run(state, AssembleRecorder); + InstructionOperationStatus status = InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile profile); + Assert.Equal(InstructionOperationStatus.Success, status); + return profile; + } + + private static long ReadInteger(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + return EngineTest.ReadInteger(state, -1); + } + + private static string ReadString(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + return EngineTest.ReadString(state, -1); + } + + private static bool ReadBoolean(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + Assert.Equal(LuaType.Boolean, state.TypeOf(-1)); + return state.ToBoolean(-1); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionOperationsTests.cs index 8e618dc8..6500303b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionOperationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionOperationsTests.cs @@ -1,9 +1,11 @@ +using System.Globalization; using System.Text; using CheatEngine.SDK.Engine.Assembly; using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -17,7 +19,8 @@ namespace CheatEngine.SDK.Engine.Tests.Assembly; public sealed class InstructionOperationsTests { [Fact] - public void ObserveCurrent_builds_an_x64_profile_from_coherent_target_probes_and_rejects_a_contradiction() + [Trait("Qualification", "Q32.a")] + public void observe_current_maps_the_ce_x86_family_with_64_bit_to_x64() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); @@ -31,25 +34,238 @@ public void ObserveCurrent_builds_an_x64_profile_from_coherent_target_probes_and Assert.Equal(4242, targetProfile.Target.Value); Assert.Equal(CheatEngineArchitecture.X64, targetProfile.Profile.Architecture); Assert.Equal(PointerSize.Bit64, targetProfile.Profile.AddressWidth); + Assert.True(targetProfile.Profile.IsValid); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q32.b")] + public void observe_current_maps_the_ce_x86_family_without_64_bit_to_x86() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, "instruction_target_is_64bit = false"u8); + + InstructionOperationStatus observed = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile targetProfile); + + Assert.Equal(InstructionOperationStatus.Success, observed); + Assert.Equal(CheatEngineArchitecture.X86, targetProfile.Profile.Architecture); + Assert.Equal(PointerSize.Bit32, targetProfile.Profile.AddressWidth); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [Trait("Qualification", "Q32.d")] + [InlineData(true)] + [InlineData(false)] + public void observe_current_rejects_x86_and_arm_reported_together(bool is64Bit) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes( + "instruction_target_is_x86 = true\ninstruction_target_is_arm = true\ninstruction_target_is_64bit = " + + LuaBoolean(is64Bit))); + + InstructionOperationStatus observed = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile rejectedProfile); + + Assert.Equal(InstructionOperationStatus.InvalidProfile, observed); + Assert.Equal(default, rejectedProfile); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [Trait("Qualification", "Q32.d")] + [InlineData(true)] + [InlineData(false)] + public void observe_current_rejects_a_target_reported_in_neither_family(bool is64Bit) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes( + "instruction_target_is_x86 = false\ninstruction_target_is_arm = false\ninstruction_target_is_64bit = " + + LuaBoolean(is64Bit))); - EngineTest.Run(scope.State, "instruction_target_is_x86 = true\ninstruction_target_is_64bit = true"u8); - InstructionOperationStatus contradictory = + InstructionOperationStatus observed = InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile rejectedProfile); - Assert.Equal(InstructionOperationStatus.InvalidProfile, contradictory); + Assert.Equal(InstructionOperationStatus.InvalidProfile, observed); Assert.Equal(default, rejectedProfile); Assert.Equal(0, scope.State.Top); } [Fact] - public void ObserveCurrent_uses_the_target_ARM_probe_and_matching_width_without_using_the_host_width() + [Trait("Qualification", "Q32")] + public void observe_current_without_a_selected_target_reports_target_not_selected_although_the_probes_look_like_x64() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + // CE 7.7 with no target: getOpenedProcessID() == 0 while the ISA probes read exactly like x64 (spike C3 D2). + EngineTest.Run(scope.State, """ + function getOpenedProcessID() return 0 end + function targetIs64Bit() error('targetIs64Bit must not be called without a target') end + function targetIsX86() error('targetIsX86 must not be called without a target') end + function targetIsArm() error('targetIsArm must not be called without a target') end + """u8); + + InstructionOperationStatus observed = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile targetProfile); + + Assert.Equal(InstructionOperationStatus.TargetNotSelected, observed); + Assert.Equal(default, targetProfile); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q30.c")] + public void observe_current_reports_a_file_as_process_selection_as_an_unsupported_target_backend() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + // openFileAsProcess stores processid := $FFFFFFFF and getOpenedProcessID pushes it as a Lua integer (ec45d5f). + EngineTest.Run(scope.State, """ + function getOpenedProcessID() return 4294967295 end + function targetIs64Bit() error('no ISA probe for a file opened as a process') end + function targetIsX86() error('no ISA probe for a file opened as a process') end + function targetIsArm() error('no ISA probe for a file opened as a process') end + """u8); + + InstructionOperationStatus observed = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile targetProfile); + + Assert.Equal(InstructionOperationStatus.UnsupportedTargetBackend, observed); + Assert.Equal(default, targetProfile); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [Trait("Qualification", "Q32.d")] + [InlineData("targetIsX86")] + [InlineData("targetIsArm")] + [InlineData("targetIs64Bit")] + public void observe_current_reports_an_absent_isa_probe_as_global_unavailable(string absentGlobal) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(absentGlobal + " = nil")); + + InstructionOperationStatus observed = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile targetProfile); + + Assert.Equal(InstructionOperationStatus.GlobalUnavailable, observed); + Assert.Equal(default, targetProfile); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("targetIsX86", "return nil")] + [InlineData("targetIsArm", "return 1")] + [InlineData("targetIs64Bit", "return 'true'")] + public void observe_current_reports_a_non_boolean_isa_probe_as_invalid_result(string global, string body) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("function " + global + "() " + body + " end")); + + InstructionOperationStatus observed = InstructionProfiles.TryObserveCurrent(out _); + + Assert.Equal(InstructionOperationStatus.InvalidResult, observed); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observe_current_reports_a_raising_isa_probe_as_lua_failure_and_recovers_on_the_next_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, """ + raise_isa_probe = true + function targetIsArm() + if raise_isa_probe then error('fixture ISA probe failure') end + return instruction_target_is_arm + end + """u8); + + InstructionOperationStatus failed = InstructionProfiles.TryObserveCurrent(out _); + Assert.Equal(0, scope.State.Top); + EngineTest.Run(scope.State, "raise_isa_probe = false"u8); + InstructionOperationStatus recovered = InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile profile); + + Assert.Equal(InstructionOperationStatus.LuaFailure, failed); + Assert.Equal(InstructionOperationStatus.Success, recovered); + Assert.Equal(CheatEngineArchitecture.X64, profile.Profile.Architecture); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void observe_current_reports_a_selection_change_between_the_bracketing_pid_reads_as_target_changed() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, "function targetIsArm() instruction_target_process_id = 5151 return false end"u8); + + InstructionOperationStatus observed = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile targetProfile); + + Assert.Equal(InstructionOperationStatus.TargetChanged, observed); + Assert.Equal(default, targetProfile); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observe_current_reads_only_the_pid_and_the_three_isa_probes() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, """ + function getPointerSize() error('the instruction profile never reads the configured pointer size') end + function getABI() error('the instruction profile never reads the ABI') end + function isConnectedToCEServer() error('the instruction profile never reads the backend') end + function setAssemblerMode() error('the SDK never changes the assembler mode') end + """u8); + + InstructionOperationStatus observed = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile profile); + + Assert.Equal(InstructionOperationStatus.Success, observed); + Assert.Equal(CheatEngineArchitecture.X64, profile.Profile.Architecture); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q32.d")] + public void observe_current_maps_the_arm_family_to_arm32_or_arm64_by_the_64_bit_flag_without_the_host_width() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); InstallInstructionGlobals(scope.State); - EngineTest.Run(scope.State, "instruction_target_is_64bit = false\ninstruction_target_is_arm = true"u8); + EngineTest.Run(scope.State, """ + instruction_target_is_x86 = false + instruction_target_is_64bit = false + instruction_target_is_arm = true + """u8); InstructionTargetProfile arm32 = Observe(scope.State); EngineTest.Run(scope.State, "instruction_target_is_64bit = true"u8); @@ -62,6 +278,45 @@ public void ObserveCurrent_uses_the_target_ARM_probe_and_matching_width_without_ Assert.Equal(0, scope.State.Top); } + [Fact] + [Trait("Qualification", "Q32.a")] + [Trait("Qualification", "Q32.b")] + public void x64_and_x86_profiles_carry_their_own_address_width() + { + Assert.Equal(CheatEngineArchitecture.X64, InstructionProfile.X64.Architecture); + Assert.Equal(PointerSize.Bit64, InstructionProfile.X64.AddressWidth); + Assert.True(InstructionProfile.X64.IsValid); + Assert.Equal(CheatEngineArchitecture.X86, InstructionProfile.X86.Architecture); + Assert.Equal(PointerSize.Bit32, InstructionProfile.X86.AddressWidth); + Assert.True(InstructionProfile.X86.IsValid); + Assert.NotEqual(InstructionProfile.X64, InstructionProfile.X86); + Assert.False(default(InstructionProfile).IsValid); + } + + [Fact] + [Trait("Qualification", "Q32.a")] + public void assemble_on_an_x64_profile_accepts_an_address_above_4_gib() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + Span destination = stackalloc byte[5]; + // The x64 Tutorial image base observed on CE 7.7 (spike C3 D2): the first address above 4 GiB. + Address origin = 0x1_0000_0000UL; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(targetProfile, "jmp rel", origin, + destination, out int written, out int requiredLength); + + Assert.Equal(InstructionOperationStatus.Success, status); + Assert.Equal(5, written); + Assert.Equal(5, requiredLength); + Assert.Equal(0x1_0000_0000L, ReadInteger(scope.State, "instruction_assemble_origin")); + Assert.Equal("integer", ReadString(scope.State, "instruction_assemble_origin_type")); + Assert.Equal(0, scope.State.Top); + } + [Fact] public void Assemble_passes_the_explicit_relative_origin_and_copies_the_complete_byte_table() { @@ -127,7 +382,8 @@ public void Assemble_rejection_capacity_and_malformed_tables_do_not_publish_a_pr } [Fact] - public void Assemble_rejects_an_address_wider_than_the_observed_x86_profile_before_entering_Lua() + [Trait("Qualification", "Q32.b")] + public void assemble_on_an_x86_profile_refuses_an_address_above_4_gib_before_entering_lua() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); @@ -148,15 +404,21 @@ public void Assemble_rejects_an_address_wider_than_the_observed_x86_profile_befo Assert.Equal(0, scope.State.Top); } - [Fact] - public void Assemble_reports_a_target_change_without_copying_the_returned_bytes() + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(7777L)] + [InlineData(0L)] + [InlineData(4294967295L)] + public void assemble_reports_a_target_change_after_the_effect_without_copying_bytes(long selectionAfterEffect) { + // Another process, no target and the file-as-process sentinel all differ from the profiled selection. EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); InstallInstructionGlobals(scope.State); InstructionTargetProfile targetProfile = Observe(scope.State); - EngineTest.Run(scope.State, "instruction_assemble_behavior = \"target-change\""u8); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("instruction_assemble_behavior = \"target-change\"\n" + + "instruction_assemble_next_process_id = " + selectionAfterEffect.ToString(CultureInfo.InvariantCulture))); Span destination = stackalloc byte[1]; destination[0] = 0xA5; @@ -167,6 +429,32 @@ public void Assemble_reports_a_target_change_without_copying_the_returned_bytes( Assert.Equal(0, written); Assert.Equal(0, requiredLength); Assert.Equal(0xA5, destination[0]); + Assert.Equal(1, ReadInteger(scope.State, "instruction_assemble_calls")); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData(0L)] + [InlineData(4294967295L)] + public void assemble_reports_a_selection_that_differs_before_the_call_as_target_changed_without_calling_assemble( + long selectionBeforeCall) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes( + "instruction_target_process_id = " + selectionBeforeCall.ToString(CultureInfo.InvariantCulture))); + Span destination = stackalloc byte[8]; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(targetProfile, "nop", 0x140001000UL, + destination, out int written, out int requiredLength); + + Assert.Equal(InstructionOperationStatus.TargetChanged, status); + Assert.Equal(0, written); + Assert.Equal(0, requiredLength); + Assert.Equal(0, ReadInteger(scope.State, "instruction_assemble_calls")); Assert.Equal(0, scope.State.Top); } @@ -194,6 +482,33 @@ public void Disassemble_parses_the_fixture_line_inside_the_SDK_and_copies_all_te Assert.Equal(0, scope.State.Top); } + [Fact] + public void disassembly_result_remains_valid_after_the_runtime_is_detached() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + InstructionDisassembly instruction; + Address address = 0x0000_0001_4000_1000UL; + using (HostScope scope = new(state)) + { + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + Assert.Equal(InstructionOperationStatus.Success, + InstructionDisassembler.TryDisassemble(targetProfile, address, 256, out instruction, out _)); + } + + // The runtime is detached and the Lua state closed below: every field is a copied managed value. + LuaRuntime.Detach(); + state.Dispose(); + + Assert.Equal(address, instruction.Address); + Assert.Equal("0000000140001000", instruction.AddressText); + Assert.Equal("E9 FB FF FF FF", instruction.Bytes); + Assert.Equal("jmp", instruction.Opcode); + Assert.Equal("0000000140001000", instruction.Extra); + Assert.Equal(49, instruction.Utf8ByteLength); + } + [Fact] public void Disassemble_enforces_the_raw_text_bound_and_rejects_malformed_split_results() { @@ -313,13 +628,16 @@ private static void InstallInstructionGlobals(LuaState state) EngineTest.Run(state, """ instruction_assemble_calls = 0 instruction_assemble_origin = 0 + instruction_assemble_origin_type = "none" instruction_assemble_behavior = "normal" + instruction_assemble_next_process_id = 7777 assemble = function(line, address) instruction_assemble_calls = instruction_assemble_calls + 1 instruction_assemble_origin = address + instruction_assemble_origin_type = math.type(address) if instruction_assemble_behavior == "target-change" then - instruction_target_process_id = 7777 + instruction_target_process_id = instruction_assemble_next_process_id return { 0x90 }, nil end if line == "reject" then return nil, "instruction rejected" end @@ -357,9 +675,10 @@ private static void InstallInstructionGlobals(LuaState state) private static void InstallProfileGlobals(LuaState state) { EngineTest.Run(state, """ + -- The CE-faithful x64 target (spike C3 D2): x86 family and 64-bit. instruction_target_process_id = 4242 instruction_target_is_64bit = true - instruction_target_is_x86 = false + instruction_target_is_x86 = true instruction_target_is_arm = false function getOpenedProcessID() return instruction_target_process_id end function targetIs64Bit() return instruction_target_is_64bit end @@ -374,4 +693,16 @@ private static long ReadInteger(LuaState state, string name) Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); return EngineTest.ReadInteger(state, -1); } + + private static string ReadString(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + return EngineTest.ReadString(state, -1); + } + + private static string LuaBoolean(bool value) + { + return value ? "true" : "false"; + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionStatusContractTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionStatusContractTests.cs new file mode 100644 index 00000000..14a6a7ba --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionStatusContractTests.cs @@ -0,0 +1,67 @@ +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Processes; + +namespace CheatEngine.SDK.Engine.Tests.Assembly; + +/// +/// Managed contract tests for the numeric shape of the instruction and process status enums: the zero value is +/// Unknown, never success, and every member keeps an explicit, literal value. +/// +public sealed class InstructionStatusContractTests +{ + [Fact] + public void instruction_operation_status_default_is_unknown_and_not_success() + { + InstructionOperationStatus unassigned = default; + + Assert.Equal(InstructionOperationStatus.Unknown, unassigned); + Assert.NotEqual(InstructionOperationStatus.Success, unassigned); + Assert.Equal(0, (int) unassigned); + } + + [Fact] + public void instruction_and_process_status_values_are_pinned() + { + (InstructionOperationStatus Member, int Value)[] instruction = + [ + (InstructionOperationStatus.Unknown, 0), + (InstructionOperationStatus.Success, 1), + (InstructionOperationStatus.InvalidProfile, 2), + (InstructionOperationStatus.AddressExceedsProfileWidth, 3), + (InstructionOperationStatus.TargetNotSelected, 4), + (InstructionOperationStatus.TargetChanged, 5), + (InstructionOperationStatus.DestinationTooSmall, 6), + (InstructionOperationStatus.OutputTooLong, 7), + (InstructionOperationStatus.InstructionRejected, 8), + (InstructionOperationStatus.GlobalUnavailable, 9), + (InstructionOperationStatus.LuaFailure, 10), + (InstructionOperationStatus.InvalidResult, 11), + (InstructionOperationStatus.UnsupportedTargetBackend, 12) + ]; + (ProcessOperationStatusKind Member, int Value)[] process = + [ + (ProcessOperationStatusKind.Unknown, 0), + (ProcessOperationStatusKind.Success, 1), + (ProcessOperationStatusKind.TargetNotAttached, 2), + (ProcessOperationStatusKind.SelectionNotConfirmed, 3), + (ProcessOperationStatusKind.GlobalUnavailable, 4), + (ProcessOperationStatusKind.ProtectedLuaFailure, 5), + (ProcessOperationStatusKind.InvalidResult, 6), + (ProcessOperationStatusKind.TargetChanged, 7), + (ProcessOperationStatusKind.FileAsProcessTarget, 8) + ]; + + foreach ((InstructionOperationStatus member, int value) in instruction) + { + Assert.Equal(value, (int) member); + } + + foreach ((ProcessOperationStatusKind member, int value) in process) + { + Assert.Equal(value, (int) member); + } + + Assert.Equal(instruction.Length, Enum.GetValues().Length); + Assert.Equal(process.Length, Enum.GetValues().Length); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs index 9985a1d8..df4752eb 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs @@ -245,8 +245,14 @@ public void ResolveAddress_reports_an_unavailable_global_without_entering_lua() [Fact] public void AddressResolutionOptions_supports_init_and_with() { - AddressResolutionOptions options = new() { Shallow = true }; - AddressResolutionOptions updated = options with { Shallow = false }; + AddressResolutionOptions options = new() + { + Shallow = true + }; + AddressResolutionOptions updated = options with + { + Shallow = false + }; Assert.True(options.Shallow); Assert.False(updated.Shallow); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolLeaseReplacementTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolLeaseReplacementTests.cs new file mode 100644 index 00000000..0e4622b3 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolLeaseReplacementTests.cs @@ -0,0 +1,260 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Inspection; + +/// +/// A symbol registration lease verifies, before unregistering by name, that the name still resolves to the leased +/// address, so it never removes a newer third-party definition; its release kinds start at Unknown. +/// +[Trait("Category", "NativeLua")] +public sealed class SymbolLeaseReplacementTests +{ + private static readonly SymbolName SName = new("Player.Health"); + + [Fact] + public void Symbol_lease_unregisters_when_the_name_still_maps_to_the_leased_address() + { + using Fixture fixture = new(); + + SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned(SName, 0x140001000UL); + SymbolRegistrationReleaseOutcome released = acquired.Lease!.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.Released, released.Kind); + Assert.True(released.Status!.Value.IsSuccess); + Assert.True(released.IsTerminal); + fixture.Execute("assert(removals == 1 and registered_symbols['Player.Health'] == nil)"); + fixture.Execute("assert(lookups == 1)"); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_lease_skips_unregister_when_the_name_was_replaced() + { + using Fixture fixture = new(); + SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned(SName, 0x140001000UL); + + // A third-party script re-registers the name at another address, outside the SDK coordinator. + fixture.Execute("registered_symbols['Player.Health'] = 0x150000000"); + SymbolRegistrationReleaseOutcome outcome = acquired.Lease!.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.Replaced, outcome.Kind); + Assert.True(outcome.Status!.Value.IsSuccess); + Assert.True(outcome.IsTerminal); + Assert.True(acquired.Lease.IsTerminal); + Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, acquired.Lease.Release().Kind); + fixture.Execute("assert(removals == 0 and registered_symbols['Player.Health'] == 0x150000000)"); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_lease_reports_externally_removed_when_the_name_no_longer_resolves() + { + using Fixture fixture = new(); + SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned(SName, 0x140001000UL); + + fixture.Execute("registered_symbols['Player.Health'] = nil"); + SymbolRegistrationReleaseOutcome outcome = acquired.Lease!.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.ExternallyRemoved, outcome.Kind); + Assert.Equal(LuaOperationStatusKind.NilResult, outcome.Status!.Value.Kind); + Assert.True(outcome.IsTerminal); + fixture.Execute("assert(removals == 0)"); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_lease_lookup_failure_keeps_the_lease_retryable_without_unregistering() + { + using Fixture fixture = new(); + SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned(SName, 0x140001000UL); + + fixture.Execute("lookup_mode = 'raise'"); + SymbolRegistrationReleaseOutcome failed = acquired.Lease!.Release(); + fixture.Execute("lookup_mode = 'malformed'"); + SymbolRegistrationReleaseOutcome malformed = acquired.Lease.Release(); + fixture.Execute("lookup_mode = nil"); + SymbolRegistrationReleaseOutcome released = acquired.Lease.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.CleanupUnavailable, failed.Kind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, failed.Status!.Value.Kind); + Assert.False(failed.IsTerminal); + Assert.Equal(SymbolRegistrationReleaseKind.CleanupUnavailable, malformed.Kind); + Assert.Equal(LuaOperationStatusKind.InvalidResult, malformed.Status!.Value.Kind); + Assert.False(malformed.IsTerminal); + Assert.Equal(SymbolRegistrationReleaseKind.Released, released.Kind); + fixture.Execute("assert(removals == 1 and lookups == 3)"); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_lease_without_a_lookup_global_keeps_the_lease_retryable_without_unregistering() + { + using Fixture fixture = new(); + fixture.Execute("getAddressSafe = nil"); + SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned(SName, 0x140001000UL); + + SymbolRegistrationReleaseOutcome outcome = acquired.Lease!.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.CleanupUnavailable, outcome.Kind); + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, outcome.Status!.Value.Kind); + Assert.False(outcome.IsTerminal); + Assert.False(acquired.Lease.IsTerminal); + fixture.Execute("assert(removals == 0)"); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_registration_over_an_existing_name_never_unregisters_the_previous_definition() + { + using Fixture fixture = new(); + + // A host that refuses an existing name: no lease, nothing removed. + fixture.Execute(""" + registered_symbols['Player.Health'] = 0x150000000 + refuse_existing = true + """); + SymbolRegistrationAcquireOutcome refused = SymbolRegistry.TryRegisterOwned(SName, 0x140001000UL); + + Assert.False(refused.HasLease); + Assert.Equal(LuaOperationStatusKind.LuaFailure, refused.Status.Kind); + fixture.Execute("assert(removals == 0 and registered_symbols['Player.Health'] == 0x150000000)"); + + // A host that overwrites: the lease is ours until a third party registers the name again. + fixture.Execute("refuse_existing = false"); + SymbolRegistrationAcquireOutcome overwrote = SymbolRegistry.TryRegisterOwned(SName, 0x140001000UL); + fixture.Execute("registered_symbols['Player.Health'] = 0x160000000"); + SymbolRegistrationReleaseOutcome release = overwrote.Lease!.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.Replaced, release.Kind); + fixture.Execute("assert(removals == 0 and registered_symbols['Player.Health'] == 0x160000000)"); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Failed_publication_compensation_skips_a_name_replaced_in_the_meantime() + { + using Fixture fixture = new(); + LuaState L = fixture.State; + + SymbolRegistrationHandoffException exception = Assert.Throws(() => + SymbolRegistry.TryRegisterOwnedCore(SName, 0x140001000UL, default, (_, _, _, _) => + { + EngineTest.Run(L, "registered_symbols['Player.Health'] = 0x170000000"u8); + throw new InvalidOperationException("injected lease factory failure"); + }, static (_, _) => throw new InvalidOperationException("The publisher must not run."))); + + Assert.Equal(SymbolRegistrationReleaseKind.Replaced, exception.CleanupOutcome.Kind); + fixture.Execute("assert(removals == 0 and registered_symbols['Player.Health'] == 0x170000000)"); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Release_kind_default_is_unknown_and_not_terminal() + { + SymbolRegistrationReleaseOutcome lease = default; + SymbolListRegistrationReleaseOutcome list = default; + + Assert.Equal(SymbolRegistrationReleaseKind.Unknown, lease.Kind); + Assert.Equal(0, (int) SymbolRegistrationReleaseKind.Unknown); + Assert.False(lease.IsTerminal); + Assert.Equal(SymbolRegistrationReleaseKind.Unknown, list.UnregisterKind); + Assert.False(list.IsTerminal); + Assert.Equal(1, (int) SymbolRegistrationReleaseKind.Released); + Assert.Equal(7, (int) SymbolRegistrationReleaseKind.Replaced); + Assert.Equal(8, (int) SymbolRegistrationReleaseKind.ExternallyRemoved); + } + + [Fact] + public void Lease_exposes_its_name_address_and_origin() + { + using Fixture fixture = new(); + + SymbolRegistrationAcquireOutcome acquired = + SymbolRegistry.TryRegisterOwned(SName, 0xFFFF_FFFF_FFFF_FFF0UL); + SymbolRegistrationLease lease = acquired.Lease!; + + Assert.Equal(SName, lease.Name); + Assert.Equal(new Address(0xFFFF_FFFF_FFFF_FFF0UL), lease.Address); + Assert.Equal(LuaRuntime.CurrentStateIdentity, lease.Origin.Runtime); + Assert.Null(lease.Origin.Target); + Assert.True(lease.Origin.IsCurrentRuntime); + Assert.Equal(SymbolRegistrationReleaseKind.Released, lease.Release().Kind); + Assert.Equal(0, fixture.State.Top); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Persistence_option_is_forwarded_unchanged(bool doNotSave) + { + using Fixture fixture = new(); + + SymbolRegistrationAcquireOutcome acquired = + SymbolRegistry.TryRegisterOwned(SName, 0x140001000UL, new SymbolRegistrationOptions(doNotSave)); + + Assert.Equal(doNotSave, acquired.Lease!.Options.DoNotSave); + fixture.Execute(doNotSave ? "assert(last_do_not_save == true)" : "assert(last_do_not_save == false)"); + fixture.Execute("assert(last_register_arguments == 3)"); + acquired.Lease.Dispose(); + Assert.Equal(0, fixture.State.Top); + } + + private sealed class Fixture : IDisposable + { + private readonly NativeLuaState _nativeState; + private readonly HostScope _scope; + + public Fixture() + { + EngineTest.RequireNativeLua(); + _nativeState = new NativeLuaState(); + _scope = new HostScope(_nativeState); + State = _scope.State; + EngineTest.Run(State, """ + registered_symbols = {} + removals = 0 + lookups = 0 + refuse_existing = false + registerSymbol = function(...) + last_register_arguments = select("#", ...) + local name, address, doNotSave = ... + if refuse_existing and registered_symbols[name] ~= nil then error(name .. " already exists") end + last_do_not_save = doNotSave + registered_symbols[name] = address + end + unregisterSymbol = function(name) + removals = removals + 1 + registered_symbols[name] = nil + end + getAddressSafe = function(name, isLocal, shallow) + lookups = lookups + 1 + if lookup_mode == "raise" then error("symbol handler busy") end + if lookup_mode == "malformed" then return {} end + return registered_symbols[name] + end + """u8); + } + + public LuaState State + { + get; + } + + public void Execute(string source) + { + EngineTest.Run(State, System.Text.Encoding.UTF8.GetBytes(source)); + } + + public void Dispose() + { + _scope.Dispose(); + _nativeState.Dispose(); + } + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolListTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolListTests.cs new file mode 100644 index 00000000..a4258a39 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolListTests.cs @@ -0,0 +1,445 @@ +using System.Reflection; + +using CheatEngine.SDK.Annotations.Lifetime; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Inspection; + +/// +/// Plugin-owned symbol lists, the borrowed main list, and the registration lease that unregisters before it +/// destroys, against a SymbolList class double. +/// +[Trait("Category", "NativeLua")] +public sealed class SymbolListTests +{ + [Fact] + public void Symbol_list_create_returns_an_owner_that_destroys_once() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList(); + + LuaOperationStatus status = SymbolLists.TryCreate(out Owned? created); + + Assert.True(status.IsSuccess); + Owned owner = Assert.IsType>(created); + Assert.Equal(handle, owner.Handle); + Assert.Equal(LuaRuntime.CurrentStateIdentity, owner.Origin.Runtime); + fixture.Execute("assert(create_arguments == 0)"); + owner.Dispose(); + owner.Dispose(); + Assert.True(FakeHost.IsDestroyed(fixture.State, handle)); + Assert.Equal(1, FakeHost.DestroyedCount(fixture.State)); + Assert.Equal(TargetReleaseStatus.Released, owner.LastReleaseOutcome.Status); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_create_distinguishes_missing_global_lua_failure_and_non_object() + { + using Fixture fixture = new(); + + // A resolved global stays cached for the state identity, so each redefinition starts a new state generation. + LuaOperationStatus missing = SymbolLists.TryCreate(out Owned? none); + fixture.Execute("createSymbolList = function() error('out of memory') end"); + LuaOperationStatus raised = SymbolLists.TryCreate(out _); + FakeHost.ReplaceStateGeneration(); + fixture.Execute("createSymbolList = function() return nil end"); + LuaOperationStatus nil = SymbolLists.TryCreate(out _); + FakeHost.ReplaceStateGeneration(); + fixture.Execute("createSymbolList = function() return { not_an_object = true } end"); + LuaOperationStatus invalid = SymbolLists.TryCreate(out Owned? stillNone); + + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, missing.Kind); + Assert.Null(none); + Assert.Equal(LuaOperationStatusKind.LuaFailure, raised.Kind); + Assert.Equal(LuaStatus.RuntimeError, raised.LuaStatus); + Assert.Equal(LuaOperationStatusKind.NilResult, nil.Kind); + Assert.Equal(LuaOperationStatusKind.InvalidResult, invalid.Kind); + Assert.Null(stillNone); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_members_pass_exact_arguments_and_restore_the_stack() + { + using Fixture fixture = new(); + CEObject handle = FakeHost.CreateSymbolList(fixture.State); + SymbolList list = new(handle); + + Assert.True(list.TryAddSymbol("game.exe", "hp\0max", new Address(0x8000_0000_0000_0010UL), 8).IsSuccess); + FakeHost.RunOnObject(fixture.State, handle, """ + local a = o.last_args + assert(a.n == 4) + assert(a[1] == "game.exe" and a[2] == "hp\0max" and #a[2] == 6) + assert(a[3] == math.mininteger + 0x10 and a[4] == 8) + """); + Assert.True(list.TryGetSymbolFromString("hp\0max", out SymbolInfo byName).IsSuccess); + FakeHost.RunOnObject(fixture.State, handle, "assert(o.last_args.n == 1)"); + Assert.True(list.TryGetSymbolFromAddress(new Address(0x8000_0000_0000_0014UL), out SymbolInfo byAddress) + .IsSuccess); + FakeHost.RunOnObject(fixture.State, handle, "assert(o.last_args.n == 1)"); + Assert.True(list.TryDeleteSymbol(new Address(0x8000_0000_0000_0010UL)).IsSuccess); + FakeHost.RunOnObject(fixture.State, handle, "assert(o.last_args.n == 1 and next(o.symbols) == nil)"); + Assert.True(list.TryAddSymbol("game.exe", "ammo", new Address(0x140000000), 4).IsSuccess); + Assert.True(list.TryDeleteSymbol("ammo").IsSuccess); + FakeHost.RunOnObject(fixture.State, handle, "assert(o.last_args.n == 1 and o.last_args[1] == 'ammo')"); + Assert.True(list.TryClear().IsSuccess); + Assert.True(list.TrySetName("plugin symbols").IsSuccess); + Assert.True(list.TryGetName(out string? name).IsSuccess); + + SymbolInfo expected = new("game.exe", "hp\0max", new Address(0x8000_0000_0000_0010UL), new MemorySize(8)); + Assert.Equal(expected, byName); + Assert.Equal(expected, byAddress); + Assert.Equal("plugin symbols", name); + Assert.Throws(() => list.TryAddSymbol("m", "k", new Address(1), -1)); + Assert.Throws(() => list.TryAddSymbol(null!, "k", new Address(1), 1)); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_lookup_distinguishes_not_found_from_a_malformed_symbol_table() + { + using Fixture fixture = new(); + CEObject handle = FakeHost.CreateSymbolList(fixture.State, "o.props.PID = 4242"); + SymbolList list = new(handle); + + LuaOperationStatus notFound = list.TryGetSymbolFromString("missing", out SymbolInfo none); + LuaOperationStatus noName = list.TryGetName(out string? name); + LuaOperationStatus processId = list.TryGetProcessId(out int pid); + FakeHost.RunOnObject(fixture.State, handle, "o.malformed = true; o.props.PID = 'not a pid'"); + LuaOperationStatus malformedString = list.TryGetSymbolFromString("any", out _); + LuaOperationStatus malformedAddress = list.TryGetSymbolFromAddress(new Address(1), out _); + LuaOperationStatus badProcessId = list.TryGetProcessId(out _); + + Assert.Equal(LuaOperationStatusKind.NilResult, notFound.Kind); + Assert.Equal(default(SymbolInfo), none); + Assert.Equal(LuaOperationStatusKind.NilResult, noName.Kind); + Assert.Null(name); + Assert.True(processId.IsSuccess); + Assert.Equal(4242, pid); + Assert.Equal(LuaOperationStatusKind.InvalidResult, malformedString.Kind); + Assert.Equal(LuaOperationStatusKind.InvalidResult, malformedAddress.Kind); + Assert.Equal(LuaOperationStatusKind.InvalidResult, badProcessId.Kind); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_register_then_release_unregisters_before_destroying() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList(); + Assert.True(SymbolLists.TryCreate(out Owned? created).IsSuccess); + Owned owner = created!; + + LuaOperationStatus registered = SymbolLists.TryRegister(owner, out SymbolListRegistrationLease? lease); + + Assert.True(registered.IsSuccess); + SymbolListRegistrationLease registration = Assert.IsType(lease); + Assert.True(owner.IsDisposed); + Assert.True(registration.RegistrationConfirmed); + Assert.Equal(owner.Origin, registration.Origin); + Assert.Equal(new SymbolList(handle), registration.List); + FakeHost.RunOnObject(fixture.State, handle, "assert(o.registered == true and o.register_args == 0)"); + + SymbolListRegistrationReleaseOutcome outcome = registration.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.Released, outcome.UnregisterKind); + Assert.True(outcome.UnregisterStatus!.Value.IsSuccess); + Assert.Equal(TargetReleaseStatus.Released, outcome.ListRelease.Status); + Assert.True(outcome.IsTerminal); + Assert.True(registration.IsTerminal); + Assert.Throws(() => registration.List); + SymbolListRegistrationReleaseOutcome alreadyReleased = registration.Release(); + Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, alreadyReleased.UnregisterKind); + Assert.Null(alreadyReleased.UnregisterStatus); + fixture.Execute("assert(symbol_list_unregister_calls == 1 and symbol_list_destroyed_while_registered == 0)"); + FakeHost.RunOnObject(fixture.State, handle, "assert(o.unregister_args == 0 and o.destroyed == true)"); + Assert.Equal(1, FakeHost.DestroyedCount(fixture.State)); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_unregister_failure_abandons_the_list_without_destroying_it() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList("o.unregister_raises = true"); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + Assert.True(SymbolLists.TryRegister(owner!, out SymbolListRegistrationLease? lease).IsSuccess); + + SymbolListRegistrationReleaseOutcome outcome = lease!.Release(); + lease.Dispose(); + + Assert.Equal(SymbolRegistrationReleaseKind.CleanupIndeterminate, outcome.UnregisterKind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, outcome.UnregisterStatus!.Value.Kind); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.ListRelease.Status); + Assert.True(outcome.IsTerminal); + Assert.False(FakeHost.IsDestroyed(fixture.State, handle)); + fixture.Execute("assert(symbol_list_unregister_calls == 1)"); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_unregister_member_missing_keeps_the_lease_retryable() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList(); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + Assert.True(SymbolLists.TryRegister(owner!, out SymbolListRegistrationLease? lease).IsSuccess); + FakeHost.RunOnObject(fixture.State, handle, "o.getters.unregister = function() return nil end"); + + SymbolListRegistrationReleaseOutcome unavailable = lease!.Release(); + FakeHost.RunOnObject(fixture.State, handle, "o.getters.unregister = nil"); + SymbolListRegistrationReleaseOutcome released = lease.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.CleanupUnavailable, unavailable.UnregisterKind); + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, unavailable.UnregisterStatus!.Value.Kind); + Assert.False(unavailable.IsTerminal); + Assert.Equal(SymbolRegistrationReleaseKind.Released, released.UnregisterKind); + Assert.Equal(TargetReleaseStatus.Released, released.ListRelease.Status); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_lease_after_reattach_sends_no_host_call() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList(); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + Assert.True(SymbolLists.TryRegister(owner!, out SymbolListRegistrationLease? lease).IsSuccess); + + LuaRuntime.Detach(); + LuaRuntime.Attach(fixture.Binding); + SymbolListRegistrationReleaseOutcome outcome = lease!.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.StaleRuntime, outcome.UnregisterKind); + Assert.Null(outcome.UnregisterStatus); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, outcome.ListRelease.Status); + Assert.True(outcome.IsTerminal); + fixture.Execute("assert(symbol_list_unregister_calls == 0)"); + Assert.False(FakeHost.IsDestroyed(fixture.State, handle)); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_lease_while_detached_abandons_the_list_without_a_host_call() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList(); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + Assert.True(SymbolLists.TryRegister(owner!, out SymbolListRegistrationLease? lease).IsSuccess); + + LuaRuntime.Detach(); + SymbolListRegistrationReleaseOutcome outcome = lease!.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.StaleRuntime, outcome.UnregisterKind); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.ListRelease.Status); + fixture.Execute("assert(symbol_list_unregister_calls == 0)"); + Assert.False(FakeHost.IsDestroyed(fixture.State, handle)); + } + + [Fact] + public void Symbol_list_register_failure_after_invocation_keeps_the_list_in_an_unconfirmed_lease() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList("o.register_raises = true"); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + + LuaOperationStatus status = SymbolLists.TryRegister(owner!, out SymbolListRegistrationLease? lease); + + Assert.Equal(LuaOperationStatusKind.LuaFailure, status.Kind); + Assert.True(owner!.IsDisposed); + SymbolListRegistrationLease unconfirmed = Assert.IsType(lease); + Assert.False(unconfirmed.RegistrationConfirmed); + + SymbolListRegistrationReleaseOutcome outcome = unconfirmed.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.Released, outcome.UnregisterKind); + Assert.Equal(TargetReleaseStatus.Released, outcome.ListRelease.Status); + fixture.Execute("assert(symbol_list_unregister_calls == 1 and symbol_list_destroyed_while_registered == 0)"); + Assert.True(FakeHost.IsDestroyed(fixture.State, handle)); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_register_member_missing_keeps_the_owner_with_the_caller() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList("o.getters.register = function() return nil end"); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + + LuaOperationStatus status = SymbolLists.TryRegister(owner!, out SymbolListRegistrationLease? lease); + + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, status.Kind); + Assert.Null(lease); + Assert.False(owner!.IsDisposed); + owner.Dispose(); + Assert.True(FakeHost.IsDestroyed(fixture.State, handle)); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_lease_publication_failure_unregisters_once() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList(); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + InvalidOperationException cause = new("injected lease construction failure"); + + SymbolListRegistrationHandoffException exception = Assert.Throws(() => + SymbolLists.TryRegisterCore(owner!, out _, (_, _) => throw cause)); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(SymbolRegistrationReleaseKind.Released, exception.CleanupOutcome.UnregisterKind); + Assert.Equal(TargetReleaseStatus.Unspecified, exception.CleanupOutcome.ListRelease.Status); + fixture.Execute("assert(symbol_list_register_calls == 1 and symbol_list_unregister_calls == 1)"); + Assert.False(owner!.IsDisposed); + owner.Dispose(); + Assert.True(FakeHost.IsDestroyed(fixture.State, handle)); + fixture.Execute("assert(symbol_list_destroyed_while_registered == 0)"); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_lease_publication_failure_with_a_raising_unregister_abandons_the_list() + { + using Fixture fixture = new(); + CEObject handle = fixture.StageCreatedList("o.unregister_raises = true"); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + + SymbolListRegistrationHandoffException exception = Assert.Throws(() => + SymbolLists.TryRegisterCore(owner!, out _, + static (_, _) => throw new InvalidOperationException("injected lease construction failure"))); + + Assert.Equal(SymbolRegistrationReleaseKind.CleanupIndeterminate, exception.CleanupOutcome.UnregisterKind); + Assert.True(owner!.IsDisposed); + Assert.False(FakeHost.IsDestroyed(fixture.State, handle)); + Assert.Equal(0, fixture.State.Top); + } + + [Fact] + public void Symbol_list_created_in_a_previous_runtime_cannot_be_registered() + { + using Fixture fixture = new(); + fixture.StageCreatedList(); + Assert.True(SymbolLists.TryCreate(out Owned? owner).IsSuccess); + + FakeHost.ReplaceStateGeneration(); + + Assert.Throws(() => SymbolLists.TryRegister(owner!, out _)); + Assert.False(owner!.IsDisposed); + fixture.Execute("assert(symbol_list_register_calls == 0)"); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, owner.ReleaseWithOutcome().Status); + } + + [Fact] + public void Main_symbol_list_is_borrowed_only() + { + using Fixture fixture = new(); + CEObject main = FakeHost.CreateSymbolList(fixture.State); + FakeHost.SetGlobalObject(fixture.State, "main_list", main); + fixture.Execute("getMainSymbolList = function(...) main_arguments = select('#', ...) return main_list end"); + + LuaOperationStatus status = SymbolLists.TryGetMain(out SymbolList borrowed); + + Assert.True(status.IsSuccess); + Assert.Equal(new SymbolList(main), borrowed); + fixture.Execute("assert(main_arguments == 0)"); + Assert.Equal(0, fixture.State.Top); + + ParameterInfo output = typeof(SymbolLists).GetMethod(nameof(SymbolLists.TryGetMain))!.GetParameters()[0]; + Assert.True(Attribute.IsDefined(output, typeof(CEOwnedAttribute))); + Assert.False(typeof(IDisposable).IsAssignableFrom(typeof(SymbolList))); + List offenders = []; + foreach (Type type in typeof(SymbolList).Assembly.GetExportedTypes()) + { + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + bool takesBorrowedList = (type == typeof(SymbolList) && !method.IsStatic) || + Array.Exists(method.GetParameters(), + static parameter => parameter.ParameterType == typeof(SymbolList)); + bool lifecycleName = method.Name.Contains("register", StringComparison.OrdinalIgnoreCase) || + method.Name.Contains("destroy", StringComparison.OrdinalIgnoreCase); + if (takesBorrowedList && lifecycleName) + { + offenders.Add(type.Name + "." + method.Name); + } + } + } + + Assert.True(offenders.Count == 0, + "A public method registers, unregisters or destroys a borrowed SymbolList: " + string.Join(", ", offenders)); + } + + [Fact] + public void Symbol_list_calls_after_the_host_destroyed_the_list_fail_without_an_escaping_lua_error() + { + using Fixture fixture = new(); + CEObject handle = FakeHost.CreateSymbolList(fixture.State, + "o.getters.Name = function(o) if o.destroyed then error('attempt to index a userdata value') end return o.props.Name end"); + SymbolList list = new(handle); + FakeHost.RunOnObject(fixture.State, handle, "o.destroyed = true"); + + Assert.Equal(LuaOperationStatusKind.LuaFailure, list.TryClear().Kind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, + list.TryAddSymbol("m", "k", new Address(1), 1).Kind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, list.TryGetSymbolFromString("k", out _).Kind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, list.TryGetName(out _).Kind); + Assert.Equal(0, fixture.State.Top); + + // The state is healthy afterwards. + CEObject other = FakeHost.CreateSymbolList(fixture.State); + Assert.True(new SymbolList(other).TryClear().IsSuccess); + Assert.Equal(0, fixture.State.Top); + } + + private sealed class Fixture : IDisposable + { + private readonly NativeLuaState _nativeState; + private readonly HostScope _scope; + + public Fixture() + { + EngineTest.RequireNativeLua(); + _nativeState = new NativeLuaState(); + _scope = new HostScope(_nativeState); + State = _scope.State; + FakeHost.InstallSymbolListClass(State); + } + + public LuaState State + { + get; + } + + public LuaHostBinding Binding => _scope.Binding; + + /// Creates a list that the next createSymbolList() call returns. + public CEObject StageCreatedList(string initializer = "") + { + CEObject list = FakeHost.CreateSymbolList(State, initializer); + FakeHost.SetGlobalObject(State, "staged_list", list); + Execute("createSymbolList = function(...) create_arguments = select('#', ...) return staged_list end"); + return list; + } + + public void Execute(string source) + { + EngineTest.Run(State, System.Text.Encoding.UTF8.GetBytes(source)); + } + + public void Dispose() + { + _scope.Dispose(); + _nativeState.Dispose(); + } + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs index cfeb2d2c..02242162 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs @@ -8,9 +8,31 @@ namespace CheatEngine.SDK.Engine.Tests.Inspection; /// Fixture-backed contracts for the user-symbol registry and address-name Lua globals. +/// +/// A lease verifies its name with getAddressSafe before it unregisters, so the fixtures that release a lease +/// keep a registered_symbols table that registerSymbol fills and getAddressSafe reads. +/// [Trait("Category", "NativeLua")] public sealed class SymbolRegistryTests { + /// A symbol handler double: registration, removal and name lookup share one table. + internal static ReadOnlySpan SymbolHandler => """ + registered_symbols = {} + registrations = 0 + removals = 0 + registerSymbol = function(name, address, doNotSave) + registrations = registrations + 1 + registered_symbols[name] = address + end + unregisterSymbol = function(name) + removals = removals + 1 + registered_symbols[name] = nil + end + getAddressSafe = function(name, isLocal, shallow) + return registered_symbols[name] + end + """u8; + [Fact] public void TryGetName_forwards_only_the_source_mapped_address() { @@ -126,12 +148,7 @@ public void Owned_registration_lease_never_unregisters_a_newer_coordinated_regis using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; - EngineTest.Run(L, """ - registrations = 0 - removals = 0 - registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end - unregisterSymbol = function(name) removals = removals + 1 end - """u8); + EngineTest.Run(L, SymbolHandler); SymbolName name = new("Player.Health"); SymbolRegistrationAcquireOutcome first = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); @@ -147,6 +164,32 @@ public void Owned_registration_lease_never_unregisters_a_newer_coordinated_regis Assert.Equal(0, L.Top); } + [Fact] + public void Release_outcomes_that_made_no_host_call_never_report_a_status_that_reads_as_success() + { + // A08-26: Superseded, AlreadyReleased and StaleRuntime never send a CE call, so Status must be null rather + // than default(LuaOperationStatus): a numeric default that happened to equal LuaOperationStatusKind.Success + // would otherwise make IsSuccess read true for a release that never touched Cheat Engine. + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, SymbolHandler); + SymbolName name = new("Player.Health"); + + SymbolRegistrationAcquireOutcome first = SymbolRegistry.TryRegisterOwned(name, 0x140001000UL); + SymbolRegistrationAcquireOutcome second = SymbolRegistry.TryRegisterOwned(name, 0x140002000UL); + SymbolRegistrationReleaseOutcome superseded = first.Lease!.Release(); + SymbolRegistrationReleaseOutcome alreadyReleased = first.Lease.Release(); + + Assert.Equal(SymbolRegistrationReleaseKind.Superseded, superseded.Kind); + Assert.Null(superseded.Status); + Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, alreadyReleased.Kind); + Assert.Null(alreadyReleased.Status); + _ = second.Lease!.Release(); + Assert.Equal(0, L.Top); + } + [Fact] public void Owned_registration_factory_failure_compensates_once_and_preserves_the_primary_cause() { @@ -154,22 +197,17 @@ public void Owned_registration_factory_failure_compensates_once_and_preserves_th using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; - EngineTest.Run(L, """ - registrations = 0 - removals = 0 - registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end - unregisterSymbol = function(name) removals = removals + 1 end - """u8); + EngineTest.Run(L, SymbolHandler); InvalidOperationException cause = new("injected lease factory failure"); SymbolRegistrationHandoffException exception = Assert.Throws(() => SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, - (_, _, _) => throw cause, + (_, _, _, _) => throw cause, static (_, _) => throw new InvalidOperationException("The publisher must not run."))); Assert.Same(cause, exception.InnerException); Assert.Equal(SymbolRegistrationReleaseKind.Released, exception.CleanupOutcome.Kind); - Assert.True(exception.CleanupOutcome.Status.IsSuccess); + Assert.True(exception.CleanupOutcome.Status!.Value.IsSuccess); EngineTest.Run(L, "assert(registrations == 1 and removals == 1)"u8); Assert.Equal(0, L.Top); } @@ -181,22 +219,17 @@ public void Owned_registration_publish_failure_compensates_once_and_preserves_th using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; - EngineTest.Run(L, """ - registrations = 0 - removals = 0 - registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end - unregisterSymbol = function(name) removals = removals + 1 end - """u8); + EngineTest.Run(L, SymbolHandler); InvalidOperationException cause = new("injected lease publication failure"); SymbolRegistrationHandoffException exception = Assert.Throws(() => SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, - static (name, options, identity) => new SymbolRegistrationLease(name, options, identity), + static (name, address, options, identity) => new SymbolRegistrationLease(name, address, options, identity), (_, _) => throw cause)); Assert.Same(cause, exception.InnerException); Assert.Equal(SymbolRegistrationReleaseKind.Released, exception.CleanupOutcome.Kind); - Assert.True(exception.CleanupOutcome.Status.IsSuccess); + Assert.True(exception.CleanupOutcome.Status!.Value.IsSuccess); EngineTest.Run(L, "assert(registrations == 1 and removals == 1)"u8); Assert.Equal(0, L.Top); } @@ -208,12 +241,7 @@ public void Owned_registration_publication_failure_after_detach_preserves_the_pr using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; - EngineTest.Run(L, """ - registrations = 0 - removals = 0 - registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end - unregisterSymbol = function(name) removals = removals + 1 end - """u8); + EngineTest.Run(L, SymbolHandler); InvalidOperationException cause = new("injected lease publication failure after detach"); SymbolRegistrationHandoffException exception; @@ -221,7 +249,8 @@ public void Owned_registration_publication_failure_after_detach_preserves_the_pr { exception = Assert.Throws(() => SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, - static (name, options, identity) => new SymbolRegistrationLease(name, options, identity), + static (name, address, options, identity) => + new SymbolRegistrationLease(name, address, options, identity), (_, _) => { LuaRuntime.Detach(); @@ -235,7 +264,7 @@ public void Owned_registration_publication_failure_after_detach_preserves_the_pr Assert.Same(cause, exception.InnerException); Assert.Equal(SymbolRegistrationReleaseKind.StaleRuntime, exception.CleanupOutcome.Kind); - Assert.True(exception.CleanupOutcome.Status.IsSuccess); + Assert.Null(exception.CleanupOutcome.Status); Assert.True(exception.CleanupOutcome.IsTerminal); EngineTest.Run(L, "assert(registrations == 1 and removals == 0)"u8); Assert.Equal(0, L.Top); @@ -248,20 +277,18 @@ public void Owned_registration_publication_failure_with_unavailable_unregister_p using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; - EngineTest.Run(L, """ - registrations = 0 - registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end - """u8); + EngineTest.Run(L, SymbolHandler); + EngineTest.Run(L, "unregisterSymbol = nil"u8); InvalidOperationException cause = new("injected lease publication failure"); SymbolRegistrationHandoffException exception = Assert.Throws(() => SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, - static (name, options, identity) => new SymbolRegistrationLease(name, options, identity), + static (name, address, options, identity) => new SymbolRegistrationLease(name, address, options, identity), (_, _) => throw cause)); Assert.Same(cause, exception.InnerException); Assert.Equal(SymbolRegistrationReleaseKind.CleanupUnavailable, exception.CleanupOutcome.Kind); - Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, exception.CleanupOutcome.Status.Kind); + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, exception.CleanupOutcome.Status!.Value.Kind); Assert.False(exception.CleanupOutcome.IsTerminal); EngineTest.Run(L, "assert(registrations == 1)"u8); Assert.Equal(0, L.Top); @@ -274,10 +301,8 @@ public void Owned_registration_compensation_failure_is_indeterminate_and_never_r using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; + EngineTest.Run(L, SymbolHandler); EngineTest.Run(L, """ - registrations = 0 - removals = 0 - registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end unregisterSymbol = function(name) removals = removals + 1 error('cleanup started then failed') @@ -287,12 +312,12 @@ public void Owned_registration_compensation_failure_is_indeterminate_and_never_r SymbolRegistrationHandoffException exception = Assert.Throws(() => SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, - (_, _, _) => throw cause, + (_, _, _, _) => throw cause, static (_, _) => throw new InvalidOperationException("The publisher must not run."))); Assert.Same(cause, exception.InnerException); Assert.Equal(SymbolRegistrationReleaseKind.CleanupIndeterminate, exception.CleanupOutcome.Kind); - Assert.Equal(LuaOperationStatusKind.LuaFailure, exception.CleanupOutcome.Status.Kind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, exception.CleanupOutcome.Status!.Value.Kind); Assert.True(exception.CleanupOutcome.IsTerminal); EngineTest.Run(L, "assert(registrations == 1 and removals == 1)"u8); Assert.Equal(0, L.Top); @@ -355,10 +380,8 @@ public void Owned_registration_becomes_stale_without_unregistering_after_runtime using (HostScope scope = new(state)) { LuaState L = scope.State; - EngineTest.Run(L, """ - registerSymbol = function(name, address, doNotSave) end - unregisterSymbol = function(name) error('must not run after detach') end - """u8); + EngineTest.Run(L, SymbolHandler); + EngineTest.Run(L, "unregisterSymbol = function(name) error('must not run after detach') end"u8); SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL); Assert.True(acquired.HasLease); @@ -368,6 +391,7 @@ public void Owned_registration_becomes_stale_without_unregistering_after_runtime SymbolRegistrationReleaseOutcome release = lease.Release(); Assert.Equal(SymbolRegistrationReleaseKind.StaleRuntime, release.Kind); + Assert.Null(release.Status); Assert.True(lease.IsTerminal); } @@ -394,7 +418,8 @@ public void Owned_registration_cleanup_can_retry_unavailability_and_dispose_only using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; - EngineTest.Run(L, "registerSymbol = function(name, address, doNotSave) end"u8); + EngineTest.Run(L, SymbolHandler); + EngineTest.Run(L, "saved_unregister = unregisterSymbol; unregisterSymbol = nil"u8); SymbolRegistrationAcquireOutcome retryable = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL, @@ -407,7 +432,7 @@ public void Owned_registration_cleanup_can_retry_unavailability_and_dispose_only Assert.False(unavailable.IsTerminal); Assert.False(retryable.Lease.IsTerminal); - EngineTest.Run(L, "unregisterSymbol = function(name) end"u8); + EngineTest.Run(L, "unregisterSymbol = saved_unregister"u8); SymbolRegistrationReleaseOutcome released = retryable.Lease.Release(); Assert.Equal(SymbolRegistrationReleaseKind.Released, released.Kind); Assert.True(released.IsTerminal); @@ -423,16 +448,16 @@ public void Owned_registration_cleanup_after_a_lua_failure_is_indeterminate_and_ using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; - + EngineTest.Run(L, SymbolHandler); EngineTest.Run(L, - "removals = 0; registerSymbol = function(name, address, doNotSave) end; unregisterSymbol = function(name) removals = removals + 1; error('cleanup started then failed') end"u8); + "unregisterSymbol = function(name) removals = removals + 1; error('cleanup started then failed') end"u8); SymbolRegistrationAcquireOutcome failed = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Mana"), 0x140002000UL); SymbolRegistrationReleaseOutcome indeterminate = failed.Lease!.Release(); Assert.Equal(SymbolRegistrationReleaseKind.CleanupIndeterminate, indeterminate.Kind); Assert.True(indeterminate.IsTerminal); - Assert.False(indeterminate.Status.IsSuccess); + Assert.False(indeterminate.Status!.Value.IsSuccess); Assert.True(failed.Lease.IsTerminal); Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, failed.Lease.Release().Kind); failed.Lease.Dispose(); @@ -448,11 +473,7 @@ public void Owned_registration_dispose_is_no_throw_idempotent_and_unregisters_on using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; - EngineTest.Run(L, """ - removals = 0 - registerSymbol = function(name, address, doNotSave) end - unregisterSymbol = function(name) removals = removals + 1 end - """u8); + EngineTest.Run(L, SymbolHandler); SymbolRegistrationAcquireOutcome acquired = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Memory/AddressTypeSeparationTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Memory/AddressTypeSeparationTests.cs new file mode 100644 index 00000000..18ae454d --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Memory/AddressTypeSeparationTests.cs @@ -0,0 +1,50 @@ +using System.Reflection; + +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.SDK.Engine.Tests.Memory; + +/// +/// The three pointer-like value types stay apart (audit A07-01): a target , a host +/// and a handle expose no conversion operator to each other or +/// to a floating-point type, so a 64-bit address can never be rounded through a implicitly. +/// +public sealed class AddressTypeSeparationTests +{ + // The 1.0.0 surface: the lossless raw conversions of Address, and nothing else. + private static readonly string[] s_allowed = + [ + "Address.op_Explicit(Address) -> UInt64", + "Address.op_Implicit(UInt64) -> Address" + ]; + + [Fact] + [Trait("Qualification", "Q21")] + public void Address_types_expose_no_conversion_to_each_other_or_to_double() + { + Type[] types = [typeof(Address), typeof(HostAddress), typeof(CEObject)]; + + string[] conversions = + [ + .. types.SelectMany(static type => type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Static | BindingFlags.DeclaredOnly)) + .Where(static method => method.Name is "op_Implicit" or "op_Explicit") + .Select(Describe) + .Order(StringComparer.Ordinal) + ]; + + Assert.Equal(s_allowed, conversions); + Assert.DoesNotContain(conversions, static conversion => + conversion.Contains("Double", StringComparison.Ordinal) || + conversion.Contains("Single", StringComparison.Ordinal) || + conversion.Contains("Decimal", StringComparison.Ordinal)); + } + + private static string Describe(MethodInfo method) + { + string parameters = string.Join(", ", method.GetParameters().Select(static p => p.ParameterType.Name)); + return method.DeclaringType!.Name + "." + method.Name + "(" + parameters + ") -> " + method.ReturnType.Name; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryApiTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryApiTests.cs index 05e75c2a..6930bba3 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryApiTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryApiTests.cs @@ -143,6 +143,7 @@ function writeBytesLocal(a, values) """u8; [Fact] + [Trait("Qualification", "Q21")] public void Target_scalars_preserve_signedness_widths_pointer_bits_and_floating_point_values() { EngineTest.RequireNativeLua(); @@ -190,6 +191,7 @@ public void Target_scalars_preserve_signedness_widths_pointer_bits_and_floating_ } [Fact] + [Trait("Qualification", "Q20")] public void Target_buffers_and_strings_keep_their_order_and_never_return_a_dangling_lua_span() { EngineTest.RequireNativeLua(); @@ -370,19 +372,22 @@ public void Empty_byte_reads_and_writes_do_not_resolve_or_invoke_CE_globals() EngineTest.Run(scope.State, "function readBytes(_) error('must not run') end function readBytesLocal(_) error('must not run') end function writeBytes(_) error('must not run') end function writeBytesLocal(_) error('must not run') end"u8); - Assert.Equal(0, FakeHost.ProviderCalls); + // Baseline is 1, not 0: LuaRuntime.Attach makes one eager provider call of its own to stamp the universe + // (WI-3, external-reset detection), before any of the empty read/write calls below. + int baseline = FakeHost.ProviderCalls; + Assert.Equal(1, baseline); Assert.True(TargetMemory.TryReadBytes(1UL, [], out MemoryAccessFailure failure)); Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(1, FakeHost.ProviderCalls); + Assert.Equal(baseline + 1, FakeHost.ProviderCalls); Assert.True(HostMemory.TryReadBytes(new HostAddress(1), [], out failure)); Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(2, FakeHost.ProviderCalls); + Assert.Equal(baseline + 2, FakeHost.ProviderCalls); Assert.True(TargetMemory.TryWriteBytes(1UL, [], out failure)); Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(3, FakeHost.ProviderCalls); + Assert.Equal(baseline + 3, FakeHost.ProviderCalls); Assert.True(HostMemory.TryWriteBytes(new HostAddress(1), [], out failure)); Assert.Equal(MemoryAccessFailure.None, failure); - Assert.Equal(4, FakeHost.ProviderCalls); + Assert.Equal(baseline + 4, FakeHost.ProviderCalls); Assert.Equal(0, scope.State.Top); } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryScalarsFidelityTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryScalarsFidelityTests.cs new file mode 100644 index 00000000..f7fbc3cf --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryScalarsFidelityTests.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using System.Text; + +using CheatEngine.SDK.Engine.Generated; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Memory; + +/// +/// Qualification Q21 and Q22 at C2 through the Engine's own generated wrappers (, emitted +/// from the memory-scalars spec): a signed 32-bit read keeps -1, an address above 4 GiB or above +/// reaches Lua as the exact integer, and the Try form tells a value from nil, +/// false and a raise (audit A12-22, A12-23). The stand-ins record what Lua received. +/// +[Trait("Category", "NativeLua")] +public sealed class MemoryScalarsFidelityTests +{ + private static ReadOnlySpan StandIn => """ + seen = {} + local function record(prefix, a) seen[#seen + 1] = prefix .. math.type(a) .. ':' .. string.format('%x', a) end + function readInteger(a, signed) + record('r32|', a) + if signed == true then return -1 end + return 4294967295 + end + function readQword(a) + record('r64|', a) + if a == 1 then return nil end + if a == 2 then return false end + if a == 3 then error('access violation') end + if a == 4 then return 0 end + if a == 5 then return end + return math.maxinteger + end + function writeQword(a, v) record('w64|', a) seen[#seen + 1] = math.type(v) .. ':' .. tostring(v) return true end + """u8; + + [Fact] + [Trait("Qualification", "Q21")] + public void Reads_int32_minus_one_without_a_double_conversion() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, StandIn); + + // The generated wrapper passes signed = true: the stand-in answers -1 only then, 4294967295 otherwise. + Assert.True(MemoryScalars.TryReadInt32(new Address(0x10), out int value)); + Assert.Equal(-1, value); + Assert.Equal("r32|integer:10", Seen(scope, 1)); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void Address_above_4_gib_reaches_lua_as_an_exact_integer() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, StandIn); + + Assert.True(MemoryScalars.TryReadInt64(new Address(0x1_0000_0000UL), out long maximum)); + Assert.Equal(long.MaxValue, maximum); + Assert.Equal("r64|integer:100000000", Seen(scope, 1)); + Assert.True(MemoryScalars.WriteInt64(new Address(0xFFFF_FFFF_FFFF_F000UL), long.MinValue)); + Assert.Equal("w64|integer:fffffffffffff000", Seen(scope, 2)); + Assert.Equal("integer:-9223372036854775808", Seen(scope, 3)); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Try_read_reports_nil_false_and_raise_as_failure_and_zero_as_value() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, StandIn); + + Assert.False(MemoryScalars.TryReadInt64(new Address(1), out long nil)); + Assert.Equal(0, nil); + Assert.False(MemoryScalars.TryReadInt64(new Address(2), out long no)); + Assert.Equal(0, no); + Assert.False(MemoryScalars.TryReadInt64(new Address(3), out long raised)); + Assert.Equal(0, raised); + Assert.Equal(0, scope.State.Top); + Assert.False(MemoryScalars.TryReadInt64(new Address(5), out long none)); + Assert.Equal(0, none); + Assert.True(MemoryScalars.TryReadInt64(new Address(4), out long zero)); + Assert.Equal(0, zero); + Assert.Equal(0, scope.State.Top); + } + + private static string Seen(HostScope scope, int index) + { + EngineTest.Run(scope.State, + Encoding.UTF8.GetBytes("return seen[" + index.ToString(CultureInfo.InvariantCulture) + "]"), 1); + string value = EngineTest.ReadString(scope.State, -1); + scope.State.Pop(1); + return value; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryTextFidelityTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryTextFidelityTests.cs new file mode 100644 index 00000000..6f1eb5a8 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Memory/MemoryTextFidelityTests.cs @@ -0,0 +1,149 @@ +using CheatEngine.SDK.Engine.Memory; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Memory; + +/// +/// Qualification Q20 at C2 for the memory text and byte surfaces: embedded NULs and invalid UTF-8 keep their exact +/// bytes in the byte forms, maximumLength and the wide flag reach Cheat Engine's globals unchanged, and a +/// partial byte read reports its confirmed prefix (audit A12-11, A12-15, A12-16). The stand-ins record what they +/// receive; the unit CE gives maximumLength for a wide read is not qualified here (C3). +/// +[Trait("Category", "NativeLua")] +public sealed class MemoryTextFidelityTests +{ + private static ReadOnlySpan StandIn => """ + calls = {} + writes = {} + local texts = { [256] = 'a\0b\0c', [512] = '\255\254A' } + local function record(kind, maximumLength, wide) + calls[#calls + 1] = kind .. '|' .. math.type(maximumLength) .. '|' .. tostring(maximumLength) .. '|' .. tostring(wide) + end + function readString(a, maximumLength, wide) record('target', maximumLength, wide) return texts[a] end + function readStringLocal(a, maximumLength, wide) record('host', maximumLength, wide) return texts[a] end + function writeString(a, v, wide) writes[#writes + 1] = 'target|' .. #v .. '|' .. tostring(wide) return true end + function writeStringLocal(a, v, wide) writes[#writes + 1] = 'host|' .. #v .. '|' .. tostring(wide) return true end + function readBytes(a, count, asTable) return { 7, 8 } end + function readBytesLocal(a, count, asTable) return { 9 } end + """u8; + + [Fact] + [Trait("Qualification", "Q20")] + public void Target_utf8_read_keeps_embedded_nul_and_reports_the_exact_byte_count() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, StandIn); + Span destination = stackalloc byte[16]; + + Assert.True(TargetMemory.TryReadUtf8(256UL, 100, destination, false, out int written, out int required, + out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.Equal(5, written); + Assert.Equal(5, required); + Assert.True(destination[..written].SequenceEqual("a\0b\0c"u8)); + Assert.True(TargetMemory.TryReadString(256UL, 100, false, out string? text, out failure)); + Assert.Equal("a\0b\0c", text); + Assert.True(HostMemory.TryReadUtf8(new HostAddress(256), 100, destination, false, out written, out failure)); + Assert.Equal(5, written); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Host_and_target_text_reads_pass_maximum_length_and_wide_flag_unchanged() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, StandIn); + Span destination = stackalloc byte[16]; + + Assert.True(TargetMemory.TryReadString(256UL, 7, true, out _, out _)); + Assert.True(TargetMemory.TryReadUtf8(256UL, 0, destination, false, out _, out _)); + Assert.True(HostMemory.TryReadString(new HostAddress(256), 3, true, out _, out _)); + Assert.True(HostMemory.TryReadUtf8(new HostAddress(256), int.MaxValue, destination, false, out _, out _)); + + // The count is forwarded as an integer, never rescaled for a wide read: its unit is CE's to define. + EngineTest.Run(scope.State, "return table.concat(calls, ';')"u8, 1); + Assert.Equal("target|integer|7|true;target|integer|0|false;host|integer|3|true;host|integer|2147483647|false", + EngineTest.ReadString(scope.State, -1)); + scope.State.Pop(1); + Assert.Throws(() => TargetMemory.TryReadString(256UL, -1, false, out _, out _)); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Utf8_write_with_embedded_nul_reaches_lua_with_its_exact_length() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, StandIn); + + Assert.True(TargetMemory.TryWriteUtf8(768UL, "x\0y"u8, false, out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.True(TargetMemory.TryWriteString(768UL, "x\0y\u00E9".AsSpan(), true, out failure)); + Assert.True(HostMemory.TryWriteUtf8(new HostAddress(768), "\0"u8, false, out failure)); + Assert.True(HostMemory.TryWriteString(new HostAddress(768), "\uD800".AsSpan(), false, out failure)); + + EngineTest.Run(scope.State, "return table.concat(writes, ';')"u8, 1); + Assert.Equal("target|3|false;target|5|true;host|1|false;host|3|false", EngineTest.ReadString(scope.State, -1)); + scope.State.Pop(1); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Invalid_utf8_text_read_keeps_raw_bytes_in_the_byte_form() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, StandIn); + Span destination = stackalloc byte[8]; + + Assert.True(TargetMemory.TryReadUtf8(512UL, 100, destination, false, out int written, + out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.None, failure); + Assert.True(destination[..written].SequenceEqual(new byte[] { 0xFF, 0xFE, (byte) 'A' })); + + // The string form is the explicit, lossy conversion: each invalid byte becomes U+FFFD. + Assert.True(TargetMemory.TryReadString(512UL, 100, false, out string? text, out failure)); + Assert.Equal("\uFFFD\uFFFDA", text); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Partial_byte_read_reports_the_confirmed_prefix_not_zero() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, StandIn); + Span destination = stackalloc byte[4]; + destination.Fill(0xA5); + + Assert.False(TargetMemory.TryReadBytes(16UL, destination, out int written, out MemoryAccessFailure failure)); + Assert.Equal(MemoryAccessFailure.PartialRead, failure); + Assert.Equal(2, written); + Assert.True(destination.SequenceEqual(new byte[] { 7, 8, 0xA5, 0xA5 })); + + destination.Fill(0xA5); + Assert.False(HostMemory.TryReadBytes(new HostAddress(16), destination, out written, out failure)); + Assert.Equal(MemoryAccessFailure.PartialRead, failure); + Assert.Equal(1, written); + Assert.True(destination.SequenceEqual(new byte[] { 9, 0xA5, 0xA5, 0xA5 })); + + // The all-or-nothing overload copies nothing and reports the read as failed. + destination.Fill(0xA5); + Assert.False(TargetMemory.TryReadBytes(16UL, destination, out failure)); + Assert.Equal(MemoryAccessFailure.ReadFailed, failure); + Assert.True(destination.SequenceEqual(new byte[] { 0xA5, 0xA5, 0xA5, 0xA5 })); + Assert.Equal(0, scope.State.Top); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/EngineResourceOriginTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/EngineResourceOriginTests.cs new file mode 100644 index 00000000..54872062 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/EngineResourceOriginTests.cs @@ -0,0 +1,62 @@ +using System.Reflection; + +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Objects; + +/// The origin context of durable resources: a copied, SDK-made value that compares both identity components. +public sealed class EngineResourceOriginTests +{ + [Fact] + public void Default_origin_names_no_runtime_and_no_target() + { + EngineResourceOrigin origin = default; + + Assert.Equal(0, origin.Runtime.AttachEpoch); + Assert.Equal(0, origin.Runtime.StateGeneration); + Assert.Null(origin.Target); + Assert.False(origin.IsTargetBound); + Assert.False(origin.IsCurrentRuntime); + } + + [Fact] + public void Consumers_cannot_construct_an_origin() + { + ConstructorInfo[] constructors = typeof(EngineResourceOrigin).GetConstructors( + BindingFlags.Instance | BindingFlags.Public); + + Assert.All(constructors, static constructor => Assert.Empty(constructor.GetParameters())); + Assert.All(typeof(EngineResourceOrigin).GetProperties(BindingFlags.Instance | BindingFlags.Public), + static property => Assert.False(property.CanWrite, property.Name + " is writable.")); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Current_runtime_compares_both_the_attach_epoch_and_the_state_generation() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineResourceOrigin first = EngineResourceOrigin.CaptureRuntime(); + + FakeHost.ReplaceStateGeneration(); + EngineResourceOrigin replaced = EngineResourceOrigin.CaptureRuntime(); + + Assert.Equal(first.Runtime.AttachEpoch, replaced.Runtime.AttachEpoch); + Assert.NotEqual(first, replaced); + Assert.False(first.IsCurrentRuntime); + Assert.True(replaced.IsCurrentRuntime); + + LuaRuntime.Detach(); + Assert.False(replaced.IsCurrentRuntime); + + LuaRuntime.Attach(scope.Binding); + EngineResourceOrigin reattached = EngineResourceOrigin.CaptureRuntime(); + Assert.Equal(replaced.Runtime.AttachEpoch + 1, reattached.Runtime.AttachEpoch); + Assert.False(replaced.IsCurrentRuntime); + Assert.True(reattached.IsCurrentRuntime); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnedTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnedTests.cs index 7f898899..cb3c8eb8 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnedTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnedTests.cs @@ -1,4 +1,6 @@ +using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Marshalling; @@ -8,7 +10,10 @@ namespace CheatEngine.SDK.Engine.Tests.Objects; -/// Ownership: explicit destroy through the protected call, exactly once, and the transfer API. +/// +/// Ownership: explicit destroy through the protected call, exactly once, the transfer API, and the origin policy that +/// refuses to destroy in another Lua universe. +/// public sealed class OwnedTests { [Fact] @@ -78,7 +83,7 @@ public void TryDestroy_while_detached_throws_before_touching_the_state_and_retai [Fact] [Trait("Category", "NativeLua")] - public void A_detached_owner_can_be_retried_after_its_original_host_binding_is_reattached() + public void A_detached_owner_is_refused_after_reattach_and_consumed_without_destroy() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); @@ -86,21 +91,239 @@ public void A_detached_owner_can_be_retried_after_its_original_host_binding_is_r LuaState L = scope.State; CEObject handle = FakeHost.CreateObject(L, "Probe"); Owned owned = new(handle); + EngineResourceOrigin origin = owned.Origin; LuaRuntime.Detach(); Assert.Throws(owned.Dispose); Assert.False(owned.IsDisposed); - Assert.False(FakeHost.IsDestroyed(L, handle)); LuaRuntime.Attach(scope.Binding); + Assert.False(owned.Origin.IsCurrentRuntime); owned.Dispose(); + Assert.True(owned.IsDisposed); + Assert.Equal(origin, owned.Origin); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, owned.LastReleaseOutcome.Status); + Assert.True(owned.LastReleaseOutcome.RequiresManualRecovery); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q17")] + public void Dispose_after_a_controlled_state_replacement_consumes_the_owner_without_destroy() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + LuaStateIdentity created = LuaRuntime.CurrentStateIdentity; + + FakeHost.ReplaceStateGeneration(); + + Assert.Equal(created.AttachEpoch, LuaRuntime.CurrentStateIdentity.AttachEpoch); + Assert.Equal(created.StateGeneration + 1, LuaRuntime.CurrentStateIdentity.StateGeneration); + Assert.False(owned.Origin.IsCurrentRuntime); + owned.Dispose(); + + Assert.True(owned.IsDisposed); + Assert.Equal(created, owned.Origin.Runtime); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, owned.LastReleaseOutcome.Status); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void TryDestroy_after_a_runtime_identity_change_throws_and_consumes_the_owner_without_destroy() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + FakeHost.ReplaceStateGeneration(); + InvalidOperationException exception = Assert.Throws(() => owned.TryDestroy(L)); + + Assert.Contains("previous Lua runtime identity", exception.Message, StringComparison.Ordinal); + Assert.True(owned.IsDisposed); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, owned.LastReleaseOutcome.Status); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + Assert.True(owned.TryDestroy(L).IsOk); + Assert.Equal(0, FakeHost.DestroyedCount(L)); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void ReleaseWithOutcome_reports_released_after_exactly_one_destroy() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + TargetReleaseOutcome outcome = owned.ReleaseWithOutcome(); + + Assert.Equal(TargetReleaseStatus.Released, outcome.Status); + Assert.False(outcome.RequiresManualRecovery); + Assert.Equal(outcome, owned.LastReleaseOutcome); Assert.True(owned.IsDisposed); Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(outcome, owned.ReleaseWithOutcome()); + owned.Dispose(); Assert.Equal(1, FakeHost.DestroyedCount(L)); Assert.Equal(0, L.Top); } + [Fact] + [Trait("Category", "NativeLua")] + public void ReleaseWithOutcome_when_destroy_raises_reports_unconfirmed_and_never_retries() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + EngineTest.Run(L, "destroy_attempts = 0"u8); + FakeHost.RunOnObject(L, handle, """ + o.getters.destroy = function() + return function() destroy_attempts = destroy_attempts + 1 error("refuses to be destroyed") end + end + """); + Owned owned = new(handle); + + TargetReleaseOutcome outcome = owned.ReleaseWithOutcome(); + TargetReleaseOutcome again = owned.ReleaseWithOutcome(); + owned.Dispose(); + + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, outcome.Status); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.FailureKind); + Assert.Equal(outcome, again); + Assert.True(owned.IsDisposed); + EngineTest.Run(L, "assert(destroy_attempts == 1)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void ReleaseWithOutcome_while_detached_reports_not_invoked_and_consumes_the_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + LuaRuntime.Detach(); + TargetReleaseOutcome outcome = owned.ReleaseWithOutcome(); + + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.Status); + Assert.Equal(EngineFailureKind.BindingFailure, outcome.FailureKind); + Assert.True(owned.IsDisposed); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void ReleaseWithOutcome_with_a_binding_that_has_no_pusher_reports_not_invoked() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state, false); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(handle); + + TargetReleaseOutcome outcome = owned.ReleaseWithOutcome(); + + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.Status); + Assert.Equal(EngineFailureKind.BindingFailure, outcome.FailureKind); + Assert.True(owned.IsDisposed); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Transfer_preserves_the_origin_of_the_source_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = FakeHost.CreateObject(L, "Probe"); + Owned source = new(handle); + EngineResourceOrigin origin = source.Origin; + + FakeHost.ReplaceStateGeneration(); + Owned destination = source.Transfer(); + destination.Dispose(); + + Assert.Equal(origin, destination.Origin); + Assert.NotEqual(LuaRuntime.CurrentStateIdentity, destination.Origin.Runtime); + Assert.Equal(TargetReleaseStatus.RefusedRuntimeChanged, destination.LastReleaseOutcome.Status); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void Owned_child_destroyed_by_its_parent_before_dispose_reports_unconfirmed_and_never_retries() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject child = FakeHost.CreateObject(L, "Probe"); + Owned owned = new(child); + EngineTest.Run(L, "destroy_attempts = 0"u8); + // The parent destroys its child behind the owner's back; the child's destroy then fails as CE's does. + FakeHost.RunOnObject(L, child, """ + o.destroyed = true + o.getters.destroy = function() + return function() destroy_attempts = destroy_attempts + 1 error("object already destroyed") end + end + """); + + owned.Dispose(); + owned.Dispose(); + + Assert.True(owned.IsDisposed); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, owned.LastReleaseOutcome.Status); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, owned.LastReleaseOutcome.FailureKind); + EngineTest.Run(L, "assert(destroy_attempts == 1)"u8); + Assert.Equal(0, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void An_owner_captures_the_runtime_identity_that_created_it() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using Owned owned = new(FakeHost.CreateObject(scope.State, "Probe")); + + Assert.Equal(LuaRuntime.CurrentStateIdentity, owned.Origin.Runtime); + Assert.Null(owned.Origin.Target); + Assert.False(owned.Origin.IsTargetBound); + Assert.True(owned.Origin.IsCurrentRuntime); + Assert.Equal(TargetReleaseStatus.Unspecified, owned.LastReleaseOutcome.Status); + } + [Fact] [Trait("Category", "NativeLua")] public void Dispose_with_a_binding_that_has_no_pusher_throws_and_retains_the_owner() diff --git a/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnershipSurfaceTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnershipSurfaceTests.cs new file mode 100644 index 00000000..377e057e --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Objects/OwnershipSurfaceTests.cs @@ -0,0 +1,117 @@ +using System.Reflection; + +using CheatEngine.SDK.Annotations.Lifetime; +using CheatEngine.SDK.Engine.Allocation; +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Objects; + +using EngineAddressList = CheatEngine.SDK.Engine.AddressList.AddressList; +using EngineMemoryRecord = CheatEngine.SDK.Engine.AddressList.MemoryRecord; + +namespace CheatEngine.SDK.Engine.Tests.Objects; + +/// +/// The public ownership surface of the Engine assembly: every durable resource exposes its origin, a getter never +/// grants destroy authority, and memory records stay borrowed. +/// +public sealed class OwnershipSurfaceTests +{ + private const BindingFlags PublicMembers = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + private static readonly Type[] SDurableResources = + [ + typeof(Owned), typeof(AllocatedRegion), typeof(AutoAssemblerPatch), typeof(SymbolRegistrationLease), + typeof(SymbolListRegistrationLease) + ]; + + [Fact] + public void Every_durable_resource_exposes_its_origin() + { + foreach (Type resource in SDurableResources) + { + PropertyInfo? origin = resource.GetProperty("Origin", BindingFlags.Instance | BindingFlags.Public); + + Assert.True(origin is not null, resource.Name + " has no public Origin."); + Assert.Equal(typeof(EngineResourceOrigin), origin.PropertyType); + Assert.False(origin.CanWrite, resource.Name + ".Origin is writable."); + } + } + + [Fact] + public void No_public_property_or_get_method_returns_an_owner() + { + List offenders = []; + foreach (Type type in EngineTypes()) + { + foreach (PropertyInfo property in type.GetProperties(PublicMembers)) + { + if (IsOwner(property.PropertyType)) + { + offenders.Add(type.Name + "." + property.Name); + } + } + + foreach (MethodInfo method in type.GetMethods(PublicMembers)) + { + if (!method.IsSpecialName && method.Name.StartsWith("Get", StringComparison.Ordinal) && + IsOwner(method.ReturnType)) + { + offenders.Add(type.Name + "." + method.Name); + } + } + } + + Assert.True(offenders.Count == 0, "A getter grants destroy authority: " + string.Join(", ", offenders)); + } + + [Fact] + public void Memory_records_are_never_wrapped_in_an_owner() + { + Type ownedRecord = typeof(Owned); + List offenders = []; + foreach (Type type in EngineTypes()) + { + foreach (PropertyInfo property in type.GetProperties(PublicMembers)) + { + if (property.PropertyType == ownedRecord) + { + offenders.Add(type.Name + "." + property.Name); + } + } + + foreach (MethodInfo method in type.GetMethods(PublicMembers)) + { + if (method.ReturnType == ownedRecord || Array.Exists(method.GetParameters(), + parameter => UnderlyingType(parameter.ParameterType) == ownedRecord)) + { + offenders.Add(type.Name + "." + method.Name); + } + } + } + + ParameterInfo created = typeof(EngineAddressList).GetMethod(nameof(EngineAddressList.TryCreateMemoryRecord))! + .GetParameters()[0]; + + Assert.True(offenders.Count == 0, "A memory record is exposed as an owner: " + string.Join(", ", offenders)); + Assert.True(created.IsOut); + Assert.True(Attribute.IsDefined(created, typeof(CEOwnedAttribute))); + } + + private static Type[] EngineTypes() + { + return typeof(Owned<>).Assembly.GetExportedTypes(); + } + + private static bool IsOwner(Type type) + { + Type candidate = UnderlyingType(type); + return candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(Owned<>); + } + + private static Type UnderlyingType(Type type) + { + return type.IsByRef ? type.GetElementType()! : type; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Processes/ProcessOperationStatusTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Processes/ProcessOperationStatusTests.cs new file mode 100644 index 00000000..a56eba41 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Processes/ProcessOperationStatusTests.cs @@ -0,0 +1,45 @@ +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Engine.Tests.Processes; + +/// Managed contract tests for the value-only . +public sealed class ProcessOperationStatusTests +{ + [Fact] + public void process_operation_status_default_is_unknown_and_not_success() + { + ProcessOperationStatus unassigned = default; + + Assert.Equal(ProcessOperationStatusKind.Unknown, unassigned.Kind); + Assert.False(unassigned.IsSuccess); + Assert.NotEqual(ProcessOperationStatus.Success, unassigned); + Assert.Equal(LuaStatus.Ok, unassigned.LuaStatus); + } + + [Fact] + public void named_statuses_carry_their_kind_and_only_success_reports_success() + { + (ProcessOperationStatus Status, ProcessOperationStatusKind Kind)[] statuses = + [ + (ProcessOperationStatus.Success, ProcessOperationStatusKind.Success), + (ProcessOperationStatus.TargetNotAttached, ProcessOperationStatusKind.TargetNotAttached), + (ProcessOperationStatus.SelectionNotConfirmed, ProcessOperationStatusKind.SelectionNotConfirmed), + (ProcessOperationStatus.GlobalUnavailable, ProcessOperationStatusKind.GlobalUnavailable), + (ProcessOperationStatus.InvalidResult, ProcessOperationStatusKind.InvalidResult), + (ProcessOperationStatus.TargetChanged, ProcessOperationStatusKind.TargetChanged), + (ProcessOperationStatus.FileAsProcessTarget, ProcessOperationStatusKind.FileAsProcessTarget), + (ProcessOperationStatus.ProtectedLuaFailure(LuaStatus.RuntimeError), + ProcessOperationStatusKind.ProtectedLuaFailure) + ]; + + foreach ((ProcessOperationStatus status, ProcessOperationStatusKind kind) in statuses) + { + Assert.Equal(kind, status.Kind); + Assert.Equal(kind == ProcessOperationStatusKind.Success, status.IsSuccess); + } + + Assert.Equal(LuaStatus.RuntimeError, ProcessOperationStatus.ProtectedLuaFailure(LuaStatus.RuntimeError).LuaStatus); + Assert.Throws(() => ProcessOperationStatus.ProtectedLuaFailure(LuaStatus.Ok)); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeHostOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeHostOperationsTests.cs new file mode 100644 index 00000000..98d17c3f --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeHostOperationsTests.cs @@ -0,0 +1,212 @@ +using System.Text; + +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Processes; + +/// +/// Native-Lua boundary tests for the Cheat Engine host facts: bitness, operating system and the complete file +/// version. The stand-ins return the spike C3 values; they are fixture contracts, not host qualification. +/// +[Trait("Category", "NativeLua")] +public sealed class RuntimeHostOperationsTests +{ + [Fact] + public void runtime_host_operations_decode_cheat_engine_bitness_operating_system_and_file_version() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + + LuaOperationStatus bitnessStatus = RuntimeHostOperations.TryIsCheatEngine64Bit(out bool is64Bit); + LuaOperationStatus systemStatus = + RuntimeHostOperations.TryGetOperatingSystem(out CheatEngineOperatingSystem operatingSystem); + LuaOperationStatus versionStatus = + RuntimeHostOperations.TryGetCheatEngineFileVersion(out CheatEngineVersion version); + + Assert.True(bitnessStatus.IsSuccess); + Assert.True(is64Bit); + Assert.True(systemStatus.IsSuccess); + Assert.Equal(CheatEngineOperatingSystem.Windows, operatingSystem); + Assert.True(versionStatus.IsSuccess); + Assert.Equal(CheatEngineVersion.Ce77010621, version); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("rt_file_version_table.build = 10620")] + [InlineData("rt_file_version_table.major = 8")] + [InlineData("rt_file_version_table.minor = nil")] + [InlineData("rt_file_version_table.release = 0.0")] + [InlineData("rt_file_version_table.build = '10621'")] + [InlineData("rt_file_version = -1")] + [InlineData("rt_file_version = 1970354901756285.0")] + [InlineData("rt_file_version = '1970354901756285'")] + [InlineData("rt_file_version_table = 'not a table'")] + public void file_version_integer_and_table_must_agree(string change) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(change)); + + LuaOperationStatus status = RuntimeHostOperations.TryGetCheatEngineFileVersion(out CheatEngineVersion version); + + Assert.Equal(LuaOperationStatusKind.InvalidResult, status.Kind); + Assert.Equal(default, version); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void file_version_integer_without_a_table_is_accepted() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + EngineTest.Run(scope.State, "rt_file_version_table = nil"u8); + + LuaOperationStatus status = RuntimeHostOperations.TryGetCheatEngineFileVersion(out CheatEngineVersion version); + + Assert.True(status.IsSuccess); + Assert.Equal(CheatEngineVersion.Ce77010621, version); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("function getCheatEngineFileVersion() end")] + [InlineData("function getCheatEngineFileVersion() return nil end")] + public void file_version_without_any_result_is_a_nil_result(string fixture) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(fixture)); + + LuaOperationStatus status = RuntimeHostOperations.TryGetCheatEngineFileVersion(out CheatEngineVersion version); + + Assert.Equal(LuaOperationStatusKind.NilResult, status.Kind); + Assert.Equal(default, version); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void file_version_and_other_host_globals_keep_absence_and_lua_failure_distinct() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + + LuaOperationStatus absent = RuntimeHostOperations.TryGetCheatEngineFileVersion(out _); + // Resolved globals are cached per state identity, so each stand-in reads a variable instead of being redefined. + EngineTest.Run(scope.State, """ + bitness_result = nil + function getCheatEngineFileVersion() error('fixture version failure') end + function cheatEngineIs64Bit() return bitness_result end + function getOperatingSystem() return 5 end + """u8); + LuaOperationStatus raising = RuntimeHostOperations.TryGetCheatEngineFileVersion(out _); + LuaOperationStatus nilBitness = RuntimeHostOperations.TryIsCheatEngine64Bit(out bool is64Bit); + LuaOperationStatus unknownSystem = + RuntimeHostOperations.TryGetOperatingSystem(out CheatEngineOperatingSystem operatingSystem); + EngineTest.Run(scope.State, "bitness_result = 'yes'"u8); + LuaOperationStatus malformedBitness = RuntimeHostOperations.TryIsCheatEngine64Bit(out _); + + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, absent.Kind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, raising.Kind); + Assert.Equal(LuaStatus.RuntimeError, raising.LuaStatus); + Assert.Equal(LuaOperationStatusKind.NilResult, nilBitness.Kind); + Assert.False(is64Bit); + Assert.Equal(LuaOperationStatusKind.InvalidResult, unknownSystem.Kind); + Assert.Equal(CheatEngineOperatingSystem.Unknown, operatingSystem); + Assert.Equal(LuaOperationStatusKind.InvalidResult, malformedBitness.Kind); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observe_host_reads_the_four_host_facts_without_inference() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + + LuaOperationStatus status = RuntimeHostOperations.ObserveHost(out CheatEngineHostObservation host); + + Assert.True(status.IsSuccess); + Assert.Equal(CheatEngineVersion.Ce77010621, host.FileVersion); + Assert.Equal(CheatEngineArchitecture.X64, host.SystemArchitecture); + Assert.True(host.CheatEngineIs64Bit); + Assert.Equal(CheatEngineOperatingSystem.Windows, host.OperatingSystem); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observe_host_keeps_absent_globals_unknown_instead_of_inferring_them() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + // Only the system architecture is present: it must not fill the 64-bit flag. + EngineTest.Run(scope.State, "function getSystemArchitecture() return 1 end"u8); + + LuaOperationStatus status = RuntimeHostOperations.ObserveHost(out CheatEngineHostObservation host); + + Assert.True(status.IsSuccess); + Assert.Null(host.FileVersion); + Assert.Equal(CheatEngineArchitecture.X64, host.SystemArchitecture); + Assert.Null(host.CheatEngineIs64Bit); + Assert.Equal(CheatEngineOperatingSystem.Unknown, host.OperatingSystem); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observe_host_with_an_unreadable_version_resource_keeps_the_other_facts() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + EngineTest.Run(scope.State, "function getCheatEngineFileVersion() end"u8); + + LuaOperationStatus status = RuntimeHostOperations.ObserveHost(out CheatEngineHostObservation host); + + Assert.True(status.IsSuccess); + Assert.Null(host.FileVersion); + Assert.True(host.CheatEngineIs64Bit); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("function getSystemArchitecture() error('fixture failure') end", LuaOperationStatusKind.LuaFailure)] + [InlineData("function cheatEngineIs64Bit() error('fixture failure') end", LuaOperationStatusKind.LuaFailure)] + [InlineData("function getOperatingSystem() error('fixture failure') end", LuaOperationStatusKind.LuaFailure)] + [InlineData("function getCheatEngineFileVersion() error('fixture failure') end", LuaOperationStatusKind.LuaFailure)] + [InlineData("function cheatEngineIs64Bit() return nil end", LuaOperationStatusKind.NilResult)] + [InlineData("function getOperatingSystem() return nil end", LuaOperationStatusKind.NilResult)] + [InlineData("function getSystemArchitecture() return 99 end", LuaOperationStatusKind.InvalidResult)] + [InlineData("function getOperatingSystem() return 3 end", LuaOperationStatusKind.InvalidResult)] + [InlineData("function cheatEngineIs64Bit() return 1 end", LuaOperationStatusKind.InvalidResult)] + [InlineData("rt_file_version_table.major = 6", LuaOperationStatusKind.InvalidResult)] + public void observe_host_keeps_raising_nil_and_malformed_results_distinct(string fixture, + LuaOperationStatusKind expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(fixture)); + + LuaOperationStatus status = RuntimeHostOperations.ObserveHost(out CheatEngineHostObservation host); + + Assert.Equal(expected, status.Kind); + Assert.Equal(default, host); + Assert.Equal(0, scope.State.Top); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs index 7e0dc2a5..4c8ae58b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using CheatEngine.SDK.Engine.Inspection; @@ -89,6 +90,35 @@ function getCEVersion() error('fixture version failure') end Assert.Equal(0, lua.Top); } + [Fact] + public void integral_float_codes_are_invalid_in_every_api_that_reads_the_same_integer_global() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState lua = scope.State; + FakeHost.InstallCe77HostFacts(lua); + FakeHost.InstallCe77X64TargetFacts(lua, 4242); + EngineTest.Run(lua, """ + rt_system_architecture = 1.0 + rt_operating_system = 0.0 + rt_abi = 0.0 + """u8); + + LuaOperationStatus architectureStatus = RuntimeHostOperations.TryGetSystemArchitecture(out _); + LuaOperationStatus operatingSystemStatus = RuntimeHostOperations.TryGetOperatingSystem(out _); + LuaOperationStatus abiStatus = RuntimeHostOperations.TryGetTargetAbi(out _); + LuaOperationStatus hostStatus = RuntimeHostOperations.ObserveHost(out _); + ProcessOperationStatus targetStatus = RuntimeProcessOperations.ObserveTargetArchitecture(out _); + + Assert.Equal(LuaOperationStatusKind.InvalidResult, architectureStatus.Kind); + Assert.Equal(LuaOperationStatusKind.InvalidResult, operatingSystemStatus.Kind); + Assert.Equal(LuaOperationStatusKind.InvalidResult, abiStatus.Kind); + Assert.Equal(LuaOperationStatusKind.InvalidResult, hostStatus.Kind); + Assert.Equal(ProcessOperationStatusKind.InvalidResult, targetStatus.Kind); + Assert.Equal(0, lua.Top); + } + [Fact] public void RuntimeHostOperations_absent_global_reports_unavailability_without_entering_a_call() { @@ -152,6 +182,7 @@ function targetIs64Bit() error('must not be called') end [InlineData("-1")] [InlineData("2147483648")] [InlineData("'42'")] + [InlineData("4242.0")] public void ObserveCurrent_malformed_process_identifier_is_not_converted_to_target_absence(string luaResult) { EngineTest.RequireNativeLua(); @@ -290,10 +321,534 @@ public void RuntimeProcess_capability_identifiers_are_stable_and_distinct() Assert.NotEqual(RuntimeCapabilityId.CurrentProcess, RuntimeCapabilityId.ProcessSelection); } + [Fact] + [Trait("Qualification", "Q31.a")] + public void observe_target_architecture_reports_x64_bitness_and_a_narrower_configured_pointer_size_separately() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + // Spike C3 D3a: after setPointerSize(4) on the x64 target, getPointerSize() == 4 while targetIs64Bit() stays true. + EngineTest.Run(scope.State, "rt_pointer_size = 4"u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(CheatEngineArchitecture.X64, observation.Architecture); + Assert.Equal(PointerSize.Bit64, observation.Bitness); + Assert.Equal(4, observation.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Bit32, observation.ConfiguredPointerSize); + Assert.True(observation.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q32.a")] + public void observe_target_architecture_with_the_spike_x64_facts_reports_x64_and_eight_byte_pointers() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(new TargetProcessId(4242), observation.ProcessId); + Assert.Equal(TargetBackend.LocalProcess, observation.Backend); + Assert.Equal(PointerSize.Bit64, observation.Bitness); + Assert.True(observation.IsX86Family); + Assert.False(observation.IsArmFamily); + Assert.False(observation.IsAndroid); + Assert.Equal(0, observation.AbiCode); + Assert.Equal(TargetAbi.Windows, observation.Abi); + Assert.Equal(8, observation.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Bit64, observation.ConfiguredPointerSize); + Assert.Equal(CheatEngineArchitecture.X64, observation.Architecture); + Assert.False(observation.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q32.b")] + public void observe_target_architecture_with_the_spike_x86_facts_reports_x86_and_four_byte_pointers() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X86TargetFacts(scope.State, 21544); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(CheatEngineArchitecture.X86, observation.Architecture); + Assert.Equal(PointerSize.Bit32, observation.Bitness); + Assert.Equal(PointerSize.Bit32, observation.ConfiguredPointerSize); + Assert.False(observation.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [Trait("Qualification", "Q32.d")] + [InlineData(false, true, false, CheatEngineArchitecture.Arm32)] + [InlineData(false, true, true, CheatEngineArchitecture.Arm64)] + [InlineData(true, true, true, CheatEngineArchitecture.Unknown)] + [InlineData(false, false, true, CheatEngineArchitecture.Unknown)] + public void observe_target_architecture_maps_arm32_arm64_and_keeps_contradictory_families_unknown(bool isX86, + bool isArm, bool is64Bit, CheatEngineArchitecture expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes( + "rt_is_x86 = " + LuaBoolean(isX86) + "\nrt_is_arm = " + LuaBoolean(isArm) + "\nrt_is_64bit = " + + LuaBoolean(is64Bit))); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(expected, observation.Architecture); + Assert.Equal(isX86, observation.IsX86Family); + Assert.Equal(isArm, observation.IsArmFamily); + Assert.Equal(is64Bit ? PointerSize.Bit64 : PointerSize.Bit32, observation.Bitness); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q32.d")] + public void observe_target_architecture_keeps_absent_isa_android_abi_and_pointer_probes_unknown_instead_of_false() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, """ + function getOpenedProcessID() return 4242 end + function targetIs64Bit() return true end + """u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(TargetBackend.Unknown, observation.Backend); + Assert.Equal(PointerSize.Bit64, observation.Bitness); + Assert.Null(observation.IsX86Family); + Assert.Null(observation.IsArmFamily); + Assert.Null(observation.IsAndroid); + Assert.Null(observation.AbiCode); + Assert.Equal(TargetAbi.Unknown, observation.Abi); + Assert.Null(observation.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Unknown, observation.ConfiguredPointerSize); + Assert.Null(observation.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(CheatEngineArchitecture.Unknown, observation.Architecture); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observe_target_architecture_keeps_an_undocumented_abi_code_without_failing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, "rt_abi = 7"u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(7, observation.AbiCode); + Assert.Equal(TargetAbi.Unknown, observation.Abi); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void observe_target_architecture_without_a_selected_target_reads_no_fact() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 0); + RaiseOnEveryTargetFact(scope.State); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.Equal(ProcessOperationStatusKind.TargetNotAttached, status.Kind); + Assert.Equal(default, observation); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [Trait("Qualification", "Q32")] + [InlineData("rt_process_id = 5151")] + [InlineData("rt_process_id = 0")] + [InlineData("rt_process_id = 4294967295")] + public void observe_target_architecture_reports_a_pid_change_between_the_bracketing_reads_as_target_changed( + string change) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes( + "function getPointerSize() " + change + " return rt_pointer_size end")); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.Equal(ProcessOperationStatusKind.TargetChanged, status.Kind); + Assert.Equal(default, observation); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observe_target_architecture_keeps_the_failure_of_the_closing_pid_read() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, """ + pid_reads = 0 + function getOpenedProcessID() + pid_reads = pid_reads + 1 + if pid_reads > 1 then error('fixture closing PID read failure') end + return rt_process_id + end + """u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.Equal(ProcessOperationStatusKind.ProtectedLuaFailure, status.Kind); + Assert.Equal(LuaStatus.RuntimeError, status.LuaStatus); + Assert.Equal(default, observation); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q30.d")] + public void observe_target_architecture_on_a_ceserver_connection_keeps_the_facts_and_names_the_backend() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, "rt_ceserver = true"u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.True(status.IsSuccess); + Assert.Equal(TargetBackend.CEServer, observation.Backend); + Assert.Equal(new TargetProcessId(4242), observation.ProcessId); + Assert.Equal(CheatEngineArchitecture.X64, observation.Architecture); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q30.c")] + public void observe_target_architecture_reports_the_file_as_process_sentinel_without_reading_facts() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, "rt_process_id = 4294967295"u8); + RaiseOnEveryTargetFact(scope.State); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.Equal(ProcessOperationStatusKind.FileAsProcessTarget, status.Kind); + Assert.Equal(default, observation); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("isConnectedToCEServer", "error('fixture probe failure')", ProcessOperationStatusKind.ProtectedLuaFailure)] + [InlineData("isConnectedToCEServer", "return nil", ProcessOperationStatusKind.InvalidResult)] + [InlineData("isConnectedToCEServer", "return 'false'", ProcessOperationStatusKind.InvalidResult)] + [InlineData("targetIsX86", "error('fixture probe failure')", ProcessOperationStatusKind.ProtectedLuaFailure)] + [InlineData("targetIsX86", "return nil", ProcessOperationStatusKind.InvalidResult)] + [InlineData("targetIsArm", "return 0", ProcessOperationStatusKind.InvalidResult)] + [InlineData("targetIsAndroid", "return nil", ProcessOperationStatusKind.InvalidResult)] + [InlineData("targetIsAndroid", "error('fixture probe failure')", ProcessOperationStatusKind.ProtectedLuaFailure)] + [InlineData("getABI", "return 'x'", ProcessOperationStatusKind.InvalidResult)] + [InlineData("getABI", "return 0.5", ProcessOperationStatusKind.InvalidResult)] + [InlineData("getPointerSize", "return nil", ProcessOperationStatusKind.InvalidResult)] + [InlineData("getPointerSize", "error('fixture probe failure')", ProcessOperationStatusKind.ProtectedLuaFailure)] + [InlineData("targetIs64Bit", "return nil", ProcessOperationStatusKind.InvalidResult)] + public void raising_or_malformed_optional_probe_fails_the_observation_instead_of_becoming_unknown(string global, + string body, ProcessOperationStatusKind expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("function " + global + "() " + body + " end")); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.Equal(expected, status.Kind); + Assert.Equal(default, observation); + if (expected == ProcessOperationStatusKind.ProtectedLuaFailure) + { + Assert.Equal(LuaStatus.RuntimeError, status.LuaStatus); + } + + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observe_target_architecture_requires_the_bitness_probe() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, "targetIs64Bit = nil"u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.Equal(ProcessOperationStatusKind.GlobalUnavailable, status.Kind); + Assert.Equal(default, observation); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void the_next_observation_after_a_probe_failure_succeeds_on_the_same_state() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, """ + rt_raise_android = true + function targetIsAndroid() + if rt_raise_android then error('fixture probe failure') end + return rt_is_android + end + """u8); + + ProcessOperationStatus failed = RuntimeProcessOperations.ObserveTargetArchitecture(out _); + Assert.Equal(0, scope.State.Top); + EngineTest.Run(scope.State, "rt_raise_android = false"u8); + ProcessOperationStatus recovered = + RuntimeProcessOperations.ObserveTargetArchitecture(out TargetArchitectureObservation observation); + + Assert.Equal(ProcessOperationStatusKind.ProtectedLuaFailure, failed.Kind); + Assert.True(recovered.IsSuccess); + Assert.Equal(CheatEngineArchitecture.X64, observation.Architecture); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData(4, ProcessOperationStatusKind.Success)] + [InlineData(8, ProcessOperationStatusKind.Success)] + public void configured_pointer_size_of_four_or_eight_is_reported_with_its_raw_value(int configured, + ProcessOperationStatusKind expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes( + "rt_pointer_size = " + configured.ToString(CultureInfo.InvariantCulture))); + + ProcessOperationStatus status = + RuntimeProcessOperations.TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize); + + Assert.Equal(expected, status.Kind); + Assert.Equal(configured, rawBytes); + Assert.Equal(new PointerSize(configured), pointerSize); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [Trait("Qualification", "Q31")] + [InlineData("2", 2)] + [InlineData("0", 0)] + [InlineData("-1", -1)] + [InlineData("16", 16)] + [InlineData("2147483648", 0)] + [InlineData("4.0", 0)] + [InlineData("'4'", 0)] + public void configured_pointer_size_outside_four_and_eight_is_invalid_and_keeps_the_raw_value(string luaValue, + int expectedRaw) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + // Spike C3 D3b: setPointerSize accepts any integer (2 was stored and read back). + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("rt_pointer_size = " + luaValue)); + + ProcessOperationStatus status = + RuntimeProcessOperations.TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize); + + Assert.Equal(ProcessOperationStatusKind.InvalidResult, status.Kind); + Assert.Equal(expectedRaw, rawBytes); + Assert.Equal(PointerSize.Unknown, pointerSize); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q31")] + public void configured_pointer_size_is_not_read_without_a_selected_target() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + // With no target CE 7.7 still answers getPointerSize() == 8 (spike C3 D2); the SDK must not report it. + FakeHost.InstallCe77X64TargetFacts(scope.State, 0); + EngineTest.Run(scope.State, "function getPointerSize() error('getPointerSize must not be read without a target') end"u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize); + + Assert.Equal(ProcessOperationStatusKind.TargetNotAttached, status.Kind); + Assert.Equal(0, rawBytes); + Assert.Equal(PointerSize.Unknown, pointerSize); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q31")] + public void configured_pointer_size_comes_from_get_pointer_size_not_from_the_plugin_process_width() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + Assert.Equal(8, IntPtr.Size); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, "rt_pointer_size = 4"u8); + + ProcessOperationStatus status = + RuntimeProcessOperations.TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize); + + Assert.True(status.IsSuccess); + Assert.Equal(4, rawBytes); + Assert.Equal(PointerSize.Bit32, pointerSize); + Assert.NotEqual(IntPtr.Size, pointerSize.Bytes); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("targetIs64Bit")] + [InlineData("targetIsX86")] + [InlineData("isConnectedToCEServer")] + [InlineData("getABI")] + public void configured_pointer_size_reads_only_the_pid_and_get_pointer_size(string otherGlobal) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes( + "function " + otherGlobal + "() error('not part of the configured pointer size read') end")); + + ProcessOperationStatus status = RuntimeProcessOperations.TryGetConfiguredPointerSize(out int rawBytes, out _); + + Assert.True(status.IsSuccess); + Assert.Equal(8, rawBytes); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("getPointerSize = nil", ProcessOperationStatusKind.GlobalUnavailable)] + [InlineData("rt_process_id = 4294967295", ProcessOperationStatusKind.FileAsProcessTarget)] + [InlineData("function getPointerSize() rt_process_id = 77 return 8 end", ProcessOperationStatusKind.TargetChanged)] + [InlineData("function getPointerSize() error('fixture failure') end", ProcessOperationStatusKind.ProtectedLuaFailure)] + public void configured_pointer_size_keeps_availability_backend_change_and_lua_failures_distinct(string fixture, + ProcessOperationStatusKind expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(fixture)); + + ProcessOperationStatus status = + RuntimeProcessOperations.TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize); + + Assert.Equal(expected, status.Kind); + Assert.Equal(0, rawBytes); + Assert.Equal(PointerSize.Unknown, pointerSize); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q30.c")] + public void observe_current_reports_the_file_as_process_sentinel_distinctly_from_a_malformed_pid() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, """ + selected = 4294967295 + function getOpenedProcessID() return selected end + function targetIs64Bit() error('a file opened as a process has no bitness probe here') end + """u8); + + ProcessOperationStatus sentinel = + RuntimeProcessOperations.ObserveCurrent(out CurrentProcessObservation sentinelObservation); + EngineTest.Run(scope.State, "selected = 4294967294"u8); + ProcessOperationStatus malformed = RuntimeProcessOperations.ObserveCurrent(out _); + + Assert.Equal(ProcessOperationStatusKind.FileAsProcessTarget, sentinel.Kind); + Assert.Equal(default, sentinelObservation); + Assert.Equal(ProcessOperationStatusKind.InvalidResult, malformed.Kind); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q31.a")] + public void observe_current_keeps_bitness_as_its_pointer_size_while_the_configured_size_differs() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, "rt_pointer_size = 4"u8); + + ProcessOperationStatus status = RuntimeProcessOperations.ObserveCurrent(out CurrentProcessObservation current); + + Assert.True(status.IsSuccess); + Assert.Equal(PointerSize.Bit64, current.PointerSize); + Assert.Equal(0, scope.State.Top); + } + + private static void RaiseOnEveryTargetFact(LuaState state) + { + EngineTest.Run(state, """ + local function forbidden(name) + return function() error(name .. ' must not be read for this selection') end + end + isConnectedToCEServer = forbidden('isConnectedToCEServer') + targetIs64Bit = forbidden('targetIs64Bit') + targetIsX86 = forbidden('targetIsX86') + targetIsArm = forbidden('targetIsArm') + targetIsAndroid = forbidden('targetIsAndroid') + getABI = forbidden('getABI') + getPointerSize = forbidden('getPointerSize') + """u8); + } + private static void InstallCurrentProcessGlobals(LuaState state, int processId, bool is64Bit) { string source = "function getOpenedProcessID() return " + processId + " end\n" + - "function targetIs64Bit() return " + LuaBoolean(is64Bit) + " end"; + "function targetIs64Bit() return " + LuaBoolean(is64Bit) + " end"; EngineTest.Run(state, Encoding.UTF8.GetBytes(source)); } diff --git a/tests/CheatEngine.SDK.Engine.Tests/README.md b/tests/CheatEngine.SDK.Engine.Tests/README.md index 6d59dd18..545158ba 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/README.md +++ b/tests/CheatEngine.SDK.Engine.Tests/README.md @@ -20,13 +20,16 @@ Tests tagged `Category=NativeLua` run against the Lua DLL of Cheat Engine 7.7 ke native code. See [ `tests/CheatEngine.SDK.Tests.Shared/README.md`](../CheatEngine.SDK.Tests.Shared/README.md). -| Piece | Role | -|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Support/FakeHost.cs` | Stands in for `GetLuaState` and `LuaPushClassInstance`, not for Lua. A Lua model supplies object, list, scanner and address-list stand-ins, getters and setters that can raise, zero-based `obj[i]` and `destroy` bookkeeping. | -| `Support/HostScope.cs` | Attaches `LuaRuntime` to a fixture state for one test and detaches on dispose. Tests attach the runtime only through it, and tests that need it unattached call `LuaRuntime.Detach()` first. | -| `Support/DebugAssertScope.cs` | Turns a failed `Debug.Assert` into an exception, so the Debug-only main-thread guard of `Owned` is testable. That test skips in Release. | -| `Support/EngineTest.cs` | `RequireNativeLua()` skips without a Lua library. `RunOnWorker` runs work on a fresh thread and returns what it threw. | -| `AssemblyInfo.cs` | Runs tests sequentially, because `LuaRuntime` and the fake host are process-wide. | +| Piece | Role | +|--------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Support/FakeHost.cs` | Stands in for `GetLuaState` and `LuaPushClassInstance`, not for Lua. A Lua model supplies object, list, scanner and address-list stand-ins, getters and setters that can raise, zero-based `obj[i]` and `destroy` bookkeeping. | +| `Support/FakeHost.SRes.cs` | Adds the S-RES fixtures: a controlled state replacement (`ReplaceStateGeneration`), a symbol-list class, a qualified local target, and a re-entrant host hook that calls back into the SDK from inside a Lua call. | +| `Support/FakeHost.Scanning.cs` | Adds `managed_hook`, a Lua function that runs test code from inside a CE-shaped call, so a stand-in can model CE running queued main-thread work (for example during `waitTillDone`) that calls back into a scan session. | +| `Support/HostScope.cs` | Attaches `LuaRuntime` to a fixture state for one test and detaches on dispose. Tests attach the runtime only through it, and tests that need it unattached call `LuaRuntime.Detach()` first. | +| `Support/DebugAssertScope.cs` | Turns a failed `Debug.Assert` into an exception, so the Debug-only main-thread guard of `Owned` is testable. That test skips in Release. | +| `Support/EngineTest.cs` | `RequireNativeLua()` skips without a Lua library. `RunOnWorker` runs work on a fresh thread and returns what it threw. | +| `AssemblyInfo.cs` | Runs tests sequentially, because `LuaRuntime` and the fake host are process-wide. | +| `Scanning/MemScanTestHost.cs` | Lua stand-ins for `createMemScan`, `createFoundList` and the MemScan/FoundList members the SDK calls; every call is traced, and Lua globals steer each result shape (wait result, stop, error text, rows, target). | Each fake object is a Lua table found by its pointer, so every push of one pointer finds the same state. Pointers are synthetic and never dereferenced. The double follows the assumed userdata layout, so the suite cannot prove that Cheat @@ -41,17 +44,49 @@ behavior. - Hot paths allocate exactly zero bytes after warm-up, measured by `Support/AllocationGate.cs`: typed get, set and call, stack-level primitives, `Address` and enum marshalling, enum name lookup. - A typed operation acquires the state once and pushes the object once (`HostCallCountTests`). -- `Owned` destroys the object exactly once. After the plugin is disabled, `Dispose` skips the destroy call and leaves - the object alive. +- `Owned` destroys the object exactly once. After the plugin is disabled, `Dispose` throws and leaves the object + alive; after a re-enable or a controlled state replacement, every release path consumes the owner without a call and + reports `RefusedRuntimeChanged`. `ReleaseWithOutcome` never throws, and a transfer keeps the origin + (`OwnedTests`, `EngineResourceOriginTests`, `OwnershipSurfaceTests`). - Enum values and Cheat Engine names are pinned by literals. - `RuntimeInfo`/`RuntimeCapabilities` retain explicit unknown facts. `TargetMemory` and `HostMemory` keep their address types separate, preserve byte ordering through span calls, and distinguish expected read/write failures. +- Runtime and target facts (ISA family, bitness, configured pointer size, ABI, Android, host bitness, OS, file version, + backend) are read separately, PID first, with the spike C3 values in `Support/FakeHost.Rt.cs`; absent globals stay + unknown, raising and malformed probes keep distinct statuses, and the probes touch only a read-only allowlist of + globals (`RuntimeProcessOperationsTests`, `RuntimeHostOperationsTests`, `RuntimeObservationsTests`, + `InstructionOperationsTests`, `InstructionAssemblerTests`, `RuntimeCapabilityProbesTests`). +- `TargetSelection` emits local creation-time evidence only when `isConnectedToCEServer()` returned `false`; CEServer, + unknown-backend and file-as-process selections are refused without a process lookup (`TargetSelectionTests`). Every + fixture that models a qualified local target therefore defines `isConnectedToCEServer` (use + `FakeHost.LocalTargetBackendChunk`). - Inspection snapshots distinguish documented `nil` from malformed results. Allocation ownership is consumed exactly - once even when the underlying release fails. AOB and StringList results are explicit `Owned` values. -- A scan session enforces its state machine and destroys its owned `FoundList` before its `MemScan`. Address-list and - memory-record handles remain CE-borrowed and are never implicitly owned. + once even when the underlying release fails; `TryAllocate` reports every result with its effect state and never + leaves a live address without an owner or one reported compensation; a released region never frees a later + allocation at the same address (`TargetMemoryAllocatorTests`, `AllocationLifecycleTests`). AOB and StringList results + are explicit `Owned` values. +- Auto Assembler activation reports a factual outcome, copies CE text only on request and bounded, and publishes a + bounded disable-info snapshot that never fails the activation (`AutoAssemblerOutcomeTests`, + `AutoAssemblerDisableInfoSnapshotTests`). +- Symbol leases never unregister a replaced or removed name, and a registered symbol list is unregistered before it is + destroyed (`SymbolLeaseReplacementTests`, `SymbolListTests`). +- A scan session enforces its state machine and destroys its owned `FoundList` before its `MemScan`, after one + cooperative stop when a scan may still run; a release requested from inside one of its own CE waits is deferred until + that wait has returned, and other members called from there are refused. AOB zero matches as CE 7.7 reports them (no + value) are `NoResult`; the bounded AOB route is exhaustive, post-filters its start, reports a factual `NoMatches` and + releases its session once even when its staging buffer cannot be allocated; the chapter-13 battery is covered at + fixture level and tagged `Q25`–`Q29` (`AobScannerTests`, `AobBoundedScanTests`, `AobFirstFoundScanTests`, + `MemoryScanSessionReleaseTests`, `MemoryScanSessionDeadlineTests`, `MemoryScanSessionBatteryTests`). Address-list and + memory-record handles remain CE-borrowed and are never implicitly owned. Activation reports its before and after + state and never retries, and a table load refuses re-entrant mutations (`MemoryRecordActivationTests`, + `AddressListExitTests`). - Address-list and memory-record wrappers also omit `MainThreadOnly` metadata until the CE 7.7 dispatcher probe turns their GUI affinity inference into an enforceable contract (`AddressListValueTests`). +- Memory text keeps embedded NULs and raw invalid UTF-8 in the byte forms, `maximumLength` and the wide flag reach CE + unchanged, and a partial byte read reports its prefix (`MemoryTextFidelityTests`, qualification Q20). The generated + `MemoryScalars` read a signed -1, pass an address above 4 GiB as an exact Lua integer and keep `nil`, `false` and a + raise apart from 0 (`MemoryScalarsFidelityTests`, Q21 and Q22). `Address`, `HostAddress` and `CEObject` expose no + conversion to each other or to a floating-point type (`AddressTypeSeparationTests`, Q21). ## Run the tests diff --git a/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeCapabilityProbesTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeCapabilityProbesTests.cs new file mode 100644 index 00000000..36c6567b --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeCapabilityProbesTests.cs @@ -0,0 +1,146 @@ +using System.Text; + +using CheatEngine.SDK.Engine.Generated; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Runtime; + +/// +/// End-to-end behaviour of the wired runtime-capabilities EngineApi spec: spec file, generator and the +/// compiled in CheatEngine.SDK.Engine.dll, called against the spike C3 +/// stand-ins on the bundled Lua 5.3 library (C2 fixture; not a host run). +/// +[Trait("Category", "NativeLua")] +public sealed class RuntimeCapabilityProbesTests +{ + [Fact] + public void generated_runtime_capability_probes_return_the_spike_x64_facts_and_restore_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + FakeHost.InstallCe77X64TargetFacts(scope.State, 45052); + EngineTest.Run(scope.State, "function getCEVersion() return 7.7 end"u8); + + Assert.Equal(7.7d, RuntimeCapabilityProbes.GetCheatEngineVersion()); + Assert.Equal(1, RuntimeCapabilityProbes.GetSystemArchitectureCode()); + Assert.Equal(0, RuntimeCapabilityProbes.GetTargetAbiCode()); + Assert.True(RuntimeCapabilityProbes.IsCheatEngine64Bit()); + Assert.True(RuntimeCapabilityProbes.IsTarget64Bit()); + Assert.True(RuntimeCapabilityProbes.IsTargetX86()); + Assert.False(RuntimeCapabilityProbes.IsTargetArm()); + Assert.False(RuntimeCapabilityProbes.IsTargetAndroid()); + Assert.Equal(8, RuntimeCapabilityProbes.GetConfiguredPointerSizeBytes()); + Assert.Equal(0, RuntimeCapabilityProbes.GetOperatingSystemCode()); + Assert.False(RuntimeCapabilityProbes.IsConnectedToCEServer()); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void generated_configured_pointer_size_probe_returns_the_raw_integer() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 45052); + // Spike C3 D3b: setPointerSize(2) was accepted and read back. + EngineTest.Run(scope.State, "rt_pointer_size = 2"u8); + + Assert.Equal(2, RuntimeCapabilityProbes.GetConfiguredPointerSizeBytes()); + Assert.True(RuntimeCapabilityProbes.IsTarget64Bit()); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("getPointerSize")] + [InlineData("isConnectedToCEServer")] + [InlineData("targetIsAndroid")] + [InlineData("getOperatingSystem")] + public void generated_probe_on_an_absent_global_fails_as_the_emitter_documents(string global) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + + LuaException failure = Assert.Throws(() => Call(global)); + + Assert.Contains(global, failure.Message, StringComparison.Ordinal); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("getPointerSize", "return 'eight'")] + [InlineData("getPointerSize", "return nil")] + [InlineData("isConnectedToCEServer", "return nil")] + [InlineData("targetIsAndroid", "return 0")] + [InlineData("getOperatingSystem", "error('fixture failure')")] + public void generated_probe_raises_a_lua_exception_for_a_wrong_type_or_a_raising_global_and_recovers(string global, + string body) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + FakeHost.InstallCe77X64TargetFacts(scope.State, 45052); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("function " + global + "() " + body + " end")); + + Assert.Throws(() => Call(global)); + Assert.Equal(0, scope.State.Top); + // The next generated call on the same state still works. + Assert.True(RuntimeCapabilityProbes.IsTarget64Bit()); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("4.0")] + [InlineData("'4'")] + public void generated_int32_probe_converts_an_integral_float_or_numeral_string_that_the_structured_api_refuses( + string luaValue) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77X64TargetFacts(scope.State, 45052); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("rt_pointer_size = " + luaValue)); + + // The generated int32 wrapper reads through Int32Marshaller (lua_tointegerx), which converts an integral float + // or an integer numeral string; the spec header documents that policy. + Assert.Equal(4, RuntimeCapabilityProbes.GetConfiguredPointerSizeBytes()); + Assert.Equal(0, scope.State.Top); + + // The structured API requires the Lua integer subtype that Cheat Engine pushes and refuses the same value. + ProcessOperationStatus status = + RuntimeProcessOperations.TryGetConfiguredPointerSize(out int rawBytes, out PointerSize pointerSize); + Assert.Equal(ProcessOperationStatusKind.InvalidResult, status.Kind); + Assert.Equal(0, rawBytes); + Assert.Equal(PointerSize.Unknown, pointerSize); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void generated_probes_throw_the_lifecycle_failure_while_no_runtime_is_attached() + { + LuaRuntime.Detach(); + + Assert.Throws(() => RuntimeCapabilityProbes.GetConfiguredPointerSizeBytes()); + Assert.Throws(() => RuntimeCapabilityProbes.IsConnectedToCEServer()); + } + + private static object Call(string global) + { + return global switch + { + "getPointerSize" => RuntimeCapabilityProbes.GetConfiguredPointerSizeBytes(), + "isConnectedToCEServer" => RuntimeCapabilityProbes.IsConnectedToCEServer(), + "targetIsAndroid" => RuntimeCapabilityProbes.IsTargetAndroid(), + "getOperatingSystem" => RuntimeCapabilityProbes.GetOperatingSystemCode(), + _ => throw new ArgumentOutOfRangeException(nameof(global), global, "No generated probe for this global.") + }; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeContractsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeContractsTests.cs index 9b46b994..9242b80c 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeContractsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeContractsTests.cs @@ -1,3 +1,6 @@ +using System.Reflection; + +using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Runtime; namespace CheatEngine.SDK.Engine.Tests.Runtime; @@ -75,6 +78,220 @@ public void TryDecodeTargetAbi_unknown_code_is_rejected(int code) Assert.Equal(TargetAbi.Unknown, abi); } + [Theory] + [Trait("Qualification", "Q32")] + [InlineData(true, false, true, true, CheatEngineArchitecture.X64)] + [InlineData(true, false, false, true, CheatEngineArchitecture.X86)] + [InlineData(false, true, true, true, CheatEngineArchitecture.Arm64)] + [InlineData(false, true, false, true, CheatEngineArchitecture.Arm32)] + [InlineData(true, true, true, false, CheatEngineArchitecture.Unknown)] + [InlineData(true, true, false, false, CheatEngineArchitecture.Unknown)] + [InlineData(false, false, true, false, CheatEngineArchitecture.Unknown)] + [InlineData(false, false, false, false, CheatEngineArchitecture.Unknown)] + public void try_derive_target_architecture_follows_the_ce_family_table(bool isX86Family, bool isArmFamily, + bool is64Bit, bool expectedResult, CheatEngineArchitecture expected) + { + bool derived = RuntimeInfo.TryDeriveTargetArchitecture(isX86Family, isArmFamily, is64Bit, + out CheatEngineArchitecture architecture); + + Assert.Equal(expectedResult, derived); + Assert.Equal(expected, architecture); + } + + [Fact] + [Trait("Qualification", "Q32.a")] + public void try_derive_target_architecture_maps_the_x86_family_with_64_bit_to_x64() + { + // Spike C3 D2 (CE 7.7.0.10621, x64 Tutorial target): targetIsX86 = true, targetIs64Bit = true, targetIsArm = false. + Assert.True(RuntimeInfo.TryDeriveTargetArchitecture(true, false, true, out CheatEngineArchitecture x64)); + Assert.Equal(CheatEngineArchitecture.X64, x64); + } + + [Fact] + [Trait("Qualification", "Q32.b")] + public void try_derive_target_architecture_maps_the_x86_family_without_64_bit_to_x86() + { + // Spike C3 D2 (CE 7.7.0.10621, i386 tutorial target): targetIsX86 = true, targetIs64Bit = false. + Assert.True(RuntimeInfo.TryDeriveTargetArchitecture(true, false, false, out CheatEngineArchitecture x86)); + Assert.Equal(CheatEngineArchitecture.X86, x86); + } + + [Fact] + [Trait("Qualification", "Q32.d")] + public void try_derive_target_architecture_keeps_arm_and_contradictory_families_apart_from_the_64_bit_flag() + { + Assert.True(RuntimeInfo.TryDeriveTargetArchitecture(false, true, false, out CheatEngineArchitecture arm32)); + Assert.True(RuntimeInfo.TryDeriveTargetArchitecture(false, true, true, out CheatEngineArchitecture arm64)); + Assert.False(RuntimeInfo.TryDeriveTargetArchitecture(true, true, true, out CheatEngineArchitecture both)); + Assert.False(RuntimeInfo.TryDeriveTargetArchitecture(false, false, true, out CheatEngineArchitecture neither)); + + Assert.Equal(CheatEngineArchitecture.Arm32, arm32); + Assert.Equal(CheatEngineArchitecture.Arm64, arm64); + Assert.Equal(CheatEngineArchitecture.Unknown, both); + Assert.Equal(CheatEngineArchitecture.Unknown, neither); + } + + [Theory] + [InlineData(0, CheatEngineOperatingSystem.Windows)] + [InlineData(1, CheatEngineOperatingSystem.MacOS)] + [InlineData(2, CheatEngineOperatingSystem.Linux)] + public void try_decode_operating_system_known_codes_decode(int code, CheatEngineOperatingSystem expected) + { + Assert.True(RuntimeInfo.TryDecodeOperatingSystem(code, out CheatEngineOperatingSystem operatingSystem)); + Assert.Equal(expected, operatingSystem); + } + + [Theory] + [InlineData(-1)] + [InlineData(3)] + [InlineData(int.MaxValue)] + public void try_decode_operating_system_unknown_code_is_rejected(int code) + { + Assert.False(RuntimeInfo.TryDecodeOperatingSystem(code, out CheatEngineOperatingSystem operatingSystem)); + Assert.Equal(CheatEngineOperatingSystem.Unknown, operatingSystem); + } + + [Fact] + public void try_decode_file_version_splits_the_packed_integer() + { + // Spike C3 D5: CE 7.7.0.10621 x64 returned 0x700070000297D (1970354901756285). + Assert.True(RuntimeInfo.TryDecodeFileVersion(0x7_0007_0000_297DL, out CheatEngineVersion ce77)); + Assert.Equal(CheatEngineVersion.Ce77010621, ce77); + + // The largest non-negative Lua integer: every 16-bit field at its maximum, the major field at 0x7FFF. + Assert.True(RuntimeInfo.TryDecodeFileVersion(long.MaxValue, out CheatEngineVersion largest)); + Assert.Equal(new CheatEngineVersion(32767, 65535, 65535, 65535), largest); + + Assert.True(RuntimeInfo.TryDecodeFileVersion(0x0001_0002_0003_0004L, out CheatEngineVersion ordered)); + Assert.Equal(new CheatEngineVersion(1, 2, 3, 4), ordered); + + // A negative packed value (all 64 bits set, or any sign bit) is not a version. + Assert.False(RuntimeInfo.TryDecodeFileVersion(-1L, out CheatEngineVersion negative)); + Assert.Equal(default, negative); + Assert.False(RuntimeInfo.TryDecodeFileVersion(long.MinValue, out _)); + } + + [Fact] + [Trait("Qualification", "Q32.d")] + public void target_architecture_observation_computed_members_never_infer_missing_facts() + { + TargetProcessId pid = new(4242); + TargetArchitectureObservation onlyBitness = new(pid, TargetBackend.Unknown, PointerSize.Bit64, null, null, + null, null, null); + TargetArchitectureObservation onlyX86 = new(pid, TargetBackend.LocalProcess, PointerSize.Bit64, true, null, + false, 0, 8); + TargetArchitectureObservation unknownBitness = new(pid, TargetBackend.LocalProcess, PointerSize.Unknown, true, + false, false, 0, 8); + TargetArchitectureObservation odd = new(pid, TargetBackend.LocalProcess, PointerSize.Bit64, true, false, false, + 9, 2); + + Assert.Equal(CheatEngineArchitecture.Unknown, onlyBitness.Architecture); + Assert.Equal(PointerSize.Unknown, onlyBitness.ConfiguredPointerSize); + Assert.Null(onlyBitness.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(TargetAbi.Unknown, onlyBitness.Abi); + Assert.Null(onlyBitness.IsAndroid); + + // The ARM fact is absent, so no architecture is derived even though the x86 family is reported. + Assert.Equal(CheatEngineArchitecture.Unknown, onlyX86.Architecture); + Assert.Equal(PointerSize.Bit64, onlyX86.ConfiguredPointerSize); + + Assert.Equal(CheatEngineArchitecture.Unknown, unknownBitness.Architecture); + Assert.Null(unknownBitness.ConfiguredPointerSizeDiffersFromBitness); + + // Any integer is kept raw; only 4 and 8 become a PointerSize; an undocumented ABI code stays raw. + Assert.Equal(2, odd.ConfiguredPointerSizeBytes); + Assert.Equal(PointerSize.Unknown, odd.ConfiguredPointerSize); + Assert.True(odd.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(9, odd.AbiCode); + Assert.Equal(TargetAbi.Unknown, odd.Abi); + Assert.Equal(CheatEngineArchitecture.X64, odd.Architecture); + } + + [Fact] + public void target_architecture_observation_is_a_value_that_compares_every_fact() + { + TargetProcessId pid = new(4242); + TargetArchitectureObservation x64 = new(pid, TargetBackend.LocalProcess, PointerSize.Bit64, true, false, false, + 0, 8); + TargetArchitectureObservation same = new(pid, TargetBackend.LocalProcess, PointerSize.Bit64, true, false, false, + 0, 8); + TargetArchitectureObservation remote = new(pid, TargetBackend.CEServer, PointerSize.Bit64, true, false, false, + 0, 8); + TargetArchitectureObservation narrowed = new(pid, TargetBackend.LocalProcess, PointerSize.Bit64, true, false, + false, 0, 4); + + Assert.Equal(x64, same); + Assert.NotEqual(x64, remote); + Assert.NotEqual(x64, narrowed); + Assert.Equal(TargetBackend.LocalProcess, x64.Backend); + Assert.Equal(pid, x64.ProcessId); + } + + [Fact] + public void runtime_capability_identifiers_are_stable_distinct_and_not_lua_global_names() + { + RuntimeCapabilityId[] identifiers = + [ + RuntimeCapabilityId.CheatEngineVersion, RuntimeCapabilityId.SystemArchitecture, + RuntimeCapabilityId.TargetArchitecture, RuntimeCapabilityId.CurrentProcess, + RuntimeCapabilityId.ProcessSelection, RuntimeCapabilityId.TargetAbi, + RuntimeCapabilityId.ConfiguredPointerSize, RuntimeCapabilityId.CheatEngineBitness, + RuntimeCapabilityId.OperatingSystem, RuntimeCapabilityId.TargetAndroid, RuntimeCapabilityId.TargetBackend + ]; + string[] luaGlobals = + [ + "getCheatEngineFileVersion", "getCEVersion", "getSystemArchitecture", "cheatEngineIs64Bit", + "getOperatingSystem", "getOpenedProcessID", "openProcess", "targetIs64Bit", "targetIsX86", "targetIsArm", + "targetIsAndroid", "getABI", "getPointerSize", "isConnectedToCEServer" + ]; + + Assert.Equal("Runtime.ConfiguredPointerSize", RuntimeCapabilityId.ConfiguredPointerSize.Value); + Assert.Equal("Runtime.CheatEngineBitness", RuntimeCapabilityId.CheatEngineBitness.Value); + Assert.Equal("Runtime.OperatingSystem", RuntimeCapabilityId.OperatingSystem.Value); + Assert.Equal("Runtime.TargetAndroid", RuntimeCapabilityId.TargetAndroid.Value); + Assert.Equal("Runtime.TargetBackend", RuntimeCapabilityId.TargetBackend.Value); + Assert.Equal(identifiers.Length, identifiers.Distinct().Count()); + foreach (RuntimeCapabilityId identifier in identifiers) + { + Assert.False(identifier.IsEmpty); + Assert.DoesNotContain(identifier.Value, luaGlobals, StringComparer.OrdinalIgnoreCase); + Assert.Contains(".", identifier.Value, StringComparison.Ordinal); + } + } + + [Fact] + public void target_backend_and_operating_system_values_are_pinned() + { + Assert.Equal(0, (byte) TargetBackend.Unknown); + Assert.Equal(1, (byte) TargetBackend.LocalProcess); + Assert.Equal(2, (byte) TargetBackend.FileAsProcess); + Assert.Equal(3, (byte) TargetBackend.CEServer); + Assert.Equal(4, Enum.GetValues().Length); + + Assert.Equal(0, (byte) CheatEngineOperatingSystem.Unknown); + Assert.Equal(1, (byte) CheatEngineOperatingSystem.Windows); + Assert.Equal(2, (byte) CheatEngineOperatingSystem.MacOS); + Assert.Equal(3, (byte) CheatEngineOperatingSystem.Linux); + Assert.Equal(4, Enum.GetValues().Length); + } + + [Fact] + public void cheat_engine_host_observation_keeps_each_fact_separate() + { + CheatEngineHostObservation host = new(null, CheatEngineArchitecture.X64, null, + CheatEngineOperatingSystem.Unknown); + CheatEngineHostObservation reported = host with + { + CheatEngineIs64Bit = false + }; + + Assert.Null(host.FileVersion); + Assert.Null(host.CheatEngineIs64Bit); + Assert.Equal(CheatEngineArchitecture.X64, host.SystemArchitecture); + Assert.False(reported.CheatEngineIs64Bit); + Assert.NotEqual(host, reported); + } + [Fact] public void PointerSize_known_widths_expose_bits_and_reject_other_widths() { @@ -85,16 +302,34 @@ public void PointerSize_known_widths_expose_bits_and_reject_other_widths() Assert.True(PointerSize.Bit64.IsKnown); Assert.False(PointerSize.Unknown.IsKnown); Assert.Equal(0, PointerSize.Unknown.Bits); +#pragma warning disable CESDK7001 // Pins the obsolete 1.0.0 behaviour, kept for binary compatibility. Assert.Equal(PointerSize.Bit32, PointerSize.FromArchitecture(CheatEngineArchitecture.X86)); Assert.Equal(PointerSize.Bit32, PointerSize.FromArchitecture(CheatEngineArchitecture.Arm32)); Assert.Equal(PointerSize.Bit64, PointerSize.FromArchitecture(CheatEngineArchitecture.X64)); Assert.Equal(PointerSize.Bit64, PointerSize.FromArchitecture(CheatEngineArchitecture.Arm64)); Assert.Equal(PointerSize.Unknown, PointerSize.FromArchitecture(CheatEngineArchitecture.Unknown)); +#pragma warning restore CESDK7001 Assert.Throws(() => new PointerSize(0)); Assert.Throws(() => new PointerSize(2)); Assert.Throws(() => new PointerSize(16)); } + [Fact] + public void pointer_size_from_architecture_is_obsolete_with_its_documented_diagnostic_id() + { + MethodInfo? method = typeof(PointerSize).GetMethod(nameof(PointerSize.FromArchitecture), + BindingFlags.Public | BindingFlags.Static, [typeof(CheatEngineArchitecture)]); + + Assert.NotNull(method); + ObsoleteAttribute? obsolete = method.GetCustomAttribute(); + Assert.NotNull(obsolete); + Assert.Equal("CESDK7001", obsolete.DiagnosticId); + Assert.Equal("https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md", + obsolete.UrlFormat); + Assert.False(obsolete.IsError); + Assert.Contains("ConfiguredPointerSize", obsolete.Message, StringComparison.Ordinal); + } + [Fact] public void PointerSize_little_endian_primitives_use_the_explicit_target_width_and_preserve_destinations_on_rejection() @@ -181,6 +416,87 @@ public void RuntimeCapabilities_empty_or_duplicate_identifiers_are_rejected() Assert.Throws(() => new RuntimeCapabilityId(" ")); } + [Fact] + [Trait("Qualification", "Q31.a")] + public void runtime_info_created_from_observations_uses_the_configured_pointer_size_and_keeps_unknowns() + { + CheatEngineHostObservation host = new(CheatEngineVersion.Ce77010621, CheatEngineArchitecture.X64, true, + CheatEngineOperatingSystem.Windows); + TargetArchitectureObservation narrowed = new(new TargetProcessId(4242), TargetBackend.LocalProcess, + PointerSize.Bit64, true, false, false, 0, 4); + TargetArchitectureObservation widened = new(new TargetProcessId(4242), TargetBackend.LocalProcess, + PointerSize.Bit32, true, false, false, 0, 8); + TargetArchitectureObservation unknownFamilies = new(new TargetProcessId(4242), TargetBackend.Unknown, + PointerSize.Bit64, null, null, null, 9, 2); + + RuntimeInfo info = new(host, narrowed, RuntimeCapabilities.Empty); + RuntimeInfo widenedInfo = new(host, widened, RuntimeCapabilities.Empty); + RuntimeInfo withoutTarget = new(host with + { + FileVersion = null + }, null, RuntimeCapabilities.Empty); + RuntimeInfo unknown = new(host, unknownFamilies, RuntimeCapabilities.Empty); + + Assert.Equal(host, info.Host); + Assert.Equal(narrowed, info.Target); + Assert.Equal(CheatEngineVersion.Ce77010621, info.Version); + Assert.Equal(CheatEngineArchitecture.X64, info.SystemArchitecture); + Assert.Equal(CheatEngineArchitecture.X64, info.TargetArchitecture); + Assert.Equal(PointerSize.Bit32, info.PointerSize); + Assert.Equal(PointerSize.Bit64, info.Target?.Bitness); + Assert.Equal(TargetAbi.Windows, info.TargetAbi); + + // The other direction: an x86 target whose configured size was raised to 8 reports 8, never its bitness. + Assert.Equal(CheatEngineArchitecture.X86, widenedInfo.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, widenedInfo.PointerSize); + Assert.Equal(PointerSize.Bit32, widenedInfo.Target?.Bitness); + + Assert.Null(withoutTarget.Target); + Assert.Equal(default, withoutTarget.Version); + Assert.Equal(CheatEngineArchitecture.Unknown, withoutTarget.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, withoutTarget.PointerSize); + Assert.Equal(TargetAbi.Unknown, withoutTarget.TargetAbi); + + Assert.Equal(CheatEngineArchitecture.Unknown, unknown.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, unknown.PointerSize); + Assert.Equal(TargetAbi.Unknown, unknown.TargetAbi); + Assert.Throws(() => new RuntimeInfo(host, null, null!)); + } + + [Fact] + public void legacy_runtime_info_constructor_keeps_caller_supplied_facts() + { + RuntimeInfo info = new(new CheatEngineVersion(7, 5, 0, 0), CheatEngineArchitecture.X64, + CheatEngineArchitecture.Arm64, PointerSize.Bit64, TargetAbi.Unix, RuntimeCapabilities.Empty); + + Assert.Null(info.Host); + Assert.Null(info.Target); + Assert.Equal(new CheatEngineVersion(7, 5, 0, 0), info.Version); + Assert.Equal(CheatEngineArchitecture.Arm64, info.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, info.PointerSize); + Assert.Equal(TargetAbi.Unix, info.TargetAbi); + } + + [Fact] + [Trait("Qualification", "Q32.c")] + public void system_architecture_i386_is_reported_as_a_host_fact_and_never_changes_target_facts() + { + // An i386 Cheat Engine host (getSystemArchitecture() == 0) is an unsupported route of the x64-only SDK; the fact + // is still reported as-is and never rewrites the target facts or the CE bitness fact. + Assert.True(RuntimeInfo.TryDecodeSystemArchitecture(0, out CheatEngineArchitecture i386)); + CheatEngineHostObservation host = new(null, i386, null, CheatEngineOperatingSystem.Windows); + TargetArchitectureObservation x64Target = new(new TargetProcessId(4242), TargetBackend.LocalProcess, + PointerSize.Bit64, true, false, false, 0, 8); + + RuntimeInfo info = new(host, x64Target, RuntimeCapabilities.Empty); + + Assert.Equal(CheatEngineArchitecture.X86, info.SystemArchitecture); + Assert.Null(info.Host?.CheatEngineIs64Bit); + Assert.Equal(CheatEngineArchitecture.X64, info.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, info.PointerSize); + Assert.Equal(PointerSize.Bit64, info.Target?.Bitness); + } + [Fact] public void RuntimeInfo_constructor_preserves_explicit_runtime_facts_without_normalization() { diff --git a/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeObservationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeObservationsTests.cs new file mode 100644 index 00000000..18bbc5ab --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Runtime/RuntimeObservationsTests.cs @@ -0,0 +1,307 @@ +using System.Text; + +using CheatEngine.SDK.Engine.Assembly; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Processes; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Runtime; + +/// +/// Native-Lua tests for the SDK-produced snapshot and for the read-only allowlist of every +/// runtime probe. The stand-ins return the spike C3 values; these are fixture contracts (C1/C2), not host +/// qualification. +/// +[Trait("Category", "NativeLua")] +public sealed class RuntimeObservationsTests +{ + [Fact] + [Trait("Qualification", "Q32.a")] + public void try_observe_runtime_info_with_the_spike_x64_facts_produces_a_complete_snapshot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + FakeHost.InstallCe77X64TargetFacts(scope.State, 45052); + + ProcessOperationStatus status = RuntimeObservations.TryObserveRuntimeInfo(out RuntimeInfo? info); + + Assert.True(status.IsSuccess); + Assert.NotNull(info); + Assert.Equal(CheatEngineVersion.Ce77010621, info.Version); + Assert.Equal(CheatEngineArchitecture.X64, info.SystemArchitecture); + Assert.Equal(CheatEngineArchitecture.X64, info.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, info.PointerSize); + Assert.Equal(TargetAbi.Windows, info.TargetAbi); + Assert.Equal(new CheatEngineHostObservation(CheatEngineVersion.Ce77010621, CheatEngineArchitecture.X64, true, + CheatEngineOperatingSystem.Windows), info.Host); + Assert.Equal(new TargetArchitectureObservation(new TargetProcessId(45052), TargetBackend.LocalProcess, + PointerSize.Bit64, true, false, false, 0, 8), info.Target); + + RuntimeCapabilityId[] expected = + [ + RuntimeCapabilityId.CheatEngineVersion, RuntimeCapabilityId.SystemArchitecture, + RuntimeCapabilityId.CheatEngineBitness, RuntimeCapabilityId.OperatingSystem, + RuntimeCapabilityId.CurrentProcess, RuntimeCapabilityId.TargetBackend, + RuntimeCapabilityId.TargetArchitecture, RuntimeCapabilityId.TargetAndroid, RuntimeCapabilityId.TargetAbi, + RuntimeCapabilityId.ConfiguredPointerSize + ]; + Assert.Equal(expected.Length, info.Capabilities.Count); + foreach (RuntimeCapabilityId capability in expected) + { + Assert.True(info.Capabilities.TryGet(capability, out RuntimeCapabilityAvailability availability)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, availability.State); + Assert.Equal(CheatEngineVersion.Ce77010621, availability.Contract.MinimumCheatEngineVersion); + Assert.Equal(RuntimeThreadRequirement.Unknown, availability.Contract.ThreadRequirement); + Assert.Equal(RuntimeOwnership.None, availability.Contract.Ownership); + } + + RuntimeCapabilityContract version = Contract(info, RuntimeCapabilityId.CheatEngineVersion); + RuntimeCapabilityContract bitness = Contract(info, RuntimeCapabilityId.CheatEngineBitness); + RuntimeCapabilityContract pointer = Contract(info, RuntimeCapabilityId.ConfiguredPointerSize); + Assert.Equal(RuntimeReturnSemantics.OptionalValue, version.ReturnSemantics); + Assert.Equal(RuntimeArchitectureScope.CheatEngine, bitness.ArchitectureScope); + Assert.Equal(RuntimeArchitectureRequirement.X64, bitness.ArchitectureRequirement); + Assert.Equal(RuntimeReturnSemantics.Value, bitness.ReturnSemantics); + Assert.Equal(RuntimeArchitectureScope.Target, pointer.ArchitectureScope); + Assert.Equal(RuntimeArchitectureRequirement.Unknown, pointer.ArchitectureRequirement); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unknown, + info.Capabilities.GetState(RuntimeCapabilityId.ProcessSelection)); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q31.a")] + public void try_observe_runtime_info_with_a_configured_pointer_size_of_four_on_x64_reports_it_as_the_pointer_size_and_keeps_bitness() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + FakeHost.InstallCe77X64TargetFacts(scope.State, 45052); + EngineTest.Run(scope.State, "rt_pointer_size = 4"u8); + + ProcessOperationStatus status = RuntimeObservations.TryObserveRuntimeInfo(out RuntimeInfo? info); + + Assert.True(status.IsSuccess); + Assert.NotNull(info); + Assert.Equal(PointerSize.Bit32, info.PointerSize); + Assert.Equal(CheatEngineArchitecture.X64, info.TargetArchitecture); + Assert.Equal(PointerSize.Bit64, info.Target?.Bitness); + Assert.True(info.Target?.ConfiguredPointerSizeDiffersFromBitness); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q32")] + public void try_observe_runtime_info_without_a_target_keeps_host_facts_and_no_target_facts() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + // With no target CE 7.7 still reports x86 family, 64-bit and pointer size 8 (spike C3 D2): none may be read. + FakeHost.InstallCe77X64TargetFacts(scope.State, 0); + EngineTest.Run(scope.State, """ + function targetIs64Bit() error('no target fact without a target') end + function getPointerSize() error('no target fact without a target') end + function isConnectedToCEServer() error('no target fact without a target') end + """u8); + + ProcessOperationStatus status = RuntimeObservations.TryObserveRuntimeInfo(out RuntimeInfo? info); + + Assert.True(status.IsSuccess); + Assert.NotNull(info); + Assert.NotNull(info.Host); + Assert.Null(info.Target); + Assert.Equal(CheatEngineVersion.Ce77010621, info.Version); + Assert.Equal(CheatEngineArchitecture.Unknown, info.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, info.PointerSize); + Assert.Equal(TargetAbi.Unknown, info.TargetAbi); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, + info.Capabilities.GetState(RuntimeCapabilityId.CurrentProcess)); + Assert.False(info.Capabilities.TryGet(RuntimeCapabilityId.TargetArchitecture, out _)); + Assert.False(info.Capabilities.TryGet(RuntimeCapabilityId.ConfiguredPointerSize, out _)); + Assert.False(info.Capabilities.TryGet(RuntimeCapabilityId.TargetBackend, out _)); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q30.c")] + public void try_observe_runtime_info_on_a_file_as_process_target_returns_no_snapshot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, "rt_process_id = 4294967295"u8); + + ProcessOperationStatus status = RuntimeObservations.TryObserveRuntimeInfo(out RuntimeInfo? info); + + Assert.Equal(ProcessOperationStatusKind.FileAsProcessTarget, status.Kind); + Assert.Null(info); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("function getPointerSize() rt_process_id = 77 return 8 end", ProcessOperationStatusKind.TargetChanged)] + [InlineData("function getABI() error('fixture failure') end", ProcessOperationStatusKind.ProtectedLuaFailure)] + [InlineData("function targetIsAndroid() return nil end", ProcessOperationStatusKind.InvalidResult)] + [InlineData("function getOperatingSystem() error('fixture failure') end", + ProcessOperationStatusKind.ProtectedLuaFailure)] + [InlineData("function cheatEngineIs64Bit() return nil end", ProcessOperationStatusKind.InvalidResult)] + [InlineData("rt_file_version_table.build = 1", ProcessOperationStatusKind.InvalidResult)] + public void try_observe_runtime_info_keeps_target_change_lua_failure_and_malformed_results(string fixture, + ProcessOperationStatusKind expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(fixture)); + + ProcessOperationStatus status = RuntimeObservations.TryObserveRuntimeInfo(out RuntimeInfo? info); + + Assert.Equal(expected, status.Kind); + Assert.Null(info); + if (expected == ProcessOperationStatusKind.ProtectedLuaFailure) + { + Assert.Equal(LuaStatus.RuntimeError, status.LuaStatus); + } + + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void absent_globals_are_unavailable_and_unprobed_capabilities_are_absent_from_the_set() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + // Host: only getSystemArchitecture. Target: PID, the 64-bit flag and the x86 probe; no ARM, Android, ABI, + // pointer-size or CEServer probe. + EngineTest.Run(scope.State, """ + function getSystemArchitecture() return 1 end + function getOpenedProcessID() return 4242 end + function targetIs64Bit() return true end + function targetIsX86() return true end + """u8); + + ProcessOperationStatus status = RuntimeObservations.TryObserveRuntimeInfo(out RuntimeInfo? info); + + Assert.True(status.IsSuccess); + Assert.NotNull(info); + RuntimeCapabilities capabilities = info.Capabilities; + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + capabilities.GetState(RuntimeCapabilityId.CheatEngineVersion)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, + capabilities.GetState(RuntimeCapabilityId.SystemArchitecture)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + capabilities.GetState(RuntimeCapabilityId.CheatEngineBitness)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + capabilities.GetState(RuntimeCapabilityId.OperatingSystem)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Available, + capabilities.GetState(RuntimeCapabilityId.CurrentProcess)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + capabilities.GetState(RuntimeCapabilityId.TargetBackend)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + capabilities.GetState(RuntimeCapabilityId.TargetArchitecture)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + capabilities.GetState(RuntimeCapabilityId.TargetAndroid)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + capabilities.GetState(RuntimeCapabilityId.TargetAbi)); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + capabilities.GetState(RuntimeCapabilityId.ConfiguredPointerSize)); + Assert.False(capabilities.TryGet(RuntimeCapabilityId.ProcessSelection, out _)); + Assert.Equal(10, capabilities.Count); + + Assert.Null(info.Host?.FileVersion); + Assert.Null(info.Host?.CheatEngineIs64Bit); + Assert.Equal(default, info.Version); + Assert.Equal(TargetBackend.Unknown, info.Target?.Backend); + Assert.Null(info.Target?.IsArmFamily); + Assert.Equal(CheatEngineArchitecture.Unknown, info.TargetArchitecture); + Assert.Equal(PointerSize.Unknown, info.PointerSize); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("getOpenedProcessID = nil", "Process.Current")] + [InlineData("targetIs64Bit = nil", "Runtime.TargetArchitecture")] + public void an_absent_required_target_global_leaves_the_target_unobserved_and_its_capability_unavailable( + string fixture, string capability) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallCe77HostFacts(scope.State); + FakeHost.InstallCe77X64TargetFacts(scope.State, 4242); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes(fixture)); + + ProcessOperationStatus status = RuntimeObservations.TryObserveRuntimeInfo(out RuntimeInfo? info); + + Assert.True(status.IsSuccess); + Assert.NotNull(info); + Assert.Null(info.Target); + Assert.Equal(RuntimeCapabilityAvailabilityState.Unavailable, + info.Capabilities.GetState(new RuntimeCapabilityId(capability))); + Assert.False(info.Capabilities.TryGet(RuntimeCapabilityId.ConfiguredPointerSize, out _)); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q45")] + public void runtime_probes_never_call_dbk_dbvm_open_process_or_setters() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + FakeHost.InstallRecordingRuntimeGlobals(scope.State, 45052); + + Assert.True(RuntimeObservations.TryObserveRuntimeInfo(out RuntimeInfo? info).IsSuccess); + Assert.True(RuntimeHostOperations.ObserveHost(out _).IsSuccess); + Assert.True(RuntimeHostOperations.TryGetCheatEngineFileVersion(out _).IsSuccess); + Assert.True(RuntimeHostOperations.TryGetSystemArchitecture(out _).IsSuccess); + Assert.True(RuntimeHostOperations.TryIsCheatEngine64Bit(out _).IsSuccess); + Assert.True(RuntimeHostOperations.TryGetOperatingSystem(out _).IsSuccess); + Assert.True(RuntimeHostOperations.TryGetTargetAbi(out _).IsSuccess); + Assert.True(RuntimeProcessOperations.ObserveTargetArchitecture(out _).IsSuccess); + Assert.True(RuntimeProcessOperations.TryGetConfiguredPointerSize(out _, out _).IsSuccess); + Assert.True(RuntimeProcessOperations.ObserveCurrent(out _).IsSuccess); + Assert.Equal(InstructionOperationStatus.Success, InstructionProfiles.TryObserveCurrent(out _)); + + IReadOnlyList calls = FakeHost.RecordedRuntimeGlobals(scope.State); + Assert.NotNull(info); + Assert.NotEmpty(calls); + Assert.All(calls, call => Assert.Contains(call, FakeHost.ReadOnlyRuntimeGlobals, StringComparer.Ordinal)); + Assert.DoesNotContain(calls, call => FakeHost.ForbiddenRuntimeGlobals.Contains(call, StringComparer.Ordinal)); + Assert.Empty(FakeHost.RecordedArityViolations(scope.State)); + Assert.Equal(FakeHost.ReadOnlyRuntimeGlobals.Order(StringComparer.Ordinal), + calls.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void try_observe_runtime_info_on_a_detached_runtime_retains_the_lifecycle_admission_failure() + { + LuaRuntime.Detach(); + + Assert.Throws(() => RuntimeObservations.TryObserveRuntimeInfo(out _)); + Assert.Throws(() => RuntimeHostOperations.ObserveHost(out _)); + Assert.Throws(() => RuntimeProcessOperations.ObserveTargetArchitecture(out _)); + Assert.Throws(() => RuntimeProcessOperations.TryGetConfiguredPointerSize(out _, out _)); + } + + private static RuntimeCapabilityContract Contract(RuntimeInfo info, RuntimeCapabilityId capability) + { + Assert.True(info.Capabilities.TryGet(capability, out RuntimeCapabilityAvailability availability)); + return availability.Contract; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobBoundedScanTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobBoundedScanTests.cs new file mode 100644 index 00000000..ee432f99 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobBoundedScanTests.cs @@ -0,0 +1,908 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +// The deadline overload is gated by CESDK5010 on purpose; its tests opt in for this file only. +#pragma warning disable CESDK5010 + +namespace CheatEngine.SDK.Engine.Tests.Scanning.Aob; + +/// +/// The bounded, exhaustive AOB route over a MemScan session (audit F07, A13-07, A13-09, Q28, Q29; spike D4): CE's work +/// is limited to [Start, Stop), OnlyOneResult is always off, the start is post-filtered, only addresses +/// are read, and the session is released once on every path. The CE call accounting in these tests is the C1 cost +/// proxy of F07; host timings are a C3 matter. +/// +[Trait("Category", "NativeLua")] +public sealed class AobBoundedScanTests +{ + private const string Pattern = "55 48 89 E5"; + + private static readonly AobScanBounds ModuleBounds = Bounds(0x1_0000_0000, 0x1_0036_7000); + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_three_host_rows_two_in_bounds_follow_the_exact_CE_call_sequence() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000", "100010BC0", "7FFC7A0A0000"); + Address[] destination = new Address[4]; + + AobBoundedScanResult result = Scan(ModuleBounds, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.Matches, result.Kind); + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Written); + Assert.Equal([new Address(0x1_0000_0000), new Address(0x1_0001_0BC0)], destination[..2]); + Assert.Equal(3UL, result.HostResultCount); + Assert.Equal(3UL, result.RowsRead); + Assert.Equal(0UL, result.UnreadHostRows); + Assert.Equal(1UL, result.AtOrAfterStopSkipped); + Assert.True(result.InBoundsCountIsExact); + Assert.Equal( + "factory.scan,factory.list,scan.setOnlyOneResult:false,scan.first:14,scan.wait,list.initialize," + + "results.getCount,scan.ErrorString,results.getAddress:0,results.getAddress:1,results.getAddress:2," + + "list.deinitialize,list.destroy,scan.destroy", + MemScanTestHost.ReadTrace(L)); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, result.Termination); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, result.Release.Termination); + Assert.Equal(TargetReleaseStatus.Released, result.Release.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, result.Release.MemScan.Status); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_refuses_invalid_bounds_before_any_CE_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.ClearTrace(L); + Address[] destination = [new Address(0xA11CE)]; + + AobBoundedScanResult result = Scan(default, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.InvalidBounds, result.Kind); + Assert.False(result.IsSuccess); + Assert.Equal(MemoryScanCreationStatus.Unknown, result.Creation.Status); + Assert.Equal(default, result.Release); + Assert.Equal(new Address(0xA11CE), destination[0]); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_rejects_a_null_pattern_and_an_empty_destination_before_any_CE_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.ClearTrace(L); + + Assert.Throws(() => AobScanner.TryScanWithinBounds(null!, ModuleBounds, + AobScanOptions.Default, new Address[1], TestContext.Current.CancellationToken)); + Assert.Throws(() => AobScanner.TryScanWithinBounds(Pattern, ModuleBounds, + AobScanOptions.Default, Span
.Empty, TestContext.Current.CancellationToken)); + Assert.Throws(() => AobScanner.TryScanWithinBounds(Pattern, ModuleBounds, + AobScanOptions.Default, TimeSpan.Zero, new Address[1], TestContext.Current.CancellationToken)); + + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_passes_the_bounds_verbatim_as_integers_and_disables_only_one_result_before_the_first_scan() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + + AobBoundedScanResult result = Scan(Bounds(0x1_0000_0000, 0x8000_0000_0000_0000), new Address[1]); + + Assert.Equal(AobBoundedScanOutcomeKind.NoMatches, result.Kind); + Assert.StartsWith("factory.scan,factory.list,scan.setOnlyOneResult:false,scan.first:14,", + MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + MemScanTestHost.AssertLua(L, "set_only_one_argument_count == 1"); + MemScanTestHost.AssertLua(L, "first_scan_args[1] == 1 and first_scan_args[2] == 8 and first_scan_args[3] == 0"); + MemScanTestHost.AssertLua(L, "first_scan_args[4] == '55 48 89 E5' and first_scan_args[5] == ''"); + MemScanTestHost.AssertLua(L, "first_scan_start_type == 'integer' and first_scan_args[6] == 0x100000000"); + MemScanTestHost.AssertLua(L, "first_scan_stop_type == 'integer' and first_scan_args[7] == math.mininteger"); + MemScanTestHost.AssertLua(L, "first_scan_args[8] == '' and first_scan_args[9] == 0 and first_scan_args[10] == ''"); + MemScanTestHost.AssertLua(L, "first_scan_args[11] == true and first_scan_args[12] == false"); + MemScanTestHost.AssertLua(L, "first_scan_args[13] == false and first_scan_args[14] == false"); + Assert.Equal(0L, MemScanTestHost.ReadInteger(L, "create_mem_scan_argument_count")); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_passes_explicit_protection_and_alignment_options_verbatim() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + + _ = AobScanner.TryScanWithinBounds(Pattern, ModuleBounds, new AobScanOptions("+X-C-W", FastScanMethod.Aligned, "4"), + new Address[1], TestContext.Current.CancellationToken); + + MemScanTestHost.AssertLua(L, "first_scan_args[8] == '+X-C-W' and first_scan_args[9] == 1"); + MemScanTestHost.AssertLua(L, "first_scan_args[10] == '4'"); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + [Trait("Qualification", "Q29")] + public void TryScanWithinBounds_never_writes_is_unique_or_scan_callbacks() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000", "100000010"); + + AobBoundedScanResult result = Scan(ModuleBounds, new Address[4]); + + string trace = MemScanTestHost.ReadTrace(L); + Assert.Equal(AobBoundedScanOutcomeKind.Matches, result.Kind); + Assert.DoesNotContain("scan.set.", trace, StringComparison.Ordinal); + Assert.DoesNotContain("setOnlyOneResult:true", trace, StringComparison.Ordinal); + Assert.DoesNotContain("scan.getOnlyResult", trace, StringComparison.Ordinal); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_zero_found_rows_with_an_empty_error_string_is_no_matches() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + Address[] destination = [new Address(0xA11CE)]; + + AobBoundedScanResult result = Scan(ModuleBounds, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.NoMatches, result.Kind); + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Written); + Assert.Equal(0UL, result.HostResultCount); + Assert.True(result.InBoundsCountIsExact); + Assert.Null(result.HostErrorText); + Assert.False(result.IsHostErrorTextUnreadable); + Assert.Equal(new Address(0xA11CE), destination[0]); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_zero_found_rows_with_a_host_error_string_is_host_reported_error_without_parsing_it() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "scan_error_string = 'No readable memory found'"); + Address[] destination = [new Address(0xA11CE)]; + + AobBoundedScanResult result = Scan(ModuleBounds, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.HostReportedError, result.Kind); + Assert.False(result.IsSuccess); + Assert.Equal("No readable memory found", result.HostErrorText); + Assert.False(result.IsHostErrorTextTruncated); + Assert.Equal(new Address(0xA11CE), destination[0]); + Assert.Equal(TargetReleaseStatus.Released, result.Release.MemScan.Status); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData("No readable memory found. Please make sure you are attached to a live process")] + [InlineData("Aucune mémoire lisible trouvée — vérifiez la cible")] + [InlineData("text\\0with an embedded NUL")] + public void TryScanWithinBounds_host_error_text_is_classified_by_presence_not_content(string text) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "scan_error_string = '" + text + "'"); + + AobBoundedScanResult result = Scan(ModuleBounds, new Address[1]); + + Assert.Equal(AobBoundedScanOutcomeKind.HostReportedError, result.Kind); + Assert.Equal(text.Replace("\\0", "\0", StringComparison.Ordinal), result.HostErrorText); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_unreadable_host_error_text_never_changes_the_primary_outcome() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "scan_error_mode = 'raise'"); + SetAddresses(L, "100000000"); + + AobBoundedScanResult matches = Scan(ModuleBounds, new Address[1]); + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "scan_error_mode = 'raise'"); + AobBoundedScanResult noMatches = Scan(ModuleBounds, new Address[1]); + + Assert.Equal(AobBoundedScanOutcomeKind.Matches, matches.Kind); + Assert.True(matches.IsHostErrorTextUnreadable); + Assert.Equal(AobBoundedScanOutcomeKind.NoMatches, noMatches.Kind); + Assert.True(noMatches.IsHostErrorTextUnreadable); + Assert.Null(noMatches.HostErrorText); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_skips_and_counts_matches_the_host_returns_below_start() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + // The start bound falls one byte inside the match at 0x100010BC0; CE still returns it (spike D4.2). + SetAddresses(L, "100010BC0", "100010BF0"); + + AobBoundedScanResult result = Scan(Bounds(0x1_0001_0BC1, 0x1_0036_7000), new Address[4]); + + Assert.Equal(AobBoundedScanOutcomeKind.Matches, result.Kind); + Assert.Equal(1UL, result.BelowStartSkipped); + Assert.Equal(0UL, result.AtOrAfterStopSkipped); + Assert.Equal(1, result.Written); + Assert.Equal(2UL, result.RowsRead); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_skips_and_counts_matches_at_or_after_stop() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100366FFF", "100367000", "FFFFFFFFFFFFFFFF"); + Address[] destination = new Address[4]; + + AobBoundedScanResult result = Scan(ModuleBounds, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.Matches, result.Kind); + Assert.Equal(1, result.Written); + Assert.Equal(new Address(0x1_0036_6FFF), destination[0]); + Assert.Equal(2UL, result.AtOrAfterStopSkipped); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryScanWithinBounds_fills_only_the_destination_and_reports_the_materialization_limit() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000", "100000010", "100000020", "100000030", "100000040"); + Address[] destination = new Address[2]; + + AobBoundedScanResult result = Scan(ModuleBounds, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.Matches, result.Kind); + Assert.Equal(2, result.Written); + Assert.Equal([new Address(0x1_0000_0000), new Address(0x1_0000_0010)], destination); + Assert.True(result.IsMaterializationLimitReached); + Assert.Equal(5UL, result.HostResultCount); + Assert.Equal(2UL, result.RowsRead); + Assert.Equal(3UL, result.UnreadHostRows); + Assert.False(result.InBoundsCountIsExact); + Assert.DoesNotContain("results.getAddress:2", MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryScanWithinBounds_publishes_nothing_when_cancelled_during_the_copy_and_still_releases_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemScanTestHost.HostObjects objects = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "found_count = 3"); + using CancellationTokenSource cancellation = new(); + using FakeHost.PCallProbe probe = + FakeHost.ReplaceFoundListGetAddressWithPCallProbe(L, objects.FoundList, cancellation.Cancel); + Address[] destination = [new Address(0xA11CE), new Address(0xA11CE), new Address(0xA11CE)]; + + AobBoundedScanResult result = ScanWithToken(Bounds(0, 0x10000), destination, cancellation.Token); + + Assert.Equal(AobBoundedScanOutcomeKind.Cancelled, result.Kind); + Assert.Equal(0, result.Written); + Assert.Equal(1, probe.GetAddressCallCount); + Assert.Equal(1UL, result.RowsRead); + Assert.All(destination, static address => Assert.Equal(new Address(0xA11CE), address)); + Assert.EndsWith("list.deinitialize,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L), + StringComparison.Ordinal); + Assert.Equal(TargetReleaseStatus.Released, result.Release.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, result.Release.MemScan.Status); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + [SuppressMessage("xUnit.Analyzers", "xUnit1051", + Justification = "The token is cancelled on purpose to prove that no session is created.")] + public void TryScanWithinBounds_cancellation_before_the_scan_creates_no_session() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.ClearTrace(L); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + AobBoundedScanResult result = ScanWithToken(ModuleBounds, new Address[1], cancellation.Token); + + Assert.Equal(AobBoundedScanOutcomeKind.Cancelled, result.Kind); + Assert.Equal(MemoryScanCreationStatus.Unknown, result.Creation.Status); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData("100000000", 0x1_0000_0000UL)] + [InlineData("7FFC7A0A0000", 0x7FFC_7A0A_0000UL)] + [InlineData("00400000", 0x40_0000UL)] + public void TryScanWithinBounds_accepts_unpadded_x64_and_zero_padded_x86_host_address_text(string text, + ulong expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, text); + Address[] destination = new Address[1]; + + AobBoundedScanResult result = Scan(Bounds(0, 0x8000_0000_0000), destination); + + Assert.Equal(AobBoundedScanOutcomeKind.Matches, result.Kind); + Assert.Equal(new Address(expected), destination[0]); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_passes_the_pattern_bytes_verbatim_including_nul_and_non_ascii() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + + _ = AobScanner.TryScanWithinBounds("48\0é ??", ModuleBounds, AobScanOptions.Default, new Address[1], + TestContext.Current.CancellationToken); + + MemScanTestHost.AssertLua(L, "#first_scan_args[4] == 8 and first_scan_args[4] == '48\\0\\195\\169 ??'"); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_without_a_qualified_target_reports_session_creation_failed_and_starts_no_scan() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "opened_process_id = 0"); + MemScanTestHost.ClearTrace(L); + + AobBoundedScanResult result = Scan(ModuleBounds, new Address[1]); + + Assert.Equal(AobBoundedScanOutcomeKind.SessionCreationFailed, result.Kind); + Assert.Equal(MemoryScanCreationStatus.TargetIdentityUnavailable, result.Creation.Status); + Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, result.Creation.TargetObservation.Status); + Assert.Equal(default, result.Release); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_scan_failure_is_scan_failed_and_still_releases_child_before_parent() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "scan_first_raises = true; scan_first_error_payload = 'first scan rejected'"); + + AobBoundedScanResult result = Scan(ModuleBounds, new Address[1]); + + Assert.Equal(AobBoundedScanOutcomeKind.ScanFailed, result.Kind); + Assert.Equal(LuaStatus.RuntimeError, result.LuaStatus); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, result.Termination); + Assert.Equal(MemoryScanTerminationStatus.Confirmed, result.Release.Termination); + Assert.EndsWith("scan.first:14,scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy", + MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + Assert.Equal(0, L.Top); + } + + // Audit ch.08 and F13: a managed failure after the session exists, here the staging buffer's allocation, must not + // leak the CE objects. The found list and then its scanner are destroyed once, and the original failure propagates. + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_staging_allocation_failure_releases_the_created_session_once_and_rethrows() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000"); + InsufficientMemoryException injected = new("injected staging allocation failure"); + StagingPool pool = new(injected); + Address[] destination = [new Address(0xA11CE)]; + + InsufficientMemoryException thrown = Assert.Throws(() => + AobBoundedScan.Run(Pattern, ModuleBounds, AobScanOptions.Default, null, destination, pool, + TestContext.Current.CancellationToken)); + + Assert.Same(injected, thrown); + Assert.Equal(1, pool.RentCount); + Assert.Equal(0, pool.ReturnCount); + Assert.Equal(new Address(0xA11CE), destination[0]); + Assert.Equal("factory.scan,factory.list,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData("found_addresses = { '100000000' }", AobBoundedScanOutcomeKind.Matches)] + [InlineData("scan_first_raises = true", AobBoundedScanOutcomeKind.ScanFailed)] + public void TryScanWithinBounds_returns_its_staging_buffer_once_to_the_pool_it_came_from(string mode, + AobBoundedScanOutcomeKind expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, mode); + StagingPool pool = new(null); + + AobBoundedScanResult result = AobBoundedScan.Run(Pattern, ModuleBounds, AobScanOptions.Default, null, + new Address[2], pool, TestContext.Current.CancellationToken); + + Assert.Equal(expected, result.Kind); + Assert.Equal(1, pool.RentCount); + Assert.Equal(1, pool.ReturnCount); + Assert.Same(pool.LastRented, pool.LastReturned); + Assert.EndsWith("list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData("found_count = 'many'")] + [InlineData("found_count = -1")] + [InlineData("found_count = 1.5")] + [InlineData("found_addresses = { 'zz' }")] + [InlineData("found_addresses = { 42 }")] + [InlineData("found_addresses = { '100000000', '0x' }")] + public void TryScanWithinBounds_malformed_count_or_address_is_invalid_result_and_releases_once(string mode) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, mode); + Address[] destination = [new Address(0xA11CE), new Address(0xA11CE)]; + + AobBoundedScanResult result = Scan(ModuleBounds, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.InvalidResult, result.Kind); + Assert.Equal(0, result.Written); + Assert.All(destination, static address => Assert.Equal(new Address(0xA11CE), address)); + Assert.EndsWith("list.deinitialize,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L), + StringComparison.Ordinal); + Assert.Equal(TargetReleaseStatus.Released, result.Release.FoundList.Status); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void TryScanWithinBounds_target_change_during_the_scan_is_target_changed_and_publishes_nothing() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemScanTestHost.HostObjects objects = MemScanTestHost.Install(L); + SetAddresses(L, "100000000"); + MemScanTestHost.Run(L, "scan_first_hook = function() opened_process_id = " + + MemScanTestHost.FindOtherQualifiedProcessId().ToString(CultureInfo.InvariantCulture) + + " end"); + Address[] destination = [new Address(0xA11CE)]; + + AobBoundedScanResult result = Scan(ModuleBounds, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.TargetChanged, result.Kind); + Assert.Equal(new Address(0xA11CE), destination[0]); + Assert.Equal(MemoryScanTerminationStatus.NotInvoked, result.Release.Termination); + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, result.Release.FoundList.Status); + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, result.Release.MemScan.Status); + Assert.Equal("factory.scan,factory.list,scan.setOnlyOneResult:false,scan.first:14", + MemScanTestHost.ReadTrace(L)); + Assert.False(FakeHost.IsDestroyed(L, objects.Scanner)); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData("matches", AobBoundedScanOutcomeKind.Matches)] + [InlineData("no-rows", AobBoundedScanOutcomeKind.NoMatches)] + [InlineData("host-error", AobBoundedScanOutcomeKind.HostReportedError)] + [InlineData("set-only-one-raises", AobBoundedScanOutcomeKind.ScanFailed)] + [InlineData("first-raises", AobBoundedScanOutcomeKind.ScanFailed)] + [InlineData("wait-raises", AobBoundedScanOutcomeKind.ScanFailed)] + [InlineData("initialize-raises", AobBoundedScanOutcomeKind.ScanFailed)] + [InlineData("count-raises", AobBoundedScanOutcomeKind.ScanFailed)] + [InlineData("address-raises", AobBoundedScanOutcomeKind.ScanFailed)] + [InlineData("address-malformed", AobBoundedScanOutcomeKind.InvalidResult)] + [InlineData("no-target", AobBoundedScanOutcomeKind.SessionCreationFailed)] + [InlineData("no-factory", AobBoundedScanOutcomeKind.SessionCreationFailed)] + [InlineData("destroy-raises", AobBoundedScanOutcomeKind.Matches)] + public void TryScanWithinBounds_leaves_the_stack_balanced_on_every_exit_path(string mode, + AobBoundedScanOutcomeKind expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000"); + MemScanTestHost.Run(L, mode switch + { + "no-rows" => "found_addresses = {}", + "host-error" => "found_addresses = {}; scan_error_string = 'error'", + "set-only-one-raises" => "scan_set_only_one_raises = true", + "first-raises" => "scan_first_raises = true", + "wait-raises" => "scan_wait_modes = { 'raise', 'true' }", + "initialize-raises" => "list_initialize_raises = true", + "count-raises" => "found_count_raises = true", + "address-raises" => "found_address_raises = true", + "address-malformed" => "found_addresses = { 'not-an-address' }", + "no-target" => "opened_process_id = 0", + "no-factory" => "createMemScan = nil", + "destroy-raises" => "scan_destroy_raises = true; list_destroy_raises = true", + _ => "local _ = 0" + }); + + AobBoundedScanResult result = Scan(ModuleBounds, new Address[2]); + + Assert.Equal(expected, result.Kind); + Assert.Equal(0, L.Top); + if (expected == AobBoundedScanOutcomeKind.SessionCreationFailed) + { + Assert.Equal(default, result.Release); + } + else + { + Assert.True(result.Release.OwnershipConsumed); + } + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_next_call_on_the_same_state_succeeds_after_a_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "scan_first_raises = true"); + AobBoundedScanResult failed = Scan(ModuleBounds, new Address[1]); + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "scan_first_raises = false"); + SetAddresses(L, "100000000"); + Address[] destination = new Address[1]; + + AobBoundedScanResult next = Scan(ModuleBounds, destination); + + Assert.Equal(AobBoundedScanOutcomeKind.ScanFailed, failed.Kind); + Assert.Equal(AobBoundedScanOutcomeKind.Matches, next.Kind); + Assert.Equal(new Address(0x1_0000_0000), destination[0]); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_with_a_met_deadline_matches_the_unbounded_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000", "7FFC7A0A0000", "100000040"); + Address[] plainDestination = new Address[4]; + AobBoundedScanResult plain = Scan(ModuleBounds, plainDestination); + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000", "7FFC7A0A0000", "100000040"); + Address[] deadlineDestination = new Address[4]; + + AobBoundedScanResult deadline = AobScanner.TryScanWithinBounds(Pattern, ModuleBounds, AobScanOptions.Default, + TimeSpan.FromSeconds(30), deadlineDestination, TestContext.Current.CancellationToken); + + Assert.Equal(plain.Kind, deadline.Kind); + Assert.Equal(plain.Written, deadline.Written); + Assert.Equal(plain.HostResultCount, deadline.HostResultCount); + Assert.Equal(plain.RowsRead, deadline.RowsRead); + Assert.Equal(plain.AtOrAfterStopSkipped, deadline.AtOrAfterStopSkipped); + Assert.Equal(plainDestination, deadlineDestination); + Assert.Contains("scan.wait:30000,list.initialize", MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, deadline.Termination); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryScanWithinBounds_with_an_expired_deadline_terminates_cooperatively_waits_and_releases_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000"); + MemScanTestHost.Run(L, "scan_wait_modes = { 'false', 'true' }"); + Address[] destination = [new Address(0xA11CE)]; + + AobBoundedScanResult result = AobScanner.TryScanWithinBounds(Pattern, ModuleBounds, AobScanOptions.Default, + TimeSpan.FromMilliseconds(100), destination, TestContext.Current.CancellationToken); + + Assert.Equal(AobBoundedScanOutcomeKind.WaitTimedOut, result.Kind); + Assert.Equal(MemoryScanTerminationStatus.Confirmed, result.Termination); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, result.Release.Termination); + Assert.Equal(new Address(0xA11CE), destination[0]); + Assert.Equal( + "factory.scan,factory.list,scan.setOnlyOneResult:false,scan.first:14,scan.wait:100," + + "scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy", + MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryScanWithinBounds_with_an_expired_deadline_and_an_unconfirmed_stop_never_repeats_the_stop() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "scan_wait_mode = 'false'"); + + AobBoundedScanResult result = AobScanner.TryScanWithinBounds(Pattern, ModuleBounds, AobScanOptions.Default, + TimeSpan.FromMilliseconds(100), new Address[1], TestContext.Current.CancellationToken); + + Assert.Equal(AobBoundedScanOutcomeKind.WaitTimedOut, result.Kind); + Assert.Equal(MemoryScanTerminationStatus.WaitTimedOut, result.Termination); + Assert.Equal(MemoryScanTerminationStatus.WaitTimedOut, result.Release.Termination); + Assert.Equal( + "factory.scan,factory.list,scan.setOnlyOneResult:false,scan.first:14,scan.wait:100," + + "scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy", + MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_issues_one_scan_wait_initialize_and_count_and_at_most_one_row_read_per_needed_row() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "FFFF0000", "100000000", "7FFC7A0A0000", "100000010", "100000020", "100000030", "100000040"); + Address[] destination = new Address[3]; + + AobBoundedScanResult result = Scan(ModuleBounds, destination); + + string trace = MemScanTestHost.ReadTrace(L); + Assert.Equal(1, Occurrences(trace, "scan.first:")); + Assert.Equal(1, Occurrences(trace, "scan.wait")); + Assert.Equal(1, Occurrences(trace, "list.initialize")); + Assert.Equal(1, Occurrences(trace, "results.getCount")); + Assert.Equal((int) result.RowsRead, Occurrences(trace, "results.getAddress:")); + Assert.Equal(result.RowsRead, + (ulong) result.Written + result.BelowStartSkipped + result.AtOrAfterStopSkipped); + Assert.Equal(5UL, result.RowsRead); + Assert.Equal(1UL, result.BelowStartSkipped); + Assert.Equal(1UL, result.AtOrAfterStopSkipped); + Assert.Equal(3, result.Written); + Assert.Equal(2UL, result.UnreadHostRows); + Assert.True(result.IsMaterializationLimitReached); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_reads_only_addresses_and_never_values() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000", "100000010", "100000020"); + + AobBoundedScanResult result = Scan(ModuleBounds, new Address[8]); + + Assert.Equal(3, result.Written); + Assert.DoesNotContain("results.getValue", MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryScanWithinBounds_reports_non_negative_host_scan_copy_and_total_durations() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + SetAddresses(L, "100000000", "100000010"); + MemScanTestHost.Run(L, + "scan_wait_hook = function() local t = os.clock(); while os.clock() - t < 0.02 do end end"); + + AobBoundedScanResult result = Scan(ModuleBounds, new Address[4]); + + Assert.Equal(AobBoundedScanOutcomeKind.Matches, result.Kind); + Assert.True(result.HostScanElapsed >= TimeSpan.FromMilliseconds(15), + "The host scan duration must include the wait: " + result.HostScanElapsed); + Assert.True(result.CopyElapsed >= TimeSpan.Zero); + Assert.True(result.TotalElapsed >= result.HostScanElapsed + result.CopyElapsed, + "The total must contain the disjoint scan and copy intervals."); + Assert.Equal(0, L.Top); + } + + private static AobBoundedScanResult Scan(AobScanBounds bounds, Span
destination) + { + return AobScanner.TryScanWithinBounds(Pattern, bounds, AobScanOptions.Default, destination, + TestContext.Current.CancellationToken); + } + + private static AobBoundedScanResult ScanWithToken(AobScanBounds bounds, Span
destination, + CancellationToken cancellationToken) + { + return AobScanner.TryScanWithinBounds(Pattern, bounds, AobScanOptions.Default, destination, cancellationToken); + } + + private static AobScanBounds Bounds(ulong start, ulong stop) + { + Assert.True(AobScanBounds.TryCreate(new Address(start), new Address(stop), out AobScanBounds bounds)); + return bounds; + } + + private static void SetAddresses(LuaState state, params string[] addresses) + { + MemScanTestHost.Run(state, "found_addresses = { '" + string.Join("', '", addresses) + "' }"); + } + + private static int Occurrences(string text, string value) + { + int count = 0; + for (int index = text.IndexOf(value, StringComparison.Ordinal); + index >= 0; + index = text.IndexOf(value, index + value.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } + + // The staging-buffer seam of AobBoundedScan.Run: hands out plain arrays, or fails every Rent with the injected + // exception, and records both calls. + private sealed class StagingPool : ArrayPool
+ { + private readonly Exception? _rentFailure; + + public StagingPool(Exception? rentFailure) + { + _rentFailure = rentFailure; + } + + public int RentCount + { + get; private set; + } + + public int ReturnCount + { + get; private set; + } + + public Address[]? LastRented + { + get; private set; + } + + public Address[]? LastReturned + { + get; private set; + } + + public override Address[] Rent(int minimumLength) + { + RentCount++; + if (_rentFailure is not null) + { + throw _rentFailure; + } + + LastRented = new Address[minimumLength]; + return LastRented; + } + + public override void Return(Address[] array, bool clearArray = false) + { + ReturnCount++; + LastReturned = array; + } + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobFirstFoundScanTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobFirstFoundScanTests.cs new file mode 100644 index 00000000..b6558f0b --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobFirstFoundScanTests.cs @@ -0,0 +1,287 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +// The first-found route is gated by CESDK5011 on purpose; these tests opt in for this file only. +#pragma warning disable CESDK5011 + +namespace CheatEngine.SDK.Engine.Tests.Scanning.Aob; + +/// +/// The separately named first-found opt-in (audit A13-07, F07; spike D4.6): OnlyOneResult on, the found list +/// never initialized, getOnlyResult read with no argument and one result, only a Lua integer accepted as an +/// address, a match below the start reported as indeterminate, and the session released once on every path. +/// +[Trait("Category", "NativeLua")] +public sealed class AobFirstFoundScanTests +{ + private const string Pattern = "48 83 EC ??"; + + private static readonly AobScanBounds ModuleBounds = Bounds(0x1_0000_0000, 0x1_0036_7000); + + [Fact] + [Trait("Qualification", "Q28")] + public void TryFindFirstFoundWithinBounds_enables_only_one_result_and_never_initializes_the_found_list() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "only_result_mode = 'value'; only_result_value = 0x100003590"); + + AobFirstFoundResult result = Find(ModuleBounds); + + Assert.Equal(AobFirstFoundOutcomeKind.Found, result.Kind); + Assert.Equal( + "factory.scan,factory.list,scan.setOnlyOneResult:true,scan.first:14,scan.wait,scan.getOnlyResult," + + "list.destroy,scan.destroy", + MemScanTestHost.ReadTrace(L)); + MemScanTestHost.AssertLua(L, "set_only_one_argument_count == 1 and only_result_argument_count == 0"); + MemScanTestHost.AssertLua(L, "first_scan_args[2] == 8 and first_scan_args[6] == 0x100000000"); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, result.Release.Termination); + Assert.Equal(TargetReleaseStatus.Released, result.Release.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, result.Release.MemScan.Status); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData("none")] + [InlineData("nil")] + public void TryFindFirstFoundWithinBounds_zero_values_from_get_only_result_is_not_found(string mode) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "only_result_mode = '" + mode + "'"); + + AobFirstFoundResult result = Find(ModuleBounds); + + Assert.Equal(AobFirstFoundOutcomeKind.NotFound, result.Kind); + Assert.False(result.HasAddress); + Assert.Equal(Address.Zero, result.Address); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryFindFirstFoundWithinBounds_integer_inside_bounds_is_found() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "only_result_mode = 'value'; only_result_value = 0x100366FFF"); + + AobFirstFoundResult result = Find(ModuleBounds); + + Assert.Equal(AobFirstFoundOutcomeKind.Found, result.Kind); + Assert.True(result.HasAddress); + Assert.Equal(new Address(0x1_0036_6FFF), result.Address); + Assert.Equal(LuaStatus.Ok, result.LuaStatus); + Assert.True(result.HostScanElapsed >= TimeSpan.Zero); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData(0x1_0000_0FFFUL)] + [InlineData(0x1_0000_0000UL)] + public void TryFindFirstFoundWithinBounds_address_below_start_is_indeterminate_found_outside_bounds(ulong reported) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "only_result_mode = 'value'; only_result_value = " + reported.ToString(CultureInfo.InvariantCulture)); + + AobFirstFoundResult result = Find(Bounds(0x1_0000_1000, 0x1_0036_7000)); + + Assert.Equal(AobFirstFoundOutcomeKind.FoundOutsideBounds, result.Kind); + Assert.True(result.HasAddress); + Assert.Equal(new Address(reported), result.Address); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData("3.0")] + [InlineData("'1000'")] + [InlineData("true")] + [InlineData("{}")] + public void TryFindFirstFoundWithinBounds_float_string_or_boolean_result_is_invalid_result(string value) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "only_result_mode = 'value'; only_result_value = " + value); + + AobFirstFoundResult result = Find(Bounds(0, 0x10_0000)); + + Assert.Equal(AobFirstFoundOutcomeKind.InvalidResult, result.Kind); + Assert.False(result.HasAddress); + Assert.Equal(Address.Zero, result.Address); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryFindFirstFoundWithinBounds_keeps_the_64_bit_pattern_of_high_addresses() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "only_result_mode = 'value'; only_result_value = math.mininteger"); + + AobFirstFoundResult result = Find(Bounds(0x7FFF_FFFF_FFFF_0000, 0xFFFF_FFFF_FFFF_FFFF)); + + Assert.Equal(AobFirstFoundOutcomeKind.Found, result.Kind); + Assert.Equal(new Address(0x8000_0000_0000_0000), result.Address); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData("found", AobFirstFoundOutcomeKind.Found, "scan.getOnlyResult,list.destroy,scan.destroy")] + [InlineData("not-found", AobFirstFoundOutcomeKind.NotFound, "scan.getOnlyResult,list.destroy,scan.destroy")] + [InlineData("invalid", AobFirstFoundOutcomeKind.InvalidResult, "scan.getOnlyResult,list.destroy,scan.destroy")] + [InlineData("get-raises", AobFirstFoundOutcomeKind.ScanFailed, "scan.getOnlyResult,list.destroy,scan.destroy")] + [InlineData("set-raises", AobFirstFoundOutcomeKind.ScanFailed, "scan.setOnlyOneResult:true,list.destroy,scan.destroy")] + [InlineData("first-raises", AobFirstFoundOutcomeKind.ScanFailed, + "scan.first:14,scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy")] + [InlineData("wait-raises", AobFirstFoundOutcomeKind.ScanFailed, + "scan.wait,scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy")] + public void TryFindFirstFoundWithinBounds_releases_child_before_parent_on_every_path(string mode, + AobFirstFoundOutcomeKind expected, string traceEnd) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, mode switch + { + "found" => "only_result_mode = 'value'; only_result_value = 0x100000000", + "invalid" => "only_result_mode = 'value'; only_result_value = 'x'", + "get-raises" => "only_result_mode = 'raise'", + "set-raises" => "scan_set_only_one_raises = true", + "first-raises" => "scan_first_raises = true", + "wait-raises" => "scan_wait_modes = { 'raise', 'true' }", + _ => "only_result_mode = 'none'" + }); + + AobFirstFoundResult result = Find(ModuleBounds); + + Assert.Equal(expected, result.Kind); + Assert.EndsWith(traceEnd, MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + Assert.DoesNotContain("list.initialize", MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + Assert.True(result.Release.OwnershipConsumed); + Assert.Equal(TargetReleaseStatus.Released, result.Release.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, result.Release.MemScan.Status); + if (expected == AobFirstFoundOutcomeKind.ScanFailed) + { + Assert.Equal(LuaStatus.RuntimeError, result.LuaStatus); + } + + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + [SuppressMessage("xUnit.Analyzers", "xUnit1051", + Justification = "The token is cancelled on purpose to prove that no session is created.")] + public void TryFindFirstFoundWithinBounds_refuses_invalid_bounds_and_a_cancelled_token_before_any_CE_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.ClearTrace(L); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + AobFirstFoundResult invalid = Find(default); + AobFirstFoundResult cancelled = + AobScanner.TryFindFirstFoundWithinBounds(Pattern, ModuleBounds, AobScanOptions.Default, cancellation.Token); + + Assert.Equal(AobFirstFoundOutcomeKind.InvalidBounds, invalid.Kind); + Assert.Equal(AobFirstFoundOutcomeKind.Cancelled, cancelled.Kind); + Assert.Equal(MemoryScanCreationStatus.Unknown, cancelled.Creation.Status); + Assert.Throws(() => AobScanner.TryFindFirstFoundWithinBounds(null!, ModuleBounds, + AobScanOptions.Default, TestContext.Current.CancellationToken)); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void TryFindFirstFoundWithinBounds_without_a_qualified_target_reports_session_creation_failed() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "opened_process_id = 0"); + MemScanTestHost.ClearTrace(L); + + AobFirstFoundResult result = Find(ModuleBounds); + + Assert.Equal(AobFirstFoundOutcomeKind.SessionCreationFailed, result.Kind); + Assert.Equal(MemoryScanCreationStatus.TargetIdentityUnavailable, result.Creation.Status); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q30.a")] + public void TryFindFirstFoundWithinBounds_target_change_during_the_scan_reports_no_address() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + MemScanTestHost.Run(L, "only_result_mode = 'value'; only_result_value = 0x100000000; " + + "scan_first_hook = function() opened_process_id = " + + MemScanTestHost.FindOtherQualifiedProcessId().ToString(CultureInfo.InvariantCulture) + " end"); + + AobFirstFoundResult result = Find(ModuleBounds); + + Assert.Equal(AobFirstFoundOutcomeKind.TargetChanged, result.Kind); + Assert.False(result.HasAddress); + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, result.Release.MemScan.Status); + Assert.DoesNotContain("scan.getOnlyResult", MemScanTestHost.ReadTrace(L), StringComparison.Ordinal); + Assert.Equal(0, L.Top); + } + + private static AobFirstFoundResult Find(AobScanBounds bounds) + { + return AobScanner.TryFindFirstFoundWithinBounds(Pattern, bounds, AobScanOptions.Default, + TestContext.Current.CancellationToken); + } + + private static AobScanBounds Bounds(ulong start, ulong stop) + { + Assert.True(AobScanBounds.TryCreate(new Address(start), new Address(stop), out AobScanBounds bounds)); + return bounds; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScanBoundsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScanBoundsTests.cs new file mode 100644 index 00000000..dbd43274 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScanBoundsTests.cs @@ -0,0 +1,116 @@ +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.SDK.Engine.Tests.Scanning.Aob; + +/// +/// The CE work limit of the bounded AOB route: a non-empty half-open range, refused before any CE call when empty +/// or inverted (spike D4.3), built from a module as [base, base + size) (CE's own module convention, D4.1). +/// +public sealed class AobScanBoundsTests +{ + [Fact] + [Trait("Qualification", "Q28")] + public void AobScanBounds_default_is_invalid() + { + AobScanBounds bounds = default; + + Assert.False(bounds.IsValid); + Assert.Equal(0UL, bounds.Length); + Assert.False(bounds.Contains(Address.Zero)); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData(0x1000UL, 0x1000UL)] + [InlineData(0x2000UL, 0x1000UL)] + [InlineData(0UL, 0UL)] + [InlineData(ulong.MaxValue, 0UL)] + public void AobScanBounds_TryCreate_refuses_empty_and_inverted_ranges(ulong start, ulong stop) + { + Assert.False(AobScanBounds.TryCreate(new Address(start), new Address(stop), out AobScanBounds bounds)); + + Assert.Equal(default, bounds); + Assert.False(bounds.IsValid); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void AobScanBounds_TryCreate_keeps_the_bounds_verbatim_and_reports_the_length() + { + Assert.True(AobScanBounds.TryCreate(new Address(0x1000), new Address(0x1004), out AobScanBounds bounds)); + + Assert.True(bounds.IsValid); + Assert.Equal(new Address(0x1000), bounds.Start); + Assert.Equal(new Address(0x1004), bounds.Stop); + Assert.Equal(4UL, bounds.Length); + Assert.True(AobScanBounds.TryCreate(Address.Zero, new Address(ulong.MaxValue), out AobScanBounds whole)); + Assert.Equal(ulong.MaxValue, whole.Length); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void AobScanBounds_TryFromModule_uses_base_plus_image_size_as_the_exclusive_stop() + { + ModuleInfo module = new("Tutorial-x86_64.exe", new Address(0x1_0000_0000), new MemorySize(0x36_7000), true, + "Tutorial-x86_64.exe"); + + Assert.True(AobScanBounds.TryFromModule(in module, out AobScanBounds bounds)); + + Assert.Equal(new Address(0x1_0000_0000), bounds.Start); + Assert.Equal(new Address(0x1_0036_7000), bounds.Stop); + Assert.Equal(0x36_7000UL, bounds.Length); + Assert.True(bounds.Contains(new Address(0x1_0036_6FFF))); + Assert.False(bounds.Contains(new Address(0x1_0036_7000))); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void AobScanBounds_TryFromModule_refuses_a_module_without_image_size() + { + ModuleInfo module = new("unknown.dll", new Address(0x40_0000), null, false, "unknown.dll"); + + Assert.False(AobScanBounds.TryFromModule(in module, out AobScanBounds bounds)); + + Assert.False(bounds.IsValid); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void AobScanBounds_TryFromModule_refuses_a_zero_image_size() + { + ModuleInfo module = new("empty.dll", new Address(0x40_0000), new MemorySize(0), false, "empty.dll"); + + Assert.False(AobScanBounds.TryFromModule(in module, out AobScanBounds bounds)); + + Assert.False(bounds.IsValid); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData(0xFFFF_FFFF_FFFF_F000UL, 0x1000UL)] + [InlineData(0xFFFF_FFFF_FFFF_F000UL, 0x2000UL)] + [InlineData(ulong.MaxValue, 1UL)] + public void AobScanBounds_TryFromModule_refuses_an_end_beyond_the_address_space(ulong baseAddress, ulong size) + { + ModuleInfo module = new("high.dll", new Address(baseAddress), new MemorySize(size), true, "high.dll"); + + Assert.False(AobScanBounds.TryFromModule(in module, out AobScanBounds bounds)); + + Assert.False(bounds.IsValid); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void AobScanBounds_Contains_is_start_inclusive_and_stop_exclusive() + { + Assert.True(AobScanBounds.TryCreate(new Address(0x1000), new Address(0x2000), out AobScanBounds bounds)); + + Assert.False(bounds.Contains(new Address(0x0FFF))); + Assert.True(bounds.Contains(new Address(0x1000))); + Assert.True(bounds.Contains(new Address(0x1FFF))); + Assert.False(bounds.Contains(new Address(0x2000))); + Assert.False(bounds.Contains(new Address(ulong.MaxValue))); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerPublicationTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerPublicationTests.cs new file mode 100644 index 00000000..485d3127 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerPublicationTests.cs @@ -0,0 +1,155 @@ +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Scanning.Aob; + +/// +/// The AOB result list has a single destroy authority from the moment CE returns it (audit ch.08, F13 SDK side, +/// A13-34): a managed failure while publishing its owner destroys the raw list once and rethrows the original +/// failure; a successful publication leaves the owner as the only authority. +/// +[Trait("Category", "NativeLua")] +public sealed class AobScannerPublicationTests +{ + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_publication_failure_destroys_the_raw_list_once_and_rethrows() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = CreateTracedList(L, "Probe", destroyRaises: false); + AobStringListTestHost.InstallAobScan(L, handle); + InvalidOperationException injected = new("injected publication failure"); + Owned? results = null; + + InvalidOperationException thrown = Assert.Throws(() => + AobScanner.TryScanOutcomeCore("48 8B", AobScanOptions.Default, _ => throw injected, out results)); + + Assert.Same(injected, thrown); + Assert.Null(results); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal("list.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanDetailed_publication_failure_destroys_the_raw_list_once_and_rethrows() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = CreateTracedList(L, "Probe", destroyRaises: false); + AobStringListTestHost.InstallAobScan(L, handle); + InvalidOperationException injected = new("injected owner allocation failure"); + Owned? results = null; + + InvalidOperationException thrown = Assert.Throws(() => + AobScanner.TryScanDetailedCore("48 8B", AobScanOptions.Default, _ => throw injected, out results)); + + Assert.Same(injected, thrown); + Assert.Null(results); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal("list.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_publication_failure_with_a_refusing_destroy_rethrows_the_original_failure_without_retrying() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = CreateTracedList(L, "Stubborn", destroyRaises: true); + AobStringListTestHost.InstallAobScan(L, handle); + InvalidOperationException injected = new("injected publication failure"); + Owned? results = null; + + InvalidOperationException thrown = Assert.Throws(() => + AobScanner.TryScanOutcomeCore("48 8B", AobScanOptions.Default, _ => throw injected, out results)); + + Assert.Same(injected, thrown); + Assert.Null(results); + Assert.Equal("list.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_publication_failure_with_a_lua_exception_is_rethrown_not_reported_as_a_lua_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = CreateTracedList(L, "Probe", destroyRaises: false); + AobStringListTestHost.InstallAobScan(L, handle); + LuaException injected = new("injected publication failure"); + Owned? results = null; + + LuaException thrown = Assert.Throws(() => + AobScanner.TryScanOutcomeCore("48 8B", AobScanOptions.Default, _ => throw injected, out results)); + + Assert.Same(injected, thrown); + Assert.Null(results); + Assert.Equal("list.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_successful_publication_leaves_the_only_destroy_authority_with_the_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateList(L); + AobStringListTestHost.InstallAobScan(L, handle); + long destroyedBefore = FakeHost.DestroyedCount(L); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B", out Owned? results); + + Assert.Equal(AobScanOutcome.Matches(2), outcome); + Owned owned = Assert.IsType>(results); + Assert.Equal(destroyedBefore, FakeHost.DestroyedCount(L)); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + + owned.Dispose(); + Assert.Equal(destroyedBefore + 1, FakeHost.DestroyedCount(L)); + owned.Dispose(); + + Assert.Equal(destroyedBefore + 1, FakeHost.DestroyedCount(L)); + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + } + + // A two-entry list whose destroy is traced; it can refuse destruction like the model's Stubborn class does. + private static CEObject CreateTracedList(LuaState state, string className, bool destroyRaises) + { + EngineTest.Run(state, "trace = {}"u8); + string refusal = destroyRaises ? "; error('refuses to be destroyed')" : string.Empty; + return FakeHost.CreateObject(state, className, + "o.props.Count = 2\n" + + "o.items = { '00401000', '7FF6A1B2C3D4' }\n" + + "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'list.destroy')" + + refusal + " end end"); + } + + private static string ReadTrace(LuaState state) + { + using LuaFrame frame = new(state); + EngineTest.Run(state, "return table.concat(trace, ',')"u8, 1); + return EngineTest.ReadString(state, -1); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerTests.cs index 213ce1f9..68e6511b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobScannerTests.cs @@ -1,6 +1,10 @@ +using System.Globalization; +using System.Text; + using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; @@ -318,6 +322,7 @@ public void TryScanOutcome_distinguishes_missing_global_protected_lookup_failure } [Fact] + [Trait("Qualification", "Q27")] public void TryScanOutcome_releases_a_valid_host_object_when_its_count_is_unreadable() { EngineTest.RequireNativeLua(); @@ -357,4 +362,381 @@ public void TryScan_while_detached_throws_without_attempting_lua_access() Assert.Throws(() => AobScanner.TryScan("90", out _)); } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_zero_values_as_CE_7_7_reports_zero_matches_is_no_result_never_no_matches() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + long destroyedBefore = FakeHost.DestroyedCount(L); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("zero-values", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.NoResult, outcome.Kind); + Assert.NotEqual(AobScanOutcomeKind.NoMatches, outcome.Kind); + Assert.False(outcome.IsSuccess); + Assert.False(outcome.HasResultCount); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + Assert.Null(results); + Assert.Equal(destroyedBefore, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanDetailed_zero_values_and_an_explicit_nil_yield_the_same_status_and_restore_the_stack() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + long destroyedBefore = FakeHost.DestroyedCount(L); + + AobScanStatus zeroValues = AobScanner.TryScanDetailed("zero-values", out Owned? zeroResults); + Assert.Equal(0, L.Top); + AobScanStatus explicitNil = AobScanner.TryScanDetailed("nil-result", out Owned? nilResults); + Assert.Equal(0, L.Top); + + Assert.Equal(AobScanStatus.NoResult, zeroValues); + Assert.Equal(zeroValues, explicitNil); + Assert.Null(zeroResults); + Assert.Null(nilResults); + Assert.Equal(destroyedBefore, FakeHost.DestroyedCount(L)); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScan_zero_values_returns_false_without_an_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + long destroyedBefore = FakeHost.DestroyedCount(L); + + Assert.False(AobScanner.TryScan("zero-values", out Owned? results)); + + Assert.Null(results); + Assert.Equal(destroyedBefore, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_missing_global_is_global_unavailable() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.GlobalUnavailable, outcome.Kind); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + Assert.False(outcome.IsSuccess); + Assert.Null(results); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_nil_result_is_no_result() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("nil-result", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.NoResult, outcome.Kind); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + Assert.False(outcome.IsSuccess); + Assert.Null(results); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_protected_call_error_is_protected_lua_failure_with_its_status() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + long destroyedBefore = FakeHost.DestroyedCount(L); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("raise", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.ProtectedLuaFailure, outcome.Kind); + Assert.Equal(LuaStatus.RuntimeError, outcome.LuaStatus); + Assert.False(outcome.IsSuccess); + Assert.Null(results); + Assert.Equal(destroyedBefore, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_protected_global_lookup_error_is_protected_lua_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + setmetatable(_G, { + __index = function(_, name) + if name == 'AOBScan' then error('AOBScan lookup failed') end + end + }) + """u8); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.ProtectedLuaFailure, outcome.Kind); + Assert.Equal(LuaStatus.RuntimeError, outcome.LuaStatus); + Assert.Null(results); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_malformed_userdata_is_invalid_result() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + _ = L.NewUserdata(1); + Assert.True(L.TrySetGlobal("aob_malformed"u8).IsOk); + long destroyedBefore = FakeHost.DestroyedCount(L); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("malformed-result", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.InvalidResult, outcome.Kind); + Assert.Equal(LuaStatus.Ok, outcome.LuaStatus); + Assert.False(outcome.IsSuccess); + Assert.Null(results); + Assert.Equal(destroyedBefore, FakeHost.DestroyedCount(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_valid_empty_list_is_no_matches_with_a_live_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateEmptyList(L); + AobStringListTestHost.InstallAobScan(L, handle); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.NoMatches, outcome.Kind); + Assert.Equal(0, outcome.ResultCount); + Owned owned = Assert.IsType>(results); + Assert.False(owned.IsDisposed); + Assert.False(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + + owned.Dispose(); + + Assert.True(FakeHost.IsDestroyed(L, handle)); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q27")] + [InlineData("'AOBScan failed'")] + [InlineData("'Échec de l’analyse — mémoire illisible'")] + [InlineData("''")] + [InlineData("{ code = 1 }")] + [InlineData("nil")] + [InlineData("42")] + public void TryScanOutcome_category_does_not_depend_on_the_lua_error_text(string payload) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + EngineTest.Run(L, Encoding.UTF8.GetBytes("aob_error_payload = " + payload)); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("raise-text", out Owned? results); + + Assert.Equal(AobScanOutcome.ProtectedLuaFailure(LuaStatus.RuntimeError), outcome); + Assert.Null(results); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q27")] + public void TryScanOutcome_next_call_on_the_same_state_succeeds_after_a_protected_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject handle = AobStringListTestHost.CreateList(L); + AobStringListTestHost.InstallAobScan(L, handle); + + AobScanOutcome failed = AobScanner.TryScanOutcome("raise", out Owned? failedResults); + AobScanOutcome next = AobScanner.TryScanOutcome("48 8B", out Owned? results); + + Assert.Equal(AobScanOutcomeKind.ProtectedLuaFailure, failed.Kind); + Assert.Null(failedResults); + Assert.Equal(AobScanOutcome.Matches(2), next); + using Owned owned = Assert.IsType>(results); + Assert.True(owned.Value.TryGetItem(0, out string? first)); + Assert.Equal("00401000", first); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryScanOutcome_with_target_context_reports_the_same_qualified_incarnation_before_and_after() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallTarget(L, Environment.ProcessId); + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B", AobScanOptions.Default, + out Owned? results, out AobScanTargetContext context); + + using Owned owned = Assert.IsType>(results); + Assert.Equal(AobScanOutcome.Matches(2), outcome); + Assert.True(context.Before.IsQualified); + Assert.True(context.After.IsQualified); + Assert.Equal(Environment.ProcessId, context.Before.Incarnation!.Value.ProcessId); + Assert.Equal(context.Before.Incarnation, context.After.Incarnation); + Assert.True(context.IsSameQualifiedIncarnation); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryScanOutcome_with_target_context_reports_a_target_change_without_reclassifying_the_scan() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + int otherProcessId = MemScanTestHost.FindOtherQualifiedProcessId(); + AobStringListTestHost.InstallTarget(L, Environment.ProcessId); + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + MemScanTestHost.Run(L, "aob_retarget_pid = " + otherProcessId.ToString(CultureInfo.InvariantCulture)); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("retarget", AobScanOptions.Default, + out Owned? results, out AobScanTargetContext context); + + // Both observations are qualified, but of two different process incarnations. + using Owned owned = Assert.IsType>(results); + Assert.Equal(AobScanOutcome.Matches(2), outcome); + Assert.True(context.Before.IsQualified); + Assert.True(context.After.IsQualified); + Assert.Equal(Environment.ProcessId, context.Before.Incarnation!.Value.ProcessId); + Assert.Equal(otherProcessId, context.After.Incarnation!.Value.ProcessId); + Assert.NotEqual(context.Before.Incarnation, context.After.Incarnation); + Assert.False(context.IsSameQualifiedIncarnation); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryScanOutcome_with_target_context_and_no_target_still_scans_and_reports_unqualified_facts() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallTarget(L, 0); + AobStringListTestHost.InstallAobScan(L, AobStringListTestHost.CreateList(L)); + + AobScanOutcome outcome = AobScanner.TryScanOutcome("48 8B", AobScanOptions.Default, + out Owned? results, out AobScanTargetContext context); + + using Owned owned = Assert.IsType>(results); + Assert.Equal(AobScanOutcome.Matches(2), outcome); + Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, context.Before.Status); + Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, context.After.Status); + Assert.False(context.IsSameQualifiedIncarnation); + Assert.Equal(0, L.Top); + Assert.Equal(1L, MemScanTestHost.ReadInteger(L, "aob_calls")); + } + + [Theory] + [InlineData("48 8B")] + [InlineData("zero-values")] + [InlineData("nil-result")] + [InlineData("raise")] + [InlineData("invalid-result")] + [InlineData("empty")] + public void TryScanOutcome_with_target_context_classifies_exactly_like_the_three_argument_overload(string mode) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + AobStringListTestHost.InstallTarget(L, Environment.ProcessId); + AobStringListTestHost.InstallAobScan(L, CreateListFor(L, mode)); + + AobScanOutcome plain = AobScanner.TryScanOutcome(mode, AobScanOptions.Default, out Owned? first); + first?.Dispose(); + AobStringListTestHost.SetGlobalObject(L, "aob_results"u8, CreateListFor(L, mode)); + AobScanOutcome withContext = AobScanner.TryScanOutcome(mode, AobScanOptions.Default, + out Owned? second, out _); + second?.Dispose(); + + Assert.Equal(plain, withContext); + Assert.Equal(first is null, second is null); + Assert.Equal(0, L.Top); + } + + [Theory] + [InlineData(AobScanStatus.Unknown)] + [InlineData(AobScanStatus.Success)] + [InlineData((AobScanStatus) 250)] + public void FromStatus_never_maps_an_unexpected_status_to_a_count_failure(AobScanStatus status) + { + AobScanOutcome outcome = AobScanner.FromStatus(status, LuaStatus.Ok); + + Assert.Equal(AobScanOutcomeKind.Unknown, outcome.Kind); + Assert.NotEqual(AobScanOutcomeKind.ResultListCountUnavailable, outcome.Kind); + Assert.False(outcome.IsSuccess); + Assert.Equal(default, outcome); + } + + [Theory] + [InlineData(AobScanStatus.GlobalUnavailable, AobScanOutcomeKind.GlobalUnavailable)] + [InlineData(AobScanStatus.LuaFailure, AobScanOutcomeKind.ProtectedLuaFailure)] + [InlineData(AobScanStatus.NoResult, AobScanOutcomeKind.NoResult)] + [InlineData(AobScanStatus.InvalidResult, AobScanOutcomeKind.InvalidResult)] + public void FromStatus_maps_every_failure_status_to_its_own_outcome_kind(AobScanStatus status, + AobScanOutcomeKind expected) + { + AobScanOutcome outcome = AobScanner.FromStatus(status, LuaStatus.RuntimeError); + + Assert.Equal(expected, outcome.Kind); + Assert.False(outcome.IsSuccess); + } + + private static CEObject CreateListFor(LuaState state, string mode) + { + return string.Equals(mode, "empty", StringComparison.Ordinal) + ? AobStringListTestHost.CreateEmptyList(state) + : AobStringListTestHost.CreateList(state); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobStringListTestHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobStringListTestHost.cs index 9fca570c..e030d57a 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobStringListTestHost.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Aob/AobStringListTestHost.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using System.Text; + using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.State; @@ -58,7 +61,11 @@ public static CEObject CreateList(LuaState state) """); } - /// Creates the CE-shaped empty StringList result used to prove that an empty scan remains successful. + /// + /// Creates an empty StringList result. CE 7.7.0.10621 was never observed to return one for AOBScan + /// (spike D1: zero matches return no value); it models the documented "valid empty list" branch that + /// NoMatches stays reserved for on the global route. + /// public static CEObject CreateEmptyList(LuaState state) { return FakeHost.CreateObject(state, "Probe", "o.props.Count = 0"); @@ -78,7 +85,15 @@ public static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEOb Assert.True(state.TrySetGlobal(name).IsOk); } - /// Installs a string-form AOBScan stand-in that records every optional argument and returns the given list. + /// + /// Installs a string-form AOBScan stand-in that records every optional argument and returns the given list. + /// The pattern selects a result shape: zero-values returns no value at all, which is what CE 7.7.0.10621 + /// does on zero matches (spike D1); nil-result returns one explicit nil; raise and + /// raise-text raise (the latter with the Lua value of the global aob_error_payload, so a test can + /// vary the error text or type); invalid-result returns a number; malformed-result returns the + /// global aob_malformed; retarget sets opened_process_id to aob_retarget_pid and + /// returns the list; any other pattern returns the list. + /// public static void InstallAobScan(LuaState state, CEObject results) { SetGlobalObject(state, "aob_results"u8, results); @@ -89,15 +104,32 @@ function AOBScan(...) aob_protection = select(2, ...) aob_alignment = select(3, ...) aob_alignment_parameter = select(4, ...) + aob_calls = (aob_calls or 0) + 1 + if aob_pattern == 'zero-values' then return end if aob_pattern == 'nil-result' then return nil end if aob_pattern == 'raise' then error('AOBScan stand-in raised') end + if aob_pattern == 'raise-text' then error(aob_error_payload) end if aob_pattern == 'invalid-result' then return 42 end if aob_pattern == 'malformed-result' then return aob_malformed end + if aob_pattern == 'retarget' then opened_process_id = aob_retarget_pid end return aob_results end """u8); } + /// + /// Models CE's target selection: getOpenedProcessID returns the global opened_process_id (initially + /// ) and, as every fixture of a qualified local target does, isConnectedToCEServer + /// returns . + /// + public static void InstallTarget(LuaState state, long processId) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes( + "opened_process_id = " + processId.ToString(CultureInfo.InvariantCulture) + "\n" + + "function getOpenedProcessID() return opened_process_id end\n" + + "function isConnectedToCEServer() return false end")); + } + /// Installs the exact CE factory spelling and makes it return the supplied fresh fake list. public static void InstallStringListFactory(LuaState state, CEObject created) { diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemScanTestHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemScanTestHost.cs new file mode 100644 index 00000000..8243d81c --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemScanTestHost.cs @@ -0,0 +1,334 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; + +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Tests.Scanning; + +/// +/// CE 7.7-shaped Lua stand-ins for createMemScan, createFoundList, the MemScan members the SDK calls, +/// and the FoundList members it reads, over the normal fake-host userdata. Every call is appended to the Lua +/// trace table; tests steer the stand-ins through Lua globals, so one fixture covers every result shape. +/// +/// +/// Trace entries (in call order): +/// +/// factory.scan, factory.list: the two CE factories. +/// +/// scan.setOnlyOneResult:<v>, scan.first:<argument count>, +/// scan.next:<argument count>, scan.wait (no argument) or scan.wait:<timeout>, +/// scan.terminate:<force>, scan.new, scan.getOnlyResult, scan.ErrorString (a +/// property read), scan.set.<property>:<v> (a write to OnlyOneResult, +/// IsUnique, OnGuiUpdate, OnScanDone or OnScanStart), scan.destroy. +/// +/// +/// list.initialize, list.deinitialize, results.getCount, +/// results.getAddress:<i>, results.getValue:<i>, list.destroy. +/// +/// +/// Steering globals (all optional): +/// +/// +/// scan_wait_mode (default 'true') or a queue scan_wait_modes: 'true', +/// 'false', 'raise', 'nil', 'none' (zero values), 'number', +/// 'string'; scan_wait_hook runs inside the wait. +/// +/// +/// scan_first_raises with scan_first_error_payload, scan_first_hook, +/// scan_next_raises with scan_next_error_payload, scan_saved_results (the saved-result +/// names a ten-argument nextScan accepts; any other name raises), scan_set_only_one_raises, +/// scan_terminate_raises, scan_new_raises, scan_destroy_raises. +/// +/// +/// scan_error_mode ('string' by default, 'nil', 'number', 'raise') and +/// scan_error_string (default empty) for the ErrorString property. +/// +/// +/// only_result_mode ('none' by default, 'nil', 'value', 'raise') and +/// only_result_value for getOnlyResult. +/// +/// +/// found_addresses (a Lua array of the values getAddress returns; a missing entry is +/// synthesized), found_count (overrides the count), found_count_raises, +/// found_address_raises, list_initialize_raises, list_destroy_raises. +/// +/// +/// opened_process_id: the selected target; isConnectedToCEServer returns +/// , as every fixture of a qualified local target does. +/// +/// +/// +internal static class MemScanTestHost +{ + private const string ScannerInitializer = """ + o.props.setOnlyOneResult = function(...) + set_only_one_argument_count = select('#', ...) + table.insert(trace, 'scan.setOnlyOneResult:' .. tostring((...))) + if scan_set_only_one_raises then error('setOnlyOneResult rejected') end + end + o.props.firstScan = function(...) + first_scan_args = table.pack(...) + first_scan_start_type = math.type(first_scan_args[6]) + first_scan_stop_type = math.type(first_scan_args[7]) + table.insert(trace, 'scan.first:' .. first_scan_args.n) + if scan_first_hook then scan_first_hook() end + if scan_first_raises then error(scan_first_error_payload) end + end + o.props.nextScan = function(...) + next_scan_args = table.pack(...) + table.insert(trace, 'scan.next:' .. next_scan_args.n) + local saved = next_scan_args[10] + if saved ~= nil and not (scan_saved_results and scan_saved_results[saved]) then + error('saved result ' .. tostring(saved) .. ' does not exist') + end + if scan_next_raises then error(scan_next_error_payload) end + end + o.props.waitTillDone = function(...) + local n = select('#', ...) + wait_calls = (wait_calls or 0) + 1 + wait_argument_count = n + wait_argument = ... + wait_argument_type = math.type((...)) + if n == 0 then table.insert(trace, 'scan.wait') + else table.insert(trace, 'scan.wait:' .. tostring((...))) end + if scan_wait_hook then scan_wait_hook() end + local mode = scan_wait_mode or 'true' + if scan_wait_modes and #scan_wait_modes > 0 then mode = table.remove(scan_wait_modes, 1) end + if mode == 'true' then return true end + if mode == 'false' then return false end + if mode == 'raise' then error('waitTillDone rejected') end + if mode == 'nil' then return nil end + if mode == 'none' then return end + if mode == 'number' then return 1 end + if mode == 'string' then return 'true' end + error('unknown wait mode ' .. tostring(mode)) + end + o.props.terminateScan = function(...) + terminate_argument_count = select('#', ...) + terminate_argument = ... + table.insert(trace, 'scan.terminate:' .. tostring((...))) + if scan_terminate_raises then error('terminateScan rejected') end + end + o.props.newScan = function() + table.insert(trace, 'scan.new') + if scan_new_raises then error('newScan rejected') end + end + o.props.getOnlyResult = function(...) + only_result_argument_count = select('#', ...) + table.insert(trace, 'scan.getOnlyResult') + local mode = only_result_mode or 'none' + if mode == 'none' then return end + if mode == 'nil' then return nil end + if mode == 'raise' then error('getOnlyResult rejected') end + return only_result_value + end + o.getters.ErrorString = function(o) + table.insert(trace, 'scan.ErrorString') + local mode = scan_error_mode or 'string' + if mode == 'raise' then error('ErrorString unreadable') end + if mode == 'nil' then return nil end + if mode == 'number' then return 42 end + return scan_error_string or '' + end + for _, name in ipairs({ 'OnlyOneResult', 'IsUnique', 'OnGuiUpdate', 'OnScanDone', 'OnScanStart' }) do + o.setters[name] = function(o, value) + table.insert(trace, 'scan.set.' .. name .. ':' .. tostring(value)) + o.props[name] = value + end + end + o.getters.destroy = function(o) + return function() + o.destroyed = true + table.insert(trace, 'scan.destroy') + if scan_destroy_raises then error('scan destroy rejected') end + end + end + """; + + private const string FoundListInitializer = """ + o.props.initialize = function() + table.insert(trace, 'list.initialize') + if list_initialize_raises then error('initialize rejected') end + end + o.props.deinitialize = function() table.insert(trace, 'list.deinitialize') end + o.props.getCount = function() + table.insert(trace, 'results.getCount') + if found_count_raises then error('getCount rejected') end + if found_count ~= nil then return found_count end + return #found_addresses + end + o.getters.Count = function(o) + if found_count ~= nil then return found_count end + return #found_addresses + end + o.props.getAddress = function(index) + table.insert(trace, 'results.getAddress:' .. index) + if found_address_raises then error('getAddress rejected') end + local value = found_addresses[index + 1] + if value == nil then return string.format('%X', 0x10000 + index) end + return value + end + o.props.getValue = function(index) + table.insert(trace, 'results.getValue:' .. index) + return '100' + end + o.getters.destroy = function(o) + return function() + o.destroyed = true + table.insert(trace, 'list.destroy') + if list_destroy_raises then error('list destroy rejected') end + end + end + """; + + private static ReadOnlySpan Factories => """ + function createMemScan(...) + create_mem_scan_argument_count = select('#', ...) + table.insert(trace, 'factory.scan') + return factory_scan + end + function createFoundList(...) + create_found_list_argument_count = select('#', ...) + table.insert(trace, 'factory.list') + return factory_found_list + end + function getOpenedProcessID() return opened_process_id end + function isConnectedToCEServer() return false end + """u8; + + /// + /// Creates the scanner and found-list stand-ins, installs the two factories and selects the current process as + /// the qualified local target. + /// + public static HostObjects Install(LuaState state) + { + EngineTest.Run(state, "trace = {}; found_addresses = {}"u8); + Run(state, "opened_process_id = " + Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + CEObject scanner = FakeHost.CreateObject(state, "Object", ScannerInitializer); + CEObject foundList = FakeHost.CreateObject(state, "Object", FoundListInitializer); + SetGlobalObject(state, "factory_scan"u8, scanner); + SetGlobalObject(state, "factory_found_list"u8, foundList); + EngineTest.Run(state, Factories); + return new HostObjects(scanner, foundList); + } + + /// + /// Installs the stand-ins, creates a session through the production factory and clears the trace, so a test + /// traces only its own session calls. + /// + public static MemoryScanSession CreateSession(LuaState state) + { + _ = Install(state); + MemoryScanCreationOutcome outcome = MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? session); + Assert.Equal(MemoryScanCreationStatus.Success, outcome.Status); + ClearTrace(state); + return Assert.IsType(session); + } + + /// Runs a Lua chunk given as a managed string (UTF-8 encoded), failing the test on error. + public static void Run(LuaState state, string source) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes(source)); + } + + /// The comma-joined trace. + public static string ReadTrace(LuaState state) + { + using LuaFrame frame = new(state); + EngineTest.Run(state, "return table.concat(trace, ',')"u8, 1); + return EngineTest.ReadString(state, -1); + } + + /// Empties the trace. + public static void ClearTrace(LuaState state) + { + EngineTest.Run(state, "trace = {}"u8); + } + + /// Evaluates a Lua expression that must produce . + public static void AssertLua(LuaState state, string condition) + { + using LuaFrame frame = new(state); + EngineTest.Run(state, Encoding.UTF8.GetBytes("return " + condition), 1); + Assert.True(state.ToBoolean(-1), "Lua condition failed: " + condition); + } + + /// Evaluates a Lua expression that must produce an integer. + public static long ReadInteger(LuaState state, string expression) + { + using LuaFrame frame = new(state); + EngineTest.Run(state, Encoding.UTF8.GetBytes("return " + expression), 1); + return EngineTest.ReadInteger(state, -1); + } + + /// + /// Returns the identifier of another live process whose start time is readable, so that CE "selecting" it gives + /// a qualified observation of a different target. The oldest such process is chosen because it is the least + /// likely to exit during the test. + /// + public static int FindOtherQualifiedProcessId() + { + int selected = 0; + DateTime oldest = DateTime.MaxValue; + foreach (Process process in Process.GetProcesses()) + { + using (process) + { + if (process.Id == Environment.ProcessId || process.Id <= 4) + { + continue; + } + + try + { + DateTime started = process.StartTime; + if (started < oldest) + { + oldest = started; + selected = process.Id; + } + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception + or NotSupportedException) + { + // Not readable by this user (protected or already exited): not a qualifiable target. + } + } + } + + Assert.True(selected != 0, "No other process with a readable start time exists; a target change cannot be modelled."); + return selected; + } + + /// + /// Replaces the target incarnation a session captured at creation. This models a process-identifier reuse that + /// cannot be produced with real processes in a unit test (the current process's start time is fixed): the + /// session's next context check then observes the same identifier with a different start time. + /// + public static void ReplaceCapturedTarget(MemoryScanSession session, TargetProcessIncarnation incarnation) + { + FieldInfo field = typeof(MemoryScanSession).GetField("_targetObservation", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("The session's captured target field was not found."); + field.SetValue(session, TargetSelectionObservation.Qualified(incarnation)); + } + + private static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEObject value) + { + using LuaFrame frame = new(state); + value.Push(state); + Assert.True(state.TrySetGlobal(name).IsOk); + } + + /// The two host objects the factories return. + [StructLayout(LayoutKind.Auto)] + internal readonly record struct HostObjects(CEObject Scanner, CEObject FoundList); +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs index c419e50b..1135b1ba 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs @@ -15,6 +15,7 @@ namespace CheatEngine.SDK.Engine.Tests.Scanning; public sealed class MemoryScanSessionFactoryTests { [Fact] + [Trait("Qualification", "Q25")] public void TryCreateWithOutcome_when_both_factories_return_host_objects_transfers_ownership_to_the_session() { EngineTest.RequireNativeLua(); @@ -46,6 +47,7 @@ public void TryCreateWithOutcome_when_both_factories_return_host_objects_transfe } [Fact] + [Trait("Qualification", "Q25")] public void TryCreate_when_the_child_factory_is_unavailable_rolls_back_the_created_parent() { EngineTest.RequireNativeLua(); @@ -68,6 +70,7 @@ public void TryCreate_when_the_child_factory_is_unavailable_rolls_back_the_creat } [Fact] + [Trait("Qualification", "Q25")] public void TryCreate_when_the_child_factory_raises_rolls_back_the_created_parent_and_restores_the_stack() { EngineTest.RequireNativeLua(); @@ -99,6 +102,7 @@ function createFoundList(scan) } [Fact] + [Trait("Qualification", "Q25")] public void TryCreate_when_the_child_factory_returns_a_nonobject_rolls_back_the_created_parent() { EngineTest.RequireNativeLua(); @@ -129,6 +133,7 @@ function createFoundList(scan) } [Fact] + [Trait("Qualification", "Q25")] public void TryCreate_when_the_child_factory_aliases_the_parent_rolls_back_without_creating_a_second_owner() { EngineTest.RequireNativeLua(); @@ -160,6 +165,7 @@ function createFoundList(scan) } [Fact] + [Trait("Qualification", "Q25")] public void TryCreate_when_internal_adoption_fails_rolls_back_the_child_before_the_parent() { EngineTest.RequireNativeLua(); @@ -182,6 +188,7 @@ public void TryCreate_when_internal_adoption_fails_rolls_back_the_child_before_t } [Fact] + [Trait("Qualification", "Q25")] public void TryCreate_when_the_parent_factory_returns_a_nonobject_does_not_publish_a_session() { EngineTest.RequireNativeLua(); @@ -198,6 +205,7 @@ public void TryCreate_when_the_parent_factory_returns_a_nonobject_does_not_publi } [Fact] + [Trait("Qualification", "Q25")] public void TryCreateDetailed_when_a_factory_returns_nil_keeps_absence_distinct_from_a_Lua_failure() { EngineTest.RequireNativeLua(); @@ -215,6 +223,7 @@ public void TryCreateDetailed_when_a_factory_returns_nil_keeps_absence_distinct_ } [Fact] + [Trait("Qualification", "Q25")] public void TryCreateDetailed_when_the_child_factory_returns_nil_reports_absence_and_releases_the_parent() { EngineTest.RequireNativeLua(); @@ -236,6 +245,7 @@ function createFoundList(scan) function getOpenedProcessID() return {{Environment.ProcessId}} end + function isConnectedToCEServer() return false end """)); MemoryScanCreationStatus status = MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created); @@ -248,6 +258,7 @@ function getOpenedProcessID() } [Fact] + [Trait("Qualification", "Q25")] public void TryCreateDetailed_when_rollback_destroy_is_not_confirmed_reports_that_fact_without_retrying() { EngineTest.RequireNativeLua(); @@ -276,6 +287,7 @@ function createFoundList(scan) function getOpenedProcessID() return {{Environment.ProcessId}} end + function isConnectedToCEServer() return false end """)); MemoryScanCreationStatus status = MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created); @@ -287,6 +299,7 @@ function getOpenedProcessID() } [Fact] + [Trait("Qualification", "Q25")] public void TryCreateWithOutcome_refuses_an_unqualified_target_before_either_factory_acquires_an_owner() { EngineTest.RequireNativeLua(); @@ -315,6 +328,76 @@ public void TryCreateWithOutcome_refuses_an_unqualified_target_before_either_fac Assert.Equal(0, L.Top); } + [Fact] + [Trait("Qualification", "Q25")] + public void TryCreate_when_adoption_fails_and_the_child_rollback_raises_still_destroys_the_parent_once_and_rethrows_the_adoption_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + CEObject foundList = FakeHost.CreateObject(L, "Object", """ + o.getters.destroy = function(o) + return function() + table.insert(trace, 'list.destroy') + error('fixture child destroy failure') + end + end + """); + InstallFactories(L, scanner, foundList); + InvalidOperationException injected = new("injected adoption failure"); + + InvalidOperationException failure = Assert.Throws(() => + MemoryScanSessions.TryCreateCore(out _, (_, _) => throw injected)); + + Assert.Same(injected, failure); + Assert.True(FakeHost.IsDestroyed(L, scanner)); + Assert.Equal("factory.scan,factory.list,list.destroy,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q25")] + public void TryCreate_when_the_parent_rollback_raises_after_a_child_failure_reports_rollback_unconfirmed_without_retrying() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = FakeHost.CreateObject(L, "Object", """ + o.getters.destroy = function(o) + return function() + table.insert(trace, 'scan.destroy') + error('fixture parent destroy failure') + end + end + """); + SetGlobalObject(L, "factory_scan"u8, scanner); + EngineTest.Run(L, Encoding.UTF8.GetBytes($$""" + trace = {} + function createMemScan() + table.insert(trace, 'factory.scan') + return factory_scan + end + function createFoundList(scan) + table.insert(trace, 'factory.list') + error('found-list creation failed') + end + function getOpenedProcessID() + return {{Environment.ProcessId}} + end + function isConnectedToCEServer() return false end + """)); + + MemoryScanCreationOutcome outcome = MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? created); + + Assert.Equal(MemoryScanCreationStatus.RollbackUnconfirmed, outcome.Status); + Assert.Null(created); + Assert.Equal("factory.scan,factory.list,scan.destroy", ReadTrace(L)); + Assert.Equal(0, L.Top); + } + private static CEObject CreateScanner(LuaState state) { return FakeHost.CreateObject(state, "Object", """ @@ -356,14 +439,16 @@ function createFoundList(scan) function getOpenedProcessID() return {{Environment.ProcessId}} end + function isConnectedToCEServer() return false end """)); } private static void InstallCurrentTarget(LuaState state) { EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + - Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + - " end")); + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + + " end")); + EngineTest.Run(state, FakeHost.LocalTargetBackendChunk); } private static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEObject value) diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs index 630bfdd5..650aebed 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs @@ -24,6 +24,7 @@ namespace CheatEngine.SDK.Engine.Tests.Scanning; public sealed class MemoryScanSessionTests { [Fact] + [Trait("Qualification", "Q26")] public void First_scan_wait_and_read_follow_the_documented_CE_sequence() { EngineTest.RequireNativeLua(); @@ -50,6 +51,7 @@ public void First_scan_wait_and_read_follow_the_documented_CE_sequence() } [Fact] + [Trait("Qualification", "Q26")] public void Next_scan_is_rejected_until_a_first_scan_completed_and_omits_an_unspecified_optional_argument() { EngineTest.RequireNativeLua(); @@ -72,6 +74,7 @@ public void Next_scan_is_rejected_until_a_first_scan_completed_and_omits_an_unsp } [Fact] + [Trait("Qualification", "Q26")] public void Next_scan_passes_a_present_saved_result_name_as_the_tenth_argument() { EngineTest.RequireNativeLua(); @@ -100,6 +103,7 @@ public void Next_scan_passes_a_present_saved_result_name_as_the_tenth_argument() } [Fact] + [Trait("Qualification", "Q26")] public void Wait_error_invalidates_the_session_so_reset_can_recover() { EngineTest.RequireNativeLua(); @@ -120,6 +124,7 @@ public void Wait_error_invalidates_the_session_so_reset_can_recover() } [Fact] + [Trait("Qualification", "Q26")] public void ReleaseWithOutcome_confirms_and_consumes_the_child_before_the_parent_without_a_retry() { EngineTest.RequireNativeLua(); @@ -144,6 +149,7 @@ public void ReleaseWithOutcome_confirms_and_consumes_the_child_before_the_parent } [Fact] + [Trait("Qualification", "Q26")] public void ReleaseWithOutcome_reports_unknown_child_cleanup_and_still_attempts_the_parent_once() { EngineTest.RequireNativeLua(); @@ -167,6 +173,7 @@ public void ReleaseWithOutcome_reports_unknown_child_cleanup_and_still_attempts_ } [Fact] + [Trait("Qualification", "Q26")] public void Dispose_after_a_detach_consumes_ownership_without_throwing_or_using_the_prior_runtime() { EngineTest.RequireNativeLua(); @@ -203,6 +210,7 @@ public void Dispose_after_a_detach_consumes_ownership_without_throwing_or_using_ } [Fact] + [Trait("Qualification", "Q26")] public void A_large_found_list_count_is_preserved_without_widening_row_indices() { EngineTest.RequireNativeLua(); @@ -221,6 +229,7 @@ public void A_large_found_list_count_is_preserved_without_widening_row_indices() } [Fact] + [Trait("Qualification", "Q26")] public void A_negative_found_list_count_is_rejected_by_raw_and_session_reads() { EngineTest.RequireNativeLua(); @@ -239,6 +248,7 @@ public void A_negative_found_list_count_is_rejected_by_raw_and_session_reads() } [Fact] + [Trait("Qualification", "Q26")] public void An_empty_found_list_does_not_call_address_or_value_methods_out_of_range() { EngineTest.RequireNativeLua(); @@ -262,6 +272,7 @@ public void An_empty_found_list_does_not_call_address_or_value_methods_out_of_ra } [Fact] + [Trait("Qualification", "Q26")] public void Wait_for_completion_requests_zero_Lua_results_from_wait_till_done() { EngineTest.RequireNativeLua(); @@ -282,6 +293,7 @@ public void Wait_for_completion_requests_zero_Lua_results_from_wait_till_done() } [Fact] + [Trait("Qualification", "Q26")] public void Failed_protected_scan_invalidates_the_session_until_reset_succeeds() { EngineTest.RequireNativeLua(); @@ -307,6 +319,7 @@ public void Failed_protected_scan_invalidates_the_session_until_reset_succeeds() } [Fact] + [Trait("Qualification", "Q26")] public void A_main_thread_only_scan_operation_is_rejected_before_it_touches_CE() { EngineTest.RequireNativeLua(); @@ -324,6 +337,7 @@ public void A_main_thread_only_scan_operation_is_rejected_before_it_touches_CE() } [Fact] + [Trait("Qualification", "Q26")] public void Disposal_on_a_worker_never_throws_or_retries_unsafe_child_or_parent_cleanup() { EngineTest.RequireNativeLua(); @@ -346,6 +360,7 @@ public void Disposal_on_a_worker_never_throws_or_retries_unsafe_child_or_parent_ } [Fact] + [Trait("Qualification", "Q26")] public void Adopt_transfers_the_source_owners_and_keeps_borrowed_handle_identity() { EngineTest.RequireNativeLua(); @@ -366,6 +381,7 @@ public void Adopt_transfers_the_source_owners_and_keeps_borrowed_handle_identity } [Fact] + [Trait("Qualification", "Q25")] public void Adopt_when_session_publication_fails_keeps_the_source_owners_for_child_before_parent_cleanup() { EngineTest.RequireNativeLua(); @@ -391,6 +407,7 @@ public void Adopt_when_session_publication_fails_keeps_the_source_owners_for_chi } [Fact] + [Trait("Qualification", "Q26")] public void A_non_hexadecimal_address_text_is_a_stable_unexpected_host_result() { EngineTest.RequireNativeLua(); @@ -408,6 +425,7 @@ public void A_non_hexadecimal_address_text_is_a_stable_unexpected_host_result() } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResults_refuses_an_insufficient_destination_before_reading_any_row() { EngineTest.RequireNativeLua(); @@ -431,6 +449,7 @@ public void TryCopyResults_refuses_an_insufficient_destination_before_reading_an } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResults_creates_a_complete_non_streaming_snapshot_within_the_caller_bound() { EngineTest.RequireNativeLua(); @@ -455,6 +474,7 @@ public void TryCopyResults_creates_a_complete_non_streaming_snapshot_within_the_ } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResults_keeps_an_empty_found_list_distinct_from_an_invalid_result() { EngineTest.RequireNativeLua(); @@ -478,6 +498,7 @@ public void TryCopyResults_keeps_an_empty_found_list_distinct_from_an_invalid_re } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResults_reports_a_malformed_row_without_publishing_a_partial_snapshot() { EngineTest.RequireNativeLua(); @@ -502,6 +523,7 @@ public void TryCopyResults_reports_a_malformed_row_without_publishing_a_partial_ } [Fact] + [Trait("Qualification", "Q29")] [SuppressMessage("xUnit.Analyzers", "xUnit1051", Justification = "The fixture must start with a deliberately cancelled token to prove no Lua row call begins.")] public void TryCopyResults_reports_a_preexisting_cancellation_without_reading_or_publishing_rows() @@ -531,6 +553,7 @@ public void TryCopyResults_reports_a_preexisting_cancellation_without_reading_or } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResults_cancels_after_get_address_without_reading_a_value_or_publishing_a_snapshot() { EngineTest.RequireNativeLua(); @@ -561,6 +584,7 @@ public void TryCopyResults_cancels_after_get_address_without_reading_a_value_or_ } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResultsPage_copies_only_the_requested_bounded_page_without_materializing_the_full_result_set() { EngineTest.RequireNativeLua(); @@ -583,6 +607,7 @@ public void TryCopyResultsPage_copies_only_the_requested_bounded_page_without_ma } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResultsPage_does_not_publish_a_prefix_when_a_page_row_is_invalid() { EngineTest.RequireNativeLua(); @@ -606,6 +631,7 @@ public void TryCopyResultsPage_does_not_publish_a_prefix_when_a_page_row_is_inva } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResultsPage_reports_no_results_without_touching_the_destination() { EngineTest.RequireNativeLua(); @@ -629,6 +655,7 @@ public void TryCopyResultsPage_reports_no_results_without_touching_the_destinati } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResultsPage_rejects_an_exact_end_start_without_reading_a_row() { EngineTest.RequireNativeLua(); @@ -652,6 +679,7 @@ public void TryCopyResultsPage_rejects_an_exact_end_start_without_reading_a_row( } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResultsPage_rejects_zero_capacity_without_reading_a_row() { EngineTest.RequireNativeLua(); @@ -672,6 +700,7 @@ public void TryCopyResultsPage_rejects_zero_capacity_without_reading_a_row() } [Fact] + [Trait("Qualification", "Q29")] public void TryCopyResultsPage_rejects_a_negative_start_before_reading_the_count() { EngineTest.RequireNativeLua(); @@ -694,6 +723,7 @@ public void TryCopyResultsPage_rejects_a_negative_start_before_reading_the_count } [Fact] + [Trait("Qualification", "Q29")] [SuppressMessage("xUnit.Analyzers", "xUnit1051", Justification = "The fixture starts with a deliberately cancelled token to prove no paged-row call begins.")] public void TryCopyResultsPage_does_not_publish_a_prefix_when_cancellation_precedes_the_page() @@ -722,6 +752,7 @@ public void TryCopyResultsPage_does_not_publish_a_prefix_when_cancellation_prece } [Fact] + [Trait("Qualification", "Q29")] [SuppressMessage("xUnit.Analyzers", "xUnit1051", Justification = "The fixture must start with a deliberately cancelled token to prove no CE scan call begins.")] public void A_cancellable_first_scan_honors_preexisting_cancellation_without_claiming_to_interrupt_CE() @@ -742,6 +773,7 @@ public void A_cancellable_first_scan_honors_preexisting_cancellation_without_cla } [Fact] + [Trait("Qualification", "Q29")] public void A_cancellable_wait_records_post_call_cancellation_without_falsely_claiming_to_interrupt_CE() { EngineTest.RequireNativeLua(); @@ -762,6 +794,7 @@ public void A_cancellable_wait_records_post_call_cancellation_without_falsely_cl } [Fact] + [Trait("Qualification", "Q26")] public void A_session_refuses_scan_work_when_the_original_target_is_no_longer_selected() { EngineTest.RequireNativeLua(); @@ -783,6 +816,7 @@ public void A_session_refuses_scan_work_when_the_original_target_is_no_longer_se } [Fact] + [Trait("Qualification", "Q26")] public void ReleaseWithOutcome_refuses_cleanup_on_a_replaced_target_and_consumes_both_owners() { EngineTest.RequireNativeLua(); @@ -837,8 +871,9 @@ private static MemoryScanSession CreateSession(LuaState state, bool firstScanRai private static void InstallCurrentTarget(LuaState state) { EngineTest.Run(state, Encoding.UTF8.GetBytes("opened_process_id = " + - Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + - "; function getOpenedProcessID() return opened_process_id end")); + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + + "; function getOpenedProcessID() return opened_process_id end")); + EngineTest.Run(state, FakeHost.LocalTargetBackendChunk); } private static string ScanInitializer(bool firstScanRaises, bool waitRaises) @@ -860,14 +895,14 @@ private static string FoundListInitializer(bool invalidAddress = false, string r string firstAddress = invalidAddress ? "'not-an-address'" : "'00001234'"; string destroyFailure = destroyRaises ? "; error('found-list destroy rejected')" : string.Empty; return "o.props.initialize = function() table.insert(trace, 'list.initialize') end\n" + - "o.props.deinitialize = function() table.insert(trace, 'list.deinitialize') end\n" + - "o.props.Count = " + resultCountLiteral + "\n" + - "o.props.getCount = function() table.insert(trace, 'results.getCount'); return o.props.Count end\n" + - "o.props.getAddress = function(index) table.insert(trace, 'results.getAddress:' .. index); if index == 0 then return " + - firstAddress + " end; return 'FFFFFFFFFFFFFFFF' end\n" + - "o.props.getValue = function(index) table.insert(trace, 'results.getValue:' .. index); return '100' end\n" + - "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'list.destroy')" + - destroyFailure + " end end"; + "o.props.deinitialize = function() table.insert(trace, 'list.deinitialize') end\n" + + "o.props.Count = " + resultCountLiteral + "\n" + + "o.props.getCount = function() table.insert(trace, 'results.getCount'); return o.props.Count end\n" + + "o.props.getAddress = function(index) table.insert(trace, 'results.getAddress:' .. index); if index == 0 then return " + + firstAddress + " end; return 'FFFFFFFFFFFFFFFF' end\n" + + "o.props.getValue = function(index) table.insert(trace, 'results.getValue:' .. index); return '100' end\n" + + "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'list.destroy')" + + destroyFailure + " end end"; } private static string ReadTrace(LuaState state) diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/ScanExperimentalApiTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/ScanExperimentalApiTests.cs new file mode 100644 index 00000000..a4e8bb45 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/ScanExperimentalApiTests.cs @@ -0,0 +1,90 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using CheatEngine.SDK.Engine.Scanning.Aob; + +namespace CheatEngine.SDK.Engine.Tests.Scanning; + +/// +/// The exact set of scanning members behind an [Experimental] gate, read by reflection from the compiled +/// Engine assembly: CESDK5010 covers every member whose correctness depends on the unobserved timed-out wait or on +/// terminateScan (spike D4.7), CESDK5011 covers the separately named first-found opt-in (F07); nothing else in +/// the scanning namespaces is gated. The no-deadline bounded scan stays ungated because the spike settled its +/// semantics. +/// +public sealed class ScanExperimentalApiTests +{ + private const string UrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md"; + + [Fact] + public void Scanning_experimental_members_are_exactly_the_documented_gates() + { + SortedSet expected = new(StringComparer.Ordinal) + { + "CESDK5010 AobScanner.TryScanWithinBounds(String, AobScanBounds, AobScanOptions, TimeSpan, Span
, CancellationToken)", + "CESDK5010 MemoryScanSession.TryTerminateScan(TimeSpan)", + "CESDK5010 MemoryScanSession.TryWaitForCompletion(TimeSpan)", + "CESDK5011 AobScanner.TryFindFirstFoundWithinBounds(String, AobScanBounds, AobScanOptions, CancellationToken)" + }; + + SortedSet actual = new(StringComparer.Ordinal); + foreach ((MemberInfo member, ExperimentalAttribute attribute) in ExperimentalScanningMembers()) + { + Assert.Equal(UrlFormat, attribute.UrlFormat); + actual.Add(attribute.DiagnosticId + " " + Describe(member)); + } + + Assert.Equal(expected, actual, StringComparer.Ordinal); + } + + private static IEnumerable<(MemberInfo Member, ExperimentalAttribute Attribute)> ExperimentalScanningMembers() + { + foreach (Type type in typeof(AobScanner).Assembly.GetExportedTypes()) + { + if (type.Namespace is null || + !type.Namespace.StartsWith("CheatEngine.SDK.Engine.Scanning", StringComparison.Ordinal)) + { + continue; + } + + if (type.GetCustomAttribute() is { } typeAttribute) + { + yield return (type, typeAttribute); + } + + foreach (MemberInfo member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + if (member.GetCustomAttribute() is { } attribute) + { + yield return (member, attribute); + } + } + } + } + + private static string Describe(MemberInfo member) + { + if (member is MethodBase method) + { + string parameters = string.Join(", ", method.GetParameters().Select(static parameter => + parameter.ParameterType.IsByRef + ? TypeName(parameter.ParameterType.GetElementType()!) + "&" + : TypeName(parameter.ParameterType))); + return method.DeclaringType!.Name + "." + method.Name + "(" + parameters + ")"; + } + + return member is Type type ? type.Name : member.DeclaringType!.Name + "." + member.Name; + } + + private static string TypeName(Type type) + { + if (!type.IsGenericType) + { + return type.Name; + } + + string name = type.Name[..type.Name.IndexOf('`', StringComparison.Ordinal)]; + return name + "<" + string.Join(", ", type.GetGenericArguments().Select(TypeName)) + ">"; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/ScanStatusContractTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/ScanStatusContractTests.cs new file mode 100644 index 00000000..6a74f926 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/ScanStatusContractTests.cs @@ -0,0 +1,203 @@ +using System.Globalization; + +using CheatEngine.SDK.Engine.Scanning.Aob; +using CheatEngine.SDK.Engine.Scanning.Values; + +namespace CheatEngine.SDK.Engine.Tests.Scanning; + +/// +/// Numeric contracts of the SDK-owned scan status and outcome enums: the zero value is Unknown, so a +/// default value never reads as success, and every member keeps the value these literals pin. The CE-mirroring +/// enums (VariableType, ScanOption, ...) are frozen elsewhere and are not listed here. +/// +public sealed class ScanStatusContractTests +{ + /// The AOB status and outcome enums, as (type, name, value). + public static TheoryData AobPins => new() + { + { typeof(AobScanStatus), "Unknown", 0 }, + { typeof(AobScanStatus), "Success", 1 }, + { typeof(AobScanStatus), "GlobalUnavailable", 2 }, + { typeof(AobScanStatus), "LuaFailure", 3 }, + { typeof(AobScanStatus), "NoResult", 4 }, + { typeof(AobScanStatus), "InvalidResult", 5 }, + { typeof(AobBoundedScanOutcomeKind), "Unknown", 0 }, + { typeof(AobBoundedScanOutcomeKind), "Matches", 1 }, + { typeof(AobBoundedScanOutcomeKind), "NoMatches", 2 }, + { typeof(AobBoundedScanOutcomeKind), "InvalidBounds", 3 }, + { typeof(AobBoundedScanOutcomeKind), "SessionCreationFailed", 4 }, + { typeof(AobBoundedScanOutcomeKind), "ScanFailed", 5 }, + { typeof(AobBoundedScanOutcomeKind), "WaitTimedOut", 6 }, + { typeof(AobBoundedScanOutcomeKind), "HostReportedError", 7 }, + { typeof(AobBoundedScanOutcomeKind), "InvalidResult", 8 }, + { typeof(AobBoundedScanOutcomeKind), "TargetChanged", 9 }, + { typeof(AobBoundedScanOutcomeKind), "TargetIdentityUnavailable", 10 }, + { typeof(AobBoundedScanOutcomeKind), "RuntimeInvalidated", 11 }, + { typeof(AobBoundedScanOutcomeKind), "Cancelled", 12 }, + { typeof(AobFirstFoundOutcomeKind), "Unknown", 0 }, + { typeof(AobFirstFoundOutcomeKind), "Found", 1 }, + { typeof(AobFirstFoundOutcomeKind), "NotFound", 2 }, + { typeof(AobFirstFoundOutcomeKind), "FoundOutsideBounds", 3 }, + { typeof(AobFirstFoundOutcomeKind), "InvalidBounds", 4 }, + { typeof(AobFirstFoundOutcomeKind), "SessionCreationFailed", 5 }, + { typeof(AobFirstFoundOutcomeKind), "ScanFailed", 6 }, + { typeof(AobFirstFoundOutcomeKind), "InvalidResult", 7 }, + { typeof(AobFirstFoundOutcomeKind), "TargetChanged", 8 }, + { typeof(AobFirstFoundOutcomeKind), "TargetIdentityUnavailable", 9 }, + { typeof(AobFirstFoundOutcomeKind), "RuntimeInvalidated", 10 }, + { typeof(AobFirstFoundOutcomeKind), "Cancelled", 11 } + }; + + /// The session creation and materialization enums, as (type, name, value). + public static TheoryData FactoryAndCopyPins => new() + { + { typeof(MemoryScanCreationStatus), "Unknown", 0 }, + { typeof(MemoryScanCreationStatus), "Success", 1 }, + { typeof(MemoryScanCreationStatus), "GlobalUnavailable", 2 }, + { typeof(MemoryScanCreationStatus), "LuaFailure", 3 }, + { typeof(MemoryScanCreationStatus), "NoScannerResult", 4 }, + { typeof(MemoryScanCreationStatus), "InvalidScannerResult", 5 }, + { typeof(MemoryScanCreationStatus), "NoFoundListResult", 6 }, + { typeof(MemoryScanCreationStatus), "InvalidFoundListResult", 7 }, + { typeof(MemoryScanCreationStatus), "AliasedFoundList", 8 }, + { typeof(MemoryScanCreationStatus), "RollbackUnconfirmed", 9 }, + { typeof(MemoryScanCreationStatus), "TargetIdentityUnavailable", 10 }, + { typeof(MemoryScanMaterializationStatus), "Unknown", 0 }, + { typeof(MemoryScanMaterializationStatus), "Success", 1 }, + { typeof(MemoryScanMaterializationStatus), "NoResults", 2 }, + { typeof(MemoryScanMaterializationStatus), "DestinationTooSmall", 3 }, + { typeof(MemoryScanMaterializationStatus), "Cancelled", 4 }, + { typeof(MemoryScanMaterializationStatus), "RuntimeInvalidated", 5 }, + { typeof(MemoryScanMaterializationStatus), "TargetIdentityUnavailable", 6 }, + { typeof(MemoryScanMaterializationStatus), "TargetIdentityMismatch", 7 }, + { typeof(MemoryScanMaterializationStatus), "LuaFailure", 8 }, + { typeof(MemoryScanMaterializationStatus), "InvalidResult", 9 }, + { typeof(MemoryScanMaterializationStatus), "PageStartOutOfRange", 10 } + }; + + /// The wait, termination and invalidation enums of a session, as (type, name, value). + public static TheoryData SessionPins => new() + { + { typeof(MemoryScanWaitStatus), "Unknown", 0 }, + { typeof(MemoryScanWaitStatus), "Completed", 1 }, + { typeof(MemoryScanWaitStatus), "TimedOut", 2 }, + { typeof(MemoryScanWaitStatus), "LuaFailure", 3 }, + { typeof(MemoryScanWaitStatus), "InvalidResult", 4 }, + { typeof(MemoryScanWaitStatus), "InitializationFailed", 5 }, + { typeof(MemoryScanWaitStatus), "RuntimeInvalidated", 6 }, + { typeof(MemoryScanWaitStatus), "TargetIdentityUnavailable", 7 }, + { typeof(MemoryScanWaitStatus), "TargetIdentityMismatch", 8 }, + { typeof(MemoryScanTerminationStatus), "Unknown", 0 }, + { typeof(MemoryScanTerminationStatus), "NotRequired", 1 }, + { typeof(MemoryScanTerminationStatus), "Confirmed", 2 }, + { typeof(MemoryScanTerminationStatus), "WaitTimedOut", 3 }, + { typeof(MemoryScanTerminationStatus), "TerminateFailed", 4 }, + { typeof(MemoryScanTerminationStatus), "WaitFailed", 5 }, + { typeof(MemoryScanTerminationStatus), "NotInvoked", 6 }, + { typeof(MemoryScanInvalidationReason), "None", 0 }, + { typeof(MemoryScanInvalidationReason), "ProtectedLuaFailure", 1 }, + { typeof(MemoryScanInvalidationReason), "RuntimeIdentityChanged", 2 }, + { typeof(MemoryScanInvalidationReason), "TargetChanged", 3 }, + { typeof(MemoryScanInvalidationReason), "TargetProcessReused", 4 }, + { typeof(MemoryScanInvalidationReason), "ScanTerminated", 5 } + }; + + [Fact] + public void AobScanStatus_default_is_unknown_and_never_success() + { + AobScanStatus status = default; + + Assert.Equal(AobScanStatus.Unknown, status); + Assert.NotEqual(AobScanStatus.Success, status); + Assert.Equal("Unknown", Enum.GetName(status)); + } + + [Fact] + public void MemoryScanCreationStatus_default_is_unknown_and_never_success() + { + MemoryScanCreationStatus status = default; + + Assert.Equal(MemoryScanCreationStatus.Unknown, status); + Assert.NotEqual(MemoryScanCreationStatus.Success, status); + Assert.Equal("Unknown", Enum.GetName(status)); + } + + [Fact] + public void MemoryScanMaterializationStatus_default_is_unknown_and_never_success() + { + MemoryScanMaterializationStatus status = default; + + Assert.Equal(MemoryScanMaterializationStatus.Unknown, status); + Assert.NotEqual(MemoryScanMaterializationStatus.Success, status); + Assert.Equal("Unknown", Enum.GetName(status)); + } + + [Fact] + public void MemoryScanWaitStatus_and_MemoryScanTerminationStatus_defaults_are_unknown_and_never_success() + { + MemoryScanWaitStatus wait = default; + MemoryScanTerminationStatus termination = default; + + Assert.Equal(MemoryScanWaitStatus.Unknown, wait); + Assert.NotEqual(MemoryScanWaitStatus.Completed, wait); + Assert.Equal(MemoryScanTerminationStatus.Unknown, termination); + Assert.NotEqual(MemoryScanTerminationStatus.Confirmed, termination); + Assert.NotEqual(MemoryScanTerminationStatus.NotRequired, termination); + } + + [Fact] + public void AobBoundedScanResult_default_is_unknown_and_never_success() + { + AobBoundedScanResult result = default; + + Assert.Equal(AobBoundedScanOutcomeKind.Unknown, result.Kind); + Assert.False(result.IsSuccess); + Assert.False(result.InBoundsCountIsExact); + Assert.Equal(MemoryScanTerminationStatus.Unknown, result.Termination); + Assert.Null(result.HostErrorText); + } + + [Fact] + public void MemoryScanCreationOutcome_default_status_is_unknown() + { + MemoryScanCreationOutcome outcome = default; + + Assert.Equal(MemoryScanCreationStatus.Unknown, outcome.Status); + Assert.False(outcome.TargetObservation.IsQualified); + } + + [Theory] + [MemberData(nameof(AobPins))] + [MemberData(nameof(FactoryAndCopyPins))] + [MemberData(nameof(SessionPins))] + public void Scan_status_enums_pin_their_numeric_values(Type enumType, string name, long value) + { + Assert.True(Enum.IsDefined(enumType, name), $"{enumType.Name}.{name} is not declared."); + + long actual = Convert.ToInt64(Enum.Parse(enumType, name), CultureInfo.InvariantCulture); + + Assert.Equal(value, actual); + } + + [Fact] + public void Scan_status_enums_declare_exactly_the_pinned_members() + { + Dictionary> pinned = []; + foreach (TheoryDataRow row in AobPins.Concat(FactoryAndCopyPins).Concat(SessionPins)) + { + (Type enumType, string name, _) = row.Data; + if (!pinned.TryGetValue(enumType, out SortedSet? names)) + { + names = new SortedSet(StringComparer.Ordinal); + pinned.Add(enumType, names); + } + + names.Add(name); + } + + foreach ((Type enumType, SortedSet names) in pinned) + { + Assert.Equal(names, Enum.GetNames(enumType).Order(StringComparer.Ordinal), StringComparer.Ordinal); + } + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/FirstScanRequestTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/FirstScanRequestTests.cs new file mode 100644 index 00000000..84057e43 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/FirstScanRequestTests.cs @@ -0,0 +1,124 @@ +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Scanning.Values; + +/// +/// The byte-array first-scan request: the exact fourteen CE positions of the spike's range scan (spike-c3 P4), the +/// refusal of an empty or inverted range before any CE call (D4.3), and the exact-integer push of 64-bit bounds. +/// +[Trait("Category", "NativeLua")] +public sealed class FirstScanRequestTests +{ + [Fact] + [Trait("Qualification", "Q28")] + public void FirstScanRequest_ByteArray_fills_the_fourteen_CE_positions_of_a_hexadecimal_byte_array_scan() + { + FirstScanRequest request = FirstScanRequest.ByteArray("55 48 89 E5", new Address(0x1_0000_0000), + new Address(0x1_0036_7000)); + FirstScanRequest aligned = FirstScanRequest.ByteArray("48 83 EC ??", new Address(0x40_0000), + new Address(0x70_8000), "+X", FastScanMethod.Aligned, "4"); + + Assert.Equal(ScanOption.ExactValue, request.ScanOption); + Assert.Equal(VariableType.ByteArray, request.VariableType); + Assert.Equal(RoundingType.Rounded, request.RoundingType); + Assert.Equal("55 48 89 E5", request.Input1); + Assert.Equal(string.Empty, request.Input2); + Assert.Equal(new Address(0x1_0000_0000), request.StartAddress); + Assert.Equal(new Address(0x1_0036_7000), request.StopAddress); + Assert.Equal(string.Empty, request.ProtectionFlags); + Assert.Equal(FastScanMethod.NotAligned, request.FastScanMethod); + Assert.Equal(string.Empty, request.AlignmentParameter); + Assert.True(request.IsHexadecimalInput); + Assert.False(request.IsNotBinaryString); + Assert.False(request.IsUnicodeScan); + Assert.False(request.IsCaseSensitive); + Assert.Equal("+X", aligned.ProtectionFlags); + Assert.Equal(FastScanMethod.Aligned, aligned.FastScanMethod); + Assert.Equal("4", aligned.AlignmentParameter); + Assert.Equal(VariableType.ByteArray, aligned.VariableType); + Assert.True(aligned.IsHexadecimalInput); + } + + [Theory] + [Trait("Qualification", "Q28")] + [InlineData(0x1000UL, 0x1000UL)] + [InlineData(0x2000UL, 0x1000UL)] + [InlineData(0UL, 0UL)] + [InlineData(ulong.MaxValue, 0UL)] + public void FirstScanRequest_ByteArray_refuses_an_empty_or_inverted_range(ulong start, ulong stop) + { + ArgumentOutOfRangeException failure = Assert.Throws(() => + FirstScanRequest.ByteArray("90", new Address(start), new Address(stop))); + + Assert.Equal("stopAddress", failure.ParamName); + Assert.Throws(() => FirstScanRequest.ByteArray("90", new Address(start), + new Address(stop), string.Empty, FastScanMethod.NotAligned, string.Empty)); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void FirstScanRequest_ByteArray_refuses_null_strings() + { + Address start = new(0x1000); + Address stop = new(0x2000); + + Assert.Equal("pattern", + Assert.Throws(() => FirstScanRequest.ByteArray(null!, start, stop)).ParamName); + Assert.Equal("pattern", Assert.Throws(() => + FirstScanRequest.ByteArray(null!, start, stop, string.Empty, FastScanMethod.NotAligned, + string.Empty)).ParamName); + Assert.Equal("protectionFlags", Assert.Throws(() => + FirstScanRequest.ByteArray("90", start, stop, null!, FastScanMethod.NotAligned, string.Empty)).ParamName); + Assert.Equal("alignmentParameter", Assert.Throws(() => + FirstScanRequest.ByteArray("90", start, stop, string.Empty, FastScanMethod.NotAligned, null!)).ParamName); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void MemoryScanSession_first_scan_pushes_addresses_above_4_GiB_and_above_2_pow_63_as_exact_integers() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = MemScanTestHost.CreateSession(L); + + session.StartFirstScan(FirstScanRequest.ByteArray("55 48 89 E5 00 C3", new Address(0x1_0000_0000), + new Address(0x8000_0000_0000_0000))); + + Assert.Equal("scan.first:14", MemScanTestHost.ReadTrace(L)); + MemScanTestHost.AssertLua(L, "first_scan_args.n == 14"); + MemScanTestHost.AssertLua(L, "first_scan_args[1] == 1 and first_scan_args[2] == 8 and first_scan_args[3] == 0"); + MemScanTestHost.AssertLua(L, "first_scan_args[4] == '55 48 89 E5 00 C3' and first_scan_args[5] == ''"); + MemScanTestHost.AssertLua(L, "first_scan_start_type == 'integer' and first_scan_args[6] == 0x100000000"); + MemScanTestHost.AssertLua(L, "first_scan_stop_type == 'integer' and first_scan_args[7] == math.mininteger"); + MemScanTestHost.AssertLua(L, + "first_scan_args[8] == '' and first_scan_args[9] == 0 and first_scan_args[10] == ''"); + MemScanTestHost.AssertLua(L, "first_scan_args[11] == true and first_scan_args[12] == false"); + MemScanTestHost.AssertLua(L, "first_scan_args[13] == false and first_scan_args[14] == false"); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q28")] + public void MemoryScanSession_first_scan_pushes_the_largest_exclusive_stop_as_minus_one() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = MemScanTestHost.CreateSession(L); + + session.StartFirstScan(FirstScanRequest.ByteArray("90", new Address(0xFFFF_FFFF_FFFF_FFFE), + new Address(0xFFFF_FFFF_FFFF_FFFF))); + + MemScanTestHost.AssertLua(L, "first_scan_start_type == 'integer' and first_scan_args[6] == -2"); + MemScanTestHost.AssertLua(L, "first_scan_stop_type == 'integer' and first_scan_args[7] == -1"); + Assert.Equal(0, L.Top); + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionBatteryTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionBatteryTests.cs new file mode 100644 index 00000000..2a45b2a7 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionBatteryTests.cs @@ -0,0 +1,318 @@ +using System.Globalization; +using System.Text; + +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Scanning.Values; + +/// +/// The rest of the chapter-13 qualification battery at fixture level (audit A13-20 to A13-33): target replacement +/// and process-identifier reuse during a wait, a found list already open, a missing saved result, a stale owner +/// after a runtime identity change, no progress or completion callback, error-text independence, a huge found list +/// read one page at a time, and a CE-reported scan error. These are C1 fixture contracts, not host qualification. +/// +[Trait("Category", "NativeLua")] +public sealed class MemoryScanSessionBatteryTests +{ + [Fact] + [Trait("Qualification", "Q26")] + [Trait("Qualification", "Q30.a")] + public void Wait_after_the_target_was_replaced_invalidates_as_target_changed_without_initializing_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "opened_process_id = " + MemScanTestHost.FindOtherQualifiedProcessId().ToString(CultureInfo.InvariantCulture)); + + MemoryScanException failure = Assert.Throws(session.WaitForCompletion); + + Assert.Equal(MemoryScanFailureKind.TargetIdentityMismatch, failure.FailureKind); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.TargetChanged, session.InvalidationReason); + Assert.Equal(TargetIdentityCheckKind.TargetChanged, session.LastTargetCheck!.Value.Kind); + Assert.Throws(() => session.Results); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + session.Abandon(); + } + + [Fact] + [Trait("Qualification", "Q26")] + [Trait("Qualification", "Q30.a")] + public void Wait_after_the_process_id_was_reused_invalidates_as_target_process_reused() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + TargetProcessIncarnation captured = session.TargetObservation.Incarnation!.Value; + MemScanTestHost.ReplaceCapturedTarget(session, + new TargetProcessIncarnation(captured.ProcessId, captured.StartedAtUtcTicks - 1)); + + MemoryScanException failure = Assert.Throws(session.WaitForCompletion); + + Assert.Equal(MemoryScanFailureKind.TargetIdentityMismatch, failure.FailureKind); + Assert.Equal(MemoryScanInvalidationReason.TargetProcessReused, session.InvalidationReason); + Assert.Equal(TargetIdentityCheckKind.ProcessReused, session.LastTargetCheck!.Value.Kind); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + MemoryScanReleaseOutcome release = session.ReleaseWithOutcome(); + Assert.Equal(TargetReleaseStatus.RefusedProcessReused, release.FoundList.Status); + Assert.Equal(MemoryScanTerminationStatus.NotInvoked, release.Termination); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Waiting_again_after_results_are_ready_is_refused_without_reinitializing_the_found_list() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + session.WaitForCompletion(); + MemScanTestHost.ClearTrace(L); + + MemoryScanStateException refused = Assert.Throws(session.WaitForCompletion); + + Assert.Equal(MemoryScanState.ResultsReady, refused.State); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Next_scan_with_a_missing_saved_result_is_refused_and_invalidates_without_readable_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + session.WaitForCompletion(); + MemScanTestHost.Run(L, "scan_saved_results = { baseline = true }"); + MemScanTestHost.ClearTrace(L); + NextScanRequest missing = new(ScanOption.ExactValue, RoundingType.Rounded, "90", string.Empty, false, false, + false, false, false, "gone"); + + MemoryScanException failure = Assert.Throws(() => session.StartNextScan(missing)); + + Assert.Equal(MemoryScanFailureKind.LuaError, failure.FailureKind); + Assert.Equal("MemoryScan.NextScan", failure.Operation); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Throws(() => session.Results); + Assert.Equal("list.deinitialize,scan.next:10", MemScanTestHost.ReadTrace(L)); + + MemScanTestHost.ClearTrace(L); + session.Reset(); + + Assert.Equal(MemoryScanState.New, session.State); + Assert.Equal("list.deinitialize,scan.new", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + /// + /// A detach and re-attach changes the runtime's attach epoch, which the session compares. A generation-only reset + /// (a new Lua state under the same attachment) is not reachable from this test project: CheatEngine.SDK.Lua + /// grants no InternalsVisibleTo to it, so that path stays a C3 scenario. + /// + [Fact] + [Trait("Qualification", "Q26")] + public void Runtime_identity_change_during_results_ready_refuses_the_owner_and_releases_without_CE_calls() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemScanTestHost.HostObjects objects = MemScanTestHost.Install(L); + Assert.Equal(MemoryScanCreationStatus.Success, + MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? created).Status); + MemoryScanSession session = Assert.IsType(created); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + LuaRuntime.Detach(); + LuaRuntime.Attach(scope.Binding); + MemScanTestHost.ClearTrace(L); + + MemoryScanMaterializationStatus copied = session.TryCopyResults(new MemoryScanResult[4], out _, out _); + MemoryScanReleaseOutcome release = session.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanMaterializationStatus.RuntimeInvalidated, copied); + Assert.Equal(MemoryScanInvalidationReason.RuntimeIdentityChanged, session.InvalidationReason); + Assert.Equal(TargetReleaseStatus.NotInvoked, release.FoundList.Status); + Assert.Equal(EngineFailureKind.BindingFailure, release.FoundList.FailureKind); + Assert.Equal(TargetReleaseStatus.NotInvoked, release.MemScan.Status); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, release.Termination); + Assert.False(FakeHost.IsDestroyed(L, objects.FoundList)); + Assert.False(FakeHost.IsDestroyed(L, objects.Scanner)); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Scan_sessions_never_install_progress_or_completion_callbacks() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + _ = MemScanTestHost.Install(L); + Assert.Equal(MemoryScanCreationStatus.Success, + MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? created).Status); + MemoryScanSession session = Assert.IsType(created); + + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + session.StartNextScan(NextScanRequest.ExactValue("90")); + session.WaitForCompletion(); + session.Reset(); + session.StartFirstScan(FirstScanRequest.ByteArray("90", new Address(0x1000), new Address(0x2000))); + session.Dispose(); + + string trace = MemScanTestHost.ReadTrace(L); + Assert.DoesNotContain("scan.set.", trace, StringComparison.Ordinal); + Assert.DoesNotContain("setOnlyOneResult", trace, StringComparison.Ordinal); + Assert.Equal(0L, MemScanTestHost.ReadInteger(L, "create_mem_scan_argument_count")); + Assert.Equal(1L, MemScanTestHost.ReadInteger(L, "create_found_list_argument_count")); + Assert.EndsWith("scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy", trace, StringComparison.Ordinal); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q26")] + [InlineData("'firstScan failed'")] + [InlineData("'Échec de l’analyse — mémoire illisible'")] + [InlineData("''")] + [InlineData("{ code = 1 }")] + [InlineData("nil")] + [InlineData("42")] + public void First_scan_failure_category_does_not_depend_on_the_lua_error_text(string payload) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = MemScanTestHost.CreateSession(L); + EngineTest.Run(L, Encoding.UTF8.GetBytes("scan_first_raises = true; scan_first_error_payload = " + payload)); + + MemoryScanException failure = Assert.Throws(() => + session.StartFirstScan(FirstScanRequest.ByteArray("90", new Address(0x1000), new Address(0x2000)))); + + Assert.Equal(MemoryScanFailureKind.LuaError, failure.FailureKind); + Assert.Equal("MemoryScan.FirstScan", failure.Operation); + Assert.Equal("The protected Lua call for memory scan operation 'MemoryScan.FirstScan' failed.", failure.Message); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.ProtectedLuaFailure, session.InvalidationReason); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryCopyResultsPage_on_a_huge_found_list_reads_only_the_page_rows() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = HugeResults(L); + MemoryScanResult[] page = new MemoryScanResult[4]; + + MemoryScanMaterializationStatus status = session.TryCopyResultsPage(0, page, out ulong total, out int written); + + Assert.Equal(MemoryScanMaterializationStatus.Success, status); + Assert.Equal(3_000_000_000UL, total); + Assert.Equal(4, written); + Assert.Equal(new MemoryScanResult(new Address(0x10003), "100"), page[3]); + Assert.Equal( + "results.getCount,results.getAddress:0,results.getValue:0,results.getAddress:1,results.getValue:1," + + "results.getAddress:2,results.getValue:2,results.getAddress:3,results.getValue:3", + MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryCopyResultsPage_allocations_are_bounded_by_the_page_not_the_found_list() + { + const long Bound = 16 * 1024; + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = HugeResults(L); + MemoryScanResult[] page = new MemoryScanResult[4]; + for (int warmUp = 0; warmUp < 8; warmUp++) + { + _ = session.TryCopyResultsPage(warmUp, page, out _, out _); + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + MemoryScanMaterializationStatus status = session.TryCopyResultsPage(1_000_000, page, out _, out int written); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(MemoryScanMaterializationStatus.Success, status); + Assert.Equal(4, written); + Assert.True(allocated < Bound, + $"Copying a four-row page of a three-billion-row found list allocated {allocated} bytes (bound {Bound})."); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Unreadable_memory_reported_by_CE_is_a_bounded_host_error_fact() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_error_string = 'No readable memory found'"); + session.WaitForCompletion(); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained]; + + MemoryScanMaterializationStatus copied = session.TryCopyResults(destination, out ulong total, out int written); + bool read = session.TryGetHostErrorText(out string? text, out bool truncated); + + Assert.Equal(MemoryScanMaterializationStatus.NoResults, copied); + Assert.Equal(0UL, total); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.True(read); + Assert.Equal("No readable memory found", text); + Assert.False(truncated); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(0, L.Top); + } + + private static MemoryScanSession StartScanning(LuaState state) + { + MemoryScanSession session = MemScanTestHost.CreateSession(state); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + MemScanTestHost.ClearTrace(state); + return session; + } + + private static MemoryScanSession HugeResults(LuaState state) + { + MemoryScanSession session = StartScanning(state); + MemScanTestHost.Run(state, "found_count = 3000000000"); + session.WaitForCompletion(); + MemScanTestHost.ClearTrace(state); + return session; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionDeadlineTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionDeadlineTests.cs new file mode 100644 index 00000000..d34add8b --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionDeadlineTests.cs @@ -0,0 +1,639 @@ +using System.Globalization; +using System.Text; + +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +// These tests exercise the CESDK5010 experimental members on purpose; the opt-in is scoped to this file. +#pragma warning disable CESDK5010 + +namespace CheatEngine.SDK.Engine.Tests.Scanning.Values; + +/// +/// The deadline, cooperative termination and host error text primitives of a scan session (spike D4.7, audit +/// A13-26, A13-28, Q29): CE's waitTillDone(timeout) with one integer argument and one boolean result, +/// terminateScan(false) followed by one bounded wait, and a bounded, unparsed copy of ErrorString. +/// +[Trait("Category", "NativeLua")] +public sealed class MemoryScanSessionDeadlineTests +{ + private static readonly TimeSpan Deadline = TimeSpan.FromMilliseconds(250); + + [Fact] + [Trait("Qualification", "Q29")] + public void TryWaitForCompletion_true_initializes_the_found_list_and_reports_completed() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + + MemoryScanWaitStatus status = session.TryWaitForCompletion(Deadline); + + Assert.Equal(MemoryScanWaitStatus.Completed, status); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(MemoryScanInvalidationReason.None, session.InvalidationReason); + Assert.Equal("scan.wait:250,list.initialize", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryWaitForCompletion_false_keeps_scanning_without_initializing_and_reports_timed_out() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = 'false'"); + + MemoryScanWaitStatus status = session.TryWaitForCompletion(Deadline); + + Assert.Equal(MemoryScanWaitStatus.TimedOut, status); + Assert.Equal(MemoryScanState.Scanning, session.State); + Assert.Equal(MemoryScanInvalidationReason.None, session.InvalidationReason); + Assert.Throws(() => session.Results); + Assert.Equal("scan.wait:250", MemScanTestHost.ReadTrace(L)); + + MemScanTestHost.Run(L, "scan_wait_mode = 'true'"); + Assert.Equal(MemoryScanWaitStatus.Completed, session.TryWaitForCompletion(Deadline)); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryWaitForCompletion_passes_one_integer_millisecond_argument_and_reads_one_result() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = 'false'"); + + Assert.Equal(MemoryScanWaitStatus.TimedOut, session.TryWaitForCompletion(TimeSpan.FromSeconds(1.5))); + MemScanTestHost.AssertLua(L, "wait_argument_count == 1"); + MemScanTestHost.AssertLua(L, "wait_argument_type == 'integer' and wait_argument == 1500"); + + using FakeHost.PCallProbe probe = + FakeHost.ReplaceWaitTillDoneWithPCallProbe(L, session.Scanner.Handle); + MemoryScanWaitStatus probed = session.TryWaitForCompletion(TimeSpan.FromSeconds(1.5)); + + Assert.Equal(1, probe.WaitCallCount); + Assert.Equal(1, probe.WaitArgumentCount); + Assert.Equal(1, probe.WaitResultCount); + Assert.Equal(MemoryScanWaitStatus.InvalidResult, probed); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(0L)] + [InlineData(-10_000L)] + [InlineData(-1L)] + [InlineData((int.MaxValue + 1L) * TimeSpan.TicksPerMillisecond)] + [InlineData(long.MaxValue)] + public void TryWaitForCompletion_validates_timeouts_before_any_CE_call(long ticks) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + // -10 000 ticks is Timeout.InfiniteTimeSpan; long.MaxValue ticks is TimeSpan.MaxValue. + TimeSpan timeout = TimeSpan.FromTicks(ticks); + + ArgumentOutOfRangeException failure = + Assert.Throws(() => session.TryWaitForCompletion(timeout)); + + Assert.Equal("timeout", failure.ParamName); + Assert.Equal(MemoryScanState.Scanning, session.State); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(4_000L, 1L)] + [InlineData(10_001L, 2L)] + [InlineData(int.MaxValue * TimeSpan.TicksPerMillisecond, int.MaxValue)] + public void TryWaitForCompletion_rounds_a_partial_millisecond_up_and_accepts_int_max_milliseconds(long ticks, + long expectedMilliseconds) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = 'false'"); + + Assert.Equal(MemoryScanWaitStatus.TimedOut, session.TryWaitForCompletion(TimeSpan.FromTicks(ticks))); + + Assert.Equal(expectedMilliseconds, MemScanTestHost.ReadInteger(L, "wait_argument")); + MemScanTestHost.AssertLua(L, "wait_argument_type == 'integer'"); + } + + [Theory] + [Trait("Qualification", "Q29")] + [InlineData("nil")] + [InlineData("none")] + [InlineData("number")] + [InlineData("string")] + public void TryWaitForCompletion_non_boolean_result_is_invalid_result_and_invalidates(string mode) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = '" + mode + "'"); + + MemoryScanWaitStatus status = session.TryWaitForCompletion(Deadline); + + Assert.Equal(MemoryScanWaitStatus.InvalidResult, status); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.ProtectedLuaFailure, session.InvalidationReason); + Assert.Equal("scan.wait:250", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryWaitForCompletion_lua_error_is_lua_failure_and_invalidates() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = 'raise'"); + + MemoryScanWaitStatus status = session.TryWaitForCompletion(Deadline); + + Assert.Equal(MemoryScanWaitStatus.LuaFailure, status); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.ProtectedLuaFailure, session.InvalidationReason); + Assert.Equal("scan.wait:250", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void TryWaitForCompletion_initialize_failure_after_completion_is_initialization_failed_and_never_exposes_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "list_initialize_raises = true"); + + MemoryScanWaitStatus status = session.TryWaitForCompletion(Deadline); + + Assert.Equal(MemoryScanWaitStatus.InitializationFailed, status); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Throws(() => session.Results); + Assert.Throws(() => session.TryCopyResults(new MemoryScanResult[4], out _, out _)); + Assert.Equal("scan.wait:250,list.initialize", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void TryWaitForCompletion_after_a_target_replacement_reports_the_mismatch_without_calling_CE() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "opened_process_id = " + MemScanTestHost.FindOtherQualifiedProcessId().ToString(CultureInfo.InvariantCulture)); + + MemoryScanWaitStatus status = session.TryWaitForCompletion(Deadline); + + Assert.Equal(MemoryScanWaitStatus.TargetIdentityMismatch, status); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.TargetChanged, session.InvalidationReason); + Assert.Equal(TargetIdentityCheckKind.TargetChanged, session.LastTargetCheck!.Value.Kind); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + session.Abandon(); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryTerminateScan_requests_cooperative_termination_then_waits_and_invalidates_as_scan_terminated() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + + MemoryScanTerminationStatus status = session.TryTerminateScan(TimeSpan.FromSeconds(2)); + + Assert.Equal(MemoryScanTerminationStatus.Confirmed, status); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.ScanTerminated, session.InvalidationReason); + Assert.Throws(() => session.Results); + Assert.Equal("scan.terminate:false,scan.wait:2000", MemScanTestHost.ReadTrace(L)); + + MemScanTestHost.ClearTrace(L); + session.Reset(); + + Assert.Equal(MemoryScanState.New, session.State); + Assert.Equal(MemoryScanInvalidationReason.None, session.InvalidationReason); + Assert.Equal("list.deinitialize,scan.new", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryTerminateScan_passes_an_explicit_false_force_argument() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + + Assert.Equal(MemoryScanTerminationStatus.Confirmed, session.TryTerminateScan(TimeSpan.FromMilliseconds(40))); + + MemScanTestHost.AssertLua(L, "terminate_argument_count == 1"); + MemScanTestHost.AssertLua(L, "type(terminate_argument) == 'boolean' and terminate_argument == false"); + MemScanTestHost.AssertLua(L, + "wait_argument_count == 1 and wait_argument_type == 'integer' and wait_argument == 40"); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryTerminateScan_failed_terminate_skips_the_wait_and_reports_terminate_failed() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_terminate_raises = true"); + + MemoryScanTerminationStatus status = session.TryTerminateScan(Deadline); + + Assert.Equal(MemoryScanTerminationStatus.TerminateFailed, status); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.ScanTerminated, session.InvalidationReason); + Assert.Equal("scan.terminate:false", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q29")] + [InlineData("false", MemoryScanTerminationStatus.WaitTimedOut)] + [InlineData("raise", MemoryScanTerminationStatus.WaitFailed)] + [InlineData("nil", MemoryScanTerminationStatus.WaitFailed)] + [InlineData("number", MemoryScanTerminationStatus.WaitFailed)] + public void TryTerminateScan_unconfirmed_wait_blocks_reset_but_allows_release(string waitMode, + MemoryScanTerminationStatus expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = '" + waitMode + "'"); + + MemoryScanTerminationStatus status = session.TryTerminateScan(Deadline); + + Assert.Equal(expected, status); + MemoryScanStateException refused = Assert.Throws(session.Reset); + Assert.Equal("Reset", refused.Operation); + Assert.Throws(() => session.TryTerminateScan(Deadline)); + Assert.Equal("scan.terminate:false,scan.wait:250", MemScanTestHost.ReadTrace(L)); + + MemScanTestHost.ClearTrace(L); + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(expected, outcome.Termination); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.Equal("list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryTerminateScan_unconfirmed_stop_is_kept_by_a_refused_release_without_any_CE_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = 'false'"); + Assert.Equal(MemoryScanTerminationStatus.WaitTimedOut, session.TryTerminateScan(Deadline)); + MemScanTestHost.Run(L, "opened_process_id = " + + MemScanTestHost.FindOtherQualifiedProcessId().ToString(CultureInfo.InvariantCulture)); + MemScanTestHost.ClearTrace(L); + + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + // The release is refused on the replaced target, and it reports the stop that was requested, not NotInvoked. + Assert.Equal(MemoryScanTerminationStatus.WaitTimedOut, outcome.Termination); + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, outcome.MemScan.Status); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryTerminateScan_without_a_started_scan_is_a_state_error_without_a_CE_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = MemScanTestHost.CreateSession(L); + + MemoryScanStateException beforeScan = + Assert.Throws(() => session.TryTerminateScan(Deadline)); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + MemScanTestHost.ClearTrace(L); + MemoryScanStateException afterCompletion = + Assert.Throws(() => session.TryTerminateScan(Deadline)); + + Assert.Equal(MemoryScanState.New, beforeScan.State); + Assert.Equal(MemoryScanState.ResultsReady, afterCompletion.State); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryTerminateScan_with_a_replaced_target_is_not_invoked_and_makes_no_scanner_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "opened_process_id = " + MemScanTestHost.FindOtherQualifiedProcessId().ToString(CultureInfo.InvariantCulture)); + + MemoryScanTerminationStatus status = session.TryTerminateScan(Deadline); + + Assert.Equal(MemoryScanTerminationStatus.NotInvoked, status); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.TargetChanged, session.InvalidationReason); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + session.Abandon(); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryGetHostErrorText_copies_at_most_the_bound_at_a_utf8_boundary_and_reports_truncation() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + string full = "a" + string.Concat(Enumerable.Repeat("é€😀", 200)); + MemScanTestHost.Run(L, "scan_error_string = '" + full + "'"); + + Assert.True(session.TryGetHostErrorText(out string? text, out bool truncated)); + + Assert.True(truncated); + Assert.StartsWith(text, full, StringComparison.Ordinal); + Assert.True(Encoding.UTF8.GetByteCount(text) <= MemoryScanSession.HostErrorTextMaximumUtf8Bytes); + Assert.True(Encoding.UTF8.GetByteCount(text) > MemoryScanSession.HostErrorTextMaximumUtf8Bytes - 4); + Assert.DoesNotContain('�', text); + Assert.Equal(MemoryScanState.Scanning, session.State); + + MemScanTestHost.Run(L, "scan_error_string = 'short'"); + Assert.True(session.TryGetHostErrorText(out string? shortText, out bool shortTruncated)); + Assert.Equal("short", shortText); + Assert.False(shortTruncated); + Assert.Equal("scan.ErrorString,scan.ErrorString", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q29")] + [InlineData(new byte[] { 0x41, 0xC3, 0xA9, 0x42 }, 2, "A")] + [InlineData(new byte[] { 0x41, 0xE2, 0x82, 0xAC, 0x42 }, 3, "A")] + [InlineData(new byte[] { 0x41, 0xF0, 0x9F, 0x98, 0x80, 0x42 }, 4, "A")] + [InlineData(new byte[] { 0x41, 0xF0, 0x9F, 0x98, 0x80, 0x42 }, 5, "A😀")] + [InlineData(new byte[] { 0x80, 0x80, 0x80, 0x80, 0x80, 0x80 }, 4, "����")] + [InlineData(new byte[] { 0x41, 0x00, 0x42, 0x43 }, 3, "A\0B")] + public void TryGetHostErrorText_cuts_back_to_a_utf8_sequence_start_or_at_the_bound_for_malformed_text( + byte[] utf8, int bound, string expected) + { + string text = MemoryScanSession.DecodeBoundedUtf8(utf8, bound, out bool truncated); + + Assert.Equal(expected, text); + Assert.True(truncated); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryGetHostErrorText_keeps_embedded_nul_and_non_ascii_text_exact() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_error_string = 'A\\0B — Échec de lecture'"); + + Assert.True(session.TryGetHostErrorText(out string? text, out bool truncated)); + + Assert.Equal("A\0B — Échec de lecture", text); + Assert.False(truncated); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q29")] + [InlineData("nil")] + [InlineData("number")] + [InlineData("raise")] + public void TryGetHostErrorText_non_string_value_is_unreadable_and_keeps_the_session_state(string mode) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_error_mode = '" + mode + "'"); + + bool read = session.TryGetHostErrorText(out string? text, out bool truncated); + + Assert.False(read); + Assert.Null(text); + Assert.False(truncated); + Assert.Equal(MemoryScanState.Scanning, session.State); + Assert.Equal(MemoryScanInvalidationReason.None, session.InvalidationReason); + Assert.Equal("scan.ErrorString", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void TryGetHostErrorText_with_a_replaced_target_returns_false_without_a_scanner_call_and_invalidates() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_error_string = 'unread'; opened_process_id = " + + MemScanTestHost.FindOtherQualifiedProcessId().ToString(CultureInfo.InvariantCulture)); + + bool read = session.TryGetHostErrorText(out string? text, out bool truncated); + + // Only the target check reached CE (getOpenedProcessID); the scanner's ErrorString was never read. + Assert.False(read); + Assert.Null(text); + Assert.False(truncated); + Assert.Equal(MemoryScanState.Invalidated, session.State); + Assert.Equal(MemoryScanInvalidationReason.TargetChanged, session.InvalidationReason); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + session.Abandon(); + } + + [Fact] + [Trait("Qualification", "Q29")] + public void A_disposed_session_refuses_the_deadline_termination_and_error_text_members_without_a_CE_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + session.Dispose(); + MemScanTestHost.ClearTrace(L); + + Assert.Throws(() => session.TryWaitForCompletion(Deadline)); + Assert.Throws(() => session.TryTerminateScan(Deadline)); + Assert.Throws(() => session.TryGetHostErrorText(out _, out _)); + + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void TryWaitForCompletion_disposed_from_inside_the_wait_releases_once_after_it_returned() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemoryScanReleaseOutcome? inner = null; + using FakeHost.ManagedHookScope hook = FakeHost.InstallManagedHook(L, () => inner = session.ReleaseWithOutcome()); + InstallWaitHook(L); + + Assert.Throws(() => session.TryWaitForCompletion(Deadline)); + + Assert.Null(hook.Failure); + Assert.Equal(default(MemoryScanReleaseOutcome), inner); + Assert.Equal("scan.wait:250,hook.returned,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + MemoryScanReleaseOutcome outcome = session.LastReleaseOutcome; + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, outcome.Termination); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void TryWaitForCompletion_disposed_from_inside_a_timed_out_wait_stops_the_scan_once_after_it_returned() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + List inner = []; + using FakeHost.ManagedHookScope hook = FakeHost.InstallManagedHook(L, () => inner.Add(session.ReleaseWithOutcome())); + InstallWaitHook(L); + MemScanTestHost.Run(L, "scan_wait_modes = { 'false', 'true' }"); + + Assert.Throws(() => session.TryWaitForCompletion(Deadline)); + + // The deferred release stops the still-running scan once; its own settle wait runs the hook again, and that + // second re-entrant release starts nothing either. + Assert.Null(hook.Failure); + Assert.Equal(2, hook.CallCount); + Assert.Equal([default, default], inner); + Assert.Equal( + "scan.wait:250,hook.returned,scan.terminate:false,scan.wait:5000,hook.returned,list.destroy,scan.destroy", + MemScanTestHost.ReadTrace(L)); + MemoryScanReleaseOutcome outcome = session.LastReleaseOutcome; + Assert.Equal(MemoryScanTerminationStatus.Confirmed, outcome.Termination); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q26")] + [InlineData("true", MemoryScanTerminationStatus.NotRequired)] + [InlineData("false", MemoryScanTerminationStatus.WaitTimedOut)] + public void TryTerminateScan_disposed_from_inside_the_settle_wait_releases_once_without_a_second_stop( + string waitMode, MemoryScanTerminationStatus expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + using FakeHost.ManagedHookScope hook = FakeHost.InstallManagedHook(L, session.Dispose); + InstallWaitHook(L); + MemScanTestHost.Run(L, "scan_wait_mode = '" + waitMode + "'"); + + Assert.Throws(() => session.TryTerminateScan(Deadline)); + + // A confirmed stop ended the scan (nothing left to stop); an unconfirmed one is reported, never repeated. + Assert.Null(hook.Failure); + Assert.Equal(1, hook.CallCount); + Assert.Equal("scan.terminate:false,scan.wait:250,hook.returned,list.destroy,scan.destroy", + MemScanTestHost.ReadTrace(L)); + MemoryScanReleaseOutcome outcome = session.LastReleaseOutcome; + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(expected, outcome.Termination); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.Equal(0, L.Top); + } + + // Every wait runs the managed hook (CE pumping queued main-thread work), then records that the hook returned. + private static void InstallWaitHook(LuaState state) + { + MemScanTestHost.Run(state, "scan_wait_hook = function() managed_hook(); table.insert(trace, 'hook.returned') end"); + } + + private static MemoryScanSession StartScanning(LuaState state) + { + MemoryScanSession session = MemScanTestHost.CreateSession(state); + session.StartFirstScan(FirstScanRequest.ByteArray("90 90", new Address(0x1000), new Address(0x2000))); + MemScanTestHost.ClearTrace(state); + return session; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionReleaseTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionReleaseTests.cs new file mode 100644 index 00000000..d474aafb --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/Values/MemoryScanSessionReleaseTests.cs @@ -0,0 +1,438 @@ +using System.Globalization; + +using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Errors; +using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; +using CheatEngine.SDK.Engine.Tests.Support; +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Engine.Tests.Scanning.Values; + +/// +/// Releasing a session whose scan may still be running (audit A13-26, A13-25, A18-26, Q26): one cooperative +/// terminateScan(false), one bounded waitTillDone(5000), then the found list and the scanner destroyed +/// once each, child before parent, even when the stop is not confirmed; no CE call when cleanup cannot safely begin. +/// +[Trait("Category", "NativeLua")] +public sealed class MemoryScanSessionReleaseTests +{ + [Fact] + [Trait("Qualification", "Q26")] + public void Dispose_while_scanning_terminates_waits_then_destroys_child_before_parent_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + + session.Dispose(); + session.Dispose(); + + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal("scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void ReleaseWithOutcome_while_scanning_reports_a_confirmed_termination() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanTerminationStatus.Confirmed, outcome.Termination); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.True(outcome.OwnershipConsumed); + MemScanTestHost.AssertLua(L, "terminate_argument_count == 1 and terminate_argument == false"); + MemScanTestHost.AssertLua(L, "wait_argument_count == 1 and wait_argument_type == 'integer'"); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void ReleaseWithOutcome_while_scanning_with_a_failing_terminate_is_unconfirmed_and_still_destroys_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_terminate_raises = true"); + + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanTerminationStatus.TerminateFailed, outcome.Termination); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.Equal("scan.terminate:false,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q26")] + [InlineData("false", MemoryScanTerminationStatus.WaitTimedOut)] + [InlineData("raise", MemoryScanTerminationStatus.WaitFailed)] + [InlineData("none", MemoryScanTerminationStatus.WaitFailed)] + public void ReleaseWithOutcome_while_scanning_with_an_expired_settle_wait_is_unconfirmed_and_still_destroys_once( + string waitMode, MemoryScanTerminationStatus expected) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = '" + waitMode + "'"); + + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + Assert.Equal(expected, outcome.Termination); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.Equal("scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + Assert.Equal(1L, MemScanTestHost.ReadInteger(L, "wait_calls")); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void ReleaseWithOutcome_after_a_failed_wait_terminates_because_the_scan_may_still_run() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_modes = { 'raise', 'true' }"); + Assert.Throws(session.WaitForCompletion); + Assert.Equal(MemoryScanState.Invalidated, session.State); + MemScanTestHost.ClearTrace(L); + + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanTerminationStatus.Confirmed, outcome.Termination); + Assert.Equal("scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void ReleaseWithOutcome_after_a_failed_first_scan_call_terminates_because_ce_may_have_started() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = MemScanTestHost.CreateSession(L); + MemScanTestHost.Run(L, "scan_first_raises = true; scan_first_error_payload = 'first scan rejected'"); + Assert.Throws(() => + session.StartFirstScan(FirstScanRequest.ByteArray("90", new Address(0x1000), new Address(0x2000)))); + MemScanTestHost.ClearTrace(L); + + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanTerminationStatus.Confirmed, outcome.Termination); + Assert.Equal("scan.terminate:false,scan.wait:5000,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void ReleaseWithOutcome_in_new_or_results_ready_never_calls_terminate_scan() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession fresh = MemScanTestHost.CreateSession(L); + + MemoryScanReleaseOutcome newOutcome = fresh.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanTerminationStatus.NotRequired, newOutcome.Termination); + Assert.Equal("list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + + MemoryScanSession completed = MemScanTestHost.CreateSession(L); + completed.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + completed.WaitForCompletion(); + MemScanTestHost.ClearTrace(L); + + MemoryScanReleaseOutcome readyOutcome = completed.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanTerminationStatus.NotRequired, readyOutcome.Termination); + Assert.Equal("list.deinitialize,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Second_release_while_scanning_returns_the_same_outcome_without_any_CE_call() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "scan_wait_mode = 'false'"); + MemoryScanReleaseOutcome first = session.ReleaseWithOutcome(); + MemScanTestHost.ClearTrace(L); + + MemoryScanReleaseOutcome second = session.ReleaseWithOutcome(); + session.Dispose(); + + Assert.Equal(first, second); + Assert.Equal(MemoryScanTerminationStatus.WaitTimedOut, second.Termination); + Assert.Equal(first, session.LastReleaseOutcome); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Detach_during_scanning_consumes_both_owners_without_CE_calls_and_reports_not_invoked() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemScanTestHost.HostObjects objects = MemScanTestHost.Install(L); + Assert.Equal(MemoryScanCreationStatus.Success, + MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? created).Status); + MemoryScanSession session = Assert.IsType(created); + session.StartFirstScan(FirstScanRequest.ByteArray("90", new Address(0x1000), new Address(0x2000))); + MemScanTestHost.ClearTrace(L); + + LuaRuntime.Detach(); + try + { + session.Dispose(); + } + finally + { + LuaRuntime.Attach(scope.Binding); + } + + MemoryScanReleaseOutcome outcome = session.LastReleaseOutcome; + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(MemoryScanTerminationStatus.NotInvoked, outcome.Termination); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.MemScan.Status); + Assert.True(outcome.OwnershipConsumed); + Assert.False(FakeHost.IsDestroyed(L, objects.FoundList)); + Assert.False(FakeHost.IsDestroyed(L, objects.Scanner)); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Release_on_a_worker_while_scanning_never_calls_CE() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + + Exception? failure = EngineTest.RunOnWorker(session.Dispose); + + Assert.Null(failure); + MemoryScanReleaseOutcome outcome = session.LastReleaseOutcome; + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(MemoryScanTerminationStatus.NotInvoked, outcome.Termination); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.FoundList.Status); + Assert.Equal(EngineFailureKind.BindingFailure, outcome.FoundList.FailureKind); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Release_on_a_replaced_target_while_scanning_refuses_every_CE_call_and_reports_not_invoked() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemScanTestHost.Run(L, "opened_process_id = " + MemScanTestHost.FindOtherQualifiedProcessId().ToString(CultureInfo.InvariantCulture)); + + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanTerminationStatus.NotInvoked, outcome.Termination); + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.RefusedTargetChanged, outcome.MemScan.Status); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Dispose_from_inside_the_release_settle_wait_makes_no_CE_call_and_the_outer_release_destroys_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemoryScanReleaseOutcome? inner = null; + using FakeHost.ManagedHookScope hook = FakeHost.InstallManagedHook(L, () => + { + // CE's settle wait ran queued main-thread work that releases the same session again. + inner = session.ReleaseWithOutcome(); + session.Dispose(); + session.Abandon(); + }); + InstallWaitHook(L); + + MemoryScanReleaseOutcome outer = session.ReleaseWithOutcome(); + + Assert.Null(hook.Failure); + Assert.Equal(1, hook.CallCount); + Assert.Equal(default(MemoryScanReleaseOutcome), inner); + Assert.Equal("scan.terminate:false,scan.wait:5000,hook.returned,list.destroy,scan.destroy", + MemScanTestHost.ReadTrace(L)); + Assert.Equal(MemoryScanTerminationStatus.Confirmed, outer.Termination); + Assert.Equal(TargetReleaseStatus.Released, outer.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outer.MemScan.Status); + Assert.True(outer.OwnershipConsumed); + Assert.Equal(outer, session.LastReleaseOutcome); + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Dispose_from_inside_WaitForCompletion_releases_once_after_the_wait_returned_without_initializing_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemoryScanSession session = StartScanning(L); + MemoryScanReleaseOutcome? inner = null; + using FakeHost.ManagedHookScope hook = FakeHost.InstallManagedHook(L, () => + { + inner = session.ReleaseWithOutcome(); + session.Dispose(); + }); + InstallWaitHook(L); + + Assert.Throws(session.WaitForCompletion); + + Assert.Null(hook.Failure); + Assert.Equal(1, hook.CallCount); + Assert.Equal(default(MemoryScanReleaseOutcome), inner); + Assert.Equal("scan.wait,hook.returned,list.destroy,scan.destroy", MemScanTestHost.ReadTrace(L)); + MemoryScanReleaseOutcome outcome = session.LastReleaseOutcome; + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, outcome.Termination); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.True(outcome.OwnershipConsumed); + + MemScanTestHost.ClearTrace(L); + Assert.Equal(outcome, session.ReleaseWithOutcome()); + Assert.Equal(string.Empty, MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Abandon_from_inside_WaitForCompletion_abandons_once_after_the_wait_returned_without_any_destroy() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + MemScanTestHost.HostObjects objects = MemScanTestHost.Install(L); + Assert.Equal(MemoryScanCreationStatus.Success, + MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? created).Status); + MemoryScanSession session = Assert.IsType(created); + session.StartFirstScan(FirstScanRequest.ByteArray("90", new Address(0x1000), new Address(0x2000))); + MemScanTestHost.ClearTrace(L); + using FakeHost.ManagedHookScope hook = FakeHost.InstallManagedHook(L, () => + { + // A release requested first does not override the explicit no-CE-call abandon. + _ = session.ReleaseWithOutcome(); + session.Abandon(); + }); + InstallWaitHook(L); + + Assert.Throws(session.WaitForCompletion); + + Assert.Null(hook.Failure); + Assert.Equal("scan.wait,hook.returned", MemScanTestHost.ReadTrace(L)); + MemoryScanReleaseOutcome outcome = session.LastReleaseOutcome; + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.MemScan.Status); + Assert.Equal(MemoryScanTerminationStatus.NotRequired, outcome.Termination); + Assert.True(outcome.OwnershipConsumed); + Assert.False(FakeHost.IsDestroyed(L, objects.FoundList)); + Assert.False(FakeHost.IsDestroyed(L, objects.Scanner)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q26")] + public void Members_called_from_inside_a_wait_are_refused_before_any_CE_call_and_the_wait_completes() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + using MemoryScanSession session = StartScanning(L); + List refusals = []; + using FakeHost.ManagedHookScope hook = FakeHost.InstallManagedHook(L, () => + { + refusals.Add(Record.Exception(() => _ = session.Scanner)); + refusals.Add(Record.Exception(() => session.TryGetHostErrorText(out _, out _))); + refusals.Add(Record.Exception(session.WaitForCompletion)); + }); + InstallWaitHook(L); + + session.WaitForCompletion(); + + Assert.Null(hook.Failure); + Assert.Collection(refusals, + refusal => AssertReentrantRefusal(refusal, "Scanner"), + refusal => AssertReentrantRefusal(refusal, "TryGetHostErrorText"), + refusal => AssertReentrantRefusal(refusal, "WaitForCompletion")); + Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Equal("scan.wait,hook.returned,list.initialize", MemScanTestHost.ReadTrace(L)); + Assert.Equal(0, L.Top); + } + + private static void AssertReentrantRefusal(Exception? refusal, string operation) + { + MemoryScanStateException exception = Assert.IsType(refusal); + Assert.Equal(operation, exception.Operation); + Assert.Equal(MemoryScanState.Scanning, exception.State); + Assert.Contains("'WaitForCompletion' operation is still inside a Cheat Engine call", exception.Message, + StringComparison.Ordinal); + } + + // Every wait runs the managed hook (CE pumping queued main-thread work), then records that the hook returned. + private static void InstallWaitHook(LuaState state) + { + MemScanTestHost.Run(state, "scan_wait_hook = function() managed_hook(); table.insert(trace, 'hook.returned') end"); + } + + private static MemoryScanSession StartScanning(LuaState state) + { + MemoryScanSession session = MemScanTestHost.CreateSession(state); + session.StartFirstScan(FirstScanRequest.ByteArray("90 90", new Address(0x1000), new Address(0x2000))); + MemScanTestHost.ClearTrace(state); + return session; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs index ffc733b5..0d25ba1b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs @@ -14,7 +14,7 @@ internal static class EngineTest /// Debug.Assert guards exist. Tests of a Debug-only guard skip in Release. /// #if DEBUG - public const bool IsDebugBuild = true; + public const bool IsDebugBuild = true; #else public const bool IsDebugBuild = false; #endif diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.Rt.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.Rt.cs new file mode 100644 index 00000000..709b6509 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.Rt.cs @@ -0,0 +1,189 @@ +using System.Globalization; +using System.Text; + +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Tests.Support; + +/// +/// S-RT stand-ins for Cheat Engine's runtime and target globals, with the values CE 7.7.0.10621 x64 reported in +/// spike C3 (Lua-only host observation, 2026-09-22; design input, never qualification evidence). +/// +/// +/// Every stand-in reads a plain rt_* Lua global, so a test changes one fact with a one-line chunk such as +/// rt_pointer_size = 4 and can replace any stand-in by redefining its function. Other lots' fixtures that model +/// a qualified local target reuse (shared-contracts section 6, OI-2). +/// +internal static partial class FakeHost +{ + /// + /// The CE 7.7 file version as getCheatEngineFileVersion packs it: major, minor, release and build, 16 + /// bits each (7.7.0.10621). + /// + public const long Ce77PackedFileVersion = 0x7_0007_0000_297DL; + + /// + /// Defines isConnectedToCEServer returning , as CE 7.7 does for a local target + /// (spike C3 D5). Every fixture that models a qualified local target needs it, because + /// TargetSelection refuses local incarnation evidence when the probe is absent. + /// + public static ReadOnlySpan LocalTargetBackendChunk => "function isConnectedToCEServer() return false end"u8; + + /// + /// The Lua global names that the SDK's runtime and target observations are allowed to read (audit A17-18, Q45). + /// + public static IReadOnlyList ReadOnlyRuntimeGlobals + { + get; + } = + [ + "getOpenedProcessID", "isConnectedToCEServer", "targetIs64Bit", "targetIsX86", "targetIsArm", + "targetIsAndroid", "getABI", "getPointerSize", "getCheatEngineFileVersion", "getSystemArchitecture", + "cheatEngineIs64Bit", "getOperatingSystem" + ]; + + /// + /// Lua globals that change Cheat Engine or the target, or load a driver, which no runtime observation may call + /// (audit A17-18, Q45). + /// + public static IReadOnlyList ForbiddenRuntimeGlobals + { + get; + } = + [ + "openProcess", "openFileAsProcess", "setPointerSize", "setAssemblerMode", "dbk_initialize", + "dbk_initialized", "dbvm_initialize", "dbvm_initialized", "dbk_useKernelmodeOpenProcess", "pause", + "unpause" + ]; + + /// + /// Installs the target facts CE 7.7 reported for the x64 Tutorial: x86 family, 64-bit, not ARM, not Android, + /// Windows ABI, pointer size 8, no CEServer connection (spike C3 D2/D5). + /// + public static void InstallCe77X64TargetFacts(LuaState state, int processId) + { + InstallTargetFacts(state, processId, true, 8); + } + + /// + /// Installs the target facts CE 7.7 reported for the i386 tutorial: x86 family, not 64-bit, pointer size 4 + /// (spike C3 D2). + /// + public static void InstallCe77X86TargetFacts(LuaState state, int processId) + { + InstallTargetFacts(state, processId, false, 4); + } + + /// + /// Installs the host facts CE 7.7.0.10621 x64 reported: the packed file version with its table, host + /// architecture code 1 (x86_64), a 64-bit Cheat Engine and operating-system code 0 (spike C3 D5). + /// + public static void InstallCe77HostFacts(LuaState state) + { + string source = + "rt_file_version = " + Ce77PackedFileVersion.ToString(CultureInfo.InvariantCulture) + "\n" + + """ + rt_file_version_table = { major = 7, minor = 7, release = 0, build = 10621, FileVersion = '7.7.0.10621', + ProductVersion = '7.7', CompanyName = 'Cheat Engine' } + rt_system_architecture = 1 + rt_cheat_engine_is_64bit = true + rt_operating_system = 0 + function getCheatEngineFileVersion() return rt_file_version, rt_file_version_table end + function getSystemArchitecture() return rt_system_architecture end + function cheatEngineIs64Bit() return rt_cheat_engine_is_64bit end + function getOperatingSystem() return rt_operating_system end + """; + EngineTest.Run(state, Encoding.UTF8.GetBytes(source)); + } + + /// + /// Installs the CE 7.7 x64 host and target facts as recording functions that also assert they receive no + /// argument, makes every entry record and raise, and sets a _G + /// metatable whose __index records every other global lookup. reads + /// the record back. + /// + public static void InstallRecordingRuntimeGlobals(LuaState state, int processId) + { + InstallCe77HostFacts(state); + InstallCe77X64TargetFacts(state, processId); + StringBuilder source = new(); + source.Append("rt_calls = {}\nrt_arity_violations = {}\n"); + source.Append("local function record(name) rt_calls[#rt_calls + 1] = name end\n"); + foreach (string name in ReadOnlyRuntimeGlobals) + { + source.Append("do local original = ").Append(name).Append('\n') + .Append(" ").Append(name).Append(" = function(...)\n") + .Append(" record('").Append(name).Append("')\n") + .Append(" if select('#', ...) ~= 0 then rt_arity_violations[#rt_arity_violations + 1] = '") + .Append(name).Append("' end\n") + .Append(" return original()\n") + .Append(" end\nend\n"); + } + + foreach (string name in ForbiddenRuntimeGlobals) + { + source.Append(name).Append(" = function() record('").Append(name) + .Append("') error('forbidden runtime global called: ").Append(name).Append("') end\n"); + } + + source.Append("setmetatable(_G, { __index = function(_, key) record('?' .. tostring(key)) return nil end })\n"); + EngineTest.Run(state, Encoding.UTF8.GetBytes(source.ToString())); + } + + /// Reads the global names recorded by , in call order. + public static IReadOnlyList RecordedRuntimeGlobals(LuaState state) + { + return ReadStringSequence(state, "rt_calls"u8); + } + + /// Reads the names of recorded read-only globals that were called with at least one argument. + public static IReadOnlyList RecordedArityViolations(LuaState state) + { + return ReadStringSequence(state, "rt_arity_violations"u8); + } + + private static void InstallTargetFacts(LuaState state, int processId, bool is64Bit, int pointerSize) + { + string source = + "rt_process_id = " + processId.ToString(CultureInfo.InvariantCulture) + "\n" + + "rt_is_64bit = " + (is64Bit ? "true" : "false") + "\n" + + "rt_pointer_size = " + pointerSize.ToString(CultureInfo.InvariantCulture) + "\n" + + """ + rt_ceserver = false + rt_is_x86 = true + rt_is_arm = false + rt_is_android = false + rt_abi = 0 + function getOpenedProcessID() return rt_process_id end + function isConnectedToCEServer() return rt_ceserver end + function targetIs64Bit() return rt_is_64bit end + function targetIsX86() return rt_is_x86 end + function targetIsArm() return rt_is_arm end + function targetIsAndroid() return rt_is_android end + function getABI() return rt_abi end + function getPointerSize() return rt_pointer_size end + """; + EngineTest.Run(state, Encoding.UTF8.GetBytes(source)); + } + + private static List ReadStringSequence(LuaState state, ReadOnlySpan global) + { + List values = []; + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(global).IsOk); + Assert.True(state.IsTable(-1)); + int table = state.AbsoluteIndex(-1); + for (long index = 1; ; index++) + { + if (state.RawGetIndex(table, index) == LuaType.Nil) + { + break; + } + + values.Add(EngineTest.ReadString(state, -1)); + state.Pop(1); + } + + return values; + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.SRes.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.SRes.cs new file mode 100644 index 00000000..35759b20 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.SRes.cs @@ -0,0 +1,243 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Interop.Types; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Tests.Support; + +/// +/// The S-RES part of the fake host: a controlled Lua state replacement, a Cheat Engine SymbolList class +/// model, a qualified local target, host-object globals, and a Lua-callable hook that re-enters managed code from +/// inside a host call (for the table-load guard). +/// +internal static unsafe partial class FakeHost +{ + private static readonly Lazy SReplaceStateGeneration = new(CreateStateReplacement); + private static Action? s_reentrantHook; + private static Exception? s_reentrantHookFailure; + + /// + /// The Lua side of the SymbolList class model. A list keeps its symbols by search key, counts register, + /// unregister and destroy calls in globals, and raises on every member once it was destroyed, as Cheat Engine does + /// for a detached userdata. + /// + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", + Justification = "Lua model data kept in one literal, like the base model of FakeHost.")] + private static ReadOnlySpan SymbolListModel => """ + local host = ... + symbol_list_register_calls = 0 + symbol_list_unregister_calls = 0 + symbol_list_destroyed_while_registered = 0 + local function guard(o) + if o.destroyed then error("attempt to index a userdata value") end + end + local function copy(s) + return { modulename = s.modulename, searchkey = s.searchkey, address = s.address, symbolsize = s.symbolsize } + end + host.classes.SymbolList = setmetatable({ + clear = function(o) guard(o) o.symbols = {} end, + addSymbol = function(o, ...) + guard(o) + o.last_args = table.pack(...) + local m, k, a, s = ... + o.symbols[k] = { modulename = m, searchkey = k, address = a, symbolsize = s } + end, + deleteSymbol = function(o, ...) + guard(o) + o.last_args = table.pack(...) + local key = ... + if type(key) == "number" then + for name, s in pairs(o.symbols) do + if s.address == key then o.symbols[name] = nil end + end + else + o.symbols[key] = nil + end + end, + getSymbolFromAddress = function(o, ...) + guard(o) + o.last_args = table.pack(...) + if o.malformed then return { modulename = 1 } end + local address = ... + for _, s in pairs(o.symbols) do + if address >= s.address and address < s.address + s.symbolsize then return copy(s) end + end + return nil + end, + getSymbolFromString = function(o, ...) + guard(o) + o.last_args = table.pack(...) + if o.malformed then return { modulename = "m", searchkey = "k", address = "not an address", symbolsize = 1 } end + local s = o.symbols[(...)] + if s == nil then return nil end + return copy(s) + end, + register = function(o, ...) + guard(o) + o.register_args = select("#", ...) + symbol_list_register_calls = symbol_list_register_calls + 1 + if o.register_raises then o.registered = true error("register failed after it began") end + o.registered = true + end, + unregister = function(o, ...) + guard(o) + o.unregister_args = select("#", ...) + symbol_list_unregister_calls = symbol_list_unregister_calls + 1 + if o.unregister_raises then error("unregister failed after it began") end + o.registered = false + end, + destroy = function(o) + if o.destroyed then error("object already destroyed") end + if o.registered then symbol_list_destroyed_while_registered = symbol_list_destroyed_while_registered + 1 end + o.destroyed = true + host.destroyed = host.destroyed + 1 + end, + }, { __index = host.classes.Object }) + """u8; + + /// + /// Runs the SDK-controlled Lua state replacement without replacing the fixture state: calls the internal + /// LuaRuntime.BeginStateReset() and disposes the returned transition at once, which advances + /// and neutralizes callbacks exactly like a supported reset. + /// + /// + /// Depends on the internal member CheatEngine.SDK.Lua.Runtime.LuaRuntime.BeginStateReset() returning the + /// stack-only LuaStateResetTransition with a public Dispose(). Reflection cannot invoke a method that + /// returns a by-ref-like type, so a dynamic method calls it and disposes the transition immediately; nothing runs in + /// between, so the transition is never left open. A change of that internal protocol (S-HOST) must update this + /// helper. + /// + public static void ReplaceStateGeneration() + { + SReplaceStateGeneration.Value(); + } + + /// Adds the SymbolList class to the installed model, so accepts it. + public static void InstallSymbolListClass(LuaState L) + { + using LuaFrame frame = new(L); + LuaStatus status = L.TryLoad(SymbolListModel, "=fakehost-symbollist"u8); + FailIfNotOk(L, status, "loading the SymbolList model"); + Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + HostKey)); + FailIfNotOk(L, L.TryCall(1, 0), "installing the SymbolList model"); + } + + /// Creates a fake SymbolList object with an empty symbol table. + public static CEObject CreateSymbolList(LuaState L, string initializer = "") + { + return CreateObject(L, "SymbolList", "o.symbols = {}\n" + initializer); + } + + /// Assigns a host object to a Lua global (the userdata the host pusher creates). + public static void SetGlobalObject(LuaState L, string name, CEObject value) + { + using LuaFrame frame = new(L); + value.Push(L); + FailIfNotOk(L, L.TrySetGlobal(Encoding.UTF8.GetBytes(name)), "setting the global " + name); + } + + /// + /// Runs Lua against the fake object table of (bound to o): for example + /// o.destroyed = true to make the host destroy the object behind the plugin's back. + /// + public static void RunOnObject(LuaState L, CEObject value, string source) + { + using LuaFrame frame = new(L); + byte[] chunk = Encoding.UTF8.GetBytes("local o = ...\n" + source); + FailIfNotOk(L, L.TryLoad(chunk, "=fakehost-object"u8), "loading an object chunk"); + Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey)); + Assert.Equal(LuaType.Table, L.RawGetPointer(-1, value.Value)); + L.Remove(-2); + FailIfNotOk(L, L.TryCall(1, 0), "running an object chunk"); + } + + /// + /// Defines getOpenedProcessID returning this test process (so the SDK can qualify a local incarnation from + /// its creation time) and isConnectedToCEServer returning (a qualified local target, + /// shared-contracts section 6). + /// + public static void InstallQualifiedLocalTarget(LuaState L) + { + EngineTest.Run(L, Encoding.UTF8.GetBytes( + "fake_target_pid = " + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + "\n" + + "function getOpenedProcessID() return fake_target_pid end\n" + + "function isConnectedToCEServer() return false end")); + } + + /// + /// Installs a Lua global C function that calls synchronously on the calling thread, from + /// inside whatever Lua host call invokes it. A managed exception is caught (it must never cross the C boundary) and + /// reported by . + /// + public static IDisposable InstallReentrantHook(LuaState L, string globalName, Action hook) + { + s_reentrantHook = hook; + s_reentrantHookFailure = null; + using LuaFrame frame = new(L); + L.PushUncheckedFunction( + new LuaNativeFunction((nint) (delegate* unmanaged[Cdecl]) &InvokeReentrantHook)); + FailIfNotOk(L, L.TrySetGlobal(Encoding.UTF8.GetBytes(globalName)), "installing the re-entrant hook"); + return new ReentrantHookScope(); + } + + /// Returns and clears the exception the re-entrant hook threw, if any. + public static Exception? TakeReentrantHookFailure() + { + Exception? failure = s_reentrantHookFailure; + s_reentrantHookFailure = null; + return failure; + } + + private static Action CreateStateReplacement() + { + MethodInfo begin = typeof(LuaRuntime).GetMethod("BeginStateReset", BindingFlags.Static | BindingFlags.NonPublic, + Type.EmptyTypes) + ?? throw new InvalidOperationException("LuaRuntime.BeginStateReset() was not found."); + Type transition = begin.ReturnType; + MethodInfo dispose = transition.GetMethod("Dispose", BindingFlags.Instance | BindingFlags.Public, + Type.EmptyTypes) + ?? throw new InvalidOperationException("LuaStateResetTransition.Dispose() was not found."); + DynamicMethod method = new("ReplaceStateGeneration", null, Type.EmptyTypes, typeof(FakeHost).Module, true); + ILGenerator il = method.GetILGenerator(); + LocalBuilder local = il.DeclareLocal(transition); + il.Emit(OpCodes.Call, begin); + il.Emit(OpCodes.Stloc, local); + il.Emit(OpCodes.Ldloca, local); + il.Emit(OpCodes.Call, dispose); + il.Emit(OpCodes.Ret); + return method.CreateDelegate(); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int InvokeReentrantHook(lua_State* state) + { + try + { + s_reentrantHook?.Invoke(); + } + catch (Exception exception) + { + s_reentrantHookFailure = exception; + } + + return 0; + } + + private sealed class ReentrantHookScope : IDisposable + { + public void Dispose() + { + s_reentrantHook = null; + } + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.Scanning.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.Scanning.cs new file mode 100644 index 00000000..598b1742 --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.Scanning.cs @@ -0,0 +1,92 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.Interop.Types; +using CheatEngine.SDK.Lua.State; + +namespace CheatEngine.SDK.Engine.Tests.Support; + +// The scanning part of the fake host: a Lua-callable function that runs managed test code, so that a Lua stand-in +// can model Cheat Engine running queued main-thread work (CE's synchronize, which MainThread.Invoke uses) from inside +// one of its own calls, for example while waitTillDone pumps CheckSynchronize. +internal static unsafe partial class FakeHost +{ + private static ManagedHookScope? s_activeManagedHook; + + /// + /// Installs the Lua global managed_hook, a bare C function that runs and returns no + /// value. A stand-in calls it from inside a CE-shaped call, for example with + /// scan_wait_hook = managed_hook. An exception thrown by the hook never crosses the native boundary: it is + /// recorded in . + /// + public static ManagedHookScope InstallManagedHook(LuaState L, Action hook) + { + ArgumentNullException.ThrowIfNull(hook); + if (s_activeManagedHook is not null) + { + throw new InvalidOperationException("Only one fake-host managed hook can be active."); + } + + using LuaFrame frame = new(L); + L.PushUncheckedFunction(new LuaNativeFunction((nint) (delegate* unmanaged[Cdecl]) &RunManagedHook)); + Assert.True(L.TrySetGlobal("managed_hook"u8).IsOk); + ManagedHookScope scope = new(hook); + s_activeManagedHook = scope; + return scope; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int RunManagedHook(lua_State* state) + { + s_activeManagedHook?.Run(); + return 0; + } + + /// The active managed hook; disposing it makes managed_hook a no-op. + internal sealed class ManagedHookScope : IDisposable + { + private readonly Action _hook; + + internal ManagedHookScope(Action hook) + { + _hook = hook; + } + + /// Gets how many times Lua called managed_hook. + public int CallCount + { + get; + private set; + } + + /// Gets the first exception the hook threw, if any. + public Exception? Failure + { + get; + private set; + } + + /// + public void Dispose() + { + if (s_activeManagedHook == this) + { + s_activeManagedHook = null; + } + } + + internal void Run() + { + CallCount++; + try + { + _hook(); + } + catch (Exception exception) + { + Failure ??= exception; + } + } + } +} diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs index 8e752300..94e8ad65 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs @@ -30,7 +30,7 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// state lives in a Lua table stored as the userdata's user value (lua_setuservalue), keyed by pointer in a /// registry table so that every push of the same pointer finds the same state. /// -internal static unsafe class FakeHost // NOSONAR: the fixture implements Cheat Engine's unmanaged callback ABI. +internal static unsafe partial class FakeHost // NOSONAR: the fixture implements Cheat Engine's unmanaged callback ABI. { // Registry keys: light userdata whose values are the addresses of these bytes (stable for the process). private const int MetatableKey = 0; @@ -327,15 +327,15 @@ internal PCallProbe(Action? afterWaitTillDone, Action? afterGetAddress = null) } FieldInfo tableField = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic) - ?? throw new InvalidOperationException( - "The Lua API table was not available for probing."); + ?? throw new InvalidOperationException( + "The Lua API table was not available for probing."); _table = tableField.GetValue(null) - ?? throw new InvalidOperationException("The Lua API table was not initialized for probing."); + ?? throw new InvalidOperationException("The Lua API table was not initialized for probing."); _pcallField = _table.GetType().GetField("lua_pcallk", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException( - "The Lua protected-call slot was not available for probing."); + ?? throw new InvalidOperationException( + "The Lua protected-call slot was not available for probing."); s_forwardedPCall = (nint) (_pcallField.GetValue(_table) - ?? throw new InvalidOperationException("The Lua protected-call slot was null.")); + ?? throw new InvalidOperationException("The Lua protected-call slot was null.")); _pcallField.SetValue(_table, (nint) (delegate* unmanaged[Cdecl]) &ObservePCall); tableField.SetValue(null, _table); @@ -411,8 +411,8 @@ private static int ObservePCall(lua_State* state, int argumentCount, int resultC { PCallProbe? probe = s_activePCallProbe; if (probe is not null && - (nint) lua_tocfunction(state, -argumentCount - 1) == - (nint) (delegate* unmanaged[Cdecl]) &WaitTillDone) + (nint) lua_tocfunction(state, -argumentCount - 1) == + (nint) (delegate* unmanaged[Cdecl]) &WaitTillDone) { probe.Observe(argumentCount, resultCount); } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Tables/CheatTableFilesTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Tables/CheatTableFilesTests.cs index 3e0b7ae2..a8990cda 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Tables/CheatTableFilesTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Tables/CheatTableFilesTests.cs @@ -1,6 +1,8 @@ +using CheatEngine.SDK.Engine.AddressList; using CheatEngine.SDK.Engine.Tables; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -63,4 +65,86 @@ public void Table_file_calls_distinguish_missing_globals_and_lua_failures() Assert.Equal(LuaOperationStatusKind.LuaFailure, CheatTableFiles.TrySave("broken.ct").Kind); Assert.Equal(top, L.Top); } + + [Fact] + public void TryLoad_passes_exactly_the_path_and_merge_arguments() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + load_arguments = -1 + loadTable = function(...) + load_arguments = select("#", ...) + load_path, load_merge = ... + end + """u8); + + LuaOperationStatus merged = CheatTableFiles.TryLoad("with scripts.ct", true); + EngineTest.Run(L, "assert(load_arguments == 2 and load_path == 'with scripts.ct' and load_merge == true)"u8); + LuaOperationStatus replaced = CheatTableFiles.TryLoad("with scripts.ct", false); + EngineTest.Run(L, "assert(load_arguments == 2 and load_merge == false)"u8); + + Assert.True(merged.IsSuccess); + Assert.True(replaced.IsSuccess); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryLoad_marks_address_list_mutations_as_refused_while_it_runs() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + List observed = []; + MemoryRecordMutationOutcome refused = default; + using (FakeHost.InstallReentrantHook(L, "table_script", () => + { + observed.Add(CheatTableFiles.IsLoadInProgressOnCurrentThread); + refused = AddressListMutations.Delete(new MemoryRecordId(1)); + })) + { + EngineTest.Run(L, "loadTable = function(path, merge) table_script() end"u8); + + Assert.False(CheatTableFiles.IsLoadInProgressOnCurrentThread); + Assert.True(CheatTableFiles.TryLoad("scripted.ct", false).IsSuccess); + } + + Assert.Null(FakeHost.TakeReentrantHookFailure()); + Assert.Equal([true], observed); + Assert.Equal(MemoryRecordMutationProblem.TableLoadInProgress, refused.Problem); + Assert.Equal(MemoryRecordMutationEffect.NotAttempted, refused.Effect); + Assert.False(CheatTableFiles.IsLoadInProgressOnCurrentThread); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryLoad_clears_the_load_scope_after_a_lua_failure() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, "loadTable = function() error('corrupt table') end"u8); + + LuaOperationStatus status = CheatTableFiles.TryLoad("corrupt.ct", false); + + Assert.Equal(LuaOperationStatusKind.LuaFailure, status.Kind); + Assert.False(CheatTableFiles.IsLoadInProgressOnCurrentThread); + Assert.Equal(MemoryRecordMutationProblem.GlobalUnavailable, + AddressListMutations.Delete(new MemoryRecordId(1)).Problem); + Assert.Equal(0, L.Top); + } + + [Fact] + public void TryLoad_clears_the_load_scope_when_the_runtime_is_detached() + { + LuaRuntime.Detach(); + + Assert.Throws(() => CheatTableFiles.TryLoad("any.ct", false)); + + Assert.False(CheatTableFiles.IsLoadInProgressOnCurrentThread); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetReleaseOutcomeTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetReleaseOutcomeTests.cs index 6b15def7..919bb734 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetReleaseOutcomeTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetReleaseOutcomeTests.cs @@ -10,6 +10,9 @@ public sealed class TargetReleaseOutcomeTests [InlineData(TargetIdentityCheckKind.NoTargetSelected, TargetReleaseStatus.RefusedNoTarget)] [InlineData(TargetIdentityCheckKind.GlobalUnavailable, TargetReleaseStatus.RefusedIdentityUnavailable)] [InlineData(TargetIdentityCheckKind.InvalidResult, TargetReleaseStatus.RefusedIdentityUnavailable)] + [InlineData(TargetIdentityCheckKind.RemoteBackend, TargetReleaseStatus.RefusedIdentityUnavailable)] + [InlineData(TargetIdentityCheckKind.FileAsProcess, TargetReleaseStatus.RefusedIdentityUnavailable)] + [InlineData(TargetIdentityCheckKind.BackendUnknown, TargetReleaseStatus.RefusedIdentityUnavailable)] public void Refused_cleanup_preserves_the_specific_or_unavailable_target_fact(TargetIdentityCheckKind checkKind, TargetReleaseStatus expectedStatus) { @@ -17,6 +20,9 @@ public void Refused_cleanup_preserves_the_specific_or_unavailable_target_fact(Ta { TargetIdentityCheckKind.NoTargetSelected => TargetSelectionObservationStatus.NoTargetSelected, TargetIdentityCheckKind.GlobalUnavailable => TargetSelectionObservationStatus.GlobalUnavailable, + TargetIdentityCheckKind.RemoteBackend => TargetSelectionObservationStatus.CurrentTargetRemoteBackend, + TargetIdentityCheckKind.FileAsProcess => TargetSelectionObservationStatus.CurrentTargetFileAsProcess, + TargetIdentityCheckKind.BackendUnknown => TargetSelectionObservationStatus.CurrentTargetBackendUnknown, _ => TargetSelectionObservationStatus.InvalidResult }; TargetIdentityCheck check = new(checkKind, TargetSelectionObservation.FromStatus(observationStatus)); @@ -25,6 +31,7 @@ public void Refused_cleanup_preserves_the_specific_or_unavailable_target_fact(Ta Assert.Equal(expectedStatus, outcome.Status); Assert.Equal(check, outcome.TargetCheck); + Assert.Equal(checkKind, outcome.TargetCheck?.Kind); Assert.Null(outcome.FailureKind); Assert.True(outcome.RequiresManualRecovery); } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetSelectionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetSelectionTests.cs index ac004249..8deffef0 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetSelectionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Targets/TargetSelectionTests.cs @@ -1,5 +1,6 @@ using System.Text; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.State; @@ -26,8 +27,9 @@ public void ObserveCurrent_with_the_current_process_id_returns_a_qualified_incar Assert.True(observation.IsQualified); Assert.True(observation.Incarnation.HasValue); Assert.Equal(Environment.ProcessId, observation.Incarnation.Value.ProcessId); - Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalProcessStartTime, - observation.Evidence); + Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalBackendConfirmed | + TargetIdentityEvidence.LocalProcessStartTime, observation.Evidence); + Assert.Equal(TargetBackend.LocalProcess, observation.Backend); Assert.True(TargetSelection.ValidateCurrent(observation.Incarnation.Value).IsCurrent); Assert.Equal(0, scope.State.Top); } @@ -91,6 +93,8 @@ public void ObserveCurrent_when_the_selection_callback_raises_preserves_the_Lua_ [Theory] [InlineData("function getOpenedProcessID() return true end")] [InlineData("function getOpenedProcessID() return -1 end")] + [InlineData("function getOpenedProcessID() return '42' end")] + [InlineData("function getOpenedProcessID() return 4294967294 end")] public void ObserveCurrent_when_the_selection_callback_returns_an_invalid_PID_reports_an_invalid_result( string fixture) { @@ -121,7 +125,9 @@ public void TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); Assert.Equal(TargetSelectionObservationStatus.CurrentTargetUnqualified, observation.Status); - Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId, observation.Evidence); + Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId | TargetIdentityEvidence.LocalBackendConfirmed, + observation.Evidence); + Assert.Equal(TargetBackend.LocalProcess, observation.Backend); Assert.Equal(int.MaxValue, observation.SelectedProcessId); Assert.Null(observation.Incarnation); Assert.False(observation.IsQualified); @@ -168,8 +174,195 @@ public void Default_selection_observation_is_not_qualified() Assert.False(observation.IsQualified); } + [Fact] + [Trait("Qualification", "Q30.d")] + public void target_selection_emits_no_local_start_time_evidence_when_connected_to_ceserver() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + // The test process's own PID would qualify if a local lookup ran. + InstallOpenedProcessId(scope.State, Environment.ProcessId); + EngineTest.Run(scope.State, "function isConnectedToCEServer() return true end"u8); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + + Assert.Equal(TargetSelectionObservationStatus.CurrentTargetRemoteBackend, observation.Status); + Assert.Equal(TargetBackend.CEServer, observation.Backend); + Assert.Equal(Environment.ProcessId, observation.SelectedProcessId); + Assert.Null(observation.Incarnation); + Assert.False(observation.IsQualified); + Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId, observation.Evidence); + Assert.False(observation.Evidence.HasFlag(TargetIdentityEvidence.LocalProcessStartTime)); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q30.d")] + public void target_selection_emits_no_local_start_time_evidence_when_the_ceserver_probe_is_unavailable() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes( + "function getOpenedProcessID() return " + Environment.ProcessId + " end")); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + TargetIdentityCheck check = + TargetSelection.ValidateCurrent(new TargetProcessIncarnation(Environment.ProcessId, 1001)); + + Assert.Equal(TargetSelectionObservationStatus.CurrentTargetBackendUnknown, observation.Status); + Assert.Equal(TargetBackend.Unknown, observation.Backend); + Assert.Equal(Environment.ProcessId, observation.SelectedProcessId); + Assert.Null(observation.Incarnation); + Assert.False(observation.IsQualified); + Assert.Equal(TargetIdentityEvidence.CheatEngineSelectedProcessId, observation.Evidence); + Assert.Equal(TargetIdentityCheckKind.BackendUnknown, check.Kind); + Assert.False(check.IsCurrent); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q30.d")] + public void ceserver_target_yields_no_local_incarnation_proof_and_validate_current_refuses_it() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallOpenedProcessId(scope.State, Environment.ProcessId); + EngineTest.Run(scope.State, """ + ceserver_connected = false + function isConnectedToCEServer() return ceserver_connected end + """u8); + TargetSelectionObservation local = TargetSelection.ObserveCurrent(); + Assert.True(local.IsQualified); + TargetProcessIncarnation expected = local.Incarnation.GetValueOrDefault(); + + // Cheat Engine now serves the same PID through CEServer: the local creation time no longer describes it. + EngineTest.Run(scope.State, "ceserver_connected = true"u8); + TargetIdentityCheck check = TargetSelection.ValidateCurrent(expected); + TargetReleaseOutcome refused = TargetReleaseOutcome.Refused(check); + + Assert.Equal(TargetIdentityCheckKind.RemoteBackend, check.Kind); + Assert.False(check.IsCurrent); + Assert.Equal(TargetBackend.CEServer, check.Observed.Backend); + Assert.Null(check.Observed.Incarnation); + Assert.Equal(TargetReleaseStatus.RefusedIdentityUnavailable, refused.Status); + Assert.True(refused.RequiresManualRecovery); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + [Trait("Qualification", "Q30.c")] + public void file_as_process_target_is_refused_without_a_bcl_process_lookup() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + EngineTest.Run(scope.State, """ + function getOpenedProcessID() return 4294967295 end + function isConnectedToCEServer() error('a file opened as a process needs no backend probe') end + """u8); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + TargetIdentityCheck check = + TargetSelection.ValidateCurrent(new TargetProcessIncarnation(Environment.ProcessId, 1001)); + + Assert.Equal(TargetSelectionObservationStatus.CurrentTargetFileAsProcess, observation.Status); + Assert.Equal(TargetBackend.FileAsProcess, observation.Backend); + Assert.Null(observation.SelectedProcessId); + Assert.Null(observation.Incarnation); + Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); + Assert.False(observation.IsQualified); + Assert.Equal(TargetIdentityCheckKind.FileAsProcess, check.Kind); + Assert.False(check.IsCurrent); + Assert.Equal(0, scope.State.Top); + } + + [Theory] + [InlineData("error('fixture backend probe failure')", TargetSelectionObservationStatus.LuaFailure, + TargetIdentityCheckKind.LuaFailure)] + [InlineData("return nil", TargetSelectionObservationStatus.InvalidResult, TargetIdentityCheckKind.InvalidResult)] + [InlineData("return 'false'", TargetSelectionObservationStatus.InvalidResult, + TargetIdentityCheckKind.InvalidResult)] + [InlineData("return 0", TargetSelectionObservationStatus.InvalidResult, TargetIdentityCheckKind.InvalidResult)] + public void raising_or_malformed_ceserver_probe_keeps_the_lua_failure_or_invalid_result(string body, + TargetSelectionObservationStatus expectedStatus, TargetIdentityCheckKind expectedCheck) + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallOpenedProcessId(scope.State, Environment.ProcessId); + EngineTest.Run(scope.State, Encoding.UTF8.GetBytes("function isConnectedToCEServer() " + body + " end")); + + TargetSelectionObservation observation = TargetSelection.ObserveCurrent(); + TargetIdentityCheck check = + TargetSelection.ValidateCurrent(new TargetProcessIncarnation(Environment.ProcessId, 1001)); + + Assert.Equal(expectedStatus, observation.Status); + Assert.Equal(TargetIdentityEvidence.None, observation.Evidence); + Assert.Null(observation.Incarnation); + Assert.False(observation.IsQualified); + Assert.Equal(expectedCheck, check.Kind); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void the_next_target_observation_after_a_ceserver_probe_failure_succeeds() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallOpenedProcessId(scope.State, Environment.ProcessId); + EngineTest.Run(scope.State, """ + raise_backend_probe = true + function isConnectedToCEServer() + if raise_backend_probe then error('fixture backend probe failure') end + return false + end + """u8); + + TargetSelectionObservation failed = TargetSelection.ObserveCurrent(); + Assert.Equal(0, scope.State.Top); + EngineTest.Run(scope.State, "raise_backend_probe = false"u8); + TargetSelectionObservation recovered = TargetSelection.ObserveCurrent(); + + Assert.Equal(TargetSelectionObservationStatus.LuaFailure, failed.Status); + Assert.True(recovered.IsQualified); + Assert.Equal(TargetBackend.LocalProcess, recovered.Backend); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void observations_that_differ_only_by_backend_are_not_equal() + { + TargetSelectionObservation remote = TargetSelectionObservation.RemoteBackend(4242); + TargetSelectionObservation unknown = TargetSelectionObservation.BackendUnknown(4242); + + Assert.NotEqual(remote, unknown); + Assert.Equal(remote.SelectedProcessId, unknown.SelectedProcessId); + Assert.Equal(remote.Evidence, unknown.Evidence); + Assert.Equal(TargetBackend.Unknown, default(TargetSelectionObservation).Backend); + } + + [Fact] + public void target_enum_members_are_appended_with_pinned_values() + { + Assert.Equal(7, (byte) TargetSelectionObservationStatus.CurrentTargetRemoteBackend); + Assert.Equal(8, (byte) TargetSelectionObservationStatus.CurrentTargetFileAsProcess); + Assert.Equal(9, (byte) TargetSelectionObservationStatus.CurrentTargetBackendUnknown); + Assert.Equal(9, (byte) TargetIdentityCheckKind.RemoteBackend); + Assert.Equal(10, (byte) TargetIdentityCheckKind.FileAsProcess); + Assert.Equal(11, (byte) TargetIdentityCheckKind.BackendUnknown); + Assert.Equal(4, (byte) TargetIdentityEvidence.LocalBackendConfirmed); + Assert.Equal(6, (byte) TargetSelectionObservationStatus.InvalidResult); + Assert.Equal(8, (byte) TargetIdentityCheckKind.InvalidResult); + Assert.Equal(2, (byte) TargetIdentityEvidence.LocalProcessStartTime); + } + private static void InstallOpenedProcessId(LuaState state, int processId) { EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + processId + " end")); + EngineTest.Run(state, FakeHost.LocalTargetBackendChunk); } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/packages.lock.json b/tests/CheatEngine.SDK.Engine.Tests/packages.lock.json new file mode 100644 index 00000000..ee88a87c --- /dev/null +++ b/tests/CheatEngine.SDK.Engine.Tests/packages.lock.json @@ -0,0 +1,238 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.engine": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.tests.shared": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Hosting.Tests/AssemblyInfo.cs b/tests/CheatEngine.SDK.Hosting.Tests/AssemblyInfo.cs index 7ac103ab..f0b828d4 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/AssemblyInfo.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/AssemblyInfo.cs @@ -2,5 +2,7 @@ using Xunit.v3; // PluginHost, LuaRuntime and the fake exports are process-wide statics, exactly as in a Cheat Engine process where -// one load context hosts one plugin: tests that bootstrap, enable or disable would race across parallel classes. +// one loaded Hosting assembly instance hosts one plugin: tests that bootstrap, enable or disable would race across +// parallel classes. Whether one Hosting assembly instance equals one assembly load context, or one plugin, is a +// separately qualified host fact (Q09), not assumed by this statement. [assembly: Parallelization(Mode = ParallelMode.None)] diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs index ff76a04b..f781fcf9 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs @@ -9,6 +9,7 @@ namespace CheatEngine.SDK.Hosting.Tests.Bootstrap; public sealed unsafe class GetVersionTests { [Fact] + [Trait("Qualification", "Q01")] public void Fills_version_6_and_the_bootstrap_name_pointer() { HostingTest.Reset(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs index a1520ebd..d56f61cd 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs @@ -15,6 +15,7 @@ namespace CheatEngine.SDK.Hosting.Tests.Bootstrap; public sealed unsafe class InitializeManagedTests { [Fact] + [Trait("Qualification", "Q02")] public void Writes_exactly_the_36_byte_record_and_nothing_past_it() { HostingTest.Reset(); @@ -40,6 +41,7 @@ public void Writes_exactly_the_36_byte_record_and_nothing_past_it() } [Fact] + [Trait("Qualification", "Q02")] public void Writes_the_record_at_an_odd_address_without_touching_the_guard() { HostingTest.Reset(); @@ -53,6 +55,7 @@ public void Writes_the_record_at_an_odd_address_without_touching_the_guard() } [Fact] + [Trait("Qualification", "Q05")] public void Second_call_is_idempotent_and_writes_the_same_bytes_including_the_name_pointer() { HostingTest.Reset(); @@ -81,6 +84,7 @@ public void Name_is_the_ASCII_bytes_of_the_factory_name_NUL_terminated() } [Fact] + [Trait("Qualification", "Q05.a")] public void Non_ASCII_name_is_converted_to_the_process_ANSI_code_page() { HostingTest.Reset(); @@ -129,6 +133,7 @@ public void Null_record_address_fails_and_is_logged() [InlineData(36)] [InlineData(40)] [InlineData(4096)] + [Trait("Qualification", "Q04")] public void An_opaque_bootstrap_argument_is_recorded_without_changing_the_record_write(int hostArgument) { HostingTest.Reset(); @@ -141,6 +146,34 @@ public void An_opaque_bootstrap_argument_is_recorded_without_changing_the_record Assert.Equal(hostArgument, PluginHost.LastInitRecordArgument); } + [Fact] + [Trait("Qualification", "Q04")] + public void The_bootstrap_argument_is_never_used_as_a_size_or_precondition() + { + // The Name pointer (offset 0-7) is a fresh native allocation every time HostingTest.Reset() clears the + // previous one, so its address legitimately differs run to run: that is not what this test is about. The + // remaining 28 bytes (GetVersion, EnablePlugin, DisablePlugin, Version) are addresses of static native + // thunks and a compile-time constant, so they are the part that must never move because of hostArgument. + int[] hostArguments = [int.MinValue, -1, 0, 1, 36, int.MaxValue]; + const int NameFieldSize = sizeof(long); + byte[]? reference = null; + + foreach (int hostArgument in hostArguments) + { + HostingTest.Reset(); + using HostSimulator host = new(); + + Assert.Equal(1, host.Initialize(hostArgument)); + + Assert.True(host.GuardIntact, $"hostArgument={hostArgument} overran the record."); + ReadOnlySpan stable = host.RecordBytes[NameFieldSize..]; + reference ??= stable.ToArray(); + Assert.True(stable.SequenceEqual(reference), + $"hostArgument={hostArgument} changed the 36-byte record write."); + Assert.Equal(hostArgument, PluginHost.LastInitRecordArgument); + } + } + [Fact] public void A_second_factory_type_is_rejected_deterministically_and_the_first_keeps_working() { diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/LuaModuleLocatorTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/LuaModuleLocatorTests.cs index 4bb6484e..5ffb6205 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/LuaModuleLocatorTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/LuaModuleLocatorTests.cs @@ -77,6 +77,33 @@ public void Binding_again_the_module_the_table_is_bound_to_succeeds_and_keeps_it Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); } + [Fact] + [Trait("Category", "NativeLua")] + public void Repeated_enable_releases_exactly_the_reference_each_lookup_added() + { + HostingTest.RequireNativeLua(); + Assert.SkipUnless(OperatingSystem.IsWindows(), "The loaded-module lookup is implemented for Windows only."); + LuaModuleLocator.ResetFreedReferenceCountForTests(); + + // The first bind here may already observe the table bound to this same handle from an earlier test (static, + // process-wide state); ignore its effect on the counter. Every bind after that one is guaranteed to see the + // table already bound to this handle, so each must release exactly the one loader reference its own lookup + // added -- never more (a double free), never fewer (an accumulated reference; A05-01). + Assert.True(LuaModule.TryGetLoaded(NativeLuaLibrary.LibraryPath!, out IntPtr firstHandle)); + Assert.True(LuaModuleLocator.BindLocated(firstHandle, true, out _)); + long baseline = LuaModuleLocator.FreedReferenceCountForTests; + + for (int i = 0; i < 2; i++) + { + Assert.True(LuaModule.TryGetLoaded(NativeLuaLibrary.LibraryPath!, out IntPtr handle)); + Assert.Equal(NativeLuaLibrary.Handle, handle); + Assert.True(LuaModuleLocator.BindLocated(handle, true, out _)); + } + + Assert.Equal(baseline + 2, LuaModuleLocator.FreedReferenceCountForTests); + Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); + } + private static string FindUnmappedSystemDll() { string? found = null; diff --git a/tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj b/tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj index 76483900..9f743483 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj +++ b/tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj @@ -13,4 +13,16 @@ + + + + + + + diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/CoexistencePluginLayout.cs b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/CoexistencePluginLayout.cs new file mode 100644 index 00000000..d08813fe --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/CoexistencePluginLayout.cs @@ -0,0 +1,87 @@ +using System.Reflection; + +namespace CheatEngine.SDK.Hosting.Tests.Coexistence; + +/// +/// Locates the prebuilt output of tests/CheatEngine.SDK.LivePlugin.Coexistence/{PluginA,PluginB} through +/// values this test project's own csproj emits +/// (CoexistencePluginBinRoot, CoexistencePluginConfiguration, RepoRoot), never a +/// ProjectReference to either plugin: a ProjectReference there would change +/// tests/CheatEngine.SDK.LivePlugin.Coexistence/{PluginA,PluginB}/packages.lock.json, which this lot does +/// not own. +/// +internal static class CoexistencePluginLayout +{ + /// The exact <AssemblyName> of Plugin A's csproj. + internal const string PluginAAssemblyName = "CheatEngine.SDK.LivePlugin.Coexistence.PluginA"; + + /// The exact <AssemblyName> of Plugin B's csproj. + internal const string PluginBAssemblyName = "CheatEngine.SDK.LivePlugin.Coexistence.PluginB"; + + /// The repository root, from the RepoRoot assembly metadata. + internal static string RepoRoot => RequireMetadata("RepoRoot"); + + /// Plugin A's own output directory (<bin root>/<assembly name>/<configuration>). + internal static string PluginADirectory => Path.Combine(BinRoot, PluginAAssemblyName, LowercaseConfiguration); + + /// Plugin B's own output directory. + internal static string PluginBDirectory => Path.Combine(BinRoot, PluginBAssemblyName, LowercaseConfiguration); + + private static string BinRoot => RequireMetadata("CoexistencePluginBinRoot"); + + private static string LowercaseConfiguration => RequireMetadata("CoexistencePluginConfiguration").ToLowerInvariant(); + + /// + /// Builds the shared-layout scenario: copies both plugin output directories into one folder. Fails loudly, per + /// the fixture's own README ("do not copy either result into the other directory" is exactly the opposite of + /// the shared-layout scenario's point), if a same-named file differs between the two builds -- the shared + /// dependency files (the SDK assemblies, the bridge) must be byte-identical, since both plugins are built from + /// the same source tree and configuration. + /// + internal static string CreateSharedLayout(string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + CopyDirectoryInto(PluginADirectory, destinationDirectory, compareExisting: false); + CopyDirectoryInto(PluginBDirectory, destinationDirectory, compareExisting: true); + return destinationDirectory; + } + + private static void CopyDirectoryInto(string sourceDirectory, string destinationDirectory, bool compareExisting) + { + foreach (string sourceFile in Directory.EnumerateFiles(sourceDirectory)) + { + string fileName = Path.GetFileName(sourceFile); + string destinationFile = Path.Combine(destinationDirectory, fileName); + if (File.Exists(destinationFile)) + { + if (compareExisting) + { + byte[] sourceBytes = File.ReadAllBytes(sourceFile); + byte[] destinationBytes = File.ReadAllBytes(destinationFile); + if (!sourceBytes.AsSpan().SequenceEqual(destinationBytes)) + { + throw new InvalidOperationException( + $"'{fileName}' differs between Plugin A's and Plugin B's output; the shared-layout scenario requires every same-named file to be identical."); + } + } + + continue; + } + + File.Copy(sourceFile, destinationFile); + } + } + + private static string RequireMetadata(string key) + { + foreach (AssemblyMetadataAttribute attribute in typeof(CoexistencePluginLayout).Assembly.GetCustomAttributes()) + { + if (string.Equals(attribute.Key, key, StringComparison.Ordinal) && !string.IsNullOrEmpty(attribute.Value)) + { + return attribute.Value; + } + } + + throw new InvalidOperationException($"Assembly metadata '{key}' was not emitted by the test project."); + } +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorAlcRoute.cs b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorAlcRoute.cs new file mode 100644 index 00000000..2647c895 --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorAlcRoute.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.SDK.Hosting.Tests.Coexistence; + +/// The hostfxr assembly-load-context route the emulator drives a scenario with. +internal enum NativeHostEmulatorAlcRoute +{ + /// The documented hdt_load_assembly_and_get_function_pointer route: one isolated ALC per assembly path. + Component, + + /// The undocumented hdt_load_assembly + hdt_get_function_pointer route into the default ALC. + Default, +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorEnvironment.cs b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorEnvironment.cs new file mode 100644 index 00000000..0528cbb1 --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorEnvironment.cs @@ -0,0 +1,41 @@ +namespace CheatEngine.SDK.Hosting.Tests.Coexistence; + +/// +/// Locates the directory tests/native-host-emulator/build.ps1 produced. Same opt-in shape as +/// CheatEngine.SDK.Abi.Tests.Fixture.NativeAbiFixtureFacts: unset locally, the emulator-dependent tests +/// assert the documented opt-out and return (never ); required mode without a +/// directory is an actionable error. +/// +internal static class NativeHostEmulatorEnvironment +{ + /// Name of the CI-provided absolute path to the built native host emulator directory. + internal const string DirectoryEnvironmentVariable = "CESDK_NATIVE_HOST_EMULATOR_DIR"; + + /// Name of the opt-in gate that makes the native host emulator mandatory. + internal const string RequiredEnvironmentVariable = "CESDK_NATIVE_HOST_EMULATOR_REQUIRED"; + + /// Reads both environment variables and applies . + internal static string? FromEnvironment() + { + return ResolveDirectory( + Environment.GetEnvironmentVariable(DirectoryEnvironmentVariable), + Environment.GetEnvironmentVariable(RequiredEnvironmentVariable)); + } + + /// Applies the opt-in rule: a supplied directory wins; required mode without one is an error. + internal static string? ResolveDirectory(string? directory, string? requiredMode) + { + if (!string.IsNullOrWhiteSpace(directory)) + { + return directory; + } + + if (string.Equals(requiredMode, "true", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"'{RequiredEnvironmentVariable}=true' requires '{DirectoryEnvironmentVariable}' to name a built native host emulator directory."); + } + + return null; + } +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorResult.cs b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorResult.cs new file mode 100644 index 00000000..635c0560 --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorResult.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Hosting.Tests.Coexistence; + +/// One completed run of ce-host-emulator.exe: its process exit code and its parsed facts file. +internal sealed record NativeHostEmulatorResult(int ExitCode, IReadOnlyDictionary Facts); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorRunner.cs b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorRunner.cs new file mode 100644 index 00000000..d875062d --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorRunner.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace CheatEngine.SDK.Hosting.Tests.Coexistence; + +/// +/// Runs tests/native-host-emulator/build.ps1's output against a pair of plugin directories. One process per +/// scenario (pitfall #10 of s-host.md section 5: only one .NET runtime can ever load per process), a 60 s +/// timeout, and an explicit --dotnet-root derived from the running test host's own runtime directory -- +/// never an installed Cheat Engine, never the ambient environment. +/// +internal static class NativeHostEmulatorRunner +{ + private static readonly TimeSpan ProcessTimeout = TimeSpan.FromSeconds(60); + + internal static NativeHostEmulatorResult Run( + string emulatorDirectory, + string pluginADirectory, + string pluginBDirectory, + NativeHostEmulatorAlcRoute route, + string factsPath) + { + string exePath = Path.Combine(emulatorDirectory, "ce-host-emulator.exe"); + if (!File.Exists(exePath)) + { + throw new FileNotFoundException($"'{exePath}' does not exist: run tests/native-host-emulator/build.ps1 first.", exePath); + } + + string runtimeConfigPath = Path.Combine(emulatorDirectory, "ce-like.runtimeconfig.json"); + string luaPath = Path.Combine(CoexistencePluginLayout.RepoRoot, "native", "cheat-engine", "lua53-64.dll"); + + ProcessStartInfo startInfo = new(exePath) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + AddArgument(startInfo, "--lua", luaPath); + AddArgument(startInfo, "--dotnet-root", ResolveDotNetRoot()); + AddArgument(startInfo, "--runtimeconfig", runtimeConfigPath); + AddArgument(startInfo, "--plugin-a-dir", pluginADirectory); + AddArgument(startInfo, "--plugin-a-assembly", CoexistencePluginLayout.PluginAAssemblyName); + AddArgument(startInfo, "--plugin-b-dir", pluginBDirectory); + AddArgument(startInfo, "--plugin-b-assembly", CoexistencePluginLayout.PluginBAssemblyName); + AddArgument(startInfo, "--alc", route == NativeHostEmulatorAlcRoute.Default ? "default" : "component"); + AddArgument(startInfo, "--facts", factsPath); + + using Process process = new() + { + StartInfo = startInfo, + }; + process.Start(); + string standardOutput = process.StandardOutput.ReadToEnd(); + string standardError = process.StandardError.ReadToEnd(); + bool exited = process.WaitForExit((int) ProcessTimeout.TotalMilliseconds); + if (!exited) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException($"'{exePath}' did not exit within {ProcessTimeout}."); + } + + int exitCode = process.ExitCode; + Dictionary facts = File.Exists(factsPath) + ? ReadFacts(factsPath) + : new Dictionary(StringComparer.Ordinal); + + if (facts.Count == 0 && exitCode != 0) + { + throw new InvalidOperationException( + $"'{exePath}' exited {exitCode} without writing facts. Standard output:\n{standardOutput}\nStandard error:\n{standardError}"); + } + + return new NativeHostEmulatorResult(exitCode, facts); + } + + /// + /// Three levels up from the running test host's own Microsoft.NETCore.App runtime directory + /// (<root>/shared/Microsoft.NETCore.App/<version>/), exactly as s-host.md WI-7 specifies. + /// + private static string ResolveDotNetRoot() + { + // GetRuntimeDirectory() ends with a trailing separator (".../Microsoft.NETCore.App//"), and + // Directory.GetParent/DirectoryInfo.Parent of a trailing-separator path is a documented no-op (it trims the + // separator without moving up a level): trim it first, so three .Parent steps really are three levels up + // ( -> Microsoft.NETCore.App -> shared -> the dotnet root), exactly as s-host.md WI-7 specifies. + string runtimeDirectory = Path.TrimEndingDirectorySeparator(RuntimeEnvironment.GetRuntimeDirectory()); + DirectoryInfo? root = new DirectoryInfo(runtimeDirectory).Parent?.Parent?.Parent; + if (root is null) + { + throw new InvalidOperationException($"Could not derive a .NET root three levels above '{runtimeDirectory}'."); + } + + return root.FullName; + } + + private static void AddArgument(ProcessStartInfo startInfo, string name, string value) + { + startInfo.ArgumentList.Add(name); + startInfo.ArgumentList.Add(value); + } + + private static Dictionary ReadFacts(string path) + { + Dictionary facts = new(StringComparer.Ordinal); + foreach (string line in File.ReadLines(path)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + int separator = line.IndexOf('=', StringComparison.Ordinal); + if (separator <= 0) + { + continue; + } + + facts[line[..separator]] = line[(separator + 1)..]; + } + + return facts; + } +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorTests.cs new file mode 100644 index 00000000..7900b3b1 --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Coexistence/NativeHostEmulatorTests.cs @@ -0,0 +1,287 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Abi.Native; + +namespace CheatEngine.SDK.Hosting.Tests.Coexistence; + +/// +/// Consumes tests/native-host-emulator's built exe against the two Coexistence plugins' prebuilt output +/// (WI-7, SDK-COEX-1, F03, Q09). C2 evidence only: a native hostfxr host, not Cheat Engine. See +/// for the opt-in shape and +/// tests/native-host-emulator/README.md, "Known local dependency", for why every emulator-dependent test +/// below is currently red until a fix lands outside this lot's owned files. +/// +public sealed unsafe partial class NativeHostEmulatorTests +{ + [Fact] + public void Required_mode_rejects_an_absent_emulator_directory() + { + InvalidOperationException exception = Assert.Throws( + () => NativeHostEmulatorEnvironment.ResolveDirectory(null, "true")); + + Assert.Equal( + $"'{NativeHostEmulatorEnvironment.RequiredEnvironmentVariable}=true' requires '{NativeHostEmulatorEnvironment.DirectoryEnvironmentVariable}' to name a built native host emulator directory.", + exception.Message); + } + + [Fact] + public void Emulator_is_optional_when_required_mode_is_off() + { + Assert.Null(NativeHostEmulatorEnvironment.ResolveDirectory(null, null)); + } + + [Fact] + public void Emulator_abi_header_declares_the_managed_record_sizes() + { + string headerPath = Path.Combine(CoexistencePluginLayout.RepoRoot, "tests", "native-host-emulator", "ce_host_emulator_abi.h"); + string headerText = File.ReadAllText(headerPath); + + Dictionary declaredSizes = new(StringComparer.Ordinal); + foreach (Match match in StaticAssertSizeOf().Matches(headerText)) + { + declaredSizes[match.Groups["type"].Value] = int.Parse(match.Groups["size"].Value, CultureInfo.InvariantCulture); + } + + Assert.Equal(36, declaredSizes["CePluginInitRecord"]); + Assert.Equal(sizeof(PluginInitRecord), declaredSizes["CePluginInitRecord"]); + Assert.Equal(48, declaredSizes["CeManagedExportedFunctions"]); + Assert.Equal(sizeof(ManagedExportedFunctions), declaredSizes["CeManagedExportedFunctions"]); + Assert.Equal(16, declaredSizes["CePluginVersion"]); + Assert.Equal(sizeof(PluginVersion), declaredSizes["CePluginVersion"]); + } + + [Trait("Qualification", "Q09.b")] + [Fact] + public void Separate_folders_measure_distinct_hosting_instances_and_disabling_A_leaves_B_callable() + { + string? emulatorDirectory = NativeHostEmulatorEnvironment.FromEnvironment(); + if (emulatorDirectory is null) + { + Assert.Null(emulatorDirectory); + return; + } + + NativeHostEmulatorResult result = RunSeparateComponentScenario(emulatorDirectory, out string factsPath); + + Assert.True(0 == result.ExitCode, DescribeFailure("Q09.b", result, factsPath)); + Assert.Equal("separate", result.Facts["layout"]); + Assert.Equal("component", result.Facts["alc.route"]); + AssertBootstrapAndEnableSucceeded(result, "a", factsPath); + AssertBootstrapAndEnableSucceeded(result, "b", factsPath); + + // The measured relation: the component route isolates each assembly path into its own ALC, so two distinct + // output directories give two distinct PluginHost static instances. + Assert.Equal("true", result.Facts["ab.hosting_type_handle_distinct"]); + + Assert.Equal("nil", result.Facts["after_disable_a.a_identity_type"]); + Assert.Equal("nil", result.Facts["after_disable_a.a_ping_type"]); + Assert.Equal("ok", result.Facts["after_disable_a.b_identity_call"]); + Assert.True(TryParseInt(result.Facts["after_disable_a.b_ping"], out int pingAfterDisableA) && pingAfterDisableA > 0, + DescribeFailure("Q09.b", result, factsPath)); + } + + [Trait("Qualification", "Q09.a")] + [Fact] + public void Shared_folder_under_the_component_route_is_measured_and_disabling_A_leaves_B_callable() + { + string? emulatorDirectory = NativeHostEmulatorEnvironment.FromEnvironment(); + if (emulatorDirectory is null) + { + Assert.Null(emulatorDirectory); + return; + } + + string sharedDirectory = Path.Combine(Path.GetTempPath(), "cesdk-native-host-emulator-" + Guid.NewGuid().ToString("N")); + try + { + CoexistencePluginLayout.CreateSharedLayout(sharedDirectory); + string factsPath = Path.Combine(sharedDirectory, "facts-q09a.txt"); + NativeHostEmulatorResult result = NativeHostEmulatorRunner.Run( + emulatorDirectory, sharedDirectory, sharedDirectory, NativeHostEmulatorAlcRoute.Component, factsPath); + + Assert.True(0 == result.ExitCode, DescribeFailure("Q09.a", result, factsPath)); + Assert.Equal("shared", result.Facts["layout"]); + Assert.Equal("component", result.Facts["alc.route"]); + AssertBootstrapAndEnableSucceeded(result, "a", factsPath); + AssertBootstrapAndEnableSucceeded(result, "b", factsPath); + + // Reported, not presumed (s-host.md WI-7): the component route keys isolation on the assembly path, so a + // shared output folder measures the same "distinct instances" relation as separate folders. + Assert.Equal("true", result.Facts["ab.hosting_mvid_equal"]); + Assert.Equal("true", result.Facts["ab.hosting_type_handle_distinct"]); + + Assert.Equal("ok", result.Facts["after_disable_a.b_identity_call"]); + Assert.True(TryParseInt(result.Facts["after_disable_a.b_ping"], out int ping) && ping > 0, + DescribeFailure("Q09.a", result, factsPath)); + } + finally + { + TryDeleteDirectory(sharedDirectory); + } + } + + [Trait("Qualification", "Q09")] + [Fact] + public void Default_context_loading_rejects_the_second_plugin_deterministically() + { + string? emulatorDirectory = NativeHostEmulatorEnvironment.FromEnvironment(); + if (emulatorDirectory is null) + { + Assert.Null(emulatorDirectory); + return; + } + + string sharedDirectory = Path.Combine(Path.GetTempPath(), "cesdk-native-host-emulator-" + Guid.NewGuid().ToString("N")); + try + { + CoexistencePluginLayout.CreateSharedLayout(sharedDirectory); + string factsPath = Path.Combine(sharedDirectory, "facts-q09.txt"); + NativeHostEmulatorResult result = NativeHostEmulatorRunner.Run( + emulatorDirectory, sharedDirectory, sharedDirectory, NativeHostEmulatorAlcRoute.Default, factsPath); + + Assert.True(0 == result.ExitCode, DescribeFailure("Q09", result, factsPath)); + Assert.Equal("default", result.Facts["alc.route"]); + + // A (the first factory registered in the shared Hosting instance) succeeds. + Assert.Equal("ok", result.Facts["a.bootstrap.second"]); + Assert.Equal("ok", result.Facts["a.enable.1"]); + Assert.Contains("Default", result.Facts["a.identity.plugin_alc"], StringComparison.Ordinal); + + // B shares that same Hosting instance (the default ALC never isolates it), so PluginHost's "one factory + // per loaded Hosting instance" rule (PluginHost.cs TryRegisterFactory) rejects it deterministically, at + // the bootstrap call itself -- this is measured from the real generated bootstrap, not asserted from + // documentation. + Assert.Equal("failed", result.Facts["b.bootstrap.first"]); + Assert.Equal("failed", result.Facts["b.bootstrap.second"]); + Assert.Equal("skipped", result.Facts["b.enable.1"]); + } + finally + { + TryDeleteDirectory(sharedDirectory); + } + } + + [Trait("Qualification", "Q02")] + [Fact] + public void Bootstrap_canaries_and_name_pointers_stay_intact_for_both_plugins() + { + string? emulatorDirectory = NativeHostEmulatorEnvironment.FromEnvironment(); + if (emulatorDirectory is null) + { + Assert.Null(emulatorDirectory); + return; + } + + NativeHostEmulatorResult result = RunSeparateComponentScenario(emulatorDirectory, out string factsPath); + + Assert.True(0 == result.ExitCode, DescribeFailure("Q02", result, factsPath)); + Assert.Equal("intact", result.Facts["a.bootstrap.guard"]); + Assert.Equal("intact", result.Facts["b.bootstrap.guard"]); + Assert.Equal("true", result.Facts["a.bootstrap.name_pointer_stable"]); + Assert.Equal("true", result.Facts["b.bootstrap.name_pointer_stable"]); + } + + [Trait("Qualification", "Q05")] + [Fact] + public void Reenabling_A_answers_with_a_new_epoch() + { + string? emulatorDirectory = NativeHostEmulatorEnvironment.FromEnvironment(); + if (emulatorDirectory is null) + { + Assert.Null(emulatorDirectory); + return; + } + + NativeHostEmulatorResult result = RunSeparateComponentScenario(emulatorDirectory, out string factsPath); + + Assert.True(0 == result.ExitCode, DescribeFailure("Q05", result, factsPath)); + Assert.Equal("ok", result.Facts["a.enable.2"]); + Assert.True(TryParseInt(result.Facts["a.identity.epoch"], out int firstEpoch), DescribeFailure("Q05", result, factsPath)); + Assert.True(TryParseInt(result.Facts["a.identity.epoch_after_reenable"], out int reenabledEpoch), + DescribeFailure("Q05", result, factsPath)); + Assert.True(reenabledEpoch > firstEpoch, DescribeFailure("Q05", result, factsPath)); + } + + [Fact] + public void Emulator_facts_contain_no_absolute_path() + { + string? emulatorDirectory = NativeHostEmulatorEnvironment.FromEnvironment(); + if (emulatorDirectory is null) + { + Assert.Null(emulatorDirectory); + return; + } + + NativeHostEmulatorResult result = RunSeparateComponentScenario(emulatorDirectory, out string factsPath); + + foreach (KeyValuePair fact in result.Facts) + { + Assert.False(AbsolutePathPattern().IsMatch(fact.Value), + $"Fact '{fact.Key}'='{fact.Value}' contains what looks like an absolute path."); + } + } + + private static NativeHostEmulatorResult RunSeparateComponentScenario(string emulatorDirectory, out string factsPath) + { + factsPath = Path.Combine(Path.GetTempPath(), $"cesdk-native-host-emulator-{Guid.NewGuid():N}.txt"); + return NativeHostEmulatorRunner.Run( + emulatorDirectory, + CoexistencePluginLayout.PluginADirectory, + CoexistencePluginLayout.PluginBDirectory, + NativeHostEmulatorAlcRoute.Component, + factsPath); + } + + private static void AssertBootstrapAndEnableSucceeded(NativeHostEmulatorResult result, string label, string factsPath) + { + Assert.True(string.Equals("ok", result.Facts[$"{label}.bootstrap.first"], StringComparison.Ordinal), DescribeFailure(label, result, factsPath)); + Assert.True(string.Equals("ok", result.Facts[$"{label}.bootstrap.second"], StringComparison.Ordinal), DescribeFailure(label, result, factsPath)); + Assert.True(string.Equals("ok", result.Facts[$"{label}.getversion"], StringComparison.Ordinal), DescribeFailure(label, result, factsPath)); + Assert.True(string.Equals("ok", result.Facts[$"{label}.enable.1"], StringComparison.Ordinal), DescribeFailure(label, result, factsPath)); + } + + private static bool TryParseInt(string text, out int value) + { + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + + private static string DescribeFailure(string label, NativeHostEmulatorResult result, string factsPath) + { + StringBuilder builder = new(); + builder.Append(CultureInfo.InvariantCulture, $"[{label}] exit={result.ExitCode} facts='{factsPath}':"); + foreach (KeyValuePair fact in result.Facts) + { + builder.Append(CultureInfo.InvariantCulture, $" {fact.Key}={fact.Value};"); + } + + return builder.ToString(); + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException) + { + // Best-effort cleanup: a lingering file handle on a temp copy of the emulator's inputs never fails the test. + } + catch (UnauthorizedAccessException) + { + // Same rationale as above. + } + } + + [GeneratedRegex(@"static_assert\(sizeof\((?\w+)\)\s*==\s*(?\d+)", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex StaticAssertSizeOf(); + + [GeneratedRegex(@"[A-Za-z]:[\\/]|\\\\[A-Za-z0-9._-]+\\", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] + private static partial Regex AbsolutePathPattern(); +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/HostLogContainmentTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/HostLogContainmentTests.cs new file mode 100644 index 00000000..8405ae9a --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/HostLogContainmentTests.cs @@ -0,0 +1,183 @@ +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Diagnostics; +using CheatEngine.SDK.Hosting.Tests.Support; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Hosting.Tests.Diagnostics; + +/// +/// WI-6 (A24-21, SRC02-06, A02-11): a faulty or reentrant is contained during every +/// ABI callback. It cannot recurse into , cannot re-enter a lifecycle transition, and +/// cannot acquire Lua while a transition owns admission; none of that waits for the sink. +/// +public sealed unsafe class HostLogContainmentTests : IDisposable +{ + public HostLogContainmentTests() + { + HostLog.ResetForTests(); + } + + public void Dispose() + { + HostLog.ResetForTests(); + } + + [Fact] + public void A_sink_that_writes_to_HostLog_from_its_own_Write_is_not_reentered_and_is_counted() + { + int outerWrites = 0; + int innerWrites = 0; + HostLog.Sink = new CallbackLogSink(message => + { + outerWrites++; + if (string.Equals(message, "outer", StringComparison.Ordinal)) + { + HostLog.Write(HostLogLevel.Error, "inner"); + } + else if (string.Equals(message, "inner", StringComparison.Ordinal)) + { + innerWrites++; + } + }); + + HostLog.Write(HostLogLevel.Error, "outer"); + + Assert.Equal(1, outerWrites); + Assert.Equal(0, innerWrites); + Assert.Equal(1, HostLog.DroppedReentrantEntries); + + // Other threads are never affected by this thread's guard. + int otherThreadWrites = 0; + Thread other = new(() => + { + HostLog.Sink = new CallbackLogSink(_ => Interlocked.Increment(ref otherThreadWrites)); + HostLog.Write(HostLogLevel.Error, "from another thread"); + }); + other.Start(); + Assert.True(other.Join(TimeSpan.FromSeconds(5))); + Assert.Equal(1, otherThreadWrites); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_throwing_sink_during_EnablePlugin_and_DisablePlugin_never_escapes_and_results_match_the_no_sink_case() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostLog.Sink = new ThrowingLogSink(); + HostLog.MinimumLevel = HostLogLevel.Trace; + + Exception? enableFailure = Record.Exception(() => HostingTest.Enable(host, state)); + Assert.Null(enableFailure); + Assert.True(PluginHost.IsEnabled); + + Exception? disableFailure = Record.Exception(() => Assert.True(host.CallDisable().IsTrue)); + Assert.Null(disableFailure); + Assert.False(PluginHost.IsEnabled); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_reentrant_sink_during_EnablePlugin_cannot_recurse_and_the_enable_result_stands() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + int sinkEntries = 0; + HostLog.MinimumLevel = HostLogLevel.Trace; + HostLog.Sink = new CallbackLogSink(message => + { + sinkEntries++; + // A sink calling back into HostLog.Write from here would previously recurse; now it is dropped and + // counted instead, and this Write call returns normally either way. + HostLog.Write(HostLogLevel.Trace, "reentrant from " + message); + }); + + Exception? failure = Record.Exception(() => HostingTest.Enable(host, state)); + + Assert.Null(failure); + Assert.True(PluginHost.IsEnabled); + Assert.True(sinkEntries > 0); + Assert.True(HostLog.DroppedReentrantEntries > 0); + } + + [Fact] + [Trait("Category", "NativeLua")] + public void A_sink_that_acquires_a_Lua_operation_during_a_lifecycle_transition_is_contained() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + HostLog.MinimumLevel = HostLogLevel.Trace; + bool? acquiredDuringOnDisable = null; + Exception? acquisitionFailure = null; + HostLog.Sink = new CallbackLogSink(message => + { + if (string.Equals(message, "RecordingPlugin.OnDisable observed", StringComparison.Ordinal) + && acquiredDuringOnDisable is null) + { + // OnDisable still runs on the captured main thread while the runtime is attached (operation + // admission itself does not close until after OnDisable returns): the acquisition succeeds and + // completes immediately. The containment being proven is that a sink doing this from inside a + // native callback neither crashes nor deadlocks the transition. + acquisitionFailure = Record.Exception(() => + { + acquiredDuringOnDisable = LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation); + operation.Dispose(); + }); + } + }); + + Assert.True(host.CallDisable().IsTrue); + + Assert.Null(acquisitionFailure); + Assert.NotNull(acquiredDuringOnDisable); + Assert.True(acquiredDuringOnDisable); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + } + + [Fact] + public void A_second_factory_rejection_is_logged_outside_the_registration_lock() + { + HostingTest.Reset(); + using HostSimulator first = new(); + using HostSimulator other = new(); + Assert.Equal(1, first.Initialize()); + bool? gateHeldWhileLogging = null; + HostLog.Sink = new CallbackLogSink(message => + { + if (message.Contains("already registered", StringComparison.Ordinal) && gateHeldWhileLogging is null) + { + gateHeldWhileLogging = PluginHost.IsRegistrationGateHeldByCurrentThreadForTesting; + } + }); + + Assert.Equal(0, other.Initialize()); + + Assert.NotNull(gateHeldWhileLogging); + Assert.False(gateHeldWhileLogging); + } + + private sealed class CallbackLogSink(Action onWrite) : IHostLogSink + { + public void Write(HostLogLevel level, string message, Exception? exception) + { + onWrite(message); + } + } + + private sealed class ThrowingLogSink : IHostLogSink + { + public void Write(HostLogLevel level, string message, Exception? exception) + { + throw new NotSupportedException("sink failure requested by the test"); + } + } +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/LoadIdentificationTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/LoadIdentificationTests.cs new file mode 100644 index 00000000..71e50026 --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Diagnostics/LoadIdentificationTests.cs @@ -0,0 +1,289 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Abi; +using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Diagnostics; +using CheatEngine.SDK.Hosting.Tests.Support; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Hosting.Tests.Diagnostics; + +/// +/// The opt-in, bounded CheatEngineSdkIdentification diagnostic (A21-34, A24-11, A24-19, A20-Q46-3): silent +/// unless requested, emitted at most once per enable attempt before the exports record is copied, path-free and +/// built without touching Lua or plugin code. +/// +public sealed unsafe partial class LoadIdentificationTests +{ + private const string Prefix = "CheatEngineSdkIdentification: "; + + [GeneratedRegex(@"lua\.module=(?[^;]*); lua\.sha256=(?[0-9a-f]{64}|unavailable)", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] + private static partial Regex LuaModuleFieldsPattern(); + + [GeneratedRegex("^[A-Za-z]:", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex DriveRootPattern(); + + private static readonly string[] SExpectedKeys = + [ + "sdk.version", "sdk.commit", "sdk.consistent", "hosting.mvid", "hosting.alc", "plugin.id", "plugin.assembly", + "host.argument", "exports.size", "bridge.fingerprint", "bridge.sha256", "lua.module", "lua.sha256", "ce.file", + "ce.fileVersion", "runtime", "arch" + ]; + + [Fact] + public void Identification_is_not_emitted_unless_opted_in() + { + CapturingLogSink sink = HostingTest.Reset(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + host.CallEnable(&exports, 1); + + Assert.Equal(0, CountIdentificationEntries(sink)); + } + + [Fact] + public void Identification_is_emitted_once_per_enable_attempt_when_opted_in_programmatically() + { + CapturingLogSink sink = HostingTest.Reset(); + HostLog.IdentifyOnEnable = true; + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + host.CallEnable(&exports, 1); + + Assert.Equal(1, CountIdentificationEntries(sink)); + } + + [Fact] + public void Identification_is_emitted_when_the_environment_opt_in_is_set() + { + CapturingLogSink sink = HostingTest.Reset(); + HostLog.IdentifyOnEnableEnvironmentReader = static () => "1"; + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + host.CallEnable(&exports, 1); + + // The seam opted in; the real process environment was never touched. + Assert.False(HostLog.IdentifyOnEnable); + Assert.Null(Environment.GetEnvironmentVariable(HostLog.IdentifyOnEnableEnvironmentVariable)); + Assert.Equal(1, CountIdentificationEntries(sink)); + } + + [Trait("Qualification", "Q46")] + [Fact] + public void Identification_contains_the_sdk_version_and_a_40_hex_commit_or_unknown() + { + string line = EnableWithIdentification(); + + Assert.Matches(@"sdk\.version=[^;]*; sdk\.commit=(?:[0-9a-f]{40}|unknown); sdk\.consistent=(?:true|false)(?:; |$)", + line); + } + + [Trait("Qualification", "Q46")] + [Fact] + public void Identification_contains_the_bridge_fingerprint_or_unavailable() + { + string line = EnableWithIdentification(); + + Assert.Matches(@"bridge\.fingerprint=(?:unavailable|[0-9a-f]{64}:[0-9a-f]{64})(?:; |$)", line); + } + + [Trait("Qualification", "Q46")] + [Trait("Category", "NativeLua")] + [Fact] + public void Identification_contains_the_lua_module_file_name_and_sha256_without_a_directory() + { + HostingTest.RequireNativeLua(); + string line = EnableWithIdentification(useNativeLuaModule: true); + + Match match = LuaModuleFieldsPattern().Match(line); + Assert.True(match.Success, "lua.module/lua.sha256 not found in: " + line); + string moduleName = match.Groups["module"].Value; + Assert.DoesNotContain('\\', moduleName); + Assert.DoesNotContain('/', moduleName); + Assert.Equal(Path.GetFileName(NativeLuaLibrary.LibraryPath!), moduleName); + Assert.Matches("^[0-9a-f]{64}$", match.Groups["sha"].Value); + } + + [Trait("Qualification", "Q46")] + [Trait("Category", "NativeLua")] + [Fact] + public void Identification_never_contains_a_directory_separator_drive_root_or_user_name() + { + HostingTest.RequireNativeLua(); + string line = EnableWithIdentification(useNativeLuaModule: true); + + foreach ((string _, string value) in ParsePairs(line)) + { + Assert.DoesNotContain('\\', value); + Assert.DoesNotContain('/', value); + Assert.False(DriveRootPattern().IsMatch(value), "drive root in value: " + value); + if (!string.IsNullOrEmpty(Environment.UserName)) + { + Assert.DoesNotContain(Environment.UserName, value, StringComparison.OrdinalIgnoreCase); + } + } + } + + [Trait("Qualification", "Q46")] + [Fact] + public void Identification_is_bounded_and_keeps_a_fixed_key_order() + { + string line = EnableWithIdentification(); + + Assert.True(line.Length <= 1024, "entry exceeds 1024 characters: " + line.Length); + + string[] actualKeys = ExtractKeys(line); + Assert.Equal(SExpectedKeys, actualKeys); + + foreach ((string key, string value) in ParsePairs(line)) + { + // bridge.fingerprint is the single fixed-shape "<64 hex>:<64 hex>" exception (129 characters), validated + // by its own pattern instead of the general per-value bound. + if (string.Equals(key, "bridge.fingerprint", StringComparison.Ordinal)) + { + Assert.Matches("^(?:unavailable|[0-9a-f]{64}:[0-9a-f]{64})$", value); + continue; + } + + Assert.True(value.Length <= 128, "value exceeds 128 characters: " + value); + } + } + + [Fact] + public void Identification_calls_no_Lua_and_no_plugin_code() + { + HostingTest.Reset(); + HostLog.IdentifyOnEnable = true; + ManagedExportedFunctions exports = FakeExports.Create(); + + LoadIdentification.EmitIfRequested(&exports, 1, typeof(RecordingPluginFactory)); + + Assert.Equal(0, FakeExports.GetLuaStateCalls); + Assert.Equal(0, RecordingPlugin.ConstructorCalls); + Assert.Null(RecordingPlugin.LastConstructed); + } + + [Fact] + public void Identification_is_emitted_even_when_the_Lua_bind_fails() + { + CapturingLogSink sink = HostingTest.Reset(); + HostLog.IdentifyOnEnable = true; + HostingTest.UseNoModule(); + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + Bool32 result = host.CallEnable(&exports, 1); + + Assert.False(result.IsTrue); + Assert.Equal(1, CountIdentificationEntries(sink)); + } + + [Theory] + [InlineData(int.MinValue)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + [InlineData(36)] + [InlineData(int.MaxValue)] + public void Identification_records_the_raw_host_argument_without_interpreting_it(int hostArgument) + { + CapturingLogSink sink = HostingTest.Reset(); + HostLog.IdentifyOnEnable = true; + using HostSimulator host = new(); + Assert.Equal(1, host.Initialize(hostArgument)); + ManagedExportedFunctions exports = FakeExports.Create(); + + host.CallEnable(&exports, 1); + + string line = RequireIdentificationLine(sink); + string expected = "host.argument=" + hostArgument.ToString(CultureInfo.InvariantCulture); + Assert.Contains(expected, line, StringComparison.Ordinal); + } + + private static string EnableWithIdentification(bool useNativeLuaModule = false) + { + CapturingLogSink sink = HostingTest.Reset(); + HostLog.IdentifyOnEnable = true; + if (useNativeLuaModule) + { + LuaModuleLocator.Resolver = &ResolveNativeLuaModule; + } + + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + host.CallEnable(&exports, 1); + return RequireIdentificationLine(sink); + } + + private static nint ResolveNativeLuaModule() + { + return NativeLuaLibrary.Handle; + } + + private static string RequireIdentificationLine(CapturingLogSink sink) + { + foreach ((HostLogLevel level, string message, Exception? _) in sink.Entries) + { + if (level == HostLogLevel.Information && message.StartsWith(Prefix, StringComparison.Ordinal)) + { + return message; + } + } + + Assert.Fail("No CheatEngineSdkIdentification entry was captured."); + return string.Empty; // unreachable + } + + private static int CountIdentificationEntries(CapturingLogSink sink) + { + int count = 0; + foreach ((HostLogLevel level, string message, Exception? _) in sink.Entries) + { + if (level == HostLogLevel.Information && message.StartsWith(Prefix, StringComparison.Ordinal)) + { + count++; + } + } + + return count; + } + + private static string[] ExtractKeys(string line) + { + (string Key, string Value)[] pairs = ParsePairs(line); + string[] keys = new string[pairs.Length]; + for (int i = 0; i < pairs.Length; i++) + { + keys[i] = pairs[i].Key; + } + + return keys; + } + + private static (string Key, string Value)[] ParsePairs(string line) + { + string body = line[Prefix.Length..]; + string[] segments = body.Split("; "); + (string, string)[] result = new (string, string)[segments.Length]; + for (int i = 0; i < segments.Length; i++) + { + int equals = segments[i].IndexOf('='); + result[i] = equals >= 0 + ? (segments[i][..equals], segments[i][(equals + 1)..]) + : (segments[i], string.Empty); + } + + return result; + } +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs index 9208ba07..92b7bb2a 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs @@ -10,6 +10,7 @@ using CheatEngine.SDK.Hosting.Tests.Support; using CheatEngine.SDK.Hosting.Threading; using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -59,6 +60,7 @@ public void Disable_from_a_non_main_thread_is_refused_without_starting_cleanup() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] public void Disable_from_an_admitted_Lua_operation_is_refused_without_changing_the_lifecycle() { HostingTest.RequireNativeLua(); @@ -81,6 +83,7 @@ public void Disable_from_an_admitted_Lua_operation_is_refused_without_changing_t [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] public void Disable_from_executing_dispatched_work_is_refused_without_waiting_for_that_work() { HostingTest.RequireNativeLua(); @@ -131,6 +134,7 @@ public void Disable_from_executing_dispatched_work_is_refused_without_waiting_fo [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] [SuppressMessage("Meziantou.Analyzer", "MA0051", Justification = "This test deliberately covers the complete close-drain-detach sequence in one deterministic scenario.")] @@ -208,6 +212,155 @@ public void Disable_on_the_GUI_thread_pumps_admitted_worker_work_before_detachin Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); } + [Fact] + [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] + public void Disable_while_a_worker_waits_in_a_Lua_synchronize_dispatch_pumps_it_before_detach() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + LuaState main = new(state.Pointer); + using RealSynchronizeStandIn standIn = RealSynchronizeStandIn.Install(main); + FakeExports.CheckSynchronizeHandlerForTests = RealSynchronizeStandIn.TryPumpQueuedCall; + int mainThreadId = Environment.CurrentManagedThreadId; + int[] executedThreadId = new int[1]; + + // A05-01/A08-28: unlike Disable_on_the_GUI_thread_pumps_admitted_worker_work_before_detaching, which + // intercepts MainThreadDispatcher.Dispatch directly, this worker goes through the real Lua `synchronize` + // global. Disable's CheckSynchronize drain (PluginHost.DrainAdmittedMainThreadWork) must busy-poll that real + // Lua call to completion before it detaches, exactly as it would while Cheat Engine's GUI thread waits for a + // plugin worker that is blocked inside the host's own synchronize. + Task worker = Task.Factory.StartNew( + () => Record.Exception(() => MainThread.Invoke( + static box => box[0] = Environment.CurrentManagedThreadId, executedThreadId)), + TestContext.Current.CancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + Assert.True(RealSynchronizeStandIn.WaitUntilQueued(TimeSpan.FromSeconds(5)), + "The worker never reached the real Lua synchronize call."); + Assert.True(host.CallDisable().IsTrue); + Exception? workerFailure = AwaitWorker(worker); + + Assert.Null(workerFailure); + Assert.True(RealSynchronizeStandIn.Pumped, "The drain never ran the worker's queued Lua call."); + Assert.Equal(mainThreadId, executedThreadId[0]); + Assert.True(FakeExports.CheckSynchronizeCalls > 0); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(PluginHostLifecyclePhase.Registered, PluginHost.Phase); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + // A plain (non-test, non-unsafe-context) helper: xUnit1031 targets [Fact]/[Theory] methods specifically, so the + // bounded blocking wait lives here instead of in the test method itself. + private static Exception? AwaitWorker(Task worker) + { + return worker.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).GetAwaiter() + .GetResult(); + } + + // A native `synchronize` stand-in that genuinely hops to the real calling (main) thread, unlike + // MainThreadDispatcher.DispatchOverrideForTests: the worker blocks inside an actual Lua call while Disable's + // CheckSynchronize drain busy-polls TryPumpQueuedCall from the main thread until the queued call has been + // pushed, run exactly once, and released (A08-28). A call before anything is queued, or after the one queued + // call already ran, is a harmless no-op: the drain loop calls it repeatedly while it spins. + private sealed unsafe class RealSynchronizeStandIn : IDisposable + { + private static ManualResetEventSlim? s_queued; + private static ManualResetEventSlim? s_completed; + private static LuaRef? s_pendingRef; + private static LuaStatus s_pumpedCallStatus; + private static int s_pumped; + + private RealSynchronizeStandIn() + { + } + + public static bool Pumped => Volatile.Read(ref s_pumped) != 0; + + /// Waits until the worker's synchronize call has actually queued a pending call. + public static bool WaitUntilQueued(TimeSpan timeout) + { + return s_queued!.Wait(timeout); + } + + public static RealSynchronizeStandIn Install(LuaState main) + { + s_queued = new ManualResetEventSlim(false); + s_completed = new ManualResetEventSlim(false); + s_pendingRef = null; + s_pumped = 0; + Assert.True(main.TryPushFunction(new LuaNativeFunction(&SynchronizeThunk)).IsOk); + Assert.True(main.TrySetGlobal("synchronize"u8).IsOk); + return new RealSynchronizeStandIn(); + } + + public static void TryPumpQueuedCall() + { + if (!s_queued!.Wait(0)) + { + return; + } + + LuaRef? pendingRef = Interlocked.Exchange(ref s_pendingRef, null); + if (pendingRef is null) + { + return; + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + Assert.True(state.TryPushRef(pendingRef)); + s_pumpedCallStatus = state.TryCall(0, 0); + pendingRef.Release(state); + Interlocked.Exchange(ref s_pumped, 1); + s_completed!.Set(); + } + + public void Dispose() + { + if (Pumped) + { + Assert.True(s_pumpedCallStatus.IsOk, "The pumped call failed."); + } + + s_queued?.Dispose(); + s_completed?.Dispose(); + s_queued = null; + s_completed = null; + s_pendingRef = null; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int SynchronizeThunk(nint pointer) + { + LuaState state = new(pointer); + try + { + if (!state.IsFunction(1)) + { + return LuaThunk.Fail(state, "the real stand-in expects a function argument"u8); + } + + state.PushValue(1); + s_pendingRef = state.CreateRef(); + s_queued!.Set(); + if (!s_completed!.Wait(TimeSpan.FromSeconds(5))) + { + return LuaThunk.Fail(state, "the main-thread drain never ran the queued call"u8); + } + + return 0; + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } + } + [Fact] public void Disabling_while_disabled_is_a_no_op_reported_as_TRUE_with_a_warning() { @@ -252,6 +405,7 @@ public void Disable_runs_OnDisable_while_attached_then_detaches_and_withdraws_th [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q15")] public void Disable_neutralizes_the_callbacks_the_plugin_forgot() { HostingTest.RequireNativeLua(); @@ -364,6 +518,7 @@ public void OnDisable_throwing_is_logged_but_reports_TRUE_after_the_plugin_is_di [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q05")] public void Enable_disable_enable_reuses_the_instance_and_attaches_with_a_new_epoch() { HostingTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs index 58779d5c..816dec10 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs @@ -23,6 +23,7 @@ namespace CheatEngine.SDK.Hosting.Tests.Lifecycle; public sealed unsafe class EnablePluginTests { [Fact] + [Trait("Qualification", "Q03")] public void A_null_exports_record_fails_without_touching_Lua() { CapturingLogSink sink = HostingTest.Reset(); @@ -43,6 +44,7 @@ public void A_null_exports_record_fails_without_touching_Lua() [InlineData(40)] [InlineData(47)] [InlineData(-48)] + [Trait("Qualification", "Q03")] public void An_undersized_exports_record_fails_cleanly(int reportedSize) { CapturingLogSink sink = HostingTest.Reset(); @@ -61,6 +63,7 @@ public void An_undersized_exports_record_fails_cleanly(int reportedSize) } [Fact] + [Trait("Qualification", "Q03")] public void A_record_without_GetLuaState_fails() { CapturingLogSink sink = HostingTest.Reset(); @@ -88,6 +91,7 @@ public void Enable_before_the_bootstrap_fails() } [Fact] + [Trait("Qualification", "Q11")] public void Without_a_Lua_module_in_the_process_the_enable_fails_before_any_plugin_code() { CapturingLogSink sink = HostingTest.Reset(); @@ -224,6 +228,7 @@ public void A_state_provider_that_returns_null_fails_the_self_check() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q06")] public void OnEnable_throwing_makes_the_enable_fail_and_detaches_the_runtime() { HostingTest.RequireNativeLua(); @@ -251,6 +256,7 @@ public void OnEnable_throwing_makes_the_enable_fail_and_detaches_the_runtime() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q08")] public void OnEnable_failure_with_detach_failure_keeps_incomplete_cleanup_retryable() { HostingTest.RequireNativeLua(); @@ -299,6 +305,7 @@ public void OnEnable_failure_with_detach_failure_keeps_incomplete_cleanup_retrya [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q08")] public void A_failed_cleanup_retry_rejects_nested_disable_until_the_retry_unwinds() { HostingTest.RequireNativeLua(); @@ -339,7 +346,12 @@ public void A_failed_cleanup_retry_rejects_nested_disable_until_the_retry_unwind Assert.False(nestedResult); Assert.True(LuaRuntime.IsAttached); Assert.Equal(PluginHostLifecyclePhase.Disabling, PluginHost.Phase); - Assert.True(sink.HasEntry(HostLogLevel.Error, "a disable transition is already completing")); + // WI-6: the nested call's own "a disable transition is already completing" log happens while this + // thread is still inside the outer HostLog.Write that dispatched to sink.OnMessage, so it is a + // reentrant write on the same thread. It is dropped and counted instead of reaching the sink, which is + // exactly the containment the nested lifecycle refusal above (nestedResult == false) does not depend on. + Assert.False(sink.HasEntry(HostLogLevel.Error, "a disable transition is already completing")); + Assert.True(HostLog.DroppedReentrantEntries > 0); LuaCallbackRegistry.AfterReleaseForTesting = null; sink.OnMessage = null; @@ -356,6 +368,7 @@ public void A_failed_cleanup_retry_rejects_nested_disable_until_the_retry_unwind [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q06")] public void A_throwing_constructor_fails_the_enable_and_is_retried_on_the_next_enable() { HostingTest.RequireNativeLua(); @@ -423,6 +436,7 @@ public void Enabling_twice_without_a_disable_is_ignored_with_a_warning() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q11")] public void The_production_module_lookup_finds_the_fixture_when_it_is_Cheat_Engines_DLL() { HostingTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ExternalResetLifecycleTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ExternalResetLifecycleTests.cs new file mode 100644 index 00000000..c6281b5d --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ExternalResetLifecycleTests.cs @@ -0,0 +1,77 @@ +using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Tests.Support; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Hosting.Tests.Lifecycle; + +/// +/// WI-3 (A08-21, A08-22, A20-Q18-2): the host replacing its Lua state outside the SDK's controlled reset path is +/// detected, logged once, and the plugin recovers cleanly through the ordinary disable/enable cycle. +/// +public sealed unsafe class ExternalResetLifecycleTests +{ + [Fact] + [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q18")] + public void An_external_reset_during_enable_logs_one_LuaStateReplacedExternally_error() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using NativeLuaState state = new(); + using NativeLuaState replacement = new(); + using HostSimulator host = new(); + bool triggeringAcquisitionSucceeded = true; + RecordingPlugin.ActionInOnEnable = () => + { + // Simulate the host swapping the Lua state behind the SDK's back while this very enable's OnEnable is + // still running, then take one admitted operation to trigger detection. + FakeExports.UseState(replacement.L); + triggeringAcquisitionSucceeded = LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation); + operation.Dispose(); + }; + + RecordingPlugin plugin = HostingTest.Enable(host, state); + + Assert.False(triggeringAcquisitionSucceeded); + Assert.True(PluginHost.IsEnabled); + Assert.True(LuaRuntime.ExternalStateResetDetected); + Assert.Single(sink.Errors("LuaStateReplacedExternally")); + Assert.Equal(1, plugin.EnableCalls); + } + + [Fact] + [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q18")] + public void Disable_after_an_external_reset_reports_TRUE_after_abandoning_and_the_next_enable_works() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using NativeLuaState replacement = new(); + using HostSimulator host = new(); + RecordingPlugin.CreateCallbacksInOnEnable = true; + RecordingPlugin plugin = HostingTest.Enable(host, state); + Assert.NotNull(plugin.CallbackOne); + + FakeExports.UseState(replacement.L); + Assert.False(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation triggering)); + triggering.Dispose(); + Assert.True(LuaRuntime.ExternalStateResetDetected); + + Assert.True(host.CallDisable().IsTrue); + + Assert.True(plugin.CallbackOne!.IsReleased); + Assert.True(plugin.CallbackTwo!.IsReleased); + Assert.False(PluginHost.IsEnabled); + + // Re-enable on the original state works again: Attach clears the external-reset flag and restamps. + FakeExports.UseState(state.L); + ManagedExportedFunctions exports = FakeExports.Create(); + Assert.True(host.CallEnable(&exports, 1).IsTrue); + + Assert.True(PluginHost.IsEnabled); + Assert.False(LuaRuntime.ExternalStateResetDetected); + } +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/PartialLuaModuleEnableTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/PartialLuaModuleEnableTests.cs new file mode 100644 index 00000000..5045b8c1 --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/PartialLuaModuleEnableTests.cs @@ -0,0 +1,58 @@ +using CheatEngine.SDK.Abi; +using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Api; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Hosting.Tests.Lifecycle; + +/// +/// Q11 through the whole enable path: the module resolver seam hands the host a copy of the Lua fixture that lacks +/// one export (). The enable must fail before any plugin code and before the host +/// asks for its Lua state, and the log must name the missing export. The copy is loaded in this test process only. +/// +public sealed unsafe class PartialLuaModuleEnableTests +{ + private static nint s_partialModule; + + [Fact] + [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q11")] + public void Enable_with_a_partial_lua_module_fails_before_any_plugin_code_and_names_the_missing_export() + { + HostingTest.RequireNativeLua(); + CapturingLogSink sink = HostingTest.Reset(); + using PartialLuaModule module = PartialLuaModule.Load(NativeLuaLibrary.LibraryPath!); + nint boundBefore = LuaApi.ModuleHandle; + s_partialModule = module.Handle; + try + { + LuaModuleLocator.Resolver = &ResolvePartialModule; + using HostSimulator host = new(); + HostingTest.Bootstrap(host); + ManagedExportedFunctions exports = FakeExports.Create(); + + Bool32 result = host.CallEnable(&exports, 1); + + Assert.False(result.IsTrue); + Assert.False(PluginHost.IsEnabled); + Assert.False(LuaRuntime.IsAttached); + Assert.Equal(0, RecordingPlugin.ConstructorCalls); + Assert.Equal(0, FakeExports.GetLuaStateCalls); + Assert.NotEmpty(sink.Errors(PartialLuaModule.RemovedExport)); + Assert.Equal(boundBefore, LuaApi.ModuleHandle); + } + finally + { + LuaModuleLocator.Resolver = null; + s_partialModule = 0; + } + } + + private static nint ResolvePartialModule() + { + return s_partialModule; + } +} diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs index 1be9ec3f..51354137 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs @@ -25,6 +25,7 @@ public sealed unsafe class ReentrancyTests [Trait("Category", "NativeLua")] [SuppressMessage("xUnit.Analyzers", "xUnit1051", Justification = "The bounded lifecycle barrier is a deterministic host-thread synchronization point.")] + [Trait("Qualification", "Q07")] public void A_concurrent_disable_during_OnEnable_fails_immediately_and_the_outer_enable_decides_the_state() { HostingTest.RequireNativeLua(); @@ -81,6 +82,7 @@ public void A_concurrent_disable_during_OnEnable_fails_immediately_and_the_outer [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] public void Disable_nested_in_OnEnable_is_refused_and_the_enable_stands() { HostingTest.RequireNativeLua(); @@ -182,6 +184,7 @@ public void Enable_nested_in_OnDisable_is_refused_and_the_disable_stands() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] public void Disable_nested_in_OnDisable_is_refused_and_OnDisable_runs_once() { HostingTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/README.md b/tests/CheatEngine.SDK.Hosting.Tests/README.md index 83430dd3..bba9b643 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/README.md +++ b/tests/CheatEngine.SDK.Hosting.Tests/README.md @@ -50,6 +50,24 @@ wrong-thread behavior is rejected; they do not claim a thread hop. The end-to-en `LuaRuntime`, withdraws `PluginContext` and releases Lua callbacks the plugin forgot. - `MainThread.ProcessMessages`, `CheckSynchronize` and `Invoke` throw `InvalidOperationException` while no plugin is enabled. A worker `Invoke` rejects a `synchronize` callback that runs it on the wrong managed thread. +- Without a Lua module, or with a module that lacks one Lua export, the enable fails before any plugin code runs and + before the host is asked for its Lua state, and the log names what is missing (`EnablePluginTests`, + `PartialLuaModuleEnableTests`). +- The 2.0 conservative admission default refuses a worker before the Lua state provider ever runs; the worker-side + `synchronize` hand-off inside `MainThread.Invoke` is the single documented default exception and keeps working + unopted-in (`MainThreadTests`). +- An external Lua-state reset is logged once as `LuaStateReplacedExternally:` and does not survive into the next + enable; there is no public reset API (`ExternalResetLifecycleTests`). +- A throwing or re-entrant `HostLog` sink is contained during every native callback, and a second-factory rejection is + logged outside the registration lock (`HostLogContainmentTests`). +- The opt-in `CheatEngineSdkIdentification` diagnostic is silent unless requested, bounded, path-free and built without + calling Lua or constructing the plugin (`LoadIdentificationTests`). +- The native hostfxr host emulator's A/B coexistence facts are consumed as C2 evidence only, redacted of every + absolute path, and never presented as Cheat Engine's own behaviour (`NativeHostEmulatorTests`). +- The bootstrap's second raw argument never changes the 36-byte record write, for any value including `int.MinValue` + and `int.MaxValue` (`InitializeManagedTests`); repeated enabling never accumulates a Lua-module loader reference + (`LuaModuleLocatorTests`); and `DisablePlugin` pumps a worker genuinely blocked inside the host's real Lua + `synchronize` call, not only a dispatch override, before it detaches (`DisablePluginTests`). ## Run the tests diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs index e3548c17..8ae7aea4 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs @@ -4,6 +4,7 @@ using CheatEngine.SDK.Abi; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Context; +using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; using CheatEngine.SDK.Hosting.Threading; using CheatEngine.SDK.Lua.Callbacks; @@ -86,6 +87,16 @@ public static Func? NestedCallInOnDisable set; } + /// + /// Arbitrary test code to run from inside , after the first Lua operation of the enable + /// (so the runtime is attached and stamped); null for none. Runs once, then clears itself. + /// + public static Action? ActionInOnEnable + { + get; + set; + } + public static int ConstructorCalls { get; @@ -220,6 +231,7 @@ public static void Reset() ContinueOnEnable = null; NestedCallInOnEnable = null; NestedCallInOnDisable = null; + ActionInOnEnable = null; ConstructorCalls = 0; RuntimeAttachedInConstructor = false; HostEnabledInConstructor = false; @@ -247,18 +259,25 @@ protected internal override void OnEnable() } } + Action? actionInOnEnable = ActionInOnEnable; + if (actionInOnEnable is not null) + { + ActionInOnEnable = null; + actionInOnEnable(); + } + if (CreateCallbacksInOnEnable) { if (!LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), - out LuaCallback? first).IsOk - || first is null) + out LuaCallback? first).IsOk + || first is null) { throw new InvalidOperationException("first callback creation failed"); } if (!LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), - out LuaCallback? second).IsOk - || second is null) + out LuaCallback? second).IsOk + || second is null) { throw new InvalidOperationException("second callback creation failed"); } @@ -287,6 +306,9 @@ protected internal override void OnDisable() DisableCalls++; RuntimeAttachedInOnDisable = LuaRuntime.IsAttached; HostEnabledInOnDisable = PluginHost.IsEnabled; + // A stable, deliberately logged marker: a test's log sink can react to it to observe exactly this point in + // the disable transition (operation admission already closed, OnDisable still running). + HostLog.Trace("RecordingPlugin.OnDisable observed"); Func? nested = NestedCallInOnDisable; if (nested is not null) diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Threading/MainThreadTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Threading/MainThreadTests.cs index 59fcbb04..3256d9c6 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Threading/MainThreadTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Threading/MainThreadTests.cs @@ -1,11 +1,14 @@ using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Tests.Support; using CheatEngine.SDK.Hosting.Threading; +using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Api; +using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -236,6 +239,127 @@ public void A_synchronize_that_raises_is_reported_with_the_Lua_message() Assert.Equal(0, LuaApi.lua_gettop(state.L)); } + [Fact] + [Trait("Category", "NativeLua")] + public void Invoke_from_a_worker_works_under_the_default_admission_policy() + { + HostingTest.RequireNativeLua(); + HostingTest.Reset(); + using NativeLuaState state = new(); + using HostSimulator host = new(); + HostingTest.Enable(host, state); + LuaState main = new(state.Pointer); + int mainThreadId = Environment.CurrentManagedThreadId; + using HoppingSynchronizeStandIn standIn = HoppingSynchronizeStandIn.Install(main); + int[] box = new int[1]; + + Task worker = Task.Factory.StartNew( + () => Record.Exception(() => MainThread.Invoke(static b => b[0] = Environment.CurrentManagedThreadId, box)), + TestContext.Current.CancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + // This is the real captured main thread: it pumps the pending call itself, exactly as CE's own main thread + // would while a worker blocks inside synchronize. If MainThreadDispatcher.Dispatch ever regressed to plain + // LuaRuntime.AcquireOperation(), the worker would be refused with ThreadNotAdmitted before it ever reached + // this stand-in, and PumpQueuedCall would time out waiting for a call that was never queued. + Assert.True(HoppingSynchronizeStandIn.PumpQueuedCall(TimeSpan.FromSeconds(5)), + "The worker's dispatch never reached the synchronize stand-in."); + Exception? workerFailure = AwaitWorker(worker); + + Assert.Null(workerFailure); + Assert.Equal(mainThreadId, box[0]); + Assert.Equal(0, LuaApi.lua_gettop(state.L)); + } + + // A plain (non-test, non-unsafe-context) helper: xUnit1031 targets [Fact]/[Theory] methods specifically, so + // bounded blocking here, rather than in the test method itself, keeps that rule meaningful elsewhere. + private static Exception? AwaitWorker(Task worker) + { + return worker.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).GetAwaiter() + .GetResult(); + } + + // A native `synchronize` stand-in that genuinely hops to the real calling (main) thread instead of running the + // pushed closure inline: it hands the closure to PumpQueuedCall (run on this test's true main thread) and blocks + // the worker until that pump has actually invoked it, so the test observes MainThread.Invoke's full round trip + // rather than only the rejection path the other stand-in tests prove. + private sealed unsafe class HoppingSynchronizeStandIn : IDisposable + { + private static ManualResetEventSlim? s_queued; + private static ManualResetEventSlim? s_completed; + private static LuaRef? s_pendingRef; + private static LuaStatus s_pumpedCallStatus; + + private HoppingSynchronizeStandIn() + { + } + + public static HoppingSynchronizeStandIn Install(LuaState main) + { + s_queued = new ManualResetEventSlim(false); + s_completed = new ManualResetEventSlim(false); + s_pendingRef = null; + Assert.True(main.TryPushFunction(new LuaNativeFunction(&SynchronizeThunk)).IsOk); + Assert.True(main.TrySetGlobal("synchronize"u8).IsOk); + return new HoppingSynchronizeStandIn(); + } + + /// Waits for a queued call and runs it on the calling thread. Call this from the real main thread. + public static bool PumpQueuedCall(TimeSpan timeout) + { + if (!s_queued!.Wait(timeout)) + { + return false; + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + LuaState state = operation.State; + LuaRef pendingRef = s_pendingRef!; + Assert.True(state.TryPushRef(pendingRef)); + s_pumpedCallStatus = state.TryCall(0, 0); + pendingRef.Release(state); + s_completed!.Set(); + return true; + } + + public void Dispose() + { + Assert.True(s_pumpedCallStatus.IsOk, + s_pumpedCallStatus.IsOk ? string.Empty : "The pumped call failed."); + s_queued?.Dispose(); + s_completed?.Dispose(); + s_queued = null; + s_completed = null; + s_pendingRef = null; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int SynchronizeThunk(nint pointer) + { + LuaState state = new(pointer); + try + { + if (!state.IsFunction(1)) + { + return LuaThunk.Fail(state, "the hopping stand-in expects a function argument"u8); + } + + state.PushValue(1); + s_pendingRef = state.CreateRef(); + s_queued!.Set(); + if (!s_completed!.Wait(TimeSpan.FromSeconds(5))) + { + return LuaThunk.Fail(state, "the main-thread pump never ran the queued call"u8); + } + + return 0; + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } + } + private static void InstallSynchronizeStandIn(NativeLuaState state) { LuaState L = LuaRuntime.AcquireState(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/packages.lock.json b/tests/CheatEngine.SDK.Hosting.Tests/packages.lock.json new file mode 100644 index 00000000..a2e6b3bd --- /dev/null +++ b/tests/CheatEngine.SDK.Hosting.Tests/packages.lock.json @@ -0,0 +1,242 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.tests.shared": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs b/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs index e022711c..614921d0 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs @@ -5,6 +5,7 @@ using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Context; using CheatEngine.SDK.Hosting.Diagnostics; +using CheatEngine.SDK.Lua.Runtime; namespace LivePlugin.Coexistence; @@ -27,12 +28,17 @@ internal static string GetIdentity(string pluginLabel, Assembly pluginAssembly) AssemblyLoadContext? pluginLoadContext = AssemblyLoadContext.GetLoadContext(pluginAssembly); AssemblyLoadContext? hostingLoadContext = AssemblyLoadContext.GetLoadContext(hostingAssembly); + // Appended (WI-7, F03 C2): the SDK's own attach epoch and the PluginHost static-state identity, so a C2 + // host-emulated run and a future exact-host run can tell whether two enabled plugins share one Hosting + // instance. Appended at the end, after the existing fields: the Checkpoint B runner and receipts parse the + // existing prefix and must keep working unmodified. return string.Create( CultureInfo.InvariantCulture, $"Plugin={pluginLabel}; PluginAssembly={pluginAssembly.FullName}; PluginMvid={pluginAssembly.ManifestModule.ModuleVersionId}; " + $"HostingAssembly={hostingAssembly.FullName}; HostingMvid={hostingAssembly.ManifestModule.ModuleVersionId}; " + $"PluginALC={Describe(pluginLoadContext)}; HostingALC={Describe(hostingLoadContext)}; " + - $"SameALC={ReferenceEquals(pluginLoadContext, hostingLoadContext)}"); + $"SameALC={ReferenceEquals(pluginLoadContext, hostingLoadContext)}; " + + $"Epoch={LuaRuntime.Epoch}; HostingTypeHandle=0x{typeof(PluginHost).TypeHandle.Value:X}"); } private static string Describe(AssemblyLoadContext? loadContext) diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props b/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props index 66d1463d..82edc12e 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props @@ -9,8 +9,16 @@ true $(ArtifactsPath)\obj\$(MSBuildProjectName)\generated\$(Configuration) false + + true + + + + diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/packages.lock.json b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/packages.lock.json new file mode 100644 index 00000000..67bcb512 --- /dev/null +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/packages.lock.json @@ -0,0 +1,52 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.engine": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/packages.lock.json b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/packages.lock.json new file mode 100644 index 00000000..67bcb512 --- /dev/null +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/packages.lock.json @@ -0,0 +1,52 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.engine": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md b/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md index 90acf273..8a08440c 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md @@ -1,7 +1,6 @@ # CheatEngine.SDK.LivePlugin.Coexistence -An opt-in, manual fixture for the exact-host portion -of [SDK-005](../../documentations/engineering/work-items/SDK-005.md). +An opt-in, manual fixture for the exact-host portion of qualification scenarios Q09 and Q10. It builds two distinct plugin assemblies, `PluginA` and `PluginB`, and records the identities that Cheat Engine actually loads. It is not a unit test, it is not part of ordinary CI, and this repository contains **no executed result** for it. @@ -42,8 +41,9 @@ DLL, record: - the timestamp, operator and complete DebugView transcript; and - the Plugin A/B identity lines, every Lua command result, and any loader/enable failure. -Leaving any field unknown means the result is an unqualified manual observation. The architecture-review scenarios -remain `Specified_Not_Executed`: R25/T049–T050, R26/T051–T052, and R34/T067–T068/T076. +A qualification run records these fields as evidence, in the pull request or release notes that claim the result. +Leaving any field unknown means the result is an unqualified manual observation. Qualification scenarios Q09 and Q10 +remain not executed, as do the related scenarios Q19 and Q30. ## Build and run @@ -98,8 +98,8 @@ See [Native AOT libraries](https://learn.microsoft.com/dotnet/core/deploying/nat ## Scope deliberately left to follow-up fixtures -- `T051` and `T052`: first-thread Lua acquisition, refusal and cross-plugin worker/main-thread concurrency; -- `T068` and `T076`: target switch and retained allocation/patch ownership; and +- Q19: first-thread Lua acquisition, refusal and cross-plugin worker/main-thread concurrency; +- Q30: target switch and retained allocation/patch ownership; and - a real side-by-side package test, after the package tuple and loader profile are identified. Those scenarios need their production owners and exact host facts. This fixture must not be used to advertise them as diff --git a/tests/CheatEngine.SDK.LivePlugin/README.md b/tests/CheatEngine.SDK.LivePlugin/README.md index 380774c1..ae087a73 100644 --- a/tests/CheatEngine.SDK.LivePlugin/README.md +++ b/tests/CheatEngine.SDK.LivePlugin/README.md @@ -55,11 +55,11 @@ land in `artifacts/obj/CheatEngine.SDK.LivePlugin/generated//`. Prerequisites: -| Need | Detail | -|----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Platform | Windows x64 with the pinned CE `7.7.0.10621` host (verify SHA-256 `9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D`) and Sysinternals [DebugView](https://learn.microsoft.com/sysinternals/downloads/debugview) | -| SDK | .NET SDK 10.0.401 or later, the version `global.json` pins | -| Runtime policy | The inspected CE binary uses the `nethost`/`hostfxr` route. The workspace's .NET 10 `ce.runtimeconfig.json` is a local modification, not an installer baseline. Preserve the controlled host's existing policy and record it with the transcript; do not treat this guide as permission to edit an installed CE runtime configuration. Microsoft documents `nethost`/`hostfxr` for framework-dependent components. | +| Need | Detail | +|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Platform | Windows x64 with the pinned CE `7.7.0.10621` host (verify SHA-256 `9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D`) and Sysinternals [DebugView](https://learn.microsoft.com/sysinternals/downloads/debugview) | +| SDK | .NET SDK 10.0.401 exactly, the version `global.json` pins with `rollForward: disable` | +| Runtime policy | The inspected CE binary uses the `nethost`/`hostfxr` route. The workspace's .NET 10 `ce.runtimeconfig.json` is a local modification, not an installer baseline. Preserve the controlled host's existing policy and record it with the transcript, including its observed hash; do not treat this guide as permission to edit an installed CE runtime configuration. Microsoft documents `nethost`/`hostfxr` for framework-dependent components. | 1. Build the plugin with `dotnet build tests/CheatEngine.SDK.LivePlugin/CheatEngine.SDK.LivePlugin.csproj -c Release`. The output folder is diff --git a/tests/CheatEngine.SDK.LivePlugin/packages.lock.json b/tests/CheatEngine.SDK.LivePlugin/packages.lock.json new file mode 100644 index 00000000..67bcb512 --- /dev/null +++ b/tests/CheatEngine.SDK.LivePlugin/packages.lock.json @@ -0,0 +1,52 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.engine": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj b/tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj index a3472c76..888c4999 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj @@ -16,7 +16,13 @@ + + + + + + diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/HostProfileObservationTests.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/HostProfileObservationTests.cs index 08c2a47e..a11016da 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/HostProfileObservationTests.cs +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/HostProfileObservationTests.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using System.Text.Json; + using LiveProbe; namespace CheatEngine.SDK.LiveProbe.Tests; @@ -72,4 +75,40 @@ public void ObserveFileVersion_when_access_is_revoked_after_open_reports_typed_u Assert.Equal("unavailable: UnauthorizedAccessException", outcome); } + + [Fact] + public void Host_profile_records_the_loaded_bridge_module_and_never_a_file_next_to_the_application() + { + // The test output folder holds a cheatengine-sdk-lua-bridge.dll that this process does not need to load. The + // record may name a bridge only when the process loaded it; modules stay loaded, so a module listed after the + // capture was loaded when the record named it. + string record = HostProfileObservation.Capture(AuthorizationDecision.Denied("test")); + using JsonDocument document = JsonDocument.Parse(record); + JsonElement bridge = document.RootElement.GetProperty("bridge"); + string[] loaded = LoadedModulePaths("cheatengine-sdk-lua-bridge.dll"); + + if (bridge.TryGetProperty("path", out JsonElement path)) + { + Assert.Contains(path.GetString()!, loaded, StringComparer.OrdinalIgnoreCase); + } + else + { + Assert.Equal("not-observed", bridge.GetProperty("outcome").GetString()); + } + } + + private static string[] LoadedModulePaths(string fileName) + { + using Process process = Process.GetCurrentProcess(); + List paths = []; + foreach (ProcessModule module in process.Modules) + { + if (string.Equals(Path.GetFileName(module.FileName), fileName, StringComparison.OrdinalIgnoreCase)) + { + paths.Add(module.FileName); + } + } + + return [.. paths]; + } } diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeFaultInjectionTests.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeFaultInjectionTests.cs new file mode 100644 index 00000000..ed19e383 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeFaultInjectionTests.cs @@ -0,0 +1,114 @@ +using LiveProbe; + +namespace CheatEngine.SDK.LiveProbe.Tests; + +/// +/// The Checkpoint-B fault switch (liveprobe.fault.json, qualification scenarios Q06 and Q08 at C3), evaluated +/// with an injected authorization gate and an injected file reader. No host, no Lua, no file system. +/// +public sealed class LiveProbeFaultInjectionTests +{ + private const string PluginDirectory = "plugin-directory"; + + [Fact] + public void Fault_switch_is_ignored_when_authorization_is_denied() + { + bool fileRead = false; + + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate( + static () => AuthorizationDecision.Denied("The authorization manifest has expired."), + PluginDirectory, + _ => + { + fileRead = true; + return Switch("OnEnable"); + }); + + Assert.False(fileRead); + Assert.Equal(LiveProbeFaultStage.None, decision.Stage); + Assert.False(decision.FileFound); + Assert.Contains("authorization denied", decision.Reason, StringComparison.Ordinal); + Assert.Contains("The authorization manifest has expired.", decision.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData("""{"schema":"ce77-live-probe-fault-v0","throwIn":"OnEnable"}""", "unknown schema")] + [InlineData("""{"throwIn":"OnEnable"}""", "no schema")] + [InlineData("""{"schema":"ce77-live-probe-fault-v1","throwIn":"OnEnable "}""", "unknown throwIn")] + [InlineData("""{"schema":"ce77-live-probe-fault-v1","throwIn":"onenable"}""", "unknown throwIn")] + [InlineData("""{"schema":"ce77-live-probe-fault-v1"}""", "no throwIn")] + [InlineData("""["ce77-live-probe-fault-v1"]""", "no schema")] + [InlineData("""{"schema":""", "not valid JSON")] + public void Fault_switch_with_an_unknown_schema_is_ignored_and_reported(string content, string reported) + { + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate(Allowed, PluginDirectory, _ => content); + + Assert.Equal(LiveProbeFaultStage.None, decision.Stage); + Assert.True(decision.FileFound); + Assert.Contains("ignored and reported", decision.Reason, StringComparison.Ordinal); + Assert.Contains(reported, decision.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData("None")] + [InlineData("FactoryCreate")] + [InlineData("OnEnable")] + [InlineData("OnDisable")] + public void Fault_switch_selects_exactly_the_requested_stage(string throwIn) + { + LiveProbeFaultStage requested = Enum.Parse(throwIn); + string? readPath = null; + + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate(Allowed, PluginDirectory, path => + { + readPath = path; + return Switch(requested.ToString()); + }); + + Assert.Equal(Path.Combine(PluginDirectory, LiveProbeFaultInjection.FileName), readPath); + Assert.Equal(requested, decision.Stage); + Assert.True(decision.FileFound); + foreach (LiveProbeFaultStage stage in Enum.GetValues()) + { + bool expected = stage == requested && stage != LiveProbeFaultStage.None; + Assert.Equal(expected, LiveProbeFaultInjection.ThrowsAt(decision, stage)); + } + } + + [Fact] + public void Absent_fault_file_means_no_fault() + { + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate(Allowed, PluginDirectory, + static _ => null); + + Assert.Equal(LiveProbeFaultStage.None, decision.Stage); + Assert.False(decision.FileFound); + Assert.Contains("no fault", decision.Reason, StringComparison.Ordinal); + foreach (LiveProbeFaultStage stage in Enum.GetValues()) + { + Assert.False(LiveProbeFaultInjection.ThrowsAt(decision, stage)); + } + } + + [Fact] + public void An_unreadable_fault_file_is_ignored_and_reported_without_throwing() + { + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate(Allowed, PluginDirectory, + static _ => throw new IOException("Synthetic sharing violation.")); + + Assert.Equal(LiveProbeFaultStage.None, decision.Stage); + Assert.True(decision.FileFound); + Assert.Contains("IOException", decision.Reason, StringComparison.Ordinal); + } + + private static AuthorizationDecision Allowed() + { + return AuthorizationDecision.Allowed("C:\\ce.exe", "HOST", 401, "C:\\disposable-target.exe", "TARGET", + DateTimeOffset.MaxValue); + } + + private static string Switch(string throwIn) + { + return "{\"schema\":\"" + LiveProbeFaultInjection.Schema + "\",\"throwIn\":\"" + throwIn + "\"}"; + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs new file mode 100644 index 00000000..21d943ab --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs @@ -0,0 +1,192 @@ +using System.Text.Json; + +using LiveProbe; + +namespace CheatEngine.SDK.LiveProbe.Tests; + +/// +/// The status records the qualification driver reads (ce77_live_probe_status() and +/// ce77_live_probe_status_json()) and the two Checkpoint-B command hooks, with injected host facts and gates. +/// +public sealed class LiveProbeStatusTests +{ + [Fact] + public void Status_reports_the_exports_size_and_the_raw_second_bootstrap_integer_without_interpretation() + { + const int RawSecondInteger = -1_234_567; + LiveProbeState.CaptureBootstrap(0x1000, RawSecondInteger); + LiveProbeHostFacts host = Facts(RawSecondInteger, 48, 7, 3); + + string text = LiveProbeState.GetStatus(host); + using JsonDocument json = JsonDocument.Parse(LiveProbeState.GetStatusJson(host)); + + Assert.Contains("opaqueSecondInt=-1234567 (raw; no size/version meaning assigned)", text, + StringComparison.Ordinal); + Assert.Contains("pluginHostLastInitRecordArgument=-1234567", text, StringComparison.Ordinal); + Assert.Contains("pluginId=7, epoch=3, reportedExportsSize=48", text, StringComparison.Ordinal); + + JsonElement root = json.RootElement; + Assert.Equal(LiveProbeStatusReport.Schema, root.GetProperty("schema").GetString()); + JsonElement bootstrap = root.GetProperty("bootstrap"); + Assert.Equal(RawSecondInteger, bootstrap.GetProperty("opaqueSecondInt").GetInt32()); + Assert.Equal(RawSecondInteger, bootstrap.GetProperty("pluginHostLastInitRecordArgument").GetInt32()); + Assert.Equal(LiveProbeStatusReport.NoInterpretation, bootstrap.GetProperty("interpretation").GetString()); + Assert.True(bootstrap.GetProperty("calls").GetInt32() >= 1); + JsonElement context = root.GetProperty("context"); + Assert.True(context.GetProperty("present").GetBoolean()); + Assert.Equal(48, context.GetProperty("reportedExportsSize").GetInt32()); + Assert.Equal(7u, context.GetProperty("pluginId").GetUInt32()); + Assert.Equal(3, context.GetProperty("epoch").GetInt32()); + Assert.Equal("Enabled", context.GetProperty("phase").GetString()); + + // The bootstrap record never names the raw integer a size, a length or a version. + foreach (JsonProperty property in bootstrap.EnumerateObject()) + { + Assert.DoesNotContain("size", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("length", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("version", property.Name, StringComparison.OrdinalIgnoreCase); + } + + Assert.Equal(16, root.GetProperty("versionQuery").GetProperty("lastRecordSize").GetInt32()); + } + + [Fact] + public void Status_without_an_enabled_context_reports_none_instead_of_zero_values() + { + LiveProbeHostFacts host = Facts(0, 0, 0, 0) with + { + HasContext = false, + Phase = "Registered" + }; + + string text = LiveProbeState.GetStatus(host); + using JsonDocument json = JsonDocument.Parse(LiveProbeState.GetStatusJson(host)); + + Assert.Contains("context=none", text, StringComparison.Ordinal); + JsonElement context = json.RootElement.GetProperty("context"); + Assert.False(context.GetProperty("present").GetBoolean()); + Assert.False(context.TryGetProperty("reportedExportsSize", out _)); + Assert.False(context.TryGetProperty("pluginId", out _)); + Assert.Equal("Registered", context.GetProperty("phase").GetString()); + } + + [Fact] + public void Status_json_reports_the_fault_switch_decision_and_the_assembly_identities() + { + // A pinned decision, not the process-wide LiveProbeFaultInjection state the report would otherwise echo. + LiveProbeFaultDecision decision = new(LiveProbeFaultStage.OnDisable, + "Fault switch selects OnDisable (liveprobe.fault.json, schema ce77-live-probe-fault-v1).", true); + LiveProbeStatusSnapshot snapshot = new(Facts(0, 48, 1, 1), 1, 0, false, 0, 0, null, true, "allowed", true, + "allowed", decision, ["OnEnable@1", "OnDisable@2"], 0, "none", "none", "none", "not prepared"); + + using JsonDocument json = JsonDocument.Parse(LiveProbeStatusReport.ToJson(snapshot)); + + JsonElement fault = json.RootElement.GetProperty("faultInjection"); + Assert.Equal("OnDisable", fault.GetProperty("stage").GetString()); + Assert.Equal(decision.Reason, fault.GetProperty("reason").GetString()); + Assert.True(fault.GetProperty("fileFound").GetBoolean()); + Assert.Equal(["OnEnable@1", "OnDisable@2"], + fault.GetProperty("injected").EnumerateArray().Select(static stage => stage.GetString()!), + StringComparer.Ordinal); + JsonElement identity = json.RootElement.GetProperty("identity"); + Assert.Equal("plugin.dll", identity.GetProperty("pluginAssemblyLocation").GetString()); + Assert.Equal("hosting.dll", identity.GetProperty("hostingAssemblyLocation").GetString()); + Assert.Equal("Default (collectible=False)", identity.GetProperty("hostingLoadContext").GetString()); + } + + [Fact] + public void Captured_host_facts_without_a_plugin_host_report_no_context() + { + LiveProbeHostFacts host = LiveProbeHostFacts.Capture(); + + Assert.False(host.HasContext); + Assert.False(string.IsNullOrEmpty(host.PluginAssemblyMvid)); + Assert.False(string.IsNullOrEmpty(host.HostingAssemblyMvid)); + } + + [Fact] + public void Throw_hook_returns_the_denial_and_throws_nothing_without_authorization() + { + string result = LiveProbeState.ThrowManagedExceptionIfAuthorized( + static () => AuthorizationDecision.Denied("CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE is absent."), + static () => throw new InvalidOperationException("A denied authorization must not read CE's PID.")); + + Assert.Equal("Live probe denied: CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE is absent.", result); + } + + [Fact] + public void Throw_hook_throws_the_marked_exception_when_authorized_for_the_opened_target() + { + InvalidOperationException exception = Assert.Throws(() => + LiveProbeState.ThrowManagedExceptionIfAuthorized(Allowed, static () => 401)); + + Assert.Equal(LiveProbeState.ManagedExceptionMarker, exception.Message); + using JsonDocument json = JsonDocument.Parse(LiveProbeState.GetStatusJson(Facts(0, 48, 1, 1))); + Assert.True(json.RootElement.GetProperty("managedExceptionThrows").GetInt32() >= 1); + } + + [Theory] + [InlineData(0.5)] + [InlineData(61)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void Pump_hook_refuses_a_duration_outside_one_to_sixty_seconds_before_any_host_call(double seconds) + { + bool pumped = false; + + string result = LiveProbeState.PumpMessages( + static () => throw new InvalidOperationException("An invalid duration must not evaluate authorization."), + static () => throw new InvalidOperationException("An invalid duration must not read CE's PID."), + seconds, + _ => + { + pumped = true; + return "pumped"; + }); + + Assert.False(pumped); + Assert.StartsWith("Pump refused:", result, StringComparison.Ordinal); + } + + [Fact] + public void Pump_hook_is_inert_without_authorization_and_pumps_only_for_the_opened_target() + { + double pumpedFor = 0; + + string denied = LiveProbeState.PumpMessages(static () => AuthorizationDecision.Denied("No manifest."), + static () => 401, 5, seconds => + { + pumpedFor = seconds; + return "pumped"; + }); + string wrongTarget = LiveProbeState.PumpMessages(Allowed, static () => 402, 5, seconds => + { + pumpedFor = seconds; + return "pumped"; + }); + string allowed = LiveProbeState.PumpMessages(Allowed, static () => 401, 5, seconds => + { + pumpedFor = seconds; + return "pumped"; + }); + + Assert.Equal("Live probe denied: No manifest.", denied); + Assert.Equal("Live probe denied: CE reports opened process 402, not manifest process 401.", wrongTarget); + Assert.Equal("pumped", allowed); + Assert.Equal(5, pumpedFor); + } + + private static LiveProbeHostFacts Facts(int lastInitRecordArgument, int reportedExportsSize, uint pluginId, + int epoch) + { + return new LiveProbeHostFacts(true, pluginId, epoch, reportedExportsSize, true, true, "Enabled", + lastInitRecordArgument, 16, "plugin.dll", "00000000-0000-0000-0000-000000000001", "hosting.dll", + "00000000-0000-0000-0000-000000000002", "Default (collectible=False)"); + } + + private static AuthorizationDecision Allowed() + { + return AuthorizationDecision.Allowed("C:\\ce.exe", "HOST", 401, "C:\\disposable-target.exe", "TARGET", + DateTimeOffset.MaxValue); + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/ProbeHostGlobals.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/ProbeHostGlobals.cs index f5f1c2c0..bc916c33 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/ProbeHostGlobals.cs +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/ProbeHostGlobals.cs @@ -1,7 +1,8 @@ -namespace CheatEngine.SDK.LiveProbe.Tests; +namespace LiveProbe; -// The source-linked LiveProbeState tests inject the PID reader and never call this generated Lua global. Keeping the -// stub local avoids loading a plugin generator or a Cheat Engine host during unit tests. +// The source-linked LiveProbe sources call ProbeHostGlobals.GetOpenedProcessId, which the plugin project generates from +// [LuaGlobal("getOpenedProcessID")]. The tests inject the PID reader and never call it; this stub keeps the same +// namespace and signature without loading the LuaBindings generator or a Cheat Engine host. internal static class ProbeHostGlobals { internal static long GetOpenedProcessId() diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/README.md b/tests/CheatEngine.SDK.LiveProbe.Tests/README.md index e95d129f..c8e03c22 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/README.md +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/README.md @@ -1,6 +1,52 @@ # CheatEngine.SDK.LiveProbe.Tests -Deterministic unit tests for the manually loaded CE 7.7 LiveProbe evidence harness. The project compiles the four -evidence-only sources under test directly and supplies a local stub for the generated CE Lua global, so it does not load -the plugin or invoke its source generator. Tests inject only in-process authorization/PID and file-open results; they -never start Cheat Engine, load a CE host, select a target, or produce a live qualification artifact. +Deterministic unit tests for the manually loaded CE 7.7 [LiveProbe](../CheatEngine.SDK.LiveProbe/README.md) evidence +harness. + +## Objective + +Prove the fail-closed behaviour of the harness without a Cheat Engine host: the authorization gate is re-evaluated +before every acting command, the Checkpoint B hooks are inert without it, the fault switch selects exactly one stage, +and the status records report raw host facts without interpreting them. + +## Why it exists + +The harness is only ever run inside Cheat Engine, where a regression would surface as a wrong or missing qualification +record. These tests catch it in CI instead. They never start Cheat Engine, load a CE host, select a target, or produce +a qualification artifact: a green run here is C1 evidence about the harness, never host evidence. + +## How it works + +The project compiles the Lua-free sources of the harness directly (`Compile Include` links in the project file) and +supplies a local stub for the generated `ProbeHostGlobals.GetOpenedProcessId` Lua global, in the same `LiveProbe` +namespace, so it neither loads the plugin nor runs its Lua source generator. Tests inject the authorization decision, +CE's opened PID, the fault-switch file reader and the `PluginHost` facts. `AssemblyInfo.cs` disables parallelization +because the harness keeps process-wide static state. + +| Test class | Covers | +|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| `LiveProbeStateTests` | Fresh authorization and target-PID checks before host-profile capture and every protected command. | +| `LiveProbeStatusTests` | Text and JSON status (plugin id, epoch, exports size, raw second bootstrap integer), the exception hook and the pump hook. | +| `LiveProbeFaultInjectionTests` | The `liveprobe.fault.json` switch: never read without authorization, exact stage selection, ignored and reported failures. | +| `HostProfileObservationTests` | Typed outcomes for missing, locked, vanishing or protected identity files; the bridge record names the loaded module only. | + +## Promise + +- A capture or protected command after the manifest expired, the target image changed or CE selected another PID + returns a fresh denial and never runs the probe (`LiveProbeStateTests`). +- The status reports the exports size and the raw second bootstrap integer without naming it a size, length or + version + (`LiveProbeStatusTests.Status_reports_the_exports_size_and_the_raw_second_bootstrap_integer_without_interpretation`). +- The exception and pump hooks do nothing without authorization, and the pump refuses an out-of-range duration before + any host call (`LiveProbeStatusTests`). +- The fault switch is ignored without authorization, selects exactly the requested stage, and treats an absent file as + no fault (`LiveProbeFaultInjectionTests`). +- Identity-file failures are reported as typed outcomes (`HostProfileObservationTests`), and the bridge identity is the + module the process loaded, never a file next to the application + (`HostProfileObservationTests.Host_profile_records_the_loaded_bridge_module_and_never_a_file_next_to_the_application`). + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj +``` diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/packages.lock.json b/tests/CheatEngine.SDK.LiveProbe.Tests/packages.lock.json new file mode 100644 index 00000000..9a8731e7 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/packages.lock.json @@ -0,0 +1,236 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs b/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs index c761576a..e3543f50 100644 --- a/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs +++ b/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Diagnostics; + using LiveProbe; namespace CESDK; @@ -17,29 +18,34 @@ namespace CESDK; #pragma warning disable MA0049 // CE's host requires CESDK.CESDK exactly. public static unsafe class CESDK { - /// - /// CE's fixed managed component entry point. The second argument is recorded raw and deliberately has no - /// inferred semantic. - /// - /// Host-owned bootstrap storage. - /// The unmodified second integer provided by CE. - /// One only when the production bootstrap wrote its packed record. - public static int CEPluginInitialize(nint initRecord, int opaqueHostArgument) - { - try - { - LiveProbeState.CaptureBootstrap(initRecord, opaqueHostArgument); - var result = PluginHost.InitializeManaged(initRecord, opaqueHostArgument); - LiveProbeState.TryWriteTailCanaryAfterPackedRecord(initRecord, result); - return result; - } - catch (Exception exception) - { - // Do not let even the probe's diagnostics escape through hostfxr's component entry point. - HostLog.Write(HostLogLevel.Error, "CE 7.7 live-probe bootstrap failed.", exception); - return ManagedEntryPoint.Failure; - } - } + /// + /// CE's fixed managed component entry point. The second argument is recorded raw and deliberately has no + /// inferred semantic. + /// + /// Host-owned bootstrap storage. + /// The unmodified second integer provided by CE. + /// One only when the production bootstrap wrote its packed record. + public static int CEPluginInitialize(nint initRecord, int opaqueHostArgument) + { + try + { + LiveProbeState.CaptureBootstrap(initRecord, opaqueHostArgument); +#if LIVEPROBE_NON_ASCII_NAME + int result = + PluginHost.InitializeManaged(initRecord, opaqueHostArgument); +#else + int result = PluginHost.InitializeManaged(initRecord, opaqueHostArgument); +#endif + LiveProbeState.TryWriteTailCanaryAfterPackedRecord(initRecord, result); + return result; + } + catch (Exception exception) + { + // Do not let even the probe's diagnostics escape through hostfxr's component entry point. + HostLog.Write(HostLogLevel.Error, "CE 7.7 live-probe bootstrap failed.", exception); + return ManagedEntryPoint.Failure; + } + } } #pragma warning restore MA0049 #pragma warning restore MA0048 diff --git a/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs b/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs index 2cf101e3..b3e0f750 100644 --- a/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs +++ b/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs @@ -1,6 +1,9 @@ using System.Globalization; + using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; namespace LiveProbe; @@ -14,30 +17,37 @@ namespace LiveProbe; /// internal sealed class Ce77LiveProbePlugin : CheatEnginePlugin { - /// - protected override void OnEnable() - { - var state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); - var registration = ProbeConsole.RegisterLuaFunctions(state); - HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - string.Create(CultureInfo.InvariantCulture, - $"CE 7.7 live probe: console command registration -> {registration}.")); - - LiveProbeState.ValidateAfterEnable(); - HostLog.Write(HostLogLevel.Information, LiveProbeState.GetStatus()); - } - - /// - protected override void OnDisable() - { - var state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); - var registration = ProbeConsole.UnregisterLuaFunctions(state); - HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - string.Create(CultureInfo.InvariantCulture, - $"CE 7.7 live probe: console command unregistration -> {registration}.")); - - // Deliberately do not dispose the callback-shutdown probe here. LuaRuntime.Detach, which runs immediately after - // OnDisable, is the system under test: it must neutralize the callback before freeing its GCHandle. - LiveProbeState.RecordDisable(); - } + /// + protected override void OnEnable() + { + LuaState state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); + LuaStatus registration = ProbeConsole.RegisterLuaFunctions(state); + HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + string.Create(CultureInfo.InvariantCulture, + $"CE 7.7 live probe: console command registration -> {registration}.")); + + LiveProbeState.ValidateAfterEnable(); + HostLog.Write(HostLogLevel.Information, LiveProbeState.GetStatus()); + + // Checkpoint B, Q06: an authorized liveprobe.fault.json can make this enable fail after the console commands + // were registered, so the SDK's cleanup of a failed enable is observable. + LiveProbeFaultInjection.EnterOnEnable(); + } + + /// + protected override void OnDisable() + { + LuaState state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); + LuaStatus registration = ProbeConsole.UnregisterLuaFunctions(state); + HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + string.Create(CultureInfo.InvariantCulture, + $"CE 7.7 live probe: console command unregistration -> {registration}.")); + + // Deliberately do not dispose the callback-shutdown probe here. LuaRuntime.Detach, which runs immediately after + // OnDisable, is the system under test: it must neutralize the callback before freeing its GCHandle. + LiveProbeState.RecordDisable(); + + // Checkpoint B, Q08: an authorized liveprobe.fault.json can make OnDisable throw after its own cleanup. + LiveProbeFaultInjection.EnterOnDisable(); + } } diff --git a/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj b/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj index 3f43c335..acae138c 100644 --- a/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj +++ b/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj @@ -1,7 +1,8 @@ - + LiveProbe x64 x64 @@ -12,6 +13,14 @@ $(ArtifactsPath)\obj\$(MSBuildProjectName)\generated\$(Configuration) + + + $(DefineConstants);LIVEPROBE_NON_ASCII_NAME + + diff --git a/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs b/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs index f1d18bdc..d3fe445d 100644 --- a/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs +++ b/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs @@ -36,7 +36,9 @@ internal static string Capture(AuthorizationDecision authorization) WriteFileIdentity(writer, "host", authorization.HostPath, authorization.HostSha256); WriteFileIdentity(writer, "lua", FindLoadedModulePath(LuaModule.CheatEngine64ModuleName), null); - WriteFileIdentity(writer, "bridge", Path.Combine(AppContext.BaseDirectory, BridgeFileName), null); + // The bridge module the process actually loaded (through the plugin's dependency resolution), never a file + // guessed next to AppContext.BaseDirectory: under Cheat Engine's hostfxr runtime that is not the plugin folder. + WriteFileIdentity(writer, "bridge", FindLoadedModulePath(BridgeFileName), null); WriteFileIdentity(writer, "plugin", typeof(HostProfileObservation).Assembly.Location, null); writer.WriteStartObject("target"); @@ -83,7 +85,7 @@ private static void WriteFileIdentityFields(Utf8JsonWriter writer, string? path, writer.WriteString("machine", reader.PEHeaders.CoffHeader.Machine.ToString()); } catch (Exception exception) when (exception is BadImageFormatException or IOException - or UnauthorizedAccessException) + or UnauthorizedAccessException) { writer.WriteString("machine", "unavailable: " + exception.GetType().Name); } @@ -130,7 +132,7 @@ internal static string ObserveFileVersion(string path, Func get return getFileVersion(path) ?? "not-present"; } catch (Exception exception) when (exception is ArgumentException or Win32Exception - or IOException or UnauthorizedAccessException) + or IOException or UnauthorizedAccessException) { return "unavailable: " + exception.GetType().Name; } @@ -150,7 +152,7 @@ internal static string ObserveFileVersion(string path, Func get } } catch (Exception exception) when (exception is InvalidOperationException or NotSupportedException - or Win32Exception) + or Win32Exception) { HostLog.Write(HostLogLevel.Warning, "CE 7.7 host-profile probe could not enumerate loaded modules.", exception); diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs index d10b460b..e85be096 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs @@ -189,17 +189,17 @@ private static bool TryReadManifest(string path, out AuthorizationManifest manif using JsonDocument document = JsonDocument.Parse(stream); JsonElement root = document.RootElement; if (root.ValueKind != JsonValueKind.Object || !TryString(root, "schema", out string schema) || - !string.Equals(schema, "ce77-live-probe-v1", StringComparison.Ordinal) || - !TryString(root, "acknowledgement", out string acknowledgement) || - !TryString(root, "hostSha256", out string hostSha256) || - !TryString(root, "targetSha256", out string targetSha256) || - !TryString(root, "expiresUtc", out string expiresText) || - !root.TryGetProperty("targetProcessId", out JsonElement pid) || - !pid.TryGetInt32(out int targetProcessId) || - !root.TryGetProperty("disposable", out JsonElement disposable) || - disposable.ValueKind is not JsonValueKind.True and not JsonValueKind.False || - !DateTimeOffset.TryParse(expiresText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, - out DateTimeOffset expiresUtc)) + !string.Equals(schema, "ce77-live-probe-v1", StringComparison.Ordinal) || + !TryString(root, "acknowledgement", out string acknowledgement) || + !TryString(root, "hostSha256", out string hostSha256) || + !TryString(root, "targetSha256", out string targetSha256) || + !TryString(root, "expiresUtc", out string expiresText) || + !root.TryGetProperty("targetProcessId", out JsonElement pid) || + !pid.TryGetInt32(out int targetProcessId) || + !root.TryGetProperty("disposable", out JsonElement disposable) || + disposable.ValueKind is not JsonValueKind.True and not JsonValueKind.False || + !DateTimeOffset.TryParse(expiresText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, + out DateTimeOffset expiresUtc)) { failure = "The authorization manifest is missing a required ce77-live-probe-v1 field."; return false; @@ -210,7 +210,7 @@ disposable.ValueKind is not JsonValueKind.True and not JsonValueKind.False || return true; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException - or ArgumentException) + or ArgumentException) { failure = exception.GetType().Name + ": " + exception.Message; return false; @@ -246,7 +246,7 @@ private static bool TryGetProcessImage(int processId, out string path, out strin return true; } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException - or NotSupportedException or Win32Exception) + or NotSupportedException or Win32Exception) { failure = exception.GetType().Name + ": " + exception.Message; return false; @@ -275,7 +275,7 @@ private static bool IsAmd64Pe(string path, out string failure) return false; } catch (Exception exception) when (exception is BadImageFormatException or IOException - or UnauthorizedAccessException) + or UnauthorizedAccessException) { failure = exception.GetType().Name + ": " + exception.Message; return false; diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultDecision.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultDecision.cs new file mode 100644 index 00000000..214610b3 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultDecision.cs @@ -0,0 +1,11 @@ +namespace LiveProbe; + +/// One evaluation of the fault-injection switch. +/// The stage that throws during this enable; when ignored. +/// Why this stage was selected or why the switch was ignored, for the log and the status record. +/// Whether a switch file existed next to the plugin and was read. +internal readonly record struct LiveProbeFaultDecision(LiveProbeFaultStage Stage, string Reason, bool FileFound) +{ + internal static LiveProbeFaultDecision NotEvaluated => + new(LiveProbeFaultStage.None, "Not evaluated: no enable has run.", false); +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultInjection.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultInjection.cs new file mode 100644 index 00000000..2b458941 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultInjection.cs @@ -0,0 +1,242 @@ +using System.Globalization; +using System.Text.Json; + +using CheatEngine.SDK.Hosting.Diagnostics; + +namespace LiveProbe; + +/// +/// The Checkpoint-B fault-injection switch (qualification scenarios Q06 and Q08 at C3). A JSON file named +/// next to the plugin assembly selects one lifecycle stage that throws a managed exception: +/// {"schema":"ce77-live-probe-fault-v1","throwIn":"None|FactoryCreate|OnEnable|OnDisable"}. +/// +/// +/// The switch is read once per enable, without Lua, and only when +/// allows: without the operator's short-lived authorization the file is never opened. Every decision, including an +/// ignored file, is logged and kept for ce77_live_probe_status_json(). This is a test-harness mechanism only; +/// the SDK has no such switch. +/// +internal static class LiveProbeFaultInjection +{ + internal const string FileName = "liveprobe.fault.json"; + internal const string Schema = "ce77-live-probe-fault-v1"; + internal const string InjectedFaultMessagePrefix = "CE 7.7 live probe injected fault at "; + + private const int MaximumFileBytes = 4096; + private static readonly Lock Gate = new(); + private static LiveProbeFaultDecision s_current = LiveProbeFaultDecision.NotEvaluated; + private static bool s_evaluatedForPendingEnable; + private static int s_enableSequence; + private static readonly List s_injected = []; + + /// Gets the decision of the most recent enable. + internal static LiveProbeFaultDecision Current + { + get + { + lock (Gate) + { + return s_current; + } + } + } + + /// Gets the stages that actually threw, in order, as stage@enable entries. + internal static IReadOnlyList InjectedFaults + { + get + { + lock (Gate) + { + return [.. s_injected]; + } + } + } + + /// + /// Evaluates the switch. Pure apart from the two injected delegates: the authorization evaluator and the file + /// reader (which returns when the file does not exist). + /// + /// The live-probe authorization gate; the file is read only when it allows. + /// The directory that holds the plugin assembly. + /// Reads a whole file as UTF-8 text, or returns when it is absent. + /// The decision; never throws for a missing, unreadable or malformed file. + internal static LiveProbeFaultDecision Evaluate(Func evaluateAuthorization, + string pluginDirectory, Func readFile) + { + AuthorizationDecision authorization = evaluateAuthorization(); + if (!authorization.IsAllowed) + { + return new LiveProbeFaultDecision(LiveProbeFaultStage.None, + "Fault switch ignored without reading it: live-probe authorization denied (" + authorization.Reason + + ").", false); + } + + string path = Path.Combine(pluginDirectory, FileName); + string? text; + try + { + text = readFile(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + return new LiveProbeFaultDecision(LiveProbeFaultStage.None, + "Fault switch ignored: " + FileName + " could not be read (" + exception.GetType().Name + ").", true); + } + + if (text is null) + { + return new LiveProbeFaultDecision(LiveProbeFaultStage.None, + "No " + FileName + " next to the plugin: no fault.", false); + } + + return Parse(text); + } + + /// Returns whether throws at . + internal static bool ThrowsAt(LiveProbeFaultDecision decision, LiveProbeFaultStage stage) + { + return stage is not LiveProbeFaultStage.None && decision.Stage == stage; + } + + /// + /// Production hook of IPluginFactory.Create: the first point of an enable that constructs the plugin. + /// Evaluates the switch for this enable and throws when it selects . + /// + internal static void EnterFactoryCreate() + { + LiveProbeFaultDecision decision = EvaluateForEnable(); + lock (Gate) + { + s_evaluatedForPendingEnable = true; + } + + ThrowIfSelected(decision, LiveProbeFaultStage.FactoryCreate); + } + + /// + /// Production hook of OnEnable. Reuses the decision the factory made during this same enable, otherwise + /// evaluates the switch, then throws when it selects . + /// + internal static void EnterOnEnable() + { + LiveProbeFaultDecision decision; + bool reuse; + lock (Gate) + { + reuse = s_evaluatedForPendingEnable; + s_evaluatedForPendingEnable = false; + decision = s_current; + } + + if (!reuse) + { + decision = EvaluateForEnable(); + } + + ThrowIfSelected(decision, LiveProbeFaultStage.OnEnable); + } + + /// Production hook of OnDisable: throws when this enable's decision selects that stage. + internal static void EnterOnDisable() + { + ThrowIfSelected(Current, LiveProbeFaultStage.OnDisable); + } + + private static LiveProbeFaultDecision EvaluateForEnable() + { + string pluginDirectory = Path.GetDirectoryName(typeof(LiveProbeFaultInjection).Assembly.Location) ?? + AppContext.BaseDirectory; + LiveProbeFaultDecision decision = Evaluate(LiveProbeAuthorization.Evaluate, pluginDirectory, ReadIfPresent); + int sequence; + lock (Gate) + { + sequence = ++s_enableSequence; + s_current = decision; + } + + HostLog.Write(decision.Stage == LiveProbeFaultStage.None ? HostLogLevel.Information : HostLogLevel.Warning, + string.Create(CultureInfo.InvariantCulture, + $"CE 7.7 live probe: fault switch for enable #{sequence} -> {decision.Stage}. {decision.Reason}")); + return decision; + } + + private static void ThrowIfSelected(LiveProbeFaultDecision decision, LiveProbeFaultStage stage) + { + if (!ThrowsAt(decision, stage)) + { + return; + } + + lock (Gate) + { + s_injected.Add(string.Create(CultureInfo.InvariantCulture, $"{stage}@{s_enableSequence}")); + } + + throw new InvalidOperationException(InjectedFaultMessagePrefix + stage + " (" + FileName + ")."); + } + + private static LiveProbeFaultDecision Parse(string text) + { + try + { + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("schema", out JsonElement schema) || schema.ValueKind != JsonValueKind.String) + { + return Ignored("it has no schema string"); + } + + if (!string.Equals(schema.GetString(), Schema, StringComparison.Ordinal)) + { + return Ignored("unknown schema '" + schema.GetString() + "', expected '" + Schema + "'"); + } + + if (!root.TryGetProperty("throwIn", out JsonElement throwIn) || throwIn.ValueKind != JsonValueKind.String) + { + return Ignored("it has no throwIn string"); + } + + string requested = throwIn.GetString() ?? string.Empty; + foreach (LiveProbeFaultStage stage in Enum.GetValues()) + { + if (string.Equals(stage.ToString(), requested, StringComparison.Ordinal)) + { + return new LiveProbeFaultDecision(stage, + "Fault switch selects " + stage + " (" + FileName + ", schema " + Schema + ").", true); + } + } + + return Ignored("unknown throwIn '" + requested + "'"); + } + catch (JsonException exception) + { + return Ignored("it is not valid JSON (" + exception.GetType().Name + ")"); + } + } + + private static LiveProbeFaultDecision Ignored(string why) + { + return new LiveProbeFaultDecision(LiveProbeFaultStage.None, + "Fault switch ignored and reported: " + FileName + " " + why + ".", true); + } + + private static string? ReadIfPresent(string path) + { + FileInfo file = new(path); + if (!file.Exists) + { + return null; + } + + if (file.Length > MaximumFileBytes) + { + throw new IOException("The fault switch is larger than " + + MaximumFileBytes.ToString(CultureInfo.InvariantCulture) + " bytes."); + } + + return File.ReadAllText(path); + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultStage.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultStage.cs new file mode 100644 index 00000000..b0dc3d52 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultStage.cs @@ -0,0 +1,17 @@ +namespace LiveProbe; + +/// The lifecycle stage at which the fault-injection switch throws, if any. +internal enum LiveProbeFaultStage +{ + /// No fault: the switch is absent, ignored or explicitly None. + None, + + /// The plugin factory throws before it constructs the plugin (the enable fails and is retried later). + FactoryCreate, + + /// OnEnable throws after the console commands are registered. + OnEnable, + + /// OnDisable throws after the console commands are unregistered. + OnDisable +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeHostFacts.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeHostFacts.cs new file mode 100644 index 00000000..d1af6dbb --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeHostFacts.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using System.Reflection; +using System.Runtime.Loader; + +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Context; + +namespace LiveProbe; + +/// +/// Facts the SDK host recorded about this process, read without Lua. Qualification scenarios Q03 (observed exports +/// size), Q04 (raw second bootstrap integer), Q05 (plugin id and epoch) and Q40 (where the assemblies were loaded +/// from) record them from ce77_live_probe_status_json(). +/// +internal readonly record struct LiveProbeHostFacts( + bool HasContext, + uint PluginId, + int Epoch, + int ReportedExportsSize, + bool HasProcessMessages, + bool HasCheckSynchronize, + string Phase, + int LastInitRecordArgument, + int LastVersionRecordSize, + string PluginAssemblyLocation, + string PluginAssemblyMvid, + string HostingAssemblyLocation, + string HostingAssemblyMvid, + string HostingLoadContext) +{ + /// Reads the current facts from and the loaded assemblies. + internal static LiveProbeHostFacts Capture() + { + PluginContext? context = PluginHost.Context; + Assembly plugin = typeof(LiveProbeHostFacts).Assembly; + Assembly hosting = typeof(PluginHost).Assembly; + AssemblyLoadContext? hostingLoadContext = AssemblyLoadContext.GetLoadContext(hosting); + return new LiveProbeHostFacts( + context is not null, + context?.PluginId ?? 0, + context?.Epoch ?? 0, + context?.ReportedExportsSize ?? 0, + context?.HasProcessMessages ?? false, + context?.HasCheckSynchronize ?? false, + PluginHost.Phase.ToString(), + PluginHost.LastInitRecordArgument, + PluginHost.LastVersionRecordSize, + plugin.Location, + plugin.ManifestModule.ModuleVersionId.ToString("D", CultureInfo.InvariantCulture), + hosting.Location, + hosting.ManifestModule.ModuleVersionId.ToString("D", CultureInfo.InvariantCulture), + hostingLoadContext is null + ? "" + : string.Create(CultureInfo.InvariantCulture, + $"{hostingLoadContext.Name ?? ""} (collectible={hostingLoadContext.IsCollectible})")); + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs index fee5e7d3..fc1bf1f3 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs @@ -1,12 +1,14 @@ +using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json; using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Threading; -using CheatEngine.SDK.LiveProbe.Tests; using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.References; @@ -20,6 +22,12 @@ namespace LiveProbe; internal static unsafe class LiveProbeState { private const uint TailCanary = 0x7A_51_CE_77U; + private const int MinimumPumpSeconds = 1; + private const int MaximumPumpSeconds = 60; + + /// The message of the exception ce77_live_probe_throw_managed_exception() throws (Q14 at C3). + internal const string ManagedExceptionMarker = "CE 7.7 live probe deliberate managed exception (Q14)."; + private static readonly Lock Gate = new(); private static BootstrapObservation s_bootstrap; private static AuthorizationDecision s_bootstrapAuthorization = AuthorizationDecision.Denied("Not evaluated."); @@ -33,6 +41,7 @@ internal static unsafe class LiveProbeState private static LuaCallback? s_callback; private static CallbackCounter? s_callbackCounter; private static int s_luaProbeSerial; + private static int s_managedExceptionThrows; internal static void CaptureBootstrap(nint initRecord, int opaqueHostArgument) { @@ -88,7 +97,10 @@ internal static void TryWriteTailCanaryAfterPackedRecord(nint initRecord, int bo { lock (Gate) { - s_bootstrap = s_bootstrap with { TailFailure = Describe(exception) }; + s_bootstrap = s_bootstrap with + { + TailFailure = Describe(exception) + }; } HostLog.Write(HostLogLevel.Error, "CE 7.7 live-probe tail canary failed.", exception); @@ -168,6 +180,12 @@ internal static void RecordDisable() } internal static string GetStatus() + { + return GetStatus(LiveProbeHostFacts.Capture()); + } + + // The facts parameter is the unit-test seam: production always captures them from PluginHost immediately. + internal static string GetStatus(LiveProbeHostFacts host) { lock (Gate) { @@ -177,7 +195,21 @@ internal static string GetStatus() .Append(s_bootstrap.InitRecord.ToString("X", CultureInfo.InvariantCulture)) .Append(", opaqueSecondInt=").Append(s_bootstrap.OpaqueArgument) .Append(" (raw; no size/version meaning assigned)") - .Append(", tailCanaryWritten=").Append(s_bootstrap.TailCanaryWritten) + .Append(", pluginHostLastInitRecordArgument=").Append(host.LastInitRecordArgument) + .Append(", phase=").Append(host.Phase); + + if (host.HasContext) + { + builder.Append(", pluginId=").Append(host.PluginId) + .Append(", epoch=").Append(host.Epoch) + .Append(", reportedExportsSize=").Append(host.ReportedExportsSize); + } + else + { + builder.Append(", context=none"); + } + + builder.Append(", tailCanaryWritten=").Append(s_bootstrap.TailCanaryWritten) .Append(", tailWrites=").Append(s_bootstrap.TailWriteCount); if (s_bootstrap.TailCanaryWritten) @@ -211,6 +243,95 @@ internal static string GetStatus() } } + internal static string GetStatusJson() + { + return GetStatusJson(LiveProbeHostFacts.Capture()); + } + + // One JSON object for the qualification driver (schema ce77-live-probe-status-v1). Like the text status it is a + // read of process-local facts: it neither touches a target nor calls Lua, so it is not authorization-gated. + internal static string GetStatusJson(LiveProbeHostFacts host) + { + LiveProbeStatusSnapshot snapshot; + lock (Gate) + { + snapshot = new LiveProbeStatusSnapshot( + host, + s_bootstrap.Calls, + s_bootstrap.OpaqueArgument, + s_bootstrap.TailCanaryWritten, + s_bootstrap.TailWriteCount, + s_bootstrap.TailReadBeforeWrite, + s_bootstrap.TailFailure, + s_bootstrapAuthorization.IsAllowed, + s_bootstrapAuthorization.Reason, + IsRuntimeProbeAllowedUnsafe(), + s_targetMatchFailure, + LiveProbeFaultInjection.Current, + LiveProbeFaultInjection.InjectedFaults, + s_managedExceptionThrows, + s_synchronize.ToDisplayString(), + s_luaThread.ToDisplayString(), + s_reset.ToDisplayString(), + s_callback is null + ? "not prepared" + : string.Create(CultureInfo.InvariantCulture, + $"prepared=true, released={s_callback.IsReleased}, managedCalls={s_callbackCounter?.Calls ?? 0}")); + } + + return LiveProbeStatusReport.ToJson(snapshot); + } + + internal static string ThrowManagedExceptionIfAuthorized() + { + return ThrowManagedExceptionIfAuthorized(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId); + } + + // Q14 at C3: the generated [LuaFunction] thunk must turn this exception into a Lua error that the driver's pcall + // catches, and the next call on the same state must still work. Without a fresh authorization it returns the + // denial and throws nothing. + internal static string ThrowManagedExceptionIfAuthorized(Func evaluateAuthorization, + Func getOpenedProcessId) + { + if (!TryRequireRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId, out string denied)) + { + return denied; + } + + lock (Gate) + { + s_managedExceptionThrows++; + } + + throw new InvalidOperationException(ManagedExceptionMarker); + } + + internal static string PumpMessages(double seconds) + { + return PumpMessages(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId, seconds, + PumpInsideAdmittedWork); + } + + // Q07 at C3, an observation rather than a promise: the pump runs as admitted main-thread work so that the operator + // can untick the plugin in Cheat Engine while this callback is still running. The SDK is expected to refuse that + // nested disable without waiting for itself; the returned record lists the lifecycle phases seen between pumps. + internal static string PumpMessages(Func evaluateAuthorization, + Func getOpenedProcessId, double seconds, Func pump) + { + if (!double.IsFinite(seconds) || seconds < MinimumPumpSeconds || seconds > MaximumPumpSeconds) + { + return string.Create(CultureInfo.InvariantCulture, + $"Pump refused: the duration must be between {MinimumPumpSeconds} and {MaximumPumpSeconds} seconds."); + } + + if (!TryRequireRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId, out string denied)) + { + return denied; + } + + return pump(seconds); + } + internal static string CaptureHostProfile() { return CaptureHostProfile(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId, @@ -252,7 +373,8 @@ internal static string BeginSynchronizeProbe() Thread thread = new(RunSynchronizeProbe) { - IsBackground = true, Name = "CheatEngine.SDK CE77 synchronize probe" + IsBackground = true, + Name = "CheatEngine.SDK CE77 synchronize probe" }; thread.Start(); return "Synchronize probe started. Do not block the CE GUI thread; poll ce77_live_probe_synchronize_status()."; @@ -298,7 +420,11 @@ internal static string BeginLuaThreadProbe() // The delayed worker starts only after this Lua callback has returned its string to CE. It is still a live, // opt-in observation against CE's per-thread state contract, never a general concurrency guarantee for Lua. - Thread thread = new(RunLuaThreadProbe) { IsBackground = true, Name = "CheatEngine.SDK CE77 Lua thread probe" }; + Thread thread = new(RunLuaThreadProbe) + { + IsBackground = true, + Name = "CheatEngine.SDK CE77 Lua thread probe" + }; thread.Start(); return "Lua thread/registry probe started. Do not run other Lua code for one second; poll ce77_live_probe_lua_threads_status()."; @@ -443,6 +569,57 @@ internal static string PrepareCallbackShutdownProbe() "Callback prepared. First run pcall(ce77_live_probe_callback_shutdown) once (it returns a count). Then disable this plugin in CE, run pcall(ce77_live_probe_callback_shutdown) again, and preserve the raw pcall result. Re-enable and call ce77_live_probe_status()."; } + private static string PumpInsideAdmittedWork(double seconds) + { + return MainThread.Invoke(static duration => PumpAndObservePhases(duration), seconds); + } + + private static string PumpAndObservePhases(double seconds) + { + PluginHostLifecyclePhase before = PluginHost.Phase; + PluginHostLifecyclePhase last = before; + List sequence = [before.ToString()]; + int pumps = 0; + TimeSpan limit = TimeSpan.FromSeconds(seconds); + Stopwatch elapsed = Stopwatch.StartNew(); + while (elapsed.Elapsed < limit) + { + MainThread.ProcessMessages(); + pumps++; + PluginHostLifecyclePhase now = PluginHost.Phase; + if (now != last) + { + sequence.Add(now.ToString()); + last = now; + } + + Thread.Sleep(20); + } + + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + writer.WriteString("schema", "ce77-live-probe-pump-v1"); + writer.WriteNumber("requestedSeconds", seconds); + writer.WriteNumber("elapsedMs", (long) elapsed.Elapsed.TotalMilliseconds); + writer.WriteNumber("pumps", pumps); + writer.WriteString("phaseBefore", before.ToString()); + writer.WriteString("phaseAfter", PluginHost.Phase.ToString()); + writer.WriteStartArray("phaseSequence"); + foreach (string phase in sequence) + { + writer.WriteStringValue(phase); + } + + writer.WriteEndArray(); + writer.WriteBoolean("enabledAfter", PluginHost.IsEnabled); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + private static void RunSynchronizeProbe() { SynchronizeObservation observation = SynchronizeObservation.Started; @@ -466,21 +643,34 @@ private static void RunSynchronizeProbe() { MainThread.Invoke( static _ => throw new InvalidOperationException("CE77-live-probe expected dispatch failure."), 0); - observation = observation with { ExceptionResult = "unexpectedly returned" }; + observation = observation with + { + ExceptionResult = "unexpectedly returned" + }; } catch (InvalidOperationException exception) { - observation = observation with { ExceptionResult = "re-thrown: " + exception.Message }; + observation = observation with + { + ExceptionResult = "re-thrown: " + exception.Message + }; } } catch (Exception exception) { - observation = observation with { Completion = "failed", Failure = Describe(exception) }; + observation = observation with + { + Completion = "failed", + Failure = Describe(exception) + }; } lock (Gate) { - s_synchronize = observation with { IsRunning = false }; + s_synchronize = observation with + { + IsRunning = false + }; } } @@ -517,21 +707,29 @@ private static void RunLuaThreadProbe() } catch (Exception exception) { - observation = observation with { Completion = "failed", Failure = Describe(exception) }; + observation = observation with + { + Completion = "failed", + Failure = Describe(exception) + }; } lock (Gate) { - s_luaThread = observation with { IsRunning = false }; + s_luaThread = observation with + { + IsRunning = false + }; } } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static int CallbackShutdownThunk(nint statePointer) { - LuaState state = new(statePointer); + // The whole body is one guard try (CESDK1004): nothing may run before it, not even the state wrapper. try { + LuaState state = new(statePointer); if (!LuaThunk.TryGetState(state, out CallbackCounter? counter)) { return LuaThunk.Fail(state, "callback shutdown probe state is unavailable"u8); @@ -542,7 +740,7 @@ private static int CallbackShutdownThunk(nint statePointer) } catch (Exception exception) { - return LuaThunk.Fail(state, exception); + return LuaThunk.Fail(new LuaState(statePointer), exception); } } diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusReport.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusReport.cs new file mode 100644 index 00000000..96cfbfea --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusReport.cs @@ -0,0 +1,133 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace LiveProbe; + +/// +/// Serializes the status record as one JSON object (schema ). The qualification driver stores the +/// text verbatim in its event log; the runner parses it. Nothing here interprets the second bootstrap integer. +/// +internal static class LiveProbeStatusReport +{ + internal const string Schema = "ce77-live-probe-status-v1"; + + /// The value of bootstrap.interpretation: the raw integer is never given a meaning here. + internal const string NoInterpretation = "none"; + + internal static string ToJson(in LiveProbeStatusSnapshot snapshot) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + writer.WriteString("schema", Schema); + WriteBootstrap(writer, snapshot); + + writer.WriteStartObject("versionQuery"); + writer.WriteNumber("lastRecordSize", snapshot.Host.LastVersionRecordSize); + writer.WriteEndObject(); + + WriteContext(writer, snapshot.Host); + WriteIdentity(writer, snapshot.Host); + WriteGates(writer, snapshot); + WriteFaultInjection(writer, snapshot); + writer.WriteNumber("managedExceptionThrows", snapshot.ManagedExceptionThrows); + + writer.WriteStartObject("observations"); + writer.WriteString("synchronize", snapshot.Synchronize); + writer.WriteString("luaThreads", snapshot.LuaThreads); + writer.WriteString("reset", snapshot.Reset); + writer.WriteString("callback", snapshot.Callback); + writer.WriteEndObject(); + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteBootstrap(Utf8JsonWriter writer, in LiveProbeStatusSnapshot snapshot) + { + writer.WriteStartObject("bootstrap"); + writer.WriteNumber("calls", snapshot.BootstrapCalls); + writer.WriteNumber("opaqueSecondInt", snapshot.OpaqueSecondInt); + writer.WriteNumber("pluginHostLastInitRecordArgument", snapshot.Host.LastInitRecordArgument); + writer.WriteString("interpretation", NoInterpretation); + writer.WriteBoolean("tailCanaryWritten", snapshot.TailCanaryWritten); + writer.WriteNumber("tailWrites", snapshot.TailWrites); + if (snapshot.TailCanaryWritten) + { + writer.WriteString("tailBeforeHex", snapshot.TailReadBeforeWrite.ToString("X8", CultureInfo.InvariantCulture)); + } + else + { + writer.WriteNull("tailBeforeHex"); + } + + if (snapshot.TailFailure is null) + { + writer.WriteNull("tailFailure"); + } + else + { + writer.WriteString("tailFailure", snapshot.TailFailure); + } + + writer.WriteEndObject(); + } + + private static void WriteContext(Utf8JsonWriter writer, in LiveProbeHostFacts host) + { + writer.WriteStartObject("context"); + writer.WriteBoolean("present", host.HasContext); + if (host.HasContext) + { + writer.WriteNumber("pluginId", host.PluginId); + writer.WriteNumber("epoch", host.Epoch); + writer.WriteNumber("reportedExportsSize", host.ReportedExportsSize); + writer.WriteBoolean("hasProcessMessages", host.HasProcessMessages); + writer.WriteBoolean("hasCheckSynchronize", host.HasCheckSynchronize); + } + + writer.WriteString("phase", host.Phase); + writer.WriteEndObject(); + } + + private static void WriteIdentity(Utf8JsonWriter writer, in LiveProbeHostFacts host) + { + writer.WriteStartObject("identity"); + writer.WriteString("pluginAssemblyLocation", host.PluginAssemblyLocation); + writer.WriteString("pluginAssemblyMvid", host.PluginAssemblyMvid); + writer.WriteString("hostingAssemblyLocation", host.HostingAssemblyLocation); + writer.WriteString("hostingAssemblyMvid", host.HostingAssemblyMvid); + writer.WriteString("hostingLoadContext", host.HostingLoadContext); + writer.WriteEndObject(); + } + + private static void WriteGates(Utf8JsonWriter writer, in LiveProbeStatusSnapshot snapshot) + { + writer.WriteStartObject("gates"); + writer.WriteBoolean("bootstrapAllowed", snapshot.BootstrapGateAllowed); + writer.WriteString("bootstrapReason", snapshot.BootstrapGateReason); + writer.WriteBoolean("runtimeAllowed", snapshot.RuntimeGateAllowed); + writer.WriteString("runtimeReason", snapshot.RuntimeGateReason); + writer.WriteEndObject(); + } + + private static void WriteFaultInjection(Utf8JsonWriter writer, in LiveProbeStatusSnapshot snapshot) + { + writer.WriteStartObject("faultInjection"); + writer.WriteString("stage", snapshot.Fault.Stage.ToString()); + writer.WriteString("reason", snapshot.Fault.Reason); + writer.WriteBoolean("fileFound", snapshot.Fault.FileFound); + writer.WriteStartArray("injected"); + foreach (string injected in snapshot.InjectedFaults) + { + writer.WriteStringValue(injected); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusSnapshot.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusSnapshot.cs new file mode 100644 index 00000000..49c640cf --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusSnapshot.cs @@ -0,0 +1,22 @@ +namespace LiveProbe; + +/// Everything ce77_live_probe_status_json() reports, captured under the probe's lock. +internal readonly record struct LiveProbeStatusSnapshot( + LiveProbeHostFacts Host, + int BootstrapCalls, + int OpaqueSecondInt, + bool TailCanaryWritten, + int TailWrites, + uint TailReadBeforeWrite, + string? TailFailure, + bool BootstrapGateAllowed, + string BootstrapGateReason, + bool RuntimeGateAllowed, + string RuntimeGateReason, + LiveProbeFaultDecision Fault, + IReadOnlyList InjectedFaults, + int ManagedExceptionThrows, + string Synchronize, + string LuaThreads, + string Reset, + string Callback); diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs b/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs index 47bdecb2..76a0b596 100644 --- a/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs +++ b/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs @@ -8,76 +8,107 @@ namespace LiveProbe; /// internal static partial class ProbeConsole { - /// Returns the gate decision and all observations accumulated in this process. - [LuaFunction("ce77_live_probe_status")] - public static string Status() - { - return LiveProbeState.GetStatus(); - } + /// Returns the gate decision and all observations accumulated in this process. + [LuaFunction("ce77_live_probe_status")] + public static string Status() + { + return LiveProbeState.GetStatus(); + } - /// Captures a JSON identity record for the authorized CE host, Lua, bridge, plugin, and disposable target. - [LuaFunction("ce77_live_probe_host_profile")] - public static string HostProfile() - { - return LiveProbeState.CaptureHostProfile(); - } + /// + /// Returns the same observations as as one JSON object (schema + /// ce77-live-probe-status-v1) for the qualification driver: plugin id, epoch, reported exports size, the raw + /// second bootstrap integer, assembly identities and the fault-switch decision. + /// + [LuaFunction("ce77_live_probe_status_json")] + public static string StatusJson() + { + return LiveProbeState.GetStatusJson(); + } - /// Starts the non-mutating worker-to-GUI synchronize probe and returns immediately. - [LuaFunction("ce77_live_probe_begin_synchronize")] - public static string BeginSynchronize() - { - return LiveProbeState.BeginSynchronizeProbe(); - } + /// + /// Authorized only: throws a managed exception inside the generated thunk so that a pcall can record the + /// resulting Lua error (qualification scenario Q14 at C3). Returns the denial text when not authorized. + /// + [LuaFunction("ce77_live_probe_throw_managed_exception")] + public static string ThrowManagedException() + { + return LiveProbeState.ThrowManagedExceptionIfAuthorized(); + } - /// Returns the most recent synchronize probe result without waiting or pumping the GUI thread. - [LuaFunction("ce77_live_probe_synchronize_status")] - public static string SynchronizeStatus() - { - return LiveProbeState.GetSynchronizeStatus(); - } + /// + /// Authorized only: pumps Cheat Engine's messages for 1 to 60 seconds from admitted main-thread work, so that an + /// operator can untick the plugin while this callback runs (Q07 at C3). Returns a JSON phase record. + /// + [LuaFunction("ce77_live_probe_pump_messages")] + public static string PumpMessages(double seconds) + { + return LiveProbeState.PumpMessages(seconds); + } - /// Starts the worker Lua-state and shared-registry observation and returns immediately. - [LuaFunction("ce77_live_probe_begin_lua_threads")] - public static string BeginLuaThreads() - { - return LiveProbeState.BeginLuaThreadProbe(); - } + /// Captures a JSON identity record for the authorized CE host, Lua, bridge, plugin, and disposable target. + [LuaFunction("ce77_live_probe_host_profile")] + public static string HostProfile() + { + return LiveProbeState.CaptureHostProfile(); + } - /// Returns the worker Lua-state and shared-registry observation. - [LuaFunction("ce77_live_probe_lua_threads_status")] - public static string LuaThreadsStatus() - { - return LiveProbeState.GetLuaThreadStatus(); - } + /// Starts the non-mutating worker-to-GUI synchronize probe and returns immediately. + [LuaFunction("ce77_live_probe_begin_synchronize")] + public static string BeginSynchronize() + { + return LiveProbeState.BeginSynchronizeProbe(); + } - /// Captures a Lua-state/reference snapshot before an operator manually calls CE's resetLuaState(). - [LuaFunction("ce77_live_probe_snapshot_before_reset")] - public static string SnapshotBeforeReset() - { - return LiveProbeState.SnapshotBeforeReset(); - } + /// Returns the most recent synchronize probe result without waiting or pumping the GUI thread. + [LuaFunction("ce77_live_probe_synchronize_status")] + public static string SynchronizeStatus() + { + return LiveProbeState.GetSynchronizeStatus(); + } - /// Captures a second snapshot after an operator manually called CE's resetLuaState(). - [LuaFunction("ce77_live_probe_snapshot_after_reset")] - public static string SnapshotAfterReset() - { - return LiveProbeState.SnapshotAfterReset(); - } + /// Starts the worker Lua-state and shared-registry observation and returns immediately. + [LuaFunction("ce77_live_probe_begin_lua_threads")] + public static string BeginLuaThreads() + { + return LiveProbeState.BeginLuaThreadProbe(); + } - /// Observes the type and identity text of CE's getMainForm() userdata without retaining it. - [LuaFunction("ce77_live_probe_userdata")] - public static string ObserveUserdata() - { - return LiveProbeState.ObserveHostUserdata(); - } + /// Returns the worker Lua-state and shared-registry observation. + [LuaFunction("ce77_live_probe_lua_threads_status")] + public static string LuaThreadsStatus() + { + return LiveProbeState.GetLuaThreadStatus(); + } - /// - /// Registers a harmless managed callback, intentionally leaves it registered, then asks the operator to disable - /// the plugin and call it through pcall. This tests LuaRuntime.Detach's callback neutralization. - /// - [LuaFunction("ce77_live_probe_prepare_callback_shutdown")] - public static string PrepareCallbackShutdown() - { - return LiveProbeState.PrepareCallbackShutdownProbe(); - } + /// Captures a Lua-state/reference snapshot before an operator manually calls CE's resetLuaState(). + [LuaFunction("ce77_live_probe_snapshot_before_reset")] + public static string SnapshotBeforeReset() + { + return LiveProbeState.SnapshotBeforeReset(); + } + + /// Captures a second snapshot after an operator manually called CE's resetLuaState(). + [LuaFunction("ce77_live_probe_snapshot_after_reset")] + public static string SnapshotAfterReset() + { + return LiveProbeState.SnapshotAfterReset(); + } + + /// Observes the type and identity text of CE's getMainForm() userdata without retaining it. + [LuaFunction("ce77_live_probe_userdata")] + public static string ObserveUserdata() + { + return LiveProbeState.ObserveHostUserdata(); + } + + /// + /// Registers a harmless managed callback, intentionally leaves it registered, then asks the operator to disable + /// the plugin and call it through pcall. This tests LuaRuntime.Detach's callback neutralization. + /// + [LuaFunction("ce77_live_probe_prepare_callback_shutdown")] + public static string PrepareCallbackShutdown() + { + return LiveProbeState.PrepareCallbackShutdownProbe(); + } } diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbeHostGlobals.cs b/tests/CheatEngine.SDK.LiveProbe/ProbeHostGlobals.cs index 45c8a72f..a8f8a5f4 100644 --- a/tests/CheatEngine.SDK.LiveProbe/ProbeHostGlobals.cs +++ b/tests/CheatEngine.SDK.LiveProbe/ProbeHostGlobals.cs @@ -5,7 +5,7 @@ namespace LiveProbe; /// Minimal CE Lua globals required only to verify the operator's target attachment. internal static partial class ProbeHostGlobals { - /// Reads CE's current selected process identifier. - [LuaGlobal("getOpenedProcessID")] - internal static partial long GetOpenedProcessId(); + /// Reads CE's current selected process identifier. + [LuaGlobal("getOpenedProcessID")] + internal static partial long GetOpenedProcessId(); } diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs index 95be9406..a0a4e8be 100644 --- a/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs +++ b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs @@ -6,10 +6,12 @@ namespace LiveProbe; // Cheat Engine before PluginHost sees it, and must not give that value a size/version meaning. internal sealed class ProbePluginFactory : IPluginFactory { - public static ReadOnlySpan Utf8Name => "CheatEngine.SDK CE 7.7 Live Probe"u8; + public static ReadOnlySpan Utf8Name => "CheatEngine.SDK CE 7.7 Live Probe"u8; - public static CheatEnginePlugin Create() - { - return new Ce77LiveProbePlugin(); - } + public static CheatEnginePlugin Create() + { + // Checkpoint B, Q06: an authorized liveprobe.fault.json can make construction fail for this enable. + LiveProbeFaultInjection.EnterFactoryCreate(); + return new Ce77LiveProbePlugin(); + } } diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactoryNonAscii.cs b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactoryNonAscii.cs new file mode 100644 index 00000000..073643fb --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactoryNonAscii.cs @@ -0,0 +1,18 @@ +#if LIVEPROBE_NON_ASCII_NAME +using CheatEngine.SDK.Hosting.Plugin; + +namespace LiveProbe; + +// Built only with -p:LiveProbeNonAsciiName=true (Checkpoint B, Q05.a). The name mixes one Latin-1 character and two +// characters outside code page 1252, so the run shows how Cheat Engine 7.7 decodes the name the SDK converts to the +// process ANSI code page (libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs). +internal sealed class ProbePluginFactoryNonAscii : IPluginFactory +{ + public static ReadOnlySpan Utf8Name => "CheatEngine.SDK Live Probe \u00e9 \u65e5\u672c"u8; + + public static CheatEnginePlugin Create() + { + return ProbePluginFactory.Create(); + } +} +#endif diff --git a/tests/CheatEngine.SDK.LiveProbe/README.md b/tests/CheatEngine.SDK.LiveProbe/README.md index 14f762a1..b926acb5 100644 --- a/tests/CheatEngine.SDK.LiveProbe/README.md +++ b/tests/CheatEngine.SDK.LiveProbe/README.md @@ -3,11 +3,27 @@ `CheatEngine.SDK.LiveProbe` is a manually loaded **evidence harness**, not a unit-test project, sample plugin, package asset, or normal CI input. It records CE 7.7 behaviours that fixture tests cannot establish: the raw managed bootstrap argument, the disputed packed-record tail, `synchronize`, per-thread Lua states and registry sharing, external -`resetLuaState`, CE userdata, and callback cleanup on plugin disable. +`resetLuaState`, CE userdata, callback cleanup on plugin disable, and the Checkpoint B hooks of the qualification runner. + +## Objective + +Give a local qualification run and a human operator one +plugin that exposes, through Lua-console commands, the facts the exact-host (C3) qualification scenarios record: plugin +id and epoch (Q05), the reported exports-table size (Q03), the raw second bootstrap integer (Q04), a managed exception +inside a Lua callback (Q14), lifecycle faults on demand (Q06, Q08), a message pump during a callback (Q07), the +non-ASCII plugin name (Q05.a) and where the SDK assemblies were loaded from (Q40). + +## Why it exists + +Only a real Cheat Engine host can show these behaviours, and C1/C2 success is never host evidence. The harness keeps +every probe opt-in and fail-closed, so +loading it by mistake observes nothing and changes nothing. The solution compiles it, so a compile break is caught by CI; +CI never loads or runs it. ## Safety boundary -The probe is deliberately inert unless all of these are true: +The probe is deliberately inert unless all of these are true (the one exception is the two status commands below, which +only report process-local facts the probe already holds and act on nothing): 1. The process is x64 and its main executable is exactly CE `7.7.0.10621` x64, SHA-256 `9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D`. @@ -23,14 +39,72 @@ SDK's conservative packed 36-byte bootstrap record. It runs only during bootstra isolated to the CE process, not the target, but must still be run only with a disposable test setup. A canary value that survives **does not prove allocation capacity** on its own; preserve all raw observations for review. -The harness does not write a result file: it logs raw values through `HostLog`/`OutputDebugString`, so capture it with a -debugger or DebugView and save the transcript outside the repository. Do not place installed CE binaries, target -binaries, manifests containing sensitive paths, or captured process memory in source control. +The harness does not write a result file: it logs raw values through `HostLog`/`OutputDebugString` and returns them to +the Lua caller. The qualification runner's Lua driver records the returned values in a redacted event log; a manual +operator keeps the transcript outside the repository. Do not place installed CE binaries, target binaries, manifests +containing sensitive paths, or captured process memory in source control. + +## How it works + +| File | Content | +|-----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `CE77LiveProbeBootstrap.cs` | The hand-written `CESDK.CESDK.CEPluginInitialize(nint, int)`. It records the second integer raw, then forwards to `PluginHost.InitializeManaged`. With `LIVEPROBE_NON_ASCII_NAME` it selects the non-ASCII factory. | +| `ProbePluginFactory.cs`, `ProbePluginFactoryNonAscii.cs` | The factories. `Create` first evaluates the fault switch. The non-ASCII factory exists only in the `-p:LiveProbeNonAsciiName=true` build. | +| `Ce77LiveProbePlugin.cs` | `OnEnable` registers the console commands, re-checks the gates and applies the `OnEnable` fault; `OnDisable` unregisters them and applies the `OnDisable` fault. | +| `ProbeConsole.cs`, `ProbeHostGlobals.cs` | The `[LuaFunction]` console commands below and the one `[LuaGlobal("getOpenedProcessID")]` binding. | +| `LiveProbeAuthorization.cs`, `AuthorizationDecision.cs` | The fail-closed gate: exact host hash and version, operator acknowledgement, unexpired manifest, live hash-verified disposable target. | +| `LiveProbeState.cs` | Process-local observations and the command implementations. Every command that acts re-evaluates the gate first. | +| `LiveProbeFaultInjection.cs`, `LiveProbeFaultStage.cs`, `LiveProbeFaultDecision.cs` | The fault switch (see below). Lua-free, compile-linked into the tests. | +| `LiveProbeHostFacts.cs`, `LiveProbeStatusSnapshot.cs`, `LiveProbeStatusReport.cs` | The status record: `PluginHost` facts, gates, fault decisions and observations serialized as `ce77-live-probe-status-v1` JSON. Lua-free, compile-linked into the tests. | +| `HostProfileObservation.cs` | The `ce77-live-host-profile-v1` identity record of host, Lua module, bridge, plugin and target files. | + +### Console commands + +Every command returns a self-contained string. Commands that act (all except the two status commands) re-evaluate the +authorization manifest, the target image and CE's opened PID immediately before acting, and return +`Live probe denied: ` otherwise. + +| Command | Observation | Used by | +|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------| +| `ce77_live_probe_status()` | Human-readable: bootstrap calls, the raw second integer (`opaqueSecondInt`, never labelled size or version), `PluginHost.LastInitRecordArgument`, phase, plugin id, epoch, reported exports size, gates, prior observations. | operator | +| `ce77_live_probe_status_json()` | The same facts as one `ce77-live-probe-status-v1` JSON object, plus assembly locations, MVIDs, the Hosting load context and every fault-switch decision. Not gated: it reads process-local facts only. | Q03, Q04, Q05, Q06, Q08, Q40 | +| `ce77_live_probe_host_profile()` | One `ce77-live-host-profile-v1` JSON identity record for the authorized CE host, loaded Lua module, loaded bridge, plugin and disposable target. | Q40 | +| `ce77_live_probe_throw_managed_exception()` | Throws `InvalidOperationException("CE 7.7 live probe deliberate managed exception (Q14).")` inside the generated thunk; call it under `pcall` and record the Lua error. | Q14 | +| `ce77_live_probe_pump_messages(seconds)` | Pumps CE's messages for 1–60 seconds from admitted main-thread work and returns a `ce77-live-probe-pump-v1` JSON record of the lifecycle phases seen. The operator unticks the plugin meanwhile. An observation, not a promise. | Q07 | +| `ce77_live_probe_begin_synchronize()` then `ce77_live_probe_synchronize_status()` | Worker, thunk and nested-invoke managed thread IDs; return round-trip and propagated exception. | operator | +| `ce77_live_probe_begin_lua_threads()` then `ce77_live_probe_lua_threads_status()` | GUI and worker `lua_State*` identities and a private raw-registry marker read by the worker. Do not execute other Lua for one second. | Q19 | +| `ce77_live_probe_snapshot_before_reset()` / `ce77_live_probe_snapshot_after_reset()` | State pointer, SDK epoch and reference slot before and after an operator-run `resetLuaState()`; whether the old SDK reference still pushed. The harness never calls the reset. | Q17, Q18 | +| `ce77_live_probe_userdata()` | `type(getMainForm())` and `tostring(getMainForm())`, without retaining the userdata or invoking the host-object pusher. | operator | +| `ce77_live_probe_prepare_callback_shutdown()` | Installs a counter callback and leaves it registered in `OnDisable`, so `LuaRuntime.Detach` must neutralize it. Call `pcall(ce77_live_probe_callback_shutdown)` before and after disabling. | Q15 | + +### Fault switch (Q06, Q08) + +A file named `liveprobe.fault.json` next to `CheatEngine.SDK.LiveProbe.dll` selects one lifecycle stage that throws: + +```json +{ "schema": "ce77-live-probe-fault-v1", "throwIn": "OnEnable" } +``` + +`throwIn` is `None`, `FactoryCreate` (the factory throws before constructing the plugin; the enable fails and the next +enable tries again), `OnEnable` (after the console commands are registered) or `OnDisable` (after they are +unregistered). The switch is read once per enable, without Lua, and only when the authorization gate allows: without +the manifest the file is never opened. An unknown schema, an unknown stage or malformed JSON is ignored and reported. Every +decision is logged and appears under `faultInjection` in `ce77_live_probe_status_json()`, with the list of stages that +actually threw. The runner writes and deletes the file in the bundle folder; it never lives in the repository. + +### Non-ASCII name build (Q05.a) + +`dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Release -p:LiveProbeNonAsciiName=true` +defines `LIVEPROBE_NON_ASCII_NAME`, and the bootstrap then registers the name `CheatEngine.SDK Live Probe é 日本` (one +Latin-1 character and two characters outside code page 1252). It settles, by observation, whether Cheat Engine 7.7 +decodes the name the SDK converts to the process ANSI code page +([`AnsiNameBuffer`](../../libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs)). The default build keeps the ASCII +name `CheatEngine.SDK CE 7.7 Live Probe`. The switch writes to the same output folder, so rebuild without it afterwards. ## Build and load -Build it manually; it is intentionally absent from `CheatEngine.SDK.slnx`, so ordinary SDK builds and CI never load or -run it. +A qualification run builds the harness from the exact CI package into a clean folder and drives it. For a manual +session: ```powershell dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Release @@ -38,9 +112,8 @@ dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Keep the complete `artifacts/bin/CheatEngine.SDK.LiveProbe/release/` folder together when loading `CheatEngine.SDK.LiveProbe.dll` in CE's plugin settings. It needs the SDK assemblies, `.deps.json`, -`.runtimeconfig.json` and `cheatengine-sdk-lua-bridge.dll` next to the plugin. Follow the CE/.NET runtime-host setup -requirements documented by [`CheatEngine.SDK.LivePlugin`](../CheatEngine.SDK.LivePlugin/README.md) before attempting a -live run. +`.runtimeconfig.json` and `cheatengine-sdk-lua-bridge.dll` next to the plugin. Record the supported host and runtime +policy alongside the transcript; never edit an installed CE to run this harness. Create a short-lived authorization file on a secure local volume. Substitute only the hash and PID of the disposable program that the operator has deliberately launched and attached in CE: @@ -59,36 +132,49 @@ program that the operator has deliberately launched and attached in CE: Set both environment variables in the same process tree that starts CE. Check `ce77_live_probe_status()` immediately after enabling. If it reports any denied gate, stop: none of the action commands should be used and no result is -evidence. - -## Console protocol - -Run every command from CE's Lua Engine and preserve the command, UTC time, returned text, DebugView transcript, CE -binary hash, target image hash, PID, architecture and manifest expiry with the result. Commands intentionally do not -guess a pass/fail conclusion. - -| Command | Observation | Operator action / interpretation | -|-----------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ce77_live_probe_status()` | Every raw bootstrap integer, tail-canary record, gate decision and prior outcome. | The second integer is reported as `opaqueSecondInt`; never label it size/version from this output alone. | -| `ce77_live_probe_host_profile()` | One JSON identity record for the authorized CE host, loaded Lua module, adjacent bridge binary, plugin binary, and disposable target. | Save the returned JSON with the DebugView transcript outside the repository. An observed file is not a live qualification until the artifact is reviewed against the catalogue. | -| `ce77_live_probe_begin_synchronize()` then `ce77_live_probe_synchronize_status()` | Worker, thunk and nested-invoke managed thread IDs; return round-trip and propagated exception. | Do not block the GUI; poll until completion. Compare IDs with the enable-thread log. | -| `ce77_live_probe_begin_lua_threads()` then `ce77_live_probe_lua_threads_status()` | GUI and worker `lua_State*` identities and a private raw-registry marker read by the worker. | Do not execute other Lua for one second. This is a narrow observation, not permission for arbitrary concurrent Lua. | -| `ce77_live_probe_snapshot_before_reset()` | State pointer, SDK epoch and reference slot before reset. | Manually call CE's `resetLuaState()`; the harness never calls it. | -| `ce77_live_probe_snapshot_after_reset()` | State/epoch after reset and whether the old SDK reference pushed. | Record raw outcome. External reset without a corresponding SDK notification remains unsupported. | -| `ce77_live_probe_userdata()` | `type(getMainForm())` and `tostring(getMainForm())`. | This observes CE userdata without retaining it or invoking the host-object pusher. | -| `ce77_live_probe_prepare_callback_shutdown()` | Installs a counter callback. | Call `pcall(ce77_live_probe_callback_shutdown)` once; disable the plugin; call it again under `pcall`; then re-enable and collect `status()`. | - -The callback probe intentionally leaves the callback registered in `OnDisable`; `LuaRuntime.Detach` is responsible for -neutralizing it. Do not force-unload assemblies or use CE's process-killing actions to end a run. Disable the plugin, -close CE normally, delete the short-lived authorization manifest, and terminate only the disposable target through its -normal cleanup route. +evidence. Disable the plugin, close CE normally, delete the short-lived authorization manifest, and terminate only the +disposable target through its normal cleanup route. Do not force-unload assemblies or use CE's process-killing actions. + +## Promise + +- The harness is compiled by CI through `CheatEngine.SDK.slnx` as an x64 dynamic-loading plugin that is not a test + module and never packs (`QualificationHarnessShapeTests.LiveProbe_is_in_the_solution_as_an_x64_dynamic_loading_plugin_that_never_packs`). +- A fresh authorization is required before every acting command, and a changed manifest, target image or CE target + PID is refused (`LiveProbeStateTests`). +- The raw second bootstrap integer and the reported exports size are reported without interpretation + (`LiveProbeStatusTests.Status_reports_the_exports_size_and_the_raw_second_bootstrap_integer_without_interpretation`). +- Without authorization the exception and pump hooks are inert, and the pump refuses a duration outside 1–60 seconds + before any host call (`LiveProbeStatusTests`). +- The two status commands are deliberately not gated: they serialize process-local facts (bootstrap record, `PluginHost` + state, gate results, fault decisions, assembly identities), touch no target and make no Lua call of their own; an absent context is + reported as absent, never as zero values + (`LiveProbeStatusTests.Status_without_an_enabled_context_reports_none_instead_of_zero_values`, + `LiveProbeStatusTests.Status_json_reports_the_fault_switch_decision_and_the_assembly_identities`). +- The fault switch is never read without authorization, selects exactly the requested stage, and ignores and reports an + absent, unreadable or unknown switch (`LiveProbeFaultInjectionTests`). +- Missing, locked or vanishing identity files are reported as typed outcomes, never as a crash + (`HostProfileObservationTests`), and the bridge identity is the module the process loaded, never a file guessed next + to the application + (`HostProfileObservationTests.Host_profile_records_the_loaded_bridge_module_and_never_a_file_next_to_the_application`). +- No live test is invoked by `dotnet test`, normal CI, Release validation or packaging: the solution only compiles it, + and no workflow references the runner (`LocalQualificationRunnerTests.No_workflow_references_the_local_qualification_runner`). + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj +``` + +The tests compile the Lua-free sources of this harness directly (see +[`CheatEngine.SDK.LiveProbe.Tests`](../CheatEngine.SDK.LiveProbe.Tests/README.md)); a host run is the qualification +runner's job. ## Scope and limitations -- `ce77_live_probe_host_profile()` and every protected command re-evaluate the authorization manifest, target image, - and CE opened-process PID immediately before acting. This is a current-state check, not proof that CE did not select - another target between observations; PID reuse by an identical executable is not distinguishable without an - operator-supplied incarnation value, which the `ce77-live-probe-v1` manifest does not contain. +- Every protected command re-evaluates the gate immediately before acting. This is a current-state check, not proof + that CE did not select another target between observations; PID reuse by an identical executable is not + distinguishable without an operator-supplied incarnation value, which the `ce77-live-probe-v1` manifest does not + contain. - The plugin does not implement the classic native plugin Type-6 popup callback. That callback belongs to the classic ABI and needs a separately compiled, header-pinned native probe after the CE 7.7 header/Pascal divergence has been resolved. @@ -101,8 +187,7 @@ normal cleanup route. reset/generation contract. - The worker-and-registry observation is opt-in only. A distinct worker Lua pointer may be a coroutine sharing the main virtual machine, heap and registry, so it is not evidence of independent heaps or safe concurrent execution. -- No live test is invoked by `dotnet test`, normal CI, Release validation or packaging. The only automated validation of - this project is compilation. +- The pump hook reports what happened while the operator acted; it does not promise how Cheat Engine delivers a + disable during a callback. -Detailed result templates and evidence rules live in [ -`documentations/CheatEngine.SDK/live-probes`](../../documentations/CheatEngine.SDK/live-probes/README.md). +Record every result and its evidence in the pull request or release notes that claim it. diff --git a/tests/CheatEngine.SDK.LiveProbe/packages.lock.json b/tests/CheatEngine.SDK.LiveProbe/packages.lock.json new file mode 100644 index 00000000..ca4a72fd --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/packages.lock.json @@ -0,0 +1,44 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs b/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs index 7261da7c..6fad5749 100644 --- a/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs +++ b/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs @@ -95,9 +95,9 @@ public static int Main(string[] arguments) private static bool HasValidArguments(string[] arguments) { return arguments.Length is >= 1 and <= 2 && - (arguments.Length != 2 || - string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) || - string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal)); + (arguments.Length != 2 || + string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) || + string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal)); } private static int RunRequestedProbe(LuaState state, nint module, string[] arguments) @@ -248,7 +248,13 @@ private static int ProbeStringAllocation(LuaState state, byte[] message) return Fail("TryPushString did not return LUA_ERRMEM"); } - return AssertErrorThenRestoreSentinel(state, "TryPushString"); + if (AssertErrorThenRestoreSentinel(state, "TryPushString") != 0) + { + return 1; + } + + WriteMarker("MARK PushBytes protected failure recovered"); + return 0; } private static int RunCheckStackGrowthProbe(LuaState state) @@ -501,7 +507,7 @@ private static int VerifyUncheckedFunctionReservationFailure(LuaState state, Lua catch (InvalidOperationException exception) { if (!string.Equals(exception.Message, UncheckedFunctionReservationFailureMessage, - StringComparison.Ordinal)) + StringComparison.Ordinal)) { return Fail("PushUncheckedFunction returned an unstable reservation failure message"); } @@ -544,7 +550,13 @@ private static int ProbeThunkFailure(LuaState state, byte[] message) return Fail("LuaThunk.Fail returned the wrong result count"); } - return AssertResultsThenRestoreSentinel(state, LuaThunk.FailureResultCount, "LuaThunk.Fail"); + if (AssertResultsThenRestoreSentinel(state, LuaThunk.FailureResultCount, "LuaThunk.Fail") != 0) + { + return 1; + } + + WriteMarker("MARK LuaThunk.Fail protected failure recovered"); + return 0; } private static int ProbeTableAllocation(LuaState state) @@ -560,7 +572,13 @@ private static int ProbeTableAllocation(LuaState state) return Fail("CreateTable did not throw LUA_ERRMEM"); } - return AssertOnlySentinelRemains(state, "CreateTable"); + if (AssertOnlySentinelRemains(state, "CreateTable") != 0) + { + return 1; + } + + WriteMarker("MARK CreateTable protected failure recovered"); + return 0; } private static int ProbeByteTableAllocation(LuaState state) @@ -599,7 +617,13 @@ private static int ProbeUserdataAllocation(LuaState state) return Fail("NewUserdata did not throw LUA_ERRMEM"); } - return AssertOnlySentinelRemains(state, "NewUserdata"); + if (AssertOnlySentinelRemains(state, "NewUserdata") != 0) + { + return 1; + } + + WriteMarker("MARK NewUserdata protected failure recovered"); + return 0; } private static int ProbeRawSetAllocation(LuaState state) @@ -618,7 +642,13 @@ private static int ProbeRawSetAllocation(LuaState state) return Fail("TryRawSet did not throw LUA_ERRMEM"); } - return AssertTableThenRestoreSentinel(state, "TryRawSet"); + if (AssertTableThenRestoreSentinel(state, "TryRawSet") != 0) + { + return 1; + } + + WriteMarker("MARK RawSet protected failure recovered"); + return 0; } private static int ProbeRawSetIndexAllocation(LuaState state) @@ -636,7 +666,13 @@ private static int ProbeRawSetIndexAllocation(LuaState state) return Fail("RawSetIndex did not throw LUA_ERRMEM"); } - return AssertTableThenRestoreSentinel(state, "RawSetIndex"); + if (AssertTableThenRestoreSentinel(state, "RawSetIndex") != 0) + { + return 1; + } + + WriteMarker("MARK RawSetIndex protected failure recovered"); + return 0; } private static int ProbeRawSetPointerAllocation(LuaState state) @@ -654,7 +690,13 @@ private static int ProbeRawSetPointerAllocation(LuaState state) return Fail("RawSetPointer did not throw LUA_ERRMEM"); } - return AssertTableThenRestoreSentinel(state, "RawSetPointer"); + if (AssertTableThenRestoreSentinel(state, "RawSetPointer") != 0) + { + return 1; + } + + WriteMarker("MARK RawSetPointer protected failure recovered"); + return 0; } private static int ProbeReferenceAllocation(LuaState state) @@ -671,7 +713,13 @@ private static int ProbeReferenceAllocation(LuaState state) return Fail("CreateRef did not throw LUA_ERRMEM"); } - return AssertOnlySentinelRemains(state, "CreateRef"); + if (AssertOnlySentinelRemains(state, "CreateRef") != 0) + { + return 1; + } + + WriteMarker("MARK CreateReference protected failure recovered"); + return 0; } private static int ProbePrivateReferenceReleaseAllocation(LuaState state) @@ -820,7 +868,13 @@ private static int ProbeCallbackAllocation(LuaState state) return Fail("LuaCallback.TryCreate returned a callback after failure"); } - return AssertErrorThenRestoreSentinel(state, "LuaCallback.TryCreate"); + if (AssertErrorThenRestoreSentinel(state, "LuaCallback.TryCreate") != 0) + { + return 1; + } + + WriteMarker("MARK PushClosure protected failure recovered"); + return 0; } private static int ProbeGeneratedFunctionAllocation(LuaState state) @@ -916,7 +970,7 @@ private static int ProbeFailingFinalizer(LuaState state, byte[] message) LuaStatus status = state.TryPushString(message); if (status == LuaStatus.GcMetamethodError) { - return AssertErrorThenRestoreSentinel(state, "failing __gc"); + return ReportFailingFinalizerRecovered(state); } if (!status.IsOk) @@ -930,6 +984,17 @@ private static int ProbeFailingFinalizer(LuaState state, byte[] message) return Fail("the protected allocation path did not observe the failing __gc"); } + private static int ReportFailingFinalizerRecovered(LuaState state) + { + if (AssertErrorThenRestoreSentinel(state, "failing __gc") != 0) + { + return 1; + } + + WriteMarker("MARK failing __gc protected failure recovered"); + return 0; + } + private static int ProbeHostObjectPusherLongJump(LuaState state, nint luaModule) { string bridgePath = Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"); @@ -990,7 +1055,7 @@ private static int ProbeHostObjectPusherLongJump( } if (!state.TryReadString(-1, out string? error) || - !error.Contains("bad argument #1", StringComparison.Ordinal)) + !error.Contains("bad argument #1", StringComparison.Ordinal)) { return Fail("PushHostObject did not leave the native luaL_checkinteger failure message on the stack"); } diff --git a/tests/CheatEngine.SDK.Lua.FailureProbe/packages.lock.json b/tests/CheatEngine.SDK.Lua.FailureProbe/packages.lock.json new file mode 100644 index 00000000..6dd54051 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.FailureProbe/packages.lock.json @@ -0,0 +1,32 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/CheatEngine.SDK.Lua.Interop.Tests.csproj b/tests/CheatEngine.SDK.Lua.Interop.Tests/CheatEngine.SDK.Lua.Interop.Tests.csproj index 93d7b9d0..397db592 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/CheatEngine.SDK.Lua.Interop.Tests.csproj +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/CheatEngine.SDK.Lua.Interop.Tests.csproj @@ -13,4 +13,12 @@ + + + + + + diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs index 9a5a420c..7aaf14b9 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs @@ -41,7 +41,7 @@ public void Every_slot_holds_an_address_inside_the_process() object table = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; FieldInfo[] slots = typeof(LuaApi.Table).GetFields(BindingFlags.Instance | BindingFlags.Public | - BindingFlags.NonPublic); + BindingFlags.NonPublic); Assert.NotEmpty(slots); foreach (FieldInfo slot in slots) diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiPartialModuleTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiPartialModuleTests.cs new file mode 100644 index 00000000..6fa22459 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiPartialModuleTests.cs @@ -0,0 +1,63 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Lua.Interop.Api; +using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Lua.Interop.Tests.Initialization; + +/// +/// Q11 with a module that is Lua 5.3 except for one export: a copy of the fixture whose lua_rotate name was +/// overwritten in its export table (). Binding refuses it all or nothing and names +/// exactly the missing export; a table already bound stays bound to the fixture and keeps working. +/// +[Trait("Category", "NativeLua")] +public sealed unsafe class LuaApiPartialModuleTests +{ + [Fact] + [Trait("Qualification", "Q11")] + public void Module_missing_one_lua_export_is_refused_naming_exactly_that_export() + { + LuaTest.RequireNativeLua(); + using PartialLuaModule module = PartialLuaModule.Load(NativeLuaLibrary.LibraryPath!); + + IReadOnlyList missing = LuaApi.GetMissingExports(module.Handle); + EntryPointNotFoundException exception = + Assert.Throws(() => LuaApi.Initialize(module.Handle)); + + Assert.Equal([PartialLuaModule.RemovedExport], missing); + Assert.Contains($"1 of {LuaApi.GetMissingExports(NativeLibrary.GetMainProgramHandle()).Count} required exports", + exception.Message, StringComparison.Ordinal); + Assert.EndsWith($"({PartialLuaModule.RemovedExport}).", exception.Message, StringComparison.Ordinal); + Assert.False(NativeLibrary.TryGetExport(module.Handle, PartialLuaModule.RemovedExport, out _)); + Assert.True(NativeLibrary.TryGetExport(module.Handle, "lua_settop", out _)); + Assert.True(NativeLibrary.TryGetExport(module.Handle, "lua_setallocf", out _)); + Assert.NotEqual(NativeLuaLibrary.Handle, module.Handle); + } + + [Fact] + [Trait("Qualification", "Q11")] + public void TryInitialize_with_a_partial_module_leaves_the_table_unchanged() + { + LuaTest.RequireNativeLua(); + nint boundBefore = LuaApi.ModuleHandle; + using PartialLuaModule module = PartialLuaModule.Load(NativeLuaLibrary.LibraryPath!); + + bool bound = LuaApi.TryInitialize(module.Handle, out string? failure); + + Assert.False(bound); + Assert.Contains($"({PartialLuaModule.RemovedExport})", failure, StringComparison.Ordinal); + Assert.Equal(boundBefore, LuaApi.ModuleHandle); + Assert.Equal(NativeLuaLibrary.Handle, LuaApi.ModuleHandle); + + // The slot of the export the partial module lacks still reaches the fixture's lua_rotate. + using NativeLuaState state = new(false); + lua_State* L = state.L; + LuaApi.lua_pushinteger(L, 1); + LuaApi.lua_pushinteger(L, 2); + LuaApi.lua_rotate(L, 1, 1); + Assert.Equal(2, LuaApi.lua_tointegerx(L, 1, null)); + Assert.Equal(1, LuaApi.lua_tointegerx(L, 2, null)); + } +} diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs index 512dbe25..f5d58c69 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs @@ -59,6 +59,7 @@ public void TryGetLoaded_default_name_agrees_with_the_explicit_name() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q11")] public void TryGetLoaded_finds_the_lua_module_the_fixture_loaded_without_loading_another() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractBoundaryTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractBoundaryTests.cs index 85143eba..197810f2 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractBoundaryTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractBoundaryTests.cs @@ -68,6 +68,7 @@ public void Native_bridge_contract_rejects_invalid_buffers_and_zeroes_reserved_a [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q13")] public void Native_bridge_rejects_invalid_preconditions_without_mutating_the_Lua_stack() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractOperationBitTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractOperationBitTests.cs new file mode 100644 index 00000000..8773bbb5 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractOperationBitTests.cs @@ -0,0 +1,73 @@ +using System.Runtime.CompilerServices; + +using CheatEngine.SDK.Lua.Interop.Protected; + +namespace CheatEngine.SDK.Lua.Interop.Tests.Protected; + +/// +/// Q11 (audit A05-21): a bridge whose supported-operation bitmap lacks any single operation the generated +/// LuaProtectedOperation enum requires is incompatible, whatever else it offers. DLL-free: the contract is +/// built in memory. +/// +public sealed class LuaBridgeContractOperationBitTests +{ + /// Every value of the generated operation enum, as its opcode. + public static TheoryData RequiredOperations + { + get + { + TheoryData data = []; + foreach (LuaProtectedOperation operation in Enum.GetValues()) + { + data.Add((int) operation); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(RequiredOperations))] + [Trait("Qualification", "Q11")] + public void Contract_missing_any_single_required_operation_bit_is_incompatible(int opcode) + { + LuaBridgeContract contract = CompatibleContract(); + Assert.True(contract.IsCompatible()); + + contract.SupportedOperations &= ~(1UL << opcode); + Assert.False(contract.IsCompatible()); + + // Extra bits never compensate for a missing required one. + contract.SupportedOperations |= ~LuaProtectedOperationContract.RequiredBitmap; + Assert.False(contract.IsCompatible()); + } + + [Fact] + public void The_theory_covers_exactly_the_required_bitmap() + { + ulong bitmap = 0; + foreach (LuaProtectedOperation operation in Enum.GetValues()) + { + bitmap |= 1UL << (int) operation; + } + + Assert.Equal(LuaProtectedOperationContract.RequiredBitmap, bitmap); + Assert.Equal(LuaProtectedOperationContract.Count, Enum.GetValues().Length); + } + + private static LuaBridgeContract CompatibleContract() + { + return new LuaBridgeContract + { + Magic = LuaBridgeContract.ExpectedMagic, + ContractSize = (uint) Unsafe.SizeOf(), + SupportedOperations = LuaProtectedOperationContract.RequiredBitmap, + ExportTableSize = (uint) Unsafe.SizeOf(), + AbiMajor = LuaBridgeContract.ExpectedMajor, + AbiMinor = LuaBridgeContract.MinimumMinor, + PointerSize = (byte) IntPtr.Size, + LuaIntegerSize = sizeof(long), + SizeTSize = (byte) Unsafe.SizeOf() + }; + } +} diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs index e37569dd..80f3f828 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs @@ -96,7 +96,7 @@ private static bool HasDelayImports(string path) int delayImportDirectoryOffset = optionalOffset + 112 + 13 * 8; return ReadUInt32(image, delayImportDirectoryOffset) != 0 || - ReadUInt32(image, delayImportDirectoryOffset + sizeof(uint)) != 0; + ReadUInt32(image, delayImportDirectoryOffset + sizeof(uint)) != 0; } private static List ReadExportedNames(string path) diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaInteropPrimitiveMatrixTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaInteropPrimitiveMatrixTests.cs new file mode 100644 index 00000000..7d4818eb --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaInteropPrimitiveMatrixTests.cs @@ -0,0 +1,218 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +using CheatEngine.SDK.Lua.Interop.Api; + +namespace CheatEngine.SDK.Lua.Interop.Tests.Protected; + +/// +/// The primitive matrix tests/CheatEngine.SDK.Repository.Tests/LuaBridge/TestData/lua-interop-primitives.json (embedded) against the real +/// : one row per public static member, the error class and stack effect its XML documentation +/// states, the exports the function-pointer table binds, and the bridge catalogue's direct-call policy. DLL-free. +/// +public sealed partial class LuaInteropPrimitiveMatrixTests +{ + private const string MatrixResource = "CheatEngine.SDK.Lua.Interop.Tests.LuaInteropPrimitives.json"; + private const string CatalogueResource = "CheatEngine.SDK.Lua.Interop.Tests.ProtectedOperations.json"; + private const string DocumentationFile = "CheatEngine.SDK.Lua.Interop.xml"; + private const string MemberPrefix = "CheatEngine.SDK.Lua.Interop.Api.LuaApi."; + + private static readonly Dictionary RaisesFromRemark = new(StringComparer.Ordinal) + { + ["never"] = "Never", + ["memory"] = "Memory", + ["any"] = "Any", + ["always"] = "Always" + }; + + [Fact] + public void Every_public_LuaApi_member_has_exactly_one_matrix_row() + { + string[] rows = [.. Rows().Select(static row => row.GetProperty("member").GetString()!)]; + + Assert.Equal(rows.Length, rows.Distinct(StringComparer.Ordinal).Count()); + string[] missing = [.. PublicMembers().Where(member => !rows.Contains(member, StringComparer.Ordinal))]; + Assert.True(missing.Length == 0, "No matrix row for: " + string.Join(", ", missing)); + } + + [Fact] + public void Every_matrix_row_names_an_existing_LuaApi_member() + { + HashSet members = PublicMembers(); + + string[] unknown = [.. Rows().Select(static row => row.GetProperty("member").GetString()!) + .Where(member => !members.Contains(member))]; + + Assert.True(unknown.Length == 0, "Rows without a public static LuaApi member: " + string.Join(", ", unknown)); + } + + [Fact] + public void Matrix_raises_class_equals_the_documented_raises_remark() + { + string path = Path.Combine(AppContext.BaseDirectory, DocumentationFile); + Assert.True(File.Exists(path), $"The XML documentation of CheatEngine.SDK.Lua.Interop was not copied to '{path}'."); + Dictionary remarks = ReadRemarks(XDocument.Load(path)); + List problems = []; + foreach (JsonElement row in Rows()) + { + string member = row.GetProperty("member").GetString()!; + Match remark = StackAndRaises().Match(remarks.GetValueOrDefault(member, string.Empty)); + string expectedRaises = remark.Success ? RaisesFromRemark[remark.Groups["raises"].Value] : "NotApplicable"; + string? expectedStack = remark.Success ? remark.Groups["stack"].Value : null; + string? stack = row.GetProperty("stackEffect").ValueKind == JsonValueKind.Null + ? null + : row.GetProperty("stackEffect").GetString(); + if (!string.Equals(expectedRaises, row.GetProperty("raises").GetString(), StringComparison.Ordinal) || + !string.Equals(expectedStack, stack, StringComparison.Ordinal)) + { + problems.Add($"{member}: documented {expectedStack ?? "no stack"} / {expectedRaises}, matrix {stack ?? "no stack"} / {row.GetProperty("raises").GetString()}."); + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + Assert.True(remarks.Count > 100, "The XML documentation lost the LuaApi remarks."); + } + + [Fact] + public void Native_symbols_are_exactly_the_exports_the_table_binds() + { + // The main program module exports none of the table's names, so every requested export is reported missing. + IReadOnlyList exports = LuaApi.GetMissingExports(NativeLibrary.GetMainProgramHandle()); + + string[] nativeSymbols = + [ + .. Rows().Where(static row => row.GetProperty("nativeSymbol").ValueKind == JsonValueKind.String) + .Select(static row => row.GetProperty("nativeSymbol").GetString()!) + ]; + + Assert.Equal(exports.Order(StringComparer.Ordinal), nativeSymbols.Order(StringComparer.Ordinal)); + } + + [Fact] + public void Direct_api_policy_entries_agree_with_the_matrix() + { + Dictionary rows = Rows().ToDictionary(static row => row.GetProperty("member").GetString()!, + StringComparer.Ordinal); + using JsonDocument catalogue = Load(CatalogueResource); + Dictionary operations = catalogue.RootElement.GetProperty("operations").EnumerateArray() + .ToDictionary(static operation => operation.GetProperty("id").GetString()!, StringComparer.Ordinal); + List problems = []; + foreach (JsonElement policy in catalogue.RootElement.GetProperty("directApiPolicy").EnumerateArray()) + { + string member = policy.GetProperty("managedSymbol").GetString()![MemberPrefix.Length..]; + if (!rows.TryGetValue(member, out JsonElement row)) + { + problems.Add($"{member}: the policy names a member without a matrix row."); + continue; + } + + CheckPolicy(member, policy, row, operations, problems); + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + private static void CheckPolicy(string member, JsonElement policy, JsonElement row, + Dictionary operations, List problems) + { + string decision = row.GetProperty("decision").GetString()!; + string? bridge = Optional(row, "bridgeOperation"); + string expectedDecision = policy.GetProperty("allowedDirectly").GetBoolean() + ? "DirectAllowed" + : policy.TryGetProperty("conditionalDirectUse", out _) ? "ConditionallyDirect" : "BridgeRequired"; + if (!string.Equals(expectedDecision, decision, StringComparison.Ordinal) || + !string.Equals(Optional(policy, "bridgeOperation"), bridge, StringComparison.Ordinal) || + !string.Equals(policy.GetProperty("nativeSymbol").GetString(), Optional(row, "nativeSymbol"), StringComparison.Ordinal)) + { + problems.Add($"{member}: the policy route ({expectedDecision}, {Optional(policy, "bridgeOperation")}) differs from the row ({decision}, {bridge})."); + } + + int policyRank = Rank(policy.GetProperty("raises").GetString()!); + int rowRank = Rank(row.GetProperty("raises").GetString()!); + // The policy may only be more conservative than the documented class, and only where the bridge operation + // records the source conflict that justifies it (luaL_unref: documented never, implemented with lua_rawseti). + bool conflictRecorded = bridge is not null && operations.TryGetValue(bridge, out JsonElement operation) && + operation.TryGetProperty("provenanceConflict", out _); + if (policyRank != rowRank && !(policyRank > rowRank && conflictRecorded)) + { + problems.Add($"{member}: the policy classifies it {policy.GetProperty("raises").GetString()}, the matrix {row.GetProperty("raises").GetString()}."); + } + } + + private static int Rank(string raises) + { + return raises switch + { + "never" or "Never" or "NotApplicable" => 0, + "memory" or "Memory" => 1, + "any" or "Any" or "Always" => 2, + _ => throw new InvalidOperationException($"Unknown raises class {raises}.") + }; + } + + private static string? Optional(JsonElement element, string property) + { + return element.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static HashSet PublicMembers() + { + const BindingFlags Flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; + HashSet members = new(StringComparer.Ordinal); + foreach (MethodInfo method in typeof(LuaApi).GetMethods(Flags).Where(static method => !method.IsSpecialName)) + { + members.Add(method.Name); + } + + // The LUA_* properties are header constants (library names, the signature), not callable primitives. + foreach (PropertyInfo property in typeof(LuaApi).GetProperties(Flags) + .Where(static property => !property.Name.StartsWith("LUA_", StringComparison.Ordinal))) + { + members.Add(property.Name); + } + + return members; + } + + private static Dictionary ReadRemarks(XDocument documentation) + { + Dictionary remarks = new(StringComparer.Ordinal); + foreach (XElement member in documentation.Descendants("member")) + { + string name = (string?) member.Attribute("name") ?? string.Empty; + if (name.Length < 2 || !name[2..].StartsWith(MemberPrefix, StringComparison.Ordinal)) + { + continue; + } + + string shortName = name[(2 + MemberPrefix.Length)..].Split('(')[0]; + string text = string.Join(' ', (member.Element("remarks")?.Value ?? string.Empty) + .Split((char[]) [' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)); + remarks[shortName] = text; + } + + return remarks; + } + + private static JsonElement[] Rows() + { + using JsonDocument matrix = Load(MatrixResource); + return [.. matrix.RootElement.GetProperty("rows").EnumerateArray().Select(static row => row.Clone())]; + } + + private static JsonDocument Load(string resource) + { + using Stream stream = typeof(LuaInteropPrimitiveMatrixTests).Assembly.GetManifestResourceStream(resource) + ?? throw new InvalidOperationException($"The embedded resource {resource} is missing."); + return JsonDocument.Parse(stream); + } + + [GeneratedRegex(@"Stack: (?.*?)\. Raises: (?\w+)", RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 1000)] + private static partial Regex StackAndRaises(); +} diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs index 9935731d..6cd74d4b 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaProtectedApiTests.cs @@ -196,6 +196,7 @@ public void Private_reference_round_trip_creates_and_pushes_the_original_value() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q13")] public void Push_private_reference_without_its_table_returns_an_error_and_preserves_the_caller_stack() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/NestedProtectedCallTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/NestedProtectedCallTests.cs new file mode 100644 index 00000000..fa703b45 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/NestedProtectedCallTests.cs @@ -0,0 +1,89 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Lua.Interop.Protected; +using CheatEngine.SDK.Lua.Interop.Tests.Support; +using CheatEngine.SDK.Lua.Interop.Types; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + +namespace CheatEngine.SDK.Lua.Interop.Tests.Protected; + +/// +/// Audit A05-06/A05-18: the bridge keeps its call context in a thread-local variable and restores the previous one +/// around lua_pcallk, so a protected operation started while another one runs returns to the outer operation +/// intact. The nested calls come from a managed host pusher that OP_PUSH_HOST_OBJECT invokes, as Cheat +/// Engine's pusher could re-enter the SDK. +/// +/// +/// The pusher never raises a Lua error itself: that would longjmp over its managed frame. Its nested calls +/// are protected operations, whose failures come back as statuses. +/// +[Trait("Category", "NativeLua")] +public sealed unsafe class NestedProtectedCallTests +{ + private const nint NativeObject = 0x5A1E; + private const nint MissingReferenceTable = 0x5A1F; + + private static int s_nestedPushStatus = -1; + private static int s_nestedFailureStatus = -1; + private static int s_pusherTopDelta = int.MinValue; + private static nint s_seenObject; + private static bool s_pusherCaughtException; + + [Fact] + public void Nested_protected_call_from_a_host_pusher_restores_the_outer_context() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + lua_State* L = state.L; + lua_pushinteger(L, 71); + int top = lua_gettop(L); + nint pusher = (nint) (delegate* unmanaged[Stdcall]) &NestingPusher; + + int status = LuaProtectedApi.PushHostObject(L, pusher, NativeObject); + + Assert.False(s_pusherCaughtException, "The nested protected calls threw a managed exception."); + Assert.Equal(NativeObject, s_seenObject); + Assert.Equal(LUA_OK, s_nestedPushStatus); + Assert.Equal(LUA_ERRRUN, s_nestedFailureStatus); + Assert.Equal(1, s_pusherTopDelta); + Assert.Equal(LUA_OK, status); + Assert.Equal(top + 1, lua_gettop(L)); + Assert.Equal("pushed by the nested call", LuaTest.ReadString(L, -1)); + Assert.Equal(71, lua_tointegerx(L, top, null)); + + lua_settop(L, top); + Assert.Equal(top, lua_gettop(L)); + Assert.Equal(LUA_OK, LuaProtectedApi.PushBytes(L, "after"u8)); + Assert.Equal("after", LuaTest.ReadString(L, -1)); + Assert.Equal(top + 1, lua_gettop(L)); + } + + /// + /// A host pusher that pushes exactly one value through a nested protected operation, and contains the failure of + /// a second nested operation whose native code raises with lua_error inside its own lua_pcallk. + /// + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void NestingPusher(lua_State* L, void* nativeObject) + { + try + { + s_seenObject = (nint) nativeObject; + int top = lua_gettop(L); + s_nestedPushStatus = LuaProtectedApi.PushBytes(L, "pushed by the nested call"u8); + s_nestedFailureStatus = LuaProtectedApi.PushPrivateRef(L, MissingReferenceTable, 1); + if (s_nestedFailureStatus != LUA_OK) + { + lua_settop(L, -2); + } + + s_pusherTopDelta = lua_gettop(L) - top; + } + catch (Exception) + { + s_pusherCaughtException = true; + } + } +} diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/README.md b/tests/CheatEngine.SDK.Lua.Interop.Tests/README.md index 114e2e76..260e5dc1 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/README.md +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/README.md @@ -49,8 +49,17 @@ dotnet test --project tests/CheatEngine.SDK.Lua.Interop.Tests --filter-trait "Ca - `lua_Debug` and `luaL_Reg` have the C layout on x64, and the native `lua_Debug` record fits the managed struct (`NativeStructLayoutTests`, `CallbackTests`). - A failed bind leaves the table untouched, and a second Lua module is refused (`LuaApiInitializationTests`, - `LuaApiBoundTableTests`). + `LuaApiBoundTableTests`). A copy of the fixture that lacks one export is refused with exactly that export named, + and the bound table keeps working (`LuaApiPartialModuleTests`). +- A bridge contract whose bitmap lacks any single required operation is incompatible, whatever extra bits it sets + (`LuaBridgeContractOperationBitTests`). +- A protected operation started from a host pusher while another one runs, including one that fails, returns to the + outer operation intact, and the next operation succeeds (`NestedProtectedCallTests`). - The C11 bridge accepts a forward-compatible minor contract and additive operation bits, while its three generated imports are explicitly cdecl and retain GC transitions (`LuaProtectedApiTests`). Native fixture checks additionally prove that an incomplete export table, negative input count, and input count above Lua's top return the bridge's no-error sentinel without changing the Lua stack (`LuaBridgeContractBoundaryTests`). +- The primitive matrix `tests/CheatEngine.SDK.Repository.Tests/LuaBridge/TestData/lua-interop-primitives.json` has exactly one row per public static `LuaApi` + member, its error class and stack effect equal the member's `Raises:` and `Stack:` remarks, its native symbols are + exactly the exports the table binds, and the catalogue's direct-call policy agrees with it + (`LuaInteropPrimitiveMatrixTests`). diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs index 567703ed..20510f5f 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs @@ -189,7 +189,13 @@ public void Load_pulls_the_chunk_through_a_managed_reader() fixed (byte* second = "* 7"u8) fixed (byte* name = "=pieces"u8) { - ReaderState pieces = new() { First = first, FirstSize = 9, Second = second, SecondSize = 3 }; + ReaderState pieces = new() + { + First = first, + FirstSize = 9, + Second = second, + SecondSize = 3 + }; Assert.Equal(LUA_OK, lua_load(L, &ReadPieces, &pieces, name, null)); Assert.Equal(3, pieces.Calls); diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs index 30bca320..d4aa61d4 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs @@ -19,7 +19,7 @@ internal static class IlReader public static List<(OpCode Code, int Operand)> Read(MethodBase method) { byte[] il = method.GetMethodBody()?.GetILAsByteArray() ?? - throw new InvalidOperationException(method.Name + " has no IL body."); + throw new InvalidOperationException(method.Name + " has no IL body."); List<(OpCode Code, int Operand)> instructions = []; int offset = 0; diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/packages.lock.json b/tests/CheatEngine.SDK.Lua.Interop.Tests/packages.lock.json new file mode 100644 index 00000000..c149a719 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/packages.lock.json @@ -0,0 +1,220 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.tests.shared": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs index c2325fb5..c96aa5f8 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs @@ -73,13 +73,13 @@ private static long ReadEachScalar(LuaState state, int top) // Exact on purpose: the round trip through the Lua stack must not change a single bit, // so the bit patterns are compared. if (!DoubleMarshaller.TryRead(state, top + 3, out double d) || - BitConverter.DoubleToInt64Bits(d) != BitConverter.DoubleToInt64Bits(2.5)) + BitConverter.DoubleToInt64Bits(d) != BitConverter.DoubleToInt64Bits(2.5)) { Fail(); } if (!SingleMarshaller.TryRead(state, top + 4, out float f) || - BitConverter.SingleToInt32Bits(f) != BitConverter.SingleToInt32Bits(1.5f)) + BitConverter.SingleToInt32Bits(f) != BitConverter.SingleToInt32Bits(1.5f)) { Fail(); } @@ -90,7 +90,7 @@ private static long ReadEachScalar(LuaState state, int top) } if (!AddressMarshaller.TryRead(state, top + 6, out UIntPtr a) || - a != unchecked((nuint) 0xFFFF_FFFF_FFFF_FFF0UL)) + a != unchecked((nuint) 0xFFFF_FFFF_FFFF_FFF0UL)) { Fail(); } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/CallbackLifetimeConcurrencyTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/CallbackLifetimeConcurrencyTests.cs index af6c3f6a..89ad4080 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/CallbackLifetimeConcurrencyTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/CallbackLifetimeConcurrencyTests.cs @@ -35,7 +35,10 @@ public async Task Release_waits_for_a_thunk_before_its_state_lookup_then_the_thu CancellationToken cancellationToken = TestContext.Current.CancellationToken; using NativeLuaState state = new(); LuaState main = LuaTest.View(state); - using RuntimeScope scope = new(state); + // xUnit v3 resumes an async continuation on whatever thread pool thread is available (pitfall 4): the + // `using RootedThread worker` below is disposed after `await call.WaitAsync(...)` and calls + // LuaRuntime.TryAcquireOperation on whichever thread that continuation lands on, not necessarily this one. + using RuntimeScope scope = new(state, admitWorkerThreads: true); using CallbackRace race = new(cancellationToken); s_race = race; @@ -84,7 +87,9 @@ public async Task A_thunk_that_already_acquired_state_can_finish_after_release_f CancellationToken cancellationToken = TestContext.Current.CancellationToken; using NativeLuaState state = new(); LuaState main = LuaTest.View(state); - using RuntimeScope scope = new(state); + // Same post-await thread hazard as the previous test (pitfall 4): the `using RootedThread worker` below is + // disposed after `await call.WaitAsync(...)`. + using RuntimeScope scope = new(state, admitWorkerThreads: true); using CallbackRace race = new(cancellationToken); s_race = race; @@ -123,7 +128,9 @@ public async Task Detach_waits_for_an_admitted_callback_and_rejects_a_callback_t CancellationToken cancellationToken = TestContext.Current.CancellationToken; using NativeLuaState state = new(); LuaState main = LuaTest.View(state); - using RuntimeScope scope = new(state); + // Same post-await thread hazard as the tests above (pitfall 4): the `using RootedThread worker` below is + // disposed after two awaits. + using RuntimeScope scope = new(state, admitWorkerThreads: true); using CallbackRace race = new(cancellationToken); using ManualResetEventSlim admissionClosed = new(false); s_race = race; diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs index 07cd4881..2eb6a62d 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs @@ -59,6 +59,7 @@ public void Generated_function_closure_retained_across_state_reset_is_rejected() } [Fact] + [Trait("Qualification", "Q14")] public void A_failure_reported_by_the_thunk_is_a_catchable_lua_error_with_the_message() { LuaTest.RequireNativeLua(); @@ -93,6 +94,7 @@ public void An_unprotected_call_of_a_failing_thunk_fails_the_enclosing_protected } [Fact] + [Trait("Qualification", "Q14")] public void A_managed_exception_inside_a_thunk_never_escapes_and_becomes_a_lua_error() { LuaTest.RequireNativeLua(); @@ -213,7 +215,10 @@ public void Two_callbacks_of_the_same_thunk_have_independent_state() using NativeLuaState state = new(); LuaState L = LuaTest.View(state); Counter a = new(); - Counter b = new() { Value = 100 }; + Counter b = new() + { + Value = 100 + }; Assert.True(LuaCallback.TryCreate(L, Thunks.Count, a, out LuaCallback? callbackA).IsOk); Assert.True(LuaCallback.TryCreate(L, Thunks.Count, b, out LuaCallback? callbackB).IsOk); Assert.True(callbackA!.TryRegister(L, "countA"u8).IsOk); @@ -228,6 +233,7 @@ public void Two_callbacks_of_the_same_thunk_have_independent_state() } [Fact] + [Trait("Qualification", "Q15")] public void Releasing_a_callback_neutralizes_the_closure_a_script_kept() { LuaTest.RequireNativeLua(); @@ -262,6 +268,7 @@ public void Releasing_a_callback_neutralizes_the_closure_a_script_kept() } [Fact] + [Trait("Qualification", "Q15")] public void Detach_neutralizes_every_callback_the_plugin_forgot() { LuaTest.RequireNativeLua(); @@ -288,6 +295,7 @@ public void Detach_neutralizes_every_callback_the_plugin_forgot() } [Fact] + [Trait("Qualification", "Q17")] public void BeginStateReset_neutralizes_every_callback_before_the_state_is_replaced() { LuaTest.RequireNativeLua(); @@ -506,6 +514,7 @@ await Assert.ThrowsAsync(() => detach.WaitAsync(TimeS } [Fact] + [Trait("Qualification", "Q08")] public void Detach_cleanup_failure_keeps_remaining_callbacks_and_allows_a_retry() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs index 9b40b874..1ea1b1f9 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs @@ -434,24 +434,24 @@ public void Dispose() public bool Register() { if (!LuaHostSubscription.TryRegister(state, RunAdmittedCallback, - (registrationState, callback) => - { - HostCallback = callback; - return static _ => - { - }; - }, out LuaHostSubscription? firstSubscription)) + (registrationState, callback) => + { + HostCallback = callback; + return static _ => + { + }; + }, out LuaHostSubscription? firstSubscription)) { return false; } FirstSubscription = firstSubscription; return LuaHostSubscription.TryRegister(state, static () => - { - }, - (registrationState, callback) => releaseState => SecondUnregisters++, - out LuaHostSubscription? secondSubscription) - && (SecondSubscription = secondSubscription) is not null; + { + }, + (registrationState, callback) => releaseState => SecondUnregisters++, + out LuaHostSubscription? secondSubscription) + && (SecondSubscription = secondSubscription) is not null; } private void RunAdmittedCallback() diff --git a/tests/CheatEngine.SDK.Lua.Tests/Calls/LuaOperationStatusTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Calls/LuaOperationStatusTests.cs new file mode 100644 index 00000000..89bd2f2a --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Calls/LuaOperationStatusTests.cs @@ -0,0 +1,67 @@ +using CheatEngine.SDK.Lua.Calls; + +namespace CheatEngine.SDK.Lua.Tests.Calls; + +/// +/// The generated-binding outcome value (audit ADR-08, orchestrator decision O3): an unassigned status is +/// , never success, and the numeric values of the kinds are pinned. No +/// Lua library involved. +/// +public sealed class LuaOperationStatusTests +{ + [Fact] + public void Default_status_is_unknown_and_never_success() + { + LuaOperationStatus unassigned = default; + + Assert.Equal(LuaOperationStatusKind.Unknown, unassigned.Kind); + Assert.False(unassigned.IsSuccess); + Assert.Equal(LuaStatus.Ok, unassigned.LuaStatus); + Assert.NotEqual(LuaOperationStatus.Success, unassigned); + Assert.True(LuaOperationStatus.Success != unassigned); + } + + [Fact] + public void Named_statuses_keep_their_kind_and_lua_status() + { + (LuaOperationStatus Status, LuaOperationStatusKind Kind)[] named = + [ + (LuaOperationStatus.Success, LuaOperationStatusKind.Success), + (LuaOperationStatus.GlobalUnavailable, LuaOperationStatusKind.GlobalUnavailable), + (LuaOperationStatus.NilResult, LuaOperationStatusKind.NilResult), + (LuaOperationStatus.InvalidResult, LuaOperationStatusKind.InvalidResult), + (LuaOperationStatus.StackUnavailable, LuaOperationStatusKind.StackUnavailable), + (LuaOperationStatus.MissingResult, LuaOperationStatusKind.MissingResult), + (LuaOperationStatus.ResultCapacityExceeded, LuaOperationStatusKind.ResultCapacityExceeded) + ]; + + foreach ((LuaOperationStatus status, LuaOperationStatusKind kind) in named) + { + Assert.Equal(kind, status.Kind); + Assert.Equal(LuaStatus.Ok, status.LuaStatus); + Assert.Equal(kind == LuaOperationStatusKind.Success, status.IsSuccess); + } + + LuaOperationStatus failure = LuaOperationStatus.LuaFailure(LuaStatus.RuntimeError); + Assert.Equal(LuaOperationStatusKind.LuaFailure, failure.Kind); + Assert.Equal(LuaStatus.RuntimeError, failure.LuaStatus); + Assert.False(failure.IsSuccess); + Assert.NotEqual(LuaOperationStatus.LuaFailure(LuaStatus.MemoryError), failure); + Assert.Equal(LuaOperationStatus.LuaFailure(LuaStatus.RuntimeError).GetHashCode(), failure.GetHashCode()); + } + + [Fact] + public void Status_kind_numeric_values_are_pinned() + { + Assert.Equal(0, (int) LuaOperationStatusKind.Unknown); + Assert.Equal(1, (int) LuaOperationStatusKind.Success); + Assert.Equal(2, (int) LuaOperationStatusKind.GlobalUnavailable); + Assert.Equal(3, (int) LuaOperationStatusKind.LuaFailure); + Assert.Equal(4, (int) LuaOperationStatusKind.NilResult); + Assert.Equal(5, (int) LuaOperationStatusKind.InvalidResult); + Assert.Equal(6, (int) LuaOperationStatusKind.StackUnavailable); + Assert.Equal(7, (int) LuaOperationStatusKind.MissingResult); + Assert.Equal(8, (int) LuaOperationStatusKind.ResultCapacityExceeded); + Assert.Equal(9, Enum.GetValues().Length); + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj b/tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj index 2805d08e..5232da23 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj +++ b/tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj @@ -14,4 +14,10 @@ + + + + + diff --git a/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaCallSupportOptionalTests.cs b/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaCallSupportOptionalTests.cs new file mode 100644 index 00000000..9a0a7b4f --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaCallSupportOptionalTests.cs @@ -0,0 +1,142 @@ +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.CompilerServices; +using CheatEngine.SDK.Lua.Marshalling; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Lua.Tests.Support; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Lua.Tests.CompilerServices; + +/// +/// The generator-facing helpers behind optional arguments, optional results and variadic results +/// (, , +/// ): omitted, nil and a value stay distinct, reads never +/// change the stack, and a failure is classified by type, never by text. +/// +[Trait("Category", "NativeLua")] +public sealed class LuaCallSupportOptionalTests +{ + [Fact] + public void PushOptional_pushes_a_value_or_nil_and_refuses_an_omitted_argument() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + + LuaCallSupport.PushOptional(L, LuaOptional.Of(42L)); + LuaCallSupport.PushOptional(L, LuaOptional.Nil()); + ArgumentException omitted = Assert.Throws(() => + LuaCallSupport.PushOptional(L, default)); + + Assert.Equal("value", omitted.ParamName); + Assert.Equal(2, L.Top); + Assert.True(L.IsInteger(1)); + Assert.True(L.IsNil(2)); + } + + [Fact] + public void TryReadOptional_reads_absent_as_omitted_nil_as_nil_and_refuses_another_kind() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushInteger(7); + L.PushNil(); + L.PushString("text"u8); + + Assert.True(LuaCallSupport.TryReadOptional(L, 1, out LuaOptional value)); + Assert.Equal(LuaOptional.Of(7L), value); + Assert.True(LuaCallSupport.TryReadOptional(L, 2, out LuaOptional nil)); + Assert.True(nil.IsNil); + Assert.True(LuaCallSupport.TryReadOptional(L, 4, out LuaOptional absent)); + Assert.True(absent.IsOmitted); + Assert.False(LuaCallSupport.TryReadOptional(L, 3, out LuaOptional wrong)); + Assert.True(wrong.IsOmitted); + Assert.True(LuaCallSupport.TryReadOptional(L, 3, out LuaOptional text)); + Assert.Equal(LuaOptional.Of("text"), text); + Assert.Throws(() => + LuaCallSupport.TryReadOptional(L, -1, out _)); + Assert.Equal(3, L.Top); + Assert.Equal(LuaType.String, L.TypeOf(3)); + } + + [Fact] + public void ReadResults_copies_every_value_to_the_top_or_reports_the_needed_capacity() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString("before"u8); + L.PushInteger(10); + L.PushInteger(20); + L.PushInteger(30); + Span values = stackalloc long[4]; + Span small = stackalloc long[2]; + + Assert.Equal(LuaOperationStatus.Success, LuaCallSupport.ReadResults(L, 2, values, out int count)); + Assert.Equal(3, count); + Assert.Equal([10L, 20L, 30L], values[..3].ToArray()); + Assert.Equal(LuaOperationStatus.ResultCapacityExceeded, + LuaCallSupport.ReadResults(L, 2, small, out int needed)); + Assert.Equal(3, needed); + Assert.Equal([0L, 0L], small.ToArray()); + Assert.Equal(LuaOperationStatus.Success, LuaCallSupport.ReadResults(L, 5, small, out int none)); + Assert.Equal(0, none); + Assert.Throws(() => + LuaCallSupport.ReadResults(L, 0, new long[1], out _)); + Assert.Equal(4, L.Top); + } + + [Fact] + public void ReadResults_classifies_a_nil_or_wrong_kind_value_and_clears_what_it_copied() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushInteger(1); + L.PushInteger(2); + L.PushNil(); + Span values = stackalloc long[4]; + + Assert.Equal(LuaOperationStatus.NilResult, LuaCallSupport.ReadResults(L, 1, values, out int nilCount)); + Assert.Equal(0, nilCount); + Assert.Equal([0L, 0L, 0L, 0L], values.ToArray()); + + L.SetTop(2); + L.PushBoolean(true); + Assert.Equal(LuaOperationStatus.InvalidResult, + LuaCallSupport.ReadResults(L, 1, values, out int invalidCount)); + Assert.Equal(0, invalidCount); + Assert.Equal([0L, 0L, 0L, 0L], values.ToArray()); + Assert.Equal(3, L.Top); + } + + [Fact] + public void Optional_helpers_allocate_nothing_when_warm() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + long[] buffer = new long[4]; + long sink = 0; + + AllocationGate.AssertZero(() => + { + LuaCallSupport.PushOptional(L, LuaOptional.Of(5L)); + LuaCallSupport.PushOptional(L, LuaOptional.Of(6L)); + if (!LuaCallSupport.TryReadOptional(L, 1, out LuaOptional value) + || !LuaCallSupport.ReadResults(L, 1, buffer, out int count).IsSuccess + || count != 2) + { + throw new InvalidOperationException("wrong read"); + } + + LuaCallSupport.PushOptional(L, LuaOptional.Nil()); + sink += value.Value + buffer[1]; + L.SetTop(0); + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaGlobalPushOutcomeTests.cs b/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaGlobalPushOutcomeTests.cs new file mode 100644 index 00000000..7aa657da --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/CompilerServices/LuaGlobalPushOutcomeTests.cs @@ -0,0 +1,52 @@ +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.CompilerServices; + +namespace CheatEngine.SDK.Lua.Tests.CompilerServices; + +/// +/// The generator-facing global-resolution outcome (orchestrator decision O3): an unassigned outcome is +/// and projects to an unknown operation status, never to success and never +/// to a Lua failure with an Ok status. No Lua library involved. +/// +public sealed class LuaGlobalPushOutcomeTests +{ + [Fact] + public void Default_outcome_is_unknown_and_projects_to_an_unknown_operation_status() + { + LuaGlobalPushOutcome unassigned = default; + + Assert.Equal(LuaGlobalPushStatus.Unknown, unassigned.Status); + Assert.False(unassigned.IsSuccess); + + LuaOperationStatus projected = unassigned.ToOperationStatus(); + Assert.Equal(LuaOperationStatusKind.Unknown, projected.Kind); + Assert.False(projected.IsSuccess); + Assert.Equal(default, projected); + } + + [Fact] + public void Named_outcomes_project_to_their_operation_status() + { + Assert.True(LuaGlobalPushOutcome.Success.IsSuccess); + Assert.Equal(LuaGlobalPushStatus.Success, LuaGlobalPushOutcome.Success.Status); + Assert.Equal(LuaOperationStatus.Success, LuaGlobalPushOutcome.Success.ToOperationStatus()); + + Assert.Equal(LuaGlobalPushStatus.Unavailable, LuaGlobalPushOutcome.Unavailable.Status); + Assert.Equal(LuaOperationStatus.GlobalUnavailable, LuaGlobalPushOutcome.Unavailable.ToOperationStatus()); + + LuaGlobalPushOutcome failure = LuaGlobalPushOutcome.LuaFailure(LuaStatus.MemoryError); + Assert.Equal(LuaGlobalPushStatus.LuaFailure, failure.Status); + Assert.Equal(LuaStatus.MemoryError, failure.LuaStatus); + Assert.Equal(LuaOperationStatus.LuaFailure(LuaStatus.MemoryError), failure.ToOperationStatus()); + } + + [Fact] + public void Push_status_numeric_values_are_pinned() + { + Assert.Equal(0, (int) LuaGlobalPushStatus.Unknown); + Assert.Equal(1, (int) LuaGlobalPushStatus.Success); + Assert.Equal(2, (int) LuaGlobalPushStatus.Unavailable); + Assert.Equal(3, (int) LuaGlobalPushStatus.LuaFailure); + Assert.Equal(4, Enum.GetValues().Length); + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs b/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs index 194a1a52..9bc11a9f 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs @@ -49,7 +49,7 @@ private static partial bool TryReadInt32Raw(nuint address, bool signed, out int LuaState L = LuaRuntime.AcquireState(); // one state acquisition per operation int top = L.Top; // explicit settop: no EH region on the success path if (!LuaGlobalFunctions.TryPush(L, s_readInteger, - "readInteger"u8)) // rawgeti on the cached ref; resolve + type-check + luaL_ref on first use + "readInteger"u8)) // rawgeti on the cached ref; resolve + type-check + luaL_ref on first use { return LuaCallSupport.Fail(L, top, out value); // cold, NoInlining: restore top, default the result } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Marshalling/LuaOptionalTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Marshalling/LuaOptionalTests.cs new file mode 100644 index 00000000..327219de --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Marshalling/LuaOptionalTests.cs @@ -0,0 +1,120 @@ +using CheatEngine.SDK.Lua.Marshalling; +using CheatEngine.SDK.Lua.Tests.Support; + +namespace CheatEngine.SDK.Lua.Tests.Marshalling; + +/// +/// The explicit optional model (audit A19-07, AX06-22, A06-08): omitted, nil and a value are three distinct +/// states, is omitted, and nothing is inferred from a null reference. Managed only. +/// +public sealed class LuaOptionalTests +{ + [Fact] + public void Default_value_is_omitted_and_not_nil() + { + LuaOptional value = default; + + Assert.True(value.IsOmitted); + Assert.False(value.IsNil); + Assert.False(value.HasValue); + Assert.Equal(LuaOptional.Omitted(), value); + Assert.NotEqual(LuaOptional.Nil(), value); + Assert.Equal("", value.ToString()); + } + + [Fact] + public void Nil_is_distinct_from_omitted_and_from_every_value() + { + LuaOptional nil = LuaOptional.Nil(); + + Assert.True(nil.IsNil); + Assert.False(nil.IsOmitted); + Assert.False(nil.HasValue); + Assert.NotEqual(LuaOptional.Omitted(), nil); + Assert.NotEqual(LuaOptional.Of(0), nil); + Assert.NotEqual(LuaOptional.Of(default(int)), nil); + Assert.Equal("nil", nil.ToString()); + + LuaOptional nilText = LuaOptional.Nil(); + Assert.NotEqual(LuaOptional.Of(string.Empty), nilText); + Assert.False(nilText.TryGetValue(out string? text)); + Assert.Null(text); + } + + [Fact] + public void Of_keeps_the_value_and_rejects_a_null_reference() + { + LuaOptional wide = LuaOptional.Of(long.MinValue); + Assert.True(wide.HasValue); + Assert.Equal(long.MinValue, wide.Value); + Assert.True(wide.TryGetValue(out long read)); + Assert.Equal(long.MinValue, read); + + LuaOptional number = LuaOptional.Of(2.5); + Assert.Equal("2.5", number.ToString()); + + LuaOptional text = LuaOptional.Of("abc"); + Assert.Equal("abc", text.Value); + Assert.Equal("abc", text.ToString()); + + string? missing = null; + ArgumentNullException exception = Assert.Throws(() => LuaOptional.Of(missing!)); + Assert.Equal("value", exception.ParamName); + } + + [Fact] + public void Value_throws_unless_a_value_is_present() + { + InvalidOperationException omitted = + Assert.Throws(() => LuaOptional.Omitted().Value); + InvalidOperationException nil = Assert.Throws(() => LuaOptional.Nil().Value); + + Assert.Contains("omitted", omitted.Message, StringComparison.Ordinal); + Assert.Contains("nil", nil.Message, StringComparison.Ordinal); + Assert.False(LuaOptional.Omitted().TryGetValue(out int none)); + Assert.Equal(0, none); + } + + [Fact] + public void Equality_distinguishes_the_three_states() + { + LuaOptional[] states = [LuaOptional.Omitted(), LuaOptional.Nil(), LuaOptional.Of(0), LuaOptional.Of(1)]; + + for (int i = 0; i < states.Length; i++) + { + for (int j = 0; j < states.Length; j++) + { + Assert.Equal(i == j, states[i] == states[j]); + Assert.Equal(i != j, states[i] != states[j]); + Assert.Equal(i == j, states[i].Equals((object) states[j])); + } + } + + Assert.Equal(LuaOptional.Of(7), LuaOptional.Of(7)); + Assert.Equal(LuaOptional.Of(7).GetHashCode(), LuaOptional.Of(7).GetHashCode()); + Assert.Equal(LuaOptional.Nil().GetHashCode(), LuaOptional.Nil().GetHashCode()); + Assert.False(LuaOptional.Of(7).Equals(7)); + Assert.Equal(LuaOptional.Of("a"), LuaOptional.Of(new string('a', 1))); + } + + [Fact] + public void Factories_do_not_allocate_for_value_types() + { + long sink = 0; + + AllocationGate.AssertZero(() => + { + LuaOptional value = LuaOptional.Of(sink + 1); + LuaOptional nil = LuaOptional.Nil(); + LuaOptional omitted = LuaOptional.Omitted(); + if (value == nil || nil == omitted || !value.TryGetValue(out long read)) + { + throw new InvalidOperationException("states merged"); + } + + sink += read + (nil.IsNil ? 1 : 0) + (omitted.IsOmitted ? 1 : 0) + value.GetHashCode(); + }); + + Assert.NotEqual(0, sink); + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Marshalling/MarshallerRoundTripTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Marshalling/MarshallerRoundTripTests.cs index efefae90..29ee4eb0 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Marshalling/MarshallerRoundTripTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Marshalling/MarshallerRoundTripTests.cs @@ -1,4 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.State; @@ -128,10 +130,13 @@ public void Address_accepts_a_float_with_an_integral_value_like_lua_does() LuaState L = LuaTest.View(state); L.PushNumber(4096.0); L.PushNumber(4096.5); + L.PushNumber(9007199254740991.0); Assert.True(AddressMarshaller.TryRead(L, 1, out UIntPtr integral)); Assert.Equal((nuint) 4096, integral); Assert.False(AddressMarshaller.TryRead(L, 2, out _)); + Assert.True(AddressMarshaller.TryRead(L, 3, out UIntPtr largest)); + Assert.Equal(unchecked((nuint) 9007199254740991UL), largest); } [Theory] @@ -235,7 +240,7 @@ public void Integer_and_float_are_discriminated() Assert.True(DoubleMarshaller.TryRead(L, 1, out double d1)); Assert.Equal(3.0, d1); - // 3.0: a float with an integral value; Lua's conversion accepts it as an integer, IsInteger tells the difference. + // 3.0: an integral float below 2^53 is accepted as an integer; IsInteger tells the difference. Assert.False(L.IsInteger(2)); Assert.True(Int64Marshaller.TryRead(L, 2, out long i2)); Assert.Equal(3, i2); @@ -246,7 +251,8 @@ public void Integer_and_float_are_discriminated() Assert.True(DoubleMarshaller.TryRead(L, 3, out double d3)); Assert.Equal(2.5, d3); - // Strings follow Lua's own coercion; the string marshallers stay strict the other way round (see string tests). + // An integer numeral string reads, a float numeral does not; the string marshallers stay strict the other way + // round (see string tests). Assert.True(Int64Marshaller.TryRead(L, 4, out long i4)); Assert.Equal(42, i4); Assert.False(Int64Marshaller.TryRead(L, 5, out _)); @@ -264,6 +270,222 @@ public void Integer_and_float_are_discriminated() Assert.Equal(6, L.Top); } + [Theory] + [Trait("Qualification", "Q21")] + [InlineData(long.MinValue)] + [InlineData(long.MaxValue)] + [InlineData((1L << 53) + 1)] + [InlineData(-(1L << 53) - 1)] + [InlineData(long.MaxValue - 1)] + public void Int64_keeps_every_bit_of_integer_subtype_values(long value) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + Int64Marshaller.Push(L, value); + LuaTest.Run(L, Encoding.UTF8.GetBytes(string.Create(CultureInfo.InvariantCulture, $"return 0x{value:X16}")), 1); + + Assert.True(Int64Marshaller.TryRead(L, 1, out long pushed)); + Assert.Equal(value, pushed); + Assert.True(L.IsInteger(2)); + Assert.True(Int64Marshaller.TryRead(L, 2, out long fromSource)); + Assert.Equal(value, fromSource); + } + + [Theory] + [Trait("Qualification", "Q21")] + [InlineData("return 2^53")] + [InlineData("return -2^53")] + [InlineData("return 2^53 + 2")] + [InlineData("return 1e18")] + [InlineData("return -1e18")] + [InlineData("return 2^63")] + [InlineData("return 1e19")] + [InlineData("return 9007199254740993.0")] + public void Int64_refuses_a_float_at_or_above_2_pow_53(string source) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, Encoding.UTF8.GetBytes(source), 1); + + Assert.False(L.IsInteger(1)); + Assert.False(Int64Marshaller.TryRead(L, 1, out long read)); + Assert.Equal(0, read); + Assert.False(Int32Marshaller.TryRead(L, 1, out _)); + Assert.Equal(LuaType.Number, L.TypeOf(1)); + Assert.Equal(1, L.Top); + } + + [Theory] + [Trait("Qualification", "Q21")] + [InlineData(0.0, 0L)] + [InlineData(-1.0, -1L)] + [InlineData(3.0, 3L)] + [InlineData(-4096.0, -4096L)] + [InlineData(9007199254740991.0, 9007199254740991L)] + [InlineData(-9007199254740991.0, -9007199254740991L)] + public void Int64_accepts_an_integral_float_below_2_pow_53(double value, long expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushNumber(value); + + Assert.False(L.IsInteger(1)); + Assert.True(Int64Marshaller.TryRead(L, 1, out long read)); + Assert.Equal(expected, read); + Assert.False(L.IsInteger(1)); + } + + [Theory] + [Trait("Qualification", "Q21")] + [InlineData("42", true, 42L)] + [InlineData(" -17\t\n", true, -17L)] + [InlineData("+8", true, 8L)] + [InlineData("0x10", true, 16L)] + [InlineData(" 0XfF ", true, 255L)] + [InlineData("-0x10", true, -16L)] + [InlineData("0xFFFFFFFFFFFFFFFF", true, -1L)] + [InlineData("0x000000000000000000001", true, 1L)] + [InlineData("9007199254740993", true, 9007199254740993L)] + [InlineData("9223372036854775807", true, long.MaxValue)] + [InlineData("-9223372036854775808", true, long.MinValue)] + [InlineData("9223372036854775808", false, 0L)] + [InlineData("-9223372036854775809", false, 0L)] + [InlineData("0x10000000000000000", false, 0L)] + [InlineData("3.0", false, 0L)] + [InlineData("1e3", false, 0L)] + [InlineData("0x1p4", false, 0L)] + [InlineData("0x", false, 0L)] + [InlineData("", false, 0L)] + [InlineData(" ", false, 0L)] + [InlineData("-", false, 0L)] + [InlineData("12abc", false, 0L)] + [InlineData("inf", false, 0L)] + public void Int64_accepts_integer_numeral_strings_and_refuses_float_numeral_strings(string text, bool accepted, + long expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString(text.AsSpan()); + + Assert.Equal(accepted, Int64Marshaller.TryRead(L, 1, out long read)); + Assert.Equal(expected, read); + Assert.Equal(LuaType.String, L.TypeOf(1)); + Assert.Equal(text, LuaTest.ReadString(L, 1)); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void Int64_refuses_a_numeral_with_an_embedded_nul() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushString("42\u00007"u8); + L.PushString("42\u0000"u8); + + Assert.False(Int64Marshaller.TryRead(L, 1, out long first)); + Assert.Equal(0, first); + Assert.False(Int64Marshaller.TryRead(L, 2, out _)); + Assert.False(Int32Marshaller.TryRead(L, 2, out _)); + } + + [Theory] + [Trait("Qualification", "Q21")] + [InlineData("return 2147483647", true, int.MaxValue)] + [InlineData("return -2147483648", true, int.MinValue)] + [InlineData("return 2147483648", false, 0)] + [InlineData("return 4294967295", false, 0)] + [InlineData("return 7.0", true, 7)] + [InlineData("return 2^31", false, 0)] + [InlineData("return 2^53", false, 0)] + [InlineData("return 1.5", false, 0)] + [InlineData("return '123'", true, 123)] + [InlineData("return ' -0x10 '", true, -16)] + [InlineData("return '2147483648'", false, 0)] + [InlineData("return '12.0'", false, 0)] + public void Int32_follows_the_int64_conversion_policy_and_its_range(string source, bool accepted, int expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, Encoding.UTF8.GetBytes(source), 1); + + Assert.Equal(accepted, Int32Marshaller.TryRead(L, 1, out int read)); + Assert.Equal(expected, read); + Assert.Equal(1, L.Top); + } + + [Theory] + [Trait("Qualification", "Q21")] + [InlineData("return 2^53")] + [InlineData("return -2^53")] + [InlineData("return 2^53 + 2")] + [InlineData("return 0x7FF6A0001000 * 2^10")] + [InlineData("return 2^63")] + [InlineData("return 2^64")] + [InlineData("return 1e19")] + public void Address_refuses_a_float_at_or_above_2_pow_53(string source) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, Encoding.UTF8.GetBytes(source), 1); + + Assert.False(AddressMarshaller.TryRead(L, 1, out UIntPtr read)); + Assert.Equal((nuint) 0, read); + Assert.Equal(LuaType.Number, L.TypeOf(1)); + Assert.False(L.IsInteger(1)); + } + + [Theory] + [Trait("Qualification", "Q21")] + [InlineData("return 0x100000000", 0x1_0000_0000UL)] + [InlineData("return 0x100000000 * 3 + 5", 0x3_0000_0005UL)] + [InlineData("return 0x7FF6A0001000", 0x7FF6_A000_1000UL)] + [InlineData("return 0x0020000000000001", 0x0020_0000_0000_0001UL)] + [InlineData("return 0x7FFFFFFFFFFFFFFF", 0x7FFF_FFFF_FFFF_FFFFUL)] + [InlineData("return 0x8000000000000000", 0x8000_0000_0000_0000UL)] + [InlineData("return 0xFFFFFFFFFFFFF000", 0xFFFF_FFFF_FFFF_F000UL)] + [InlineData("return -4096", 0xFFFF_FFFF_FFFF_F000UL)] + public void Address_keeps_values_above_4_gib_and_above_long_MaxValue(string source, ulong expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + LuaTest.Run(L, Encoding.UTF8.GetBytes(source), 1); + + Assert.True(L.IsInteger(1)); + Assert.True(AddressMarshaller.TryRead(L, 1, out UIntPtr read)); + Assert.Equal((nuint) expected, read); + + // Pushed back, the address is the same lua_Integer bits Lua produced. + AddressMarshaller.Push(L, read); + Assert.True(L.RawEquals(1, 2)); + } + + [Fact] + public void Integer_conversion_of_floats_and_numerals_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(false); + LuaState L = LuaTest.View(state); + L.PushNumber(4096.0); + L.PushString(" 0x10 "u8); + L.PushNumber(9007199254740992.0); + L.PushString("1e3"u8); + long sink = 0; + + AllocationGate.AssertZero(() => sink += ReadConvertedValues(L)); + + Assert.NotEqual(0, sink); + Assert.Equal(4096 + 16, ReadConvertedValues(L)); + Assert.Equal(4, L.Top); + } + [Fact] public void Absent_values_read_as_failures_not_as_defaults() { @@ -308,6 +530,28 @@ public void Generic_code_over_a_marshaller_is_the_same_call() Assert.True(bytes.SequenceEqual("bytes"u8)); } + private static long ReadConvertedValues(LuaState L) + { + long total = 0; + if (Int64Marshaller.TryRead(L, 1, out long integral)) + { + total += integral; + } + + if (Int32Marshaller.TryRead(L, 2, out int numeral)) + { + total += numeral; + } + + // Both refused: a float at 2^53 and a float numeral. + if (AddressMarshaller.TryRead(L, 3, out UIntPtr refused) || Int64Marshaller.TryRead(L, 4, out _)) + { + total += (long) refused; + } + + return total; + } + private static void PushThrough(LuaState L, T value) where T : allows ref struct where TMarshaller : ILuaMarshaller diff --git a/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs index b23e2120..ccc85edc 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs @@ -13,6 +13,7 @@ namespace CheatEngine.SDK.Lua.Tests.Protected; public sealed class CheckStackFailureProcessTests { [Fact] + [Trait("Qualification", "Q12")] public async Task Direct_checkstack_growth_with_a_rejecting_allocator_returns_zero_and_recovers() { Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Protected/ErrorInFlightTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Protected/ErrorInFlightTests.cs new file mode 100644 index 00000000..cc73c5e0 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Protected/ErrorInFlightTests.cs @@ -0,0 +1,48 @@ +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Lua.Tests.Callbacks; +using CheatEngine.SDK.Lua.Tests.Support; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Lua.Tests.Protected; + +/// +/// Audit A05-17: a managed callback that fails while an error is already in flight. The message handler of a +/// protected call is a managed thunk whose body throws; the thunk reports the failure, its wrapper raises, and Lua +/// handles the handler's own error as an error in error handling. The caller gets one distinct status, the stack +/// is exactly as documented, and the state keeps working. +/// +/// +/// Every managed frame has returned before Lua raises: the thunk only reports the failure, and the SDK wrapper, +/// which is Lua code, raises it. No longjmp crosses a managed frame, however deep the handler recursion. +/// +[Trait("Category", "NativeLua")] +public sealed class ErrorInFlightTests +{ + [Fact] + public void Managed_callback_failing_inside_a_message_handler_yields_one_distinct_status_and_restores_the_stack() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + L.PushInteger(7); + Assert.True(L.TryPushFunction(Thunks.Throw).IsOk); + int handler = L.Top; + Assert.True(L.TryLoad("error('inner')"u8, "=t"u8).IsOk); + + LuaStatus status = L.TryCall(0, 0, handler); + + Assert.Equal(LuaStatus.MessageHandlerError, status); + Assert.Equal(handler + 1, L.Top); + Assert.Equal("error in error handling", LuaError.FromStack(L, status).Message); + Assert.True(L.TryReadInteger(1, out long below)); + Assert.Equal(7, below); + + L.SetTop(0); + LuaStatus next = L.TryExecute("return 6 * 7"u8, 1); + Assert.True(next.IsOk); + Assert.True(L.TryReadInteger(-1, out long value)); + Assert.Equal(42, value); + Assert.Equal(1, L.Top); + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs index e86af5ab..89ac3b29 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Text.Json; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -10,6 +11,7 @@ namespace CheatEngine.SDK.Lua.Tests.Protected; public sealed class NativeFailureProcessTests { [Fact] + [Trait("Qualification", "Q12")] public async Task Generated_function_PushClosure_failure_returns_status_and_restores_stack() { Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); @@ -66,6 +68,7 @@ public async Task Generated_function_PushClosure_failure_returns_status_and_rest } [Fact] + [Trait("Qualification", "Q13")] public async Task Allocation_failures_return_through_the_native_boundary() { Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); @@ -122,4 +125,102 @@ public async Task Allocation_failures_return_through_the_native_boundary() standardOutput, StringComparison.Ordinal); } + + /// + /// Data-driven from libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json: every operation that can raise names its + /// failure evidence, and every probe marker it names is printed by one run of the failure probe. The probe prints a + /// marker only after the failure returned a status and the Lua stack was restored, so a missing marker is a + /// failure path that no longer recovers. Adding a raising operation without evidence fails here. + /// + [Fact] + [Trait("Qualification", "Q13")] + public async Task Every_catalogued_raising_operation_reports_its_failure_marker() + { + Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); + List markers = []; + using (JsonDocument catalogue = LoadCatalogue()) + { + foreach (JsonElement operation in catalogue.RootElement.GetProperty("operations").EnumerateArray()) + { + string id = operation.GetProperty("id").GetString()!; + if (string.Equals(operation.GetProperty("raises").GetString(), "never", StringComparison.Ordinal)) + { + continue; + } + + Assert.True(operation.TryGetProperty("failureEvidence", out JsonElement evidence) && + evidence.GetArrayLength() > 0, + $"Protected operation {id} can raise but names no failure evidence."); + foreach (JsonElement item in evidence.EnumerateArray()) + { + if (string.Equals(item.GetProperty("kind").GetString(), "FailureProbeMarker", StringComparison.Ordinal)) + { + markers.Add(item.GetProperty("marker").GetString()!); + } + } + } + } + + Assert.NotEmpty(markers); + string standardOutput = await RunFailureProbeAsync(); + + string[] missing = [.. markers.Where(marker => !standardOutput.Contains(marker, StringComparison.Ordinal))]; + Assert.True(missing.Length == 0, + $"The failure probe did not print: {string.Join(" | ", missing)}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}"); + } + + private static JsonDocument LoadCatalogue() + { + const string ResourceName = "CheatEngine.SDK.Lua.Tests.ProtectedOperations.json"; + using Stream stream = typeof(NativeFailureProcessTests).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"The embedded resource {ResourceName} is missing."); + return JsonDocument.Parse(stream); + } + + /// Runs the failure probe's default battery in a child process and returns its standard output. + private static async Task RunFailureProbeAsync() + { + string baseDirectory = AppContext.BaseDirectory; + string probe = Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.FailureProbe.dll"); + Assert.True(File.Exists(probe), $"Failure probe was not copied to '{probe}'."); + + ProcessStartInfo start = new("dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.ArgumentList.Add("exec"); + start.ArgumentList.Add("--runtimeconfig"); + start.ArgumentList.Add(Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.runtimeconfig.json")); + start.ArgumentList.Add("--depsfile"); + start.ArgumentList.Add(Path.Combine(baseDirectory, "CheatEngine.SDK.Lua.Tests.deps.json")); + start.ArgumentList.Add(probe); + start.ArgumentList.Add(NativeLuaLibrary.LibraryPath!); + + using Process? process = Process.Start(start); + Assert.NotNull(process); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + Task output = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task error = process.StandardError.ReadToEndAsync(cancellationToken); + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + process.Kill(true); + Assert.Fail("The native failure probe did not exit within 30 seconds."); + } + + string standardOutput = await output; + string standardError = await error; + Assert.True(process.ExitCode == 0, + string.Create(CultureInfo.InvariantCulture, + $"Probe exit code: {process.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}")); + return standardOutput; + } } diff --git a/tests/CheatEngine.SDK.Lua.Tests/README.md b/tests/CheatEngine.SDK.Lua.Tests/README.md index df5a5719..8f25b06b 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/README.md +++ b/tests/CheatEngine.SDK.Lua.Tests/README.md @@ -14,14 +14,15 @@ error unwind a managed frame or allocates on a hot path. Only a real interpreter ## How it works -| Area | Contract asserted | -|--------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Stack and protected operations | `LuaFrame` restores the stack top on every exit path; raising metamethods, syntax errors and host-object pusher exits become statuses, never a Lua unwind | -| Marshallers | Values round trip exactly, and a value that does not fit is refused instead of truncated | -| References and runtime | Every `LuaRuntime.Attach` advances the epoch, and a `LuaRef` from an earlier epoch is never pushed | -| Callbacks | Test thunks follow the SDK rule (static, cdecl, catch-all), state travels in the upvalue, and `Detach` drains admitted invocations before neutralizing callbacks | -| Allocation | `AllocationGate` requires exactly zero bytes allocated on the calling thread once a body is warm | -| Call shape | `Generated/MemoryBindings.cs` and `StringBindings.cs` hold the call shape of generated bodies, run against Lua stand-ins | +| Area | Contract asserted | +|--------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Stack and protected operations | `LuaFrame` restores the stack top on every exit path; raising metamethods, syntax errors and host-object pusher exits become statuses, never a Lua unwind | +| Marshallers | Values round trip exactly, a value that does not fit is refused instead of truncated, and no integer or address passes through a lossy `double` (Q21) | +| Optional values and status | `LuaOptional` keeps omitted, `nil` and a value apart; `LuaCallSupport` pushes, reads and copies optional and variadic values; status enums start at `Unknown = 0` | +| References and runtime | Every `LuaRuntime.Attach` advances the epoch, and a `LuaRef` from an earlier epoch is never pushed | +| Callbacks | Test thunks follow the SDK rule (static, cdecl, catch-all), state travels in the upvalue, and `Detach` drains admitted invocations before neutralizing callbacks | +| Allocation | `AllocationGate` requires exactly zero bytes allocated on the calling thread once a body is warm | +| Call shape | `Generated/MemoryBindings.cs` and `StringBindings.cs` hold the call shape of generated bodies, run against Lua stand-ins | The suite runs sequentially, because `LuaRuntime` is one ambient binding per process and every `LuaRef` reads its epoch. `HostDouble` stands in for the host's exported functions, and `RuntimeScope` attaches on creation and detaches on @@ -50,6 +51,11 @@ dotnet test --project tests/CheatEngine.SDK.Lua.Tests --filter-trait "Category=N - The stack is balanced on every path of a frame (`LuaFrameTests`, `ProtectedOperationTests`). - No Lua error unwinds a managed frame, including a host-object pusher's non-local exit, and no managed exception escapes a thunk (`ProtectedOperationTests`, `LuaCallbackTests`, `NativeFailureProcessTests`). +- Every protected operation that can raise recovers from its failure: the failure probe prints the marker the + catalogue names for it only after the status came back and the stack was restored + (`NativeFailureProcessTests.Every_catalogued_raising_operation_reports_its_failure_marker`). +- A managed message handler that fails while an error is in flight yields one `MessageHandlerError` status with the + stack as documented, and the state keeps working (`ErrorInFlightTests`). - A stale reference is detected by its epoch (`LuaRefEpochTests`, `LuaRefTests`). - `LuaRuntime.Detach` neutralizes every callback the plugin forgot and waits for an admitted callback while rejecting a later callback invocation (`LuaCallbackTests`, `CallbackLifetimeConcurrencyTests`). @@ -58,3 +64,9 @@ dotnet test --project tests/CheatEngine.SDK.Lua.Tests --filter-trait "Category=N - Hot paths allocate zero bytes once warm: scalars, protected calls, callbacks, the generated call shape and the benchmark's 1,024-byte non-ASCII UTF-8 payload (`ZeroAllocationTests`, `ReadIntegerBindingTests`, `StringBindingTests`). +- Integer and address marshallers keep every bit of an integer and refuse a float at or above 2^53, a float numeral + and an out-of-range numeral (`MarshallerRoundTripTests`, traited `Qualification=Q21`). +- `LuaOptional` and the optional and variadic call helpers keep omitted, `nil` and values distinct, allocate + nothing, and report a missing result or an exceeded capacity (`LuaOptionalTests`, `LuaCallSupportOptionalTests`). +- A default status is `Unknown`, never success, and every status value is pinned (`LuaOperationStatusTests`, + `LuaGlobalPushOutcomeTests`). diff --git a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs index 631d5654..a33f737e 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs @@ -50,6 +50,7 @@ public void A_reference_bound_in_the_current_state_identity_is_current() } [Fact] + [Trait("Qualification", "Q17")] public void A_reference_from_another_epoch_is_resolved_but_stale() { LuaRef reference = new(); @@ -62,6 +63,7 @@ public void A_reference_from_another_epoch_is_resolved_but_stale() } [Fact] + [Trait("Qualification", "Q17")] public void A_reference_from_another_state_generation_is_resolved_but_stale() { LuaRef reference = new(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs index afad9efb..e9b23074 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs @@ -119,6 +119,7 @@ public void References_are_invalidated_by_detach_and_reattach() } [Fact] + [Trait("Qualification", "Q17")] public void A_reference_from_the_pre_reset_state_never_releases_a_current_generation_slot() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/References/ReferenceConcurrencyTests.cs b/tests/CheatEngine.SDK.Lua.Tests/References/ReferenceConcurrencyTests.cs index fcab5f7e..55b11a05 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/References/ReferenceConcurrencyTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/References/ReferenceConcurrencyTests.cs @@ -23,7 +23,10 @@ public async Task Concurrent_sdk_references_do_not_disturb_a_host_registry_refer CancellationToken cancellationToken = TestContext.Current.CancellationToken; using NativeLuaState state = new(false); LuaState main = LuaTest.View(state); - using RuntimeScope scope = new(state); + // xUnit v3 resumes the async continuation below on whatever thread pool thread is available (pitfall 4): + // the `using RootedThread` values are disposed after `await Task.WhenAll(...).WaitAsync(...)`, and their + // Dispose calls LuaRuntime.AcquireState(), possibly from a thread other than the one that attached. + using RuntimeScope scope = new(state, admitWorkerThreads: true); using RootedThread first = RootedThread.Create(main); using RootedThread second = RootedThread.Create(main); using RootedThread host = RootedThread.Create(main); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs index fb808a04..58e044d9 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs @@ -22,6 +22,7 @@ public void Default_outcomes_expose_an_empty_failure_list() } [Fact] + [Trait("Qualification", "Q16")] public void Reject_existing_preflights_without_replacing_the_effective_global() { LuaTest.RequireNativeLua(); @@ -40,6 +41,7 @@ public void Reject_existing_preflights_without_replacing_the_effective_global() } [Fact] + [Trait("Qualification", "Q16")] public void Replace_existing_restores_the_prior_value_only_while_the_lease_still_owns_the_global() { LuaTest.RequireNativeLua(); @@ -63,6 +65,7 @@ public void Replace_existing_restores_the_prior_value_only_while_the_lease_still } [Fact] + [Trait("Qualification", "Q16")] public void Release_preserves_a_later_replacement_and_reports_it_without_writing() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaExternalResetDetectionTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaExternalResetDetectionTests.cs new file mode 100644 index 00000000..656d93b1 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaExternalResetDetectionTests.cs @@ -0,0 +1,263 @@ +using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.References; +using CheatEngine.SDK.Lua.Registration; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Lua.Tests.Callbacks; +using CheatEngine.SDK.Lua.Tests.Support; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.Lua.Tests.Runtime; + +/// +/// The 2.0 decision for resetLuaState (WI-3, O2): no public reset API; a reset the SDK was not told about +/// is detected deterministically, refuses old owners, never releases a reference into the replacement registry +/// (A08-22), and reports one diagnostic. Uses a second as the "new VM" and +/// to simulate the host swapping the provider's answer without +/// going through . +/// +[Trait("Category", "NativeLua")] +public sealed unsafe class LuaExternalResetDetectionTests +{ + [Fact] + [Trait("Qualification", "Q18")] + public void An_external_state_replacement_is_detected_on_the_next_provider_acquisition() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + using NativeLuaState replacement = new(); + Assert.False(LuaRuntime.ExternalStateResetDetected); + HostDouble.SetStateForCurrentThread(replacement.Pointer); + + LuaAdmissionStatus status = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + + Assert.Equal(LuaAdmissionStatus.ExternalStateReset, status); + Assert.True(LuaRuntime.ExternalStateResetDetected); + } + + [Fact] + [Trait("Qualification", "Q18")] + public void After_an_external_reset_every_admission_path_reports_ExternalStateReset_until_detach() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + using NativeLuaState replacement = new(); + HostDouble.SetStateForCurrentThread(replacement.Pointer); + TriggerExternalReset(); + + Assert.True(LuaRuntime.ExternalStateResetDetected); + + LuaAdmissionStatus status = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation provider); + provider.Dispose(); + Assert.Equal(LuaAdmissionStatus.ExternalStateReset, status); + + Assert.False(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation viaTry)); + viaTry.Dispose(); + + InvalidOperationException thrown = Assert.Throws(() => LuaRuntime.AcquireOperation()); + Assert.Contains("replaced its Lua state", thrown.Message, StringComparison.Ordinal); + + Assert.Throws(() => LuaRuntime.AcquireOperation(main)); + + Assert.False(LuaRuntime.TryEnterCallbackOperation(out LuaRuntimeOperation callback)); + callback.Dispose(); + } + + [Fact] + [Trait("Qualification", "Q18")] + public void External_reset_detection_advances_the_state_generation_so_old_references_are_stale() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + main.PushString("before reset"u8); + LuaRef reference = main.CreateRef(); + LuaStateIdentity before = LuaRuntime.CurrentStateIdentity; + using NativeLuaState replacement = new(); + HostDouble.SetStateForCurrentThread(replacement.Pointer); + + TriggerExternalReset(); + + LuaStateIdentity after = LuaRuntime.CurrentStateIdentity; + Assert.Equal(before.AttachEpoch, after.AttachEpoch); + Assert.Equal(before.StateGeneration + 1, after.StateGeneration); + Assert.True(reference.IsResolved); + Assert.False(reference.IsCurrent); + reference.Release(default); + } + + [Fact] + [Trait("Qualification", "Q18")] + public void A_reference_from_before_an_external_reset_is_never_released_into_the_replacement_registry() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + main.PushString("before reset"u8); + LuaRef reference = main.CreateRef(); + using NativeLuaState replacement = new(); + LuaState replacementView = new(replacement.L); + int replacementTopBefore = replacementView.Top; + HostDouble.SetStateForCurrentThread(replacement.Pointer); + + TriggerExternalReset(); + + // A stale reference releases as a no-op: it never touches the replacement state's stack or registry. + reference.Release(replacementView); + Assert.Equal(replacementTopBefore, replacementView.Top); + } + + [Fact] + [Trait("Qualification", "Q18")] + public void Detach_after_an_external_reset_abandons_callbacks_without_calling_Lua_on_the_replacement_state() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + Assert.True(LuaCallback.TryCreate(main, Thunks.Count, new Counter(), out LuaCallback? callback).IsOk); + Assert.NotNull(callback); + using NativeLuaState replacement = new(); + LuaState replacementView = new(replacement.L); + int replacementTopBefore = replacementView.Top; + HostDouble.SetStateForCurrentThread(replacement.Pointer); + + TriggerExternalReset(); + Assert.True(LuaRuntime.ExternalStateResetDetected); + + LuaRuntime.Detach(); + + Assert.True(callback.IsReleased); + Assert.Equal(0, LuaCallbackRegistry.Count); + Assert.Equal(replacementTopBefore, replacementView.Top); + } + + [Fact] + [Trait("Qualification", "Q18")] + public void Reattach_after_an_external_reset_restamps_and_admits_again() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + using NativeLuaState replacement = new(); + HostDouble.SetStateForCurrentThread(replacement.Pointer); + TriggerExternalReset(); + Assert.True(LuaRuntime.ExternalStateResetDetected); + LuaRuntime.Detach(); + + LuaHostBinding binding = HostDouble.CreateBinding(replacement.L); + LuaRuntime.Attach(in binding); + try + { + Assert.False(LuaRuntime.ExternalStateResetDetected); + Assert.True(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)); + operation.Dispose(); + } + finally + { + LuaRuntime.Detach(); + } + } + + [Fact] + [Trait("Qualification", "Q18")] + public void The_external_reset_diagnostic_is_reported_once_per_attachment() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + using NativeLuaState replacement = new(); + int raisedCount = 0; + LuaRuntimeDiagnostic? lastKind = null; + LuaRuntime.DiagnosticObserver = kind => + { + raisedCount++; + lastKind = kind; + }; + + try + { + HostDouble.SetStateForCurrentThread(replacement.Pointer); + for (int i = 0; i < 3; i++) + { + TriggerExternalReset(); + } + + Assert.Equal(1, raisedCount); + Assert.Equal(LuaRuntimeDiagnostic.ExternalStateReset, lastKind); + } + finally + { + LuaRuntime.DiagnosticObserver = null; + } + } + + [Fact] + [Trait("Qualification", "Q17")] + public void The_supported_reset_restamps_the_replacement_state_and_is_not_reported_as_external() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + using NativeLuaState replacement = new(); + + using (LuaRuntime.BeginStateReset()) + { + HostDouble.SetStateForCurrentThread(replacement.Pointer); + } + + Assert.False(LuaRuntime.ExternalStateResetDetected); + Assert.True(LuaRuntime.TryAcquireOperation(out LuaRuntimeOperation operation)); + operation.Dispose(); + Assert.False(LuaRuntime.ExternalStateResetDetected); + } + + [Fact] + [Trait("Qualification", "Q17")] + public void A_state_replacement_during_a_registration_transaction_leaves_the_lease_stale_and_the_new_state_untouched() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + using NativeLuaState replacement = new(); + LuaState replacementView = new(replacement.L); + LuaRegistrationEntry[] entries = [new LuaRegistrationEntry("sdk013_reset_registration", Thunks.Add)]; + LuaRegistrationResult result = LuaRegistrationSet.Register(main, entries); + Assert.True(result.IsSuccess); + LuaRegistrationLease lease = result.Lease!; + int replacementTopBefore = replacementView.Top; + + using (LuaRuntime.BeginStateReset()) + { + HostDouble.SetStateForCurrentThread(replacement.Pointer); + } + + LuaRegistrationReleaseOutcome outcome = lease.ReleaseWithOutcome(); + Assert.Equal(LuaRegistrationReleaseKind.Stale, outcome.Kind); + Assert.Equal(replacementTopBefore, replacementView.Top); + } + + [Fact] + public void Sdk_reference_tables_use_private_registry_keys_distinct_from_the_runtime_stamp_and_host_references() + { + Assert.NotEqual(LuaReferences.Key, LuaRuntime.StampKeyForTests); + Assert.NotEqual(0, LuaReferences.Key); + Assert.NotEqual(0, LuaRuntime.StampKeyForTests); + } + + // Simulates the host replacing the Lua state behind the SDK's back: the provider now answers with the + // replacement state's pointer, but nothing called LuaRuntime.BeginStateReset first. One admitted acquisition + // attempt is enough to trigger detection. + private static void TriggerExternalReset() + { + LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs index 4419e023..bd3a782a 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs @@ -39,6 +39,7 @@ public void Attach_publishes_the_binding_and_advances_the_attach_epoch_once() } [Fact] + [Trait("Qualification", "Q17")] public void BeginStateReset_advances_only_the_state_generation_while_the_host_remains_attached() { LuaTest.RequireNativeLua(); @@ -78,7 +79,8 @@ public void AcquireOperation_calls_the_provider_once_per_operation_and_returns_i Assert.Equal(state.Pointer, first.Handle); Assert.Equal(first, second); Assert.Equal(first, third); - Assert.Equal(3, HostDouble.ProviderCalls); + // 1: Attach's own eager universe-stamp attempt; 2-4: the three acquisitions above. + Assert.Equal(4, HostDouble.ProviderCalls); first.PushInteger(11); Assert.Equal(1, LuaTest.View(state).Top); @@ -190,7 +192,10 @@ public async Task BeginStateReset_rejects_new_operations_and_waits_for_an_admitt LuaTest.RequireNativeLua(); CancellationToken cancellationToken = TestContext.Current.CancellationToken; using NativeLuaState state = new(false); - using RuntimeScope scope = new(state); + // The worker task below calls LuaRuntime.AcquireOperation() on a real background thread, which the 2.0 + // conservative default (ADR-07) would otherwise refuse before this scenario ever reaches its reset race. + // This test exercises the reset-drain behavior, not the default admission policy, so it opts in. + using RuntimeScope scope = new(state, admitWorkerThreads: true); using ManualResetEventSlim workerAdmitted = new(false); using ManualResetEventSlim releaseWorker = new(false); using ManualResetEventSlim admissionClosed = new(false); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaThreadAdmissionTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaThreadAdmissionTests.cs new file mode 100644 index 00000000..0df5e202 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaThreadAdmissionTests.cs @@ -0,0 +1,330 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Lua.Callbacks; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Interop.Types; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.Lua.Tests.Support; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + +namespace CheatEngine.SDK.Lua.Tests.Runtime; + +/// +/// The 2.0 conservative-by-default worker-thread admission policy (F04, ADR-07): a new +/// -family call is refused on a worker thread before the host's state +/// provider ever runs, unless the calling thread is the captured main thread, the call is nested inside +/// already-admitted work, or the worker-side synchronize hand-off admits it explicitly. +/// +[Trait("Category", "NativeLua")] +public sealed unsafe class LuaThreadAdmissionTests +{ + private static LuaAdmissionStatus? s_nestedStatus; + + [Fact] + [Trait("Qualification", "Q19")] + public void Default_admission_refuses_a_worker_before_calling_the_state_provider() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + int providerCallsBefore = HostDouble.ProviderCalls; + + LuaAdmissionStatus status = RunOnWorker(() => + { + HostDouble.SetStateForCurrentThread(state.Pointer); + LuaAdmissionStatus result = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + return result; + }); + + Assert.Equal(LuaAdmissionStatus.ThreadNotAdmitted, status); + Assert.Equal(providerCallsBefore, HostDouble.ProviderCalls); + Assert.Equal(LuaThreadAdmission.MainThreadOnly, LuaRuntime.ThreadAdmission); + } + + [Fact] + [Trait("Qualification", "Q19")] + public void Two_workers_acquiring_at_the_same_time_are_both_refused_by_default() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + int providerCallsBefore = HostDouble.ProviderCalls; + using Barrier barrier = new(2); + LuaAdmissionStatus[] results = new LuaAdmissionStatus[2]; + + void Worker(int index) + { + HostDouble.SetStateForCurrentThread(state.Pointer); + barrier.SignalAndWait(TimeSpan.FromSeconds(5)); + results[index] = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + } + + Thread first = new(() => Worker(0)); + Thread second = new(() => Worker(1)); + first.Start(); + second.Start(); + Assert.True(first.Join(TimeSpan.FromSeconds(5))); + Assert.True(second.Join(TimeSpan.FromSeconds(5))); + + Assert.Equal(LuaAdmissionStatus.ThreadNotAdmitted, results[0]); + Assert.Equal(LuaAdmissionStatus.ThreadNotAdmitted, results[1]); + Assert.Equal(providerCallsBefore, HostDouble.ProviderCalls); + } + + [Fact] + public void The_captured_main_thread_is_admitted_by_default() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + + LuaAdmissionStatus status = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + + Assert.Equal(LuaAdmissionStatus.Admitted, status); + } + + [Fact] + [Trait("Qualification", "Q19")] + public void A_host_invoked_callback_on_a_worker_admits_nested_operations_on_that_thread() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + s_nestedStatus = null; + Assert.True(LuaCallback.TryCreate(main, new LuaNativeFunction(&NestedAcquireThunk), new object(), + out LuaCallback? callback).IsOk); + Assert.True(callback!.TryRegister(main, "nested_acquire"u8).IsOk); + LuaState workerCoroutine = CreateRootedCoroutine(main); + + LuaStatus workerCallStatus = RunOnWorker(() => + { + HostDouble.SetStateForCurrentThread(workerCoroutine.Handle); + return workerCoroutine.TryExecute("nested_acquire()"u8, 0); + }); + + Assert.True(workerCallStatus.IsOk); + Assert.Equal(LuaAdmissionStatus.Admitted, s_nestedStatus); + callback.Release(main); + } + + [Fact] + public void Worker_admission_opt_in_admits_workers_until_the_next_attach() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state, admitWorkerThreads: true); + + LuaAdmissionStatus status = RunOnWorker(() => + { + HostDouble.SetStateForCurrentThread(state.Pointer); + LuaAdmissionStatus result = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + return result; + }); + + Assert.Equal(LuaAdmissionStatus.Admitted, status); + } + + [Fact] + public void Worker_admission_opt_in_is_refused_while_detached() + { + LuaTest.RequireNativeLua(); + LuaRuntime.Detach(); + +#pragma warning disable CESDK5001 // Exercises the gated opt-in itself, while detached, to prove it throws. + Assert.Throws(LuaRuntime.AdmitWorkerThreads); +#pragma warning restore CESDK5001 + } + + [Fact] + public void Detach_and_attach_restore_main_thread_only_admission() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using (RuntimeScope firstAttach = new(state, admitWorkerThreads: true)) + { + Assert.Equal(LuaThreadAdmission.WorkerThreads, LuaRuntime.ThreadAdmission); + } + + Assert.Equal(LuaThreadAdmission.MainThreadOnly, LuaRuntime.ThreadAdmission); + + using RuntimeScope secondAttach = new(state); + Assert.Equal(LuaThreadAdmission.MainThreadOnly, LuaRuntime.ThreadAdmission); + } + + [Fact] + [Trait("Qualification", "Q19")] + public void Callback_dispose_on_a_non_admitted_worker_defers_neutralization_to_detach() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state); + Assert.True(LuaCallback.TryCreate(main, new LuaNativeFunction(&NestedAcquireThunk), new object(), + out LuaCallback? callback).IsOk); + Assert.NotNull(callback); + + RunOnWorker(() => + { + HostDouble.SetStateForCurrentThread(main.Handle); + callback.Dispose(); + return 0; + }); + + // A worker thread cannot obtain admission by default, so Dispose defers to the transition instead of + // abandoning the closure early: the callback remains linked until Detach neutralizes it. + Assert.False(callback.IsReleased); + Assert.Equal(1, LuaCallbackRegistry.Count); + + LuaRuntime.Detach(); + Assert.True(callback.IsReleased); + } + + [Fact] + public void The_worker_refusal_is_reported_once_per_attachment_through_the_diagnostic_observer() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + int raisedCount = 0; + LuaRuntimeDiagnostic? lastKind = null; + LuaRuntime.DiagnosticObserver = kind => + { + raisedCount++; + lastKind = kind; + }; + + try + { + for (int i = 0; i < 3; i++) + { + RunOnWorker(() => + { + HostDouble.SetStateForCurrentThread(state.Pointer); + LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + return 0; + }); + } + + Assert.Equal(1, raisedCount); + Assert.Equal(LuaRuntimeDiagnostic.WorkerThreadRefused, lastKind); + } + finally + { + LuaRuntime.DiagnosticObserver = null; + } + } + + [Fact] + public void Main_thread_dispatch_admission_is_the_only_worker_path_admitted_by_default() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + using RuntimeScope scope = new(state); + + LuaAdmissionStatus workerDirect = RunOnWorker(() => + { + HostDouble.SetStateForCurrentThread(state.Pointer); + LuaAdmissionStatus result = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation op); + op.Dispose(); + return result; + }); + + bool dispatchAdmitted = RunOnWorker(() => + { + HostDouble.SetStateForCurrentThread(state.Pointer); + try + { + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperationForMainThreadDispatch(); + return true; + } + catch (InvalidOperationException) + { + return false; + } + }); + + Assert.Equal(LuaAdmissionStatus.ThreadNotAdmitted, workerDirect); + Assert.True(dispatchAdmitted); + } + + [Fact] + public void LuaRuntimeOperation_is_a_ref_struct_so_it_cannot_cross_an_await() + { + Assert.True(typeof(LuaRuntimeOperation).IsByRefLike); + } + + [Fact] + public void Worker_admission_gate_is_experimental_CESDK5001_with_the_documentation_url() + { + MethodInfo method = typeof(LuaRuntime).GetMethod(nameof(LuaRuntime.AdmitWorkerThreads))!; + ExperimentalAttribute? attribute = method.GetCustomAttribute(); + + Assert.NotNull(attribute); + Assert.Equal("CESDK5001", attribute!.DiagnosticId); + Assert.Equal("https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md", + attribute.UrlFormat); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int NestedAcquireThunk(nint pointer) + { + LuaState state = new(pointer); + try + { + s_nestedStatus = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + return 0; + } + catch (Exception exception) + { + return LuaThunk.Fail(state, exception); + } + } + + private static LuaState CreateRootedCoroutine(LuaState main) + { + LuaStatus status = main.TryExecute("return coroutine.create(function() end)"u8, 1); + Assert.True(status.IsOk); + lua_State* pointer = lua_tothread(main.Pointer, -1); + Assert.NotEqual(nint.Zero, (nint) pointer); + main.Pop(1); + return new LuaState(pointer); + } + + private static T RunOnWorker(Func body) + { + T result = default!; + Exception? failure = null; + Thread thread = new(() => + { + try + { + result = body(); + } + catch (Exception exception) + { + failure = exception; + } + }); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "The worker thread did not return."); + if (failure is not null) + { + throw failure; + } + + return result; + } +} diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaUniverseQualificationTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaUniverseQualificationTests.cs index 1ae785e9..e4ed7a3e 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaUniverseQualificationTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaUniverseQualificationTests.cs @@ -1,3 +1,9 @@ +// The second test below reaches a worker Lua coroutine only through RuntimeScope's admitWorkerThreads: true, which +// opts in to the experimental CESDK5001 gate (RuntimeScope.cs carries that call's own justified #pragma). Without +// it, the 2.0 conservative default (ADR-07) refuses every worker acquisition before the state provider runs, and +// the qualification would have nothing to observe. See +// `First_worker_acquisition_is_refused_by_default_without_creating_a_coroutine` for the default-policy proof. + using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -24,12 +30,36 @@ public sealed class LuaUniverseQualificationTests private static unsafe LuaNativeFunction ReturnOneFunction => new(&ReturnOne); [Fact] - public void First_worker_acquisition_rejects_a_missing_state_then_uses_a_distinct_coroutine_in_the_same_universe() + [Trait("Qualification", "Q19")] + public void First_worker_acquisition_is_refused_by_default_without_creating_a_coroutine() { LuaTest.RequireNativeLua(); using NativeLuaState state = new(); - LuaState main = LuaTest.View(state); using RuntimeScope scope = new(state); + int providerCallsBefore = HostDouble.ProviderCalls; + LuaAdmissionStatus? status = null; + + Thread thread = new(() => + { + HostDouble.ClearStateForCurrentThread(); + status = LuaRuntime.TryAcquireOperationWithOutcome(out LuaRuntimeOperation operation); + operation.Dispose(); + }); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "The refused worker acquisition did not return."); + + Assert.Equal(LuaAdmissionStatus.ThreadNotAdmitted, status); + Assert.Equal(providerCallsBefore, HostDouble.ProviderCalls); + } + + [Fact] + [Trait("Qualification", "Q19")] + public void First_worker_acquisition_with_the_experimental_opt_in_uses_a_distinct_coroutine_in_the_same_universe() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState main = LuaTest.View(state); + using RuntimeScope scope = new(state, admitWorkerThreads: true); main.PushInteger(719); Assert.True(main.TrySetGlobal("sdk012_shared_universe"u8).IsOk); main.PushString("shared private registry"u8); @@ -40,7 +70,9 @@ public void First_worker_acquisition_rejects_a_missing_state_then_uses_a_distinc Assert.Null(observation.Failure); Assert.True(observation.FirstAcquisitionWasRejected); - Assert.Equal(2, HostDouble.ProviderCalls); + // 1: Attach's own eager universe-stamp attempt on the main thread; 2: the worker's rejected attempt (no + // state yet); 3: the worker's successful acquisition once its state is set. + Assert.Equal(3, HostDouble.ProviderCalls); Assert.NotEqual(main.Handle, observation.WorkerState); Assert.Equal(identity, observation.WorkerIdentity); Assert.Equal(719L, observation.GlobalValue); @@ -57,7 +89,8 @@ public void LuaTest.RequireNativeLua(); using NativeLuaState state = new(); LuaState main = LuaTest.View(state); - using RuntimeScope scope = new(state); + // ObservePostResetWorkerCore below acquires a Lua operation from a real worker thread. + using RuntimeScope scope = new(state, admitWorkerThreads: true); using RootedCoroutine worker = RootedCoroutine.Create(main); using NativeLuaState replacementState = new(); LuaState replacementMain = LuaTest.View(replacementState); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Support/RuntimeScope.cs b/tests/CheatEngine.SDK.Lua.Tests/Support/RuntimeScope.cs index 9302f2ff..c6fb1ad3 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Support/RuntimeScope.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Support/RuntimeScope.cs @@ -9,10 +9,25 @@ namespace CheatEngine.SDK.Lua.Tests.Support; /// internal sealed unsafe class RuntimeScope : IDisposable { - public RuntimeScope(NativeLuaState state, bool withPusher = true) + /// The fixture state the host double provides to the attaching (main) thread. + /// Whether the double's binding includes a host-object pusher. + /// + /// When , calls the experimental right after + /// attaching, for tests that acquire a Lua operation from a worker thread or from an xUnit v3 async continuation + /// that may resume on one. Every call site that passes this flag carries its own file-level + /// #pragma warning disable CESDK5001 with a justification comment: never a project-wide NoWarn. + /// + public RuntimeScope(NativeLuaState state, bool withPusher = true, bool admitWorkerThreads = false) { Binding = HostDouble.CreateBinding(state.L, withPusher); LuaRuntime.Attach(Binding); + if (admitWorkerThreads) + { +#pragma warning disable CESDK5001 // Test-only opt-in: the caller passed admitWorkerThreads: true for a worker-thread + // or post-await scenario; every such call site carries its own justification (see individual test files). + LuaRuntime.AdmitWorkerThreads(); +#pragma warning restore CESDK5001 + } } public LuaHostBinding Binding diff --git a/tests/CheatEngine.SDK.Lua.Tests/packages.lock.json b/tests/CheatEngine.SDK.Lua.Tests/packages.lock.json new file mode 100644 index 00000000..be8d8b17 --- /dev/null +++ b/tests/CheatEngine.SDK.Lua.Tests/packages.lock.json @@ -0,0 +1,236 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.failureprobe": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Lua": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.tests.shared": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.NativeAotLibraryProbe/README.md b/tests/CheatEngine.SDK.NativeAotLibraryProbe/README.md index 25c9e612..1a197d64 100644 --- a/tests/CheatEngine.SDK.NativeAotLibraryProbe/README.md +++ b/tests/CheatEngine.SDK.NativeAotLibraryProbe/README.md @@ -1,17 +1,41 @@ # CheatEngine.SDK.NativeAotLibraryProbe -This is an inert `win-x64` NativeAOT shared-library fixture for SDK-006. It has two deliberately non-Cheat-Engine -exports and no reference to the SDK shipping graph. It is not a plugin, does not contain any `CEPlugin_*` export, and -its exports must never be called by the loader harness. +An inert `win-x64` NativeAOT shared library, the fixture of the NativeAOT loader harness (scenario Q41). -Publish it only for the separate library-analysis gate: +## Objective + +Give the [loader harness](../CheatEngine.SDK.NativeAotLoaderHarness/README.md) a real NativeAOT shared library whose +export surface is known exactly: two deliberately non-Cheat-Engine names and nothing that looks like a plugin. + +## Why it exists + +A NativeAOT publish that succeeds proves nothing about Cheat Engine: Cheat Engine unloads plugins with `FreeLibrary`, +which .NET does not support for NativeAOT libraries, so a NativeAOT plugin DLL is not a supported profile (see the +[NativeAOT plugin profile](../../libs/CheatEngine.SDK.Abi/README.md#nativeaot-plugin-profile-f02)). What can be +checked is what such a publish exports. This probe is the controlled input of that check. It is not a plugin, has no +`CEPlugin_*` export and does not reference the SDK shipping graph. + +## How it works + +`NativeAotLibraryProbeExports` declares two `[UnmanagedCallersOnly]` entry points, named by +`NativeAotLibraryProbeExportNames.Required`: `CheatEngineSdkNativeAotProbe_LoadOnly` and +`CheatEngineSdkNativeAotProbe_NameQuery`. A NativeAOT publish exports only the `UnmanagedCallersOnly` methods of the +published assembly, plus the runtime's own `DotNetRuntimeDebugHeader`. The harness may map the published file and query +the two names; it never calls them and never frees the module. + +## Promise + +- The source declares exactly the two required entry points and no `CEPlugin_*` name + (`NativeAotLibraryProbeExportSurfaceTests` in + [`tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests`](../CheatEngine.SDK.NativeAotLoaderHarness.Tests/README.md)). +- A local publish (.NET SDK 10.0.401, win-x64, Release) exports exactly those two names and `DotNetRuntimeDebugHeader`; + the harness refuses any other surface. + +## Run the tests + +The probe has no tests of its own. Publish it only for the library-analysis gate: ```powershell dotnet publish tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj -c Release -o artifacts/nativeaot-library-probe +dotnet test --project tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests ``` - -The adjacent loader harness can inspect its export directory without loading it, then—only with an acknowledgement—map -the fixed-name output placed beside its own published executable and query the two names. It locks the file while it -checks and maps it, and intentionally does not call `NativeLibrary.Free`. -See [ADR-006](../../documentations/engineering/ADR-006-nativeaot-plugin-loader-profile.md) and the harness README for -the exact boundary. diff --git a/tests/CheatEngine.SDK.NativeAotLibraryProbe/packages.lock.json b/tests/CheatEngine.SDK.NativeAotLibraryProbe/packages.lock.json new file mode 100644 index 00000000..d4ea7a8d --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLibraryProbe/packages.lock.json @@ -0,0 +1,47 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + } + }, + "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==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests.csproj b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests.csproj new file mode 100644 index 00000000..150c8cee --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/LibraryProbeContractTests.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/LibraryProbeContractTests.cs new file mode 100644 index 00000000..28789c63 --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/LibraryProbeContractTests.cs @@ -0,0 +1,95 @@ +using CheatEngine.SDK.NativeAotLibraryProbe; +using CheatEngine.SDK.NativeAotLoaderHarness.Tests.Support; + +namespace CheatEngine.SDK.NativeAotLoaderHarness.Tests; + +/// +/// The exact export surface the harness accepts from the NativeAOT library probe (Q41): the two probe names, the +/// measured NativeAOT runtime export, nothing else, and never a CEPlugin_* classic plugin entry point. +/// +public sealed class LibraryProbeContractTests +{ + private static readonly string[] Expected = + [.. NativeAotLibraryProbeExportNames.Required, .. LibraryProbeContract.AllowedRuntimeExports]; + + [Theory] + [InlineData("CEPlugin_GetVersion")] + [InlineData("CEPlugin_InitializePlugin")] + [InlineData("CEPlugin_DisablePlugin")] + [InlineData("CEPlugin_Anything")] + [Trait("Qualification", "Q41")] + public void Refuses_a_library_that_exports_a_CEPlugin_name(string exportName) + { + IReadOnlyList violations = LibraryProbeContract.FindViolations([.. Expected, exportName]); + + string violation = Assert.Single(violations); + Assert.Contains("Cheat Engine native-plugin entry point", violation, StringComparison.Ordinal); + Assert.Contains(exportName, violation, StringComparison.Ordinal); + Assert.Throws(() => LibraryProbeContract.Validate([.. Expected, exportName])); + } + + [Fact] + [Trait("Qualification", "Q41")] + public void Refuses_a_CEPlugin_export_read_from_the_bytes_of_a_patched_image() + { + byte[] image = BridgeImage.Load(); + BridgeImage.RenameExport(image, "cheatengine_sdk_lua_protected", "CEPlugin_GetVersion"); + + List names = PortableExecutableExportReader.ReadExportNames(image); + + Assert.Contains("CEPlugin_GetVersion", names, StringComparer.Ordinal); + Assert.Contains(LibraryProbeContract.FindViolations(names), + static violation => violation.Contains("'CEPlugin_GetVersion'", StringComparison.Ordinal) && + violation.Contains("native-plugin entry point", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(NativeAotLibraryProbeExportNames.NameQuery)] + [InlineData(NativeAotLibraryProbeExportNames.LoadOnly)] + [Trait("Qualification", "Q41")] + public void Refuses_a_library_missing_a_required_probe_export(string missing) + { + string[] exports = [.. Expected.Where(name => !string.Equals(name, missing, StringComparison.Ordinal))]; + + IReadOnlyList violations = LibraryProbeContract.FindViolations(exports); + + Assert.Equal([$"The file does not expose required fixture export '{missing}'."], violations); + Assert.Throws(() => LibraryProbeContract.Validate(exports)); + } + + [Theory] + [InlineData("cheatengine_sdk_lua_protected")] + [InlineData("DllGetClassObject")] + [InlineData("CheatEngineSdkNativeAotProbe_Extra")] + [InlineData("ceplugin_getversion")] + [Trait("Qualification", "Q41")] + public void Refuses_an_unexpected_extra_export(string extra) + { + IReadOnlyList violations = LibraryProbeContract.FindViolations([.. Expected, extra]); + + Assert.Equal([$"The file exposes the unexpected export '{extra}'."], violations); + Assert.Throws(() => LibraryProbeContract.Validate([.. Expected, extra])); + } + + [Fact] + [Trait("Qualification", "Q41")] + public void Accepts_exactly_the_required_probe_exports_and_the_allowed_runtime_exports() + { + Assert.Empty(LibraryProbeContract.FindViolations(Expected)); + Assert.Empty(LibraryProbeContract.FindViolations([.. NativeAotLibraryProbeExportNames.Required])); + LibraryProbeContract.Validate(Expected); + Assert.Equal(["DotNetRuntimeDebugHeader"], LibraryProbeContract.AllowedRuntimeExports); + Assert.Equal(["DotNetRuntimeDebugHeader"], LibraryProbeContract.RuntimeExportsIn(Expected)); + } + + [Fact] + public void The_real_bridge_surface_is_refused_as_a_whole() + { + IReadOnlyList violations = + LibraryProbeContract.FindViolations(PortableExecutableExportReader.ReadExportNames(BridgeImage.Load())); + + Assert.Equal(6, violations.Count); + Assert.Equal(2, violations.Count(static violation => violation.Contains("required fixture export", StringComparison.Ordinal))); + Assert.Equal(4, violations.Count(static violation => violation.Contains("unexpected export", StringComparison.Ordinal))); + } +} diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/NativeAotLibraryProbeExportSurfaceTests.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/NativeAotLibraryProbeExportSurfaceTests.cs new file mode 100644 index 00000000..b3a2f3b9 --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/NativeAotLibraryProbeExportSurfaceTests.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +using CheatEngine.SDK.NativeAotLibraryProbe; + +namespace CheatEngine.SDK.NativeAotLoaderHarness.Tests; + +/// +/// The probe's source declares exactly the export surface the harness requires: its [UnmanagedCallersOnly] +/// entry points are the two required names, and none is a classic CEPlugin_* plugin entry point. A NativeAOT +/// publication exports only these methods of the published assembly, plus the runtime's own exports. +/// +public sealed class NativeAotLibraryProbeExportSurfaceTests +{ + [Fact] + [Trait("Qualification", "Q41")] + public void Probe_exports_exactly_the_required_names_and_no_CEPlugin_entry_point() + { + string[] entryPoints = + [ + .. typeof(NativeAotLibraryProbeExports) + .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Select(static method => method.GetCustomAttribute()) + .Where(static attribute => attribute is not null) + .Select(static attribute => attribute!.EntryPoint!) + .Order(StringComparer.Ordinal) + ]; + + Assert.Equal(NativeAotLibraryProbeExportNames.Required.Order(StringComparer.Ordinal), entryPoints, StringComparer.Ordinal); + Assert.DoesNotContain(entryPoints, + static name => name.StartsWith(LibraryProbeContract.NativePluginPrefix, StringComparison.Ordinal)); + Assert.Empty(LibraryProbeContract.FindViolations([.. entryPoints, .. LibraryProbeContract.AllowedRuntimeExports])); + } +} diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/PortableExecutableExportReaderTests.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/PortableExecutableExportReaderTests.cs new file mode 100644 index 00000000..b08fb98d --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/PortableExecutableExportReaderTests.cs @@ -0,0 +1,80 @@ +using CheatEngine.SDK.NativeAotLoaderHarness.Tests.Support; + +namespace CheatEngine.SDK.NativeAotLoaderHarness.Tests; + +/// +/// The harness's PE export reader against a real PE32+ AMD64 DLL (the checked-in Lua protection bridge) and against +/// in-memory copies patched into the shapes it must refuse. It reads bytes only; no image is mapped or loaded. +/// +public sealed class PortableExecutableExportReaderTests +{ + [Fact] + [Trait("Qualification", "Q41")] + public void Reads_the_four_exports_of_the_checked_in_bridge() + { + List names = PortableExecutableExportReader.ReadExportNames(BridgeImage.Load()); + + Assert.Equal(BridgeImage.ExportNames, names, StringComparer.Ordinal); + } + + [Theory] + [InlineData((ushort) 0x014C)] + [InlineData((ushort) 0xAA64)] + [InlineData((ushort) 0x0000)] + [Trait("Qualification", "Q41")] + public void Refuses_a_non_amd64_image(ushort machine) + { + byte[] image = BridgeImage.Load(); + BridgeImage.SetMachine(image, machine); + + InvalidOperationException exception = + Assert.Throws(() => PortableExecutableExportReader.ReadExportNames(image)); + + Assert.Equal("The file is not an AMD64 image.", exception.Message); + } + + [Fact] + [Trait("Qualification", "Q41")] + public void Refuses_an_image_without_an_export_directory() + { + byte[] image = BridgeImage.Load(); + BridgeImage.ClearExportDirectory(image); + + InvalidOperationException exception = + Assert.Throws(() => PortableExecutableExportReader.ReadExportNames(image)); + + Assert.Equal("The file has no export directory.", exception.Message); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + [InlineData(64)] + [InlineData(512)] + [InlineData(1024)] + [Trait("Qualification", "Q41")] + public void Refuses_truncated_bytes(int length) + { + byte[] image = BridgeImage.Load()[..length]; + + Exception exception = Assert.ThrowsAny(() => PortableExecutableExportReader.ReadExportNames(image)); + + Assert.True(exception is InvalidOperationException or BadImageFormatException, + $"A truncated image must be refused as malformed, not with {exception.GetType().Name}: {exception.Message}"); + } + + [Fact] + [Trait("Qualification", "Q41")] + public void Refuses_an_image_truncated_inside_an_export_name() + { + byte[] full = BridgeImage.Load(); + int firstName = full.AsSpan().IndexOf(System.Text.Encoding.ASCII.GetBytes(BridgeImage.ExportNames[0] + "\0")); + Assert.True(firstName > 0); + byte[] image = full[..(firstName + 3)]; + + InvalidOperationException exception = + Assert.Throws(() => PortableExecutableExportReader.ReadExportNames(image)); + + Assert.Equal("An export name is not zero terminated.", exception.Message); + } +} diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/README.md b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/README.md new file mode 100644 index 00000000..e1e8bfe6 --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/README.md @@ -0,0 +1,49 @@ +# CheatEngine.SDK.NativeAotLoaderHarness.Tests + +Unit tests of the NativeAOT loader harness contract, scenario Q41 at level C0. + +## Objective + +Prove, without publishing anything, that the harness in +[`tests/CheatEngine.SDK.NativeAotLoaderHarness`](../CheatEngine.SDK.NativeAotLoaderHarness/README.md) reads a PE export +directory correctly and refuses every export surface other than the probe's: a classic `CEPlugin_*` plugin entry point, +a missing probe export, or any unexpected extra export. + +## Why it exists + +The CI `aot` job publishes the probe and runs the harness, but before this project its PE reader and its refusal rules +had no unit test and the job checked exit codes only. A harness that accepted anything would look exactly like a harness +that works. A NativeAOT publish that succeeds is never a Cheat Engine load success; see the +[NativeAOT profile](../../libs/CheatEngine.SDK.Abi/README.md). + +## How it works + +The harness and the probe are RID-specific NativeAOT projects, so there is no `ProjectReference`: their pure sources +(`PortableExecutableExportReader.cs`, `LibraryProbeContract.cs`, `NativeAotLibraryProbeExportNames.cs`, +`NativeAotLibraryProbeExports.cs`) are compiled in as links. The test bytes are the checked-in Lua protection bridge DLL +(a real PE32+ AMD64 image with four named exports), embedded as a resource and patched **in memory** by +`Support/BridgeImage.cs`: an export name overwritten in place (never longer than the original), the COFF `Machine` field +rewritten, the export data directory zeroed, or the byte array truncated. A patched image is only ever parsed; nothing +maps or loads it. + +`LibraryProbeContract.AllowedRuntimeExports` is measured: a local `dotnet publish` of the probe (.NET SDK 10.0.401, +win-x64, Release) exports exactly the two probe names and `DotNetRuntimeDebugHeader`. + +## Promise + +- The reader returns the bridge's four export names and refuses a non-AMD64 image, an image without an export directory + and truncated bytes (`PortableExecutableExportReaderTests`). +- The contract refuses a `CEPlugin_*` export, including one read from patched bytes, a missing probe export and any + unexpected export, and accepts exactly the probe names plus the measured runtime export + (`LibraryProbeContractTests`). +- The probe source declares exactly the two required entry points and no `CEPlugin_*` name + (`NativeAotLibraryProbeExportSurfaceTests`). + +These are C0 evidence of Q41. The C2 evidence is the CI `aot` job's publish and harness transcript, recorded in the +matrix only after a green run. + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests +``` diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/Support/BridgeImage.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/Support/BridgeImage.cs new file mode 100644 index 00000000..a32a384f --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/Support/BridgeImage.cs @@ -0,0 +1,86 @@ +using System.Buffers.Binary; +using System.Text; + +namespace CheatEngine.SDK.NativeAotLoaderHarness.Tests.Support; + +/// +/// Copies of the checked-in Lua protection bridge DLL (a real PE32+ AMD64 image with four named exports), patched in +/// memory to produce the shapes the harness must refuse. A patched image is only ever parsed as bytes: nothing here +/// writes it to disk, maps it or loads it. +/// +internal static class BridgeImage +{ + private const string ResourceName = "CheatEngine.SDK.NativeAotLoaderHarness.Tests.Bridge.dll"; + private const int PeHeaderPointerOffset = 0x3C; + private const int OptionalHeaderOffsetFromSignature = 24; + private const int ExportDirectoryOffsetInOptionalHeader = 112; + private const ushort Pe32PlusMagic = 0x20B; + + /// The bridge's named exports, in the order the export directory lists them. + public static IReadOnlyList ExportNames + { + get; + } = + [ + "cheatengine_sdk_lua_bridge_abi_version", + "cheatengine_sdk_lua_bridge_get_contract", + "cheatengine_sdk_lua_bridge_source_fingerprint", + "cheatengine_sdk_lua_protected" + ]; + + /// A fresh copy of the embedded image. + public static byte[] Load() + { + using Stream stream = typeof(BridgeImage).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"The embedded resource {ResourceName} is missing."); + using MemoryStream copy = new(); + stream.CopyTo(copy); + return copy.ToArray(); + } + + /// Rewrites the COFF Machine field. + public static void SetMachine(byte[] image, ushort machine) + { + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(SignatureOffset(image) + 4), machine); + } + + /// Zeroes the RVA and size of the export data directory (entry 0 of the PE32+ optional header). + public static void ClearExportDirectory(byte[] image) + { + int optionalHeader = SignatureOffset(image) + OptionalHeaderOffsetFromSignature; + if (BinaryPrimitives.ReadUInt16LittleEndian(image.AsSpan(optionalHeader)) != Pe32PlusMagic) + { + throw new InvalidOperationException("The bridge image is not PE32+."); + } + + image.AsSpan(optionalHeader + ExportDirectoryOffsetInOptionalHeader, 8).Clear(); + } + + /// + /// Overwrites the NUL-terminated export name in place with + /// (never longer), padding with NUL bytes. + /// + public static void RenameExport(byte[] image, string existing, string replacement) + { + if (replacement.Length > existing.Length) + { + throw new ArgumentException("A replacement name may not be longer than the original.", nameof(replacement)); + } + + byte[] needle = [.. Encoding.ASCII.GetBytes(existing), 0]; + int offset = image.AsSpan().IndexOf(needle); + if (offset < 0) + { + throw new InvalidOperationException($"The export name '{existing}' is not in the image."); + } + + Span target = image.AsSpan(offset, existing.Length); + target.Clear(); + Encoding.ASCII.GetBytes(replacement).CopyTo(target); + } + + private static int SignatureOffset(byte[] image) + { + return BinaryPrimitives.ReadInt32LittleEndian(image.AsSpan(PeHeaderPointerOffset)); + } +} diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/packages.lock.json b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/packages.lock.json new file mode 100644 index 00000000..fe279b25 --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests/packages.lock.json @@ -0,0 +1,211 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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]" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness/LibraryProbeContract.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness/LibraryProbeContract.cs new file mode 100644 index 00000000..11f91a64 --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness/LibraryProbeContract.cs @@ -0,0 +1,105 @@ +using CheatEngine.SDK.NativeAotLibraryProbe; + +namespace CheatEngine.SDK.NativeAotLoaderHarness; + +/// +/// The exact export surface the NativeAOT library probe may have (scenario Q41): the probe's required names, the +/// runtime exports a NativeAOT shared library carries by itself, and nothing else. A CEPlugin_* name (a +/// classic Cheat Engine native plugin entry point) is refused first and always. +/// +/// +/// was measured, not assumed: dotnet publish of +/// tests/CheatEngine.SDK.NativeAotLibraryProbe with .NET SDK 10.0.401 (win-x64, Release, NativeLib=Shared) +/// produced exactly three named exports, the two probe names and DotNetRuntimeDebugHeader (the NativeAOT +/// runtime's debugger discovery header). A new runtime export after an SDK update fails the harness until it is +/// reviewed and listed here. Pure: no I/O, used by Program and by the unit tests. +/// +internal static class LibraryProbeContract +{ + /// The prefix of the classic Cheat Engine native plugin exports. + internal const string NativePluginPrefix = "CEPlugin_"; + + /// Exports the NativeAOT runtime adds to every shared library, as observed on the pinned SDK. + internal static IReadOnlyList AllowedRuntimeExports + { + get; + } = ["DotNetRuntimeDebugHeader"]; + + /// + /// Returns one message per violation of : a CEPlugin_* export, a missing + /// required probe export, or an export that is neither required nor an allowed runtime export. Empty when the + /// surface is exactly the expected one. + /// + internal static IReadOnlyList FindViolations(IReadOnlyList exportNames) + { + ArgumentNullException.ThrowIfNull(exportNames); + List violations = []; + foreach (string exportName in exportNames) + { + if (exportName.StartsWith(NativePluginPrefix, StringComparison.Ordinal)) + { + violations.Add( + $"The harness refuses a DLL that exposes a Cheat Engine native-plugin entry point ('{exportName}')."); + } + } + + foreach (string requiredName in NativeAotLibraryProbeExportNames.Required) + { + if (!Contains(exportNames, requiredName)) + { + violations.Add($"The file does not expose required fixture export '{requiredName}'."); + } + } + + foreach (string exportName in exportNames) + { + if (!exportName.StartsWith(NativePluginPrefix, StringComparison.Ordinal) && + !Contains(NativeAotLibraryProbeExportNames.Required, exportName) && + !Contains(AllowedRuntimeExports, exportName)) + { + violations.Add($"The file exposes the unexpected export '{exportName}'."); + } + } + + return violations; + } + + /// Throws when is not exactly the expected surface. + /// The surface has at least one violation; the message lists all. + internal static void Validate(IReadOnlyList exportNames) + { + IReadOnlyList violations = FindViolations(exportNames); + if (violations.Count > 0) + { + throw new InvalidOperationException(string.Join(" ", violations)); + } + } + + /// The exports of that are allowed runtime exports, in their order. + internal static IReadOnlyList RuntimeExportsIn(IReadOnlyList exportNames) + { + List runtimeExports = []; + foreach (string exportName in exportNames) + { + if (Contains(AllowedRuntimeExports, exportName)) + { + runtimeExports.Add(exportName); + } + } + + return runtimeExports; + } + + private static bool Contains(IReadOnlyList names, string name) + { + foreach (string candidate in names) + { + if (string.Equals(candidate, name, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs index 01179740..8af8d9b7 100644 --- a/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs @@ -5,13 +5,16 @@ namespace CheatEngine.SDK.NativeAotLoaderHarness; -/// Runs bounded file analysis or process-resident name queries for the SDK-006 NativeAOT library fixture. +/// +/// Runs bounded file analysis or process-resident name queries for the SDK-006 NativeAOT library fixture. Both modes +/// first require the exact export surface of (no CEPlugin_* export, every +/// probe export, nothing unexpected) and print contract=passed; any drift is a non-zero exit. +/// internal static class Program { private const string AnalyzeMode = "--analyze"; private const string LoadMode = "--load"; private const string AcknowledgeProcessResidentLoad = "--acknowledge-process-resident-load"; - private const string NativePluginPrefix = "CEPlugin_"; private const string FixtureFileName = "CheatEngine.SDK.NativeAotLibraryProbe.dll"; /// Runs the requested bounded observation. @@ -42,7 +45,7 @@ private static int Run(string[] arguments) } if (arguments.Length == 2 && string.Equals(arguments[0], LoadMode, StringComparison.Ordinal) && - string.Equals(arguments[1], AcknowledgeProcessResidentLoad, StringComparison.Ordinal)) + string.Equals(arguments[1], AcknowledgeProcessResidentLoad, StringComparison.Ordinal)) { LoadAndQueryNames(); return 0; @@ -65,6 +68,14 @@ private static void Inspect(string libraryPath) { Console.WriteLine($"export.name.{exportName}=present"); } + + foreach (string exportName in inspection.RuntimeExports) + { + Console.WriteLine($"export.runtime.{exportName}=present"); + } + + Console.WriteLine("export.unexpected=none"); + Console.WriteLine("contract=passed"); } private static void LoadAndQueryNames() @@ -96,6 +107,8 @@ private static void LoadAndQueryNames() } // NativeAOT shared-library unload is unsupported. This dedicated process exits after the observation instead. + Console.WriteLine("export.unexpected=none"); + Console.WriteLine("contract=passed"); Console.WriteLine("library.unload=not-attempted"); } @@ -130,8 +143,7 @@ private static LibraryInspection ReadAndValidateProbe(string libraryPath) try { exportNames = PortableExecutableExportReader.ReadExportNames(bytes); - EnsureNoNativePluginExports(exportNames); - EnsureExpectedExports(exportNames); + LibraryProbeContract.Validate(exportNames); } catch { @@ -139,46 +151,11 @@ private static LibraryInspection ReadAndValidateProbe(string libraryPath) throw; } - return new LibraryInspection(fullPath, sha256, fileLock); + return new LibraryInspection(fullPath, sha256, LibraryProbeContract.RuntimeExportsIn(exportNames), fileLock); } - private static void EnsureNoNativePluginExports(IReadOnlyList exportNames) - { - foreach (string exportName in exportNames) - { - if (exportName.StartsWith(NativePluginPrefix, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - "The harness refuses a DLL that exposes a Cheat Engine native-plugin entry point."); - } - } - } - - private static void EnsureExpectedExports(IReadOnlyList exportNames) - { - foreach (string requiredName in NativeAotLibraryProbeExportNames.Required) - { - bool found = false; - foreach (string exportName in exportNames) - { - if (!string.Equals(exportName, requiredName, StringComparison.Ordinal)) - { - continue; - } - - found = true; - break; - } - - if (!found) - { - throw new InvalidOperationException( - $"The file does not expose required fixture export '{requiredName}'."); - } - } - } - - private sealed class LibraryInspection(string path, string sha256, FileStream fileLock) : IDisposable + private sealed class LibraryInspection(string path, string sha256, IReadOnlyList runtimeExports, + FileStream fileLock) : IDisposable { public string Path { @@ -190,6 +167,11 @@ public string Sha256 get; } = sha256; + public IReadOnlyList RuntimeExports + { + get; + } = runtimeExports; + public void Dispose() { fileLock.Dispose(); diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness/README.md b/tests/CheatEngine.SDK.NativeAotLoaderHarness/README.md index f5368c70..61930236 100644 --- a/tests/CheatEngine.SDK.NativeAotLoaderHarness/README.md +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness/README.md @@ -1,19 +1,53 @@ # CheatEngine.SDK.NativeAotLoaderHarness -This managed executable is the bounded SDK-006 harness for a NativeAOT **test library**. It is not a test runner for -Cheat Engine plugins and does not start or attach to Cheat Engine. +A managed executable that inspects, and on explicit request maps, the NativeAOT library probe (scenario Q41). It is not +a test runner for Cheat Engine plugins and never starts or attaches to Cheat Engine. -It has exactly two modes: +## Objective -- `--analyze ` reads PE bytes with `PEReader`. It verifies the PE32+ AMD64 image and the two inert fixture - export names without mapping the DLL. +Prove that a NativeAOT publish of the [library probe](../CheatEngine.SDK.NativeAotLibraryProbe/README.md) has exactly +the export surface it is expected to have, and that such a file can be mapped and queried by name without any code of +it being called. + +## Why it exists + +A NativeAOT plugin DLL is not a supported CheatEngine.SDK profile: Cheat Engine unloads plugins with `FreeLibrary`, +which .NET does not support for NativeAOT libraries (see the +[NativeAOT plugin profile](../../libs/CheatEngine.SDK.Abi/README.md#nativeaot-plugin-profile-f02)). The CI `aot` job +still publishes the probe, so that a publish regression or an unexpected export is seen. Before this harness checked +the surface exactly, it would also have accepted a DLL with a classic `CEPlugin_*` entry point. + +## How it works + +The harness has exactly two modes: + +- `--analyze ` reads the PE bytes with `PEReader`. It verifies the PE32+ AMD64 image and the export names + without mapping the DLL. - `--load --acknowledge-process-resident-load` has no file-path argument. It derives the fixed fixture file name from - its own published directory, locks it against replacement, then maps it in the short-lived harness process. It - performs name queries only; it neither invokes an export nor frees the module. + its own published directory, locks the file against replacement, then maps it in the short-lived harness process. It + queries the two names only; it neither invokes an export nor frees the module. + +Both modes first require the exact export surface of `LibraryProbeContract`: no `CEPlugin_` export (a classic Cheat +Engine plugin entry point), both probe exports, and nothing else except the NativeAOT runtime's own +`DotNetRuntimeDebugHeader`, the one extra export a local `dotnet publish` of the probe produced. Any drift exits non-zero +with `harness.error=` naming every violation; a conforming file prints `export.runtime.*`, `export.unexpected=none` and +`contract=passed`. The load mode therefore refuses any file that has a `CEPlugin_` export, and it cannot be pointed at +an arbitrary path or a production plugin: Windows loader initialization can run while a DLL is mapped, even when no +export is invoked. -The load mode refuses any file that has a `CEPlugin_` export. It cannot be pointed at an arbitrary path or production -plugin: Windows loader initialization can execute while a DLL is mapped even when no export is invoked. Publish both -profile components into the same new output directory, then invoke the published harness: +`library.unload=not-attempted` is an intentional result. NativeAOT shared libraries do not support `FreeLibrary` or +`dlclose` unloading, so process termination is the boundary of this observation. + +## Promise + +- The contract and the PE reader are unit-tested without publishing anything + ([`tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests`](../CheatEngine.SDK.NativeAotLoaderHarness.Tests/README.md), + Q41 at level C0): a `CEPlugin_*` export, a missing probe export and any unexpected export are refused. +- The CI `aot` job's publish and harness transcript are the C2 evidence of Q41, recorded only after a green run. + +## Run the tests + +Publish both components into the same new output directory, then run the published harness: ```powershell dotnet publish tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj -c Release -o artifacts/nativeaot-loader-profile @@ -21,8 +55,5 @@ dotnet publish tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.Nati artifacts/nativeaot-loader-profile/CheatEngine.SDK.NativeAotLoaderHarness.exe --analyze artifacts/nativeaot-loader-profile/CheatEngine.SDK.NativeAotLibraryProbe.dll artifacts/nativeaot-loader-profile/CheatEngine.SDK.NativeAotLoaderHarness.exe --load --acknowledge-process-resident-load +dotnet test --project tests/CheatEngine.SDK.NativeAotLoaderHarness.Tests ``` - -`library.unload=not-attempted` is an intentional result. NativeAOT shared libraries do not support `FreeLibrary`/ -`dlclose` unloading, so process termination is the boundary for this observation. See -[ADR-006](../../documentations/engineering/ADR-006-nativeaot-plugin-loader-profile.md). diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness/packages.lock.json b/tests/CheatEngine.SDK.NativeAotLoaderHarness/packages.lock.json new file mode 100644 index 00000000..254741a9 --- /dev/null +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness/packages.lock.json @@ -0,0 +1,20 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + } + }, + "net10.0/win-x64": {} + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj b/tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj new file mode 100644 index 00000000..20ad4d18 --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj @@ -0,0 +1,27 @@ + + + + + Exe + QualificationTarget + win-x64;win-x86 + true + true + true + true + false + + true + + + diff --git a/tests/CheatEngine.SDK.QualificationTarget/ImageScanner.cs b/tests/CheatEngine.SDK.QualificationTarget/ImageScanner.cs new file mode 100644 index 00000000..93c2a56c --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/ImageScanner.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +namespace QualificationTarget; + +/// +/// Counts the occurrences of a byte pattern in memory the process owns: its main image (section by section, so only +/// mapped, readable pages are read) or a pinned heap region. The counts are the ground truth the qualification driver +/// compares a Cheat Engine scan with. +/// +internal static unsafe class ImageScanner +{ + private const uint ImageScnMemRead = 0x4000_0000; + + /// The base address and mapped size of the executable image. + internal static (nint Base, int Size) MainImage() + { + using Process process = Process.GetCurrentProcess(); + ProcessModule module = process.MainModule + ?? throw new InvalidOperationException("The main module is not available."); + return (module.BaseAddress, module.ModuleMemorySize); + } + + /// Counts in every readable section of the image at . + internal static int CountInImage(nint imageBase, ReadOnlySpan pattern) + { + byte* image = (byte*) imageBase; + int peHeader = *(int*) (image + 0x3C); + byte* ntHeaders = image + peHeader; + if (*(uint*) ntHeaders != 0x0000_4550) + { + throw new InvalidOperationException("The main module does not start with a PE header."); + } + + ushort sectionCount = *(ushort*) (ntHeaders + 6); + ushort optionalHeaderSize = *(ushort*) (ntHeaders + 20); + byte* sections = ntHeaders + 24 + optionalHeaderSize; + int count = 0; + for (int index = 0; index < sectionCount; index++) + { + byte* section = sections + (index * 40); + uint virtualSize = *(uint*) (section + 8); + uint virtualAddress = *(uint*) (section + 12); + uint characteristics = *(uint*) (section + 36); + if ((characteristics & ImageScnMemRead) != 0 && virtualSize > 0) + { + count += Count(new ReadOnlySpan(image + virtualAddress, checked((int) virtualSize)), pattern); + } + } + + return count; + } + + /// Counts every start position of in . + internal static int Count(ReadOnlySpan memory, ReadOnlySpan pattern) + { + int count = 0; + int offset = 0; + while (offset <= memory.Length - pattern.Length) + { + int found = memory[offset..].IndexOf(pattern); + if (found < 0) + { + break; + } + + count++; + offset += found + 1; + } + + return count; + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/Program.cs b/tests/CheatEngine.SDK.QualificationTarget/Program.cs new file mode 100644 index 00000000..33cd9243 --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/Program.cs @@ -0,0 +1,98 @@ +using System.Globalization; + +namespace QualificationTarget; + +/// +/// Entry point. Arguments: --heap-copies N (1-1024, default 8), --repetitions M (1-1000000, default +/// 20000) and --ready-file PATH (optional copy of the ready record). Standard input commands: step +/// advances the value cells, exit (or end of input) ends the process with exit code 0. +/// +internal static class Program +{ + private const int DefaultHeapCopies = 8; + private const int DefaultRepetitions = 20_000; + + private static int Main(string[] args) + { + if (!TryParse(args, out int heapCopies, out int repetitions, out string? readyFile, out string? error)) + { + Console.Error.WriteLine(error); + return 2; + } + + TargetMemory memory = new(heapCopies, repetitions); + (nint imageBase, int imageSize) = ImageScanner.MainImage(); + int moduleMarkers = ImageScanner.CountInImage(imageBase, TargetLayout.Marker); + string ready = ReadyRecord.Create(memory, imageBase, imageSize, moduleMarkers); + Console.Out.WriteLine(ready); + Console.Out.Flush(); + if (readyFile is not null) + { + File.WriteAllText(readyFile, ready + Environment.NewLine); + } + + return RunCommands(memory); + } + + private static int RunCommands(TargetMemory memory) + { + while (Console.In.ReadLine() is { } line) + { + switch (line.Trim()) + { + case "exit": + return 0; + case "step": + memory.Step(); + Console.Out.WriteLine(ReadyRecord.Step(memory)); + break; + case "": + break; + default: + Console.Out.WriteLine(ReadyRecord.Error("Unknown command; use step or exit.")); + break; + } + + Console.Out.Flush(); + } + + // End of input: the runner closed the pipe. + return 0; + } + + private static bool TryParse(string[] args, out int heapCopies, out int repetitions, out string? readyFile, + out string? error) + { + heapCopies = DefaultHeapCopies; + repetitions = DefaultRepetitions; + readyFile = null; + error = null; + for (int index = 0; index < args.Length; index++) + { + string? value = index + 1 < args.Length ? args[index + 1] : null; + switch (args[index]) + { + case "--heap-copies" when TryRange(value, 1, 1024, out heapCopies): + case "--repetitions" when TryRange(value, 1, 1_000_000, out repetitions): + index++; + break; + case "--ready-file" when !string.IsNullOrWhiteSpace(value): + readyFile = value; + index++; + break; + default: + error = "Invalid argument '" + args[index] + + "'. Use --heap-copies 1-1024, --repetitions 1-1000000 and --ready-file PATH."; + return false; + } + } + + return true; + } + + private static bool TryRange(string? text, int minimum, int maximum, out int value) + { + return int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value) && value >= minimum && + value <= maximum; + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/README.md b/tests/CheatEngine.SDK.QualificationTarget/README.md new file mode 100644 index 00000000..e6f3620c --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/README.md @@ -0,0 +1,77 @@ +# CheatEngine.SDK.QualificationTarget + +A small, deterministic console program that the exact-host qualification runs attach Cheat Engine to. It is a lab +target, not a test project, not a plugin and never a package. + +## Objective + +Give the scan, memory and target scenarios of the audit's qualification register (Q25-Q32) +a target whose content is known in advance on x64 and on x86: a pattern inside the executable image, copies of it on the +heap, a region with many matches of a second pattern, and two value cells that change on command. Every count the +target reports is measured by the target itself, so a Cheat Engine scan can be compared with ground truth. + +## Why it exists + +The Phase-0 host spike used the tutorial programs shipped with Cheat Engine, whose global match counts changed with the +modules loaded (35 against 31 for the same pattern). A qualification result needs a target whose layout the repository +controls and whose hash each receipt records. The machine that runs the qualification has no x86 .NET 10 runtime (only +x86 .NET 6), so the target is published with Native AOT, which supports win-x86 since .NET 9 +([Native AOT platform restrictions](https://learn.microsoft.com/dotnet/core/deploying/native-aot/#platform-architecture-restrictions)). + +## How it works + +| File | Content | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `TargetLayout.cs` | The 16-byte module-resident marker (constant data of the image), the 8-byte many-results pattern (computed at run time, so it is not in the image), the initial values. | +| `TargetMemory.cs` | Pinned heap arrays: N marker copies 64 bytes apart, M back-to-back repetitions of the second pattern, and the Int32/Int64 cells. | +| `ImageScanner.cs` | Counts a pattern in every readable section of the executable image, or in a heap region. | +| `ReadyRecord.cs` | The one-line JSON records, written with `Utf8JsonWriter` (no reflection, trim-safe). | +| `Program.cs` | Arguments, the ready record, and the `step` / `exit` command loop on standard input. | + +On start the target prints one line and waits for commands: + +```json +{"schema":"cheatengine-qualification-target/v0","pid":46920,"arch":"x64","pointerSize":8,"imageBase":"0x7FF7B65F0000","imageSize":1617920, + "regions":[{"id":"module-marker","base":"0x7FF7B65F0000","length":1617920,"pattern":"C3 5D 4B 51 54 9A 7E 21 E8 0F B2 66 13 D7 4C A5","count":2}, + {"id":"heap-marker","base":"0x1E288001588","length":512,"pattern":"C3 5D 4B 51 54 9A 7E 21 E8 0F B2 66 13 D7 4C A5","count":8}, + {"id":"many-results","base":"0x1E2880017A0","length":160000,"pattern":"8F A2 F9 0C 23 76 8D A0","count":20000}], + "values":[{"id":"int32","address":"0x1E2880288B8","value":1573167383},{"id":"int64","address":"0x1E2880288C0","value":81985529216486895}]} +``` + +(Shown wrapped; the real record is one line and the addresses differ per run.) The `module-marker` count is measured +over the image's readable sections; the win-x64 and win-x86 images of the first build held it twice, because the +compiler may emit a constant more than once, which is why the count is measured and never assumed. The heap counts are +measured over their regions. Patterns that must be absent are chosen by the qualification driver, never stored in the +target. + +| Input | Effect | +|------------------------------|-----------------------------------------------------------------------------------------| +| `--heap-copies N` | Number of heap marker copies, 1–1024 (default 8). | +| `--repetitions M` | Number of many-results repetitions, 1–1000000 (default 20000). | +| `--ready-file PATH` | Also writes the ready record to PATH; the only file the target ever writes. | +| `step` on standard input | Adds one to both value cells and prints `{"event":"step","int32":…,"int64":…}`. | +| `exit` or end of input | Exits with code 0. | + +The target opens no network connection, requests no elevation, reads no file and loads no SDK assembly. + +## Promise + +- The target is in the solution, publishes with Native AOT for `win-x64` and `win-x86`, is not a test module and never + packs (`QualificationHarnessShapeTests.QualificationTarget_is_in_the_solution_and_publishes_native_aot_for_x64_and_x86`, + `QualificationHarnessShapeTests.Qualification_harnesses_are_not_test_modules_and_never_pack`). +- It references no SDK project, so a qualification observes the SDK only from the plugin side + (`QualificationHarnessShapeTests.QualificationTarget_references_no_SDK_project`). +- The ready record and the counts are produced by the published executable; a qualification run publishes it and + records its SHA-256 in the evidence it gathers. + +## Run the tests + +The project has no test module of its own; its shape is checked by +`dotnet test --project tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj`. To try it: + +```powershell +dotnet publish tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj -c Release -r win-x64 -o +``` + +then run `/CheatEngine.SDK.QualificationTarget.exe`, type `step`, then `exit`. Use `-r win-x86` for the x86 +build; both need the MSVC linker that Native AOT uses on Windows. diff --git a/tests/CheatEngine.SDK.QualificationTarget/ReadyRecord.cs b/tests/CheatEngine.SDK.QualificationTarget/ReadyRecord.cs new file mode 100644 index 00000000..c50ffe1b --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/ReadyRecord.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace QualificationTarget; + +/// +/// The one-line JSON records the target prints: the ready record (schema ) on start, and one +/// step record per step command. Written with , so nothing depends on +/// reflection and the Native AOT build stays trim-safe. +/// +internal static class ReadyRecord +{ + internal const string Schema = "cheatengine-qualification-target/v0"; + + internal static string Create(TargetMemory memory, nint imageBase, int imageSize, int moduleMarkerCount) + { + string marker = TargetLayout.ToPattern(TargetLayout.Marker); + return Write(writer => + { + writer.WriteString("schema", Schema); + writer.WriteNumber("pid", Environment.ProcessId); + writer.WriteString("arch", RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant()); + writer.WriteNumber("pointerSize", IntPtr.Size); + writer.WriteString("imageBase", Hex(imageBase)); + writer.WriteNumber("imageSize", imageSize); + writer.WriteStartArray("regions"); + Region(writer, "module-marker", imageBase, imageSize, marker, moduleMarkerCount); + Region(writer, "heap-marker", memory.HeapMarkersAddress, memory.HeapMarkers.Length, marker, + ImageScanner.Count(memory.HeapMarkers, TargetLayout.Marker)); + Region(writer, "many-results", memory.RepeatedAddress, memory.Repeated.Length, + TargetLayout.ToPattern(memory.RepeatedPattern), ImageScanner.Count(memory.Repeated, memory.RepeatedPattern)); + writer.WriteEndArray(); + writer.WriteStartArray("values"); + Value(writer, "int32", memory.Int32Address, memory.Int32); + Value(writer, "int64", memory.Int64Address, memory.Int64); + writer.WriteEndArray(); + }); + } + + internal static string Step(TargetMemory memory) + { + return Write(writer => + { + writer.WriteString("event", "step"); + writer.WriteNumber("int32", memory.Int32); + writer.WriteNumber("int64", memory.Int64); + }); + } + + internal static string Error(string message) + { + return Write(writer => + { + writer.WriteString("event", "error"); + writer.WriteString("message", message); + }); + } + + private static void Region(Utf8JsonWriter writer, string id, nint address, int length, string pattern, int count) + { + writer.WriteStartObject(); + writer.WriteString("id", id); + writer.WriteString("base", Hex(address)); + writer.WriteNumber("length", length); + writer.WriteString("pattern", pattern); + writer.WriteNumber("count", count); + writer.WriteEndObject(); + } + + private static void Value(Utf8JsonWriter writer, string id, nint address, long value) + { + writer.WriteStartObject(); + writer.WriteString("id", id); + writer.WriteString("address", Hex(address)); + writer.WriteNumber("value", value); + writer.WriteEndObject(); + } + + private static string Hex(nint address) + { + return "0x" + ((nuint) address).ToString("X", CultureInfo.InvariantCulture); + } + + private static string Write(Action body) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + body(writer); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/TargetLayout.cs b/tests/CheatEngine.SDK.QualificationTarget/TargetLayout.cs new file mode 100644 index 00000000..530e7c5d --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/TargetLayout.cs @@ -0,0 +1,63 @@ +using System.Text; + +namespace QualificationTarget; + +/// +/// The fixed byte patterns and value cells of the target. Every value is a constant, so two runs of the same build +/// expose the same patterns and the same initial values; only addresses and the PID differ. +/// +internal static class TargetLayout +{ + /// Distance between two heap copies of the marker, so that copies never touch. + internal const int HeapCopyStride = 64; + + /// Length of the many-results pattern. + internal const int RepeatedPatternLength = 8; + + /// Initial value of the Int32 cell (0x5DC3A117); step adds one. + internal const int InitialInt32 = 1_573_167_383; + + /// Initial value of the Int64 cell (0x0123456789ABCDEF); step adds one. + internal const long InitialInt64 = 81_985_529_216_486_895; + + /// + /// The module-resident marker. A over a constant array is emitted as read-only data + /// of the image, so the executable itself holds it; the heap copies are made from it at run time. + /// + internal static ReadOnlySpan Marker => + [ + 0xC3, 0x5D, 0x4B, 0x51, 0x54, 0x9A, 0x7E, 0x21, 0xE8, 0x0F, 0xB2, 0x66, 0x13, 0xD7, 0x4C, 0xA5 + ]; + + /// + /// The many-results pattern. It is computed at run time, so its bytes are not stored in the image and every match + /// of it lies in the heap region. All eight bytes differ, so matches cannot overlap. + /// + internal static byte[] CreateRepeatedPattern() + { + byte[] pattern = new byte[RepeatedPatternLength]; + for (int index = 0; index < pattern.Length; index++) + { + pattern[index] = (byte) (0x9E ^ ((index * 0x2B) + 0x11)); + } + + return pattern; + } + + /// Formats bytes as the space-separated upper-case hex text of an AOB pattern. + internal static string ToPattern(ReadOnlySpan bytes) + { + StringBuilder builder = new(bytes.Length * 3); + foreach (byte value in bytes) + { + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append(value.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/TargetMemory.cs b/tests/CheatEngine.SDK.QualificationTarget/TargetMemory.cs new file mode 100644 index 00000000..b7958f02 --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/TargetMemory.cs @@ -0,0 +1,85 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QualificationTarget; + +/// +/// The heap side of the target: pinned arrays, so every address in the ready record stays valid for the whole run. +/// Allocated once at start; nothing is freed before exit. +/// +internal sealed unsafe class TargetMemory +{ + private readonly byte[] _heapMarkers; + private readonly byte[] _repeated; + private readonly long[] _values; + + internal TargetMemory(int heapCopies, int repetitions) + { + HeapCopies = heapCopies; + Repetitions = repetitions; + RepeatedPattern = TargetLayout.CreateRepeatedPattern(); + + _heapMarkers = GC.AllocateArray(heapCopies * TargetLayout.HeapCopyStride, pinned: true); + for (int copy = 0; copy < heapCopies; copy++) + { + TargetLayout.Marker.CopyTo(_heapMarkers.AsSpan(copy * TargetLayout.HeapCopyStride)); + } + + _repeated = GC.AllocateArray(repetitions * TargetLayout.RepeatedPatternLength, pinned: true); + for (int repetition = 0; repetition < repetitions; repetition++) + { + RepeatedPattern.CopyTo(_repeated.AsSpan(repetition * TargetLayout.RepeatedPatternLength)); + } + + // Slot 0 holds the Int32 cell in its low four bytes, slot 1 the Int64 cell; both stay naturally aligned. + _values = GC.AllocateArray(2, pinned: true); + Int32 = TargetLayout.InitialInt32; + Int64 = TargetLayout.InitialInt64; + } + + internal int HeapCopies + { + get; + } + + internal int Repetitions + { + get; + } + + internal byte[] RepeatedPattern + { + get; + } + + internal ReadOnlySpan HeapMarkers => _heapMarkers; + + internal ReadOnlySpan Repeated => _repeated; + + internal nint HeapMarkersAddress => (nint) Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(_heapMarkers)); + + internal nint RepeatedAddress => (nint) Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(_repeated)); + + internal nint Int32Address => (nint) Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(_values)); + + internal nint Int64Address => Int32Address + sizeof(long); + + internal int Int32 + { + get => Volatile.Read(ref Unsafe.As(ref _values[0])); + private set => Volatile.Write(ref Unsafe.As(ref _values[0]), value); + } + + internal long Int64 + { + get => Volatile.Read(ref _values[1]); + private set => Volatile.Write(ref _values[1], value); + } + + /// Advances both value cells by one, the change a next scan looks for. + internal void Step() + { + Int32 = unchecked(Int32 + 1); + Int64 = unchecked(Int64 + 1); + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/packages.lock.json b/tests/CheatEngine.SDK.QualificationTarget/packages.lock.json new file mode 100644 index 00000000..12907abb --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/packages.lock.json @@ -0,0 +1,63 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + } + }, + "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==" + } + }, + "net10.0/win-x86": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "AawF393Q+VkdrnrnI1gu612zh5iqpa1AGSvnKCQ3IkMgSKJXIQbO1sXYRgEzOU4f0cPZ6MaCCF29xZSGWlmbuQ==", + "dependencies": { + "runtime.win-x86.Microsoft.DotNet.ILCompiler": "10.0.12" + } + }, + "runtime.win-x86.Microsoft.DotNet.ILCompiler": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "NTcKhVn2pZ2L+pOxi9GM9tL461gK61H0g+mHIz30f7SHqk1qTWrW4Fy66RItnjeK/K5RFr64hlFSEPWFbfHksg==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Repository.Tests/Abi/AbiRouteSeparationTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Abi/AbiRouteSeparationTests.cs new file mode 100644 index 00000000..11e06d4a --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Abi/AbiRouteSeparationTests.cs @@ -0,0 +1,104 @@ +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; +using CheatEngine.SDK.Repository.Tests.SourceScanning; + +namespace CheatEngine.SDK.Repository.Tests.Abi; + +/// +/// The two exported-function tables stay separate routes (audit A17-02, AX04-11, AX05-06, A11-21): a managed plugin +/// receives the 48-byte ManagedExportedFunctions, a classic native plugin the 159-slot table whose first 144 +/// bytes ExportedFunctionsPrefix maps, and no production code converts one into the other or reaches the +/// classic prefix outside the classic dispatcher and readers. Comments and literals are ignored. +/// +public sealed partial class AbiRouteSeparationTests +{ + private const string Managed = "ManagedExportedFunctions"; + private const string Prefix = "ExportedFunctionsPrefix"; + + /// The declaration of the prefix and the only code allowed to consume it. + private static readonly string[] PrefixConsumers = + [ + "libs/CheatEngine.SDK.Abi/Native/ExportedFunctionsPrefix.cs", + "libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs", + "libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsPrefixReader.cs", + "libs/CheatEngine.SDK.Abi/Native/ClassicExportedFunctionsSlotReader.cs" + ]; + + [Fact] + public void Managed_exports_are_never_converted_to_the_classic_prefix() + { + List problems = []; + foreach ((string file, string code) in ProductionCode()) + { + if (Identifier(Managed).IsMatch(code) && Identifier(Prefix).IsMatch(code)) + { + problems.Add($"{file} names both {Managed} and {Prefix}: the managed and classic tables are separate routes."); + } + + foreach (Match cast in PrefixPointerCast().Matches(code)) + { + problems.Add($"{file}:{CSharpCode.LineOf(code, cast.Index)} casts to {Prefix}*; read the prefix with ClassicExportedFunctionsPrefixReader."); + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + public void Only_the_classic_dispatcher_and_readers_consume_the_prefix() + { + string[] consumers = + [ + .. ProductionCode().Where(static source => Identifier(Prefix).IsMatch(source.Code)) + .Select(static source => source.File) + ]; + + Assert.All(consumers, file => Assert.Contains(file, PrefixConsumers, StringComparer.Ordinal)); + Assert.Contains("libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs", consumers, StringComparer.Ordinal); + Assert.All(PrefixConsumers, file => Assert.True(RepositoryDocument.Exists(file), $"{file} no longer exists.")); + Assert.DoesNotContain(consumers, static file => file.StartsWith("libs/CheatEngine.SDK.Hosting/", StringComparison.Ordinal)); + } + + [Fact] + public void The_rules_see_code_and_ignore_comments_and_strings() + { + const string Documented = """ + /// Unlike ManagedExportedFunctions, the ExportedFunctionsPrefix is classic. + class C { string s = "ExportedFunctionsPrefix*"; } + """; + const string Converting = "unsafe class C { void M(ManagedExportedFunctions* m) { var p = (ExportedFunctionsPrefix*) m; } }"; + + string documented = CSharpCode.BlankCommentsAndLiterals(Documented); + string converting = CSharpCode.BlankCommentsAndLiterals(Converting); + + Assert.DoesNotMatch(Identifier(Prefix), documented); + Assert.True(Identifier(Managed).IsMatch(converting) && Identifier(Prefix).IsMatch(converting)); + Assert.Single(PrefixPointerCast().Matches(converting)); + } + + private static IEnumerable<(string File, string Code)> ProductionCode() + { + return RepositoryRoot.EnumerateSourceFiles("*.cs") + .Where(static file => file.StartsWith("libs/", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .Select(static file => (file, CSharpCode.BlankCommentsAndLiterals( + File.ReadAllText(RepositoryDocument.Absolute(file), Encoding.UTF8)))); + } + + private static Regex Identifier(string name) + { + return string.Equals(name, Managed, StringComparison.Ordinal) ? ManagedIdentifier() : PrefixIdentifier(); + } + + [GeneratedRegex(@"\bManagedExportedFunctions\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ManagedIdentifier(); + + [GeneratedRegex(@"\bExportedFunctionsPrefix\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PrefixIdentifier(); + + [GeneratedRegex(@"\(\s*(?:[\w.]+\.)?ExportedFunctionsPrefix\s*\*\s*\)", RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 1000)] + private static partial Regex PrefixPointerCast(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Abi/ClassicSlotRegistryContract.cs b/tests/CheatEngine.SDK.Repository.Tests/Abi/ClassicSlotRegistryContract.cs new file mode 100644 index 00000000..7b7a5a91 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Abi/ClassicSlotRegistryContract.cs @@ -0,0 +1,95 @@ +namespace CheatEngine.SDK.Repository.Tests.Abi; + +/// +/// The vocabulary of the committed classic slot registry, as the C# checks in this folder use it. There is no JSON +/// Schema file: the registry is test-owned data (never a top-level docs/ folder), so every shape rule these +/// constants describe is enforced directly by . +/// +internal static class ClassicSlotRegistryContract +{ + internal const string RegistryPath = "tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json"; + internal const string Kind = "cheatengine-classic-slot-registry/v0"; + internal const string UpstreamCommit = "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37"; + internal const string QualifiableProfile = "ce-7.7.0.10621-x64-managed-hostfxr"; + internal const string DocumentaryProfile = "ce-public-src-ec45d5f"; + + internal static readonly string[] TopLevelRequired = + ["schema", "generator", "sources", "contract", "profileStatus", "slots", "divergences", "callbackCategories"]; + + internal static readonly string[] SourceRequired = + ["id", "role", "repository", "commit", "path", "sha256", "lineRanges", "installed"]; + + internal static readonly string[] ContractRequired = + [ + "pluginContractVersion", "hostTableType", "fieldCount", "pointerFieldCount", "x64TableSize", "sdkDirectPrefixSize", + "sdkPrefixSlots" + ]; + + internal static readonly string[] SlotRequired = + [ + "slot", "x64Offset", "width", "minDeclaredSize", "section", "hostField", "hostAssignment", "hostImplementation", + "nature", "indirection", "callingConvention", "nullability", "mirrors", "divergenceRefs", "sdkExposure", "sdkField", + "facadeStatus", "ownership", "luaEquivalent", "catalogSurfaceId", "evidenceKind", "layoutEvidenceKind", + "hostProfileStatus", "qualification" + ]; + + internal static readonly string[] DivergenceRequired = + [ + "id", "subject", "slots", "records", "callbacks", "headerC", "hostPascal", "decision", "auditRef", "origin", + "evidenceKind" + ]; + + internal static readonly string[] CallbackCategoryRequired = + [ + "pluginType", "name", "hostForm", "headerForm", "additionalForms", "versionVariants", "versionDependent", "context", + "synchronousDecision", "sdkRecord", "sdkCallbackTyped", "shapeTest", "note", "evidenceKind" + ]; + + internal static readonly string[] SourceIds = ["plugin-pas", "pluginexports-pas", "cepluginsdk-h", "cepluginsdk-pas"]; + + internal static readonly string[] SourceRoles = ["Authority", "HostImplementation", "MirrorC", "MirrorPascal"]; + + internal static readonly string[] InstalledRelations = ["Identical", "CommentOnlyDifference", "Different"]; + + internal static readonly string[] Sections = ["Base", "V2", "V3", "V4", "V5"]; + + internal static readonly string[] AssignmentKinds = ["Size", "FunctionAddress", "CellAddress", "VariableAddress", "Nil"]; + + internal static readonly string[] Natures = + ["Int32", "Function", "FunctionPointerCell", "DataCell", "ObjectRefCell", "Unknown"]; + + internal static readonly string[] CallingConventions = + ["Stdcall", "Fastcall", "Register", "Cdecl", "NotApplicable", "Unknown"]; + + internal static readonly string[] Nullabilities = ["NonNull", "NilAssigned", "NotApplicable", "Unknown"]; + + internal static readonly string[] SdkExposures = ["PrefixTyped", "PrefixOpaque", "None"]; + + internal static readonly string[] FacadeStatuses = ["PrefixOnly", "Deferred"]; + + internal static readonly string[] Ownerships = ["Borrowed", "NotApplicable", "Unknown"]; + + internal static readonly string[] SlotEvidenceKinds = ["ObservedSource", "ToQualify"]; + + internal static readonly string[] ProfileStatuses = ["SourceOnly", "NotObserved"]; + + internal static readonly string[] DivergenceOrigins = ["Audit", "LotObservation"]; + + internal static readonly string[] CallbackContexts = ["MainThread", "WorkerThread", "Unknown"]; + + /// The evidence vocabulary of shared-contracts section 2.0 (audit ch. 01 plus the repository's terms). + internal static readonly string[] EvidenceKinds = + [ + "ObservedSource", "DeclaredRepo", "Deduced", "ToQualify", "ProposedDecision", "ObservedHost", "ExactBinary", + "ExactInstalledFile", "PinnedUpstream", "ObservedLive", "Inferred", "Unknown" + ]; + + /// Pinned upstream inputs of the importer (raw bytes as served, CRLF): source id to SHA-256. + internal static readonly Dictionary PinnedSourceSha256 = new(StringComparer.Ordinal) + { + ["plugin-pas"] = "358f51a39ad14d00ecba3c9137f440152d4ab85f1d2498068fa81fca906d09db", + ["pluginexports-pas"] = "0192ce02441be2fdf080ba829eae3d8edf6cb24b83308154d5068ca642e2c2ea", + ["cepluginsdk-h"] = "b6500df1e94d7bb011b38e173b2603197b7a1f304496d751ede82e57e36e532f", + ["cepluginsdk-pas"] = "cda5269f441120e5a3bff2f87e289cd71de9158ca2a619c7d0a734eb98ee6052" + }; +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Abi/ClassicSlotRegistryDocumentTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Abi/ClassicSlotRegistryDocumentTests.cs new file mode 100644 index 00000000..a1ca643d --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Abi/ClassicSlotRegistryDocumentTests.cs @@ -0,0 +1,475 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.SourceScanning; + +namespace CheatEngine.SDK.Repository.Tests.Abi; + +/// +/// The committed classic slot registry (test-owned data, never a top-level docs/ folder): the 159-slot +/// contract of the classic ExportedFunctions table, whose authority is the pinned plugin.pas (audit +/// annex 04), with the C and Pascal mirrors as secondary columns and the divergence register of annex 05 (audit +/// F09, A23-F09-1, AX04-*, AX05-*). These tests read only the committed document, never the Cheat Engine sources or +/// an installation, and enforce its shape with plain C# assertions (there is no JSON Schema infrastructure left in +/// this repository). +/// +public sealed partial class ClassicSlotRegistryDocumentTests +{ + private const string ShapeTestsPath = "tests/CheatEngine.SDK.Abi.Tests/Native/PluginCallbackShapeTests.cs"; + + /// Host nil assignments of TPluginHandler.create (audit annex 04 and brief appendix A). + private static readonly int[] NilSlots = [14, 28, 29, 40, 43, 46, 49, 50, 51, 52, 53]; + + /// Host @@X assignments: slots 18-27, 30-35 and 73-81. + private static readonly int[] CellSlots = + [ + .. Enumerable.Range(18, 10), .. Enumerable.Range(30, 6), .. Enumerable.Range(73, 9) + ]; + + private static readonly string[] AuditDivergences = ["D01", "D02", "D03", "D04", "D05", "D06", "D07", "D08", "D09", "D10"]; + + private static JsonElement Registry => RepositoryDocument.LoadJson(ClassicSlotRegistryContract.RegistryPath); + + private static JsonElement[] Slots => [.. Registry.GetProperty("slots").EnumerateArray()]; + + [Fact] + public void Registry_top_level_and_nested_objects_have_the_expected_required_properties() + { + JsonElement root = Registry; + AssertProperties(ClassicSlotRegistryContract.TopLevelRequired, root); + AssertProperties(ClassicSlotRegistryContract.SourceRequired, root.GetProperty("sources")[0]); + AssertProperties(ClassicSlotRegistryContract.ContractRequired, root.GetProperty("contract")); + AssertProperties(ClassicSlotRegistryContract.SlotRequired, Slots[0]); + AssertProperties(ClassicSlotRegistryContract.DivergenceRequired, root.GetProperty("divergences")[0]); + AssertProperties(ClassicSlotRegistryContract.CallbackCategoryRequired, root.GetProperty("callbackCategories")[0]); + + Assert.All(root.GetProperty("sources").EnumerateArray(), + source => AssertProperties(ClassicSlotRegistryContract.SourceRequired, source)); + Assert.All(Slots, slot => AssertProperties(ClassicSlotRegistryContract.SlotRequired, slot)); + Assert.All(root.GetProperty("divergences").EnumerateArray(), + divergence => AssertProperties(ClassicSlotRegistryContract.DivergenceRequired, divergence)); + Assert.All(root.GetProperty("callbackCategories").EnumerateArray(), + category => AssertProperties(ClassicSlotRegistryContract.CallbackCategoryRequired, category)); + + Assert.Equal(ClassicSlotRegistryContract.Kind, root.GetProperty("schema").GetString()); + Assert.All(root.GetProperty("sources").EnumerateArray(), source => + Assert.Contains(source.GetProperty("role").GetString(), ClassicSlotRegistryContract.SourceRoles, StringComparer.Ordinal)); + Assert.All(Slots, slot => + { + Assert.Contains(slot.GetProperty("section").GetString(), ClassicSlotRegistryContract.Sections, StringComparer.Ordinal); + Assert.Contains(slot.GetProperty("hostAssignment").GetProperty("kind").GetString(), ClassicSlotRegistryContract.AssignmentKinds, StringComparer.Ordinal); + Assert.Contains(slot.GetProperty("nature").GetString(), ClassicSlotRegistryContract.Natures, StringComparer.Ordinal); + Assert.Contains(slot.GetProperty("callingConvention").GetString(), ClassicSlotRegistryContract.CallingConventions, StringComparer.Ordinal); + Assert.Contains(slot.GetProperty("nullability").GetString(), ClassicSlotRegistryContract.Nullabilities, StringComparer.Ordinal); + Assert.Contains(slot.GetProperty("sdkExposure").GetString(), ClassicSlotRegistryContract.SdkExposures, StringComparer.Ordinal); + Assert.Contains(slot.GetProperty("facadeStatus").GetString(), ClassicSlotRegistryContract.FacadeStatuses, StringComparer.Ordinal); + Assert.Contains(slot.GetProperty("ownership").GetString(), ClassicSlotRegistryContract.Ownerships, StringComparer.Ordinal); + Assert.Contains(slot.GetProperty("evidenceKind").GetString(), ClassicSlotRegistryContract.SlotEvidenceKinds, StringComparer.Ordinal); + }); + } + + [Fact] + public void Registry_lists_one_int_and_158_pointer_slots_with_contiguous_x64_offsets_and_1272_bytes() + { + JsonElement[] slots = Slots; + JsonElement contract = Registry.GetProperty("contract"); + + Assert.Equal(159, slots.Length); + Assert.Equal(159, contract.GetProperty("fieldCount").GetInt32()); + Assert.Equal(158, contract.GetProperty("pointerFieldCount").GetInt32()); + Assert.Equal(1272, contract.GetProperty("x64TableSize").GetInt32()); + Assert.Equal(6, contract.GetProperty("pluginContractVersion").GetInt32()); + Assert.Equal("TExportedFunctions5", contract.GetProperty("hostTableType").GetString()); + + for (int index = 0; index < slots.Length; index++) + { + JsonElement slot = slots[index]; + Assert.Equal(index, slot.GetProperty("slot").GetInt32()); + Assert.Equal(index == 0 ? 0 : 8 * index, slot.GetProperty("x64Offset").GetInt32()); + Assert.Equal(index == 0 ? 4 : 8, slot.GetProperty("width").GetInt32()); + } + + Assert.Equal("integer", slots[0].GetProperty("hostField").GetProperty("type").GetString()); + Assert.Equal(158, slots.Count(static slot => slot.GetProperty("width").GetInt32() == 8)); + JsonElement last = slots[^1]; + Assert.Equal(1272, last.GetProperty("x64Offset").GetInt32() + last.GetProperty("width").GetInt32()); + } + + [Fact] + public void Every_slot_min_declared_size_is_its_offset_plus_width() + { + foreach (JsonElement slot in Slots) + { + int index = slot.GetProperty("slot").GetInt32(); + int expected = slot.GetProperty("x64Offset").GetInt32() + slot.GetProperty("width").GetInt32(); + Assert.Equal(expected, slot.GetProperty("minDeclaredSize").GetInt32()); + Assert.Equal(index == 0 ? 4 : 8 * (index + 1), expected); + } + + Assert.Equal(144, Slots[17].GetProperty("minDeclaredSize").GetInt32()); + Assert.Equal(Registry.GetProperty("contract").GetProperty("sdkDirectPrefixSize").GetInt32(), + Slots[17].GetProperty("minDeclaredSize").GetInt32()); + } + + [Fact] + public void Sections_have_the_audit_slot_ranges() + { + foreach (JsonElement slot in Slots) + { + int index = slot.GetProperty("slot").GetInt32(); + string expected = index switch + { + <= 83 => "Base", + <= 86 => "V2", + <= 94 => "V3", + <= 154 => "V4", + _ => "V5" + }; + Assert.True(string.Equals(expected, slot.GetProperty("section").GetString(), StringComparison.Ordinal), + $"Slot {index} is in section {slot.GetProperty("section").GetString()}, expected {expected}."); + } + + Assert.Equal(["sym_nameToAddress", "sym_addressToName", "sym_generateAPIHookScript"], + Slots[84..87].Select(static slot => HostName(slot)), StringComparer.Ordinal); + Assert.Equal(["ExecuteKernelCode", "UserdefinedInterruptHook", "GetLuaState", "MainThreadCall"], + Slots[155..].Select(static slot => HostName(slot)), StringComparer.Ordinal); + } + + [Fact] + public void Nil_assigned_slots_are_exactly_the_eleven_host_nil_assignments() + { + int[] nil = SlotsWhere(static slot => IsKind(slot, "Nil")); + int[] nilAssigned = SlotsWhere(static slot => string.Equals(slot.GetProperty("nullability").GetString(), "NilAssigned", StringComparison.Ordinal)); + + Assert.Equal(NilSlots, nil); + Assert.Equal(NilSlots, nilAssigned); + Assert.All(nil, static index => Assert.Equal("nil", Slots[index].GetProperty("hostAssignment").GetProperty("expression").GetString())); + Assert.Equal("fixmem", HostName(Slots[14])); + } + + [Fact] + public void Cell_slots_are_the_twenty_five_double_address_assignments() + { + int[] cells = SlotsWhere(static slot => IsKind(slot, "CellAddress")); + int[] doubleAddress = SlotsWhere(static slot => + slot.GetProperty("hostAssignment").GetProperty("expression").GetString()!.StartsWith("@@", StringComparison.Ordinal)); + int[] functionPointerCells = SlotsWhere(static slot => string.Equals(slot.GetProperty("nature").GetString(), "FunctionPointerCell", StringComparison.Ordinal)); + + Assert.Equal(25, cells.Length); + Assert.Equal(CellSlots, cells); + Assert.Equal(CellSlots, doubleAddress); + Assert.Equal(CellSlots, functionPointerCells); + Assert.All(cells, static index => + { + Assert.Equal(1, Slots[index].GetProperty("indirection").GetInt32()); + Assert.Equal("Borrowed", Slots[index].GetProperty("ownership").GetString()); + Assert.Equal("windows", Slots[index].GetProperty("hostAssignment").GetProperty("condition").GetString()); + }); + } + + [Fact] + public void Data_and_object_reference_cells_have_their_declared_nature() + { + Assert.Equal([4, 5], SlotsWhere(static slot => string.Equals(slot.GetProperty("nature").GetString(), "DataCell", StringComparison.Ordinal))); + Assert.Equal([82, 83], SlotsWhere(static slot => string.Equals(slot.GetProperty("nature").GetString(), "ObjectRefCell", StringComparison.Ordinal))); + int[] cells = [4, 5, 82, 83]; + foreach (int index in cells) + { + JsonElement slot = Slots[index]; + Assert.Equal("VariableAddress", AssignmentKind(slot)); + Assert.Equal(1, slot.GetProperty("indirection").GetInt32()); + Assert.Equal("Borrowed", slot.GetProperty("ownership").GetString()); + Assert.Equal("NotApplicable", slot.GetProperty("callingConvention").GetString()); + } + + Assert.Equal(["OpenedProcessID", "OpenedProcessHandle", "mainform", "memorybrowser"], + cells.Select(static index => HostName(Slots[index])), StringComparer.Ordinal); + Assert.Equal("Int32", Slots[0].GetProperty("nature").GetString()); + Assert.Equal("Size", AssignmentKind(Slots[0])); + Assert.All(Slots.Where(static slot => IsKind(slot, "FunctionAddress")), static slot => + { + Assert.Equal("Function", slot.GetProperty("nature").GetString()); + Assert.Equal(0, slot.GetProperty("indirection").GetInt32()); + }); + } + + [Fact] + public void Divergence_register_lists_the_ten_audit_divergences() + { + JsonElement[] divergences = [.. Registry.GetProperty("divergences").EnumerateArray()]; + string[] audit = [.. divergences.Where(static row => string.Equals(row.GetProperty("origin").GetString(), "Audit", StringComparison.Ordinal)) + .Select(static row => row.GetProperty("id").GetString()!)]; + + Assert.Equal(AuditDivergences, audit); + Assert.Equal([17], Ints(Divergence(divergences, "D01"), "slots")); + Assert.Equal([89, 90], Ints(Divergence(divergences, "D02"), "slots")); + Assert.Equal([91], Ints(Divergence(divergences, "D03"), "slots")); + Assert.Equal([124], Ints(Divergence(divergences, "D04"), "slots")); + Assert.Equal([134, 135], Ints(Divergence(divergences, "D05"), "slots")); + Assert.Equal([157], Ints(Divergence(divergences, "D06"), "slots")); + Assert.Equal([3], Ints(Divergence(divergences, "D07"), "callbacks")); + Assert.Equal([4], Ints(Divergence(divergences, "D08"), "callbacks")); + Assert.Equal([0], Ints(Divergence(divergences, "D09"), "callbacks")); + Assert.Contains("PluginInitRecord", Strings(Divergence(divergences, "D10"), "records"), StringComparer.Ordinal); + + // Lot observations are extra rows, never replacements: each is a source observation of the pinned source. + foreach (JsonElement row in divergences.Where(static row => string.Equals(row.GetProperty("origin").GetString(), "LotObservation", StringComparison.Ordinal))) + { + Assert.Equal("ObservedSource", row.GetProperty("evidenceKind").GetString()); + } + + Assert.Equal([61], Ints(Divergence(divergences, "D11"), "slots")); + Assert.Equal("@VQE", Slots[61].GetProperty("hostAssignment").GetProperty("expression").GetString()); + } + + [Fact] + public void Divergence_references_resolve_in_both_directions() + { + Dictionary slotsById = Registry.GetProperty("divergences").EnumerateArray() + .ToDictionary(static row => row.GetProperty("id").GetString()!, static row => Ints(row, "slots"), + StringComparer.Ordinal); + + foreach (JsonElement slot in Slots) + { + int index = slot.GetProperty("slot").GetInt32(); + string[] refs = [.. slot.GetProperty("divergenceRefs").EnumerateArray().Select(static id => id.GetString()!)]; + foreach (string id in refs) + { + Assert.True(slotsById.TryGetValue(id, out int[]? listed), $"Slot {index} cites the unknown divergence {id}."); + Assert.Contains(index, listed); + } + + string[] expected = [.. slotsById.Where(pair => pair.Value.Contains(index)).Select(static pair => pair.Key).Order(StringComparer.Ordinal)]; + Assert.Equal(expected, refs.Order(StringComparer.Ordinal), StringComparer.Ordinal); + } + + // D12: every slot of the cell section that the host assigns a direct @ expression (not @@, not nil). + int[] direct = SlotsWhere(static slot => slot.GetProperty("slot").GetInt32() is >= 18 and <= 81 && + IsKind(slot, "FunctionAddress")); + Assert.Equal(direct, slotsById["D12"]); + } + + [Fact] + public void Every_divergent_or_nil_slot_is_never_prefix_typed() + { + foreach (JsonElement slot in Slots) + { + bool divergent = slot.GetProperty("divergenceRefs").GetArrayLength() > 0; + bool nil = string.Equals(slot.GetProperty("nullability").GetString(), "NilAssigned", StringComparison.Ordinal); + if (divergent || nil) + { + Assert.NotEqual("PrefixTyped", slot.GetProperty("sdkExposure").GetString(), StringComparer.Ordinal); + } + } + + Assert.Equal([14, 17], SlotsWhere(static slot => string.Equals(slot.GetProperty("sdkExposure").GetString(), "PrefixOpaque", StringComparison.Ordinal))); + Assert.Equal([.. Enumerable.Range(0, 18).Except([14, 17])], + SlotsWhere(static slot => string.Equals(slot.GetProperty("sdkExposure").GetString(), "PrefixTyped", StringComparison.Ordinal))); + Assert.Equal([.. Enumerable.Range(18, 141)], + SlotsWhere(static slot => string.Equals(slot.GetProperty("sdkExposure").GetString(), "None", StringComparison.Ordinal))); + Assert.All(Slots[18..], static slot => + { + Assert.Equal("Deferred", slot.GetProperty("facadeStatus").GetString()); + Assert.Equal(JsonValueKind.Null, slot.GetProperty("sdkField").ValueKind); + }); + } + + [Fact] + public void Authority_is_plugin_pas_and_mirrors_carry_their_hashes() + { + JsonElement[] sources = [.. Registry.GetProperty("sources").EnumerateArray()]; + + Assert.Equal(ClassicSlotRegistryContract.SourceIds, sources.Select(static source => source.GetProperty("id").GetString()!), + StringComparer.Ordinal); + JsonElement authority = Assert.Single(sources, static source => string.Equals(source.GetProperty("role").GetString(), "Authority", StringComparison.Ordinal)); + Assert.Equal("Cheat Engine/plugin.pas", authority.GetProperty("path").GetString()); + foreach (JsonElement source in sources) + { + string id = source.GetProperty("id").GetString()!; + Assert.Equal(ClassicSlotRegistryContract.PinnedSourceSha256[id], source.GetProperty("sha256").GetString()); + Assert.Equal(ClassicSlotRegistryContract.UpstreamCommit, source.GetProperty("commit").GetString()); + Assert.Equal("cheat-engine/cheat-engine", source.GetProperty("repository").GetString()); + } + + JsonElement header = sources[2].GetProperty("installed"); + Assert.Equal("MirrorC", sources[2].GetProperty("role").GetString()); + Assert.Equal("9c0e31bb753d782ce20710d19828f4e97b4371c8733abd0c5c6f7f485306fb28", header.GetProperty("sha256").GetString()); + Assert.Equal("CommentOnlyDifference", header.GetProperty("relation").GetString()); + JsonElement pascal = sources[3].GetProperty("installed"); + Assert.Equal("MirrorPascal", sources[3].GetProperty("role").GetString()); + Assert.Equal(sources[3].GetProperty("sha256").GetString(), pascal.GetProperty("sha256").GetString()); + Assert.Equal("Identical", pascal.GetProperty("relation").GetString()); + Assert.Equal(JsonValueKind.Null, sources[0].GetProperty("installed").ValueKind); + + // A slot's facts come from the authority: every host field and assignment line is inside a plugin.pas range. + JsonElement[] ranges = [.. authority.GetProperty("lineRanges").EnumerateArray()]; + Assert.All(Slots, slot => + { + Assert.True(InRange(ranges, slot.GetProperty("hostField").GetProperty("line").GetInt32())); + Assert.True(InRange(ranges, slot.GetProperty("hostAssignment").GetProperty("line").GetInt32())); + }); + + // A host implementation is only cited for a function address whose target plugin.pas assigns. + Assert.All(Slots.Where(static slot => slot.GetProperty("hostImplementation").ValueKind != JsonValueKind.Null), static slot => + { + Assert.Equal("FunctionAddress", AssignmentKind(slot)); + Assert.Equal("pluginexports-pas", slot.GetProperty("hostImplementation").GetProperty("source").GetString()); + Assert.Equal("ObservedSource", slot.GetProperty("evidenceKind").GetString()); + }); + Assert.All(Slots.Where(static slot => IsKind(slot, "FunctionAddress") && + slot.GetProperty("hostImplementation").ValueKind == JsonValueKind.Null), + static slot => Assert.Equal("ToQualify", slot.GetProperty("evidenceKind").GetString())); + } + + [Fact] + public void Qualifiable_profile_status_is_not_observed_with_a_reason() + { + JsonElement[] statuses = [.. Registry.GetProperty("profileStatus").EnumerateArray()]; + + JsonElement qualifiable = Assert.Single(statuses, static status => + string.Equals(status.GetProperty("profileId").GetString(), ClassicSlotRegistryContract.QualifiableProfile, StringComparison.Ordinal)); + Assert.Equal("NotObserved", qualifiable.GetProperty("status").GetString()); + Assert.Contains("no managed-hostfxr route reaches the classic table", + qualifiable.GetProperty("reason").GetString(), StringComparison.Ordinal); + JsonElement documentary = Assert.Single(statuses, static status => + string.Equals(status.GetProperty("profileId").GetString(), ClassicSlotRegistryContract.DocumentaryProfile, StringComparison.Ordinal)); + Assert.Equal("SourceOnly", documentary.GetProperty("status").GetString()); + Assert.All(Slots, static slot => Assert.Equal("NotObserved", slot.GetProperty("hostProfileStatus").GetString())); + } + + [Fact] + public void No_slot_is_counted_as_qualified() + { + Assert.All(Slots, static slot => + { + Assert.Equal("NotExecuted", slot.GetProperty("qualification").GetString()); + Assert.Equal("Deduced", slot.GetProperty("layoutEvidenceKind").GetString()); + Assert.NotEqual("ObservedHost", slot.GetProperty("evidenceKind").GetString(), StringComparer.Ordinal); + }); + } + + [Fact] + public void Lua_equivalents_are_named_only_for_function_slots() + { + JsonElement[] withLua = [.. Slots.Where(static slot => slot.GetProperty("luaEquivalent").ValueKind != JsonValueKind.Null)]; + + Assert.NotEmpty(withLua); + Assert.All(withLua, static slot => + { + Assert.Equal("Function", slot.GetProperty("nature").GetString()); + Assert.Equal(HostName(slot), slot.GetProperty("luaEquivalent").GetProperty("name").GetString(), + StringComparer.OrdinalIgnoreCase); + }); + Assert.All(Slots, static slot => Assert.Equal(JsonValueKind.Null, slot.GetProperty("catalogSurfaceId").ValueKind)); + Assert.Equal("createForm", Slots[124].GetProperty("luaEquivalent").GetProperty("name").GetString()); + } + + [Fact] + public void Registry_uses_lowercase_hashes_and_no_absolute_local_path() + { + string text = RepositoryDocument.ReadNormalizedText(ClassicSlotRegistryContract.RegistryPath); + + Assert.DoesNotMatch(AbsoluteLocalPath(), text); + foreach (Match hash in HexHash().Matches(text)) + { + Assert.Equal(hash.Value.ToLowerInvariant(), hash.Value); + } + + Assert.True(HexHash().Count(text) >= 6, "The registry lost its source hashes."); + } + + [Fact] + public void Registry_is_canonically_formatted() + { + string text = RepositoryDocument.ReadNormalizedText(ClassicSlotRegistryContract.RegistryPath); + + Assert.Equal(RepositoryDocument.Canonical(RepositoryDocument.ParseJson(text)), text); + Assert.EndsWith("}\n", text, StringComparison.Ordinal); + Assert.False(text.EndsWith("\n\n", StringComparison.Ordinal)); + } + + [Fact] + public void Callback_categories_cover_plugin_types_0_to_8() + { + JsonElement[] categories = [.. Registry.GetProperty("callbackCategories").EnumerateArray()]; + + Assert.Equal([.. Enumerable.Range(0, 9)], categories.Select(static row => row.GetProperty("pluginType").GetInt32())); + Assert.Equal( + ["ptAddressList", "ptMemoryView", "ptOnDebugEvent", "ptProcesswatcherEvent", "ptFunctionPointerchange", "ptMainMenu", + "ptDisassemblerContext", "ptDisassemblerRenderLine", "ptAutoAssembler"], + categories.Select(static row => row.GetProperty("name").GetString()!), StringComparer.Ordinal); + Assert.True(categories[2].GetProperty("synchronousDecision").GetBoolean()); + Assert.Equal(1, categories.Count(static row => row.GetProperty("synchronousDecision").GetBoolean())); + Assert.Equal("WorkerThread", categories[3].GetProperty("context").GetString()); + Assert.Equal([3, 6, 8], categories.Where(static row => row.GetProperty("versionDependent").GetBoolean()) + .Select(static row => row.GetProperty("pluginType").GetInt32())); + Assert.All(categories, static row => Assert.Equal(row.GetProperty("versionDependent").GetBoolean(), + row.GetProperty("versionVariants").GetArrayLength() > 0)); + Assert.All(categories, static row => Assert.StartsWith("PluginCallbackShapeTests.", + row.GetProperty("shapeTest").GetString(), StringComparison.Ordinal)); + + // Every named shape test must exist, and none carries the Q38 trait: shape tests test no thread. + foreach (JsonElement row in categories) + { + string[] test = row.GetProperty("shapeTest").GetString()!.Split('.'); + IReadOnlyList? traits = TestMethodTraits.Of(ShapeTestsPath, test[1]); + Assert.True(traits is not null, $"{ShapeTestsPath} declares no {test[0]}.{test[1]}."); + Assert.DoesNotContain("Q38", traits, StringComparer.Ordinal); + } + } + + private static void AssertProperties(string[] expected, JsonElement obj) + { + string[] actual = [.. obj.EnumerateObject().Select(static property => property.Name)]; + foreach (string name in expected) + { + Assert.Contains(name, actual, StringComparer.Ordinal); + } + } + + private static int[] SlotsWhere(Func predicate) + { + return [.. Slots.Where(predicate).Select(static slot => slot.GetProperty("slot").GetInt32())]; + } + + private static string AssignmentKind(JsonElement slot) + { + return slot.GetProperty("hostAssignment").GetProperty("kind").GetString()!; + } + + private static bool IsKind(JsonElement slot, string kind) + { + return string.Equals(AssignmentKind(slot), kind, StringComparison.Ordinal); + } + + private static string HostName(JsonElement slot) + { + return slot.GetProperty("hostField").GetProperty("name").GetString()!; + } + + private static JsonElement Divergence(JsonElement[] divergences, string id) + { + return Assert.Single(divergences, row => string.Equals(row.GetProperty("id").GetString(), id, StringComparison.Ordinal)); + } + + private static int[] Ints(JsonElement row, string property) + { + return [.. row.GetProperty(property).EnumerateArray().Select(static item => item.GetInt32())]; + } + + private static string[] Strings(JsonElement row, string property) + { + return [.. row.GetProperty(property).EnumerateArray().Select(static item => item.GetString()!)]; + } + + private static bool InRange(JsonElement[] ranges, int line) + { + return ranges.Any(range => line >= range.GetProperty("start").GetInt32() && line <= range.GetProperty("end").GetInt32()); + } + + [GeneratedRegex(@"(? +/// F02 is published in the repository before any support claim: the NativeAOT profile page names the Microsoft unload +/// restriction and every load profile, the classic export names carry the FreeLibrary caveat, the packed +/// README states the limits, and the NativeAOT loader harness never frees a mapped NativeAOT module (audit A00-07, +/// A04-03, A22-15, A23-F02-1..4, AX07-06, SRCREG-08). +/// +public sealed partial class NativeAotProfileDocumentTests +{ + private const string ProfilePage = "libs/CheatEngine.SDK.Abi/README.md"; + private const string UnloadRestriction = "https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries"; + + [Fact] + public void NativeAot_profile_page_cites_the_microsoft_unload_restriction_and_lists_every_load_profile() + { + string page = Read(ProfilePage); + + Assert.Contains(UnloadRestriction, page, StringComparison.Ordinal); + Assert.Contains("FreeLibrary", page, StringComparison.Ordinal); + Assert.Contains("ce-7.7.0.10621-x64-managed-hostfxr", page, StringComparison.Ordinal); + Assert.Contains("9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d", page, StringComparison.Ordinal); + Assert.Contains("LocalModified", page, StringComparison.Ordinal); + foreach (string profile in (string[]) + [ + "| Historical CLR route", "| **Managed hostfxr route**", "| NativeAOT plugin DLL", + "| Classic native plugin exporting `CEPlugin_*`", "| x86 or ARM64 host" + ]) + { + Assert.Contains(profile, page, StringComparison.Ordinal); + } + + Assert.Contains("**Not supported**", page, StringComparison.Ordinal); + Assert.Contains("never a Cheat Engine load success", page, StringComparison.Ordinal); + Assert.Contains("Replacing the managed bootstrap with three `CEPlugin_*` exports is **not**", page, + StringComparison.Ordinal); + Assert.Contains("CESDK9102", page, StringComparison.Ordinal); + Assert.Contains("CESDK0006", page, StringComparison.Ordinal); + Assert.Contains("Q42", page, StringComparison.Ordinal); + Assert.DoesNotContain("Status: placeholder", page, StringComparison.Ordinal); + } + + [Fact] + public void NativeExportNames_documentation_carries_the_FreeLibrary_caveat() + { + string source = Read("libs/CheatEngine.SDK.Abi/Native/NativeExportNames.cs"); + string summary = source[..source.IndexOf("public static class NativeExportNames", StringComparison.Ordinal)]; + + Assert.Contains("FreeLibrary", summary, StringComparison.Ordinal); + Assert.Contains("not a supported CheatEngine.SDK profile", summary, StringComparison.Ordinal); + Assert.Contains("libs/CheatEngine.SDK.Abi/README.md", summary, StringComparison.Ordinal); + Assert.Contains("Not a replacement for the managed bootstrap", summary, StringComparison.Ordinal); + Assert.DoesNotContain("the path a Native AOT build of a plugin would take", source, StringComparison.Ordinal); + Assert.DoesNotContain("Native (Native AOT) load path only", Read("libs/CheatEngine.SDK.Abi/Native/PluginType.cs"), + StringComparison.Ordinal); + } + + [Fact] + public void Packed_readme_states_the_load_profiles_and_links_the_restrictions() + { + string readme = Read("src/CheatEngine.SDK/README.md"); + + Assert.Contains("## Load profiles and limits", readme, StringComparison.Ordinal); + Assert.DoesNotContain("## AOT status", readme, StringComparison.Ordinal); + Assert.Contains("https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/libs/CheatEngine.SDK.Abi/README.md", readme, + StringComparison.Ordinal); + Assert.Contains(UnloadRestriction, readme, StringComparison.Ordinal); + Assert.Contains("| NativeAOT plugin DLL | Not supported", readme, StringComparison.Ordinal); + } + + [Fact] + public void Loader_harness_never_frees_a_mapped_nativeaot_module() + { + string[] sources = Directory.GetFiles( + Path.Combine(RepositoryRoot.Path, "tests", "CheatEngine.SDK.NativeAotLoaderHarness"), "*.cs", + SearchOption.TopDirectoryOnly); + + Assert.NotEmpty(sources); + foreach (string file in sources) + { + string code = CSharpCode.BlankCommentsAndLiterals(File.ReadAllText(file)); + Assert.DoesNotMatch(FreeCall(), code); + } + + Assert.Contains("library.unload=not-attempted", + File.ReadAllText(Path.Combine(RepositoryRoot.Path, "tests", "CheatEngine.SDK.NativeAotLoaderHarness", "Program.cs")), + StringComparison.Ordinal); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(Path.Combine(RepositoryRoot.Path, relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + } + + [GeneratedRegex(@"\bNativeLibrary\s*\.\s*Free\s*\(|\bFreeLibrary\s*\(|\bFreeLibraryAndExitThread\s*\(", + RegexOptions.CultureInvariant, 1000)] + private static partial Regex FreeCall(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Abi/RepositoryDocument.cs b/tests/CheatEngine.SDK.Repository.Tests/Abi/RepositoryDocument.cs new file mode 100644 index 00000000..48aabcc3 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Abi/RepositoryDocument.cs @@ -0,0 +1,83 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Abi; + +/// +/// Minimal committed-file helpers for the ABI and Lua-bridge document tests. These documents are test-owned data +/// under this project (never a top-level docs/ folder, per the maintainer's no-custom-scripting pivot) and +/// are read directly off disk with no JSON Schema infrastructure: every rule is a plain C# assertion instead. +/// +internal static class RepositoryDocument +{ + /// The absolute path of a repository-relative path. + public static string Absolute(string repositoryRelativePath) + { + return Path.Combine(RepositoryRoot.Path, repositoryRelativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + /// Whether a repository-relative path names a committed file. + public static bool Exists(string repositoryRelativePath) + { + return File.Exists(Absolute(repositoryRelativePath)); + } + + /// Whether a repository-relative path names a committed directory. + public static bool DirectoryExists(string repositoryRelativePath) + { + return Directory.Exists(Absolute(repositoryRelativePath)); + } + + /// The committed text of a file, with CRLF normalized to LF. + public static string ReadNormalizedText(string repositoryRelativePath) + { + return File.ReadAllText(Absolute(repositoryRelativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + } + + /// Parses a committed JSON document. + public static JsonElement LoadJson(string repositoryRelativePath) + { + return ParseJson(ReadNormalizedText(repositoryRelativePath)); + } + + /// Parses a JSON string into a standalone (document-independent) element. + public static JsonElement ParseJson(string text) + { + using JsonDocument document = JsonDocument.Parse(text); + return document.RootElement.Clone(); + } + + /// The lowercase hex SHA-256 of the raw (unnormalized) bytes of a committed file. + public static string RawSha256(string repositoryRelativePath) + { + byte[] bytes = File.ReadAllBytes(Absolute(repositoryRelativePath)); + return Convert.ToHexStringLower(SHA256.HashData(bytes)); + } + + /// + /// Reserializes the way a committed document is formatted: 2-space indent, LF + /// newlines, relaxed escaping, one trailing newline + /// (https://learn.microsoft.com/dotnet/api/system.text.json.jsonwriteroptions.newline). + /// + public static string Canonical(JsonElement element) + { + JsonWriterOptions writerOptions = new() + { + Indented = true, + IndentSize = 2, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + NewLine = "\n" + }; + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream, writerOptions)) + { + element.WriteTo(writer); + } + + return Encoding.UTF8.GetString(stream.ToArray()) + "\n"; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json b/tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json new file mode 100644 index 00000000..624d72f9 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Abi/TestData/classic-slot-registry.json @@ -0,0 +1,9215 @@ +{ + "schema": "cheatengine-classic-slot-registry/v0", + "generator": { + "note": "Produced by a one-off, non-committed conversion script run once against the pinned Cheat Engine sources listed below (no PowerShell/JSON governance tooling is kept under eng/); this document is the committed, test-owned source of truth and is never regenerated in CI." + }, + "sources": [ + { + "id": "plugin-pas", + "role": "Authority", + "repository": "cheat-engine/cheat-engine", + "commit": "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37", + "path": "Cheat Engine/plugin.pas", + "sha256": "358f51a39ad14d00ecba3c9137f440152d4ab85f1d2498068fa81fca906d09db", + "lineRanges": [ + { + "start": 17, + "end": 17, + "subject": "CurrentPluginVersion" + }, + { + "start": 47, + "end": 226, + "subject": "TExportedFunctions5" + }, + { + "start": 230, + "end": 230, + "subject": "TExportedFunctions alias" + }, + { + "start": 726, + "end": 735, + "subject": "TPlugin0_SelectedRecord" + }, + { + "start": 1856, + "end": 2045, + "subject": "TPluginHandler.create assignments" + } + ], + "installed": null + }, + { + "id": "pluginexports-pas", + "role": "HostImplementation", + "repository": "cheat-engine/cheat-engine", + "commit": "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37", + "path": "Cheat Engine/pluginexports.pas", + "sha256": "0192ce02441be2fdf080ba829eae3d8edf6cb24b83308154d5068ca642e2c2ea", + "lineRanges": [ + { + "start": 5, + "end": 132, + "subject": "interface declarations" + } + ], + "installed": null + }, + { + "id": "cepluginsdk-h", + "role": "MirrorC", + "repository": "cheat-engine/cheat-engine", + "commit": "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37", + "path": "Cheat Engine/plugin/cepluginsdk.h", + "sha256": "b6500df1e94d7bb011b38e173b2603197b7a1f304496d751ede82e57e36e532f", + "lineRanges": [ + { + "start": 275, + "end": 458, + "subject": "ExportedFunctions" + } + ], + "installed": { + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "sha256": "9c0e31bb753d782ce20710d19828f4e97b4371c8733abd0c5c6f7f485306fb28", + "relation": "CommentOnlyDifference", + "evidenceKind": "ExactInstalledFile" + } + }, + { + "id": "cepluginsdk-pas", + "role": "MirrorPascal", + "repository": "cheat-engine/cheat-engine", + "commit": "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37", + "path": "Cheat Engine/plugin/cepluginsdk.pas", + "sha256": "cda5269f441120e5a3bff2f87e289cd71de9158ca2a619c7d0a734eb98ee6052", + "lineRanges": [ + { + "start": 353, + "end": 536, + "subject": "TExportedFunctions" + } + ], + "installed": { + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "sha256": "cda5269f441120e5a3bff2f87e289cd71de9158ca2a619c7d0a734eb98ee6052", + "relation": "Identical", + "evidenceKind": "ExactInstalledFile" + } + } + ], + "contract": { + "pluginContractVersion": 6, + "hostTableType": "TExportedFunctions5", + "fieldCount": 159, + "pointerFieldCount": 158, + "x64TableSize": 1272, + "sdkDirectPrefixSize": 144, + "sdkPrefixSlots": [ + 0, + 17 + ] + }, + "profileStatus": [ + { + "profileId": "ce-public-src-ec45d5f", + "status": "SourceOnly", + "reason": "The registry is read from the pinned public source; that profile is documentary and never qualifiable." + }, + { + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "status": "NotObserved", + "reason": "no managed-hostfxr route reaches the classic table (annexe 04, last paragraph)" + } + ], + "slots": [ + { + "slot": 0, + "x64Offset": 0, + "width": 4, + "minDeclaredSize": 4, + "section": "Base", + "hostField": { + "name": "sizeofExportedFunctions", + "type": "integer", + "line": 48 + }, + "hostAssignment": { + "kind": "Size", + "expression": "sizeof(TExportedFunctions)", + "line": 1856, + "condition": null + }, + "hostImplementation": null, + "nature": "Int32", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NotApplicable", + "mirrors": { + "headerC": { + "name": "sizeofExportedFunctions", + "line": 277, + "declaration": "int sizeofExportedFunctions;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "sizeofTExportedFunctions", + "line": 354, + "declaration": "sizeofTExportedFunctions: integer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "SizeOfExportedFunctions", + "facadeStatus": "PrefixOnly", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 1, + "x64Offset": 8, + "width": 8, + "minDeclaredSize": 16, + "section": "Base", + "hostField": { + "name": "showmessage", + "type": "pointer", + "line": 49 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_showmessage", + "line": 1857, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 18, + "declaration": "procedure ce_showmessage(s: pchar); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "ShowMessage", + "line": 278, + "declaration": "CEP_SHOWMESSAGE ShowMessage;", + "typeDeclaration": { + "line": 164, + "text": "typedef void (__stdcall *CEP_SHOWMESSAGE)(char* message);" + } + }, + "pascalMirror": { + "name": "showmessage", + "line": 355, + "declaration": "showmessage: Tce_showmessage;", + "typeDeclaration": { + "line": 223, + "text": "type Tce_showmessage=procedure (s: pchar); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "ShowMessage", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": { + "name": "showMessage" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 2, + "x64Offset": 16, + "width": 8, + "minDeclaredSize": 24, + "section": "Base", + "hostField": { + "name": "registerfunction", + "type": "pointer", + "line": 50 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_registerfunction", + "line": 1858, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 19, + "declaration": "function ce_registerfunction(pluginid,functiontype:integer; init: pointer):integer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "RegisterFunction", + "line": 279, + "declaration": "CEP_REGISTERFUNCTION RegisterFunction;", + "typeDeclaration": { + "line": 165, + "text": "typedef int (__stdcall *CEP_REGISTERFUNCTION) (int pluginid, PluginType functiontype, PVOID init);" + } + }, + "pascalMirror": { + "name": "registerfunction", + "line": 356, + "declaration": "registerfunction: Tce_registerfunction;", + "typeDeclaration": { + "line": 224, + "text": "type Tce_registerfunction=function (pluginid: integer; functiontype:TPluginType; init: pointer):integer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "RegisterFunction", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 3, + "x64Offset": 24, + "width": 8, + "minDeclaredSize": 32, + "section": "Base", + "hostField": { + "name": "unregisterfunction", + "type": "pointer", + "line": 51 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_unregisterfunction", + "line": 1859, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 20, + "declaration": "function ce_unregisterfunction(pluginid,functionid: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "UnregisterFunction", + "line": 280, + "declaration": "CEP_UNREGISTERFUNCTION UnregisterFunction;", + "typeDeclaration": { + "line": 166, + "text": "typedef BOOL (__stdcall *CEP_UNREGISTERFUNCTION) (int pluginid, int functionid);" + } + }, + "pascalMirror": { + "name": "unregisterfunction", + "line": 357, + "declaration": "unregisterfunction: Tce_unregisterfunction;", + "typeDeclaration": { + "line": 225, + "text": "type Tce_unregisterfunction=function (pluginid,functionid: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "UnregisterFunction", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 4, + "x64Offset": 32, + "width": 8, + "minDeclaredSize": 40, + "section": "Base", + "hostField": { + "name": "OpenedProcessID", + "type": "^dword", + "line": 52 + }, + "hostAssignment": { + "kind": "VariableAddress", + "expression": "@processhandler.processid", + "line": 1860, + "condition": null + }, + "hostImplementation": null, + "nature": "DataCell", + "indirection": 1, + "callingConvention": "NotApplicable", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "OpenedProcessID", + "line": 281, + "declaration": "PULONG OpenedProcessID;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "OpenedProcessID", + "line": 358, + "declaration": "OpenedProcessID: ^dword;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "OpenedProcessId", + "facadeStatus": "PrefixOnly", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 5, + "x64Offset": 40, + "width": 8, + "minDeclaredSize": 48, + "section": "Base", + "hostField": { + "name": "OpenedProcessHandle", + "type": "^thandle", + "line": 53 + }, + "hostAssignment": { + "kind": "VariableAddress", + "expression": "@processhandler.processhandle", + "line": 1861, + "condition": null + }, + "hostImplementation": null, + "nature": "DataCell", + "indirection": 1, + "callingConvention": "NotApplicable", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "OpenedProcessHandle", + "line": 282, + "declaration": "PHANDLE OpenedProcessHandle;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "OpenedProcessHandle", + "line": 359, + "declaration": "OpenedProcessHandle: ^thandle;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "OpenedProcessHandle", + "facadeStatus": "PrefixOnly", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 6, + "x64Offset": 48, + "width": 8, + "minDeclaredSize": 56, + "section": "Base", + "hostField": { + "name": "GetMainWindowHandle", + "type": "pointer", + "line": 55 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_GetMainWindowHandle", + "line": 1864, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 22, + "declaration": "function ce_GetMainWindowHandle:thandle; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetMainWindowHandle", + "line": 284, + "declaration": "CEP_GETMAINWINDOWHANDLE GetMainWindowHandle;", + "typeDeclaration": { + "line": 167, + "text": "typedef HANDLE (__stdcall *CEP_GETMAINWINDOWHANDLE) (void);" + } + }, + "pascalMirror": { + "name": "GetMainWindowHandle", + "line": 361, + "declaration": "GetMainWindowHandle: pointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "GetMainWindowHandle", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 7, + "x64Offset": 56, + "width": 8, + "minDeclaredSize": 64, + "section": "Base", + "hostField": { + "name": "AutoAssemble", + "type": "pointer", + "line": 56 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_autoassemble", + "line": 1865, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 21, + "declaration": "function ce_AutoAssemble(s: pchar):BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "AutoAssemble", + "line": 285, + "declaration": "CEP_AUTOASSEMBLE AutoAssemble;", + "typeDeclaration": { + "line": 168, + "text": "typedef BOOL (__stdcall *CEP_AUTOASSEMBLE) (char *script);" + } + }, + "pascalMirror": { + "name": "AutoAssemble", + "line": 362, + "declaration": "AutoAssemble: Tce_AutoAssembler;", + "typeDeclaration": { + "line": 226, + "text": "type Tce_AutoAssembler=function (s: pchar):BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "AutoAssemble", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": { + "name": "autoAssemble" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 8, + "x64Offset": 64, + "width": 8, + "minDeclaredSize": 72, + "section": "Base", + "hostField": { + "name": "assembler", + "type": "pointer", + "line": 57 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_assembler", + "line": 1866, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 25, + "declaration": "function ce_assembler(address:ptrUint; instruction: pchar; output: PByteArray; maxlength: integer; actualsize: pinteger):BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Assembler", + "line": 286, + "declaration": "CEP_ASSEMBLER Assembler;", + "typeDeclaration": { + "line": 169, + "text": "typedef BOOL (__stdcall *CEP_ASSEMBLER) (UINT_PTR address, char* instruction, BYTE *output, int maxlength, int *returnedsize);" + } + }, + "pascalMirror": { + "name": "assembler", + "line": 364, + "declaration": "assembler: Tce_assembler;", + "typeDeclaration": { + "line": 227, + "text": "type Tce_assembler=function(address:ptruint; instruction: pchar; output: PByteArray; maxlength: integer; actualsize: pinteger):BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "Assembler", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 9, + "x64Offset": 72, + "width": 8, + "minDeclaredSize": 80, + "section": "Base", + "hostField": { + "name": "disassembler", + "type": "pointer", + "line": 58 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_disassembler", + "line": 1867, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 26, + "declaration": "function ce_disassembler(address: ptrUint; output: pchar; maxsize: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Disassembler", + "line": 287, + "declaration": "CEP_DISASSEMBLER Disassembler;", + "typeDeclaration": { + "line": 170, + "text": "typedef BOOL (__stdcall *CEP_DISASSEMBLER) (UINT_PTR address, char* output, int maxsize);" + } + }, + "pascalMirror": { + "name": "disassembler", + "line": 365, + "declaration": "disassembler: Tce_disassembler;", + "typeDeclaration": { + "line": 228, + "text": "type Tce_disassembler=function(address: ptruint; output: pchar; maxsize: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "Disassembler", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 10, + "x64Offset": 80, + "width": 8, + "minDeclaredSize": 88, + "section": "Base", + "hostField": { + "name": "ChangeRegistersAtAddress", + "type": "pointer", + "line": 59 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_ChangeRegistersAtAddress", + "line": 1868, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 23, + "declaration": "function ce_ChangeRegistersAtAddress(address:ptrUint; changereg: pregistermodificationBP):BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "ChangeRegistersAtAddress", + "line": 288, + "declaration": "CEP_CHANGEREGATADDRESS ChangeRegistersAtAddress;", + "typeDeclaration": { + "line": 171, + "text": "typedef BOOL (__stdcall *CEP_CHANGEREGATADDRESS) (UINT_PTR address,PREGISTERMODIFICATIONINFO changereg);" + } + }, + "pascalMirror": { + "name": "ChangeRegistersAtAddress", + "line": 366, + "declaration": "ChangeRegistersAtAddress: Tce_ChangeRegistersAtAddress;", + "typeDeclaration": { + "line": 231, + "text": "type Tce_ChangeRegistersAtAddress=function(address:ptruint; changereg: pregistermodificationBP):BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "ChangeRegistersAtAddress", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 11, + "x64Offset": 88, + "width": 8, + "minDeclaredSize": 96, + "section": "Base", + "hostField": { + "name": "InjectDLL", + "type": "pointer", + "line": 60 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_injectdll", + "line": 1869, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 30, + "declaration": "function ce_InjectDLL(dllname: pchar; functiontocall: pchar):BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "InjectDLL", + "line": 289, + "declaration": "CEP_INJECTDLL InjectDLL;", + "typeDeclaration": { + "line": 172, + "text": "typedef BOOL (__stdcall *CEP_INJECTDLL) (char *dllname, char *functiontocall);" + } + }, + "pascalMirror": { + "name": "InjectDLL", + "line": 367, + "declaration": "InjectDLL: Tce_InjectDLL;", + "typeDeclaration": { + "line": 233, + "text": "type Tce_InjectDLL=function(dllname: pchar; functiontocall: pchar):BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "InjectDll", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": { + "name": "injectDLL" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 12, + "x64Offset": 96, + "width": 8, + "minDeclaredSize": 104, + "section": "Base", + "hostField": { + "name": "freezemem", + "type": "pointer", + "line": 61 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_freezemem", + "line": 1870, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 35, + "declaration": "function ce_freezemem(address: ptrUint; size: integer):integer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "FreezeMem", + "line": 290, + "declaration": "CEP_FREEZEMEM FreezeMem;", + "typeDeclaration": { + "line": 173, + "text": "typedef int (__stdcall *CEP_FREEZEMEM) (UINT_PTR address, int size);" + } + }, + "pascalMirror": { + "name": "freezemem", + "line": 368, + "declaration": "freezemem: Tce_freezemem;", + "typeDeclaration": { + "line": 235, + "text": "type Tce_freezemem= function (address: ptruint; size: integer):integer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "FreezeMemory", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 13, + "x64Offset": 104, + "width": 8, + "minDeclaredSize": 112, + "section": "Base", + "hostField": { + "name": "unfreezemem", + "type": "pointer", + "line": 62 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_unfreezemem", + "line": 1871, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 36, + "declaration": "function ce_unfreezemem(id: integer):BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "UnfreezeMem", + "line": 291, + "declaration": "CEP_UNFREEZEMEM UnfreezeMem;", + "typeDeclaration": { + "line": 174, + "text": "typedef BOOL (__stdcall *CEP_UNFREEZEMEM) (int freezeID);" + } + }, + "pascalMirror": { + "name": "unfreezemem", + "line": 369, + "declaration": "unfreezemem: Tce_unfreezemem;", + "typeDeclaration": { + "line": 236, + "text": "type Tce_unfreezemem =function (id: integer):BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "UnfreezeMemory", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 14, + "x64Offset": 112, + "width": 8, + "minDeclaredSize": 120, + "section": "Base", + "hostField": { + "name": "fixmem", + "type": "pointer", + "line": 63 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1872, + "condition": null + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "FixMem", + "line": 292, + "declaration": "CEP_FIXMEM FixMem;", + "typeDeclaration": { + "line": 175, + "text": "typedef BOOL (__stdcall *CEP_FIXMEM) (void);" + } + }, + "pascalMirror": { + "name": "fixmem", + "line": 370, + "declaration": "fixmem: pointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixOpaque", + "sdkField": "FixMemory", + "facadeStatus": "PrefixOnly", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 15, + "x64Offset": 120, + "width": 8, + "minDeclaredSize": 128, + "section": "Base", + "hostField": { + "name": "processlist", + "type": "pointer", + "line": 64 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_processlist", + "line": 1873, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 31, + "declaration": "function ce_processlist(listbuffer: pchar; listsize: integer):BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "ProcessList", + "line": 293, + "declaration": "CEP_PROCESSLIST ProcessList;", + "typeDeclaration": { + "line": 176, + "text": "typedef BOOL (__stdcall *CEP_PROCESSLIST) (char *listbuffer, int listsize);" + } + }, + "pascalMirror": { + "name": "processlist", + "line": 371, + "declaration": "processlist: Tce_processlist;", + "typeDeclaration": { + "line": 238, + "text": "type Tce_processlist=function(listbuffer: pchar; listsize: integer):BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "ProcessList", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 16, + "x64Offset": 128, + "width": 8, + "minDeclaredSize": 136, + "section": "Base", + "hostField": { + "name": "reloadsettings", + "type": "pointer", + "line": 65 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_reloadsettings", + "line": 1874, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 32, + "declaration": "function ce_reloadsettings:BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "ReloadSettings", + "line": 294, + "declaration": "CEP_RELOADSETTINGS ReloadSettings;", + "typeDeclaration": { + "line": 177, + "text": "typedef BOOL (__stdcall *CEP_RELOADSETTINGS) (void);" + } + }, + "pascalMirror": { + "name": "reloadsettings", + "line": 372, + "declaration": "reloadsettings: Tce_reloadsettings;", + "typeDeclaration": { + "line": 239, + "text": "type Tce_reloadsettings=function:BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "PrefixTyped", + "sdkField": "ReloadSettings", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 17, + "x64Offset": 136, + "width": 8, + "minDeclaredSize": 144, + "section": "Base", + "hostField": { + "name": "getaddressfrompointer", + "type": "pointer", + "line": 66 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_getaddressfrompointer", + "line": 1875, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 33, + "declaration": "function ce_getaddressfrompointer(baseaddress: ptrUint; offsetcount: integer; offsets: PDwordArray):dword; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetAddressFromPointer", + "line": 295, + "declaration": "CEP_GETADDRESSFROMPOINTER GetAddressFromPointer;", + "typeDeclaration": { + "line": 178, + "text": "typedef UINT_PTR (__stdcall *CEP_GETADDRESSFROMPOINTER) (UINT_PTR baseaddress, int offsetcount, int* offsets);" + } + }, + "pascalMirror": { + "name": "getaddressfrompointer", + "line": 373, + "declaration": "getaddressfrompointer: Tce_getaddressfrompointer;", + "typeDeclaration": { + "line": 240, + "text": "type Tce_getaddressfrompointer=function(baseaddress: ptruint; offsetcount: integer; offsets: PDwordArray):dword; stdcall;" + } + } + }, + "divergenceRefs": [ + "D01" + ], + "sdkExposure": "PrefixOpaque", + "sdkField": "GetAddressFromPointer", + "facadeStatus": "PrefixOnly", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 18, + "x64Offset": 144, + "width": 8, + "minDeclaredSize": 152, + "section": "Base", + "hostField": { + "name": "ReadProcessMemory", + "type": "pointer", + "line": 69 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@ReadProcessMemoryActual", + "line": 1880, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "ReadProcessMemory", + "line": 299, + "declaration": "CEP_READPROCESSMEMORY ReadProcessMemory;", + "typeDeclaration": { + "line": 268, + "text": "typedef BOOL(__stdcall **CEP_READPROCESSMEMORY)(HANDLE hProcess, LPCVOID lpBaseAddress, LPVOID lpBuffer, SIZE_T nSize, SIZE_T * lpNumberOfBytesRead);" + } + }, + "pascalMirror": { + "name": "ReadProcessMemory", + "line": 376, + "declaration": "ReadProcessMemory :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 19, + "x64Offset": 152, + "width": 8, + "minDeclaredSize": 160, + "section": "Base", + "hostField": { + "name": "WriteProcessMemory", + "type": "pointer", + "line": 70 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@WriteProcessMemoryActual", + "line": 1881, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "WriteProcessMemory", + "line": 300, + "declaration": "PVOID WriteProcessMemory;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "WriteProcessMemory", + "line": 377, + "declaration": "WriteProcessMemory :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 20, + "x64Offset": 160, + "width": 8, + "minDeclaredSize": 168, + "section": "Base", + "hostField": { + "name": "GetThreadContext", + "type": "pointer", + "line": 71 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@GetThreadContext", + "line": 1882, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetThreadContext", + "line": 301, + "declaration": "PVOID GetThreadContext;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetThreadContext", + "line": 378, + "declaration": "GetThreadContext :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 21, + "x64Offset": 168, + "width": 8, + "minDeclaredSize": 176, + "section": "Base", + "hostField": { + "name": "SetThreadContext", + "type": "pointer", + "line": 72 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@SetThreadContext", + "line": 1883, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "SetThreadContext", + "line": 302, + "declaration": "PVOID SetThreadContext;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "SetThreadContext", + "line": 379, + "declaration": "SetThreadContext :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 22, + "x64Offset": 176, + "width": 8, + "minDeclaredSize": 184, + "section": "Base", + "hostField": { + "name": "SuspendThread", + "type": "pointer", + "line": 73 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@SuspendThread", + "line": 1884, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "SuspendThread", + "line": 303, + "declaration": "PVOID SuspendThread;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "SuspendThread", + "line": 380, + "declaration": "SuspendThread :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 23, + "x64Offset": 184, + "width": 8, + "minDeclaredSize": 192, + "section": "Base", + "hostField": { + "name": "ResumeThread", + "type": "pointer", + "line": 74 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@ResumeThread", + "line": 1885, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "ResumeThread", + "line": 304, + "declaration": "PVOID ResumeThread;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "ResumeThread", + "line": 381, + "declaration": "ResumeThread :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 24, + "x64Offset": 192, + "width": 8, + "minDeclaredSize": 200, + "section": "Base", + "hostField": { + "name": "OpenProcess", + "type": "pointer", + "line": 75 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@OpenProcess", + "line": 1886, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "OpenProcess", + "line": 305, + "declaration": "PVOID OpenProcess;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "OpenProcess", + "line": 382, + "declaration": "OpenProcess :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 25, + "x64Offset": 200, + "width": 8, + "minDeclaredSize": 208, + "section": "Base", + "hostField": { + "name": "WaitForDebugEvent", + "type": "pointer", + "line": 76 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@WaitForDebugEvent", + "line": 1887, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "WaitForDebugEvent", + "line": 306, + "declaration": "PVOID WaitForDebugEvent;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "WaitForDebugEvent", + "line": 383, + "declaration": "WaitForDebugEvent :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 26, + "x64Offset": 208, + "width": 8, + "minDeclaredSize": 216, + "section": "Base", + "hostField": { + "name": "ContinueDebugEvent", + "type": "pointer", + "line": 77 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@ContinueDebugEvent", + "line": 1888, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "ContinueDebugEvent", + "line": 307, + "declaration": "PVOID ContinueDebugEvent;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "ContinueDebugEvent", + "line": 384, + "declaration": "ContinueDebugEvent :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 27, + "x64Offset": 216, + "width": 8, + "minDeclaredSize": 224, + "section": "Base", + "hostField": { + "name": "DebugActiveProcess", + "type": "pointer", + "line": 78 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@DebugActiveProcess", + "line": 1889, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "DebugActiveProcess", + "line": 308, + "declaration": "PVOID DebugActiveProcess;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "DebugActiveProcess", + "line": 385, + "declaration": "DebugActiveProcess :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 28, + "x64Offset": 224, + "width": 8, + "minDeclaredSize": 232, + "section": "Base", + "hostField": { + "name": "StopDebugging", + "type": "pointer", + "line": 79 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1890, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "StopDebugging", + "line": 309, + "declaration": "PVOID StopDebugging;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "StopDebugging", + "line": 386, + "declaration": "StopDebugging :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 29, + "x64Offset": 232, + "width": 8, + "minDeclaredSize": 240, + "section": "Base", + "hostField": { + "name": "StopRegisterChange", + "type": "pointer", + "line": 80 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1891, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "StopRegisterChange", + "line": 310, + "declaration": "PVOID StopRegisterChange;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "StopRegisterChange", + "line": 387, + "declaration": "StopRegisterChange :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 30, + "x64Offset": 240, + "width": 8, + "minDeclaredSize": 248, + "section": "Base", + "hostField": { + "name": "VirtualProtect", + "type": "pointer", + "line": 81 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@VirtualProtect", + "line": 1892, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "VirtualProtect", + "line": 311, + "declaration": "PVOID VirtualProtect;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "VirtualProtect", + "line": 388, + "declaration": "VirtualProtect :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 31, + "x64Offset": 248, + "width": 8, + "minDeclaredSize": 256, + "section": "Base", + "hostField": { + "name": "VirtualProtectEx", + "type": "pointer", + "line": 82 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@VirtualProtectEx", + "line": 1893, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "VirtualProtectEx", + "line": 312, + "declaration": "PVOID VirtualProtectEx;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "VirtualProtectEx", + "line": 389, + "declaration": "VirtualProtectEx :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 32, + "x64Offset": 256, + "width": 8, + "minDeclaredSize": 264, + "section": "Base", + "hostField": { + "name": "VirtualQueryEx", + "type": "pointer", + "line": 83 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@VirtualQueryExActual", + "line": 1894, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "VirtualQueryEx", + "line": 313, + "declaration": "PVOID VirtualQueryEx;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "VirtualQueryEx", + "line": 390, + "declaration": "VirtualQueryEx :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 33, + "x64Offset": 264, + "width": 8, + "minDeclaredSize": 272, + "section": "Base", + "hostField": { + "name": "VirtualAllocEx", + "type": "pointer", + "line": 84 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@VirtualAllocEx", + "line": 1895, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "VirtualAllocEx", + "line": 314, + "declaration": "PVOID VirtualAllocEx;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "VirtualAllocEx", + "line": 391, + "declaration": "VirtualAllocEx :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 34, + "x64Offset": 272, + "width": 8, + "minDeclaredSize": 280, + "section": "Base", + "hostField": { + "name": "CreateRemoteThread", + "type": "pointer", + "line": 85 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@CreateRemoteThread", + "line": 1896, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "CreateRemoteThread", + "line": 315, + "declaration": "PVOID CreateRemoteThread;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "CreateRemoteThread", + "line": 392, + "declaration": "CreateRemoteThread :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 35, + "x64Offset": 280, + "width": 8, + "minDeclaredSize": 288, + "section": "Base", + "hostField": { + "name": "OpenThread", + "type": "pointer", + "line": 86 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@OpenThread", + "line": 1897, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "OpenThread", + "line": 316, + "declaration": "PVOID OpenThread;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "OpenThread", + "line": 393, + "declaration": "OpenThread :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 36, + "x64Offset": 288, + "width": 8, + "minDeclaredSize": 296, + "section": "Base", + "hostField": { + "name": "GetPEProcess", + "type": "pointer", + "line": 87 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetPEProcess", + "line": 1898, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetPEProcess", + "line": 317, + "declaration": "PVOID GetPEProcess;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetPEProcess", + "line": 394, + "declaration": "GetPEProcess :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 37, + "x64Offset": 296, + "width": 8, + "minDeclaredSize": 304, + "section": "Base", + "hostField": { + "name": "GetPEThread", + "type": "pointer", + "line": 88 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetPEThread", + "line": 1899, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetPEThread", + "line": 318, + "declaration": "PVOID GetPEThread;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetPEThread", + "line": 395, + "declaration": "GetPEThread :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 38, + "x64Offset": 304, + "width": 8, + "minDeclaredSize": 312, + "section": "Base", + "hostField": { + "name": "GetThreadsProcessOffset", + "type": "pointer", + "line": 89 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetThreadsProcessOffset", + "line": 1900, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetThreadsProcessOffset", + "line": 319, + "declaration": "PVOID GetThreadsProcessOffset;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetThreadsProcessOffset", + "line": 396, + "declaration": "GetThreadsProcessOffset:ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 39, + "x64Offset": 312, + "width": 8, + "minDeclaredSize": 320, + "section": "Base", + "hostField": { + "name": "GetThreadListEntryOffset", + "type": "pointer", + "line": 90 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetThreadListEntryOffset", + "line": 1901, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetThreadListEntryOffset", + "line": 320, + "declaration": "PVOID GetThreadListEntryOffset;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetThreadListEntryOffset", + "line": 397, + "declaration": "GetThreadListEntryOffset:ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 40, + "x64Offset": 320, + "width": 8, + "minDeclaredSize": 328, + "section": "Base", + "hostField": { + "name": "GetProcessnameOffset", + "type": "pointer", + "line": 91 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1902, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "GetProcessnameOffset", + "line": 321, + "declaration": "PVOID GetProcessnameOffset;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetProcessnameOffset", + "line": 398, + "declaration": "GetProcessnameOffset :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 41, + "x64Offset": 328, + "width": 8, + "minDeclaredSize": 336, + "section": "Base", + "hostField": { + "name": "GetDebugportOffset", + "type": "pointer", + "line": 92 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetDebugportOffset", + "line": 1903, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetDebugportOffset", + "line": 322, + "declaration": "PVOID GetDebugportOffset;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetDebugportOffset", + "line": 399, + "declaration": "GetDebugportOffset :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 42, + "x64Offset": 336, + "width": 8, + "minDeclaredSize": 344, + "section": "Base", + "hostField": { + "name": "GetPhysicalAddress", + "type": "pointer", + "line": 93 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetPhysicalAddress", + "line": 1904, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetPhysicalAddress", + "line": 323, + "declaration": "PVOID GetPhysicalAddress;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetPhysicalAddress", + "line": 400, + "declaration": "GetPhysicalAddress :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 43, + "x64Offset": 344, + "width": 8, + "minDeclaredSize": 352, + "section": "Base", + "hostField": { + "name": "ProtectMe", + "type": "pointer", + "line": 94 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1905, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "ProtectMe", + "line": 324, + "declaration": "PVOID ProtectMe;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "ProtectMe", + "line": 401, + "declaration": "ProtectMe :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 44, + "x64Offset": 352, + "width": 8, + "minDeclaredSize": 360, + "section": "Base", + "hostField": { + "name": "GetCR4", + "type": "pointer", + "line": 95 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetCR4", + "line": 1906, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetCR4", + "line": 325, + "declaration": "PVOID GetCR4;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetCR4", + "line": 402, + "declaration": "GetCR4 :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 45, + "x64Offset": 360, + "width": 8, + "minDeclaredSize": 368, + "section": "Base", + "hostField": { + "name": "GetCR3", + "type": "pointer", + "line": 96 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetCR3", + "line": 1907, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetCR3", + "line": 326, + "declaration": "PVOID GetCR3;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetCR3", + "line": 403, + "declaration": "GetCR3 :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 46, + "x64Offset": 368, + "width": 8, + "minDeclaredSize": 376, + "section": "Base", + "hostField": { + "name": "SetCR3", + "type": "pointer", + "line": 97 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1908, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "SetCR3", + "line": 327, + "declaration": "PVOID SetCR3;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "SetCR3", + "line": 404, + "declaration": "SetCR3 :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 47, + "x64Offset": 376, + "width": 8, + "minDeclaredSize": 384, + "section": "Base", + "hostField": { + "name": "GetSDT", + "type": "pointer", + "line": 98 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetSDT", + "line": 1909, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetSDT", + "line": 328, + "declaration": "PVOID GetSDT;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetSDT", + "line": 405, + "declaration": "GetSDT :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 48, + "x64Offset": 384, + "width": 8, + "minDeclaredSize": 392, + "section": "Base", + "hostField": { + "name": "GetSDTShadow", + "type": "pointer", + "line": 99 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetSDTShadow", + "line": 1910, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetSDTShadow", + "line": 329, + "declaration": "PVOID GetSDTShadow;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetSDTShadow", + "line": 406, + "declaration": "GetSDTShadow :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 49, + "x64Offset": 392, + "width": 8, + "minDeclaredSize": 400, + "section": "Base", + "hostField": { + "name": "setAlternateDebugMethod", + "type": "pointer", + "line": 100 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1911, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "setAlternateDebugMethod", + "line": 330, + "declaration": "PVOID setAlternateDebugMethod;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "setAlternateDebugMethod", + "line": 407, + "declaration": "setAlternateDebugMethod: ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 50, + "x64Offset": 400, + "width": 8, + "minDeclaredSize": 408, + "section": "Base", + "hostField": { + "name": "getAlternateDebugMethod", + "type": "pointer", + "line": 101 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1912, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "getAlternateDebugMethod", + "line": 331, + "declaration": "PVOID getAlternateDebugMethod;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "getAlternateDebugMethod", + "line": 408, + "declaration": "getAlternateDebugMethod: ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 51, + "x64Offset": 408, + "width": 8, + "minDeclaredSize": 416, + "section": "Base", + "hostField": { + "name": "DebugProcess", + "type": "pointer", + "line": 102 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1913, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "DebugProcess", + "line": 332, + "declaration": "PVOID DebugProcess;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "DebugProcess", + "line": 409, + "declaration": "DebugProcess :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 52, + "x64Offset": 416, + "width": 8, + "minDeclaredSize": 424, + "section": "Base", + "hostField": { + "name": "ChangeRegOnBP", + "type": "pointer", + "line": 103 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1914, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "ChangeRegOnBP", + "line": 333, + "declaration": "PVOID ChangeRegOnBP;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "ChangeRegOnBP", + "line": 410, + "declaration": "ChangeRegOnBP :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 53, + "x64Offset": 424, + "width": 8, + "minDeclaredSize": 432, + "section": "Base", + "hostField": { + "name": "RetrieveDebugData", + "type": "pointer", + "line": 104 + }, + "hostAssignment": { + "kind": "Nil", + "expression": "nil", + "line": 1915, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Unknown", + "indirection": 0, + "callingConvention": "NotApplicable", + "nullability": "NilAssigned", + "mirrors": { + "headerC": { + "name": "RetrieveDebugData", + "line": 334, + "declaration": "PVOID RetrieveDebugData;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "RetrieveDebugData", + "line": 411, + "declaration": "RetrieveDebugData :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "NotApplicable", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 54, + "x64Offset": 432, + "width": 8, + "minDeclaredSize": 440, + "section": "Base", + "hostField": { + "name": "StartProcessWatch", + "type": "pointer", + "line": 105 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@StartProcessWatch", + "line": 1916, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "StartProcessWatch", + "line": 335, + "declaration": "PVOID StartProcessWatch;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "StartProcessWatch", + "line": 412, + "declaration": "StartProcessWatch :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 55, + "x64Offset": 440, + "width": 8, + "minDeclaredSize": 448, + "section": "Base", + "hostField": { + "name": "WaitForProcessListData", + "type": "pointer", + "line": 106 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@WaitForProcessListData", + "line": 1917, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "WaitForProcessListData", + "line": 336, + "declaration": "PVOID WaitForProcessListData;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "WaitForProcessListData", + "line": 413, + "declaration": "WaitForProcessListData:ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 56, + "x64Offset": 448, + "width": 8, + "minDeclaredSize": 456, + "section": "Base", + "hostField": { + "name": "GetProcessNameFromID", + "type": "pointer", + "line": 107 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetProcessNameFromID", + "line": 1918, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetProcessNameFromID", + "line": 337, + "declaration": "PVOID GetProcessNameFromID;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetProcessNameFromID", + "line": 414, + "declaration": "GetProcessNameFromID :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 57, + "x64Offset": 456, + "width": 8, + "minDeclaredSize": 464, + "section": "Base", + "hostField": { + "name": "GetProcessNameFromPEProcess", + "type": "pointer", + "line": 108 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetProcessNameFromPEProcess", + "line": 1919, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetProcessNameFromPEProcess", + "line": 338, + "declaration": "PVOID GetProcessNameFromPEProcess;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetProcessNameFromPEProcess", + "line": 415, + "declaration": "GetProcessNameFromPEProcess:ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 58, + "x64Offset": 464, + "width": 8, + "minDeclaredSize": 472, + "section": "Base", + "hostField": { + "name": "KernelOpenProcess", + "type": "pointer", + "line": 109 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@dbk32functions.OP", + "line": 1920, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "KernelOpenProcess", + "line": 339, + "declaration": "PVOID KernelOpenProcess;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "KernelOpenProcess", + "line": 416, + "declaration": "KernelOpenProcess :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 59, + "x64Offset": 472, + "width": 8, + "minDeclaredSize": 480, + "section": "Base", + "hostField": { + "name": "KernelReadProcessMemory", + "type": "pointer", + "line": 110 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@dbk32functions.RPM", + "line": 1921, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "KernelReadProcessMemory", + "line": 340, + "declaration": "PVOID KernelReadProcessMemory;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "KernelReadProcessMemory", + "line": 417, + "declaration": "KernelReadProcessMemory :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 60, + "x64Offset": 480, + "width": 8, + "minDeclaredSize": 488, + "section": "Base", + "hostField": { + "name": "KernelWriteProcessMemory", + "type": "pointer", + "line": 111 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@dbk32functions.WPM", + "line": 1922, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "KernelWriteProcessMemory", + "line": 341, + "declaration": "PVOID KernelWriteProcessMemory;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "KernelWriteProcessMemory", + "line": 418, + "declaration": "KernelWriteProcessMemory:ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 61, + "x64Offset": 488, + "width": 8, + "minDeclaredSize": 496, + "section": "Base", + "hostField": { + "name": "KernelVirtualAllocEx", + "type": "pointer", + "line": 112 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@VQE", + "line": 1923, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "KernelVirtualAllocEx", + "line": 342, + "declaration": "PVOID KernelVirtualAllocEx;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "KernelVirtualAllocEx", + "line": 419, + "declaration": "KernelVirtualAllocEx :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D11", + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 62, + "x64Offset": 496, + "width": 8, + "minDeclaredSize": 504, + "section": "Base", + "hostField": { + "name": "IsValidHandle", + "type": "pointer", + "line": 113 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@IsValidHandle", + "line": 1924, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "IsValidHandle", + "line": 343, + "declaration": "PVOID IsValidHandle;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "IsValidHandle", + "line": 420, + "declaration": "IsValidHandle :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 63, + "x64Offset": 504, + "width": 8, + "minDeclaredSize": 512, + "section": "Base", + "hostField": { + "name": "GetIDTCurrentThread", + "type": "pointer", + "line": 114 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetIDTCurrentThread", + "line": 1925, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetIDTCurrentThread", + "line": 344, + "declaration": "PVOID GetIDTCurrentThread;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetIDTCurrentThread", + "line": 421, + "declaration": "GetIDTCurrentThread :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 64, + "x64Offset": 512, + "width": 8, + "minDeclaredSize": 520, + "section": "Base", + "hostField": { + "name": "GetIDTs", + "type": "pointer", + "line": 115 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetIDTs", + "line": 1926, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetIDTs", + "line": 345, + "declaration": "PVOID GetIDTs;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetIDTs", + "line": 422, + "declaration": "GetIDTs :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 65, + "x64Offset": 520, + "width": 8, + "minDeclaredSize": 528, + "section": "Base", + "hostField": { + "name": "MakeWritable", + "type": "pointer", + "line": 116 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@MakeWritable", + "line": 1927, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "MakeWritable", + "line": 346, + "declaration": "PVOID MakeWritable;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "MakeWritable", + "line": 423, + "declaration": "MakeWritable :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 66, + "x64Offset": 528, + "width": 8, + "minDeclaredSize": 536, + "section": "Base", + "hostField": { + "name": "GetLoadedState", + "type": "pointer", + "line": 117 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetLoadedState", + "line": 1928, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetLoadedState", + "line": 347, + "declaration": "PVOID GetLoadedState;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetLoadedState", + "line": 424, + "declaration": "GetLoadedState :TGetLoadedState;", + "typeDeclaration": { + "line": 340, + "text": "type TGetLoadedState=function: BOOLEAN; stdcall;" + } + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 67, + "x64Offset": 536, + "width": 8, + "minDeclaredSize": 544, + "section": "Base", + "hostField": { + "name": "DBKSuspendThread", + "type": "pointer", + "line": 118 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@DBKSuspendThread", + "line": 1929, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "DBKSuspendThread", + "line": 348, + "declaration": "PVOID DBKSuspendThread;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "DBKSuspendThread", + "line": 425, + "declaration": "DBKSuspendThread :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 68, + "x64Offset": 544, + "width": 8, + "minDeclaredSize": 552, + "section": "Base", + "hostField": { + "name": "DBKResumeThread", + "type": "pointer", + "line": 119 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@DBKResumeThread", + "line": 1930, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "DBKResumeThread", + "line": 349, + "declaration": "PVOID DBKResumeThread;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "DBKResumeThread", + "line": 426, + "declaration": "DBKResumeThread :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 69, + "x64Offset": 552, + "width": 8, + "minDeclaredSize": 560, + "section": "Base", + "hostField": { + "name": "DBKSuspendProcess", + "type": "pointer", + "line": 120 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@DBKSuspendProcess", + "line": 1931, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "DBKSuspendProcess", + "line": 350, + "declaration": "PVOID DBKSuspendProcess;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "DBKSuspendProcess", + "line": 427, + "declaration": "DBKSuspendProcess :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 70, + "x64Offset": 560, + "width": 8, + "minDeclaredSize": 568, + "section": "Base", + "hostField": { + "name": "DBKResumeProcess", + "type": "pointer", + "line": 121 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@DBKResumeProcess", + "line": 1932, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "DBKResumeProcess", + "line": 351, + "declaration": "PVOID DBKResumeProcess;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "DBKResumeProcess", + "line": 428, + "declaration": "DBKResumeProcess :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 71, + "x64Offset": 568, + "width": 8, + "minDeclaredSize": 576, + "section": "Base", + "hostField": { + "name": "KernelAlloc", + "type": "pointer", + "line": 122 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@KernelAlloc", + "line": 1933, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "KernelAlloc", + "line": 352, + "declaration": "PVOID KernelAlloc;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "KernelAlloc", + "line": 429, + "declaration": "KernelAlloc :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 72, + "x64Offset": 576, + "width": 8, + "minDeclaredSize": 584, + "section": "Base", + "hostField": { + "name": "GetKProcAddress", + "type": "pointer", + "line": 123 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@GetKProcAddress", + "line": 1934, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetKProcAddress", + "line": 353, + "declaration": "PVOID GetKProcAddress;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "GetKProcAddress", + "line": 430, + "declaration": "GetKProcAddress :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [ + "D12" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 73, + "x64Offset": 584, + "width": 8, + "minDeclaredSize": 592, + "section": "Base", + "hostField": { + "name": "CreateToolhelp32Snapshot", + "type": "pointer", + "line": 124 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@CreateToolhelp32Snapshot", + "line": 1936, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "CreateToolhelp32Snapshot", + "line": 354, + "declaration": "PVOID CreateToolhelp32Snapshot;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "CreateToolhelp32Snapshot", + "line": 431, + "declaration": "CreateToolhelp32Snapshot:ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 74, + "x64Offset": 592, + "width": 8, + "minDeclaredSize": 600, + "section": "Base", + "hostField": { + "name": "Process32First", + "type": "pointer", + "line": 125 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@Process32First", + "line": 1937, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Process32First", + "line": 355, + "declaration": "PVOID Process32First;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "Process32First", + "line": 432, + "declaration": "Process32First :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 75, + "x64Offset": 600, + "width": 8, + "minDeclaredSize": 608, + "section": "Base", + "hostField": { + "name": "Process32Next", + "type": "pointer", + "line": 126 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@Process32Next", + "line": 1938, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Process32Next", + "line": 356, + "declaration": "PVOID Process32Next;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "Process32Next", + "line": 433, + "declaration": "Process32Next :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 76, + "x64Offset": 608, + "width": 8, + "minDeclaredSize": 616, + "section": "Base", + "hostField": { + "name": "Thread32First", + "type": "pointer", + "line": 127 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@Thread32First", + "line": 1939, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Thread32First", + "line": 357, + "declaration": "PVOID Thread32First;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "Thread32First", + "line": 434, + "declaration": "Thread32First :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 77, + "x64Offset": 616, + "width": 8, + "minDeclaredSize": 624, + "section": "Base", + "hostField": { + "name": "Thread32Next", + "type": "pointer", + "line": 128 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@Thread32Next", + "line": 1940, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Thread32Next", + "line": 358, + "declaration": "PVOID Thread32Next;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "Thread32Next", + "line": 435, + "declaration": "Thread32Next :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 78, + "x64Offset": 624, + "width": 8, + "minDeclaredSize": 632, + "section": "Base", + "hostField": { + "name": "Module32First", + "type": "pointer", + "line": 129 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@Module32First", + "line": 1941, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Module32First", + "line": 359, + "declaration": "PVOID Module32First;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "Module32First", + "line": 436, + "declaration": "Module32First :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 79, + "x64Offset": 632, + "width": 8, + "minDeclaredSize": 640, + "section": "Base", + "hostField": { + "name": "Module32Next", + "type": "pointer", + "line": 130 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@Module32Next", + "line": 1942, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Module32Next", + "line": 360, + "declaration": "PVOID Module32Next;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "Module32Next", + "line": 437, + "declaration": "Module32Next :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 80, + "x64Offset": 640, + "width": 8, + "minDeclaredSize": 648, + "section": "Base", + "hostField": { + "name": "Heap32ListFirst", + "type": "pointer", + "line": 131 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@Heap32ListFirst", + "line": 1943, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Heap32ListFirst", + "line": 361, + "declaration": "PVOID Heap32ListFirst;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "Heap32ListFirst", + "line": 438, + "declaration": "Heap32ListFirst :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 81, + "x64Offset": 648, + "width": 8, + "minDeclaredSize": 656, + "section": "Base", + "hostField": { + "name": "Heap32ListNext", + "type": "pointer", + "line": 132 + }, + "hostAssignment": { + "kind": "CellAddress", + "expression": "@@Heap32ListNext", + "line": 1944, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "FunctionPointerCell", + "indirection": 1, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "Heap32ListNext", + "line": 362, + "declaration": "PVOID Heap32ListNext;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "Heap32ListNext", + "line": 439, + "declaration": "Heap32ListNext :ppointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 82, + "x64Offset": 656, + "width": 8, + "minDeclaredSize": 664, + "section": "Base", + "hostField": { + "name": "mainform", + "type": "pointer", + "line": 135 + }, + "hostAssignment": { + "kind": "VariableAddress", + "expression": "@mainform", + "line": 1950, + "condition": null + }, + "hostImplementation": null, + "nature": "ObjectRefCell", + "indirection": 1, + "callingConvention": "NotApplicable", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "mainform", + "line": 366, + "declaration": "PVOID mainform;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "mainform", + "line": 442, + "declaration": "mainform :pointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 83, + "x64Offset": 664, + "width": 8, + "minDeclaredSize": 672, + "section": "Base", + "hostField": { + "name": "memorybrowser", + "type": "pointer", + "line": 136 + }, + "hostAssignment": { + "kind": "VariableAddress", + "expression": "@memorybrowser", + "line": 1951, + "condition": null + }, + "hostImplementation": null, + "nature": "ObjectRefCell", + "indirection": 1, + "callingConvention": "NotApplicable", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memorybrowser", + "line": 367, + "declaration": "PVOID memorybrowser;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "memorybrowser", + "line": 443, + "declaration": "memorybrowser :pointer;", + "typeDeclaration": null + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Borrowed", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 84, + "x64Offset": 672, + "width": 8, + "minDeclaredSize": 680, + "section": "V2", + "hostField": { + "name": "sym_nameToAddress", + "type": "pointer", + "line": 139 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_sym_nameToAddress", + "line": 1954, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 39, + "declaration": "function ce_sym_nameToAddress(name: pchar; address: PPtrUInt):BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "sym_nameToAddress", + "line": 370, + "declaration": "CEP_NAMETOADDRESS sym_nameToAddress;", + "typeDeclaration": { + "line": 181, + "text": "typedef BOOL (__stdcall *CEP_NAMETOADDRESS) (char *name, UINT_PTR *address);" + } + }, + "pascalMirror": { + "name": "sym_nameToAddress", + "line": 446, + "declaration": "sym_nameToAddress : Tce_sym_NameToAddress;", + "typeDeclaration": { + "line": 245, + "text": "type Tce_sym_nameToAddress=function(name: pchar; address: Pptruint):BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 85, + "x64Offset": 680, + "width": 8, + "minDeclaredSize": 688, + "section": "V2", + "hostField": { + "name": "sym_addressToName", + "type": "pointer", + "line": 140 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_sym_addressToName", + "line": 1955, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 38, + "declaration": "function ce_sym_addressToName(address:ptrUint; name: pchar; maxnamesize: integer):BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "sym_addressToName", + "line": 371, + "declaration": "CEP_ADDRESSTONAME sym_addressToName;", + "typeDeclaration": { + "line": 180, + "text": "typedef BOOL (__stdcall *CEP_ADDRESSTONAME) (UINT_PTR address, char *name, int maxnamesize);" + } + }, + "pascalMirror": { + "name": "sym_addressToName", + "line": 447, + "declaration": "sym_addressToName : Tce_sym_addressToName;", + "typeDeclaration": { + "line": 244, + "text": "type Tce_sym_addressToName=function(address:ptruint; name: pchar; maxnamesize: integer):BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 86, + "x64Offset": 688, + "width": 8, + "minDeclaredSize": 696, + "section": "V2", + "hostField": { + "name": "sym_generateAPIHookScript", + "type": "pointer", + "line": 141 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_generateAPIHookScript", + "line": 1956, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 40, + "declaration": "function ce_generateAPIHookScript(address, addresstojumpto, addresstogetnewcalladdress, script: pchar; maxscriptsize: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "sym_generateAPIHookScript", + "line": 372, + "declaration": "CEP_GENERATEAPIHOOKSCRIPT sym_generateAPIHookScript;", + "typeDeclaration": { + "line": 179, + "text": "typedef BOOL (__stdcall *CEP_GENERATEAPIHOOKSCRIPT) (char *address, char *addresstojumpto, char *addresstogetnewcalladdress, char *script, int maxscriptsize);" + } + }, + "pascalMirror": { + "name": "ce_generateAPIHookScript", + "line": 448, + "declaration": "ce_generateAPIHookScript : Tce_generateAPIHookScript;", + "typeDeclaration": { + "line": 243, + "text": "type Tce_generateAPIHookScript=function(address, addresstojumpto, addresstogetnewcalladdress, script: pchar; maxscriptsize: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 87, + "x64Offset": 696, + "width": 8, + "minDeclaredSize": 704, + "section": "V3", + "hostField": { + "name": "loadDBK32", + "type": "pointer", + "line": 144 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@LoadDBK32", + "line": 1960, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "loadDBK32", + "line": 375, + "declaration": "CEP_LOADDBK32 loadDBK32;", + "typeDeclaration": { + "line": 183, + "text": "typedef VOID (__stdcall *CEP_LOADDBK32)(void);" + } + }, + "pascalMirror": { + "name": "loadDBK32", + "line": 451, + "declaration": "loadDBK32 : TLoadDBK32;", + "typeDeclaration": { + "line": 260, + "text": "type TLoadDBK32=procedure; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 88, + "x64Offset": 704, + "width": 8, + "minDeclaredSize": 712, + "section": "V3", + "hostField": { + "name": "loaddbvmifneeded", + "type": "pointer", + "line": 145 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@loaddbvmifneeded", + "line": 1961, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "loaddbvmifneeded", + "line": 376, + "declaration": "CEP_LOADDBVMIFNEEDED loaddbvmifneeded;", + "typeDeclaration": { + "line": 184, + "text": "typedef BOOL (__stdcall *CEP_LOADDBVMIFNEEDED)(void);" + } + }, + "pascalMirror": { + "name": "loaddbvmifneeded", + "line": 452, + "declaration": "loaddbvmifneeded : TLoadDBVMifneeded;", + "typeDeclaration": { + "line": 261, + "text": "type TLoadDBVMifneeded=function: BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 89, + "x64Offset": 712, + "width": 8, + "minDeclaredSize": 720, + "section": "V3", + "hostField": { + "name": "previousOpcode", + "type": "pointer", + "line": 146 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_previousOpcode", + "line": 1963, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 28, + "declaration": "function ce_previousOpcode(address:ptrUint): ptrUint; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "previousOpcode", + "line": 377, + "declaration": "CEP_PREVIOUSOPCODE previousOpcode;", + "typeDeclaration": { + "line": 185, + "text": "typedef DWORD (__stdcall *CEP_PREVIOUSOPCODE)(UINT_PTR address);" + } + }, + "pascalMirror": { + "name": "previousOpcode", + "line": 453, + "declaration": "previousOpcode : TPreviousOpcode;", + "typeDeclaration": { + "line": 262, + "text": "type TPreviousOpcode=function(address:ptruint): dword; stdcall;" + } + } + }, + "divergenceRefs": [ + "D02" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 90, + "x64Offset": 720, + "width": 8, + "minDeclaredSize": 728, + "section": "V3", + "hostField": { + "name": "nextOpcode", + "type": "pointer", + "line": 147 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_nextOpcode", + "line": 1964, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 29, + "declaration": "function ce_nextOpcode(address:ptrUint): ptrUint; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "nextOpcode", + "line": 378, + "declaration": "CEP_NEXTOPCODE nextOpcode;", + "typeDeclaration": { + "line": 186, + "text": "typedef DWORD (__stdcall *CEP_NEXTOPCODE)(UINT_PTR address);" + } + }, + "pascalMirror": { + "name": "nextopcode", + "line": 454, + "declaration": "nextopcode : TNextOpcode;", + "typeDeclaration": { + "line": 263, + "text": "type TNextOpcode=function(address:ptruint): dword; stdcall;" + } + } + }, + "divergenceRefs": [ + "D02" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 91, + "x64Offset": 728, + "width": 8, + "minDeclaredSize": 736, + "section": "V3", + "hostField": { + "name": "disassembleEx", + "type": "pointer", + "line": 148 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_disassemble", + "line": 1965, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 27, + "declaration": "function ce_disassemble(address: pptrUint; output: pchar; maxsize: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "disassembleEx", + "line": 379, + "declaration": "CEP_DISASSEMBLEEX disassembleEx;", + "typeDeclaration": { + "line": 188, + "text": "typedef BOOL (__stdcall *CEP_DISASSEMBLEEX)(UINT_PTR address, char *output, int maxsize);" + } + }, + "pascalMirror": { + "name": "disassembleEx", + "line": 455, + "declaration": "disassembleEx : TDisassembleEx;", + "typeDeclaration": { + "line": 265, + "text": "type TDisassembleEx=function(address: pptruint; output: pchar; maxsize: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [ + "D03" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 92, + "x64Offset": 736, + "width": 8, + "minDeclaredSize": 744, + "section": "V3", + "hostField": { + "name": "loadModule", + "type": "pointer", + "line": 149 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_loadModule", + "line": 1966, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 43, + "declaration": "function ce_loadModule(modulepath: pchar; exportlist: pchar; maxsize: pinteger): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "loadModule", + "line": 380, + "declaration": "CEP_LOADMODULE loadModule;", + "typeDeclaration": { + "line": 187, + "text": "typedef BOOL (__stdcall *CEP_LOADMODULE)(char *modulepath, char *exportlist, int *maxsize);" + } + }, + "pascalMirror": { + "name": "loadModule", + "line": 456, + "declaration": "loadModule : TloadModule;", + "typeDeclaration": { + "line": 264, + "text": "type TloadModule=function(modulepath: pchar; exportlist: pchar; maxsize: pinteger): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "loadModule" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 93, + "x64Offset": 744, + "width": 8, + "minDeclaredSize": 752, + "section": "V3", + "hostField": { + "name": "aa_AddExtraCommand", + "type": "pointer", + "line": 150 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@aa_AddExtraCommand", + "line": 1968, + "condition": null + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "aa_AddExtraCommand", + "line": 381, + "declaration": "CEP_AA_ADDCOMMAND aa_AddExtraCommand;", + "typeDeclaration": { + "line": 189, + "text": "typedef VOID (__stdcall *CEP_AA_ADDCOMMAND)(char *command);" + } + }, + "pascalMirror": { + "name": "aa_AddExtraCommand", + "line": 458, + "declaration": "aa_AddExtraCommand: Taa_AddExtraCommand;", + "typeDeclaration": { + "line": 266, + "text": "type Taa_AddExtraCommand=procedure(command:pchar);" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 94, + "x64Offset": 752, + "width": 8, + "minDeclaredSize": 760, + "section": "V3", + "hostField": { + "name": "aa_RemoveExtraCommand", + "type": "pointer", + "line": 151 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@aa_RemoveExtraCommand", + "line": 1969, + "condition": null + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "aa_RemoveExtraCommand", + "line": 382, + "declaration": "CEP_AA_DELCOMMAND aa_RemoveExtraCommand;", + "typeDeclaration": { + "line": 190, + "text": "typedef VOID (__stdcall *CEP_AA_DELCOMMAND)(char *command);" + } + }, + "pascalMirror": { + "name": "aa_RemoveExtraCommand", + "line": 459, + "declaration": "aa_RemoveExtraCommand:Taa_RemoveExtraCommand;", + "typeDeclaration": { + "line": 267, + "text": "type Taa_RemoveExtraCommand=procedure(command:pchar);" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 95, + "x64Offset": 760, + "width": 8, + "minDeclaredSize": 768, + "section": "V4", + "hostField": { + "name": "createTableEntry", + "type": "pointer", + "line": 154 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createTableEntry", + "line": 1972, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 44, + "declaration": "function ce_createTableEntry: pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createTableEntry", + "line": 385, + "declaration": "CEP_CREATETABLEENTRY createTableEntry;", + "typeDeclaration": { + "line": 192, + "text": "typedef PVOID (__stdcall *CEP_CREATETABLEENTRY)(void);" + } + }, + "pascalMirror": { + "name": "createTableEntry", + "line": 462, + "declaration": "createTableEntry: TcreateTableEntry;", + "typeDeclaration": { + "line": 272, + "text": "type TcreateTableEntry=function: pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 96, + "x64Offset": 768, + "width": 8, + "minDeclaredSize": 776, + "section": "V4", + "hostField": { + "name": "getTableEntry", + "type": "pointer", + "line": 155 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_getTableEntry", + "line": 1973, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 45, + "declaration": "function ce_getTableEntry(description: pchar): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "getTableEntry", + "line": 386, + "declaration": "CEP_GETTABLEENTRY getTableEntry;", + "typeDeclaration": { + "line": 193, + "text": "typedef PVOID (__stdcall *CEP_GETTABLEENTRY)(char *description);" + } + }, + "pascalMirror": { + "name": "getTableEntry", + "line": 463, + "declaration": "getTableEntry: TgetTableEntry;", + "typeDeclaration": { + "line": 273, + "text": "type TgetTableEntry=function(description: pchar): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 97, + "x64Offset": 776, + "width": 8, + "minDeclaredSize": 784, + "section": "V4", + "hostField": { + "name": "memrec_setDescription", + "type": "pointer", + "line": 156 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_setDescription", + "line": 1974, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 46, + "declaration": "function ce_memrec_setDescription(memrec: pointer; description: pchar): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_setDescription", + "line": 387, + "declaration": "CEP_MEMREC_SETDESCRIPTION memrec_setDescription;", + "typeDeclaration": { + "line": 194, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_SETDESCRIPTION)(PVOID memrec, char *description);" + } + }, + "pascalMirror": { + "name": "memrec_setDescription", + "line": 464, + "declaration": "memrec_setDescription: Tmemrec_setDescription;", + "typeDeclaration": { + "line": 274, + "text": "type Tmemrec_setDescription=function(memrec: pointer; description: pchar): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 98, + "x64Offset": 784, + "width": 8, + "minDeclaredSize": 792, + "section": "V4", + "hostField": { + "name": "memrec_getDescription", + "type": "pointer", + "line": 157 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_getDescription", + "line": 1975, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 47, + "declaration": "function ce_memrec_getDescription(memrec: pointer): pchar; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_getDescription", + "line": 388, + "declaration": "CEP_MEMREC_GETDESCRIPTION memrec_getDescription;", + "typeDeclaration": { + "line": 195, + "text": "typedef PCHAR (__stdcall *CEP_MEMREC_GETDESCRIPTION)(PVOID memrec);" + } + }, + "pascalMirror": { + "name": "memrec_getDescription", + "line": 465, + "declaration": "memrec_getDescription: Tmemrec_getDescription;", + "typeDeclaration": { + "line": 275, + "text": "type Tmemrec_getDescription=function(memrec: pointer): pchar; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 99, + "x64Offset": 792, + "width": 8, + "minDeclaredSize": 800, + "section": "V4", + "hostField": { + "name": "memrec_getAddress", + "type": "pointer", + "line": 158 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_getAddress", + "line": 1976, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 48, + "declaration": "function ce_memrec_getAddress(memrec: pointer; address: pptruint; offsets: PDwordArray; maxoffsets: integer; neededOffsets: pinteger): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_getAddress", + "line": 389, + "declaration": "CEP_MEMREC_GETADDRESS memrec_getAddress;", + "typeDeclaration": { + "line": 196, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_GETADDRESS)(PVOID memrec, UINT_PTR *address, DWORD *offsets, int maxoffsets, int *neededOffsets);" + } + }, + "pascalMirror": { + "name": "memrec_getAddress", + "line": 466, + "declaration": "memrec_getAddress: Tmemrec_getAddress;", + "typeDeclaration": { + "line": 276, + "text": "type Tmemrec_getAddress=function(memrec: pointer; address: pptruint; offsets: PDwordArray; maxoffsets: integer; neededOffsets: pinteger): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 100, + "x64Offset": 800, + "width": 8, + "minDeclaredSize": 808, + "section": "V4", + "hostField": { + "name": "memrec_setAddress", + "type": "pointer", + "line": 159 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_setAddress", + "line": 1977, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 49, + "declaration": "function ce_memrec_setAddress(memrec: pointer; address: pchar; offsets: PDwordArray; offsetcount: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_setAddress", + "line": 390, + "declaration": "CEP_MEMREC_SETADDRESS memrec_setAddress;", + "typeDeclaration": { + "line": 197, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_SETADDRESS)(PVOID memrec, char *address, DWORD *offsets, int offsetcount);" + } + }, + "pascalMirror": { + "name": "memrec_setAddress", + "line": 467, + "declaration": "memrec_setAddress: Tmemrec_setAddress;", + "typeDeclaration": { + "line": 277, + "text": "type Tmemrec_setAddress=function(memrec: pointer; address: pchar; offsets: PDwordArray; offsetcount: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 101, + "x64Offset": 808, + "width": 8, + "minDeclaredSize": 816, + "section": "V4", + "hostField": { + "name": "memrec_getType", + "type": "pointer", + "line": 160 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_getType", + "line": 1978, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 50, + "declaration": "function ce_memrec_getType(memrec: pointer): integer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_getType", + "line": 391, + "declaration": "CEP_MEMREC_GETTYPE memrec_getType;", + "typeDeclaration": { + "line": 198, + "text": "typedef int (__stdcall *CEP_MEMREC_GETTYPE)(PVOID memrec);" + } + }, + "pascalMirror": { + "name": "memrec_getType", + "line": 468, + "declaration": "memrec_getType: Tmemrec_getType;", + "typeDeclaration": { + "line": 278, + "text": "type Tmemrec_getType=function(memrec: pointer): integer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 102, + "x64Offset": 816, + "width": 8, + "minDeclaredSize": 824, + "section": "V4", + "hostField": { + "name": "memrec_setType", + "type": "pointer", + "line": 161 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_setType", + "line": 1979, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 51, + "declaration": "function ce_memrec_setType(memrec: pointer; vtype: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_setType", + "line": 392, + "declaration": "CEP_MEMREC_SETTYPE memrec_setType;", + "typeDeclaration": { + "line": 199, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_SETTYPE)(PVOID memrec, int vtype);" + } + }, + "pascalMirror": { + "name": "memrec_setType", + "line": 469, + "declaration": "memrec_setType: Tmemrec_setType;", + "typeDeclaration": { + "line": 279, + "text": "type Tmemrec_setType=function(memrec: pointer; vtype: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 103, + "x64Offset": 824, + "width": 8, + "minDeclaredSize": 832, + "section": "V4", + "hostField": { + "name": "memrec_getValue", + "type": "pointer", + "line": 162 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_getValue", + "line": 1980, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 52, + "declaration": "function ce_memrec_getValue(memrec: pointer; value: pchar; maxsize: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_getValue", + "line": 393, + "declaration": "CEP_MEMREC_GETVALUETYPE memrec_getValue;", + "typeDeclaration": { + "line": 200, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_GETVALUETYPE)(PVOID memrec, char *value, int maxsize);" + } + }, + "pascalMirror": { + "name": "memrec_getValue", + "line": 470, + "declaration": "memrec_getValue: Tmemrec_getValue;", + "typeDeclaration": { + "line": 280, + "text": "type Tmemrec_getValue=function(memrec: pointer; value: pchar; maxsize: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 104, + "x64Offset": 832, + "width": 8, + "minDeclaredSize": 840, + "section": "V4", + "hostField": { + "name": "memrec_setValue", + "type": "pointer", + "line": 163 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_setValue", + "line": 1981, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 53, + "declaration": "function ce_memrec_setValue(memrec: pointer; value: pchar): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_setValue", + "line": 394, + "declaration": "CEP_MEMREC_SETVALUETYPE memrec_setValue;", + "typeDeclaration": { + "line": 201, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_SETVALUETYPE)(PVOID memrec, char *value);" + } + }, + "pascalMirror": { + "name": "memrec_setValue", + "line": 471, + "declaration": "memrec_setValue: Tmemrec_setValue;", + "typeDeclaration": { + "line": 281, + "text": "type Tmemrec_setValue=function(memrec: pointer; value: pchar): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 105, + "x64Offset": 840, + "width": 8, + "minDeclaredSize": 848, + "section": "V4", + "hostField": { + "name": "memrec_getScript", + "type": "pointer", + "line": 164 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_getScript", + "line": 1982, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 54, + "declaration": "function ce_memrec_getScript(memrec: pointer): pchar; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_getScript", + "line": 395, + "declaration": "CEP_MEMREC_GETSCRIPT memrec_getScript;", + "typeDeclaration": { + "line": 202, + "text": "typedef char* (__stdcall *CEP_MEMREC_GETSCRIPT)(PVOID memrec);" + } + }, + "pascalMirror": { + "name": "memrec_getScript", + "line": 472, + "declaration": "memrec_getScript: Tmemrec_getScript;", + "typeDeclaration": { + "line": 282, + "text": "type Tmemrec_getScript=function(memrec: pointer): pchar; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 106, + "x64Offset": 848, + "width": 8, + "minDeclaredSize": 856, + "section": "V4", + "hostField": { + "name": "memrec_setScript", + "type": "pointer", + "line": 165 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_setScript", + "line": 1983, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 55, + "declaration": "function ce_memrec_setScript(memrec: pointer; script: pchar): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_setScript", + "line": 396, + "declaration": "CEP_MEMREC_SETSCRIPT memrec_setScript;", + "typeDeclaration": { + "line": 203, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_SETSCRIPT)(PVOID memrec, char *script);" + } + }, + "pascalMirror": { + "name": "memrec_setScript", + "line": 473, + "declaration": "memrec_setScript: Tmemrec_setScript;", + "typeDeclaration": { + "line": 283, + "text": "type Tmemrec_setScript=function(memrec: pointer; script: pchar): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 107, + "x64Offset": 856, + "width": 8, + "minDeclaredSize": 864, + "section": "V4", + "hostField": { + "name": "memrec_isfrozen", + "type": "pointer", + "line": 166 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_isfrozen", + "line": 1984, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 57, + "declaration": "function ce_memrec_isFrozen(memrec: pointer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_isfrozen", + "line": 397, + "declaration": "CEP_MEMREC_ISFROZEN memrec_isfrozen;", + "typeDeclaration": { + "line": 204, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_ISFROZEN)(PVOID memrec);" + } + }, + "pascalMirror": { + "name": "memrec_isfrozen", + "line": 474, + "declaration": "memrec_isfrozen: Tmemrec_isfrozen;", + "typeDeclaration": { + "line": 284, + "text": "type Tmemrec_isFrozen=function(memrec: pointer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 108, + "x64Offset": 864, + "width": 8, + "minDeclaredSize": 872, + "section": "V4", + "hostField": { + "name": "memrec_freeze", + "type": "pointer", + "line": 167 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_freeze", + "line": 1985, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 58, + "declaration": "function ce_memrec_freeze(memrec: pointer; direction: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_freeze", + "line": 398, + "declaration": "CEP_MEMREC_FREEZE memrec_freeze;", + "typeDeclaration": { + "line": 205, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_FREEZE)(PVOID memrec, int direction);" + } + }, + "pascalMirror": { + "name": "memrec_freeze", + "line": 475, + "declaration": "memrec_freeze: Tmemrec_freeze;", + "typeDeclaration": { + "line": 285, + "text": "type Tmemrec_freeze=function(memrec: pointer; direction: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 109, + "x64Offset": 872, + "width": 8, + "minDeclaredSize": 880, + "section": "V4", + "hostField": { + "name": "memrec_unfreeze", + "type": "pointer", + "line": 168 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_unfreeze", + "line": 1986, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 59, + "declaration": "function ce_memrec_unfreeze(memrec: pointer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_unfreeze", + "line": 399, + "declaration": "CEP_MEMREC_UNFREEZE memrec_unfreeze;", + "typeDeclaration": { + "line": 206, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_UNFREEZE)(PVOID memrec);" + } + }, + "pascalMirror": { + "name": "memrec_unfreeze", + "line": 476, + "declaration": "memrec_unfreeze: Tmemrec_unfreeze;", + "typeDeclaration": { + "line": 286, + "text": "type Tmemrec_unfreeze=function(memrec: pointer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 110, + "x64Offset": 880, + "width": 8, + "minDeclaredSize": 888, + "section": "V4", + "hostField": { + "name": "memrec_setColor", + "type": "pointer", + "line": 169 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_setColor", + "line": 1987, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 60, + "declaration": "function ce_memrec_setColor(memrec: pointer; color: TColor): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_setColor", + "line": 400, + "declaration": "CEP_MEMREC_SETCOLOR memrec_setColor;", + "typeDeclaration": { + "line": 207, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_SETCOLOR)(PVOID memrec, DWORD color);" + } + }, + "pascalMirror": { + "name": "memrec_setColor", + "line": 477, + "declaration": "memrec_setColor: Tmemrec_setColor;", + "typeDeclaration": { + "line": 287, + "text": "type Tmemrec_setColor=function(memrec: pointer; color: TColor): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 111, + "x64Offset": 888, + "width": 8, + "minDeclaredSize": 896, + "section": "V4", + "hostField": { + "name": "memrec_appendtoentry", + "type": "pointer", + "line": 170 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_appendtoentry", + "line": 1988, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 61, + "declaration": "function ce_memrec_appendtoentry(memrec1: pointer; memrec2: pointer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_appendtoentry", + "line": 401, + "declaration": "CEP_MEMREC_APPENDTOENTRY memrec_appendtoentry;", + "typeDeclaration": { + "line": 208, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_APPENDTOENTRY)(PVOID memrec1, PVOID memrec2);" + } + }, + "pascalMirror": { + "name": "memrec_appendtoentry", + "line": 478, + "declaration": "memrec_appendtoentry: Tmemrec_appendtoentry;", + "typeDeclaration": { + "line": 288, + "text": "type Tmemrec_appendtoentry=function(memrec1: pointer; memrec2: pointer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 112, + "x64Offset": 896, + "width": 8, + "minDeclaredSize": 904, + "section": "V4", + "hostField": { + "name": "memrec_delete", + "type": "pointer", + "line": 171 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_memrec_delete", + "line": 1989, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 62, + "declaration": "function ce_memrec_delete(memrec: pointer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "memrec_delete", + "line": 402, + "declaration": "CEP_MEMREC_DELETE memrec_delete;", + "typeDeclaration": { + "line": 209, + "text": "typedef BOOL (__stdcall *CEP_MEMREC_DELETE)(PVOID memrec);" + } + }, + "pascalMirror": { + "name": "memrec_delete", + "line": 479, + "declaration": "memrec_delete: Tmemrec_delete;", + "typeDeclaration": { + "line": 289, + "text": "type Tmemrec_delete=function(memrec: pointer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 113, + "x64Offset": 904, + "width": 8, + "minDeclaredSize": 912, + "section": "V4", + "hostField": { + "name": "getProcessIDFromProcessName", + "type": "pointer", + "line": 173 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_getProcessIDFromProcessName", + "line": 1991, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 63, + "declaration": "function ce_getProcessIDFromProcessName(name: pchar): DWORD; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "getProcessIDFromProcessName", + "line": 404, + "declaration": "CEP_GETPROCESSIDFROMPROCESSNAME getProcessIDFromProcessName;", + "typeDeclaration": { + "line": 211, + "text": "typedef DWORD (__stdcall *CEP_GETPROCESSIDFROMPROCESSNAME)(char *name);" + } + }, + "pascalMirror": { + "name": "getProcessIDFromProcessName", + "line": 481, + "declaration": "getProcessIDFromProcessName: TgetProcessIDFromProcessName;", + "typeDeclaration": { + "line": 290, + "text": "type TgetProcessIDFromProcessName=function(name: pchar): DWORD; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "getProcessIDFromProcessName" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 114, + "x64Offset": 912, + "width": 8, + "minDeclaredSize": 920, + "section": "V4", + "hostField": { + "name": "openProcessEx", + "type": "pointer", + "line": 174 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_openProcess", + "line": 1992, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 64, + "declaration": "function ce_openProcess(pid: dword): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "openProcessEx", + "line": 405, + "declaration": "CEP_OPENPROCESS openProcessEx;", + "typeDeclaration": { + "line": 212, + "text": "typedef DWORD (__stdcall *CEP_OPENPROCESS)(DWORD pid);" + } + }, + "pascalMirror": { + "name": "openProcessEx", + "line": 482, + "declaration": "openProcessEx: TopenProcessEx;", + "typeDeclaration": { + "line": 291, + "text": "type TopenProcessex=function(pid: dword): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 115, + "x64Offset": 920, + "width": 8, + "minDeclaredSize": 928, + "section": "V4", + "hostField": { + "name": "debugProcessEx", + "type": "pointer", + "line": 175 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_debugProcess", + "line": 1993, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 65, + "declaration": "function ce_debugProcess(debuggerinterface: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "debugProcessEx", + "line": 406, + "declaration": "CEP_DEBUGPROCESS debugProcessEx;", + "typeDeclaration": { + "line": 213, + "text": "typedef DWORD (__stdcall *CEP_DEBUGPROCESS)(int debuggerinterface);" + } + }, + "pascalMirror": { + "name": "debugProcessEx", + "line": 483, + "declaration": "debugProcessEx: TdebugProcessEx;", + "typeDeclaration": { + "line": 292, + "text": "type TdebugProcessex=function(debuggerinterface: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 116, + "x64Offset": 928, + "width": 8, + "minDeclaredSize": 936, + "section": "V4", + "hostField": { + "name": "pause", + "type": "pointer", + "line": 176 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_pause", + "line": 1994, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 66, + "declaration": "procedure ce_pause; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "pause", + "line": 407, + "declaration": "CEP_PAUSE pause;", + "typeDeclaration": { + "line": 214, + "text": "typedef VOID (__stdcall *CEP_PAUSE)(void);" + } + }, + "pascalMirror": { + "name": "pause", + "line": 484, + "declaration": "pause: Tpause;", + "typeDeclaration": { + "line": 293, + "text": "type Tpause=procedure; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "pause" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 117, + "x64Offset": 936, + "width": 8, + "minDeclaredSize": 944, + "section": "V4", + "hostField": { + "name": "unpause", + "type": "pointer", + "line": 177 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_unpause", + "line": 1995, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 67, + "declaration": "procedure ce_unpause; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "unpause", + "line": 408, + "declaration": "CEP_UNPAUSE unpause;", + "typeDeclaration": { + "line": 215, + "text": "typedef VOID (__stdcall *CEP_UNPAUSE)(void);" + } + }, + "pascalMirror": { + "name": "unpause", + "line": 485, + "declaration": "unpause: Tunpause;", + "typeDeclaration": { + "line": 294, + "text": "type Tunpause=procedure; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "unpause" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 118, + "x64Offset": 944, + "width": 8, + "minDeclaredSize": 952, + "section": "V4", + "hostField": { + "name": "debug_setBreakpoint", + "type": "pointer", + "line": 179 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_debug_setBreakpoint", + "line": 1997, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 69, + "declaration": "function ce_debug_setBreakpoint(address: ptruint; size: integer; trigger: TBreakpointTrigger): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "debug_setBreakpoint", + "line": 410, + "declaration": "CEP_DEBUG_SETBREAKPOINT debug_setBreakpoint;", + "typeDeclaration": { + "line": 216, + "text": "typedef BOOL (__stdcall *CEP_DEBUG_SETBREAKPOINT)(UINT_PTR address, int size, int trigger);" + } + }, + "pascalMirror": { + "name": "debug_setBreakpoint", + "line": 487, + "declaration": "debug_setBreakpoint: Tdebug_setBreakpoint;", + "typeDeclaration": { + "line": 296, + "text": "type Tdebug_setBreakpoint=function(address: ptruint; size: integer; trigger: TBreakpointTrigger): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "debug_setBreakpoint" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 119, + "x64Offset": 952, + "width": 8, + "minDeclaredSize": 960, + "section": "V4", + "hostField": { + "name": "debug_removeBreakpoint", + "type": "pointer", + "line": 180 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_debug_removeBreakpoint", + "line": 1998, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 70, + "declaration": "function ce_debug_removeBreakpoint(address: ptruint): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "debug_removeBreakpoint", + "line": 411, + "declaration": "CEP_DEBUG_REMOVEBREAKPOINT debug_removeBreakpoint;", + "typeDeclaration": { + "line": 217, + "text": "typedef BOOL (__stdcall *CEP_DEBUG_REMOVEBREAKPOINT)(UINT_PTR address);" + } + }, + "pascalMirror": { + "name": "debug_removeBreakpoint", + "line": 488, + "declaration": "debug_removeBreakpoint: Tdebug_removeBreakpoint;", + "typeDeclaration": { + "line": 297, + "text": "type Tdebug_removeBreakpoint=function(address: ptruint): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "debug_removeBreakpoint" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 120, + "x64Offset": 960, + "width": 8, + "minDeclaredSize": 968, + "section": "V4", + "hostField": { + "name": "debug_continueFromBreakpoint", + "type": "pointer", + "line": 181 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_debug_continueFromBreakpoint", + "line": 1999, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 71, + "declaration": "function ce_debug_continueFromBreakpoint(ContinueOption: TContinueOption): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "debug_continueFromBreakpoint", + "line": 412, + "declaration": "CEP_DEBUG_CONTINUEFROMBREAKPOINT debug_continueFromBreakpoint;", + "typeDeclaration": { + "line": 218, + "text": "typedef BOOL (__stdcall *CEP_DEBUG_CONTINUEFROMBREAKPOINT)(int continueoption);" + } + }, + "pascalMirror": { + "name": "debug_continueFromBreakpoint", + "line": 489, + "declaration": "debug_continueFromBreakpoint: Tdebug_continueFromBreakpoint;", + "typeDeclaration": { + "line": 298, + "text": "type Tdebug_continueFromBreakpoint=function(ContinueOption: TContinueOption): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "debug_continueFromBreakpoint" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 121, + "x64Offset": 968, + "width": 8, + "minDeclaredSize": 976, + "section": "V4", + "hostField": { + "name": "closeCE", + "type": "pointer", + "line": 183 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_closeCE", + "line": 2001, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 73, + "declaration": "procedure ce_closeCE; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "closeCE", + "line": 414, + "declaration": "CEP_CLOSECE closeCE;", + "typeDeclaration": { + "line": 220, + "text": "typedef VOID (__stdcall *CEP_CLOSECE)(void);" + } + }, + "pascalMirror": { + "name": "closeCE", + "line": 491, + "declaration": "closeCE: TcloseCE;", + "typeDeclaration": { + "line": 300, + "text": "type TcloseCE=procedure; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "closeCE" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 122, + "x64Offset": 976, + "width": 8, + "minDeclaredSize": 984, + "section": "V4", + "hostField": { + "name": "hideAllCEWindows", + "type": "pointer", + "line": 184 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_hideAllCEWindows", + "line": 2002, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 74, + "declaration": "procedure ce_hideAllCEWindows; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "hideAllCEWindows", + "line": 415, + "declaration": "CEP_HIDEALLCEWINDOWS hideAllCEWindows;", + "typeDeclaration": { + "line": 221, + "text": "typedef VOID (__stdcall *CEP_HIDEALLCEWINDOWS)(void);" + } + }, + "pascalMirror": { + "name": "hideAllCEWindows", + "line": 492, + "declaration": "hideAllCEWindows: ThideAllCEWindows;", + "typeDeclaration": { + "line": 301, + "text": "type ThideAllCEWindows=procedure; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "hideAllCEWindows" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 123, + "x64Offset": 984, + "width": 8, + "minDeclaredSize": 992, + "section": "V4", + "hostField": { + "name": "unhideMainCEwindow", + "type": "pointer", + "line": 185 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_unhideMainCEwindow", + "line": 2003, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 75, + "declaration": "procedure ce_unhideMainCEwindow; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "unhideMainCEwindow", + "line": 416, + "declaration": "CEP_UNHIDEMAINCEWINDOW unhideMainCEwindow;", + "typeDeclaration": { + "line": 222, + "text": "typedef VOID (__stdcall *CEP_UNHIDEMAINCEWINDOW)(void);" + } + }, + "pascalMirror": { + "name": "unhideMainCEwindow", + "line": 493, + "declaration": "unhideMainCEwindow: TunhideMainCEwindow;", + "typeDeclaration": { + "line": 302, + "text": "type TunhideMainCEwindow=procedure; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "unhideMainCEwindow" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 124, + "x64Offset": 992, + "width": 8, + "minDeclaredSize": 1000, + "section": "V4", + "hostField": { + "name": "createForm", + "type": "pointer", + "line": 186 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createForm", + "line": 2004, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 76, + "declaration": "function ce_createForm(visible: boolean): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createForm", + "line": 417, + "declaration": "CEP_CREATEFORM createForm;", + "typeDeclaration": { + "line": 224, + "text": "typedef PVOID (__stdcall *CEP_CREATEFORM)(void);" + } + }, + "pascalMirror": { + "name": "createForm", + "line": 494, + "declaration": "createForm: TcreateForm;", + "typeDeclaration": { + "line": 303, + "text": "type TcreateForm=function(visible: boolean): pointer; stdcall;" + } + } + }, + "divergenceRefs": [ + "D04" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createForm" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 125, + "x64Offset": 1000, + "width": 8, + "minDeclaredSize": 1008, + "section": "V4", + "hostField": { + "name": "form_centerScreen", + "type": "pointer", + "line": 187 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_form_centerScreen", + "line": 2005, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 77, + "declaration": "procedure ce_form_centerScreen(f: pointer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "form_centerScreen", + "line": 418, + "declaration": "CEP_FORM_CENTERSCREEN form_centerScreen;", + "typeDeclaration": { + "line": 225, + "text": "typedef void (__stdcall *CEP_FORM_CENTERSCREEN)(PVOID form);" + } + }, + "pascalMirror": { + "name": "form_centerScreen", + "line": 495, + "declaration": "form_centerScreen: Tform_centerScreen;", + "typeDeclaration": { + "line": 304, + "text": "type Tform_centerScreen=procedure(f: pointer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 126, + "x64Offset": 1008, + "width": 8, + "minDeclaredSize": 1016, + "section": "V4", + "hostField": { + "name": "form_hide", + "type": "pointer", + "line": 188 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_form_hide", + "line": 2006, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 78, + "declaration": "procedure ce_form_hide(f: pointer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "form_hide", + "line": 419, + "declaration": "CEP_FORM_HIDE form_hide;", + "typeDeclaration": { + "line": 226, + "text": "typedef void (__stdcall *CEP_FORM_HIDE)(PVOID form);" + } + }, + "pascalMirror": { + "name": "form_hide", + "line": 496, + "declaration": "form_hide: Tform_hide;", + "typeDeclaration": { + "line": 305, + "text": "type Tform_hide=procedure(f: pointer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 127, + "x64Offset": 1016, + "width": 8, + "minDeclaredSize": 1024, + "section": "V4", + "hostField": { + "name": "form_show", + "type": "pointer", + "line": 189 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_form_show", + "line": 2007, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 79, + "declaration": "procedure ce_form_show(f: pointer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "form_show", + "line": 420, + "declaration": "CEP_FORM_SHOW form_show;", + "typeDeclaration": { + "line": 227, + "text": "typedef void (__stdcall *CEP_FORM_SHOW)(PVOID form);" + } + }, + "pascalMirror": { + "name": "form_show", + "line": 497, + "declaration": "form_show: Tform_show;", + "typeDeclaration": { + "line": 306, + "text": "type Tform_show=procedure(f: pointer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 128, + "x64Offset": 1024, + "width": 8, + "minDeclaredSize": 1032, + "section": "V4", + "hostField": { + "name": "form_onClose", + "type": "pointer", + "line": 190 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_form_onClose", + "line": 2008, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 80, + "declaration": "procedure ce_form_onClose(frm: pointer; f: pointer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "form_onClose", + "line": 421, + "declaration": "CEP_FORM_ONCLOSE form_onClose;", + "typeDeclaration": { + "line": 228, + "text": "typedef void (__stdcall *CEP_FORM_ONCLOSE)(PVOID form, PVOID function);" + } + }, + "pascalMirror": { + "name": "form_onClose", + "line": 498, + "declaration": "form_onClose: Tform_onClose;", + "typeDeclaration": { + "line": 307, + "text": "type Tform_onClose=procedure(frm: pointer; f: pointer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 129, + "x64Offset": 1032, + "width": 8, + "minDeclaredSize": 1040, + "section": "V4", + "hostField": { + "name": "createPanel", + "type": "pointer", + "line": 192 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createPanel", + "line": 2010, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 83, + "declaration": "function ce_createPanel(owner: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createPanel", + "line": 423, + "declaration": "CEP_CREATEPANEL createPanel;", + "typeDeclaration": { + "line": 230, + "text": "typedef PVOID (__stdcall *CEP_CREATEPANEL)(PVOID owner);" + } + }, + "pascalMirror": { + "name": "createPanel", + "line": 500, + "declaration": "createPanel: TcreatePanel;", + "typeDeclaration": { + "line": 309, + "text": "type TcreatePanel=function(owner: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createPanel" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 130, + "x64Offset": 1040, + "width": 8, + "minDeclaredSize": 1048, + "section": "V4", + "hostField": { + "name": "createGroupBox", + "type": "pointer", + "line": 193 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createGroupBox", + "line": 2011, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 84, + "declaration": "function ce_createGroupBox(owner: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createGroupBox", + "line": 424, + "declaration": "CEP_CREATEGROUPBOX createGroupBox;", + "typeDeclaration": { + "line": 231, + "text": "typedef PVOID (__stdcall *CEP_CREATEGROUPBOX)(PVOID owner);" + } + }, + "pascalMirror": { + "name": "createGroupBox", + "line": 501, + "declaration": "createGroupBox: TcreateGroupBox;", + "typeDeclaration": { + "line": 310, + "text": "type TcreateGroupBox=function(owner: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createGroupBox" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 131, + "x64Offset": 1048, + "width": 8, + "minDeclaredSize": 1056, + "section": "V4", + "hostField": { + "name": "createButton", + "type": "pointer", + "line": 194 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createButton", + "line": 2012, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 85, + "declaration": "function ce_createButton(owner: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createButton", + "line": 425, + "declaration": "CEP_CREATEBUTTON createButton;", + "typeDeclaration": { + "line": 232, + "text": "typedef PVOID (__stdcall *CEP_CREATEBUTTON)(PVOID owner);" + } + }, + "pascalMirror": { + "name": "createButton", + "line": 502, + "declaration": "createButton: TcreateButton;", + "typeDeclaration": { + "line": 311, + "text": "type TcreateButton=function(owner: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createButton" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 132, + "x64Offset": 1056, + "width": 8, + "minDeclaredSize": 1064, + "section": "V4", + "hostField": { + "name": "createImage", + "type": "pointer", + "line": 195 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createImage", + "line": 2013, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 86, + "declaration": "function ce_createImage(owner: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createImage", + "line": 426, + "declaration": "CEP_CREATEIMAGE createImage;", + "typeDeclaration": { + "line": 233, + "text": "typedef PVOID (__stdcall *CEP_CREATEIMAGE)(PVOID owner);" + } + }, + "pascalMirror": { + "name": "createImage", + "line": 503, + "declaration": "createImage: TcreateImage;", + "typeDeclaration": { + "line": 312, + "text": "type TcreateImage=function(owner: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createImage" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 133, + "x64Offset": 1064, + "width": 8, + "minDeclaredSize": 1072, + "section": "V4", + "hostField": { + "name": "image_loadImageFromFile", + "type": "pointer", + "line": 196 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_image_loadImageFromFile", + "line": 2014, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 87, + "declaration": "function ce_image_loadImageFromFile(image: pointer; filename: pchar): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "image_loadImageFromFile", + "line": 427, + "declaration": "CEP_IMAGE_LOADIMAGEFROMFILE image_loadImageFromFile;", + "typeDeclaration": { + "line": 235, + "text": "typedef BOOL (__stdcall *CEP_IMAGE_LOADIMAGEFROMFILE)(PVOID image, char *filename);" + } + }, + "pascalMirror": { + "name": "image_loadImageFromFile", + "line": 504, + "declaration": "image_loadImageFromFile: Timage_loadImageFromFile;", + "typeDeclaration": { + "line": 313, + "text": "type Timage_loadImageFromFile=function(image: pointer; filename: pchar): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 134, + "x64Offset": 1072, + "width": 8, + "minDeclaredSize": 1080, + "section": "V4", + "hostField": { + "name": "image_transparent", + "type": "pointer", + "line": 197 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_image_transparent", + "line": 2015, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 88, + "declaration": "procedure ce_image_transparent(image: pointer; transparent: boolean); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "image_transparent", + "line": 428, + "declaration": "CEP_IMAGE_TRANSPARENT image_transparent;", + "typeDeclaration": { + "line": 236, + "text": "typedef VOID (__stdcall *CEP_IMAGE_TRANSPARENT)(PVOID image, BOOL transparent);" + } + }, + "pascalMirror": { + "name": "image_transparent", + "line": 505, + "declaration": "image_transparent: Timage_transparent;", + "typeDeclaration": { + "line": 314, + "text": "type Timage_transparent=procedure(image: pointer; transparent: boolean); stdcall;" + } + } + }, + "divergenceRefs": [ + "D05" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 135, + "x64Offset": 1080, + "width": 8, + "minDeclaredSize": 1088, + "section": "V4", + "hostField": { + "name": "image_stretch", + "type": "pointer", + "line": 198 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_image_stretch", + "line": 2016, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 89, + "declaration": "procedure ce_image_stretch(image: pointer; stretch: boolean); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "image_stretch", + "line": 429, + "declaration": "CEP_IMAGE_STRETCH image_stretch;", + "typeDeclaration": { + "line": 237, + "text": "typedef VOID (__stdcall *CEP_IMAGE_STRETCH)(PVOID image, BOOL stretch);" + } + }, + "pascalMirror": { + "name": "image_stretch", + "line": 506, + "declaration": "image_stretch: Timage_stretch;", + "typeDeclaration": { + "line": 315, + "text": "type Timage_stretch=procedure(image: pointer; stretch: boolean); stdcall;" + } + } + }, + "divergenceRefs": [ + "D05" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 136, + "x64Offset": 1088, + "width": 8, + "minDeclaredSize": 1096, + "section": "V4", + "hostField": { + "name": "createLabel", + "type": "pointer", + "line": 200 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createLabel", + "line": 2018, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 91, + "declaration": "function ce_createLabel(owner: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createLabel", + "line": 431, + "declaration": "CEP_CREATELABEL createLabel;", + "typeDeclaration": { + "line": 239, + "text": "typedef PVOID (__stdcall *CEP_CREATELABEL)(PVOID owner);" + } + }, + "pascalMirror": { + "name": "createLabel", + "line": 508, + "declaration": "createLabel: TcreateLabel;", + "typeDeclaration": { + "line": 317, + "text": "type TcreateLabel=function(owner: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createLabel" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 137, + "x64Offset": 1096, + "width": 8, + "minDeclaredSize": 1104, + "section": "V4", + "hostField": { + "name": "createEdit", + "type": "pointer", + "line": 201 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createEdit", + "line": 2019, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 92, + "declaration": "function ce_createEdit(owner: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createEdit", + "line": 432, + "declaration": "CEP_CREATEEDIT createEdit;", + "typeDeclaration": { + "line": 240, + "text": "typedef PVOID (__stdcall *CEP_CREATEEDIT)(PVOID owner);" + } + }, + "pascalMirror": { + "name": "createEdit", + "line": 509, + "declaration": "createEdit: TcreateEdit;", + "typeDeclaration": { + "line": 318, + "text": "type TcreateEdit=function(owner: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createEdit" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 138, + "x64Offset": 1104, + "width": 8, + "minDeclaredSize": 1112, + "section": "V4", + "hostField": { + "name": "createMemo", + "type": "pointer", + "line": 202 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createMemo", + "line": 2020, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 93, + "declaration": "function ce_createMemo(owner: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createMemo", + "line": 433, + "declaration": "CEP_CREATEMEMO createMemo;", + "typeDeclaration": { + "line": 241, + "text": "typedef PVOID (__stdcall *CEP_CREATEMEMO)(PVOID owner);" + } + }, + "pascalMirror": { + "name": "createMemo", + "line": 510, + "declaration": "createMemo: TcreateMemo;", + "typeDeclaration": { + "line": 319, + "text": "type TcreateMemo=function(owner: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createMemo" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 139, + "x64Offset": 1112, + "width": 8, + "minDeclaredSize": 1120, + "section": "V4", + "hostField": { + "name": "createTimer", + "type": "pointer", + "line": 203 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_createTimer", + "line": 2021, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 94, + "declaration": "function ce_createTimer(owner: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "createTimer", + "line": 434, + "declaration": "CEP_CREATETIMER createTimer;", + "typeDeclaration": { + "line": 242, + "text": "typedef PVOID (__stdcall *CEP_CREATETIMER)(PVOID owner);" + } + }, + "pascalMirror": { + "name": "createTimer", + "line": 511, + "declaration": "createTimer: TcreateTimer;", + "typeDeclaration": { + "line": 320, + "text": "type TcreateTimer=function(owner: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "createTimer" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 140, + "x64Offset": 1120, + "width": 8, + "minDeclaredSize": 1128, + "section": "V4", + "hostField": { + "name": "timer_setInterval", + "type": "pointer", + "line": 204 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_timer_setInterval", + "line": 2022, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 95, + "declaration": "procedure ce_timer_setInterval(timer: pointer; interval: integer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "timer_setInterval", + "line": 435, + "declaration": "CEP_TIMER_SETINTERVAL timer_setInterval;", + "typeDeclaration": { + "line": 244, + "text": "typedef VOID (__stdcall *CEP_TIMER_SETINTERVAL)(PVOID timer, int interval);" + } + }, + "pascalMirror": { + "name": "timer_setInterval", + "line": 512, + "declaration": "timer_setInterval: Ttimer_setInterval;", + "typeDeclaration": { + "line": 321, + "text": "type Ttimer_setInterval=procedure(timer: pointer; interval: integer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 141, + "x64Offset": 1128, + "width": 8, + "minDeclaredSize": 1136, + "section": "V4", + "hostField": { + "name": "timer_onTimer", + "type": "pointer", + "line": 205 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_timer_onTimer", + "line": 2023, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 96, + "declaration": "procedure ce_timer_onTimer(t: pointer; f: pointer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "timer_onTimer", + "line": 436, + "declaration": "CEP_TIMER_ONTIMER timer_onTimer;", + "typeDeclaration": { + "line": 245, + "text": "typedef VOID (__stdcall *CEP_TIMER_ONTIMER)(PVOID timer, PVOID function);" + } + }, + "pascalMirror": { + "name": "timer_onTimer", + "line": 513, + "declaration": "timer_onTimer: Ttimer_onTimer;", + "typeDeclaration": { + "line": 322, + "text": "type Ttimer_onTimer=procedure(t: pointer; f: pointer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 142, + "x64Offset": 1136, + "width": 8, + "minDeclaredSize": 1144, + "section": "V4", + "hostField": { + "name": "control_setCaption", + "type": "pointer", + "line": 206 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_setCaption", + "line": 2024, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 98, + "declaration": "procedure ce_control_setCaption(control: pointer; caption: pchar); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_setCaption", + "line": 437, + "declaration": "CEP_CONTROL_SETCAPTION control_setCaption;", + "typeDeclaration": { + "line": 247, + "text": "typedef VOID (__stdcall *CEP_CONTROL_SETCAPTION)(PVOID control, char *caption);" + } + }, + "pascalMirror": { + "name": "control_setCaption", + "line": 514, + "declaration": "control_setCaption: Tcontrol_setCaption;", + "typeDeclaration": { + "line": 323, + "text": "type Tcontrol_setCaption=procedure(control: pointer; caption: pchar); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 143, + "x64Offset": 1144, + "width": 8, + "minDeclaredSize": 1152, + "section": "V4", + "hostField": { + "name": "control_getCaption", + "type": "pointer", + "line": 207 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_getCaption", + "line": 2025, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 99, + "declaration": "function ce_control_getCaption(control: pointer; caption: pchar; maxsize: integer): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_getCaption", + "line": 438, + "declaration": "CEP_CONTROL_GETCAPTION control_getCaption;", + "typeDeclaration": { + "line": 248, + "text": "typedef BOOL (__stdcall *CEP_CONTROL_GETCAPTION)(PVOID control, char *caption, int maxsize);" + } + }, + "pascalMirror": { + "name": "control_getCaption", + "line": 515, + "declaration": "control_getCaption: Tcontrol_getCaption;", + "typeDeclaration": { + "line": 324, + "text": "type Tcontrol_getCaption=function(control: pointer; caption: pchar; maxsize: integer): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 144, + "x64Offset": 1152, + "width": 8, + "minDeclaredSize": 1160, + "section": "V4", + "hostField": { + "name": "control_setPosition", + "type": "pointer", + "line": 208 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_setPosition", + "line": 2026, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 100, + "declaration": "procedure ce_control_setPosition(control: pointer; x,y: integer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_setPosition", + "line": 439, + "declaration": "CEP_CONTROL_SETPOSITION control_setPosition;", + "typeDeclaration": { + "line": 250, + "text": "typedef VOID (__stdcall *CEP_CONTROL_SETPOSITION)(PVOID control, int x, int y);" + } + }, + "pascalMirror": { + "name": "control_setPosition", + "line": 516, + "declaration": "control_setPosition: Tcontrol_setPosition;", + "typeDeclaration": { + "line": 325, + "text": "type Tcontrol_setPosition=procedure(control: pointer; x,y: integer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 145, + "x64Offset": 1160, + "width": 8, + "minDeclaredSize": 1168, + "section": "V4", + "hostField": { + "name": "control_getX", + "type": "pointer", + "line": 209 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_getX", + "line": 2027, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 101, + "declaration": "function ce_control_getX(control: pointer): integer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_getX", + "line": 440, + "declaration": "CEP_CONTROL_GETX control_getX;", + "typeDeclaration": { + "line": 251, + "text": "typedef int (__stdcall *CEP_CONTROL_GETX)(PVOID control);" + } + }, + "pascalMirror": { + "name": "control_getX", + "line": 517, + "declaration": "control_getX: Tcontrol_getX;", + "typeDeclaration": { + "line": 326, + "text": "type Tcontrol_getX=function(control: pointer): integer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 146, + "x64Offset": 1168, + "width": 8, + "minDeclaredSize": 1176, + "section": "V4", + "hostField": { + "name": "control_getY", + "type": "pointer", + "line": 210 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_getY", + "line": 2028, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 102, + "declaration": "function ce_control_getY(control: pointer): integer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_getY", + "line": 441, + "declaration": "CEP_CONTROL_GETY control_getY;", + "typeDeclaration": { + "line": 252, + "text": "typedef int (__stdcall *CEP_CONTROL_GETY)(PVOID control);" + } + }, + "pascalMirror": { + "name": "control_getY", + "line": 518, + "declaration": "control_getY: Tcontrol_getY;", + "typeDeclaration": { + "line": 327, + "text": "type Tcontrol_getY=function(control: pointer): integer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 147, + "x64Offset": 1176, + "width": 8, + "minDeclaredSize": 1184, + "section": "V4", + "hostField": { + "name": "control_setSize", + "type": "pointer", + "line": 211 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_setSize", + "line": 2029, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 103, + "declaration": "procedure ce_control_setSize(control: pointer; width,height: integer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_setSize", + "line": 442, + "declaration": "CEP_CONTROL_SETSIZE control_setSize;", + "typeDeclaration": { + "line": 254, + "text": "typedef VOID (__stdcall *CEP_CONTROL_SETSIZE)(PVOID control, int width, int height);" + } + }, + "pascalMirror": { + "name": "control_setSize", + "line": 519, + "declaration": "control_setSize: Tcontrol_setSize;", + "typeDeclaration": { + "line": 328, + "text": "type Tcontrol_setSize=procedure(control: pointer; width,height: integer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 148, + "x64Offset": 1184, + "width": 8, + "minDeclaredSize": 1192, + "section": "V4", + "hostField": { + "name": "control_getWidth", + "type": "pointer", + "line": 212 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_getWidth", + "line": 2030, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 104, + "declaration": "function ce_control_getWidth(control: pointer): integer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_getWidth", + "line": 443, + "declaration": "CEP_CONTROL_GETWIDTH control_getWidth;", + "typeDeclaration": { + "line": 255, + "text": "typedef int (__stdcall *CEP_CONTROL_GETWIDTH)(PVOID control);" + } + }, + "pascalMirror": { + "name": "control_getWidth", + "line": 520, + "declaration": "control_getWidth: Tcontrol_getWidth;", + "typeDeclaration": { + "line": 329, + "text": "type Tcontrol_getWidth=function(control: pointer): integer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 149, + "x64Offset": 1192, + "width": 8, + "minDeclaredSize": 1200, + "section": "V4", + "hostField": { + "name": "control_getHeight", + "type": "pointer", + "line": 213 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_getHeight", + "line": 2031, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 105, + "declaration": "function ce_control_getHeight(control: pointer): integer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_getHeight", + "line": 444, + "declaration": "CEP_CONTROL_GETHEIGHT control_getHeight;", + "typeDeclaration": { + "line": 256, + "text": "typedef int (__stdcall *CEP_CONTROL_GETHEIGHT)(PVOID control);" + } + }, + "pascalMirror": { + "name": "control_getHeight", + "line": 521, + "declaration": "control_getHeight: Tcontrol_getHeight;", + "typeDeclaration": { + "line": 330, + "text": "type Tcontrol_getHeight=function(control: pointer): integer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 150, + "x64Offset": 1200, + "width": 8, + "minDeclaredSize": 1208, + "section": "V4", + "hostField": { + "name": "control_setAlign", + "type": "pointer", + "line": 214 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_setAlign", + "line": 2032, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 106, + "declaration": "procedure ce_control_setAlign(control: pointer; align: integer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_setAlign", + "line": 445, + "declaration": "CEP_CONTROL_SETALIGN control_setAlign;", + "typeDeclaration": { + "line": 258, + "text": "typedef VOID (__stdcall *CEP_CONTROL_SETALIGN)(PVOID control, int align);" + } + }, + "pascalMirror": { + "name": "control_setAlign", + "line": 522, + "declaration": "control_setAlign: Tcontrol_setAlign;", + "typeDeclaration": { + "line": 331, + "text": "type Tcontrol_setAlign=procedure(control: pointer; align: integer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 151, + "x64Offset": 1208, + "width": 8, + "minDeclaredSize": 1216, + "section": "V4", + "hostField": { + "name": "control_onClick", + "type": "pointer", + "line": 215 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_control_onClick", + "line": 2033, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 107, + "declaration": "procedure ce_control_onClick(c: pointer; f: pointer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "control_onClick", + "line": 446, + "declaration": "CEP_CONTROL_ONCLICK control_onClick;", + "typeDeclaration": { + "line": 259, + "text": "typedef VOID (__stdcall *CEP_CONTROL_ONCLICK)(PVOID control, PVOID function);" + } + }, + "pascalMirror": { + "name": "control_onClick", + "line": 523, + "declaration": "control_onClick: Tcontrol_onClick;", + "typeDeclaration": { + "line": 332, + "text": "type Tcontrol_onClick=procedure(c: pointer; f: pointer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 152, + "x64Offset": 1216, + "width": 8, + "minDeclaredSize": 1224, + "section": "V4", + "hostField": { + "name": "object_destroy", + "type": "pointer", + "line": 217 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_object_destroy", + "line": 2035, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 110, + "declaration": "procedure ce_object_destroy(o: pointer); stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "object_destroy", + "line": 448, + "declaration": "CEP_OBJECT_DESTROY object_destroy;", + "typeDeclaration": { + "line": 261, + "text": "typedef VOID (__stdcall *CEP_OBJECT_DESTROY)(PVOID object);" + } + }, + "pascalMirror": { + "name": "object_destroy", + "line": 525, + "declaration": "object_destroy: Tobject_destroy;", + "typeDeclaration": { + "line": 334, + "text": "type Tobject_destroy=procedure(o: pointer); stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 153, + "x64Offset": 1224, + "width": 8, + "minDeclaredSize": 1232, + "section": "V4", + "hostField": { + "name": "messageDialog", + "type": "pointer", + "line": 218 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_messageDialog", + "line": 2036, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 111, + "declaration": "function ce_messageDialog(message: pchar; messagetype: integer; buttoncombination: integer): integer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "messageDialog", + "line": 449, + "declaration": "CEP_MESSAGEDIALOG messageDialog;", + "typeDeclaration": { + "line": 263, + "text": "typedef int (__stdcall *CEP_MESSAGEDIALOG)(char *massage, int messagetype, int buttoncombination);" + } + }, + "pascalMirror": { + "name": "messageDialog", + "line": 526, + "declaration": "messageDialog: TmessageDialog;", + "typeDeclaration": { + "line": 335, + "text": "type TmessageDialog=function(message: pchar; messagetype: integer; buttoncombination: integer): integer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "messageDialog" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 154, + "x64Offset": 1232, + "width": 8, + "minDeclaredSize": 1240, + "section": "V4", + "hostField": { + "name": "speedhack_setSpeed", + "type": "pointer", + "line": 219 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ce_speedhack_setSpeed", + "line": 2037, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 113, + "declaration": "function ce_speedhack_setSpeed(speed: single): BOOL; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "speedhack_setSpeed", + "line": 450, + "declaration": "CEP_SPEEDHACK_SETSPEED speedhack_setSpeed;", + "typeDeclaration": { + "line": 264, + "text": "typedef BOOL (__stdcall *CEP_SPEEDHACK_SETSPEED)(float speed);" + } + }, + "pascalMirror": { + "name": "speedhack_setSpeed", + "line": 527, + "declaration": "speedhack_setSpeed: Tspeedhack_setSpeed;", + "typeDeclaration": { + "line": 336, + "text": "type Tspeedhack_setSpeed=function(speed: single): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": { + "name": "speedhack_setSpeed" + }, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 155, + "x64Offset": 1240, + "width": 8, + "minDeclaredSize": 1248, + "section": "V5", + "hostField": { + "name": "ExecuteKernelCode", + "type": "pointer", + "line": 222 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@ExecuteKernelCode", + "line": 2041, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "ExecuteKernelCode", + "line": 453, + "declaration": "VOID *ExecuteKernelCode;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "ExecuteKernelCode", + "line": 530, + "declaration": "ExecuteKernelCode: TExecuteKernelCode;", + "typeDeclaration": { + "line": 339, + "text": "type TExecuteKernelCode=function(address: system.qword; parameters: system.qword): BOOL; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 156, + "x64Offset": 1248, + "width": 8, + "minDeclaredSize": 1256, + "section": "V5", + "hostField": { + "name": "UserdefinedInterruptHook", + "type": "pointer", + "line": 223 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@UserdefinedInterruptHook", + "line": 2042, + "condition": "windows" + }, + "hostImplementation": null, + "nature": "Function", + "indirection": 0, + "callingConvention": "Unknown", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "UserdefinedInterruptHook", + "line": 454, + "declaration": "VOID *UserdefinedInterruptHook;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "UserdefinedInterruptHook", + "line": 531, + "declaration": "UserdefinedInterruptHook: TUserdefinedInterruptHook;", + "typeDeclaration": { + "line": 338, + "text": "type TUserdefinedInterruptHook=function(interruptnr: integer; newCS: word; newEIP: system.qword; addressofjumpback: system.qword):boolean; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ToQualify", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 157, + "x64Offset": 1256, + "width": 8, + "minDeclaredSize": 1264, + "section": "V5", + "hostField": { + "name": "GetLuaState", + "type": "pointer", + "line": 224 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@plugin_GetLuaState", + "line": 2044, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 131, + "declaration": "function plugin_getluastate: Plua_State; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "GetLuaState", + "line": 455, + "declaration": "CEP_GETLUASTATE GetLuaState;", + "typeDeclaration": { + "line": 265, + "text": "typedef lua_State *(__fastcall *CEP_GETLUASTATE)();" + } + }, + "pascalMirror": { + "name": "GetLuaState", + "line": 532, + "declaration": "GetLuaState: TGetLuaState;", + "typeDeclaration": { + "line": 341, + "text": "type TGetLuaState=function: pointer; stdcall;" + } + } + }, + "divergenceRefs": [ + "D06" + ], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + }, + { + "slot": 158, + "x64Offset": 1264, + "width": 8, + "minDeclaredSize": 1272, + "section": "V5", + "hostField": { + "name": "MainThreadCall", + "type": "pointer", + "line": 225 + }, + "hostAssignment": { + "kind": "FunctionAddress", + "expression": "@pluginsync", + "line": 2045, + "condition": null + }, + "hostImplementation": { + "source": "pluginexports-pas", + "line": 16, + "declaration": "function pluginsync(func: TPluginFunc; parameters: pointer): pointer; stdcall;" + }, + "nature": "Function", + "indirection": 0, + "callingConvention": "Stdcall", + "nullability": "NonNull", + "mirrors": { + "headerC": { + "name": "MainThreadCall", + "line": 456, + "declaration": "VOID *MainThreadCall;", + "typeDeclaration": null + }, + "pascalMirror": { + "name": "MainThreadCall", + "line": 533, + "declaration": "MainThreadCall: TMainThreadCall;", + "typeDeclaration": { + "line": 344, + "text": "type TMainThreadCall=function(func: TPluginFunc; parameters: pointer): pointer; stdcall;" + } + } + }, + "divergenceRefs": [], + "sdkExposure": "None", + "sdkField": null, + "facadeStatus": "Deferred", + "ownership": "Unknown", + "luaEquivalent": null, + "catalogSurfaceId": null, + "evidenceKind": "ObservedSource", + "layoutEvidenceKind": "Deduced", + "hostProfileStatus": "NotObserved", + "qualification": "NotExecuted" + } + ], + "divergences": [ + { + "id": "D01", + "subject": "GetAddressFromPointer result width", + "slots": [ + 17 + ], + "records": [], + "callbacks": [], + "headerC": "CEP_GETADDRESSFROMPOINTER returns UINT_PTR (cepluginsdk.h)", + "hostPascal": "ce_getaddressfrompointer returns dword (pluginexports.pas line 33)", + "decision": "Keep the slot opaque (void*); no callable signature before a per-profile qualification.", + "auditRef": "annexes/05 divergence register; analyses/03", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D02", + "subject": "previousOpcode and nextOpcode result width", + "slots": [ + 89, + 90 + ], + "records": [], + "callbacks": [], + "headerC": "CEP_PREVIOUSOPCODE and CEP_NEXTOPCODE return DWORD", + "hostPascal": "ce_previousOpcode and ce_nextOpcode return ptrUint (pluginexports.pas lines 28-29)", + "decision": "Never generate a 32-bit result from the header; slots stay unexposed.", + "auditRef": "annexes/05 divergence register; analyses/03", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D03", + "subject": "disassembleEx address parameter", + "slots": [ + 91 + ], + "records": [], + "callbacks": [], + "headerC": "CEP_DISASSEMBLEEX takes the address by value", + "hostPascal": "the slot is assigned ce_disassemble, which takes a pointer to the address (pluginexports.pas line 27)", + "decision": "Review the effective slot binding before any native projection; slot stays unexposed.", + "auditRef": "annexes/05 divergence register; analyses/03", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D04", + "subject": "createForm parameter list", + "slots": [ + 124 + ], + "records": [], + "callbacks": [], + "headerC": "CEP_CREATEFORM takes no parameter", + "hostPascal": "ce_createForm takes visible: boolean (pluginexports.pas line 76)", + "decision": "Use the documented Lua route (createForm); never declare the two ABI forms equivalent.", + "auditRef": "annexes/05 divergence register; analyses/03", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D05", + "subject": "image_transparent and image_stretch boolean width", + "slots": [ + 134, + 135 + ], + "records": [], + "callbacks": [], + "headerC": "the flag parameter is a four-byte BOOL", + "hostPascal": "the flag parameter is a one-byte Pascal boolean (pluginexports.pas lines 88-89)", + "decision": "Make the boolean width explicit before any projection; slots stay unexposed.", + "auditRef": "annexes/05 divergence register", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D06", + "subject": "classic GetLuaState calling convention", + "slots": [ + 157 + ], + "records": [], + "callbacks": [], + "headerC": "CEP_GETLUASTATE is declared __fastcall", + "hostPascal": "the slot is assigned plugin_getluastate, declared stdcall (pluginexports.pas line 131)", + "decision": "Never transpose this convention to the distinct 48-byte managed table, whose GetLuaState stays stdcall.", + "auditRef": "annexes/05 divergence register", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D07", + "subject": "process watcher callback (plugin type 3)", + "slots": [], + "records": [ + "ProcessWatcherPluginInit" + ], + "callbacks": [ + 3 + ], + "headerC": "CEP_PLUGINTYPE3 returns void and takes a 32-bit peprocess", + "hostPascal": "TPluginFunction3 returns integer and takes a pointer-sized peprocess (plugin.pas line 776); version-1 plugins get a two-argument call", + "decision": "ProcessWatcherPluginInit.Callback stays opaque (void*) until a profile decision.", + "auditRef": "annexes/05 divergence register; analyses/03", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D08", + "subject": "pointer-change callback result (plugin type 4)", + "slots": [], + "records": [ + "FunctionPointerChangePluginInit" + ], + "callbacks": [ + 4 + ], + "headerC": "CEP_PLUGINTYPE4 returns void", + "hostPascal": "TPluginFunction4 returns a Pascal boolean (plugin.pas line 787)", + "decision": "Determine the effective use of the result; the callback stays opaque, no silent removal.", + "auditRef": "annexes/05 divergence register", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D09", + "subject": "type-0 selection record", + "slots": [], + "records": [ + "PluginType0Record" + ], + "callbacks": [ + 0 + ], + "headerC": "PLUGINTYPE0_RECORD: UINT_PTR address, BOOL ispointer (agrees with the host)", + "hostPascal": "host TPlugin0_SelectedRecord (plugin.pas lines 726-735): ptrUint address, BOOL ispointer; kit mirrors in cepluginsdk.pas use dword address (lines 161-170) or boolean ispointer (lines 147-156)", + "decision": "Offsets and widths follow the host type; SelectedRecordOracleTests keep both mirrors as negative oracles.", + "auditRef": "annexes/05 selected record; analyses/03", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D10", + "subject": "managed bootstrap entry signature", + "slots": [], + "records": [ + "PluginInitRecord" + ], + "callbacks": [], + "headerC": "not declared in the C kit; the historical public C# example uses a string entry", + "hostPascal": "the SDK targets the hostfxr component form (IntPtr args, int size) with the 36-byte packed TPluginDotNetInitResult (plugin.pas lines 29-36)", + "decision": "Separate load profiles; never fix the SDK from the historical example.", + "auditRef": "annexes/05 divergence register; analyses/04", + "origin": "Audit", + "evidenceKind": "ObservedSource" + }, + { + "id": "D11", + "subject": "KernelVirtualAllocEx assignment target", + "slots": [ + 61 + ], + "records": [], + "callbacks": [], + "headerC": "the slot is named KernelVirtualAllocEx in all three declarations", + "hostPascal": "the assignment expression is @VQE, a name that does not match the field and is not declared in pluginexports.pas", + "decision": "Record the expression verbatim (ToQualify); invent no semantics and expose nothing.", + "auditRef": "S-ABI source observation", + "origin": "LotObservation", + "evidenceKind": "ObservedSource" + }, + { + "id": "D12", + "subject": "indirection inside the cell section (slots 18-81)", + "slots": [ + 36, + 37, + 38, + 39, + 41, + 42, + 44, + 45, + 47, + 48, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72 + ], + "records": [], + "callbacks": [], + "headerC": "both mirrors declare the whole section as pointer cells (cepluginsdk.pas ppointer; cepluginsdk.h groups it as cells)", + "hostPascal": "the host assigns a cell address (@@) only to some slots of the section; the listed slots receive a direct @ expression", + "decision": "Classify each slot from its host assignment, never from the section; the listed slots are not cells.", + "auditRef": "S-ABI source observation; annexes/04 minimum access size and indirection", + "origin": "LotObservation", + "evidenceKind": "ObservedSource" + } + ], + "callbackCategories": [ + { + "pluginType": 0, + "name": "ptAddressList", + "hostForm": { + "source": "plugin-pas", + "line": 737, + "declaration": "type TPluginfunction0=function(selectedrecord: PPlugin0_SelectedRecord):bool; stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 40, + "declaration": "typedef BOOL (__stdcall *CEP_PLUGINTYPE0)(PPLUGINTYPE0_RECORD SelectedRecord);" + }, + "additionalForms": [], + "versionVariants": [], + "versionDependent": false, + "context": "MainThread", + "synchronousDecision": false, + "sdkRecord": "AddressListPluginInit", + "sdkCallbackTyped": false, + "shapeTest": "PluginCallbackShapeTests.AddressList_callback_slot_stays_opaque_until_a_live_canary_qualifies_the_selection_record", + "note": "Address-list menu entry; borrowed selection record with 255-byte text buffers, never freed or retained.", + "evidenceKind": "Deduced" + }, + { + "pluginType": 1, + "name": "ptMemoryView", + "hostForm": { + "source": "plugin-pas", + "line": 751, + "declaration": "type TPluginfunction1=function(disassembleraddress: pptruint; selected_disassembler_address: pptruint; hexviewaddress:pptruint ):bool; stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 41, + "declaration": "typedef BOOL (__stdcall *CEP_PLUGINTYPE1)(UINT_PTR *disassembleraddress, UINT_PTR *selected_disassembler_address, UINT_PTR *hexviewaddress);" + }, + "additionalForms": [], + "versionVariants": [], + "versionDependent": false, + "context": "MainThread", + "synchronousDecision": false, + "sdkRecord": "MemoryViewPluginInit", + "sdkCallbackTyped": true, + "shapeTest": "PluginCallbackShapeTests.MemoryView_callback_takes_three_in_out_addresses_and_returns_Bool32", + "note": "Memory view menu entry; addresses passed by reference.", + "evidenceKind": "Deduced" + }, + { + "pluginType": 2, + "name": "ptOnDebugEvent", + "hostForm": { + "source": "plugin-pas", + "line": 766, + "declaration": "type TPluginFunction2=function(debugevent: PDebugEvent):integer; stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 42, + "declaration": "typedef int (__stdcall *CEP_PLUGINTYPE2)(LPDEBUG_EVENT DebugEvent);" + }, + "additionalForms": [], + "versionVariants": [], + "versionDependent": false, + "context": "Unknown", + "synchronousDecision": true, + "sdkRecord": "DebugEventPluginInit", + "sdkCallbackTyped": true, + "shapeTest": "PluginCallbackShapeTests.DebugEvent_callback_takes_an_event_pointer_and_returns_int", + "note": "Debug event filter; the result is a synchronous continuation decision (0 leaves continuation to Cheat Engine, the only value the SDK returns). Thread affinity not measured (Q38).", + "evidenceKind": "Deduced" + }, + { + "pluginType": 3, + "name": "ptProcesswatcherEvent", + "hostForm": { + "source": "plugin-pas", + "line": 776, + "declaration": "type TPluginFunction3=function(processid: dword; peprocess:ptruint; created: BOOL):integer; stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 43, + "declaration": "typedef void (__stdcall *CEP_PLUGINTYPE3)(ULONG processid, ULONG peprocess, BOOL Created);" + }, + "additionalForms": [], + "versionVariants": [ + { + "source": "plugin-pas", + "line": 777, + "declaration": "type TPluginFunction3Version1=function(processid: dword; peprocess:ptruint):integer; stdcall;", + "dispatchLines": [ + 1804 + ] + } + ], + "versionDependent": true, + "context": "WorkerThread", + "synchronousDecision": false, + "sdkRecord": "ProcessWatcherPluginInit", + "sdkCallbackTyped": false, + "shapeTest": "PluginCallbackShapeTests.Untyped_callbacks_round_trip_a_function_address", + "note": "Process watcher notification off the GUI thread; version-1 plugins receive two arguments.", + "evidenceKind": "Deduced" + }, + { + "pluginType": 4, + "name": "ptFunctionPointerchange", + "hostForm": { + "source": "plugin-pas", + "line": 787, + "declaration": "type TPluginFunction4=function(section: integer):boolean; stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 44, + "declaration": "typedef void (__stdcall *CEP_PLUGINTYPE4)(int reserved);" + }, + "additionalForms": [], + "versionVariants": [], + "versionDependent": false, + "context": "Unknown", + "synchronousDecision": false, + "sdkRecord": "FunctionPointerChangePluginInit", + "sdkCallbackTyped": false, + "shapeTest": "PluginCallbackShapeTests.Untyped_callbacks_round_trip_a_function_address", + "note": "Notification that a hookable pointer section changed; result use unresolved (D08).", + "evidenceKind": "Deduced" + }, + { + "pluginType": 5, + "name": "ptMainMenu", + "hostForm": { + "source": "plugin-pas", + "line": 797, + "declaration": "type TPluginfunction5=procedure; stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 45, + "declaration": "typedef void (__stdcall *CEP_PLUGINTYPE5)(void);" + }, + "additionalForms": [], + "versionVariants": [], + "versionDependent": false, + "context": "MainThread", + "synchronousDecision": false, + "sdkRecord": "MainMenuPluginInit", + "sdkCallbackTyped": true, + "shapeTest": "PluginCallbackShapeTests.MainMenu_callback_takes_nothing_and_returns_nothing", + "note": "Main menu entry; name and shortcut must stay valid while registered.", + "evidenceKind": "Deduced" + }, + { + "pluginType": 6, + "name": "ptDisassemblerContext", + "hostForm": { + "source": "plugin-pas", + "line": 812, + "declaration": "type TPluginfunction6=function(selectedAddress: pptruint):bool; stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 47, + "declaration": "typedef BOOL (__stdcall *CEP_PLUGINTYPE6)(UINT_PTR *selectedAddress);" + }, + "additionalForms": [ + { + "source": "plugin-pas", + "line": 813, + "declaration": "type Tpluginfuntion6OnContext=function(selectedAddress: ptruint; addressofname: pointer; show: pbool):bool; stdcall;" + } + ], + "versionVariants": [ + { + "source": "plugin-pas", + "line": 814, + "declaration": "type Tpluginfuntion6OnContextVersion5=function(selectedAddress: ptruint; addressofname: pointer):bool; stdcall;", + "dispatchLines": [ + 1752 + ] + } + ], + "versionDependent": true, + "context": "MainThread", + "synchronousDecision": false, + "sdkRecord": "DisassemblerContextPluginInit", + "sdkCallbackTyped": false, + "shapeTest": "PluginCallbackShapeTests.DisassemblerContext_popup_slot_stays_opaque_until_a_live_canary_establishes_its_shape", + "note": "Disassembler context menu; the popup callback gained a show flag after contract version 5.", + "evidenceKind": "Deduced" + }, + { + "pluginType": 7, + "name": "ptDisassemblerRenderLine", + "hostForm": { + "source": "plugin-pas", + "line": 829, + "declaration": "type TPluginFunction7=procedure(address: ptruint; addressStringPointer: pointer; bytestringpointer: pointer; opcodestringpointer: pointer; specialstringpointer: pointer; textcolor: PColor); stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 48, + "declaration": "typedef void (__stdcall *CEP_PLUGINTYPE7)(UINT_PTR address, char **addressStringPointer, char **bytestringpointer, char **opcodestringpointer, char **specialstringpointer, ULONG *textcolor);" + }, + "additionalForms": [], + "versionVariants": [], + "versionDependent": false, + "context": "MainThread", + "synchronousDecision": false, + "sdkRecord": "DisassemblerRenderLinePluginInit", + "sdkCallbackTyped": true, + "shapeTest": "PluginCallbackShapeTests.DisassemblerRenderLine_callback_takes_address_four_texts_and_a_colour", + "note": "Called for every rendered disassembler line (very high frequency); no string buffer lifetime is invented.", + "evidenceKind": "Deduced" + }, + { + "pluginType": 8, + "name": "ptAutoAssembler", + "hostForm": { + "source": "plugin-pas", + "line": 839, + "declaration": "type TPluginFunction8=procedure(line: ppchar; phase: integer; id: integer); stdcall;" + }, + "headerForm": { + "source": "cepluginsdk-h", + "line": 49, + "declaration": "typedef void (__stdcall *CEP_PLUGINTYPE8)(char **line, AutoAssemblerPhase phase, int id);" + }, + "additionalForms": [], + "versionVariants": [ + { + "source": "plugin-pas", + "line": 840, + "declaration": "type TPluginFunction8Version5=procedure(line: ppchar; phase: integer); stdcall;", + "dispatchLines": [ + 1725 + ] + } + ], + "versionDependent": true, + "context": "Unknown", + "synchronousDecision": false, + "sdkRecord": "AutoAssemblerPluginInit", + "sdkCallbackTyped": true, + "shapeTest": "PluginCallbackShapeTests.AutoAssembler_callback_takes_line_phase_and_id", + "note": "Auto-assembler line preprocessor; phase preserved; plugins of contract version 5 or lower get the form without id.", + "evidenceKind": "Deduced" + } + ] +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/ApiGates/ApiGateDiagnosticTests.cs b/tests/CheatEngine.SDK.Repository.Tests/ApiGates/ApiGateDiagnosticTests.cs new file mode 100644 index 00000000..f1ca19e6 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/ApiGates/ApiGateDiagnosticTests.cs @@ -0,0 +1,175 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.ApiGates; + +/// +/// Every [Experimental("CESDKxxxx")] and [Obsolete(DiagnosticId = "CESDKxxxx")] declared by shipping +/// source (libs/, src/) is a public diagnostic without an analyzer descriptor: the compiler reports it. +/// Like an analyzer rule it needs a help link and a page (shared contracts section 3.2): the attribute uses the +/// repository's UrlFormat, analyzers/docs/<id>.md exists and starts with the id, and the +/// diagnostics index lists it. Experimental gates use the CESDK5xxx range. +/// +/// +/// Shared-contracts section 3.2 requires this catalog to be built "by reflection over the packed assemblies", the +/// same technique CheatEngine.SDK.Engine.Tests.Scanning.ScanExperimentalApiTests already uses, scoped there +/// to one namespace it has a ProjectReference to. This project has none, by design (its own top-of-file +/// comment: it only reads committed files and never builds, packs or restores), so it cannot reflect over a +/// referenced assembly. Instead it loads the shipping assemblies' own build output with +/// from artifacts/bin/CheatEngine.SDK/<configuration> — the one +/// folder src/CheatEngine.SDK copies every libs/ assembly into (its csproj comment "Libraries embedded +/// under lib/net10.0") alongside its own — which the solution build that runs before this test module (shared +/// contracts section 1.8: build, then pack, then test) has already populated. This is still a from-disk load, never +/// a compile-time reference, so the "no ProjectReference" design holds; unlike a source-text scan it cannot be +/// fooled by a documentation example or a comment, and it sees exactly what the compiler bound. +/// +public sealed class ApiGateDiagnosticTests +{ + private const string UrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md"; + + [Fact] + public void Every_api_gate_attribute_uses_a_CESDK_id_and_the_documentation_url_format() + { + List gates = ApiGates(); + List offenders = []; + foreach (ApiGate gate in gates) + { + if (!HasExpectedShape(gate.Id, gate.Kind)) + { + string expectedShape = gate.Kind == ApiGateKind.Experimental ? "CESDK5 + 3 digits" : "CESDK + 4 digits"; + offenders.Add($"{gate.Location}: {gate.Kind} id '{gate.Id}' does not match '{expectedShape}'."); + } + + if (!string.Equals(gate.UrlFormat, UrlFormat, StringComparison.Ordinal)) + { + offenders.Add($"{gate.Location}: {gate.Kind}('{gate.Id}') has UrlFormat '{gate.UrlFormat}', expected '{UrlFormat}'."); + } + } + + Assert.Contains(gates, static gate => string.Equals(gate.Id, "CESDK5010", StringComparison.Ordinal)); + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void Every_api_gate_id_has_a_documentation_page_and_an_index_row() + { + string index = File.ReadAllText(Path.Combine(RepositoryRoot.Path, "analyzers", "docs", "README.md")); + List offenders = []; + foreach (string id in ApiGates().Select(static gate => gate.Id).Distinct(StringComparer.Ordinal)) + { + string page = Path.Combine(RepositoryRoot.Path, "analyzers", "docs", id + ".md"); + if (!File.Exists(page)) + { + offenders.Add($"analyzers/docs/{id}.md is missing."); + } + else if (!File.ReadAllText(page).StartsWith("# " + id + ":", StringComparison.Ordinal)) + { + offenders.Add($"analyzers/docs/{id}.md does not start with '# {id}:'."); + } + + if (!index.Contains($"| [{id}]({id}.md) |", StringComparison.Ordinal)) + { + offenders.Add($"analyzers/docs/README.md has no table row for {id}."); + } + } + + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + } + + /// Experimental ids are exactly CESDK5 + 3 digits; Obsolete ids are exactly CESDK + 4 digits. + private static bool HasExpectedShape(string id, ApiGateKind kind) + { + string prefix = kind == ApiGateKind.Experimental ? "CESDK5" : "CESDK"; + int digitCount = kind == ApiGateKind.Experimental ? 3 : 4; + if (!id.StartsWith(prefix, StringComparison.Ordinal) || id.Length != prefix.Length + digitCount) + { + return false; + } + + for (int index = prefix.Length; index < id.Length; index++) + { + if (id[index] is < '0' or > '9') + { + return false; + } + } + + return true; + } + + /// + /// The shipping assemblies (libs/ + src/CheatEngine.SDK itself), loaded once per test process from + /// the current configuration's build output. The configuration is read from this test module's own output + /// directory name (artifacts/bin/CheatEngine.SDK.Repository.Tests/<configuration>), never + /// hard-coded, so the same test scans whichever leg (Debug or Release) built it. + /// + private static readonly Lazy> s_shippingAssemblies = new(LoadShippingAssemblies); + + private static List ApiGates() + { + List gates = []; + foreach (Assembly assembly in s_shippingAssemblies.Value) + { + string assemblyName = assembly.GetName().Name ?? assembly.FullName ?? ""; + foreach (Type type in assembly.GetExportedTypes()) + { + AddGates(gates, assemblyName, type.FullName ?? type.Name, type); + const BindingFlags memberFlags = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + foreach (MemberInfo member in type.GetMembers(memberFlags)) + { + AddGates(gates, assemblyName, (type.FullName ?? type.Name) + "." + member.Name, member); + } + } + } + + return gates; + } + + private static void AddGates(List gates, string assemblyName, string location, MemberInfo member) + { + if (member.GetCustomAttribute() is { } experimental) + { + gates.Add(new ApiGate($"{assemblyName}: {location}", ApiGateKind.Experimental, experimental.DiagnosticId, + experimental.UrlFormat ?? string.Empty)); + } + + if (member.GetCustomAttribute() is { DiagnosticId.Length: > 0 } obsolete) + { + gates.Add(new ApiGate($"{assemblyName}: {location}", ApiGateKind.Obsolete, obsolete.DiagnosticId!, + obsolete.UrlFormat ?? string.Empty)); + } + } + + private static List LoadShippingAssemblies() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory).Name; + string directory = Path.Combine(RepositoryRoot.Path, "artifacts", "bin", "CheatEngine.SDK", configuration); + if (!Directory.Exists(directory)) + { + throw new InvalidOperationException( + $"'{RepositoryRoot.ToRelative(directory)}' does not exist. Build 'CheatEngine.SDK.slnx' in the " + + $"'{configuration}' configuration before running this test: shared-contracts section 3.2 reads the " + + "built shipping assemblies, never source text."); + } + + List assemblies = []; + foreach (string dll in Directory.EnumerateFiles(directory, "CheatEngine.SDK*.dll", SearchOption.TopDirectoryOnly)) + { + assemblies.Add(Assembly.LoadFrom(dll)); + } + + return assemblies; + } + + private enum ApiGateKind + { + Unknown = 0, + Experimental, + Obsolete + } + + private sealed record ApiGate(string Location, ApiGateKind Kind, string Id, string UrlFormat); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj new file mode 100644 index 00000000..50969b42 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Diagnostics/ApiDiagnosticIdTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Diagnostics/ApiDiagnosticIdTests.cs new file mode 100644 index 00000000..3310be7d --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Diagnostics/ApiDiagnosticIdTests.cs @@ -0,0 +1,603 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Diagnostics; + +/// +/// The diagnostic identifiers that SDK product code declares on its own API through +/// [Obsolete(DiagnosticId = "CESDKnnnn")] and [Experimental("CESDKnnnn")]. The C# compiler raises them in +/// consumer code, so, like the analyzer descriptors that DiagnosticCatalogTests covers, each one needs a +/// documentation page, a row in analyzers/docs/README.md, the repository help-link format and an identifier +/// from its reserved range (shared-contracts section 3.2: 5xxx experimental gates, 7xxx obsoletions). +/// +/// +/// This project has no ProjectReference by design, so the attributes are read from the committed sources of +/// libs/ and src/ rather than by reflection over built assemblies. Only code is scanned: comments, +/// including XML documentation that quotes an attribute in a <c> element, and string or character +/// literals never count as a declaration. An attribute is found alone, inside an attribute list +/// ([EditorBrowsable(...), Obsolete(...)]) and after a target specifier ([method: Obsolete(...)]). +/// Because the scan reads text, an identifier must be written as a "CESDKnnnn" string literal: a constant or +/// any other expression is reported rather than skipped. +/// +public sealed partial class ApiDiagnosticIdTests +{ + private const string HelpUrlFormat = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/{0}.md"; + + /// + /// Reviewed obsoletions outside the 7xxx range: the legacy Lua registration pair shares its usage analyzer + /// identifier (shared-contracts section 3.3, S-REG-ANALYZER, CESDK1006). + /// + private static readonly HashSet s_reviewedObsoleteIdsOutsideTheRange = new(StringComparer.Ordinal) + { + "CESDK1006" + }; + + [Fact] + public void the_source_scan_finds_the_declared_api_diagnostic_ids() + { + IReadOnlyList ids = ScanApiDiagnosticIds(); + + Assert.Contains(ids, static id => id is { Kind: ApiDiagnosticKind.Obsolete, Id: "CESDK7001" } && + string.Equals(id.File, "libs/CheatEngine.SDK.Engine/Runtime/PointerSize.cs", StringComparison.Ordinal)); + } + + [Fact] + public void every_obsolete_or_experimental_diagnostic_id_is_a_cesdk_string_literal() + { + List offenders = []; + foreach (ApiDiagnosticId id in ScanApiDiagnosticIds()) + { + if (!id.IsLiteral) + { + offenders.Add($"{id.File}: {id.Kind} identifier '{id.Id}' is not a \"CESDKnnnn\" string literal."); + } + } + + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void every_obsolete_or_experimental_diagnostic_id_has_a_documentation_page_and_a_readme_row() + { + string readme = File.ReadAllText(Path.Combine(RepositoryRoot.Path, "analyzers", "docs", "README.md")); + List offenders = []; + foreach (ApiDiagnosticId id in ScanApiDiagnosticIds()) + { + if (!id.IsLiteral) + { + // Reported by every_obsolete_or_experimental_diagnostic_id_is_a_cesdk_string_literal. + continue; + } + + string page = Path.Combine(RepositoryRoot.Path, "analyzers", "docs", id.Id + ".md"); + if (!File.Exists(page)) + { + offenders.Add($"{id.File}: {id.Id} has no page analyzers/docs/{id.Id}.md."); + } + else if (!File.ReadAllText(page).StartsWith("# " + id.Id + ":", StringComparison.Ordinal)) + { + offenders.Add($"analyzers/docs/{id.Id}.md does not start with '# {id.Id}:'."); + } + + if (!readme.Contains($"| [{id.Id}]({id.Id}.md) |", StringComparison.Ordinal)) + { + offenders.Add($"analyzers/docs/README.md has no table row for {id.Id}."); + } + } + + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void every_obsolete_or_experimental_diagnostic_id_uses_the_repository_help_url_format() + { + List offenders = []; + foreach (ApiDiagnosticId id in ScanApiDiagnosticIds()) + { + if (!string.Equals(id.UrlFormat, HelpUrlFormat, StringComparison.Ordinal)) + { + offenders.Add($"{id.File}: {id.Id} has UrlFormat '{id.UrlFormat ?? "(none)"}', expected '{HelpUrlFormat}'."); + } + } + + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void obsolete_ids_are_in_the_7xxx_range_and_experimental_ids_in_the_5xxx_range() + { + List offenders = []; + foreach (ApiDiagnosticId id in ScanApiDiagnosticIds()) + { + if (!id.IsLiteral) + { + // Reported by every_obsolete_or_experimental_diagnostic_id_is_a_cesdk_string_literal. + continue; + } + + char rangeDigit = id.Id["CESDK".Length]; + bool inRange = id.Kind == ApiDiagnosticKind.Obsolete + ? rangeDigit == '7' || s_reviewedObsoleteIdsOutsideTheRange.Contains(id.Id) + : rangeDigit == '5'; + if (!inRange) + { + offenders.Add($"{id.File}: {id.Kind} id {id.Id} is outside its reserved range."); + } + } + + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void the_scan_reads_both_attribute_shapes_and_ignores_obsoletions_without_an_identifier() + { + const string Source = """ + [Obsolete("old", DiagnosticId = "CESDK7999", UrlFormat = "https://example.invalid/{0}")] + public static void A() { } + [Obsolete("no identifier")] + public static void B() { } + /// Gated by [Experimental("CESDK5999")] until a receipt exists. + [Experimental("CESDK5999", UrlFormat = "https://example.invalid/{0}.md")] + public static void C() { } + [System.Diagnostics.CodeAnalysis.Experimental("CESDK5998")] + public static void D() { } + """; + + List ids = [.. Scan("sample.cs", Source)]; + + Assert.Equal(3, ids.Count); + Assert.Equal(new ApiDiagnosticId("sample.cs", ApiDiagnosticKind.Obsolete, "CESDK7999", "https://example.invalid/{0}"), + ids[0]); + Assert.Equal(new ApiDiagnosticId("sample.cs", ApiDiagnosticKind.Experimental, "CESDK5999", + "https://example.invalid/{0}.md"), ids[1]); + Assert.Equal(new ApiDiagnosticId("sample.cs", ApiDiagnosticKind.Experimental, "CESDK5998", null), ids[2]); + } + + [Fact] + public void the_scan_ignores_attribute_text_in_comments_and_literals_and_keeps_urls_that_contain_slashes() + { + const string Source = """" + // [Obsolete("line comment", DiagnosticId = "CESDK7901")] + /* [Experimental("CESDK5901")] spans + [Obsolete("block comment", DiagnosticId = "CESDK7902")] */ + /// [Obsolete("documentation", DiagnosticId = "CESDK7903")] + private const string Regular = "[Experimental(\"CESDK5902\")]"; + private const string Verbatim = @"[Experimental(""CESDK5903"")] /* not a comment"; + private const string Raw = """ + [Obsolete("raw", DiagnosticId = "CESDK7904")] // not a comment + """; + [Obsolete("real", DiagnosticId = "CESDK7905", UrlFormat = "https://example.invalid/{0}.md")] // [Experimental("CESDK5905")] + public static void Real() { } + private const string Url = "https://example.invalid/"; [Experimental("CESDK5906", UrlFormat = "https://example.invalid/{0}")] + private const char Quote = '"'; [Obsolete("after a character literal", DiagnosticId = "CESDK7906", UrlFormat = "u")] + private static string F(int x) => $"{(x > 0 ? "[Experimental(\"CESDK5904\")]" : "")} // text"; [Experimental("CESDK5907", UrlFormat = "v")] + """"; + + ApiDiagnosticId[] expected = + [ + new("sample.cs", ApiDiagnosticKind.Obsolete, "CESDK7905", "https://example.invalid/{0}.md"), + new("sample.cs", ApiDiagnosticKind.Experimental, "CESDK5906", "https://example.invalid/{0}"), + new("sample.cs", ApiDiagnosticKind.Obsolete, "CESDK7906", "u"), + new("sample.cs", ApiDiagnosticKind.Experimental, "CESDK5907", "v") + ]; + + Assert.Equal(expected, Scan("sample.cs", Source)); + } + + [Fact] + public void an_attribute_quoted_in_a_string_does_not_hide_the_declaration_that_follows_it() + { + const string Source = """ + private const string Hint = "write [Obsolete(\"x\" here"; [Obsolete("real", DiagnosticId = "CESDK7907", UrlFormat = "w")] + [Obsolete("message with f(x)] inside", DiagnosticId = "CESDK7908", UrlFormat = "x")] + """; + + ApiDiagnosticId[] expected = + [ + new("sample.cs", ApiDiagnosticKind.Obsolete, "CESDK7907", "w"), + new("sample.cs", ApiDiagnosticKind.Obsolete, "CESDK7908", "x") + ]; + + Assert.Equal(expected, Scan("sample.cs", Source)); + } + + [Fact] + public void the_scan_reads_attribute_lists_and_targets_and_reports_identifiers_that_are_not_cesdk_literals() + { + const string Source = """ + [EditorBrowsable(EditorBrowsableState.Never), Obsolete("listed", DiagnosticId = "CESDK7909", UrlFormat = "a")] + [method: Obsolete("targeted", DiagnosticId = "CESDK7910", UrlFormat = "b")] + [return: global::System.Diagnostics.CodeAnalysis.ExperimentalAttribute("CESDK5908")] + [Experimental("CESDK5909"), EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete(nameof(Old), DiagnosticId = "CESDK7911", UrlFormat = "c")] + [Obsolete("constant", DiagnosticId = Ids.Old, UrlFormat = "d")] + [Experimental(Ids.Gate)] + [Obsolete("foreign", DiagnosticId = "SYSLIB0999")] + private static int Call(int x) => Math.Max(x, Obsolete(x)) + Invoke(1, Experimental(2)); + """; + + ApiDiagnosticId[] expected = + [ + new("sample.cs", ApiDiagnosticKind.Obsolete, "CESDK7909", "a"), + new("sample.cs", ApiDiagnosticKind.Obsolete, "CESDK7910", "b"), + new("sample.cs", ApiDiagnosticKind.Experimental, "CESDK5908", null), + new("sample.cs", ApiDiagnosticKind.Experimental, "CESDK5909", null), + new("sample.cs", ApiDiagnosticKind.Obsolete, "CESDK7911", "c"), + new("sample.cs", ApiDiagnosticKind.Obsolete, "Ids.Old", "d", IsLiteral: false), + new("sample.cs", ApiDiagnosticKind.Experimental, "Ids.Gate", null, IsLiteral: false), + new("sample.cs", ApiDiagnosticKind.Obsolete, "\"SYSLIB0999\"", null, IsLiteral: false) + ]; + + Assert.Equal(expected, Scan("sample.cs", Source)); + } + + private static List ScanApiDiagnosticIds() + { + List ids = []; + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*.cs")) + { + if (!file.StartsWith("libs/", StringComparison.Ordinal) && !file.StartsWith("src/", StringComparison.Ordinal)) + { + continue; + } + + ids.AddRange(Scan(file, File.ReadAllText(Path.Combine(RepositoryRoot.Path, file)))); + } + + return ids; + } + + private static IEnumerable Scan(string file, string source) + { + CodeMask mask = CodeMask.Create(source); + string code = mask.Code; + foreach (Match attribute in AttributePattern().Matches(mask.Structure)) + { + Group argumentsGroup = attribute.Groups["arguments"]; + string arguments = code.Substring(argumentsGroup.Index, argumentsGroup.Length); + bool experimental = attribute.Groups["name"].Value.EndsWith("Experimental", StringComparison.Ordinal); + ApiDiagnosticKind kind = experimental ? ApiDiagnosticKind.Experimental : ApiDiagnosticKind.Obsolete; + Match url = UrlFormatPattern().Match(arguments); + string? urlFormat = url.Success ? url.Groups["url"].Value : null; + Match id = experimental ? ExperimentalIdPattern().Match(arguments) : ObsoleteIdPattern().Match(arguments); + if (id.Success) + { + yield return new ApiDiagnosticId(file, kind, id.Groups["id"].Value, urlFormat); + continue; + } + + // An [Experimental] always names an identifier; an [Obsolete] names one only through DiagnosticId. + Match expression = experimental + ? ExperimentalExpressionPattern().Match(arguments) + : ObsoleteExpressionPattern().Match(arguments); + if (expression.Success) + { + yield return new ApiDiagnosticId(file, kind, expression.Groups["expression"].Value.Trim(), urlFormat, + IsLiteral: false); + } + } + } + + // An attribute opens a list ("[", with an optional "target:" specifier) or follows a comma inside one, and is followed + // by "]" or ",". The arguments are paren-balanced in the structure view, where literals hold no parenthesis. + [GeneratedRegex( + @"(?:\[\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*:(?!:)\s*)?|,\s*)(?(?:global::)?(?:System\.)?Obsolete|(?:global::)?(?:System\.Diagnostics\.CodeAnalysis\.)?Experimental)(?:Attribute)?\s*\((?(?>[^()]+|\((?)|\)(?<-depth>))*)(?(depth)(?!))\)\s*(?=[\],])", + RegexOptions.CultureInvariant, 1000)] + private static partial Regex AttributePattern(); + + [GeneratedRegex("""DiagnosticId\s*=\s*"(?CESDK\d{4})"\s*""", RegexOptions.CultureInvariant, 1000)] + private static partial Regex ObsoleteIdPattern(); + + [GeneratedRegex("""^\s*"(?CESDK\d{4})"\s*""", RegexOptions.CultureInvariant, 1000)] + private static partial Regex ExperimentalIdPattern(); + + [GeneratedRegex("""DiagnosticId\s*=\s*(?[^,]+)""", RegexOptions.CultureInvariant, 1000)] + private static partial Regex ObsoleteExpressionPattern(); + + [GeneratedRegex("""^\s*(?[^,]+)""", RegexOptions.CultureInvariant, 1000)] + private static partial Regex ExperimentalExpressionPattern(); + + [GeneratedRegex("""UrlFormat\s*=\s*"(?[^"]*)"\s*""", RegexOptions.CultureInvariant, 1000)] + private static partial Regex UrlFormatPattern(); + + private enum ApiDiagnosticKind + { + Unknown = 0, + Obsolete = 1, + Experimental = 2 + } + + /// One declared identifier; Id holds the expression text when it is not a CESDK string literal. + private sealed record ApiDiagnosticId( + string File, + ApiDiagnosticKind Kind, + string Id, + string? UrlFormat, + bool IsLiteral = true); + + /// + /// Two same-length views of a C# source. blanks comments (//, ///, + /// /* */) and keeps literal text, because attribute arguments are literals and a // inside a + /// UrlFormat string is not a comment. also blanks brackets and parentheses inside + /// string and character literals, so a pattern run over it can neither start nor end inside a literal. Regular, + /// verbatim, raw and interpolated strings are recognised; code inside interpolation holes is lexed as code. Line + /// breaks are kept, so positions and line numbers match the source. + /// + private sealed class CodeMask + { + private readonly char[] _code; + private readonly string _source; + private readonly char[] _structure; + private int _position; + + private CodeMask(string source) + { + _source = source; + _code = source.ToCharArray(); + _structure = source.ToCharArray(); + } + + /// The source with comments blanked. + public string Code => new(_code); + + /// The source with comments, and brackets and parentheses inside literals, blanked. + public string Structure => new(_structure); + + public static CodeMask Create(string source) + { + CodeMask mask = new(source); + mask.ReadCode(false); + return mask; + } + + /// Reads code until the end, or, inside an interpolation hole, up to its closing brace. + private void ReadCode(bool inHole) + { + int depth = 0; + while (_position < _source.Length) + { + char current = _source[_position]; + if (current == '/' && Peek(1) == '/') + { + BlankLineComment(); + } + else if (current == '/' && Peek(1) == '*') + { + BlankBlockComment(); + } + else if (current == '\'') + { + ReadCharacterLiteral(); + } + else if (!TryReadStringLiteral()) + { + if (inHole) + { + if (current is '(' or '[' or '{') + { + depth++; + } + else if ((current is ')' or ']' or '}') && depth > 0) + { + depth--; + } + else if (current == '}') + { + return; + } + else if (current == ':' && depth == 0) + { + // Format specifier: literal text up to the closing brace of the hole. + while (_position < _source.Length && _source[_position] != '}') + { + ConsumeLiteral(1); + } + + return; + } + } + + _position++; + } + } + } + + private bool TryReadStringLiteral() + { + int index = _position; + int dollars = 0; + bool verbatim = false; + while (index < _source.Length && (_source[index] is '$' or '@')) + { + if (_source[index] == '$') + { + dollars++; + } + else + { + verbatim = true; + } + + index++; + } + + if (index >= _source.Length || _source[index] != '"') + { + return false; + } + + int quotes = CountRun(index, '"'); + if (!verbatim && quotes >= 3) + { + ConsumeLiteral(index - _position + quotes); + ReadRawStringBody(quotes, dollars); + } + else if (!verbatim && quotes == 2) + { + ConsumeLiteral(index - _position + 2); + } + else + { + ConsumeLiteral(index - _position + 1); + ReadQuotedStringBody(verbatim, dollars > 0); + } + + return true; + } + + private void ReadQuotedStringBody(bool verbatim, bool interpolated) + { + while (_position < _source.Length) + { + char current = _source[_position]; + if (!verbatim && current == '\n') + { + return; + } + + if (!verbatim && current == '\\') + { + ConsumeLiteral(Math.Min(2, _source.Length - _position)); + } + else if (verbatim && current == '"' && Peek(1) == '"') + { + ConsumeLiteral(2); + } + else if (current == '"') + { + ConsumeLiteral(1); + return; + } + else if (!interpolated || !TryReadHole(1, false)) + { + ConsumeLiteral(1); + } + } + } + + private void ReadRawStringBody(int quotes, int dollars) + { + while (_position < _source.Length) + { + if (_source[_position] == '"') + { + int run = CountRun(_position, '"'); + ConsumeLiteral(run); + if (run >= quotes) + { + return; + } + } + else if (dollars == 0 || !TryReadHole(dollars, true)) + { + ConsumeLiteral(1); + } + } + } + + /// + /// At an opening brace of an interpolated string: consumes escaped braces as text, or the hole with its code. + /// A quoted string escapes a brace by doubling it; a raw string with dollar signs + /// opens a hole with that many braces. + /// + private bool TryReadHole(int dollars, bool raw) + { + if (_source[_position] != '{') + { + return false; + } + + int run = CountRun(_position, '{'); + bool opensHole = raw ? run >= dollars : run % 2 == 1; + ConsumeLiteral(run); + if (!opensHole) + { + return true; + } + + ReadCode(true); + ConsumeLiteral(Math.Min(raw ? dollars : 1, CountRun(_position, '}'))); + return true; + } + + private void ReadCharacterLiteral() + { + ConsumeLiteral(1); + while (_position < _source.Length && _source[_position] != '\n') + { + char current = _source[_position]; + ConsumeLiteral(current == '\\' ? Math.Min(2, _source.Length - _position) : 1); + if (current == '\'') + { + return; + } + } + } + + private void BlankLineComment() + { + while (_position < _source.Length && _source[_position] is not ('\r' or '\n')) + { + Blank(_position); + _position++; + } + } + + private void BlankBlockComment() + { + Blank(_position); + Blank(_position + 1); + _position += 2; + while (_position < _source.Length) + { + bool closes = _source[_position] == '*' && Peek(1) == '/'; + if (_source[_position] is not ('\r' or '\n')) + { + Blank(_position); + } + + _position++; + if (closes) + { + Blank(_position); + _position++; + return; + } + } + } + + private void Blank(int index) + { + _code[index] = ' '; + _structure[index] = ' '; + } + + private void ConsumeLiteral(int count) + { + for (int end = _position + count; _position < end; _position++) + { + if (_source[_position] is '[' or ']' or '(' or ')') + { + _structure[_position] = ' '; + } + } + } + + private int CountRun(int index, char character) + { + int end = index; + while (end < _source.Length && _source[end] == character) + { + end++; + } + + return end - index; + } + + private char Peek(int offset) + { + return _position + offset < _source.Length ? _source[_position + offset] : '\0'; + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Generation/GeneratorInventoryTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Generation/GeneratorInventoryTests.cs new file mode 100644 index 00000000..0d2ee319 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Generation/GeneratorInventoryTests.cs @@ -0,0 +1,129 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Generation; + +/// +/// Repository guards for the Roslyn components (audit A19-18, A19-20, A00-21, ADR-05): the source generator +/// inventory is the reviewed set, every analyzer and generator keeps the extended analyzer rules (RS1035: no file, +/// environment, console or culture access inside the compiler), and no generator embeds a path to a local Cheat +/// Engine installation or reads its celua.txt. +/// +public sealed partial class GeneratorInventoryTests +{ + // ADR-05: adding a generator (or a shared component) is a review decision, recorded here. + private static readonly string[] s_reviewedSourceGeneratorProjects = + [ + "source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/CheatEngine.SDK.SourceGenerators.EngineApi.csproj", + "source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/CheatEngine.SDK.SourceGenerators.EntryPoint.csproj", + "source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/CheatEngine.SDK.SourceGenerators.LuaBindings.csproj", + "source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.csproj", + "source-generators/CheatEngine.SDK.SourceGenerators.Shared/CheatEngine.SDK.SourceGenerators.Shared.csproj" + ]; + + // The shared project holds emitters and models only: it declares no [Generator] of its own. + private static readonly string[] s_generatorDeclaringProjects = + [ + "source-generators/CheatEngine.SDK.SourceGenerators.EngineApi", + "source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint", + "source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings", + "source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract" + ]; + + [Fact] + public void Source_generator_projects_are_exactly_the_reviewed_set() + { + string[] projects = + [ + .. RepositoryRoot.EnumerateSourceFiles("*.csproj") + .Where(static path => path.StartsWith("source-generators/", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + ]; + + Assert.Equal(s_reviewedSourceGeneratorProjects, projects); + + string[] declaring = + [ + .. ComponentSources() + .Where(static path => GeneratorAttribute().IsMatch(Read(path))) + .Select(static path => string.Join('/', path.Split('/').Take(2))) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + ]; + Assert.Equal(s_generatorDeclaringProjects, declaring); + } + + [Fact] + public void Every_roslyn_component_keeps_the_extended_analyzer_rules() + { + string props = Read("eng/RoslynComponent.props"); + Assert.Contains("true", props, + StringComparison.Ordinal); + Assert.Contains("true", props, StringComparison.Ordinal); + + // The profile is imported by folder: every csproj under analyzers/ and source-generators/ gets it. + string buildProps = Read("Directory.Build.props"); + Assert.Matches( + new Regex( + @"= 7, "The Roslyn component scan found only " + components.Length + " projects."); + foreach (string project in components) + { + string text = Read(project); + Assert.DoesNotContain("EnforceExtendedAnalyzerRules", text, StringComparison.Ordinal); + Assert.DoesNotContain("IsRoslynComponent", text, StringComparison.Ordinal); + Assert.DoesNotContain("RS1035", text, StringComparison.Ordinal); + Assert.DoesNotContain("RoslynComponent.props", text, StringComparison.Ordinal); + } + } + + [Fact] + public void Generators_never_embed_a_local_cheat_engine_path() + { + List offences = []; + foreach (string path in ComponentSources()) + { + string text = Read(path); + if (text.Contains("Program Files", StringComparison.OrdinalIgnoreCase) || + text.Contains("celua.txt", StringComparison.OrdinalIgnoreCase) || + DriveRootedPath().IsMatch(text)) + { + offences.Add(path); + } + } + + Assert.True(offences.Count == 0, + "Generators and analyzers must take Cheat Engine facts from committed specs, never from a local installation: " + + string.Join(", ", offences)); + } + + private static IEnumerable RoslynComponentProjects() + { + return RepositoryRoot.EnumerateSourceFiles("*.csproj").Where(static path => + path.StartsWith("analyzers/", StringComparison.Ordinal) || + path.StartsWith("source-generators/", StringComparison.Ordinal)); + } + + private static IEnumerable ComponentSources() + { + return RepositoryRoot.EnumerateSourceFiles("*.cs").Where(static path => + path.StartsWith("analyzers/", StringComparison.Ordinal) || + path.StartsWith("source-generators/", StringComparison.Ordinal)); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(Path.Combine(RepositoryRoot.Path, relativePath)); + } + + [GeneratedRegex(@"^\s*\[Generator[(\]]", RegexOptions.Multiline, 1000)] + private static partial Regex GeneratorAttribute(); + + // A drive-rooted Windows path in code or text ("C:\..." or "D:/..."), not a URI scheme such as "https://". + [GeneratedRegex(@"(? +/// .github/dependabot.yml (audit register PR-CQ-07): every ecosystem waits before proposing a fresh release, +/// the Roslyn pin and the SDK-implicit packages never move on their own, and titles stay compatible with the pull +/// request policy. https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference +/// +public sealed class DependabotConfigurationTests +{ + private const string ConfigurationPath = ".github/dependabot.yml"; + private const int MinimumCooldownDays = 7; + + /// Packages that move only with RoslynComponentFloor (CESDK9002) or with global.json. + private static readonly string[] s_pinnedPackages = + [ + "Microsoft.CodeAnalysis.CSharp", + "Microsoft.CodeAnalysis.CSharp.Workspaces", + "Microsoft.CodeAnalysis.Analyzers", + "Microsoft.NET.ILLink.Tasks", + "Microsoft.DotNet.ILCompiler", + "runtime.*.Microsoft.DotNet.ILCompiler" + ]; + + private static readonly string[] s_cooldownKeys = + ["default-days", "semver-major-days", "semver-minor-days", "semver-patch-days"]; + + [Fact] + public void Every_ecosystem_has_a_cooldown_of_at_least_seven_days() + { + List problems = []; + foreach (YamlMappingNode update in Updates()) + { + string ecosystem = YamlDocument.Scalar(update, "package-ecosystem") ?? "?"; + YamlNode? cooldown = YamlDocument.Child(update, "cooldown"); + if (cooldown is null) + { + problems.Add($"{ecosystem}: no cooldown"); + continue; + } + + if (YamlDocument.Scalar(cooldown, "default-days") is null) + { + problems.Add($"{ecosystem}: no default-days"); + } + + foreach (string key in s_cooldownKeys) + { + string? value = YamlDocument.Scalar(cooldown, key); + if (value is not null && (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int days) || days < MinimumCooldownDays)) + { + problems.Add($"{ecosystem}: {key} is {value}"); + } + } + } + + Assert.True(problems.Count == 0, + $"Every Dependabot ecosystem needs a cooldown of at least {MinimumCooldownDays} days (zizmor dependabot-cooldown): {string.Join("; ", problems)}"); + } + + [Fact] + public void Roslyn_pins_and_sdk_implicit_packages_are_ignored() + { + YamlMappingNode nuget = Ecosystem("nuget"); + HashSet ignored = new(StringComparer.Ordinal); + foreach (YamlMappingNode rule in YamlDocument.Mappings(nuget, "ignore")) + { + // A whole-package ignore has no update-types: a partial ignore would still let some bumps through. + Assert.True(YamlDocument.Child(rule, "update-types") is null && YamlDocument.Child(rule, "versions") is null, + $"The nuget ignore rule for '{YamlDocument.Scalar(rule, "dependency-name")}' must ignore every version."); + ignored.Add(YamlDocument.Scalar(rule, "dependency-name") ?? ""); + } + + foreach (string package in s_pinnedPackages) + { + Assert.True(ignored.Contains(package), $"Dependabot must ignore '{package}' ({ConfigurationPath})."); + } + } + + [Fact] + public void Roslyn_ignores_cover_every_package_pinned_to_the_roslyn_floor() + { + XDocument roslynProps = XDocument.Load(RepositoryFile.FullPath("eng/RoslynComponent.props")); + string floor = Assert.Single(roslynProps.Descendants("RoslynComponentFloor")).Value.Trim(); + XDocument packages = XDocument.Load(RepositoryFile.FullPath("Directory.Packages.props")); + + List pinnedToFloor = []; + foreach (XElement version in packages.Descendants("PackageVersion")) + { + string id = (string?) version.Attribute("Include") ?? ""; + if (id.StartsWith("Microsoft.CodeAnalysis.", StringComparison.Ordinal) + && string.Equals((string?) version.Attribute("Version"), floor, StringComparison.Ordinal)) + { + pinnedToFloor.Add(id); + } + } + + Assert.NotEmpty(pinnedToFloor); + foreach (string id in pinnedToFloor) + { + Assert.True(Array.IndexOf(s_pinnedPackages, id) >= 0, + $"'{id}' is pinned to RoslynComponentFloor {floor} in Directory.Packages.props: add a Dependabot ignore for it."); + } + } + + [Fact] + public void Dotnet_sdk_ecosystem_ignores_major_updates() + { + YamlMappingNode sdk = Ecosystem("dotnet-sdk"); + Assert.Equal("/", YamlDocument.Scalar(sdk, "directory")); + + bool ignoresMajor = false; + foreach (YamlMappingNode rule in YamlDocument.Mappings(sdk, "ignore")) + { + if (string.Equals(YamlDocument.Scalar(rule, "dependency-name"), "*", StringComparison.Ordinal) + && YamlDocument.Scalars(rule, "update-types").Contains("version-update:semver-major", StringComparer.Ordinal)) + { + ignoresMajor = true; + } + } + + Assert.True(ignoresMajor, "The dotnet-sdk ecosystem must ignore semver-major updates: a new .NET major is a migration."); + } + + [Fact] + public void Github_actions_updates_cover_the_composite_action_directories() + { + YamlMappingNode actions = Ecosystem("github-actions"); + IReadOnlyList directories = YamlDocument.Scalars(actions, "directories"); + + Assert.Contains("/", directories, StringComparer.Ordinal); + Assert.Contains("/.github/actions/*", directories, StringComparer.Ordinal); + Assert.True(Directory.Exists(RepositoryFile.FullPath(".github/actions")), + "The composite action folder moved: update the github-actions directories."); + } + + [Fact] + public void No_ecosystem_sets_a_commit_message_prefix() + { + foreach (YamlMappingNode update in Updates()) + { + YamlNode? commitMessage = YamlDocument.Child(update, "commit-message"); + Assert.True(commitMessage is null || YamlDocument.Scalar(commitMessage, "prefix") is null, + $"{YamlDocument.Scalar(update, "package-ecosystem")} sets commit-message.prefix: 'deps: ...' titles break the pull request title rule."); + Assert.Null(YamlDocument.Child(update, "insecure-external-code-execution")); + } + } + + [Fact] + public void Specific_nuget_groups_come_before_the_catch_all_group() + { + IReadOnlyList groups = YamlDocument.KeysOf(YamlDocument.Child(Ecosystem("nuget"), "groups")); + + int catchAll = -1; + for (int i = 0; i < groups.Count; i++) + { + YamlNode? group = YamlDocument.Child(YamlDocument.Child(Ecosystem("nuget"), "groups"), groups[i]); + bool versionUpdates = !string.Equals(YamlDocument.Scalar(group, "applies-to"), "security-updates", + StringComparison.Ordinal); + if (versionUpdates && YamlDocument.Scalars(group, "patterns") is ["*"]) + { + catchAll = i; + } + } + + // Dependabot puts a dependency in the first group it matches. + Assert.Equal(groups.Count - 1, catchAll); + } + + private static List Updates() + { + IReadOnlyList updates = YamlDocument.Mappings(YamlDocument.Load(ConfigurationPath).Root, "updates"); + Assert.NotEmpty(updates); + return [.. updates]; + } + + private static YamlMappingNode Ecosystem(string name) + { + List matches = []; + foreach (YamlMappingNode update in Updates()) + { + if (string.Equals(YamlDocument.Scalar(update, "package-ecosystem"), name, StringComparison.Ordinal)) + { + matches.Add(update); + } + } + + return Assert.Single(matches); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceDocumentTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceDocumentTests.cs new file mode 100644 index 00000000..eceeb38c --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceDocumentTests.cs @@ -0,0 +1,408 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// The community and security documents (audit register PR-CQ-36, A22-43): SECURITY.md, +/// CODE_OF_CONDUCT.md, the informational .github/CODEOWNERS and the issue forms, whose compatibility +/// form captures the complete support tuple (audit ch.21 "the tuple to qualify", ch.22 Checkpoint F exit) and never +/// tells users to alter Cheat Engine's runtime configuration (ch.21 "CE runtime"). +/// +public sealed partial class GovernanceDocumentTests +{ + private const string SecurityPolicy = "SECURITY.md"; + private const string CodeOfConduct = "CODE_OF_CONDUCT.md"; + private const string CodeOwners = ".github/CODEOWNERS"; + private const string IssueTemplates = ".github/ISSUE_TEMPLATE"; + private const string CompatibilityForm = ".github/ISSUE_TEMPLATE/compatibility.yml"; + private const string ChooserConfiguration = ".github/ISSUE_TEMPLATE/config.yml"; + private const string PrivateReportingUrl = "https://github.com/CheatEngineNet/CheatEngine.SDK/security/advisories/new"; + private const string LuaFixture = "native/cheat-engine/lua53-64.dll"; + private const string BridgeBinary = "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"; + + /// Maintainers who may own paths; CODEOWNERS is informational (no required code-owner review). + private static readonly string[] s_knownOwners = ["@AriusII", "@ShadowNineX"]; + + /// The support tuple of the compatibility form: required ids, then optional ids. + private static readonly string[] s_requiredTupleIds = + [ + "sdk-version", "sdk-content-hash", "bridge-sha256", "ce-version", "ce-exe-sha256", "lua-dll-sha256", + "runtimeconfig-sha256", "runtimeconfig-origin", "load-profile", "target-architecture", "evidence-level", + "build-options", "os", "dotnet", "steps", "expected", "actual" + ]; + + private static readonly string[] s_optionalTupleIds = ["bridge-fingerprint", "client-version", "logs"]; + + [Fact] + public void Security_policy_names_private_reporting_scope_response_and_supported_versions() + { + string text = RepositoryFile.ReadText(SecurityPolicy); + + Assert.Equal( + ["Supported versions", "Reporting a vulnerability", "Scope", "Response", "Verifying releases", "Binary files in this repository"], + Level2Headings(text)); + Assert.Contains(PrivateReportingUrl, text, StringComparison.Ordinal); + Assert.Contains("Never report a vulnerability in a public issue", text, StringComparison.Ordinal); + Assert.Contains("within 7 days", text, StringComparison.Ordinal); + Assert.Contains("https://github.com/cheat-engine/cheat-engine", text, StringComparison.Ordinal); + // The package identity a report must carry comes from the consumer's lock file (audit ADR-10). + Assert.Contains("`contentHash`", text, StringComparison.Ordinal); + Assert.Contains("packages.lock.json", text, StringComparison.Ordinal); + Assert.Contains("[RELEASING.md](RELEASING.md)", text, StringComparison.Ordinal); + } + + [Fact] + public void Security_policy_describes_the_committed_binaries_with_their_real_hash() + { + string policy = RepositoryFile.ReadText(SecurityPolicy); + foreach (string binary in (string[]) [LuaFixture, BridgeBinary]) + { + Assert.Contains($"`{binary}`", policy, StringComparison.Ordinal); + Assert.True(RepositoryFile.ExistsWithExactCase(binary, out bool isDirectory) && !isDirectory, + $"{SecurityPolicy} describes '{binary}', which is not in the repository."); + } + + string actual = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(RepositoryFile.FullPath(LuaFixture)))); + Assert.Contains($"`{actual}`", policy, StringComparison.Ordinal); + Assert.Contains($"`{actual}`", RepositoryFile.ReadText("native/cheat-engine/README.md"), StringComparison.Ordinal); + } + + [Fact] + public void Code_of_conduct_routes_reports_through_private_reporting_without_an_email_address() + { + string text = RepositoryFile.ReadText(CodeOfConduct); + + Assert.Contains("https://www.contributor-covenant.org/version/2/1/code_of_conduct.html", text, StringComparison.Ordinal); + Assert.Contains(PrivateReportingUrl, text, StringComparison.Ordinal); + Assert.DoesNotMatch(EmailAddress(), text); + } + + [Fact] + public void Codeowners_patterns_point_to_existing_paths_and_known_owners() + { + List<(string Pattern, string[] Owners)> rules = []; + foreach (string rawLine in RepositoryFile.ReadLines(CodeOwners)) + { + string line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + { + continue; + } + + string[] parts = line.Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries); + rules.Add((parts[0], parts[1..])); + } + + Assert.NotEmpty(rules); + Assert.Equal("*", rules[0].Pattern); + foreach ((string pattern, string[] owners) in rules) + { + Assert.NotEmpty(owners); + foreach (string owner in owners) + { + Assert.True(Array.IndexOf(s_knownOwners, owner) >= 0, $"{CodeOwners}: '{owner}' is not a known maintainer."); + } + + // GitHub rejects "!" negation and "[ ]" ranges in CODEOWNERS. + Assert.True(pattern.AsSpan().IndexOfAny("![]") < 0, $"{CodeOwners}: '{pattern}' uses unsupported syntax."); + if (string.Equals(pattern, "*", StringComparison.Ordinal)) + { + continue; + } + + string path = pattern.Trim('/'); + Assert.True(RepositoryFile.ExistsWithExactCase(path, out bool isDirectory), + $"{CodeOwners}: '{pattern}' matches nothing in the repository (exact case)."); + Assert.True(!pattern.EndsWith('/') || isDirectory, $"{CodeOwners}: '{pattern}' names a folder that is a file."); + } + } + + [Fact] + public void Codeowners_exists_only_in_the_github_folder() + { + // GitHub uses the first of .github/, the root and docs/; a second file would be silently ignored. + Assert.True(File.Exists(RepositoryFile.FullPath(CodeOwners))); + Assert.False(File.Exists(RepositoryFile.FullPath("CODEOWNERS"))); + Assert.False(File.Exists(RepositoryFile.FullPath("docs/CODEOWNERS"))); + } + + [Fact] + public void Compatibility_issue_form_requires_the_full_tuple() + { + Dictionary elements = ElementsById(YamlDocument.Load(CompatibilityForm)); + + foreach (string id in s_requiredTupleIds) + { + Assert.True(elements.TryGetValue(id, out YamlMappingNode? element), $"{CompatibilityForm} has no '{id}' field."); + Assert.True(IsRequired(element), $"{CompatibilityForm}: '{id}' must be required."); + } + + foreach (string id in s_optionalTupleIds) + { + Assert.True(elements.TryGetValue(id, out YamlMappingNode? element), $"{CompatibilityForm} has no '{id}' field."); + Assert.False(IsRequired(element), $"{CompatibilityForm}: '{id}' is optional."); + } + + IReadOnlyList levels = YamlDocument.Scalars(YamlDocument.Child(elements["evidence-level"], "attributes"), "options"); + foreach (string level in (string[]) ["(C0)", "(C1/C2)", "(C3)", "(C4)"]) + { + Assert.Contains(levels, option => option.EndsWith(level, StringComparison.Ordinal)); + } + + IReadOnlyList profiles = YamlDocument.Scalars(YamlDocument.Child(elements["load-profile"], "attributes"), "options"); + Assert.Contains(profiles, static option => option.Contains("ce-7.7.0.10621-x64-managed-hostfxr", StringComparison.Ordinal)); + } + + [Fact] + public void Compatibility_form_never_presents_a_profile_as_qualified_or_supported() + { + // The support profile starts NotExecuted: a form option must not claim more (audit ch.20 publication criterion). + foreach (string text in FormStrings(YamlDocument.Load(CompatibilityForm))) + { + foreach (string sentence in Sentences(text)) + { + if (QualificationClaim().IsMatch(sentence) && !NegationPattern().IsMatch(sentence)) + { + Assert.Fail($"{CompatibilityForm} claims support or qualification: '{sentence}'."); + } + } + } + } + + [Fact] + public void Issue_form_element_ids_are_unique_and_valid() + { + List forms = IssueForms(); + Assert.Contains(CompatibilityForm, forms, StringComparer.Ordinal); + Assert.Contains(".github/ISSUE_TEMPLATE/bug_report.yml", forms, StringComparer.Ordinal); + + foreach (string path in forms) + { + YamlDocument form = YamlDocument.Load(path); + foreach (string key in (string[]) ["name", "description"]) + { + Assert.False(string.IsNullOrWhiteSpace(YamlDocument.Scalar(form.Root, key)), $"{path} has no {key}."); + } + + HashSet ids = new(StringComparer.Ordinal); + IReadOnlyList body = YamlDocument.Mappings(form.Root, "body"); + Assert.NotEmpty(body); + foreach (YamlMappingNode element in body) + { + string type = YamlDocument.Scalar(element, "type") ?? ""; + Assert.Contains(type, (string[]) ["markdown", "input", "textarea", "dropdown", "checkboxes"], StringComparer.Ordinal); + string? id = YamlDocument.Scalar(element, "id"); + if (string.Equals(type, "markdown", StringComparison.Ordinal)) + { + continue; + } + + Assert.True(id is not null && ElementId().IsMatch(id), $"{path}: a {type} element has no valid id ('{id}')."); + Assert.True(ids.Add(id), $"{path}: the id '{id}' is used twice."); + YamlNode? attributes = YamlDocument.Child(element, "attributes"); + Assert.False(string.IsNullOrWhiteSpace(YamlDocument.Scalar(attributes, "label")), $"{path}: '{id}' has no label."); + if (string.Equals(type, "dropdown", StringComparison.Ordinal)) + { + IReadOnlyList options = YamlDocument.Scalars(attributes, "options"); + Assert.NotEmpty(options); + Assert.Equal(options.Count, new HashSet(options, StringComparer.Ordinal).Count); + } + } + + // Forms are public: no local path, and every link to this repository resolves on main. + Assert.DoesNotMatch(AbsoluteLocalPath(), form.Text); + foreach (Match link in SelfLink().Matches(form.Text)) + { + Assert.True(TryCheckSelfLink(link, out string? problem), $"{path}: {link.Value} does not resolve ({problem})."); + } + } + } + + [Fact] + public void Issue_forms_disable_blank_issues_and_link_private_reporting() + { + YamlDocument chooser = YamlDocument.Load(ChooserConfiguration); + + Assert.Equal("false", YamlDocument.Scalar(chooser.Root, "blank_issues_enabled")); + List urls = []; + foreach (YamlMappingNode link in YamlDocument.Mappings(chooser.Root, "contact_links")) + { + foreach (string key in (string[]) ["name", "url", "about"]) + { + Assert.False(string.IsNullOrWhiteSpace(YamlDocument.Scalar(link, key)), $"{ChooserConfiguration}: a contact link has no {key}."); + } + + string url = YamlDocument.Scalar(link, "url")!; + Assert.StartsWith("https://", url, StringComparison.Ordinal); + urls.Add(url); + } + + Assert.Contains(PrivateReportingUrl, urls, StringComparer.Ordinal); + Assert.Contains("https://github.com/CheatEngineNet/CheatEngine.SDK/discussions", urls, StringComparer.Ordinal); + } + + [Fact] + public void Issue_forms_never_instruct_editing_the_cheat_engine_runtime_configuration() + { + // Audit ch.21: editing a global CE runtime configuration is never a harmless step. A sentence may mention the file + // and an edit only to rule the edit out. + List offenders = []; + foreach (string path in IssueForms()) + { + foreach (string text in FormStrings(YamlDocument.Load(path))) + { + foreach (string sentence in Sentences(text)) + { + if (sentence.Contains("runtimeconfig", StringComparison.OrdinalIgnoreCase) + && EditInstruction().IsMatch(sentence) + && !NegationPattern().IsMatch(sentence)) + { + offenders.Add($"{path}: '{sentence}'"); + } + } + } + } + + Assert.True(offenders.Count == 0, $"Issue forms must never ask users to edit ce.runtimeconfig.json: {string.Join("; ", offenders)}"); + } + + /// The ## (ATX level-2) headings of a Markdown document, outside fenced code blocks, in order. + private static List Level2Headings(string text) + { + List headings = []; + bool inFence = false; + foreach (string rawLine in text.ReplaceLineEndings("\n").Split('\n')) + { + string line = rawLine.TrimEnd(); + string trimmedStart = line.TrimStart(); + if (trimmedStart.StartsWith("```", StringComparison.Ordinal) || trimmedStart.StartsWith("~~~", StringComparison.Ordinal)) + { + inFence = !inFence; + continue; + } + + if (!inFence && trimmedStart.StartsWith("## ", StringComparison.Ordinal)) + { + headings.Add(trimmedStart[3..].Trim()); + } + } + + return headings; + } + + private static bool TryCheckSelfLink(Match link, out string? problem) + { + string rest = link.Groups["rest"].Value.Split('#')[0].Split('?')[0].TrimEnd('/'); + if (rest.Length == 0 || RepositoryFile.ExistsWithExactCase(rest, out _)) + { + problem = null; + return true; + } + + problem = $"'{rest}' does not exist"; + return false; + } + + private static List IssueForms() + { + List forms = []; + foreach (string file in Directory.EnumerateFiles(RepositoryFile.FullPath(IssueTemplates), "*.yml")) + { + string relative = IssueTemplates + "/" + Path.GetFileName(file); + if (!string.Equals(relative, ChooserConfiguration, StringComparison.Ordinal)) + { + forms.Add(relative); + } + } + + forms.Sort(StringComparer.Ordinal); + return forms; + } + + private static Dictionary ElementsById(YamlDocument form) + { + Dictionary elements = new(StringComparer.Ordinal); + foreach (YamlMappingNode element in YamlDocument.Mappings(form.Root, "body")) + { + string? id = YamlDocument.Scalar(element, "id"); + if (id is not null) + { + elements[id] = element; + } + } + + return elements; + } + + private static bool IsRequired(YamlMappingNode element) + { + return string.Equals(YamlDocument.Scalar(YamlDocument.Child(element, "validations"), "required"), "true", + StringComparison.Ordinal); + } + + /// Every user-visible string of a form: its description and each element's texts and options. + private static List FormStrings(YamlDocument form) + { + List strings = []; + foreach (string key in (string[]) ["name", "description", "title"]) + { + strings.AddRange(YamlDocument.Scalars(form.Root, key)); + } + + foreach (YamlMappingNode element in YamlDocument.Mappings(form.Root, "body")) + { + YamlNode? attributes = YamlDocument.Child(element, "attributes"); + foreach (string key in (string[]) ["label", "description", "placeholder", "value", "options"]) + { + strings.AddRange(YamlDocument.Scalars(attributes, key)); + } + } + + return strings; + } + + private static IEnumerable Sentences(string text) + { + foreach (string sentence in SentenceBoundary().Split(text)) + { + string trimmed = sentence.Trim(); + if (trimmed.Length != 0) + { + yield return trimmed; + } + } + } + + [GeneratedRegex(@"(?<=[.!?;])\s+|\r?\n", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex SentenceBoundary(); + + [GeneratedRegex(@"\b(edit|modify|change|replace|overwrite)", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex EditInstruction(); + + [GeneratedRegex(@"\b(supported|qualified|compatible)\b", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex QualificationClaim(); + + [GeneratedRegex(@"\b(not|never|no|without)\b", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex NegationPattern(); + + [GeneratedRegex("^[A-Za-z0-9_-]+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ElementId(); + + [GeneratedRegex(@"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex EmailAddress(); + + /// A drive path, a user-profile folder or a file: URI: never valid in a public issue form. + [GeneratedRegex(@"[A-Za-z]:\\|\\Users\\|/home/|/Users/|file://", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex AbsoluteLocalPath(); + + /// An absolute link back into this repository on main. Group rest is the path as written. + [GeneratedRegex(@"(?i:https://github\.com/CheatEngineNet/CheatEngine\.SDK)/(?:blob|tree)/main/(?[^\s)\]""'`>]*)", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] + private static partial Regex SelfLink(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs new file mode 100644 index 00000000..4e2add89 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs @@ -0,0 +1,642 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// The advisory governance workflows outside CI / Gate (CodeQL, Scorecard, online zizmor, dependency +/// submission): repository conventions (shared-contracts §1.6, §1.13) and the choices that keep each one safe and +/// useful (audit register PR-CQ-19, -22, -24, -30, -45). +/// +public sealed partial class GovernanceWorkflowTests +{ + private const string ZizmorVersion = "1.30.1"; + + private static readonly string[] s_runnerLabels = ["windows-2025", "ubuntu-24.04"]; + + /// Artifact names the governance workflows may upload (requested for shared-contracts §1.9). + private static readonly string[] s_governanceArtifacts = ["binlogs-codeql", "dependency-snapshot"]; + + [Fact] + public void Governance_workflows_pin_every_action_by_full_sha_with_a_version_comment() + { + List offenders = []; + foreach (string path in GovernanceWorkflows.Existing()) + { + string[] lines = RepositoryFile.ReadLines(path); + for (int i = 0; i < lines.Length; i++) + { + Match uses = UsesLine().Match(lines[i]); + if (uses.Success && !uses.Groups["reference"].Value.StartsWith("./", StringComparison.Ordinal) + && !(PinnedReference().IsMatch(uses.Groups["reference"].Value) + && VersionComment().IsMatch(uses.Groups["comment"].Value))) + { + offenders.Add($"{path}:{i + 1}: {lines[i].Trim()}"); + } + } + } + + Assert.True(offenders.Count == 0, + $"Pin every action as owner/repo@<40-hex> # vX.Y.Z (shared-contracts 1.13): {string.Join("; ", offenders)}"); + } + + [Fact] + public void Governance_jobs_use_literal_runner_labels_and_timeouts() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + string? runner = YamlDocument.Scalar(job.Value, "runs-on"); + Assert.True(runner is not null && Array.IndexOf(s_runnerLabels, runner) >= 0, + $"{path} job {job.Key}: runs-on '{runner}' must be a literal windows-2025 or ubuntu-24.04 label."); + Assert.True(int.TryParse(YamlDocument.Scalar(job.Value, "timeout-minutes"), NumberStyles.None, CultureInfo.InvariantCulture, + out int minutes) && minutes > 0, + $"{path} job {job.Key} needs timeout-minutes."); + } + } + } + + [Fact] + public void Governance_workflows_start_read_only_and_comment_every_job_elevation() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + YamlDocument workflow = YamlDocument.Load(path); + Assert.Equal(new Dictionary(StringComparer.Ordinal) { ["contents"] = "read" }, + YamlDocument.Permissions(workflow.Root)); + string[] lines = RepositoryFile.ReadLines(path); + foreach (KeyValuePair job in workflow.Jobs) + { + if (YamlDocument.Child(job.Value, "permissions") is not YamlMappingNode permissions) + { + continue; + } + + foreach (KeyValuePair scope in permissions.Children) + { + string name = ((YamlScalarNode) scope.Key).Value ?? ""; + string access = ((YamlScalarNode) scope.Value).Value ?? ""; + if (string.Equals(name, "contents", StringComparison.Ordinal) + && string.Equals(access, "read", StringComparison.Ordinal)) + { + continue; + } + + string line = lines[(int) scope.Key.Start.Line - 1]; + Assert.True(line.Contains(" # ", StringComparison.Ordinal), + $"{path} job {job.Key}: '{name}: {access}' needs a trailing comment giving the reason (.coderabbit.yaml workflow hygiene)."); + } + } + } + } + + [Fact] + public void Governance_checkouts_never_persist_credentials() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + if (YamlDocument.UsesAction(step, "actions/checkout")) + { + Assert.True(string.Equals(YamlDocument.Scalar(YamlDocument.Child(step, "with"), "persist-credentials"), "false", + StringComparison.Ordinal), + $"{path} job {job.Key}: actions/checkout must set persist-credentials: false."); + } + } + } + } + } + + [Fact] + public void Governance_multi_line_scripts_stop_on_the_first_error() + { + // shared.md 7: a multi-line pwsh step starts with $ErrorActionPreference = 'Stop', so a failing cmdlet stops the + // step in the script itself rather than relying on the wrapper GitHub prepends today. + List offenders = []; + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + string[] lines = (YamlDocument.Scalar(step, "run") ?? "").ReplaceLineEndings("\n").Trim().Split('\n'); + if (lines.Length > 1 && !string.Equals(lines[0].Trim(), "$ErrorActionPreference = 'Stop'", StringComparison.Ordinal)) + { + offenders.Add($"{path} job {job.Key} step '{YamlDocument.Scalar(step, "name")}'"); + } + } + } + } + + Assert.True(offenders.Count == 0, + $"Start every multi-line run script with $ErrorActionPreference = 'Stop': {string.Join("; ", offenders)}"); + } + + [Fact] + public void Governance_scripts_check_the_exit_code_of_every_native_command() + { + // A failing native command (or repository script) in the middle of a multi-line pwsh step does not stop the step: + // every one is followed by an explicit $LASTEXITCODE check (shared.md 7, the same rule as WorkflowContractTests). + List offenders = []; + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + string[] lines = (YamlDocument.Scalar(step, "run") ?? "").ReplaceLineEndings("\n").Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + if (!NativeInvocation().IsMatch(lines[i])) + { + continue; + } + + int next = i + 1; + while (next < lines.Length && string.IsNullOrWhiteSpace(lines[next])) + { + next++; + } + + if (next >= lines.Length || !lines[next].TrimStart().StartsWith("if ($LASTEXITCODE -ne 0)", StringComparison.Ordinal)) + { + offenders.Add($"{path} job {job.Key}: '{lines[i].Trim()}'"); + } + } + } + } + } + + Assert.True(offenders.Count == 0, + $"Follow every native command with if ($LASTEXITCODE -ne 0) {{ throw ... }}: {string.Join("; ", offenders)}"); + } + + [Fact] + public void Advisory_workflows_never_use_pull_request_target_or_merge_group() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + IReadOnlyList triggers = YamlDocument.Load(path).Triggers; + Assert.DoesNotContain("pull_request_target", triggers, StringComparer.Ordinal); + Assert.DoesNotContain("merge_group", triggers, StringComparer.Ordinal); + Assert.DoesNotContain("workflow_run", triggers, StringComparer.Ordinal); + } + } + + [Fact] + public void Governance_workflows_never_enable_a_package_cache() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + string? uses = YamlDocument.Uses(step); + Assert.False(uses is not null && uses.StartsWith("actions/cache", StringComparison.Ordinal), + $"{path} job {job.Key} uses actions/cache."); + YamlNode? with = YamlDocument.Child(step, "with"); + foreach (string key in (string[]) ["cache", "dependency-caching", "trap-caching"]) + { + Assert.False(string.Equals(YamlDocument.Scalar(with, key), "true", StringComparison.Ordinal), + $"{path} job {job.Key} enables '{key}': no cache on a path reachable by codeql or the audits."); + } + } + } + } + } + + [Fact] + public void Governance_workflows_upload_only_their_reserved_artifact_names() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + if (YamlDocument.UsesAction(step, "actions/upload-artifact")) + { + string? name = YamlDocument.Scalar(YamlDocument.Child(step, "with"), "name"); + Assert.True(name is not null && Array.IndexOf(s_governanceArtifacts, name) >= 0, + $"{path} job {job.Key} uploads '{name}', which is not a reserved artifact name."); + } + } + } + } + } + + [Fact] + public void Codeql_analyzes_csharp_cpp_and_actions_with_literal_runner_labels() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.CodeQl); + + Dictionary expected = new(StringComparer.Ordinal) + { + ["csharp"] = ("csharp", "manual", "windows-2025"), + ["cpp"] = ("c-cpp", "none", "windows-2025"), + ["actions"] = ("actions", "none", "ubuntu-24.04") + }; + Assert.Equal(expected.Keys.Order(StringComparer.Ordinal), YamlDocument.KeysOf(YamlDocument.Child(workflow.Root, "jobs")).Order(StringComparer.Ordinal)); + foreach ((string id, (string language, string buildMode, string runner)) in expected) + { + YamlMappingNode job = workflow.Job(id); + Assert.Equal(runner, YamlDocument.Scalar(job, "runs-on")); + Assert.Equal($"Analyze ({language})", YamlDocument.Scalar(job, "name")); + IReadOnlyDictionary? permissions = YamlDocument.Permissions(job); + Assert.NotNull(permissions); + Assert.Equal("write", permissions["security-events"]); + + YamlNode? init = WithOf(job, "github/codeql-action/init"); + Assert.Equal(language, YamlDocument.Scalar(init, "languages")); + Assert.Equal(buildMode, YamlDocument.Scalar(init, "build-mode")); + Assert.Equal("security-extended", YamlDocument.Scalar(init, "queries")); + Assert.Equal($"/language:{language}", YamlDocument.Scalar(WithOf(job, "github/codeql-action/analyze"), "category")); + } + + // Python is not analysed: the repository has no Python source left, and a language without sources fails. + Assert.DoesNotContain("python", workflow.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Codeql_csharp_job_builds_the_product_graph_manually_without_shared_compilation() + { + YamlMappingNode job = YamlDocument.Load(GovernanceWorkflows.CodeQl).Job("csharp"); + IReadOnlyList steps = YamlDocument.Steps(job); + + YamlMappingNode checkout = Assert.Single(steps, static step => YamlDocument.UsesAction(step, "actions/checkout")); + Assert.Equal("0", YamlDocument.Scalar(YamlDocument.Child(checkout, "with"), "fetch-depth")); + YamlMappingNode setup = Assert.Single(steps, static step => + string.Equals(YamlDocument.Uses(step), "./.github/actions/setup-dotnet", StringComparison.Ordinal)); + Assert.Equal("src/CheatEngine.SDK/CheatEngine.SDK.csproj", YamlDocument.Scalar(YamlDocument.Child(setup, "with"), "restore")); + + YamlMappingNode build = Assert.Single(steps, static step => YamlDocument.Scalar(step, "run") is not null); + string run = YamlDocument.NormalizeWhitespace(YamlDocument.Scalar(build, "run")!); + foreach (string fragment in (string[]) + [ + "dotnet build src/CheatEngine.SDK/CheatEngine.SDK.csproj", "-c Release", "--no-restore", "--no-incremental", + "--disable-build-servers", "-p:UseSharedCompilation=false", "$LASTEXITCODE" + ]) + { + Assert.Contains(fragment, run, StringComparison.Ordinal); + } + + // The traced build runs between init and analyze. + int init = IndexOf(steps, "github/codeql-action/init"); + int analyze = IndexOf(steps, "github/codeql-action/analyze"); + int buildIndex = -1; + for (int i = 0; i < steps.Count; i++) + { + if (ReferenceEquals(steps[i], build)) + { + buildIndex = i; + } + } + + Assert.True(IndexOf(steps, "./.github/actions/setup-dotnet") < init && init < buildIndex && buildIndex < analyze, + "The CodeQL C# job must restore, then init, then build, then analyze."); + } + + [Fact] + public void Codeql_workflow_never_enables_a_package_cache() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.CodeQl); + Assert.DoesNotContain("actions/cache", workflow.Text, StringComparison.Ordinal); + foreach (KeyValuePair job in workflow.Jobs) + { + YamlNode? init = WithOf(job.Value, "github/codeql-action/init"); + Assert.Equal("false", YamlDocument.Scalar(init, "dependency-caching")); + Assert.Equal("false", YamlDocument.Scalar(init, "trap-caching")); + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + Assert.Null(YamlDocument.Child(YamlDocument.Child(step, "with"), "cache")); + } + } + } + + [Fact] + public void Codeql_runs_on_pull_requests_main_a_weekly_schedule_and_dispatch() + { + // A new workflow cannot be dispatched before it is on main: the pull_request trigger validates it on the vehicle + // pull request first (audit register PR-SEQ-15). + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.CodeQl); + Assert.Equal(["push", "pull_request", "schedule", "workflow_dispatch"], workflow.Triggers); + Assert.Equal(["main"], YamlDocument.Scalars(workflow.Trigger("push"), "branches")); + foreach (KeyValuePair job in workflow.Jobs) + { + Assert.Equal("${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}", + YamlDocument.Scalar(job.Value, "if")); + } + } + + [Fact] + public void Scorecard_workflow_has_no_defaults_env_or_run_steps() + { + // The Scorecard API refuses to publish results of a workflow that breaks these rules, silently for the repository. + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.Scorecard); + Assert.Null(YamlDocument.Child(workflow.Root, "defaults")); + Assert.Null(YamlDocument.Child(workflow.Root, "env")); + + KeyValuePair job = Assert.Single(workflow.Jobs); + Assert.Equal("analysis", job.Key); + foreach (string key in (string[]) ["defaults", "env", "container", "services"]) + { + Assert.True(YamlDocument.Child(job.Value, key) is null, $"The Scorecard job must not set '{key}'."); + } + + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + Assert.Null(YamlDocument.Child(step, "run")); + Assert.NotNull(YamlDocument.Uses(step)); + } + + Assert.Equal("ubuntu-24.04", YamlDocument.Scalar(job.Value, "runs-on")); + Assert.Equal(["branch_protection_rule", "push", "schedule"], workflow.Triggers); + Assert.Equal(["main"], YamlDocument.Scalars(workflow.Trigger("push"), "branches")); + Assert.Equal("true", YamlDocument.Scalar(WithOf(job.Value, "ossf/scorecard-action"), "publish_results")); + } + + [Fact] + public void Scorecard_steps_use_only_the_actions_the_verifier_allows() + { + string[] allowed = + [ + "actions/checkout", "actions/create-github-app-token", "ossf/scorecard-action", "actions/upload-artifact", + "github/codeql-action/upload-sarif", "step-security/harden-runner" + ]; + foreach (YamlMappingNode step in YamlDocument.Steps(YamlDocument.Load(GovernanceWorkflows.Scorecard).Job("analysis"))) + { + string uses = YamlDocument.Uses(step) ?? ""; + string action = uses.Split('@')[0]; + Assert.True(Array.IndexOf(allowed, action) >= 0, $"The Scorecard verifier rejects the step '{uses}'."); + } + } + + [Fact] + public void Only_the_scorecard_job_requests_an_id_token() + { + // An OIDC token is a publication credential: the Scorecard upload among the governance workflows, and the release + // chain (NuGet trusted publishing, attestations) among the others. Never at workflow level. + List holders = []; + foreach (string path in GovernanceWorkflows.AllWorkflowPaths()) + { + YamlDocument workflow = YamlDocument.Load(path); + Assert.False(YamlDocument.Permissions(workflow.Root)?.ContainsKey("id-token") ?? false, + $"{path} requests id-token at workflow level."); + foreach (KeyValuePair job in workflow.Jobs) + { + if (YamlDocument.Permissions(job.Value)?.ContainsKey("id-token") ?? false) + { + holders.Add($"{path}#{job.Key}"); + } + } + } + + foreach (string holder in holders) + { + Assert.True(string.Equals(holder, GovernanceWorkflows.Scorecard + "#analysis", StringComparison.Ordinal) + || holder.StartsWith(".github/workflows/release.yml#", StringComparison.Ordinal), + $"{holder} requests an id-token; only the Scorecard analysis job and the release workflow may."); + } + + Assert.Contains(GovernanceWorkflows.Scorecard + "#analysis", holders, StringComparer.Ordinal); + } + + [Fact] + public void Zizmor_online_pins_the_tool_version_and_enables_online_audits() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.ZizmorOnline); + YamlMappingNode job = workflow.Job("zizmor"); + + YamlNode? with = WithOf(job, "zizmorcore/zizmor-action"); + Assert.Equal(ZizmorVersion, YamlDocument.Scalar(with, "version")); + Assert.Equal("true", YamlDocument.Scalar(with, "online-audits")); + Assert.Equal("true", YamlDocument.Scalar(with, "advanced-security")); + Assert.Equal("regular", YamlDocument.Scalar(with, "persona")); + // zizmor discovers .github/zizmor.yml itself; an explicit path would break before that file exists. + Assert.Null(YamlDocument.Child(with, "config")); + Assert.Equal("write", YamlDocument.Permissions(job)!["security-events"]); + + // Fork pull requests cannot upload SARIF, and drafts wait. + string condition = YamlDocument.NormalizeWhitespace(YamlDocument.Scalar(job, "if") ?? ""); + Assert.Contains("github.event.pull_request.head.repo.full_name == github.repository", condition, StringComparison.Ordinal); + Assert.Contains("!github.event.pull_request.draft", condition, StringComparison.Ordinal); + Assert.Equal(["push", "pull_request", "schedule", "workflow_dispatch"], workflow.Triggers); + } + + [Fact] + public void Online_and_gate_zizmor_runs_pin_the_same_version() + { + // The blocking offline run of ci.yml and this advisory run must judge the same rules. + foreach (string path in GovernanceWorkflows.AllWorkflowPaths()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + if (YamlDocument.UsesAction(step, "zizmorcore/zizmor-action")) + { + Assert.True(string.Equals(YamlDocument.Scalar(YamlDocument.Child(step, "with"), "version"), ZizmorVersion, + StringComparison.Ordinal), + $"{path} job {job.Key} must pin zizmor {ZizmorVersion} like {GovernanceWorkflows.ZizmorOnline}."); + } + } + } + } + } + + [Fact] + public void Dependency_submission_runs_on_main_dispatch_and_same_repository_pull_requests_only() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.DependencySubmission); + + Assert.Equal(["push", "pull_request", "workflow_dispatch"], workflow.Triggers); + Assert.Equal(["main"], YamlDocument.Scalars(workflow.Trigger("push"), "branches")); + string condition = YamlDocument.NormalizeWhitespace(YamlDocument.Scalar(workflow.Job("detect"), "if") ?? ""); + Assert.Equal( + "github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository)", + condition); + // submit only follows a successful detect, so it inherits the fork and draft exclusion. + Assert.Equal(["detect"], YamlDocument.Scalars(workflow.Job("submit"), "needs")); + Assert.Null(YamlDocument.Child(workflow.Job("submit"), "if")); + } + + [Fact] + public void Only_the_dependency_submit_job_holds_contents_write() + { + List writers = []; + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + if (YamlDocument.Permissions(job.Value) is { } permissions + && permissions.TryGetValue("contents", out string? access) + && string.Equals(access, "write", StringComparison.Ordinal)) + { + writers.Add($"{path}#{job.Key}"); + } + } + } + + Assert.Equal([GovernanceWorkflows.DependencySubmission + "#submit"], writers); + Assert.Null(YamlDocument.Permissions(YamlDocument.Load(GovernanceWorkflows.DependencySubmission).Job("detect"))); + } + + [Fact] + public void Dependency_submit_job_runs_no_third_party_code() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.DependencySubmission); + IReadOnlyList steps = YamlDocument.Steps(workflow.Job("submit")); + + Assert.Equal(2, steps.Count); + Assert.True(YamlDocument.UsesAction(steps[0], "actions/download-artifact"), "submit starts by downloading the snapshot."); + Assert.Equal("dependency-snapshot", YamlDocument.Scalar(YamlDocument.Child(steps[0], "with"), "name")); + Assert.Null(YamlDocument.Uses(steps[1])); + string run = YamlDocument.Scalar(steps[1], "run") ?? ""; + Assert.Contains("gh api --method POST", run, StringComparison.Ordinal); + + // Detection and submission name the same commit, ref and correlator. + YamlMappingNode detect = Assert.Single(YamlDocument.Steps(workflow.Job("detect")), + static step => YamlDocument.Child(step, "env") is not null); + foreach (string variable in (string[]) ["SNAPSHOT_SHA", "SNAPSHOT_REF", "SNAPSHOT_CORRELATOR"]) + { + Assert.Equal(YamlDocument.Scalar(YamlDocument.Child(detect, "env"), variable), + YamlDocument.Scalar(YamlDocument.Child(steps[1], "env"), variable)); + } + + // The official action fetches the latest Component Detection at run time next to the write token. + Assert.DoesNotContain("component-detection-dependency-submission-action", workflow.Text, StringComparison.Ordinal); + } + + [Fact] + public void Dependency_detection_uses_a_pinned_hash_verified_component_detection() + { + // Inlined directly in the workflow (no bespoke eng/ci script): the detect job's own step, never the submit job. + YamlMappingNode detect = YamlDocument.Load(GovernanceWorkflows.DependencySubmission).Job("detect"); + YamlMappingNode step = Assert.Single(YamlDocument.Steps(detect), + static candidate => string.Equals(YamlDocument.Scalar(candidate, "name"), + "Run Component Detection and build the snapshot", StringComparison.Ordinal)); + string script = YamlDocument.Scalar(step, "run") ?? ""; + + Assert.Matches(new Regex(@"^\s*\$detectorVersion = '\d+\.\d+\.\d+'\r?$", RegexOptions.Multiline, TimeSpan.FromSeconds(1)), script); + Assert.Matches(new Regex(@"^\s*\$detectorSha256 = '[0-9a-f]{64}'\r?$", RegexOptions.Multiline, TimeSpan.FromSeconds(1)), script); + Assert.Contains("releases/download/v$detectorVersion/$detectorAsset", script, StringComparison.Ordinal); + Assert.Contains("Get-FileHash -LiteralPath $detector -Algorithm SHA256", script, StringComparison.Ordinal); + Assert.Contains("--locked-mode", script, StringComparison.Ordinal); + Assert.DoesNotContain("releases/latest", script, StringComparison.Ordinal); + Assert.DoesNotContain("dependency-graph/snapshots", script, StringComparison.Ordinal); + } + + [Theory] + [InlineData("valid", true)] + [InlineData("other_commit", false)] + [InlineData("other_ref", false)] + [InlineData("other_correlator", false)] + [InlineData("extra_property", false)] + [InlineData("no_manifest", false)] + public async Task Dependency_submit_step_submits_only_a_snapshot_of_this_run(string variant, bool submitted) + { + const string Sha = "0123456789abcdef0123456789abcdef01234567"; + const string Ref = "refs/heads/main"; + using TemporaryDirectory directory = new(); + Directory.CreateDirectory(directory.File("snapshot")); + Dictionary snapshot = new(StringComparer.Ordinal) + { + ["version"] = 0, + ["sha"] = Is(variant, "other_commit") ? new string('f', 40) : Sha, + ["ref"] = Is(variant, "other_ref") ? "refs/heads/feature" : Ref, + ["job"] = new Dictionary(StringComparer.Ordinal) + { + ["correlator"] = Is(variant, "other_correlator") ? "other" : "sdk-nuget", + ["id"] = "1" + }, + ["detector"] = new Dictionary(StringComparer.Ordinal) { ["name"] = "d", ["version"] = "1", ["url"] = "u" }, + ["scanned"] = "2026-09-23T00:00:00Z", + ["manifests"] = Is(variant, "no_manifest") + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal) { ["src/A.csproj"] = new Dictionary(StringComparer.Ordinal) } + }; + if (Is(variant, "extra_property")) + { + snapshot["extra"] = 1; + } + + await File.WriteAllTextAsync(directory.File("snapshot/snapshot.json"), JsonSerializer.Serialize(snapshot), + TestContext.Current.CancellationToken); + + YamlMappingNode submit = YamlDocument.Steps(YamlDocument.Load(GovernanceWorkflows.DependencySubmission).Job("submit"))[1]; + string marker = directory.File("gh-called.txt"); + string script = $"Set-Location -LiteralPath {PwshScript.Quote(directory.Path)}" + Environment.NewLine + + $"function gh {{ $args -join ' ' | Set-Content -LiteralPath {PwshScript.Quote(marker)}; $global:LASTEXITCODE = 0 }}" + + Environment.NewLine + YamlDocument.Scalar(submit, "run"); + Dictionary environment = new(StringComparer.Ordinal) + { + ["REPOSITORY"] = "CheatEngineNet/CheatEngine.SDK", + ["SNAPSHOT_SHA"] = Sha, + ["SNAPSHOT_REF"] = Ref, + ["SNAPSHOT_CORRELATOR"] = "sdk-nuget", + ["GITHUB_STEP_SUMMARY"] = directory.File("summary.md") + }; + + PwshResult run = await PwshScript.RunTextAsync(script, environment); + + Assert.True(submitted == (run.ExitCode == 0), run.Transcript); + Assert.Equal(submitted, File.Exists(marker)); + if (submitted) + { + Assert.StartsWith("api --method POST repos/CheatEngineNet/CheatEngine.SDK/dependency-graph/snapshots", + await File.ReadAllTextAsync(marker, TestContext.Current.CancellationToken), StringComparison.Ordinal); + } + else + { + Assert.Contains("nothing was submitted", run.StandardError + run.StandardOutput, StringComparison.Ordinal); + } + } + + private static bool Is(string value, string expected) + { + return string.Equals(value, expected, StringComparison.Ordinal); + } + + private static YamlNode? WithOf(YamlMappingNode job, string action) + { + YamlMappingNode step = Assert.Single(YamlDocument.Steps(job), step => YamlDocument.UsesAction(step, action) + || string.Equals(YamlDocument.Uses(step), action, StringComparison.Ordinal)); + return YamlDocument.Child(step, "with"); + } + + private static int IndexOf(IReadOnlyList steps, string action) + { + for (int i = 0; i < steps.Count; i++) + { + if (YamlDocument.UsesAction(steps[i], action) + || string.Equals(YamlDocument.Uses(steps[i]), action, StringComparison.Ordinal)) + { + return i; + } + } + + return -1; + } + + [GeneratedRegex(@"^\s*(-\s+)?uses:\s+(?\S+)(?.*)$", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex UsesLine(); + + [GeneratedRegex(@"^[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PinnedReference(); + + [GeneratedRegex(@"^\s+# v\d+\.\d+\.\d+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex VersionComment(); + + /// A line that starts a native command or a repository script (same pattern as WorkflowContractTests). + [GeneratedRegex(@"^\s*(?:dotnet|xmake|git|gh|tar)\s|^\s*\./|^\s*&\s", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex NativeInvocation(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflows.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflows.cs new file mode 100644 index 00000000..869ec9e6 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflows.cs @@ -0,0 +1,44 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// The advisory governance workflows of the repository, outside CI / Gate. +internal static class GovernanceWorkflows +{ + public const string CodeQl = ".github/workflows/codeql.yml"; + public const string Scorecard = ".github/workflows/scorecard.yml"; + public const string ZizmorOnline = ".github/workflows/zizmor-online.yml"; + public const string DependencySubmission = ".github/workflows/dependency-submission.yml"; + + /// The governance workflows, which never join gate.needs. + public static readonly string[] All = [CodeQl, Scorecard, ZizmorOnline, DependencySubmission]; + + /// Every workflow file, repository-relative, sorted. + public static List AllWorkflowPaths() + { + List paths = []; + foreach (string pattern in (string[]) ["*.yml", "*.yaml"]) + { + foreach (string file in Directory.EnumerateFiles(RepositoryFile.FullPath(".github/workflows"), pattern)) + { + paths.Add(".github/workflows/" + Path.GetFileName(file)); + } + } + + paths.Sort(StringComparer.Ordinal); + return paths; + } + + /// The governance workflows that exist in the working tree. + public static List Existing() + { + List existing = []; + foreach (string path in All) + { + if (File.Exists(RepositoryFile.FullPath(path))) + { + existing.Add(path); + } + } + + return existing; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshResult.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshResult.cs new file mode 100644 index 00000000..036ae325 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshResult.cs @@ -0,0 +1,8 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// The outcome of one pwsh process. +internal sealed record PwshResult(int ExitCode, string StandardOutput, string StandardError) +{ + /// Both streams, for assertion messages. + public string Transcript => $"exit code {ExitCode}{Environment.NewLine}--- stdout ---{Environment.NewLine}{StandardOutput}{Environment.NewLine}--- stderr ---{Environment.NewLine}{StandardError}"; +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshScript.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshScript.cs new file mode 100644 index 00000000..e6974a49 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshScript.cs @@ -0,0 +1,131 @@ +using System.Diagnostics; +using System.Text; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// Runs the repository's PowerShell policy code in a separate pwsh process: -NoProfile, +/// -NonInteractive, the repository root as working directory, a 120 s limit and the test cancellation token. +/// The GitHub Actions file commands (GITHUB_STEP_SUMMARY, GITHUB_OUTPUT, ...), the tokens and every +/// input the governance scripts read from the environment are removed from the child environment, so a run inside CI +/// never writes into the job summary, reads the real pull request or reaches GitHub. A missing pwsh fails the +/// test: it is never skipped +/// (--fail-skips on). +/// +internal static class PwshScript +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(120); + + /// Variables that must come from the test, never from the process that runs the tests. + private static readonly string[] s_isolatedVariables = + [ + "GITHUB_STEP_SUMMARY", "GITHUB_OUTPUT", "GITHUB_ENV", "GITHUB_PATH", "GITHUB_STATE", "GITHUB_ACTIONS", "CI", + "PR_TITLE", "PR_BODY", "PR_AUTHOR", "BASE_SHA", "HEAD_SHA", "GH_TOKEN", "GITHUB_TOKEN", "GH_REPO", + "NEEDS", "RUN_URL", "DRIFT", "BROKEN_LINKS", "REPOSITORY", "SNAPSHOT_SHA", "SNAPSHOT_REF", "SNAPSHOT_CORRELATOR", + "SNAPSHOT_JOB_ID", "SNAPSHOT_JOB_URL", "CESDK_PACKAGED_UMBRELLA_NUPKG" + ]; + + /// Runs from a temporary .ps1 file. + public static async Task RunTextAsync(string scriptText, + IReadOnlyDictionary? environment = null) + { + using TemporaryDirectory directory = new(); + string scriptPath = Path.Combine(directory.Path, "script.ps1"); + string preamble = "Set-StrictMode -Version Latest" + Environment.NewLine + + "$ErrorActionPreference = 'Stop'" + Environment.NewLine; + await File.WriteAllTextAsync(scriptPath, preamble + scriptText, new UTF8Encoding(false), + TestContext.Current.CancellationToken); + return await RunFileAsync(scriptPath, [], environment); + } + + /// Runs a script file with arguments; a repository-relative path is resolved against the root. + public static async Task RunFileAsync(string scriptPath, IReadOnlyList arguments, + IReadOnlyDictionary? environment = null) + { + string fullPath = Path.IsPathRooted(scriptPath) ? scriptPath : RepositoryFile.FullPath(scriptPath); + ProcessStartInfo startInfo = new() + { + FileName = FindPwsh(), + WorkingDirectory = RepositoryRoot.Path, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true + }; + foreach (string argument in (string[]) ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", fullPath]) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (string name in s_isolatedVariables) + { + startInfo.Environment.Remove(name); + } + + if (environment is not null) + { + foreach (KeyValuePair variable in environment) + { + startInfo.Environment[variable.Key] = variable.Value; + } + } + + using CancellationTokenSource timeout = + CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(s_timeout); + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"'{startInfo.FileName}' did not start."); + process.StandardInput.Close(); + Task standardOutput = process.StandardOutput.ReadToEndAsync(timeout.Token); + Task standardError = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); + Assert.Fail($"pwsh did not finish '{RepositoryRoot.ToRelative(fullPath)}' within {s_timeout.TotalSeconds} s."); + } + + return new PwshResult(process.ExitCode, await standardOutput, await standardError); + } + + /// A single-quoted PowerShell string literal. + public static string Quote(string value) + { + return "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + } + + private static string FindPwsh() + { + string fileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh"; + string[] directories = (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (string directory in directories) + { + string candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + { + return candidate; + } + } + + Assert.Fail( + "PowerShell 7 (pwsh) is not on PATH. The governance tests run the repository's PowerShell policy code: install it " + + "(winget install Microsoft.PowerShell, or https://learn.microsoft.com/powershell/scripting/install/installing-powershell) and rerun."); + return fileName; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/RepositoryFile.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/RepositoryFile.cs new file mode 100644 index 00000000..14609900 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/RepositoryFile.cs @@ -0,0 +1,59 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// Reads committed files by repository-relative path (forward slashes). +internal static class RepositoryFile +{ + /// The absolute path of a repository-relative path. + public static string FullPath(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + /// The text of a committed file; fails the test when it does not exist. + public static string ReadText(string relativePath) + { + string fullPath = FullPath(relativePath); + Assert.True(File.Exists(fullPath), $"'{relativePath}' does not exist."); + return File.ReadAllText(fullPath); + } + + /// The lines of a committed file, without line terminators. + public static string[] ReadLines(string relativePath) + { + return ReadText(relativePath).ReplaceLineEndings("\n").Split('\n'); + } + + /// + /// True when the repository-relative path exists with exactly this case for every segment. Windows file systems + /// ignore case, GitHub does not, so alone would accept a wrong spelling. + /// + public static bool ExistsWithExactCase(string relativePath, out bool isDirectory) + { + isDirectory = false; + string current = RepositoryRoot.Path; + string[] segments = relativePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < segments.Length; i++) + { + string? match = null; + foreach (string entry in Directory.EnumerateFileSystemEntries(current)) + { + if (string.Equals(Path.GetFileName(entry), segments[i], StringComparison.Ordinal)) + { + match = entry; + } + } + + if (match is null) + { + return false; + } + + current = match; + } + + isDirectory = Directory.Exists(current); + return true; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/TemporaryDirectory.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/TemporaryDirectory.cs new file mode 100644 index 00000000..be241ad8 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/TemporaryDirectory.cs @@ -0,0 +1,39 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// A uniquely named folder under the temporary directory, deleted on dispose. +internal sealed class TemporaryDirectory : IDisposable +{ + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "cesdk-governance-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + /// The absolute path of the folder. + public string Path + { + get; + } + + /// The absolute path of a file inside the folder. + public string File(string name) + { + return System.IO.Path.Combine(Path, name); + } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + // A virus scanner or a just-exited child can hold a file for a moment; the folder lives under TEMP. + } + catch (UnauthorizedAccessException) + { + // Same as above. + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/YamlDocument.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/YamlDocument.cs new file mode 100644 index 00000000..e35dcc97 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/YamlDocument.cs @@ -0,0 +1,278 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// A committed YAML file (workflow, Dependabot configuration, issue form) read through YamlDotNet's representation +/// model. Every scalar stays the string written in the file: the workflow key on stays "on" (no YAML 1.1 +/// boolean resolution) and 'true' and true both read as "true", which is how GitHub compares them. +/// +internal sealed class YamlDocument +{ + private YamlDocument(string relativePath, string text, YamlMappingNode root) + { + RelativePath = relativePath; + Text = text; + Root = root; + } + + /// The repository-relative path, with forward slashes. + public string RelativePath + { + get; + } + + /// The raw text, for rules about comments or exact spelling. + public string Text + { + get; + } + + /// The top-level mapping. + public YamlMappingNode Root + { + get; + } + + /// The jobs of a workflow, in file order, keyed by job id. + public IReadOnlyList> Jobs + { + get + { + List> jobs = []; + if (Child(Root, "jobs") is YamlMappingNode mapping) + { + foreach (KeyValuePair entry in mapping.Children) + { + if (entry.Value is YamlMappingNode job) + { + jobs.Add(new KeyValuePair(((YamlScalarNode) entry.Key).Value ?? "", job)); + } + } + } + + return jobs; + } + } + + /// The event names of the workflow's on key, whether it is a scalar, a sequence or a mapping. + public IReadOnlyList Triggers + { + get + { + YamlNode? on = Child(Root, "on"); + return on switch + { + YamlMappingNode mapping => KeysOf(mapping), + _ => ScalarsOf(on) + }; + } + } + + /// Loads a file below the repository root. + public static YamlDocument Load(string relativePath) + { + return Parse(relativePath, RepositoryFile.ReadText(relativePath)); + } + + /// Parses YAML text; the path is only used in messages. + public static YamlDocument Parse(string relativePath, string text) + { + YamlStream stream = new(); + using (StringReader reader = new(text)) + { + stream.Load(reader); + } + + Assert.True(stream.Documents.Count == 1, $"'{relativePath}' must hold exactly one YAML document."); + YamlMappingNode? root = stream.Documents[0].RootNode as YamlMappingNode; + Assert.True(root is not null, $"The root of '{relativePath}' must be a mapping."); + return new YamlDocument(relativePath, text, root); + } + + /// The job with the given id; fails the test when it does not exist. + public YamlMappingNode Job(string id) + { + YamlMappingNode? found = null; + foreach (KeyValuePair job in Jobs) + { + if (string.Equals(job.Key, id, StringComparison.Ordinal)) + { + found = job.Value; + } + } + + Assert.True(found is not null, $"'{RelativePath}' has no job '{id}'."); + return found; + } + + /// The value of an event under on, or (also for a scalar or sequence form). + public YamlNode? Trigger(string name) + { + return Child(Child(Root, "on"), name); + } + + /// The value of when is a mapping that holds it. + public static YamlNode? Child(YamlNode? node, string key) + { + if (node is not YamlMappingNode mapping) + { + return null; + } + + foreach (KeyValuePair entry in mapping.Children) + { + if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal)) + { + return entry.Value; + } + } + + return null; + } + + /// The scalar value of , or when it is absent or not a scalar. + public static string? Scalar(YamlNode? node, string key) + { + return Child(node, key) is YamlScalarNode scalar ? scalar.Value : null; + } + + /// The scalar values of : one for a scalar, every scalar item for a sequence. + public static IReadOnlyList Scalars(YamlNode? node, string key) + { + return ScalarsOf(Child(node, key)); + } + + /// The scalar items of a scalar or sequence node. + public static IReadOnlyList ScalarsOf(YamlNode? node) + { + List values = []; + switch (node) + { + case YamlScalarNode scalar when scalar.Value is not null: + values.Add(scalar.Value); + break; + case YamlSequenceNode sequence: + foreach (YamlNode item in sequence.Children) + { + if (item is YamlScalarNode { Value: not null } itemScalar) + { + values.Add(itemScalar.Value); + } + } + + break; + } + + return values; + } + + /// The mapping items of the sequence at . + public static IReadOnlyList Mappings(YamlNode? node, string key) + { + List mappings = []; + if (Child(node, key) is YamlSequenceNode sequence) + { + foreach (YamlNode item in sequence.Children) + { + if (item is YamlMappingNode mapping) + { + mappings.Add(mapping); + } + } + } + + return mappings; + } + + /// The keys of a mapping, in file order. + public static IReadOnlyList KeysOf(YamlNode? node) + { + List keys = []; + if (node is YamlMappingNode mapping) + { + foreach (KeyValuePair entry in mapping.Children) + { + if (entry.Key is YamlScalarNode { Value: not null } scalar) + { + keys.Add(scalar.Value); + } + } + } + + return keys; + } + + /// The steps of a job. + public static IReadOnlyList Steps(YamlMappingNode job) + { + return Mappings(job, "steps"); + } + + /// + /// The permissions of a workflow or job as scope → access. { } gives an empty map; a scalar such as + /// read-all is returned under the key *; when the key is absent. + /// + public static IReadOnlyDictionary? Permissions(YamlNode? owner) + { + YamlNode? permissions = Child(owner, "permissions"); + if (permissions is null) + { + return null; + } + + Dictionary scopes = new(StringComparer.Ordinal); + if (permissions is YamlScalarNode scalar) + { + scopes["*"] = scalar.Value ?? ""; + return scopes; + } + + foreach (string scope in KeysOf(permissions)) + { + scopes[scope] = Scalar(permissions, scope) ?? ""; + } + + return scopes; + } + + /// The action reference of a step (uses:), without its trailing comment. + public static string? Uses(YamlMappingNode step) + { + return Scalar(step, "uses"); + } + + /// True when the step uses the action at (for example actions/checkout). + public static bool UsesAction(YamlMappingNode step, string actionPath) + { + string? uses = Uses(step); + return uses is not null && uses.StartsWith(actionPath + "@", StringComparison.Ordinal); + } + + /// Collapses runs of whitespace, so folded and literal block scalars compare by content. + public static string NormalizeWhitespace(string value) + { + return string.Join(' ', value.Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries)); + } + + /// The 1-based line of the first occurrence of in the file, or 0. + public int LineOf(string text) + { + int index = Text.IndexOf(text, StringComparison.Ordinal); + if (index < 0) + { + return 0; + } + + int line = 1; + for (int i = 0; i < index; i++) + { + if (Text[i] == '\n') + { + line++; + } + } + + return line; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/HostContract/ConcurrencyContractDocumentationTests.cs b/tests/CheatEngine.SDK.Repository.Tests/HostContract/ConcurrencyContractDocumentationTests.cs new file mode 100644 index 00000000..b279cec4 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/HostContract/ConcurrencyContractDocumentationTests.cs @@ -0,0 +1,71 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.HostContract; + +/// +/// WI-2 (F04, A23-F04-3, A08-06, A20-Q19-1): the SDK never claims a runtime or load-context cardinality it has not +/// qualified. "One binding per loaded assembly instance" is a fact this SDK copy owns; "one per plugin" is a host +/// loader fact that only Q09 evidence can establish. +/// +public sealed class ConcurrencyContractDocumentationTests +{ + private static readonly Regex UnqualifiedCardinalityClaim = new( + @"\(one per plugin\)|one load context hosts one plugin|hosted by this assembly load context", + RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + // This test's own source quotes the banned phrases verbatim (as the pattern it looks for and in this remark), so + // it is excluded from its own scan; every other file under libs/ and tests/ is still checked. + private const string SelfPath = "tests/CheatEngine.SDK.Repository.Tests/HostContract/ConcurrencyContractDocumentationTests.cs"; + + [Fact] + public void Sources_and_readmes_never_claim_one_runtime_or_load_context_per_plugin() + { + List offenders = []; + foreach (string relative in RepositoryRoot.EnumerateSourceFiles("*.cs") + .Concat(RepositoryRoot.EnumerateSourceFiles("*.md")) + .Where(static path => (path.StartsWith("libs/", StringComparison.Ordinal) + || path.StartsWith("tests/", StringComparison.Ordinal)) + && !string.Equals(path, SelfPath, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal)) + { + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, relative)); + foreach (Match match in UnqualifiedCardinalityClaim.Matches(text)) + { + offenders.Add($"{relative}: '{match.Value}'"); + } + } + + Assert.True(offenders.Count == 0, + "Unqualified plugin-cardinality claim(s):" + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void Hosting_readme_publishes_the_ADR_07_concurrency_contract() + { + string readme = File.ReadAllText(Path.Combine(RepositoryRoot.Path, "libs", "CheatEngine.SDK.Hosting", "README.md")); + Assert.Contains("Threading and Lua concurrency contract (ADR-07)", readme, StringComparison.Ordinal); + int sectionStart = readme.IndexOf("Threading and Lua concurrency contract (ADR-07)", StringComparison.Ordinal); + string section = readme[sectionStart..]; + + Assert.Contains("CESDK5001", section, StringComparison.Ordinal); + Assert.Contains("ADR-07", section, StringComparison.Ordinal); + Assert.Contains("Q19", section, StringComparison.Ordinal); + Assert.Contains("pluginCS", section, StringComparison.Ordinal); + Assert.Contains("processMessages", section, StringComparison.Ordinal); + } + + [Fact] + public void Lua_readme_states_that_admission_is_not_a_heap_lock() + { + string readme = File.ReadAllText(Path.Combine(RepositoryRoot.Path, "libs", "CheatEngine.SDK.Lua", "README.md")); + Assert.Contains("not", readme, StringComparison.Ordinal); + Assert.True( + readme.Contains("not a process-wide Lua mutex", StringComparison.Ordinal) + || readme.Contains("not a heap lock", StringComparison.Ordinal) + || readme.Contains("never the shared Lua heap", StringComparison.Ordinal), + "The Lua README must state that admission protects only this SDK copy's transitions, never the shared Lua heap."); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/HostContract/NativeHostEmulatorScriptTests.cs b/tests/CheatEngine.SDK.Repository.Tests/HostContract/NativeHostEmulatorScriptTests.cs new file mode 100644 index 00000000..6e1b6f24 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/HostContract/NativeHostEmulatorScriptTests.cs @@ -0,0 +1,107 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.HostContract; + +/// +/// Repository-level checks on tests/native-host-emulator/build.ps1 and its native sources: it is a genuine +/// build script (same category as the already-accepted tests/native-abi-fixture/build.ps1), never a +/// wrapper for hidden Cheat Engine access, and it checks every native command it runs. +/// +public sealed partial class NativeHostEmulatorScriptTests +{ + private const string BuildScriptRelativePath = "tests/native-host-emulator/build.ps1"; + + [Fact] + public void Build_script_checks_the_exit_code_after_every_native_command() + { + string[] lines = File.ReadAllLines(RepositoryFile(BuildScriptRelativePath)); + + int nativeCommandCount = 0; + int checkedCount = 0; + for (int index = 0; index < lines.Length; index++) + { + if (!NativeCommandInvocation().IsMatch(lines[index])) + { + continue; + } + + nativeCommandCount++; + if (index + 1 < lines.Length && lines[index + 1].Contains("$LASTEXITCODE", StringComparison.Ordinal)) + { + checkedCount++; + } + } + + Assert.True(nativeCommandCount > 0, "The build script should invoke at least one native command (cl.exe)."); + Assert.Equal(nativeCommandCount, checkedCount); + } + + [Fact] + public void Emulator_scripts_never_reference_a_cheat_engine_installation() + { + // Code only: the README legitimately explains, in prose, that this program never touches an installed Cheat + // Engine, which itself names the install file it must not reference. + string[] codeExtensions = [".ps1", ".cpp", ".h", ".json"]; + List offendingFiles = []; + foreach (string relativePath in RepositoryRoot.EnumerateSourceFiles("tests/native-host-emulator/*")) + { + if (Array.IndexOf(codeExtensions, Path.GetExtension(relativePath)) < 0) + { + continue; + } + + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (CheatEngineInstallationReference().IsMatch(text)) + { + offendingFiles.Add(relativePath); + } + } + + Assert.True(offendingFiles.Count == 0, + $"tests/native-host-emulator/** must never reference an installed Cheat Engine: {string.Join(", ", offendingFiles)}"); + } + + [Fact] + public void Build_script_writes_only_under_its_output_directory() + { + string[] lines = File.ReadAllLines(RepositoryFile(BuildScriptRelativePath)); + + foreach (string line in lines) + { + if (WriteCommand().IsMatch(line)) + { + Assert.True(OutputDirectoryDerivedVariable().IsMatch(line), + $"This write command does not reference an output-directory-derived variable: '{line.Trim()}'"); + } + + string codePortion = CommentStart().Split(line)[0]; + Assert.False(DriveLetterPath().IsMatch(codePortion), $"Hardcoded drive-letter path outside a comment: '{line.Trim()}'"); + } + } + + private static string RepositoryFile(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + [GeneratedRegex(@"&\s*\$compiler\.Source\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex NativeCommandInvocation(); + + [GeneratedRegex(@"Program Files\\Cheat Engine|cheatengine-x86_64", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 1000)] + private static partial Regex CheatEngineInstallationReference(); + + [GeneratedRegex(@"^\s*(Copy-Item|Set-Content).*$", RegexOptions.CultureInvariant | RegexOptions.Multiline | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] + private static partial Regex WriteCommand(); + + [GeneratedRegex(@"resolvedOutputDirectory|nethostDllDestination|runtimeConfigDestination|manifestPath|\$exe\b|\$objectFile\b", + RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex OutputDirectoryDerivedVariable(); + + [GeneratedRegex(@"[A-Za-z]:\\", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex DriveLetterPath(); + + [GeneratedRegex(@"(?Locates the repository from the test output directory. +internal static class RepositoryRoot +{ + private const string SolutionFileName = "CheatEngine.SDK.slnx"; + + /// The directory that contains CheatEngine.SDK.slnx, found by walking up from the test binaries. + public static string Path + { + get; + } = FindRoot(); + + /// The solution file. + public static string SolutionPath => System.IO.Path.Combine(Path, SolutionFileName); + + /// Converts an absolute path below the repository root to a forward-slash relative path. + public static string ToRelative(string absolutePath) + { + return System.IO.Path.GetRelativePath(Path, absolutePath).Replace('\\', '/'); + } + + /// Enumerates files below the repository root, skipping build output and tool state folders. + public static IEnumerable EnumerateSourceFiles(string searchPattern) + { + foreach (string file in Directory.EnumerateFiles(Path, searchPattern, SearchOption.AllDirectories)) + { + string relative = ToRelative(file); + if (IsExcluded(relative)) + { + continue; + } + + yield return relative; + } + } + + private static bool IsExcluded(string relativePath) + { + foreach (string segment in relativePath.Split('/')) + { + if (segment is "artifacts" or "bin" or "obj" or ".git" or ".idea" or ".vs" or "TestResults") + { + return true; + } + } + + return false; + } + + private static string FindRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(System.IO.Path.Combine(directory.FullName, SolutionFileName))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException( + $"'{SolutionFileName}' was not found above '{AppContext.BaseDirectory}': the tests expect to run from the repository's artifacts directory."); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/LockFiles/LockFileTests.cs b/tests/CheatEngine.SDK.Repository.Tests/LockFiles/LockFileTests.cs new file mode 100644 index 00000000..733f8c23 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/LockFiles/LockFileTests.cs @@ -0,0 +1,192 @@ +using System.Text.Json; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.LockFiles; + +/// +/// Offline mirror of the structural checks NuGet locked-mode restore relies on, so a hand-edited, missing or +/// IDE-reformatted lock file is caught before CI runs a locked restore. Only 'dotnet restore --force-evaluate' +/// regenerates lock files; the CI lock-files job (--locked-mode) proves they equal a fresh restore. +/// +public sealed class LockFileTests +{ + private const string LockFileName = "packages.lock.json"; + private const string RegenerateHint = "Regenerate with 'dotnet restore --force-evaluate', never by hand."; + + [Fact] + public void Every_project_has_a_committed_lock_file() + { + List missing = []; + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + if (!File.Exists(FullPath(LockPathOf(project)))) + { + missing.Add(project); + } + } + + Assert.True(missing.Count == 0, $"Projects without {LockFileName}: {string.Join(", ", missing)}. {RegenerateHint}"); + } + + [Fact] + public void Lock_files_parse_and_declare_a_supported_format_version() + { + foreach ((string path, JsonElement lockFile) in LoadLockFiles()) + { + Assert.True(lockFile.TryGetProperty("version", out JsonElement version), $"{path} has no version."); + Assert.True(version.GetInt32() is 1 or 2, $"{path} declares the unsupported lock file version {version}."); + Assert.Equal(JsonValueKind.Object, lockFile.GetProperty("dependencies").ValueKind); + } + } + + [Fact] + public void Every_lock_file_is_version_2_because_every_project_uses_central_package_management() + { + // CESDK9005 forbids opting out of Central Package Management, and NuGet writes version 2 lock files for CPM projects. + foreach ((string path, JsonElement lockFile) in LoadLockFiles()) + { + Assert.True(lockFile.GetProperty("version").GetInt32() == 2, $"{path} is not a version 2 lock file. {RegenerateHint}"); + } + } + + [Fact] + public void Version_1_lock_files_hold_no_central_transitive_entries() + { + foreach ((string path, JsonElement lockFile) in LoadLockFiles()) + { + if (lockFile.GetProperty("version").GetInt32() != 1) + { + continue; + } + + foreach ((string section, string packageId, JsonElement package) in Entries(lockFile)) + { + Assert.False(string.Equals(TypeOf(package), "CentralTransitive", StringComparison.Ordinal), + $"{path}: version 1 lock holds the CentralTransitive entry {packageId} ({section}). {RegenerateHint}"); + } + } + } + + [Fact] + public void Native_aot_projects_lock_the_win_x64_ilcompiler_packages() + { + int runtimeSpecificProjects = 0; + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + XDocument document = XDocument.Load(FullPath(project)); + string? runtime = LastPropertyValue(document, "RuntimeIdentifier"); + if (runtime is null) + { + continue; + } + + runtimeSpecificProjects++; + bool publishAot = string.Equals(LastPropertyValue(document, "PublishAot"), "true", StringComparison.OrdinalIgnoreCase); + string lockPath = LockPathOf(project); + using JsonDocument lockFile = JsonDocument.Parse(File.ReadAllBytes(FullPath(lockPath))); + JsonProperty? section = null; + foreach (JsonProperty candidate in lockFile.RootElement.GetProperty("dependencies").EnumerateObject()) + { + if (candidate.Name.EndsWith($"/{runtime}", StringComparison.Ordinal)) + { + section = candidate; + } + } + + Assert.True(section is not null, + $"{project} sets RuntimeIdentifier {runtime} but {lockPath} has no '/{runtime}' section, so 'dotnet publish --no-restore' fails. {RegenerateHint}"); + if (publishAot) + { + string compiler = $"runtime.{runtime}.Microsoft.DotNet.ILCompiler"; + Assert.True(section.Value.Value.TryGetProperty(compiler, out _), + $"{project} publishes Native AOT but {lockPath} does not lock {compiler} in '{section.Value.Name}'. {RegenerateHint}"); + } + } + + Assert.True(runtimeSpecificProjects > 0, "No project sets RuntimeIdentifier: the Native AOT probes are expected to."); + } + + [Fact] + public void No_lock_file_resolves_a_cheatengine_package() + { + // The SDK never consumes its own package through NuGet; the ApiCompat 1.0.0 baseline is a PackageDownload, which + // lock files do not record. Project references appear with type "Project" and are not packages. + foreach ((string path, JsonElement lockFile) in LoadLockFiles()) + { + foreach ((string section, string packageId, JsonElement package) in Entries(lockFile)) + { + Assert.False( + !string.Equals(TypeOf(package), "Project", StringComparison.Ordinal) + && packageId.StartsWith("CheatEngine.", StringComparison.OrdinalIgnoreCase), + $"{path} resolves the package {packageId} ({section}) from a feed."); + } + } + } + + [Fact] + public void Lock_files_end_without_a_final_newline_as_nuget_writes_them() + { + // The script restores committed bytes when the JSON is unchanged, so an editor-added final newline would survive + // until the next real change; catching it here keeps the files byte-equal to NuGet's output. + foreach ((string path, _) in LoadLockFiles()) + { + byte[] content = File.ReadAllBytes(FullPath(path)); + Assert.True(content.Length > 0 && content[^1] == (byte) '}', + $"{path} does not end with '}}' as NuGet writes it (edited by hand or by an editor?). {RegenerateHint}"); + } + } + + private static List<(string Path, JsonElement LockFile)> LoadLockFiles() + { + List<(string, JsonElement)> lockFiles = []; + foreach (string path in RepositoryRoot.EnumerateSourceFiles(LockFileName)) + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(FullPath(path))); + lockFiles.Add((path, document.RootElement.Clone())); + } + + Assert.NotEmpty(lockFiles); + return lockFiles; + } + + private static IEnumerable<(string Section, string PackageId, JsonElement Package)> Entries(JsonElement lockFile) + { + foreach (JsonProperty section in lockFile.GetProperty("dependencies").EnumerateObject()) + { + foreach (JsonProperty package in section.Value.EnumerateObject()) + { + yield return (section.Name, package.Name, package.Value); + } + } + } + + private static string? TypeOf(JsonElement package) + { + return package.TryGetProperty("type", out JsonElement type) ? type.GetString() : null; + } + + private static string? LastPropertyValue(XDocument document, string name) + { + string? value = null; + foreach (XElement group in document.Descendants("PropertyGroup")) + { + foreach (XElement property in group.Elements(name)) + { + value = property.Value.Trim(); + } + } + + return value; + } + + private static string LockPathOf(string project) + { + return $"{project[..project.LastIndexOf('/')]}/{LockFileName}"; + } + + private static string FullPath(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaApiUse.cs b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaApiUse.cs new file mode 100644 index 00000000..85a236d8 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaApiUse.cs @@ -0,0 +1,6 @@ +namespace CheatEngine.SDK.Repository.Tests.LuaBridge; + +/// One raw use of a LuaApi member found in a production source. +/// The member name, for example lua_tolstring. +/// The 1-based line of the use. +internal readonly record struct LuaApiUse(string Member, int Line); diff --git a/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaApiUseScanner.cs b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaApiUseScanner.cs new file mode 100644 index 00000000..0ca857fd --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaApiUseScanner.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.SourceScanning; + +namespace CheatEngine.SDK.Repository.Tests.LuaBridge; + +/// +/// A lexical scanner for raw LuaApi uses in C# source: LuaApi.member (optionally namespace- or +/// global::-qualified), or a bare member name in a file that imports +/// using static CheatEngine.SDK.Lua.Interop.Api.LuaApi;. Comments and literals are blanked first +/// (), so documentation that names a member is not a use. Only +/// names of real LuaApi members count. +/// +/// +/// Text-based on purpose: the repository tests reference no Roslyn package. The Roslyn guard for the catalogued +/// bridge routes is LuaDirectApiBoundaryGuardTests in the analyzer tests. +/// +internal static partial class LuaApiUseScanner +{ + /// Returns every use of a member of in . + internal static IReadOnlyList Scan(string source, IReadOnlySet members) + { + string code = CSharpCode.BlankCommentsAndLiterals(source); + bool staticImport = StaticImport().IsMatch(code); + List uses = []; + foreach (Match match in MemberReference().Matches(code)) + { + string member = match.Groups["member"].Value; + if (!members.Contains(member) || (!match.Groups["qualifier"].Success && !staticImport)) + { + continue; + } + + uses.Add(new LuaApiUse(member, CSharpCode.LineOf(code, match.Index))); + } + + return uses; + } + + [GeneratedRegex(@"using\s+static\s+(?:global::)?CheatEngine\.SDK\.Lua\.Interop\.Api\.LuaApi\s*;", + RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex StaticImport(); + + [GeneratedRegex( + @"(?(?:global::)?(?:CheatEngine\.SDK\.Lua\.Interop\.Api\.)?LuaApi\s*\.\s*)?\b(?(?:lua|luaL|luaopen)_\w+)\b", + RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex MemberReference(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaBridgeDocuments.cs b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaBridgeDocuments.cs new file mode 100644 index 00000000..0b13608d --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaBridgeDocuments.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +using CheatEngine.SDK.Repository.Tests.Abi; + +namespace CheatEngine.SDK.Repository.Tests.LuaBridge; + +/// +/// Paths and readers shared by the tests of the protected-operation catalogue and of the Lua interop primitive +/// matrix. Both documents are read as committed (UTF-8, CRLF normalized to LF); neither carries a JSON Schema file +/// any more (the maintainer's no-custom-scripting pivot removed that infrastructure), so every shape rule is a +/// plain C# assertion in the tests that use these readers. +/// +internal static class LuaBridgeDocuments +{ + internal const string CataloguePath = "libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json"; + internal const string MatrixPath = "tests/CheatEngine.SDK.Repository.Tests/LuaBridge/TestData/lua-interop-primitives.json"; + internal const string BridgeSourcePath = "native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c"; + internal const string XmakePath = "native/cheatengine-sdk-lua-bridge/xmake.lua"; + internal const string LuaFixturePath = "native/cheat-engine/lua53-64.dll"; + internal const string FailureProbePath = "tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs"; + + internal static JsonElement Catalogue => RepositoryDocument.LoadJson(CataloguePath); + + internal static JsonElement Matrix => RepositoryDocument.LoadJson(MatrixPath); + + internal static JsonElement[] Operations => [.. Catalogue.GetProperty("operations").EnumerateArray()]; + + internal static JsonElement[] Rows => [.. Matrix.GetProperty("rows").EnumerateArray()]; + + /// A copy of after , for refusal checks. + internal static JsonElement Mutate(JsonElement document, Action change) + { + JsonNode root = JsonNode.Parse(document.GetRawText())!; + change(root); + return RepositoryDocument.ParseJson(root.ToJsonString()); + } + + /// The string value of , or when it is absent or null. + internal static string? OptionalString(JsonElement element, string property) + { + return element.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaInteropPrimitiveMatrixDocumentTests.cs b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaInteropPrimitiveMatrixDocumentTests.cs new file mode 100644 index 00000000..6e958eec --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/LuaInteropPrimitiveMatrixDocumentTests.cs @@ -0,0 +1,250 @@ +using System.Text; +using System.Text.Json; + +using CheatEngine.SDK.Repository.Tests.Abi; +using CheatEngine.SDK.Repository.Tests.Infrastructure; +using CheatEngine.SDK.Repository.Tests.SourceScanning; + +namespace CheatEngine.SDK.Repository.Tests.LuaBridge; + +/// +/// The committed Lua interop primitive matrix (test-owned data, never a top-level docs/ folder or a bespoke +/// eng/ tool): one row per public static LuaApi member with its Lua 5.3 error class, side effects and +/// the route SDK production code may take (audit SDK-LUA-2, A05-02/05/08, A18-09). These tests read committed +/// files only; the reflection and XML-documentation checks of the same rows run in +/// LuaInteropPrimitiveMatrixTests of the Lua.Interop tests. +/// +public sealed class LuaInteropPrimitiveMatrixDocumentTests +{ + /// + /// The ratchet of productionExceptions: lower it when an exception goes away, never raise it. A new raw + /// raising call belongs below the bridge instead. + /// + private const int MaximumProductionExceptions = 3; + + private const string ManualAnchorPrefix = "https://www.lua.org/manual/5.3/manual.html#"; + private const string RawApiDirectory = "libs/CheatEngine.SDK.Lua.Interop/Api/"; + + [Fact] + public void Primitive_matrix_has_its_schema_identity() + { + Assert.Equal("cheatengine-lua-interop-primitives/v0", LuaBridgeDocuments.Matrix.GetProperty("schema").GetString()); + } + + [Fact] + public void Primitive_matrix_is_canonical_and_its_lists_are_sorted_and_unique() + { + string text = RepositoryDocument.ReadNormalizedText(LuaBridgeDocuments.MatrixPath); + JsonElement matrix = RepositoryDocument.ParseJson(text); + + Assert.Equal(RepositoryDocument.Canonical(matrix), text); + string[] members = [.. LuaBridgeDocuments.Rows.Select(static row => row.GetProperty("member").GetString()!)]; + Assert.Equal(members.Order(StringComparer.Ordinal), members, StringComparer.Ordinal); + Assert.Equal(members.Length, members.Distinct(StringComparer.Ordinal).Count()); + string[] exceptions = + [ + .. matrix.GetProperty("productionExceptions").EnumerateArray() + .Select(static item => item.GetProperty("member").GetString() + "|" + item.GetProperty("file").GetString()) + ]; + Assert.Equal(exceptions.Order(StringComparer.Ordinal), exceptions, StringComparer.Ordinal); + Assert.Equal(exceptions.Length, exceptions.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void Rows_are_consistent_with_their_decision_bridge_operation_and_manual_marker() + { + HashSet operations = new(LuaBridgeDocuments.Operations.Select(static operation => + operation.GetProperty("id").GetString()!), StringComparer.Ordinal); + Dictionary divergences = LuaBridgeDocuments.Matrix.GetProperty("manualDivergences") + .EnumerateArray().ToDictionary(static item => item.GetProperty("member").GetString()!, StringComparer.Ordinal); + List problems = []; + foreach (JsonElement row in LuaBridgeDocuments.Rows) + { + CheckRoute(row, operations, problems); + CheckProvenance(row, divergences, problems); + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + HashSet members = new(LuaBridgeDocuments.Rows.Select(static row => row.GetProperty("member").GetString()!), + StringComparer.Ordinal); + Assert.All(divergences.Keys, member => Assert.Contains(member, members)); + } + + [Fact] + public void Production_raw_LuaApi_uses_are_classified_or_explicitly_excepted() + { + Dictionary decisions = LuaBridgeDocuments.Rows.ToDictionary( + static row => row.GetProperty("member").GetString()!, + static row => row.GetProperty("decision").GetString()!, StringComparer.Ordinal); + HashSet excepted = new(LuaBridgeDocuments.Matrix.GetProperty("productionExceptions").EnumerateArray() + .Select(static item => item.GetProperty("member").GetString() + "|" + item.GetProperty("file").GetString()), + StringComparer.Ordinal); + HashSet members = new(decisions.Keys, StringComparer.Ordinal); + HashSet used = new(StringComparer.Ordinal); + List problems = []; + foreach (string file in ProductionSources()) + { + string source = File.ReadAllText(RepositoryDocument.Absolute(file), Encoding.UTF8); + foreach (LuaApiUse use in LuaApiUseScanner.Scan(source, members)) + { + string key = use.Member + "|" + file; + used.Add(key); + if (decisions[use.Member] is not ("DirectAllowed" or "Lifecycle") && !excepted.Contains(key)) + { + problems.Add($"{file}:{use.Line}: {use.Member} is {decisions[use.Member]}; route it through the bridge or add a production exception."); + } + } + } + + problems.AddRange(excepted.Where(key => !used.Contains(key)) + .Select(static key => $"Production exception {key} matches no raw use: remove it.")); + problems.AddRange(excepted.Where(key => !string.Equals(decisions.GetValueOrDefault(key.Split('|')[0]), + "ConditionallyDirect", StringComparison.Ordinal)) + .Select(static key => $"Production exception {key} must name a ConditionallyDirect member.")); + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + Assert.InRange(excepted.Count, 0, MaximumProductionExceptions); + Assert.Contains("lua_settop", used.Select(static key => key.Split('|')[0]), StringComparer.Ordinal); + } + + [Fact] + public void Scanner_sees_qualified_and_static_uses_and_ignores_comments_and_literals() + { + HashSet members = new(["lua_tolstring", "lua_settop", "lua_gettop"], StringComparer.Ordinal); + const string Source = """" + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + // lua_gettop(L) in a comment + /* LuaApi.lua_gettop */ + class C + { + string s = "lua_gettop(L)"; + string v = @"LuaApi.lua_gettop ""quoted"""; + void M(void* L) { lua_settop(L, 0); _ = LuaApi.lua_tolstring(L, -1, null); other.lua_gettop(); } + } + """"; + + IReadOnlyList uses = LuaApiUseScanner.Scan(Source, members); + + Assert.Equal([new LuaApiUse("lua_settop", 8), new LuaApiUse("lua_tolstring", 8)], uses); + Assert.Empty(LuaApiUseScanner.Scan("class C { void M(void* L) { lua_settop(L, 0); } }", members)); + } + + [Fact] + public void Checkstack_proof_record_matches_the_bundled_lua_and_the_lf_pinned_bridge_sources() + { + JsonElement proof = LuaBridgeDocuments.Matrix.GetProperty("bridgeProof"); + + Assert.Equal(RepositoryDocument.RawSha256(LuaBridgeDocuments.LuaFixturePath), + proof.GetProperty("luaFixtureSha256").GetString()); + Assert.Equal(RepositoryDocument.RawSha256(LuaBridgeDocuments.BridgeSourcePath), + proof.GetProperty("bridgeSourceSha256").GetString()); + Assert.Equal(RepositoryDocument.RawSha256(LuaBridgeDocuments.XmakePath), + proof.GetProperty("xmakeSha256").GetString()); + Assert.DoesNotContain((byte) '\r', File.ReadAllBytes(RepositoryDocument.Absolute(LuaBridgeDocuments.BridgeSourcePath))); + + string[] test = proof.GetProperty("checkstackEvidence").GetString()!.Split('.'); + string file = Assert.Single(RepositoryRoot.EnumerateSourceFiles(test[0] + ".cs"), + static path => path.StartsWith("tests/", StringComparison.Ordinal)); + Assert.NotNull(TestMethodTraits.Of(file, test[1])); + } + + [Fact] + public void Bundled_lua_fixture_hash_equals_the_catalogue_and_primitive_matrix_hashes() + { + string fixture = RepositoryDocument.RawSha256(LuaBridgeDocuments.LuaFixturePath); + + Assert.Equal(fixture, LuaBridgeDocuments.Matrix.GetProperty("bridgeProof").GetProperty("luaFixtureSha256").GetString()); + Assert.Equal(fixture, LuaBridgeDocuments.Catalogue.GetProperty("host").GetProperty("lua") + .GetProperty("fixtureSha256").GetString(), ignoreCase: true); + } + + private static void CheckRoute(JsonElement row, HashSet operations, List problems) + { + string member = row.GetProperty("member").GetString()!; + string decision = row.GetProperty("decision").GetString()!; + string raises = row.GetProperty("raises").GetString()!; + string? bridge = LuaBridgeDocuments.OptionalString(row, "bridgeOperation"); + string? native = LuaBridgeDocuments.OptionalString(row, "nativeSymbol"); + if (raises is "Memory" or "Any" or "Always" && (decision is "DirectAllowed" or "Lifecycle")) + { + problems.Add($"{member}: a raising member cannot be {decision}."); + } + + if (bridge is not null && !operations.Contains(bridge)) + { + problems.Add($"{member}: bridge operation {bridge} is not in the catalogue."); + } + + if (string.Equals(decision, "BridgeRequired", StringComparison.Ordinal) && bridge is null && + !string.Equals(member, "lua_error", StringComparison.Ordinal)) + { + problems.Add($"{member}: BridgeRequired names no bridge operation (only lua_error, raised by the bridge's C code, may)."); + } + + if (native is not null && !string.Equals(native, member, StringComparison.Ordinal)) + { + problems.Add($"{member}: the native symbol {native} is not the member name."); + } + } + + private static void CheckProvenance(JsonElement row, Dictionary divergences, List problems) + { + string member = row.GetProperty("member").GetString()!; + JsonElement provenance = row.GetProperty("provenance"); + if (provenance.ValueKind == JsonValueKind.Null) + { + return; + } + + string marker = provenance.GetProperty("marker").GetString()!; + string source = provenance.GetProperty("source").GetString()!; + if (marker.StartsWith("none:", StringComparison.Ordinal)) + { + if (!member.StartsWith("luaopen_", StringComparison.Ordinal)) + { + problems.Add($"{member}: only a luaopen_* value may lack a manual marker."); + } + + return; + } + + if (!string.Equals(source, ManualAnchorPrefix + member, StringComparison.Ordinal)) + { + problems.Add($"{member}: the provenance must link the manual entry of {member}, found {source}."); + } + + string manualClass = ClassOfMarker(marker); + string raises = row.GetProperty("raises").GetString()!; + bool agrees = string.Equals(manualClass, raises, StringComparison.Ordinal) || + (string.Equals(manualClass, "Never", StringComparison.Ordinal) && + string.Equals(raises, "NotApplicable", StringComparison.Ordinal)); + bool recorded = divergences.TryGetValue(member, out JsonElement divergence) && + string.Equals(divergence.GetProperty("manualClass").GetString(), manualClass, StringComparison.Ordinal) && + string.Equals(divergence.GetProperty("sdkClass").GetString(), raises, StringComparison.Ordinal); + if (agrees == recorded) + { + problems.Add(agrees + ? $"{member}: a divergence is recorded, but the manual marker {marker} agrees with {raises}." + : $"{member}: the manual marker {marker} says {manualClass} and the row says {raises}; record the divergence."); + } + } + + private static string ClassOfMarker(string marker) + { + return marker[^2] switch + { + '–' => "Never", + 'm' => "Memory", + 'e' => "Any", + 'v' => "Always", + _ => throw new InvalidOperationException($"Unknown manual marker {marker}.") + }; + } + + private static IEnumerable ProductionSources() + { + return RepositoryRoot.EnumerateSourceFiles("*.cs") + .Where(static file => file.StartsWith("libs/", StringComparison.Ordinal) && + !file.StartsWith(RawApiDirectory, StringComparison.Ordinal)) + .Order(StringComparer.Ordinal); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/ProtectedOperationCatalogTests.cs b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/ProtectedOperationCatalogTests.cs new file mode 100644 index 00000000..7196dcf2 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/ProtectedOperationCatalogTests.cs @@ -0,0 +1,245 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Abi; +using CheatEngine.SDK.Repository.Tests.SourceScanning; + +namespace CheatEngine.SDK.Repository.Tests.LuaBridge; + +/// +/// libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json, the catalogue of the C11 Lua +/// protection bridge: every rule of the retired Test-ProtectedOperationCatalog.ps1 that still applies +/// (uniqueness, derived bitmap, native enum and switch, direct-call policy — the schema-file part of that script +/// is gone with it, since the repository keeps no JSON Schema infrastructure) plus the failure evidence of every +/// operation that can raise (audit A20-Q13, CI-SDK-ENG-1). The LuaBridgeContract generator reads the same file: it +/// requires schemaVersion 1 and ignores properties it does not know, such as failureEvidence. +/// +public sealed partial class ProtectedOperationCatalogTests +{ + private const int BitmapWidth = 64; + private const string ProbeMarkerKind = "FailureProbeMarker"; + private const string ManagedTestKind = "ManagedTest"; + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + + [Fact] + public void Catalog_has_its_schema_version_and_identity() + { + Assert.Equal(1, LuaBridgeDocuments.Catalogue.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("cheatengine-sdk-lua-protected-operations", LuaBridgeDocuments.Catalogue.GetProperty("catalogId").GetString()); + } + + [Fact] + public void Operation_ids_opcodes_native_enums_and_constants_are_unique_and_derive_the_bitmap() + { + JsonElement[] operations = LuaBridgeDocuments.Operations; + JsonElement contract = LuaBridgeDocuments.Catalogue.GetProperty("bridgeContract"); + ulong bitmap = 0; + int previousOpcode = -1; + foreach (JsonElement operation in operations) + { + string id = operation.GetProperty("id").GetString()!; + int opcode = operation.GetProperty("opcode").GetInt32(); + Assert.InRange(opcode, 0, BitmapWidth - 1); + Assert.True(opcode > previousOpcode, $"{id}: operations must be ordered by opcode."); + Assert.Equal(id + "Operation", operation.GetProperty("managed").GetProperty("constant").GetString()); + Assert.True(operation.GetProperty("protected").GetBoolean(), $"{id} must stay protected."); + Assert.True(operation.GetProperty("requiresNativeProtection").GetBoolean(), + $"{id} must stay below the C11 lua_pcallk boundary."); + Assert.NotEqual(0, operation.GetProperty("provenance").GetArrayLength()); + previousOpcode = opcode; + bitmap |= 1UL << opcode; + } + + AssertUnique(operations, static operation => operation.GetProperty("id").GetString()!); + AssertUnique(operations, static operation => operation.GetProperty("nativeEnum").GetString()!); + AssertUnique(operations, static operation => operation.GetProperty("managed").GetProperty("constant").GetString()!); + AssertUnique(operations, static operation => operation.GetProperty("managed").GetProperty("wrapper").GetString()!); + Assert.Equal(BitmapWidth, contract.GetProperty("operationBitmapWidth").GetInt32()); + Assert.Equal(string.Create(CultureInfo.InvariantCulture, $"0x{bitmap:X16}"), + contract.GetProperty("operationBitmap").GetString()); + } + + [Fact] + public void Every_operation_has_its_native_enum_value_and_switch_case_in_the_lf_pinned_bridge_source() + { + string source = File.ReadAllText(RepositoryDocument.Absolute(LuaBridgeDocuments.BridgeSourcePath)); + Assert.DoesNotContain('\r', source); + Match nativeEnum = NativeOperationEnum().Match(source); + Assert.True(nativeEnum.Success, "The C11 protected-operation enum was not found."); + string values = nativeEnum.Groups["values"].Value; + Match count = OperationCount().Match(values); + Assert.True(count.Success, "The C11 protected-operation enum has no count sentinel."); + Match mask = OperationMask().Match(source); + Assert.True(mask.Success, "The C11 protected-operation mask was not found."); + + JsonElement[] operations = LuaBridgeDocuments.Operations; + Assert.Equal(operations.Length, int.Parse(count.Groups["count"].Value, CultureInfo.InvariantCulture)); + foreach (JsonElement operation in operations) + { + string name = Regex.Escape(operation.GetProperty("nativeEnum").GetString()!); + int opcode = operation.GetProperty("opcode").GetInt32(); + Assert.True(Regex.IsMatch(values, $@"\b{name}\s*=\s*{opcode}\b", RegexOptions.CultureInvariant, RegexTimeout), + $"The native enum does not define {name} = {opcode}."); + Assert.True(Regex.IsMatch(source, $@"\bcase\s+{name}\s*:", RegexOptions.CultureInvariant, RegexTimeout), + $"The native bridge has no case for {name}."); + Assert.True(Regex.IsMatch(mask.Groups["mask"].Value, $@"\b{name}\b", RegexOptions.CultureInvariant, RegexTimeout), + $"The native operation mask does not name {name}."); + } + } + + [Fact] + public void Direct_api_policy_routes_are_exclusive_explicit_and_never_direct_for_raising_apis() + { + HashSet operations = new(LuaBridgeDocuments.Operations.Select(static operation => + operation.GetProperty("id").GetString()!), StringComparer.Ordinal); + JsonElement[] policies = [.. LuaBridgeDocuments.Catalogue.GetProperty("directApiPolicy").EnumerateArray()]; + AssertUnique(policies, static policy => policy.GetProperty("managedSymbol").GetString()!); + List problems = []; + foreach (JsonElement policy in policies) + { + string symbol = policy.GetProperty("managedSymbol").GetString()!; + bool direct = policy.GetProperty("allowedDirectly").GetBoolean(); + bool bridge = policy.GetProperty("requiresBridge").GetBoolean(); + string? operation = LuaBridgeDocuments.OptionalString(policy, "bridgeOperation"); + if (direct == bridge) + { + problems.Add($"{symbol}: choose exactly one route, direct or bridge."); + } + + if (direct && !string.Equals(policy.GetProperty("raises").GetString(), "never", StringComparison.Ordinal)) + { + problems.Add($"{symbol}: a directly allowed API must be classified never."); + } + + if (operation is not null && !operations.Contains(operation)) + { + problems.Add($"{symbol}: bridge operation {operation} is not in the catalogue."); + } + + if (policy.TryGetProperty("conditionalDirectUse", out JsonElement conditional) && + (direct || !bridge || !conditional.GetProperty("allowed").GetBoolean())) + { + problems.Add($"{symbol}: a conditional direct use keeps the bridge as its default route and opts in explicitly."); + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + [Trait("Qualification", "Q13")] + public void Every_raising_operation_names_a_failure_test() + { + List problems = []; + foreach (JsonElement operation in LuaBridgeDocuments.Operations) + { + string id = operation.GetProperty("id").GetString()!; + bool raises = !string.Equals(operation.GetProperty("raises").GetString(), "never", StringComparison.Ordinal); + if (raises && (!operation.TryGetProperty("failureEvidence", out JsonElement evidence) || + evidence.GetArrayLength() == 0)) + { + problems.Add($"{id} can raise but names no failure evidence (a failure-probe marker or a Q13 test)."); + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + Assert.Contains(LuaBridgeDocuments.Operations, static operation => + !string.Equals(operation.GetProperty("raises").GetString(), "never", StringComparison.Ordinal)); + } + + [Fact] + public void Managed_failure_evidence_resolves_to_a_Q13_traited_method() + { + JsonElement[] tests = [.. Evidence(ManagedTestKind)]; + Assert.NotEmpty(tests); + foreach (JsonElement test in tests) + { + string project = test.GetProperty("project").GetString()!; + string file = test.GetProperty("file").GetString()!; + string[] name = test.GetProperty("test").GetString()!.Split('.'); + Assert.True(RepositoryDocument.Exists(project), $"The evidence project {project} does not exist."); + Assert.StartsWith(project[..(project.LastIndexOf('/') + 1)], file, StringComparison.Ordinal); + IReadOnlyList? traits = TestMethodTraits.Of(file, name[1]); + Assert.True(traits is not null, $"{file} declares no {name[0]}.{name[1]}."); + Assert.Contains("Q13", traits, StringComparer.Ordinal); + } + } + + [Fact] + public void Probe_marker_evidence_is_emitted_by_the_failure_probe_source() + { + string probe = RepositoryDocument.ReadNormalizedText(LuaBridgeDocuments.FailureProbePath); + string[] markers = [.. Evidence(ProbeMarkerKind).Select(static item => item.GetProperty("marker").GetString()!)]; + + Assert.NotEmpty(markers); + Assert.Equal(markers.Length, markers.Distinct(StringComparer.Ordinal).Count()); + Assert.All(markers, marker => Assert.Contains($"WriteMarker(\"{marker}\");", probe, StringComparison.Ordinal)); + } + + [Fact] + public void Retired_live_ids_are_absent() + { + // Built from parts so this file never contains the retired strings it looks for. + string[] retired = ["P0-" + "LIVE-", "Test-Protected" + "OperationCatalog"]; + string[] documents = + [ + LuaBridgeDocuments.CataloguePath, LuaBridgeDocuments.MatrixPath, LuaBridgeDocuments.FailureProbePath, + "native/cheatengine-sdk-lua-bridge/README.md", "libs/CheatEngine.SDK.Lua.Interop/README.md", + "analyzers/docs/internal-lua-direct-api-boundary.md" + ]; + + // The whole eng/lua-bridge/** tree (including the retired PowerShell catalogue check) is gone: no bespoke + // governance script survives under eng/, per the maintainer's no-custom-scripting pivot. + Assert.False(RepositoryDocument.DirectoryExists("eng/lua-bridge"), "eng/lua-bridge/** must not be recreated."); + foreach (string document in documents) + { + string text = RepositoryDocument.ReadNormalizedText(document); + Assert.All(retired, id => Assert.DoesNotContain(id, text, StringComparison.Ordinal)); + } + + JsonElement pusher = Assert.Single(LuaBridgeDocuments.Operations, + static operation => string.Equals(operation.GetProperty("id").GetString(), "PushHostObject", StringComparison.Ordinal)); + Assert.Contains("Q23", pusher.GetProperty("provenance")[0].GetProperty("verification").GetString(), StringComparison.Ordinal); + Assert.Contains("Q23", RepositoryDocument.ReadNormalizedText("libs/CheatEngine.SDK.Lua.Interop/README.md"), + StringComparison.Ordinal); + } + + private static IEnumerable Evidence(string kind) + { + foreach (JsonElement operation in LuaBridgeDocuments.Operations) + { + if (!operation.TryGetProperty("failureEvidence", out JsonElement evidence)) + { + continue; + } + + foreach (JsonElement item in evidence.EnumerateArray()) + { + if (string.Equals(item.GetProperty("kind").GetString(), kind, StringComparison.Ordinal)) + { + yield return item; + } + } + } + } + + private static void AssertUnique(JsonElement[] items, Func key) + { + string[] duplicates = [.. items.GroupBy(key, StringComparer.Ordinal).Where(static group => group.Count() > 1) + .Select(static group => group.Key)]; + Assert.True(duplicates.Length == 0, "Duplicated: " + string.Join(", ", duplicates)); + } + + [GeneratedRegex(@"enum\s*\{\s*(?OP_PUSH_BYTES\s*=\s*0,[\s\S]*?CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_COUNT\s*=\s*\d+)\s*\};", + RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex NativeOperationEnum(); + + [GeneratedRegex(@"CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_COUNT\s*=\s*(?\d+)", RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 1000)] + private static partial Regex OperationCount(); + + [GeneratedRegex(@"#define\s+CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_MASK\s*\\(?[\s\S]*?)\n\s*\n", + RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex OperationMask(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/TestData/lua-interop-primitives.json b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/TestData/lua-interop-primitives.json new file mode 100644 index 00000000..fe23a878 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/LuaBridge/TestData/lua-interop-primitives.json @@ -0,0 +1,2597 @@ +{ + "schema": "cheatengine-lua-interop-primitives/v0", + "subject": "CheatEngine.SDK.Lua.Interop.Api.LuaApi", + "manual": "https://www.lua.org/manual/5.3/manual.html", + "bridgeProof": { + "luaFixtureSha256": "c95dcdfa0f60f97b43d970d77fd1bb907af4de04b500a3c89a99600b20b35bd2", + "bridgeSourceSha256": "3342be23f88976d9209a24bc0d8b9db512482a24d8db90381a836ea4f5595a56", + "xmakeSha256": "2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8", + "checkstackEvidence": "CheckStackFailureProcessTests.Direct_checkstack_growth_with_a_rejecting_allocator_returns_zero_and_recovers", + "verifiedOn": "2026-09-23" + }, + "manualDivergences": [ + { + "member": "luaL_dofile", + "manualClass": "Any", + "sdkClass": "Memory", + "reason": "luaL_dofile is luaL_loadfile followed by lua_pcall: the load can raise only a memory error and the call is protected, so the SDK remark is narrower than the manual." + }, + { + "member": "luaL_setmetatable", + "manualClass": "Never", + "sdkClass": "Memory", + "reason": "Lua 5.3.6 runs luaL_getmetatable (lua_getfield on the registry with a string key, which may create that string) before lua_setmetatable; the SDK remark keeps the allocation the manual omits." + } + ], + "productionExceptions": [ + { + "member": "lua_pushcclosure", + "file": "libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs", + "justification": "PushUncheckedFunction pushes a light C function (zero upvalues) in the statement right after lua_checkstack(Pointer, 1) succeeded; LuaDirectApiBoundaryGuardTests pins that exact shape." + }, + { + "member": "lua_tolstring", + "file": "libs/CheatEngine.SDK.Lua/Calls/LuaError.cs", + "justification": "FromStack reads the error value with lua_tolstring only in its LuaType.String case, so a string is returned as is and nothing is allocated." + }, + { + "member": "lua_tolstring", + "file": "libs/CheatEngine.SDK.Lua/State/LuaState.Read.cs", + "justification": "TryReadUtf8 returns false unless lua_type is LUA_TSTRING, so a string is returned as is, nothing is allocated and the stack slot is never rewritten." + } + ], + "rows": [ + { + "member": "GetMissingExports", + "nativeSymbol": null, + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "Lifecycle", + "bridgeOperation": null, + "condition": null, + "provenance": null + }, + { + "member": "Initialize", + "nativeSymbol": null, + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "Lifecycle", + "bridgeOperation": null, + "condition": null, + "provenance": null + }, + { + "member": "IsInitialized", + "nativeSymbol": null, + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "Lifecycle", + "bridgeOperation": null, + "condition": null, + "provenance": null + }, + { + "member": "ModuleHandle", + "nativeSymbol": null, + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "Lifecycle", + "bridgeOperation": null, + "condition": null, + "provenance": null + }, + { + "member": "TryInitialize", + "nativeSymbol": null, + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "Lifecycle", + "bridgeOperation": null, + "condition": null, + "provenance": null + }, + { + "member": "luaL_callmeta", + "nativeSymbol": "luaL_callmeta", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +(0|1)", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_callmeta", + "marker": "[-0, +(0|1), e]" + } + }, + { + "member": "luaL_dofile", + "nativeSymbol": null, + "raises": "Memory", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +?", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_dofile", + "marker": "[-0, +?, e]" + } + }, + { + "member": "luaL_dostring", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +?", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_dostring", + "marker": "[-0, +?, –]" + } + }, + { + "member": "luaL_getmetafield", + "nativeSymbol": "luaL_getmetafield", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +(0|1)", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_getmetafield", + "marker": "[-0, +(0|1), m]" + } + }, + { + "member": "luaL_getmetatable", + "nativeSymbol": null, + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_getmetatable", + "marker": "[-0, +1, m]" + } + }, + { + "member": "luaL_getsubtable", + "nativeSymbol": "luaL_getsubtable", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_getsubtable", + "marker": "[-0, +1, e]" + } + }, + { + "member": "luaL_len", + "nativeSymbol": "luaL_len", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_len", + "marker": "[-0, +0, e]" + } + }, + { + "member": "luaL_loadbuffer", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_loadbuffer", + "marker": "[-0, +1, –]" + } + }, + { + "member": "luaL_loadbufferx", + "nativeSymbol": "luaL_loadbufferx", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_loadbufferx", + "marker": "[-0, +1, –]" + } + }, + { + "member": "luaL_loadfile", + "nativeSymbol": null, + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_loadfile", + "marker": "[-0, +1, m]" + } + }, + { + "member": "luaL_loadfilex", + "nativeSymbol": "luaL_loadfilex", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_loadfilex", + "marker": "[-0, +1, m]" + } + }, + { + "member": "luaL_loadstring", + "nativeSymbol": "luaL_loadstring", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_loadstring", + "marker": "[-0, +1, –]" + } + }, + { + "member": "luaL_newmetatable", + "nativeSymbol": "luaL_newmetatable", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_newmetatable", + "marker": "[-0, +1, m]" + } + }, + { + "member": "luaL_newstate", + "nativeSymbol": "luaL_newstate", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "n/a", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_newstate", + "marker": "[-0, +0, –]" + } + }, + { + "member": "luaL_openlibs", + "nativeSymbol": "luaL_openlibs", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_openlibs", + "marker": "[-0, +0, e]" + } + }, + { + "member": "luaL_ref", + "nativeSymbol": "luaL_ref", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": "CreateReference", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_ref", + "marker": "[-1, +0, m]" + } + }, + { + "member": "luaL_requiref", + "nativeSymbol": "luaL_requiref", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_requiref", + "marker": "[-0, +1, e]" + } + }, + { + "member": "luaL_setfuncs", + "nativeSymbol": "luaL_setfuncs", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-nup +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_setfuncs", + "marker": "[-nup, +0, m]" + } + }, + { + "member": "luaL_setmetatable", + "nativeSymbol": "luaL_setmetatable", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_setmetatable", + "marker": "[-0, +0, –]" + } + }, + { + "member": "luaL_testudata", + "nativeSymbol": "luaL_testudata", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": true, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_testudata", + "marker": "[-0, +0, m]" + } + }, + { + "member": "luaL_tolstring", + "nativeSymbol": "luaL_tolstring", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": true, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_tolstring", + "marker": "[-0, +1, e]" + } + }, + { + "member": "luaL_traceback", + "nativeSymbol": "luaL_traceback", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_traceback", + "marker": "[-0, +1, m]" + } + }, + { + "member": "luaL_typename", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_typename", + "marker": "[-0, +0, –]" + } + }, + { + "member": "luaL_unref", + "nativeSymbol": "luaL_unref", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": "ReleaseReference", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#luaL_unref", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_absindex", + "nativeSymbol": "lua_absindex", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_absindex", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_arith", + "nativeSymbol": "lua_arith", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-(2|1) +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_arith", + "marker": "[-(2|1), +1, e]" + } + }, + { + "member": "lua_atpanic", + "nativeSymbol": "lua_atpanic", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_atpanic", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_call", + "nativeSymbol": null, + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-(n+1) +r", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_call", + "marker": "[-(nargs+1), +nresults, e]" + } + }, + { + "member": "lua_callk", + "nativeSymbol": "lua_callk", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": true, + "stackEffect": "-(nargs+1) +nresults", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_callk", + "marker": "[-(nargs + 1), +nresults, e]" + } + }, + { + "member": "lua_checkstack", + "nativeSymbol": "lua_checkstack", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_checkstack", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_close", + "nativeSymbol": "lua_close", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "n/a", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_close", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_compare", + "nativeSymbol": "lua_compare", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_compare", + "marker": "[-0, +0, e]" + } + }, + { + "member": "lua_concat", + "nativeSymbol": "lua_concat", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-n +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_concat", + "marker": "[-n, +1, e]" + } + }, + { + "member": "lua_copy", + "nativeSymbol": "lua_copy", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_copy", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_createtable", + "nativeSymbol": "lua_createtable", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": "CreateTable", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_createtable", + "marker": "[-0, +1, m]" + } + }, + { + "member": "lua_dump", + "nativeSymbol": "lua_dump", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_dump", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_error", + "nativeSymbol": "lua_error", + "raises": "Always", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_error", + "marker": "[-1, +0, v]" + } + }, + { + "member": "lua_gc", + "nativeSymbol": "lua_gc", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_gc", + "marker": "[-0, +0, m]" + } + }, + { + "member": "lua_getallocf", + "nativeSymbol": "lua_getallocf", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getallocf", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_getextraspace", + "nativeSymbol": null, + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": true, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getextraspace", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_getfield", + "nativeSymbol": "lua_getfield", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getfield", + "marker": "[-0, +1, e]" + } + }, + { + "member": "lua_getglobal", + "nativeSymbol": "lua_getglobal", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getglobal", + "marker": "[-0, +1, e]" + } + }, + { + "member": "lua_gethook", + "nativeSymbol": "lua_gethook", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_gethook", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_gethookcount", + "nativeSymbol": "lua_gethookcount", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_gethookcount", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_gethookmask", + "nativeSymbol": "lua_gethookmask", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_gethookmask", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_geti", + "nativeSymbol": "lua_geti", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_geti", + "marker": "[-0, +1, e]" + } + }, + { + "member": "lua_getinfo", + "nativeSymbol": "lua_getinfo", + "raises": "Any", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-(0|1) +(0|1|2)", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getinfo", + "marker": "[-(0|1), +(0|1|2), e]" + } + }, + { + "member": "lua_getlocal", + "nativeSymbol": "lua_getlocal", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +(0|1)", + "returnsBorrowedView": true, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getlocal", + "marker": "[-0, +(0|1), –]" + } + }, + { + "member": "lua_getmetatable", + "nativeSymbol": "lua_getmetatable", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +(0|1)", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getmetatable", + "marker": "[-0, +(0|1), –]" + } + }, + { + "member": "lua_getstack", + "nativeSymbol": "lua_getstack", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getstack", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_gettable", + "nativeSymbol": "lua_gettable", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_gettable", + "marker": "[-1, +1, e]" + } + }, + { + "member": "lua_gettop", + "nativeSymbol": "lua_gettop", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_gettop", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_getupvalue", + "nativeSymbol": "lua_getupvalue", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +(0|1)", + "returnsBorrowedView": true, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getupvalue", + "marker": "[-0, +(0|1), –]" + } + }, + { + "member": "lua_getuservalue", + "nativeSymbol": "lua_getuservalue", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_getuservalue", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_insert", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-1 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_insert", + "marker": "[-1, +1, –]" + } + }, + { + "member": "lua_isboolean", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isboolean", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_iscfunction", + "nativeSymbol": "lua_iscfunction", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_iscfunction", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isfunction", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isfunction", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isinteger", + "nativeSymbol": "lua_isinteger", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isinteger", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_islightuserdata", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_islightuserdata", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isnil", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isnil", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isnone", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isnone", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isnoneornil", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isnoneornil", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isnumber", + "nativeSymbol": "lua_isnumber", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isnumber", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isstring", + "nativeSymbol": "lua_isstring", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isstring", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_istable", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_istable", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isthread", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isthread", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isuserdata", + "nativeSymbol": "lua_isuserdata", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isuserdata", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_isyieldable", + "nativeSymbol": "lua_isyieldable", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_isyieldable", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_len", + "nativeSymbol": "lua_len", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_len", + "marker": "[-0, +1, e]" + } + }, + { + "member": "lua_load", + "nativeSymbol": "lua_load", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_load", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_newstate", + "nativeSymbol": "lua_newstate", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "n/a", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_newstate", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_newtable", + "nativeSymbol": null, + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": "CreateTable", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_newtable", + "marker": "[-0, +1, m]" + } + }, + { + "member": "lua_newthread", + "nativeSymbol": "lua_newthread", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": true, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_newthread", + "marker": "[-0, +1, m]" + } + }, + { + "member": "lua_newuserdata", + "nativeSymbol": "lua_newuserdata", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": true, + "decision": "BridgeRequired", + "bridgeOperation": "NewUserdata", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_newuserdata", + "marker": "[-0, +1, m]" + } + }, + { + "member": "lua_next", + "nativeSymbol": "lua_next", + "raises": "Any", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +(2|0)", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_next", + "marker": "[-1, +(2|0), e]" + } + }, + { + "member": "lua_pcall", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-(n+1) +(r|1)", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pcall", + "marker": "[-(nargs + 1), +(nresults|1), –]" + } + }, + { + "member": "lua_pcallk", + "nativeSymbol": "lua_pcallk", + "raises": "Never", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": true, + "stackEffect": "-(nargs+1) +(nresults|1)", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pcallk", + "marker": "[-(nargs + 1), +(nresults|1), –]" + } + }, + { + "member": "lua_pop", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-n +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pop", + "marker": "[-n, +0, –]" + } + }, + { + "member": "lua_pushboolean", + "nativeSymbol": "lua_pushboolean", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushboolean", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_pushcclosure", + "nativeSymbol": "lua_pushcclosure", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-n +1", + "returnsBorrowedView": false, + "decision": "ConditionallyDirect", + "bridgeOperation": "PushClosure", + "condition": "Zero upvalues, immediately after lua_checkstack(L, 1) succeeded on the same path: Lua 5.3.6 then pushes a light C function without allocating.", + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushcclosure", + "marker": "[-n, +1, m]" + } + }, + { + "member": "lua_pushcfunction", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushcfunction", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_pushglobaltable", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushglobaltable", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_pushinteger", + "nativeSymbol": "lua_pushinteger", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushinteger", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_pushlightuserdata", + "nativeSymbol": "lua_pushlightuserdata", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushlightuserdata", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_pushliteral", + "nativeSymbol": null, + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": "PushBytes", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushliteral", + "marker": "[-0, +1, m]" + } + }, + { + "member": "lua_pushlstring", + "nativeSymbol": "lua_pushlstring", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": true, + "decision": "BridgeRequired", + "bridgeOperation": "PushBytes", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushlstring", + "marker": "[-0, +1, m]" + } + }, + { + "member": "lua_pushnil", + "nativeSymbol": "lua_pushnil", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushnil", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_pushnumber", + "nativeSymbol": "lua_pushnumber", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushnumber", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_pushstring", + "nativeSymbol": "lua_pushstring", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": true, + "decision": "BridgeRequired", + "bridgeOperation": "PushBytes", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushstring", + "marker": "[-0, +1, m]" + } + }, + { + "member": "lua_pushthread", + "nativeSymbol": "lua_pushthread", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushthread", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_pushvalue", + "nativeSymbol": "lua_pushvalue", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_pushvalue", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_rawequal", + "nativeSymbol": "lua_rawequal", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rawequal", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_rawget", + "nativeSymbol": "lua_rawget", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-1 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rawget", + "marker": "[-1, +1, –]" + } + }, + { + "member": "lua_rawgeti", + "nativeSymbol": "lua_rawgeti", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rawgeti", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_rawgetp", + "nativeSymbol": "lua_rawgetp", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +1", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rawgetp", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_rawlen", + "nativeSymbol": "lua_rawlen", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rawlen", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_rawset", + "nativeSymbol": "lua_rawset", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-2 +0", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": "RawSet", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rawset", + "marker": "[-2, +0, m]" + } + }, + { + "member": "lua_rawseti", + "nativeSymbol": "lua_rawseti", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": "RawSetIndex", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rawseti", + "marker": "[-1, +0, m]" + } + }, + { + "member": "lua_rawsetp", + "nativeSymbol": "lua_rawsetp", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "BridgeRequired", + "bridgeOperation": "RawSetPointer", + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rawsetp", + "marker": "[-1, +0, m]" + } + }, + { + "member": "lua_register", + "nativeSymbol": null, + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_register", + "marker": "[-0, +0, e]" + } + }, + { + "member": "lua_remove", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_remove", + "marker": "[-1, +0, –]" + } + }, + { + "member": "lua_replace", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_replace", + "marker": "[-1, +0, –]" + } + }, + { + "member": "lua_resume", + "nativeSymbol": "lua_resume", + "raises": "Never", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-? +?", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_resume", + "marker": "[-?, +?, –]" + } + }, + { + "member": "lua_rotate", + "nativeSymbol": "lua_rotate", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_rotate", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_setallocf", + "nativeSymbol": "lua_setallocf", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_setallocf", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_setfield", + "nativeSymbol": "lua_setfield", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_setfield", + "marker": "[-1, +0, e]" + } + }, + { + "member": "lua_setglobal", + "nativeSymbol": "lua_setglobal", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_setglobal", + "marker": "[-1, +0, e]" + } + }, + { + "member": "lua_sethook", + "nativeSymbol": "lua_sethook", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_sethook", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_seti", + "nativeSymbol": "lua_seti", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_seti", + "marker": "[-1, +0, e]" + } + }, + { + "member": "lua_setlocal", + "nativeSymbol": "lua_setlocal", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-(0|1) +0", + "returnsBorrowedView": true, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_setlocal", + "marker": "[-(0|1), +0, –]" + } + }, + { + "member": "lua_setmetatable", + "nativeSymbol": "lua_setmetatable", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_setmetatable", + "marker": "[-1, +0, –]" + } + }, + { + "member": "lua_settable", + "nativeSymbol": "lua_settable", + "raises": "Any", + "mayRunMetamethod": true, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-2 +0", + "returnsBorrowedView": false, + "decision": "CallerProtected", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_settable", + "marker": "[-2, +0, e]" + } + }, + { + "member": "lua_settop", + "nativeSymbol": "lua_settop", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-? +?", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_settop", + "marker": "[-?, +?, –]" + } + }, + { + "member": "lua_setupvalue", + "nativeSymbol": "lua_setupvalue", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-(0|1) +0", + "returnsBorrowedView": true, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_setupvalue", + "marker": "[-(0|1), +0, –]" + } + }, + { + "member": "lua_setuservalue", + "nativeSymbol": "lua_setuservalue", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-1 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_setuservalue", + "marker": "[-1, +0, –]" + } + }, + { + "member": "lua_status", + "nativeSymbol": "lua_status", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_status", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_stringtonumber", + "nativeSymbol": "lua_stringtonumber", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +(0|1)", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_stringtonumber", + "marker": "[-0, +1, –]" + } + }, + { + "member": "lua_toboolean", + "nativeSymbol": "lua_toboolean", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_toboolean", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_tocfunction", + "nativeSymbol": "lua_tocfunction", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_tocfunction", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_tointeger", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_tointeger", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_tointegerx", + "nativeSymbol": "lua_tointegerx", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_tointegerx", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_tolstring", + "nativeSymbol": "lua_tolstring", + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": true, + "decision": "ConditionallyDirect", + "bridgeOperation": null, + "condition": "The value is already a string: lua_type returned LUA_TSTRING on the same path, so nothing is converted, allocated or rewritten.", + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_tolstring", + "marker": "[-0, +0, m]" + } + }, + { + "member": "lua_tonumber", + "nativeSymbol": null, + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_tonumber", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_tonumberx", + "nativeSymbol": "lua_tonumberx", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_tonumberx", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_topointer", + "nativeSymbol": "lua_topointer", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_topointer", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_tostring", + "nativeSymbol": null, + "raises": "Memory", + "mayRunMetamethod": false, + "mayRunFinalizer": true, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": true, + "decision": "ConditionallyDirect", + "bridgeOperation": null, + "condition": "The value is already a string: lua_type returned LUA_TSTRING on the same path, so nothing is converted, allocated or rewritten.", + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_tostring", + "marker": "[-0, +0, m]" + } + }, + { + "member": "lua_tothread", + "nativeSymbol": "lua_tothread", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": true, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_tothread", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_touserdata", + "nativeSymbol": "lua_touserdata", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": true, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_touserdata", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_type", + "nativeSymbol": "lua_type", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_type", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_typename", + "nativeSymbol": "lua_typename", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_typename", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_upvalueid", + "nativeSymbol": "lua_upvalueid", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_upvalueid", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_upvalueindex", + "nativeSymbol": null, + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_upvalueindex", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_upvaluejoin", + "nativeSymbol": "lua_upvaluejoin", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_upvaluejoin", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_version", + "nativeSymbol": "lua_version", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-0 +0", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_version", + "marker": "[-0, +0, –]" + } + }, + { + "member": "lua_xmove", + "nativeSymbol": "lua_xmove", + "raises": "Never", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": "-? +? (across two stacks)", + "returnsBorrowedView": false, + "decision": "DirectAllowed", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#lua_xmove", + "marker": "[-?, +?, –]" + } + }, + { + "member": "luaopen_base", + "nativeSymbol": "luaopen_base", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_coroutine", + "nativeSymbol": "luaopen_coroutine", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_debug", + "nativeSymbol": "luaopen_debug", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_io", + "nativeSymbol": "luaopen_io", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_math", + "nativeSymbol": "luaopen_math", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_os", + "nativeSymbol": "luaopen_os", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_package", + "nativeSymbol": "luaopen_package", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_string", + "nativeSymbol": "luaopen_string", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_table", + "nativeSymbol": "luaopen_table", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + }, + { + "member": "luaopen_utf8", + "nativeSymbol": "luaopen_utf8", + "raises": "NotApplicable", + "mayRunMetamethod": false, + "mayRunFinalizer": false, + "mayYield": false, + "stackEffect": null, + "returnsBorrowedView": false, + "decision": "ForbiddenInSdk", + "bridgeOperation": null, + "condition": null, + "provenance": { + "source": "https://www.lua.org/manual/5.3/manual.html#6", + "marker": "none: a lua_CFunction value that luaL_requiref runs; the manual gives no API marker" + } + } + ] +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Ownership/OwnershipPolicyTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Ownership/OwnershipPolicyTests.cs new file mode 100644 index 00000000..83587689 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Ownership/OwnershipPolicyTests.cs @@ -0,0 +1,112 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Ownership; + +/// +/// Ownership rules of the shipping SDK sources that a type check cannot express: no finalizer ever repairs a forgotten +/// cleanup (audit A08-25), and Cheat Engine's global that deletes every registered symbol is never bound, because it +/// is not a per-plugin cleanup path (audit A14-22). +/// +public sealed class OwnershipPolicyTests +{ + private const string DeleteAllRegisteredSymbols = "deleteAll" + "RegisteredSymbols"; + + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + + // A destructor declaration: '~Name(' at the start of a code line, outside comments. + private static readonly Regex FinalizerDeclaration = new( + "^\\s*~\\s*[A-Za-z_][A-Za-z0-9_]*\\s*\\(", RegexOptions.CultureInvariant, RegexTimeout); + + [Fact] + public void No_shipping_library_declares_a_finalizer() + { + List offenders = []; + foreach (string file in ShippingSources()) + { + offenders.AddRange(FindFinalizers(file, File.ReadAllLines(Absolute(file)))); + } + + Assert.True(offenders.Count == 0, + "A finalizer must never touch Cheat Engine, the GUI or a Lua state: " + string.Join(", ", offenders)); + } + + [Fact] + public void No_shipping_library_binds_delete_all_registered_symbols() + { + List offenders = []; + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*")) + { + bool shippingCode = (file.StartsWith("libs/", StringComparison.Ordinal) || + file.StartsWith("src/", StringComparison.Ordinal)) && + file.EndsWith(".cs", StringComparison.Ordinal); + bool generatorSpec = file.StartsWith("source-generators/", StringComparison.Ordinal) && + file.Contains("/Specs/", StringComparison.Ordinal); + if ((shippingCode || generatorSpec) && + File.ReadAllText(Absolute(file)).Contains(DeleteAllRegisteredSymbols, StringComparison.Ordinal)) + { + offenders.Add(file); + } + } + + Assert.True(offenders.Count == 0, + DeleteAllRegisteredSymbols + " removes every plugin's and script's symbols; bind per-plugin cleanup instead: " + + string.Join(", ", offenders)); + } + + [Fact] + public void Finalizer_scan_finds_a_destructor_and_ignores_comments_and_operators() + { + string[] lines = + [ + "public sealed class Holder", + "{", + "\t~Holder()", + "\t{", + "\t}", + "\t// ~Commented() is not a declaration", + "\t/// ", + "\tint Flip(int value) => ~value;", + "}" + ]; + + Assert.Equal(["libs/Sample/Holder.cs:3"], FindFinalizers("libs/Sample/Holder.cs", lines)); + } + + private static IEnumerable ShippingSources() + { + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*.cs")) + { + if (file.StartsWith("libs/", StringComparison.Ordinal) || file.StartsWith("src/", StringComparison.Ordinal)) + { + yield return file; + } + } + } + + private static List FindFinalizers(string file, string[] lines) + { + List hits = []; + for (int index = 0; index < lines.Length; index++) + { + string line = lines[index]; + if (line.TrimStart().StartsWith("//", StringComparison.Ordinal)) + { + continue; + } + + if (FinalizerDeclaration.IsMatch(line)) + { + hits.Add(file + ":" + (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + } + + return hits; + } + + private static string Absolute(string relative) + { + return Path.Combine(RepositoryRoot.Path, relative); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/EnumContractTests.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/EnumContractTests.cs new file mode 100644 index 00000000..8b15f7a4 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/EnumContractTests.cs @@ -0,0 +1,245 @@ +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// Enum contracts, read from the PublicAPI files (an enum member is the line T.M = <int> -> T). With +/// RS0016/RS0017 enforced by the build, a PublicAPI line cannot drift from the code, so these text checks freeze +/// values: enums that mirror Cheat Engine constants or appear in public Client signatures keep their 1.0.0 members, +/// every enum added since 1.0.0 is classified, and SDK-owned status/outcome enums do not read as success when a value +/// was never assigned (Unknown = 0 hygiene, audit A11-20). +/// +public sealed class EnumContractTests +{ + /// + /// Frozen at their 1.0.0 members. The nine Enums/* types mirror Cheat Engine constants (their values are + /// also pinned by tests/CheatEngine.SDK.Engine.Tests/Enums/EnumValueTests.cs); CheatEngineArchitecture + /// and TargetAbi are SDK decodings of Cheat Engine codes that appear in public Client signatures and that no + /// other test pins numerically. + /// + private static readonly SortedSet s_frozenEnums = new(StringComparer.Ordinal) + { + "CheatEngine.SDK.Engine.Enums.BreakpointMethod", + "CheatEngine.SDK.Engine.Enums.BreakpointTrigger", + "CheatEngine.SDK.Engine.Enums.ContinueMethod", + "CheatEngine.SDK.Engine.Enums.DuplicateHandling", + "CheatEngine.SDK.Engine.Enums.FastScanMethod", + "CheatEngine.SDK.Engine.Enums.MemoryProtection", + "CheatEngine.SDK.Engine.Enums.RoundingType", + "CheatEngine.SDK.Engine.Enums.ScanOption", + "CheatEngine.SDK.Engine.Enums.VariableType", + "CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.SDK.Engine.Runtime.TargetAbi" + }; + + /// Reviewed additions to a frozen enum (for example a new Cheat Engine constant), as "Type.Member". Empty. + private static readonly HashSet s_reviewedFrozenEnumAdditions = new(StringComparer.Ordinal); + + /// Every enum type declared since 1.0.0, with its category. A new enum must be added here. + private static readonly Dictionary s_addedEnums = new(StringComparer.Ordinal) + { + ["CheatEngine.SDK.Abi.Native.DebugEventDecision"] = EnumCategory.AbiDecision, + ["CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy"] = EnumCategory.Policy, + ["CheatEngine.SDK.Engine.AddressList.MemoryRecordActivationOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Assembly.AssemblePreference"] = EnumCategory.Policy, + ["CheatEngine.SDK.Engine.Assembly.AutoAssemblerApplyOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Assembly.AutoAssemblerCheckOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Assembly.AutoAssemblerDisableInfoSnapshotStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Objects.EngineEffectState"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Runtime.CheatEngineOperatingSystem"] = EnumCategory.ReasonOrEvidence, + ["CheatEngine.SDK.Engine.Runtime.TargetBackend"] = EnumCategory.ReasonOrEvidence, + ["CheatEngine.SDK.Engine.Scanning.Aob.AobBoundedScanOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Aob.AobFirstFoundOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone"] = EnumCategory.ReasonOrEvidence, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason"] = EnumCategory.ReasonOrEvidence, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanTerminationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanWaitStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence"] = EnumCategory.ReasonOrEvidence, + ["CheatEngine.SDK.Engine.Targets.TargetReleaseStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy"] = EnumCategory.Policy, + ["CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.Runtime.LuaAdmissionStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.Runtime.LuaThreadAdmission"] = EnumCategory.Policy + }; + + /// + /// Status/outcome enums whose zero member still reads as success, with the lot that renumbers them (a break, + /// declared through CompatibilitySuppressions.xml when the enum shipped, and a PublicAPI change). The list only + /// shrinks: fails once an entry is fixed. + /// + private static readonly Dictionary s_pendingZeroValueFixes = new(StringComparer.Ordinal) + { + }; + + /// Zero-member names that read as "it worked" (or, for a failure-kind enum, "no failure"). + private static readonly HashSet s_successLikeNames = new(StringComparer.Ordinal) + { + "Complete", "Completed", "Done", "None", "Ok", "Released", "Succeeded", "Success", "Successful" + }; + + /// + /// Neutral zero members for a status/outcome enum: new enums use Unknown; the other names are tolerated + /// because enums already use them with the same "nothing established yet" meaning. + /// + private static readonly HashSet s_neutralZeroNames = new(StringComparer.Ordinal) + { + "Unknown", "Unspecified", "Uninitialized", "NotAttempted" + }; + + private enum EnumCategory + { + Unknown = 0, + StatusOrOutcome, + Policy, + ReasonOrEvidence, + AbiDecision + } + + [Fact] + public void Enums_mirroring_cheat_engine_constants_or_client_signatures_keep_their_1_0_0_members() + { + Dictionary> shipped = EnumMembers(static l => l.Shipped); + foreach (string frozen in s_frozenEnums) + { + Assert.True(shipped.ContainsKey(frozen), $"{frozen} is not an enum of the 1.0.0 surface."); + } + + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + foreach (string removed in library.Removed) + { + Assert.False(IsMemberOfFrozenEnum(removed, out string enumType), + $"{library.UnshippedPath}: '*REMOVED*{removed}' changes the frozen enum {enumType}."); + } + + foreach (string added in library.Added) + { + if (IsMemberOfFrozenEnum(added, out string enumType) + && PublicApiDeclarations.TryParseEnumMember(added, out _, out string member, out _)) + { + Assert.True(s_reviewedFrozenEnumAdditions.Contains($"{enumType}.{member}"), + $"{library.UnshippedPath}: '{added}' adds a member to the frozen enum {enumType} without a reviewed entry."); + } + } + } + } + + [Fact] + public void Every_enum_added_after_1_0_0_is_classified() + { + Dictionary> shipped = EnumMembers(static l => l.Shipped); + SortedSet added = new(StringComparer.Ordinal); + foreach (string enumType in EnumMembers(static l => l.Declared).Keys) + { + if (!shipped.ContainsKey(enumType)) + { + added.Add(enumType); + } + } + + SortedSet classified = new(s_addedEnums.Keys, StringComparer.Ordinal); + Assert.True(added.SetEquals(classified), + $"Classify every enum declared since 1.0.0 in {nameof(s_addedEnums)}. Missing: {string.Join(", ", added.Except(classified, StringComparer.Ordinal))}. Stale: {string.Join(", ", classified.Except(added, StringComparer.Ordinal))}."); + Assert.DoesNotContain(EnumCategory.Unknown, s_addedEnums.Values); + } + + [Fact] + public void Status_and_outcome_enums_added_after_1_0_0_do_not_default_to_success() + { + Dictionary> declared = EnumMembers(static l => l.Declared); + foreach ((string enumType, EnumCategory category) in s_addedEnums) + { + if (category != EnumCategory.StatusOrOutcome || s_pendingZeroValueFixes.ContainsKey(enumType)) + { + continue; + } + + string? zero = ZeroMember(declared[enumType]); + Assert.True(zero is not null, $"{enumType} has no member with value 0, so default({SimpleName(enumType)}) has no name."); + Assert.False(s_successLikeNames.Contains(zero), $"{enumType}.{zero} = 0 reads as success."); + Assert.True(s_neutralZeroNames.Contains(zero), + $"{enumType}.{zero} = 0: a status/outcome enum starts with Unknown = 0 (tolerated: {string.Join(", ", s_neutralZeroNames)})."); + } + } + + [Fact] + public void Pending_zero_value_fixes_are_still_needed() + { + Dictionary> declared = EnumMembers(static l => l.Declared); + foreach ((string enumType, string owner) in s_pendingZeroValueFixes) + { + Assert.True(s_addedEnums.TryGetValue(enumType, out EnumCategory category) && category == EnumCategory.StatusOrOutcome, + $"{enumType} is pending but not classified as {EnumCategory.StatusOrOutcome}."); + Assert.False(string.IsNullOrWhiteSpace(owner)); + string? zero = declared.TryGetValue(enumType, out SortedDictionary? members) ? ZeroMember(members) : null; + Assert.True(zero is not null && s_successLikeNames.Contains(zero), + $"{enumType} no longer starts with a success-like member ({zero ?? "no zero member"}): {owner} fixed it, so remove it from {nameof(s_pendingZeroValueFixes)}."); + } + } + + private static bool IsMemberOfFrozenEnum(string line, out string enumType) + { + if (PublicApiDeclarations.TryParseEnumMember(line, out enumType, out _, out _)) + { + return s_frozenEnums.Contains(enumType); + } + + enumType = ""; + return false; + } + + private static Dictionary> EnumMembers( + Func> lines) + { + Dictionary> enums = new(StringComparer.Ordinal); + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + foreach (string line in lines(library)) + { + if (PublicApiDeclarations.TryParseEnumMember(line, out string enumType, out string member, out long value)) + { + if (!enums.TryGetValue(enumType, out SortedDictionary? members)) + { + members = new SortedDictionary(StringComparer.Ordinal); + enums.Add(enumType, members); + } + + members[member] = value; + } + } + } + + return enums; + } + + private static string? ZeroMember(SortedDictionary members) + { + foreach ((string member, long value) in members) + { + if (value == 0) + { + return member; + } + } + + return null; + } + + private static string SimpleName(string typeName) + { + return typeName[(typeName.LastIndexOf('.') + 1)..]; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiDeclarations.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiDeclarations.cs new file mode 100644 index 00000000..3c431de0 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiDeclarations.cs @@ -0,0 +1,194 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// Name-level reading of PublicAPI declaration lines and of ApiCompat documentation ids (DocIds), enough to relate a +/// compatibility suppression to the declaration it concerns. Matching is by declaring type and member name, not by +/// overload: two overloads of one method share a key. Explicit interface implementations are not mapped. +/// +internal static partial class PublicApiDeclarations +{ + private static readonly string[] s_modifiers = + ["abstract", "const", "extern", "new", "override", "readonly", "required", "sealed", "static", "virtual"]; + + /// + /// The declared name of a PublicAPI line: modifiers, the oblivious marker ~, generic argument lists, + /// parameters, the return type and an enum value are dropped. T.M(int x) -> void gives T.M, + /// T.P.get -> int gives T.P.get, T.this[int i].get -> byte gives T.this, + /// static T.operator ==(...) gives T.operator and T.M = 4 -> T gives T.M. + /// + public static string KeyOf(string line) + { + string text = line.StartsWith(PublicApiLibrary.RemovedPrefix, StringComparison.Ordinal) + ? line[PublicApiLibrary.RemovedPrefix.Length..] + : line; + text = text.TrimStart('~'); + bool stripped; + do + { + stripped = false; + foreach (string modifier in s_modifiers) + { + if (text.StartsWith(modifier + " ", StringComparison.Ordinal)) + { + text = text[(modifier.Length + 1)..]; + stripped = true; + } + } + } while (stripped); + + StringBuilder key = new(text.Length); + int depth = 0; + foreach (char character in text) + { + if (character == '<') + { + depth++; + } + else if (character == '>') + { + depth--; + } + else if (depth == 0) + { + if (character is ' ' or '(' or '[') + { + break; + } + + key.Append(character); + } + } + + return key.ToString(); + } + + /// Whether a PublicAPI line declares a type (a bare, possibly generic, type name). + public static bool IsTypeLine(string line) + { + return !line.Contains(" -> ", StringComparison.Ordinal) && !line.Contains('(', StringComparison.Ordinal); + } + + /// The PublicAPI keys (see ) a DocId can name. + public static IReadOnlySet CandidateKeysOf(string docId) + { + (char kind, string type, string member) = Split(docId); + HashSet keys = new(StringComparer.Ordinal); + if (kind == 'T') + { + keys.Add(type); + return keys; + } + + string accessorName = member.Length > 4 ? member[4..] : member; + switch (kind) + { + case 'M' when string.Equals(member, "#ctor", StringComparison.Ordinal): + keys.Add($"{type}.{SimpleName(type)}"); + break; + case 'M' when member.StartsWith("get_", StringComparison.Ordinal) + || member.StartsWith("set_", StringComparison.Ordinal): + AddPropertyKeys(keys, type, accessorName); + break; + case 'M' when member.StartsWith("add_", StringComparison.Ordinal): + keys.Add($"{type}.{accessorName}"); + break; + case 'M' when member.StartsWith("remove_", StringComparison.Ordinal): + keys.Add($"{type}.{member["remove_".Length..]}"); + break; + case 'M' when string.Equals(member, "op_Implicit", StringComparison.Ordinal): + keys.Add($"{type}.implicit"); + break; + case 'M' when string.Equals(member, "op_Explicit", StringComparison.Ordinal): + keys.Add($"{type}.explicit"); + break; + case 'M' when member.StartsWith("op_", StringComparison.Ordinal): + keys.Add($"{type}.operator"); + break; + case 'P': + AddPropertyKeys(keys, type, member); + break; + default: + keys.Add($"{type}.{member}"); + break; + } + + return keys; + } + + /// The full name of the type a DocId declares or belongs to, without generic arity. + public static string ContainingTypeOf(string docId) + { + return Split(docId).Type; + } + + /// + /// Parses an enum member line (T.M = 4 -> T). Constants (const T.C = 6 -> int) do not match + /// because their type differs from the declaring type. + /// + public static bool TryParseEnumMember(string line, out string enumType, out string member, out long value) + { + Match match = EnumMemberLine().Match(line); + if (!match.Success) + { + enumType = member = ""; + value = 0; + return false; + } + + enumType = match.Groups["type"].Value; + member = match.Groups["member"].Value; + value = long.Parse(match.Groups["value"].Value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + + private static void AddPropertyKeys(HashSet keys, string type, string property) + { + keys.Add($"{type}.{property}.get"); + keys.Add($"{type}.{property}.set"); + keys.Add($"{type}.{property}.init"); + if (string.Equals(property, "Item", StringComparison.Ordinal)) + { + keys.Add($"{type}.this"); + } + } + + private static (char Kind, string Type, string Member) Split(string docId) + { + if (docId.Length < 3 || docId[1] != ':') + { + throw new FormatException($"'{docId}' is not a documentation id."); + } + + char kind = docId[0]; + string body = docId[2..]; + int parameters = body.IndexOf('(', StringComparison.Ordinal); + if (parameters >= 0) + { + body = body[..parameters]; + } + + body = GenericArity().Replace(body, ""); + if (kind == 'T') + { + return (kind, body, ""); + } + + int dot = body.LastIndexOf('.'); + return (kind, body[..dot], body[(dot + 1)..]); + } + + private static string SimpleName(string type) + { + return type[(type.LastIndexOf('.') + 1)..]; + } + + [GeneratedRegex(@"`+\d+", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex GenericArity(); + + [GeneratedRegex(@"^(?[\w.]+)\.(?\w+) = (?-?\d+) -> \k$", RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 1000)] + private static partial Regex EnumMemberLine(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiFileTests.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiFileTests.cs new file mode 100644 index 00000000..72644269 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiFileTests.cs @@ -0,0 +1,127 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// Shape of the PublicAPI files of the six shipping libraries. The build enforces their content (RS0016/RS0017, and +/// CESDK9003 when a file is missing); these tests keep the files mergeable by ordinal sort and union and keep +/// PublicAPI.Shipped.txt equal to the published 1.0.0 surface plus explicit *REMOVED* declarations. +/// +public sealed class PublicApiFileTests +{ + private static readonly string[] s_publicApiFileNames = + [PublicApiLibrary.ShippedFileName, PublicApiLibrary.UnshippedFileName]; + + [Fact] + public void Every_shipping_library_has_both_public_api_files() + { + SortedSet libraries = new(PublicApiLibrary.EnumerateLibraryDirectories(), StringComparer.Ordinal); + SortedSet tracked = new(StringComparer.Ordinal); + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + tracked.Add(library.LibraryDirectory); + } + + Assert.Equal(libraries, tracked); + Assert.Equal(6, tracked.Count); + } + + [Fact] + public void Public_api_files_exist_only_next_to_shipping_libraries() + { + SortedSet libraries = new(PublicApiLibrary.EnumerateLibraryDirectories(), StringComparer.Ordinal); + List strays = []; + foreach (string pattern in s_publicApiFileNames) + { + foreach (string file in RepositoryRoot.EnumerateSourceFiles(pattern)) + { + if (!libraries.Contains(file[..file.LastIndexOf('/')])) + { + strays.Add(file); + } + } + } + + Assert.True(strays.Count == 0, $"PublicAPI files outside libs//: {string.Join(", ", strays)}"); + } + + [Fact] + public void Every_public_api_file_starts_with_nullable_enable() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + Assert.True(StartsWithHeader(library.ShippedFile), + $"{library.ShippedPath} must start with '{PublicApiLibrary.Header}'."); + Assert.True(StartsWithHeader(library.UnshippedFile), + $"{library.UnshippedPath} must start with '{PublicApiLibrary.Header}'."); + } + } + + [Fact] + public void Every_public_api_file_is_ordinally_sorted_after_its_header() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + AssertSortedAndDistinct(library.ShippedPath, library.ShippedFile); + AssertSortedAndDistinct(library.UnshippedPath, library.UnshippedFile); + } + } + + [Fact] + public void Shipped_files_never_contain_removed_markers() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + Assert.DoesNotContain(library.Shipped, + static line => line.StartsWith(PublicApiLibrary.RemovedPrefix, StringComparison.Ordinal)); + } + } + + [Fact] + public void Every_removed_line_names_a_line_of_the_shipped_file() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + HashSet shipped = new(library.Shipped, StringComparer.Ordinal); + foreach (string removed in library.Removed) + { + Assert.True(shipped.Contains(removed), + $"{library.UnshippedPath}: '*REMOVED*{removed}' does not repeat a line of {PublicApiLibrary.ShippedFileName} exactly."); + } + } + } + + [Fact] + public void Unshipped_never_redeclares_a_live_shipped_line() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + HashSet shipped = new(library.Shipped, StringComparer.Ordinal); + HashSet removed = new(library.Removed, StringComparer.Ordinal); + foreach (string added in library.Added) + { + Assert.False(shipped.Contains(added) && !removed.Contains(added), + $"{library.UnshippedPath}: '{added}' is already shipped."); + } + } + } + + private static bool StartsWithHeader(IReadOnlyList lines) + { + return lines.Count > 0 && string.Equals(lines[0], PublicApiLibrary.Header, StringComparison.Ordinal); + } + + private static void AssertSortedAndDistinct(string path, IReadOnlyList lines) + { + for (int index = 1; index < lines.Count; index++) + { + Assert.False(string.IsNullOrWhiteSpace(lines[index]), $"{path}:{index + 1} is blank."); + if (index > 1) + { + int order = string.CompareOrdinal(lines[index - 1], lines[index]); + Assert.True(order < 0, + $"{path}:{index + 1} is {(order == 0 ? "a duplicate" : "out of ordinal order")}: '{lines[index]}'."); + } + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs new file mode 100644 index 00000000..7ad821fd --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs @@ -0,0 +1,147 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// The committed PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt pair of one shipping library, in the +/// Microsoft.CodeAnalysis.PublicApiAnalyzers format: a #nullable enable header, then one declaration per line; +/// Unshipped may prefix a line of Shipped with *REMOVED*. +/// +internal sealed class PublicApiLibrary +{ + public const string Header = "#nullable enable"; + public const string RemovedPrefix = "*REMOVED*"; + public const string ShippedFileName = "PublicAPI.Shipped.txt"; + public const string UnshippedFileName = "PublicAPI.Unshipped.txt"; + + private PublicApiLibrary(string libraryDirectory) + { + LibraryDirectory = libraryDirectory; + ShippedFile = ReadLines(ShippedPath); + UnshippedFile = ReadLines(UnshippedPath); + } + + /// Repository-relative directory of the library, for example libs/CheatEngine.SDK.Engine. + public string LibraryDirectory + { + get; + } + + /// The assembly name, which is also the directory name. + public string Name => LibraryDirectory[(LibraryDirectory.LastIndexOf('/') + 1)..]; + + public string ShippedPath => $"{LibraryDirectory}/{ShippedFileName}"; + + public string UnshippedPath => $"{LibraryDirectory}/{UnshippedFileName}"; + + /// Every line of PublicAPI.Shipped.txt, header included. + public IReadOnlyList ShippedFile + { + get; + } + + /// Every line of PublicAPI.Unshipped.txt, header included. + public IReadOnlyList UnshippedFile + { + get; + } + + /// The declarations of the published CheatEngine.SDK 1.0.0 surface (Shipped without its header). + public IEnumerable Shipped => WithoutHeader(ShippedFile); + + /// Declarations added since 1.0.0 (Unshipped lines without the removal marker). + public IEnumerable Added + { + get + { + foreach (string line in WithoutHeader(UnshippedFile)) + { + if (!line.StartsWith(RemovedPrefix, StringComparison.Ordinal)) + { + yield return line; + } + } + } + } + + /// Shipped declarations that no longer exist, with the *REMOVED* marker stripped. + public IEnumerable Removed + { + get + { + foreach (string line in WithoutHeader(UnshippedFile)) + { + if (line.StartsWith(RemovedPrefix, StringComparison.Ordinal)) + { + yield return line[RemovedPrefix.Length..]; + } + } + } + } + + /// The current declared surface: Shipped without the removed declarations, plus the added ones. + public IReadOnlySet Declared + { + get + { + HashSet declared = new(Shipped, StringComparer.Ordinal); + declared.ExceptWith(Removed); + declared.UnionWith(Added); + return declared; + } + } + + /// Every repository-relative libs/<name> directory that holds a project file. + public static IReadOnlyList EnumerateLibraryDirectories() + { + SortedSet directories = new(StringComparer.Ordinal); + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + if (project.StartsWith("libs/", StringComparison.Ordinal)) + { + directories.Add(project[..project.LastIndexOf('/')]); + } + } + + return [.. directories]; + } + + /// Every shipping library that has both files, in ordinal order of directory. + public static IReadOnlyList LoadAll() + { + List libraries = []; + foreach (string directory in EnumerateLibraryDirectories()) + { + if (File.Exists(FullPath($"{directory}/{ShippedFileName}")) + && File.Exists(FullPath($"{directory}/{UnshippedFileName}"))) + { + libraries.Add(new PublicApiLibrary(directory)); + } + } + + return libraries; + } + + private static IEnumerable WithoutHeader(IReadOnlyList lines) + { + for (int index = 0; index < lines.Count; index++) + { + if (index == 0 && string.Equals(lines[index], Header, StringComparison.Ordinal)) + { + continue; + } + + yield return lines[index]; + } + } + + private static string[] ReadLines(string relativePath) + { + return File.ReadAllLines(FullPath(relativePath)); + } + + private static string FullPath(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md new file mode 100644 index 00000000..64b32a5e --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -0,0 +1,188 @@ +# CheatEngine.SDK.Repository.Tests + +## Objective + +Keep the repository's own contracts true: the solution inventory, the CI workflow contract, lock files, the public +API surface and the release workflow. + +## Why it exists + +Repository rules are enforced by C# tests instead of custom scripts, and they stay fast: this project only reads +committed files (solution, workflows, Dependabot configuration, issue forms, community documents). It never builds, +packs or restores. The one exception is one Governance/ test, which starts `pwsh` to run the dependency-submission +workflow's own "submit" step text against a mocked `gh`, offline. + +## How it works + +| Folder | Content | +|--------------------|--------------------------------------------------------------------------------------------------------| +| `Infrastructure/` | `RepositoryRoot` finds `CheatEngine.SDK.slnx` above the test binaries and enumerates source files. | +| `Solution/` | `SolutionInventoryTests` compares the projects on disk with the projects listed in the solution; `QualificationHarnessShapeTests` checks the shape of the two qualification-harness projects. | +| `Toolchain/` | `ToolchainPinTests` reads `global.json`, `Directory.Build.props` and `Directory.Solution.targets`: exact SDK, analysis-level pin, NuGet audit policy. | +| `LockFiles/` | `LockFileTests` mirror the structural checks a locked restore relies on, over the committed `packages.lock.json` files. | +| `PublicApi/` | `PublicApiFileTests` and `EnumContractTests` check the shape of every shipping library's PublicAPI files and the classification of enums added since 1.0.0. | +| `Abi/` | `ClassicSlotRegistryDocumentTests` checks the committed classic slot registry `Abi/TestData/classic-slot-registry.json` (test-owned data, never a top-level `docs/` folder); `NativeAotProfileDocumentTests` checks the NativeAOT restrictions published in `libs/CheatEngine.SDK.Abi/README.md`; `AbiRouteSeparationTests` keeps the managed and classic exported-function tables apart in `libs/**`. | +| `SourceScanning/` | `CSharpCode` blanks the comments and literals of C# source read as text, and `TestMethodTraits` reads the `[Trait("Qualification", …)]` attributes above a named test method, both for the rules in `Abi/` and `LuaBridge/`. | +| `LuaBridge/` | `ProtectedOperationCatalogTests` checks `libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json` against the LF-pinned C bridge source and the failure probe; `LuaInteropPrimitiveMatrixDocumentTests` checks the committed primitive matrix `LuaBridge/TestData/lua-interop-primitives.json` and the raw `LuaApi` uses of `libs/**`. | +| `Workflows/` | `WorkflowContractTests` parse `.github/workflows/*.yml` and the composite actions with YamlDotNet and freeze the CI contract (job ids, the Gate, lint, format, restore, supply-chain jobs). | +| `Release/` | `ReleaseWorkflowContractTests` reads `.github/workflows/release.yml`: the draft-first job chain, tag guards, write scopes, trusted publishing placement and the reserved artifact names. | +| `Governance/` | `GovernanceWorkflowTests` freeze CodeQL, Scorecard, the online zizmor run and dependency submission; `DependabotConfigurationTests` checks `.github/dependabot.yml`; `GovernanceDocumentTests` checks `SECURITY.md`, `CODE_OF_CONDUCT.md`, `.github/CODEOWNERS` and the issue forms. | +| `Generation/` | `GeneratorInventoryTests` pins the reviewed source generator projects, keeps the extended analyzer rules on every Roslyn component and refuses a local Cheat Engine path in their sources. | +| `Ownership/` | `OwnershipPolicyTests` scans the shipping sources: no finalizer, and no binding of Cheat Engine's global that deletes every registered symbol. | + +## Promise + +- Every `*.csproj` on disk is built by CI through the solution, unless it is listed with a reason in + `SolutionInventoryTests` (`Every_project_on_disk_is_in_the_solution_or_explicitly_excluded`). +- The solution lists no missing project and the exclusion list holds no stale entry + (`Every_project_in_the_solution_exists_and_no_exclusion_is_stale`). +- The .NET SDK is pinned exactly: `rollForward: disable`, no prerelease, and an `errorMessage` naming the pinned version + and its install command (`Global_json_requires_the_exact_sdk_with_roll_forward_disabled`, + `Global_json_error_message_names_the_pinned_sdk_version`). +- The analysis level is a release-shaped pin that moves with the SDK major and minor, and no other MSBuild file sets it + (`Analysis_level_is_pinned_to_a_release_not_latest`, `Analysis_level_pin_moves_with_the_pinned_sdk_major_and_minor`, + `No_project_or_props_file_overrides_the_pinned_analysis_level`). +- High and critical NuGet advisories fail every restore, CI solution restores assert that every project was audited, + and advisory suppressions live only in `Directory.Build.props` with a justification and an expiry + (`Nuget_audit_blocks_high_and_critical_advisories_in_every_build`, + `Ci_solution_restores_assert_that_nuget_audit_covered_every_project`, + `Nuget_audit_suppressions_live_in_the_root_props_with_a_justification_and_an_expiry`). +- Each of the six shipping libraries, and nothing else, has both PublicAPI files, each starting with + `#nullable enable` and ordinally sorted without duplicates; Shipped never carries a removal marker, and every + `*REMOVED*` line repeats a Shipped line exactly (`Every_shipping_library_has_both_public_api_files`, + `Public_api_files_exist_only_next_to_shipping_libraries`, `Every_public_api_file_starts_with_nullable_enable`, + `Every_public_api_file_is_ordinally_sorted_after_its_header`, `Shipped_files_never_contain_removed_markers`, + `Every_removed_line_names_a_line_of_the_shipped_file`, `Unshipped_never_redeclares_a_live_shipped_line`). +- Enums that mirror Cheat Engine constants or appear in Client signatures keep their 1.0.0 members, every enum added + since 1.0.0 is classified, and status/outcome enums start with a neutral zero member, except a pending list that can + only shrink (`Enums_mirroring_cheat_engine_constants_or_client_signatures_keep_their_1_0_0_members`, + `Every_enum_added_after_1_0_0_is_classified`, `Status_and_outcome_enums_added_after_1_0_0_do_not_default_to_success`, + `Pending_zero_value_fixes_are_still_needed`). +- Every project, inside or outside the solution, has a lock file in NuGet's version 2 format, ending as NuGet writes it; + Native AOT projects lock their runtime-specific ILCompiler package; no lock resolves a CheatEngine.* package from a feed + (`Every_project_has_a_committed_lock_file`, `Lock_files_parse_and_declare_a_supported_format_version`, + `Every_lock_file_is_version_2_because_every_project_uses_central_package_management`, + `Version_1_lock_files_hold_no_central_transitive_entries`, `Native_aot_projects_lock_the_win_x64_ilcompiler_packages`, + `No_lock_file_resolves_a_cheatengine_package`, `Lock_files_end_without_a_final_newline_as_nuget_writes_them`). +- The required check `CI / Gate` keeps its shape: the caller calls `ci.yml` through job `ci` named `CI`, the gate + job `gate` named `Gate` runs `always()` with no permissions, needs every other job except the advisory allowlist, and + decides from a required result per job; the `ci.yml` jobs, inputs and secret are exactly the contract's + (`Callers_invoke_ci_through_job_ci_named_CI`, `Gate_job_is_named_Gate_runs_always_and_has_no_permissions`, + `Gate_needs_every_other_ci_job_except_the_advisory_allowlist`, `Ci_jobs_match_the_frozen_contract_ids_and_names`, + `Ci_declares_exactly_the_contract_inputs_and_secret`). +- Sonar is required exactly when `SONAR_EXPECTED` says so: the job condition and the gate expression are the same text, + the quality gate is awaited outside push events, and non-product trees are excluded + (`Sonar_condition_equals_the_gate_sonar_expected_expression`, `Sonar_waits_for_the_quality_gate_outside_push_events`, + `Sonar_excludes_non_product_trees_from_analysis_and_coverage`, + `Pull_request_and_main_callers_request_sonar_and_the_release_run_never_does`). +- No workflow listens to `pull_request_target` or `merge_group`, the pull-request workflow filters no path, + main keeps every run and pull requests cancel superseded ones + (`No_workflow_uses_pull_request_target_or_a_merge_group_trigger`, `Pull_request_and_policy_workflows_have_no_path_filters`, + `Main_ci_runs_every_push_to_main_without_a_concurrency_group`, `Pull_request_ci_skips_drafts_and_cancels_superseded_runs`). +- Every job runs on `windows-2025` or `ubuntu-24.04` with a timeout, workflows grant read permissions only at the top + level and the pipeline never elevates, every remote action is pinned to a commit with its version, every checkout drops + its credentials, every native command checks its exit code, no run script interpolates an expression, and the + pipeline scripts run in `pwsh` (`Every_job_has_a_timeout_and_a_pinned_runner_label`, + `Workflows_grant_only_read_permissions_at_the_top_level`, `Pipeline_jobs_never_elevate_permissions`, + `Every_remote_action_is_pinned_to_a_full_sha_with_a_version_comment`, `Every_checkout_disables_credential_persistence`, + `Every_native_command_in_a_workflow_script_checks_its_exit_code`, `No_run_script_interpolates_an_expression`, + `Pipeline_workflows_and_composite_actions_run_scripts_in_pwsh`). +- Every dotnet job installs the pinned SDK through the composite action, every restore is locked, and no job reachable + from a release, Sonar or CodeQL run uses a package cache (`Every_dotnet_job_uses_the_composite_setup_action`, + `Composite_setup_restores_in_locked_mode`, `Every_restore_in_the_pipeline_is_locked`, + `Release_reachable_workflows_never_enable_a_package_cache`, `Sonar_restores_locked_from_nuget_org_before_the_scanner_begins`). +- The Release leg packs before it tests and hands the exact nupkg to the packaging tests, with an inlined pre-publish + sanity check (one package, its nuspec identity, the embedded SBOM and the CI-built native bridge); the Debug leg + excludes packaging tests by trait, never by skip; every module runs once with hang and crash dumps well inside the + job timeout (`Release_leg_packs_before_testing_and_exports_the_exact_nupkg`, + `Debug_leg_excludes_packaging_tests_by_trait_never_by_skip`, `Test_step_runs_every_module_once_with_the_contract_options`, + `Every_test_module_references_the_extensions_the_test_step_uses`, + `Hang_dump_timeout_is_well_below_the_build_test_job_timeout`, `Build_test_runs_both_configurations_without_fail_fast`). +- Artifacts use the reserved names and retentions only, binary logs and dumps are uploaded on failure only and never + from Sonar or release runs, jobs that version a package fetch full history, the Native AOT probes are published, and + the live probe is compiled exactly once and never shipped (`Every_uploaded_artifact_name_is_reserved`, + `Binlogs_are_uploaded_only_on_failure_and_never_from_sonar_or_release`, `Jobs_that_pack_or_test_fetch_full_history`, + `Aot_job_publishes_the_native_aot_probes`, `Live_probe_is_compiled_by_the_ci_solution_build`). +- actionlint and zizmor are pinned by version and checksum, every zizmor exception carries its reason, and the format + job verifies whitespace without a restore (`Lint_job_checks_out_the_repository_and_runs_every_linter`, + `Zizmor_and_actionlint_are_pinned_by_version_and_checksum`, `Every_zizmor_exception_carries_a_justification_comment`, + `Format_job_verifies_whitespace_without_restore`). +- The dependency review never skips and reviews pull requests only, and the lock-file job restores the solution and + every out-of-solution project in locked mode on Windows (its own `--locked-mode` restore is the verification, with + no bespoke script) (`Dependency_review_job_always_runs_and_reviews_only_pull_requests`, + `Dependency_review_configuration_blocks_advisories_and_unreviewed_licenses`, + `Lock_file_job_restores_the_solution_and_every_out_of_solution_project_locked_on_windows`). +- No workflow runs a local qualification runner or generates ApiCompat suppressions + (`No_workflow_references_the_local_qualification_runner`, `No_workflow_passes_ApiCompatGenerateSuppressionFile`). +- The release workflow is draft-first (`verify → ci → attest → draft-release → publish → verify-publication → + finalize-release`), runs for `v*.*.*` tags and manual dry runs without cancelling a run in progress, and calls `ci.yml` + with the tag version, a 90-day retention and no Sonar (`Release_jobs_form_the_draft_first_chain`, + `Release_runs_for_version_tags_and_manual_dry_runs_without_cancelling`, + `Release_calls_ci_with_the_tag_version_ninety_day_retention_and_no_sonar`). +- Publication jobs and every attestation step run only for tags of this repository; the attest job attests the package + provenance and its SPDX 2.2 SBOM extracted from the package's own embedded manifest; only `publish` uses the `nuget` + environment, with the NuGet login right before the push; `id-token`, `attestations` and `contents` write scopes are + limited to the jobs that need them (`Publication_jobs_run_only_for_tags_of_this_repository`, + `Attest_job_attests_the_package_provenance_and_its_spdx_2_2_sbom`, + `Only_the_publish_job_uses_the_nuget_environment_and_nuget_login`, `Id_token_write_is_limited_to_attest_and_publish`, + `Contents_write_is_limited_to_draft_release_and_finalize_release`). +- The release is created as a draft by `draft-release` only and published by `finalize-release` only, after + `verify-publication` polled and byte-compared the nuget.org copy against the attested package, and the package is + checked against `SHA256SUMS` before the push (`Release_is_created_as_a_draft_and_published_only_by_finalize`, + `Publication_is_verified_on_nuget_org_before_the_release_is_published`). +- Release jobs use pinned runners with timeouts, upload only `release-notes` and `attestation-bundles` (never a second + copy of the nupkg), and install the pinned SDK without a package cache where they run `dotnet` + (`Release_jobs_have_a_timeout_and_a_pinned_runner`, `Release_uploads_only_reserved_artifact_names`, + `Release_jobs_that_run_dotnet_install_the_pinned_sdk_and_never_cache_packages`). +- The advisory governance workflows (CodeQL, Scorecard, the online zizmor run, dependency submission) pin every + action, use literal runner labels and timeouts, start read-only and comment every job elevation, never persist + checkout credentials, start every multi-line script with `$ErrorActionPreference = 'Stop'`, check the exit code of + every native command, never use `pull_request_target`/`merge_group`/`workflow_run`, never enable a package cache, + and upload only their reserved artifact names (`GovernanceWorkflowTests`). CodeQL analyses C# from a manual, + traced Release build of the product graph without the compiler server, C/C++ and the workflows without a build. + Scorecard keeps the shape its publication verifier accepts and is the only advisory job besides `release.yml` that + requests an OIDC token. Dependency submission detects on a read-only token with a pinned, hash-verified Component + Detection scan inlined directly in the workflow, and submits from a separate job that is the only governance job + holding `contents: write`, runs no third-party code, and is proven end to end (with `gh` replaced by a recorder) to + refuse a snapshot of another commit, ref or correlator. +- Every Dependabot ecosystem waits at least seven days before proposing a release, the Roslyn pin and the + SDK-implicit packages never move on their own, the `dotnet-sdk` ecosystem ignores new majors, the composite action + directory is covered, and no ecosystem sets a commit prefix (`DependabotConfigurationTests`). +- `SECURITY.md` names private reporting, scope, response targets, supported versions and release verification, and + describes the two committed binaries with their real hash; `CODE_OF_CONDUCT.md` routes reports through private + reporting without an e-mail address; `.github/CODEOWNERS` is the only CODEOWNERS file, starts with `*` and names + known maintainers and existing paths; the compatibility issue form requires the complete support tuple, never + presents a profile as supported or qualified, and no form tells users to edit `ce.runtimeconfig.json` + (`GovernanceDocumentTests`). +- The source generator projects are exactly the reviewed set, every analyzer and generator keeps + `EnforceExtendedAnalyzerRules`, and no component source names `Program Files`, a drive-rooted path or + `celua.txt` (`GeneratorInventoryTests`). +- No shipping library declares a finalizer, and no shipping source or generator spec binds the Cheat Engine global that + deletes every registered symbol; the finalizer scan is self-tested against comments and the bitwise complement + operator (`OwnershipPolicyTests`: `No_shipping_library_declares_a_finalizer`, + `No_shipping_library_binds_delete_all_registered_symbols`, + `Finalizer_scan_finds_a_destructor_and_ignores_comments_and_operators`). +- The committed classic slot registry lists all 159 slots with contiguous x64 offsets, the ten audit divergences and + the nine callback categories, cites `plugin.pas` as authority with the mirrors' hashes, marks no slot qualified, and + is canonically formatted with lowercase hashes and no absolute local path (`ClassicSlotRegistryDocumentTests`). +- The NativeAOT restrictions are published in `libs/CheatEngine.SDK.Abi/README.md` and the packed SDK README before any + support claim, `NativeExportNames` and `PluginType` carry the `FreeLibrary` caveat, and the loader harness never frees + a mapped NativeAOT module (`NativeAotProfileDocumentTests`). +- No production source converts the managed exports table into the classic prefix, casts to + `ExportedFunctionsPrefix*`, or consumes the prefix outside the classic dispatcher and readers + (`AbiRouteSeparationTests`). +- The protected-operation catalogue keeps every rule of the retired PowerShell check that survives the maintainer's + no-custom-scripting pivot: unique ids, opcodes, enums and constants, the bitmap derived from the opcodes, one native + enum value, switch case and mask entry per operation in the LF-pinned bridge source, exclusive direct-call routes; + every operation that can raise names failure evidence, which resolves to a probe marker in the failure probe source + or a Q13-traited test (`ProtectedOperationCatalogTests`). +- The primitive matrix has one sorted row per `LuaApi` member whose decision agrees with its error class, bridge + operation and manual marker; every raw `LuaApi` use in `libs/**` is `DirectAllowed`, `Lifecycle` or a listed + production exception, and that list only shrinks; the pinned Lua fixture and bridge source hashes are the committed + ones (`LuaInteropPrimitiveMatrixDocumentTests`). + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj +``` diff --git a/tests/CheatEngine.SDK.Repository.Tests/Release/ReleaseWorkflow.cs b/tests/CheatEngine.SDK.Repository.Tests/Release/ReleaseWorkflow.cs new file mode 100644 index 00000000..ba38e34c --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Release/ReleaseWorkflow.cs @@ -0,0 +1,192 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Release; + +/// +/// The committed .github/workflows/release.yml as a YAML tree. Keys are read as plain strings (on is +/// never a boolean), needs may be a scalar or a sequence, and expressions are compared after whitespace +/// normalization, so a folded if: reads the same as a one-line one. +/// +internal sealed partial class ReleaseWorkflow +{ + /// The repository-relative path of the release workflow. + public const string RelativePath = ".github/workflows/release.yml"; + + private ReleaseWorkflow(YamlMappingNode root) + { + Root = root; + } + + /// The document's root mapping. + public YamlMappingNode Root + { + get; + } + + /// Parses the committed file. + public static ReleaseWorkflow Load() + { + string path = Path.Combine(RepositoryRoot.Path, RelativePath); + Assert.True(File.Exists(path), $"{RelativePath} does not exist."); + YamlStream stream = []; + using (StreamReader reader = new(path)) + { + stream.Load(reader); + } + + return new ReleaseWorkflow((YamlMappingNode) stream.Documents[0].RootNode); + } + + /// The job ids, in file order. + public List JobIds() + { + return Keys(Mapping(Root, "jobs") ?? throw new InvalidOperationException($"{RelativePath} has no jobs.")); + } + + /// A job that must exist. + public YamlMappingNode Job(string id) + { + YamlMappingNode? job = Mapping(Mapping(Root, "jobs")!, id); + Assert.True(job is not null, $"{RelativePath} has no job '{id}'."); + return job; + } + + /// The needs of a job, a scalar or a sequence, in file order; empty when absent. + public static List Needs(YamlMappingNode job) + { + if (!job.Children.TryGetValue(new YamlScalarNode("needs"), out YamlNode? needs)) + { + return []; + } + + return needs switch + { + YamlScalarNode scalar => [scalar.Value!], + YamlSequenceNode sequence => ScalarValues(sequence), + _ => throw new InvalidOperationException($"{RelativePath}: needs must be a scalar or a sequence.") + }; + } + + /// The steps of a job. + public static List Steps(YamlMappingNode job) + { + List steps = []; + if (Sequence(job, "steps") is { } sequence) + { + foreach (YamlNode step in sequence.Children) + { + steps.Add((YamlMappingNode) step); + } + } + + return steps; + } + + /// The steps of a job whose uses starts with . + public static List StepsUsing(YamlMappingNode job, string actionPrefix) + { + List steps = []; + foreach (YamlMappingNode step in Steps(job)) + { + if (Scalar(step, "uses") is { } uses && uses.StartsWith(actionPrefix, StringComparison.Ordinal)) + { + steps.Add(step); + } + } + + return steps; + } + + /// Every run script of a job, joined with newlines. + public static string RunText(YamlMappingNode job) + { + List scripts = []; + foreach (YamlMappingNode step in Steps(job)) + { + if (Scalar(step, "run") is { } run) + { + scripts.Add(run); + } + } + + return string.Join('\n', scripts); + } + + /// A with input of a step or of a reusable-workflow call, or null. + public static string? With(YamlMappingNode node, string input) + { + return Mapping(node, "with") is { } with ? Scalar(with, input) : null; + } + + /// The job's permissions as scope → value; empty when the job inherits the top-level set. + public static Dictionary Permissions(YamlMappingNode job) + { + Dictionary permissions = new(StringComparer.Ordinal); + if (Mapping(job, "permissions") is { } mapping) + { + foreach (KeyValuePair scope in mapping.Children) + { + permissions.Add(((YamlScalarNode) scope.Key).Value!, ((YamlScalarNode) scope.Value).Value!); + } + } + + return permissions; + } + + /// An expression or script with every run of whitespace collapsed to one space and the ends trimmed. + public static string Normalize(string? value) + { + return value is null ? "" : Whitespace().Replace(value, " ").Trim(); + } + + /// A child scalar's value, or null. + public static string? Scalar(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) && value is YamlScalarNode scalar + ? scalar.Value + : null; + } + + /// A child mapping, or null. + public static YamlMappingNode? Mapping(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) ? value as YamlMappingNode : null; + } + + /// A child sequence, or null. + public static YamlSequenceNode? Sequence(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) ? value as YamlSequenceNode : null; + } + + /// The keys of a mapping, in order. + public static List Keys(YamlMappingNode node) + { + List keys = []; + foreach (YamlNode key in node.Children.Keys) + { + keys.Add(((YamlScalarNode) key).Value!); + } + + return keys; + } + + /// The scalar items of a sequence. + public static List ScalarValues(YamlSequenceNode sequence) + { + List values = []; + foreach (YamlNode item in sequence.Children) + { + values.Add(((YamlScalarNode) item).Value!); + } + + return values; + } + + [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Whitespace(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Release/ReleaseWorkflowContractTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Release/ReleaseWorkflowContractTests.cs new file mode 100644 index 00000000..048717fb --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Release/ReleaseWorkflowContractTests.cs @@ -0,0 +1,347 @@ +using System.Globalization; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Release; + +/// +/// The release workflow is draft-first and compatible with immutable releases (shared contract 1.11, audit ch.21 +/// "Le tuple a qualifier", Checkpoint F): verify → ci → attest → draft-release → publish → verify-publication → +/// finalize-release. Attestations and every asset exist before the draft is published, publication jobs run only +/// for version tags of this repository, the only write tokens live in the jobs that need them, NuGet trusted +/// publishing stays in the publish job with environment nuget, and nothing is ever uploaded to a +/// published release. These tests read the committed YAML only. +/// +public sealed class ReleaseWorkflowContractTests +{ + /// The literal guard of every publication job and attestation step (TAG_GUARD in the lot brief). + private const string TagGuard = + "github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK'"; + + private const string SpdxPredicate = "https://spdx.dev/Document/v2.2"; + + private static readonly (string Id, string Name, string[] Needs)[] s_chain = + [ + ("verify", "Verify tag", []), + ("ci", "CI", ["verify"]), + ("attest", "Attest and assemble release assets", ["verify", "ci"]), + ("draft-release", "Create draft release", ["verify", "attest"]), + ("publish", "Publish to NuGet", ["verify", "draft-release"]), + ("verify-publication", "Verify nuget.org publication", ["verify", "publish"]), + ("finalize-release", "Publish GitHub release", ["verify", "attest", "verify-publication"]) + ]; + + private static readonly string[] s_publicationJobs = ["draft-release", "publish", "verify-publication", "finalize-release"]; + + private static readonly HashSet s_runnerLabels = new(StringComparer.Ordinal) { "windows-2025", "ubuntu-24.04" }; + + [Fact] + public void Release_runs_for_version_tags_and_manual_dry_runs_without_cancelling() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + YamlMappingNode on = ReleaseWorkflow.Mapping(workflow.Root, "on")!; + + Assert.Equal(["push", "workflow_dispatch"], ReleaseWorkflow.Keys(on)); + YamlMappingNode push = ReleaseWorkflow.Mapping(on, "push")!; + Assert.Equal(["tags"], ReleaseWorkflow.Keys(push)); + Assert.Equal(["v*.*.*"], ReleaseWorkflow.ScalarValues(ReleaseWorkflow.Sequence(push, "tags")!)); + + // A second tag run of the same ref waits instead of cancelling a publication half-way. + YamlMappingNode concurrency = ReleaseWorkflow.Mapping(workflow.Root, "concurrency")!; + Assert.Equal("${{ github.workflow }}-${{ github.ref }}", ReleaseWorkflow.Scalar(concurrency, "group")); + Assert.Equal("false", ReleaseWorkflow.Scalar(concurrency, "cancel-in-progress")); + Assert.Equal("read", ReleaseWorkflow.Scalar(ReleaseWorkflow.Mapping(workflow.Root, "permissions")!, "contents")); + Assert.Single(ReleaseWorkflow.Keys(ReleaseWorkflow.Mapping(workflow.Root, "permissions")!)); + } + + [Fact] + public void Release_jobs_form_the_draft_first_chain() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + + Assert.Equal(Ids(), workflow.JobIds(), StringComparer.Ordinal); + foreach ((string id, string name, string[] needs) in s_chain) + { + YamlMappingNode job = workflow.Job(id); + Assert.Equal(name, ReleaseWorkflow.Scalar(job, "name")); + Assert.Equal(needs, ReleaseWorkflow.Needs(job), StringComparer.Ordinal); + } + } + + [Fact] + public void Publication_jobs_run_only_for_tags_of_this_repository() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + + foreach (string id in Ids()) + { + string guard = ReleaseWorkflow.Normalize(ReleaseWorkflow.Scalar(workflow.Job(id), "if")); + if (s_publicationJobs.Contains(id, StringComparer.Ordinal)) + { + Assert.True(string.Equals(TagGuard, guard, StringComparison.Ordinal), $"Job '{id}' must run only when '{TagGuard}', not '{guard}'."); + } + else + { + Assert.True(guard.Length == 0, $"Job '{id}' runs on dry runs too and must not be conditional ('{guard}')."); + } + } + + // The attest job also runs on dry runs, so each of its steps that signs or checks an attestation is guarded. + YamlMappingNode attest = workflow.Job("attest"); + List attestations = ReleaseWorkflow.StepsUsing(attest, "actions/attest@"); + Assert.Equal(2, attestations.Count); + foreach (YamlMappingNode step in ReleaseWorkflow.Steps(attest)) + { + bool signsOrVerifies = attestations.Contains(step) + || (ReleaseWorkflow.Scalar(step, "run") ?? "").Contains("gh attestation verify", StringComparison.Ordinal); + if (signsOrVerifies) + { + Assert.Equal(TagGuard, ReleaseWorkflow.Normalize(ReleaseWorkflow.Scalar(step, "if"))); + } + } + } + + [Fact] + public void Attest_job_attests_the_package_provenance_and_its_spdx_2_2_sbom() + { + YamlMappingNode attest = ReleaseWorkflow.Load().Job("attest"); + List attestations = ReleaseWorkflow.StepsUsing(attest, "actions/attest@"); + + // Provenance first (no sbom-path), then the SBOM predicate, both about the package staged from nuget-package. + Assert.Equal("${{ steps.stage.outputs.package }}", ReleaseWorkflow.With(attestations[0], "subject-path")); + Assert.Null(ReleaseWorkflow.With(attestations[0], "sbom-path")); + Assert.Equal("${{ steps.stage.outputs.package }}", ReleaseWorkflow.With(attestations[1], "subject-path")); + Assert.Equal("${{ steps.stage.outputs.sbom }}", ReleaseWorkflow.With(attestations[1], "sbom-path")); + + string run = ReleaseWorkflow.RunText(attest); + // The SBOM is extracted from the package's own embedded manifest (Microsoft.Sbom.Targets), never a bespoke + // script; checksums and the pull-request lookup are gone with the release-tuple manifest. + Assert.Contains("_manifest/spdx_2.2/manifest.spdx.json", run, StringComparison.Ordinal); + Assert.Contains("--predicate-type', '" + SpdxPredicate, run, StringComparison.Ordinal); + Assert.Contains("--deny-self-hosted-runners", run, StringComparison.Ordinal); + Assert.DoesNotContain("eng/release", run, StringComparison.Ordinal); + Assert.DoesNotContain("tuple", run, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Only_the_publish_job_uses_the_nuget_environment_and_nuget_login() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + + foreach (string id in Ids()) + { + YamlMappingNode job = workflow.Job(id); + bool isPublish = string.Equals(id, "publish", StringComparison.Ordinal); + YamlNode? environment = job.Children.TryGetValue(new YamlScalarNode("environment"), out YamlNode? value) ? value : null; + List logins = ReleaseWorkflow.StepsUsing(job, "NuGet/login@"); + bool pushes = ReleaseWorkflow.RunText(job).Contains("dotnet nuget push", StringComparison.Ordinal); + if (!isPublish) + { + Assert.True(environment is null, $"Job '{id}' uses an environment; only 'publish' may use 'nuget'."); + Assert.True(logins.Count == 0 && !pushes, $"Job '{id}' logs in to or pushes to NuGet; only 'publish' may."); + continue; + } + + // Trusted publishing is bound to release.yml + environment nuget, and the temporary key lives one hour, so the + // login is the step right before the push. + Assert.Equal("nuget", ReleaseWorkflow.Scalar((YamlMappingNode) environment!, "name")); + YamlMappingNode login = Assert.Single(logins); + Assert.Equal("${{ secrets.NUGET_USER }}", ReleaseWorkflow.With(login, "user")); + List steps = ReleaseWorkflow.Steps(job); + int loginIndex = steps.IndexOf(login); + Assert.True(loginIndex + 1 < steps.Count, "The NuGet login must be followed by the push."); + Assert.Contains("dotnet nuget push", ReleaseWorkflow.Scalar(steps[loginIndex + 1], "run") ?? "", StringComparison.Ordinal); + Assert.Equal("${{ steps.login.outputs.NUGET_API_KEY }}", + ReleaseWorkflow.Scalar(ReleaseWorkflow.Mapping(steps[loginIndex + 1], "env")!, "NUGET_API_KEY")); + } + } + + [Fact] + public void Id_token_write_is_limited_to_attest_and_publish() + { + // finalize-release only flips the draft to published and re-verifies what nuget.org and the release already + // carry: it signs nothing (the release-tuple manifest it used to attest is gone), so it needs no OIDC token. + AssertWriteScope("id-token", ["attest", "publish"]); + AssertWriteScope("attestations", ["attest"]); + } + + [Fact] + public void Contents_write_is_limited_to_draft_release_and_finalize_release() + { + AssertWriteScope("contents", ["draft-release", "finalize-release"]); + + // No job holds a write scope beyond the ones the chain needs. + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + foreach (string id in Ids()) + { + foreach ((string scope, string value) in ReleaseWorkflow.Permissions(workflow.Job(id))) + { + Assert.True(!string.Equals(value, "write", StringComparison.Ordinal) || scope is "contents" or "id-token" or "attestations", + $"Job '{id}' grants '{scope}: write'."); + } + } + } + + [Fact] + public void Release_is_created_as_a_draft_and_published_only_by_finalize() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + + foreach (string id in Ids()) + { + string run = ReleaseWorkflow.RunText(workflow.Job(id)); + bool creates = run.Contains("gh release create", StringComparison.Ordinal); + bool edits = run.Contains("gh release edit", StringComparison.Ordinal); + bool clobbers = run.Contains("--clobber", StringComparison.Ordinal) && run.Contains("gh release upload", StringComparison.Ordinal); + bool publishes = run.Contains("--draft=false", StringComparison.Ordinal); + Assert.True(!creates || id is "draft-release", $"Job '{id}' creates a release; only 'draft-release' may."); + Assert.True(!edits || id is "finalize-release", $"Job '{id}' edits a release; only 'finalize-release' may."); + Assert.True(!publishes || id is "finalize-release", $"Job '{id}' publishes the release; only 'finalize-release' may."); + Assert.True(!clobbers || id is "draft-release" or "finalize-release", $"Job '{id}' replaces a release asset."); + Assert.DoesNotContain("gh release delete", run, StringComparison.Ordinal); + } + + string draft = ReleaseWorkflow.RunText(workflow.Job("draft-release")); + Assert.Contains("gh release create $env:TAG @options @files", draft, StringComparison.Ordinal); + Assert.Contains("@('--draft', '--verify-tag'", draft, StringComparison.Ordinal); + // No release-tuple manifest survives to be replaced: a published release's assets must already match exactly. + Assert.Contains("'AlreadyPublished'", draft, StringComparison.Ordinal); + Assert.DoesNotContain("--clobber", draft, StringComparison.Ordinal); + + string finalize = ReleaseWorkflow.RunText(workflow.Job("finalize-release")); + Assert.Contains("gh release edit $env:TAG --draft=false", finalize, StringComparison.Ordinal); + Assert.Contains("gh release verify $env:TAG", finalize, StringComparison.Ordinal); + Assert.Contains("gh release verify-asset $env:TAG", finalize, StringComparison.Ordinal); + Assert.DoesNotContain("tuple", finalize, StringComparison.OrdinalIgnoreCase); + + // Publishing runs only while the release is still a draft. + YamlMappingNode publishStep = Assert.Single(ReleaseWorkflow.Steps(workflow.Job("finalize-release")), + static step => (ReleaseWorkflow.Scalar(step, "run") ?? "").Contains("--draft=false", StringComparison.Ordinal)); + Assert.Equal("steps.state.outputs.draft == 'true'", ReleaseWorkflow.Normalize(ReleaseWorkflow.Scalar(publishStep, "if"))); + } + + [Fact] + public void Publication_is_verified_on_nuget_org_before_the_release_is_published() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + + // Polls the flat container, verifies the repository signature, then compares every zip entry byte for byte + // against the attested package (no bespoke eng/release script). + string verification = ReleaseWorkflow.RunText(workflow.Job("verify-publication")); + Assert.Contains("PackageBaseAddress/3.0.0", verification, StringComparison.Ordinal); + Assert.Contains("dotnet nuget verify --all", verification, StringComparison.Ordinal); + Assert.Contains(".signature.p7s", verification, StringComparison.Ordinal); + Assert.DoesNotContain("eng/release", verification, StringComparison.Ordinal); + + YamlMappingNode finalize = workflow.Job("finalize-release"); + Assert.DoesNotContain("tuple", ReleaseWorkflow.RunText(finalize), StringComparison.OrdinalIgnoreCase); + + // The publish job pushes only the file SHA256SUMS of the draft lists. + string publish = ReleaseWorkflow.RunText(workflow.Job("publish")); + Assert.True(publish.IndexOf("SHA256SUMS", StringComparison.Ordinal) < publish.IndexOf("dotnet nuget push", StringComparison.Ordinal), + "The publish job must check the package against SHA256SUMS before pushing it."); + } + + [Fact] + public void Release_calls_ci_with_the_tag_version_ninety_day_retention_and_no_sonar() + { + YamlMappingNode ci = ReleaseWorkflow.Load().Job("ci"); + + Assert.Equal("./.github/workflows/ci.yml", ReleaseWorkflow.Scalar(ci, "uses")); + YamlMappingNode with = ReleaseWorkflow.Mapping(ci, "with")!; + Assert.Equal(["package-version", "package-retention-days"], ReleaseWorkflow.Keys(with)); + Assert.Equal("${{ needs.verify.outputs.version }}", ReleaseWorkflow.Scalar(with, "package-version")); + Assert.Equal("90", ReleaseWorkflow.Scalar(with, "package-retention-days")); + Assert.Null(ReleaseWorkflow.Mapping(ci, "secrets")); + Assert.Null(ReleaseWorkflow.Scalar(ci, "secrets")); + } + + [Fact] + public void Release_jobs_have_a_timeout_and_a_pinned_runner() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + + foreach (string id in Ids()) + { + YamlMappingNode job = workflow.Job(id); + if (ReleaseWorkflow.Scalar(job, "uses") is not null) + { + continue; // GitHub rejects runs-on and timeout-minutes on a reusable-workflow call. + } + + string? runsOn = ReleaseWorkflow.Scalar(job, "runs-on"); + Assert.True(runsOn is not null && s_runnerLabels.Contains(runsOn), $"Job '{id}' runs on '{runsOn}'."); + Assert.True(int.TryParse(ReleaseWorkflow.Scalar(job, "timeout-minutes"), NumberStyles.None, CultureInfo.InvariantCulture, out int minutes) + && minutes is > 0 and <= 60, + $"Job '{id}' needs a timeout-minutes between 1 and 60."); + } + } + + [Fact] + public void Release_uploads_only_reserved_artifact_names() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + List uploaded = []; + + foreach (string id in Ids()) + { + foreach (YamlMappingNode step in ReleaseWorkflow.StepsUsing(workflow.Job(id), "actions/upload-artifact@")) + { + uploaded.Add(ReleaseWorkflow.With(step, "name")!); + Assert.Equal("90", ReleaseWorkflow.With(step, "retention-days")); + Assert.Equal("error", ReleaseWorkflow.With(step, "if-no-files-found")); + } + } + + // The nupkg travels only in ci.yml's nuget-package artifact: attestation-bundles never carries a second copy. + Assert.Equal(["release-notes", "attestation-bundles"], uploaded); + YamlMappingNode bundles = Assert.Single(ReleaseWorkflow.StepsUsing(workflow.Job("attest"), "actions/upload-artifact@")); + Assert.Contains("!artifacts/release/*.nupkg", ReleaseWorkflow.With(bundles, "path"), StringComparison.Ordinal); + } + + [Fact] + public void Release_jobs_that_run_dotnet_install_the_pinned_sdk_and_never_cache_packages() + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + + foreach (string id in Ids()) + { + YamlMappingNode job = workflow.Job(id); + Assert.Empty(ReleaseWorkflow.StepsUsing(job, "actions/cache")); + foreach (YamlMappingNode setup in ReleaseWorkflow.StepsUsing(job, "./.github/actions/setup-dotnet")) + { + Assert.True(ReleaseWorkflow.With(setup, "cache") is null or "false", $"Job '{id}' enables a package cache."); + } + + bool needsDotnet = id is "publish" or "verify-publication"; + Assert.Equal(needsDotnet, ReleaseWorkflow.StepsUsing(job, "./.github/actions/setup-dotnet").Count == 1); + } + } + + private static string[] Ids() + { + string[] ids = new string[s_chain.Length]; + for (int index = 0; index < s_chain.Length; index++) + { + ids[index] = s_chain[index].Id; + } + + return ids; + } + + private static void AssertWriteScope(string scope, string[] allowed) + { + ReleaseWorkflow workflow = ReleaseWorkflow.Load(); + List granted = []; + foreach (string id in Ids()) + { + if (ReleaseWorkflow.Permissions(workflow.Job(id)).TryGetValue(scope, out string? value) + && string.Equals(value, "write", StringComparison.Ordinal)) + { + granted.Add(id); + } + } + + Assert.Equal(allowed, granted, StringComparer.Ordinal); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Runtime/RuntimeCapabilitySpecTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Runtime/RuntimeCapabilitySpecTests.cs new file mode 100644 index 00000000..d56429f6 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Runtime/RuntimeCapabilitySpecTests.cs @@ -0,0 +1,231 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Runtime; + +/// +/// Text contracts of the runtime facts: the runtime-capabilities EngineApi spec is a wired, read-only +/// contract: ce77 spec, and the TargetBackend members are exactly the rows of the Engine README backend +/// table, where LocalProcess is the only qualified backend (audit A12-03, A17-18). +/// +public sealed partial class RuntimeCapabilitySpecTests +{ + private const string SpecPath = + "source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/runtime-capabilities.cheatengine-sdk-api.txt"; + + private const string EngineProjectPath = "libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj"; + + private const string TargetBackendPath = "libs/CheatEngine.SDK.Engine/Runtime/TargetBackend.cs"; + + private const string EngineReadmePath = "libs/CheatEngine.SDK.Engine/README.md"; + + /// + /// The read-only globals the runtime observations may call (the allowlist of + /// runtime_probes_never_call_dbk_dbvm_open_process_or_setters), minus getOpenedProcessID, plus the + /// legacy getCEVersion. + /// + private static readonly SortedSet s_readOnlyGlobals = new(StringComparer.Ordinal) + { + "getCEVersion", "isConnectedToCEServer", "targetIs64Bit", "targetIsX86", "targetIsArm", "targetIsAndroid", + "getABI", "getPointerSize", "getCheatEngineFileVersion", "getSystemArchitecture", "cheatEngineIs64Bit", + "getOperatingSystem" + }; + + /// Globals the spec must never bind: they change Cheat Engine or the target, or load a driver. + private static readonly string[] s_forbiddenGlobals = + [ + "setPointerSize", "setAssemblerMode", "openProcess", "openFileAsProcess", "pause", "unpause" + ]; + + [Fact] + public void runtime_capability_spec_uses_the_ce77_contract() + { + Dictionary header = Header(ReadSpec()); + + Assert.Equal("CheatEngine.SDK.Engine.Generated", header["namespace"]); + Assert.Equal("RuntimeCapabilityProbes", header["type"]); + Assert.Equal("ce77", header["contract"]); + Assert.Equal("7.7.0.10621", header["minimum-ce"]); + Assert.Equal("x64", header["architecture"]); + Assert.Equal("unknown", header["thread"]); + Assert.Equal("none", header["ownership"]); + Assert.StartsWith("ExactInstalledFile: ", header["provenance"], StringComparison.Ordinal); + Assert.Contains("AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00", header["provenance"], + StringComparison.Ordinal); + foreach (Dictionary entry in Entries(ReadSpec())) + { + Assert.Equal("none", entry.GetValueOrDefault("nil")); + Assert.Equal("throwing", entry.GetValueOrDefault("form")); + } + } + + [Fact] + public void runtime_capability_spec_binds_only_read_only_runtime_globals() + { + SortedSet bound = new(StringComparer.Ordinal); + foreach (Dictionary entry in Entries(ReadSpec())) + { + bound.Add(entry["global"]); + } + + Assert.Equal( + [ + "cheatEngineIs64Bit", "getABI", "getCEVersion", "getOperatingSystem", "getPointerSize", + "getSystemArchitecture", "isConnectedToCEServer", "targetIs64Bit", "targetIsAndroid", "targetIsArm", + "targetIsX86" + ], + bound); + Assert.Subset(s_readOnlyGlobals, bound); + foreach (string global in bound) + { + Assert.DoesNotContain(global, s_forbiddenGlobals, StringComparer.Ordinal); + Assert.False(global.StartsWith("dbk_", StringComparison.Ordinal), global); + Assert.False(global.StartsWith("dbvm_", StringComparison.Ordinal), global); + } + } + + [Fact] + public void engine_project_generates_the_runtime_capability_spec() + { + XDocument project = XDocument.Load(Path.Combine(RepositoryRoot.Path, EngineProjectPath)); + List specs = []; + foreach (XElement item in project.Descendants("AdditionalFiles")) + { + specs.Add((string?) item.Attribute("Include") ?? ""); + } + + Assert.Contains("../../" + SpecPath, specs, StringComparer.Ordinal); + Assert.Contains( + "../../source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/memory-scalars.cheatengine-sdk-api.txt", + specs, StringComparer.Ordinal); + } + + [Fact] + public void target_backend_members_are_the_readme_backend_rows_and_only_local_process_is_qualified() + { + string source = File.ReadAllText(Path.Combine(RepositoryRoot.Path, TargetBackendPath)); + SortedSet members = new(StringComparer.Ordinal); + foreach (Match member in EnumMemberPattern().Matches(source)) + { + members.Add(member.Groups["name"].Value); + } + + SortedSet rows = new(StringComparer.Ordinal); + List qualified = []; + foreach (string[] cells in BackendTableRows(File.ReadAllLines(Path.Combine(RepositoryRoot.Path, EngineReadmePath)))) + { + string backend = cells[0].Trim('`'); + Assert.True(rows.Add(backend), $"Duplicate backend row {backend}."); + string verdict = cells[^1]; + Assert.True(string.Equals(verdict, "no", StringComparison.Ordinal) || verdict.StartsWith("yes, ", StringComparison.Ordinal), + $"The qualified cell of {backend} is neither 'no' nor 'yes, ': {verdict}"); + if (verdict.StartsWith("yes", StringComparison.Ordinal)) + { + qualified.Add(backend); + } + } + + Assert.Equal(["CEServer", "FileAsProcess", "LocalProcess", "Unknown"], members); + Assert.Equal(members, rows); + Assert.Equal(["LocalProcess"], qualified); + } + + // The rows of the README table whose first header cell is "Backend" and last header cell is "Qualified backend", as + // trimmed cells without the outer pipes; the separator row is skipped and the table ends at the first non-table line. + private static List BackendTableRows(string[] lines) + { + List rows = []; + int header = -1; + for (int index = 0; index < lines.Length; index++) + { + if (!lines[index].StartsWith('|')) + { + continue; + } + + string[] cells = Cells(lines[index]); + if (string.Equals(cells[0], "Backend", StringComparison.Ordinal) + && string.Equals(cells[^1], "Qualified backend", StringComparison.Ordinal)) + { + Assert.Equal(-1, header); + header = index; + } + } + + Assert.True(header >= 0, $"{EngineReadmePath} has no backend table with a 'Qualified backend' column."); + for (int index = header + 2; index < lines.Length && lines[index].StartsWith('|'); index++) + { + rows.Add(Cells(lines[index])); + } + + Assert.NotEmpty(rows); + return rows; + } + + private static string[] Cells(string line) + { + string[] cells = line.Trim().Trim('|').Split('|'); + for (int cell = 0; cell < cells.Length; cell++) + { + cells[cell] = cells[cell].Trim(); + } + + return cells; + } + + [GeneratedRegex(@"^\t(?[A-Z][A-Za-z]*) = \d+,?\r?$", RegexOptions.Multiline | RegexOptions.CultureInvariant, + 1000)] + private static partial Regex EnumMemberPattern(); + + private static string[] ReadSpec() + { + return File.ReadAllLines(Path.Combine(RepositoryRoot.Path, SpecPath)); + } + + // The EngineApi spec format: blocks separated by blank lines, '#' comment lines ignored, 'key: value' lines. + private static List> Blocks(string[] lines) + { + List> blocks = []; + Dictionary? current = null; + foreach (string raw in lines) + { + string line = raw.Trim(); + if (line.StartsWith('#')) + { + continue; + } + + if (line.Length == 0) + { + current = null; + continue; + } + + if (current is null) + { + current = new Dictionary(StringComparer.Ordinal); + blocks.Add(current); + } + + int colon = line.IndexOf(':', StringComparison.Ordinal); + Assert.True(colon > 0, $"Not a 'key: value' line: {line}"); + current[line[..colon]] = line[(colon + 1)..].Trim(); + } + + return blocks; + } + + private static Dictionary Header(string[] lines) + { + return Blocks(lines)[0]; + } + + private static List> Entries(string[] lines) + { + List> blocks = Blocks(lines); + blocks.RemoveAt(0); + Assert.NotEmpty(blocks); + return blocks; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Solution/QualificationHarnessShapeTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Solution/QualificationHarnessShapeTests.cs new file mode 100644 index 00000000..d5cee953 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Solution/QualificationHarnessShapeTests.cs @@ -0,0 +1,128 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Solution; + +/// +/// The two qualification harnesses are compiled by CI through the solution, never run or packed by it: the CE 7.7 +/// live probe is an x64 dynamic-loading plugin, and the qualification target is a Native AOT console program for x64 +/// and x86 that references no SDK project. Static checks of the solution and project files; nothing is built here. +/// +public sealed class QualificationHarnessShapeTests +{ + private const string LiveProbe = "tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj"; + private const string Target = "tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj"; + + [Fact] + public void LiveProbe_is_in_the_solution_as_an_x64_dynamic_loading_plugin_that_never_packs() + { + XElement project = Project(LiveProbe); + + Assert.Contains(LiveProbe, SolutionProjects()); + Assert.Equal("x64", SolutionPlatform(LiveProbe)); + Assert.Equal("x64", Property(project, "Platforms")); + Assert.Equal("x64", Property(project, "PlatformTarget")); + Assert.Equal("true", Property(project, "EnableDynamicLoading")); + Assert.Equal("false", Property(project, "IsPackable")); + Assert.False(Path.GetFileNameWithoutExtension(LiveProbe).EndsWith(".Tests", StringComparison.Ordinal)); + } + + [Fact] + public void QualificationTarget_is_in_the_solution_and_publishes_native_aot_for_x64_and_x86() + { + XElement project = Project(Target); + + Assert.Contains(Target, SolutionProjects()); + Assert.Equal("Exe", Property(project, "OutputType")); + Assert.Equal("true", Property(project, "PublishAot")); + Assert.Equal(["win-x64", "win-x86"], + (Property(project, "RuntimeIdentifiers") ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries)); + Assert.Null(Property(project, "RuntimeIdentifier")); + Assert.True(File.Exists(Absolute("tests/CheatEngine.SDK.QualificationTarget/README.md"))); + } + + [Fact] + public void Qualification_harnesses_are_not_test_modules_and_never_pack() + { + foreach (string path in (string[]) [LiveProbe, Target]) + { + XElement project = Project(path); + + Assert.False(Path.GetFileNameWithoutExtension(path).EndsWith(".Tests", StringComparison.Ordinal), + $"{path}: eng/Tests.props turns every *.Tests project into a test module."); + Assert.Equal("false", Property(project, "IsPackable")); + Assert.Null(Property(project, "IsTestProject")); + Assert.DoesNotContain(project.Descendants("PackageReference"), + static reference => ((string?) reference.Attribute("Include") ?? string.Empty).StartsWith("xunit", + StringComparison.OrdinalIgnoreCase)); + } + } + + [Fact] + public void QualificationTarget_references_no_SDK_project() + { + XElement project = Project(Target); + + Assert.Empty(project.Descendants("ProjectReference")); + Assert.Empty(project.Descendants("PackageReference")); + foreach (string source in Directory.EnumerateFiles( + Absolute("tests/CheatEngine.SDK.QualificationTarget"), "*.cs")) + { + Assert.DoesNotContain("CheatEngine.SDK.", File.ReadAllText(source), StringComparison.Ordinal); + } + } + + private static XElement Project(string path) + { + return XDocument.Load(Absolute(path)).Root ?? throw new InvalidOperationException(path + " has no root element."); + } + + private static string? Property(XElement project, string name) + { + string? value = null; + foreach (XElement group in project.Elements("PropertyGroup")) + { + if (group.Attribute("Condition") is null && group.Element(name) is { } element) + { + value = element.Value.Trim(); + } + } + + return value; + } + + /// The projects listed in CheatEngine.SDK.slnx, repository-relative with forward slashes. + private static HashSet SolutionProjects() + { + HashSet projects = new(StringComparer.Ordinal); + foreach (XElement project in XDocument.Load(RepositoryRoot.SolutionPath).Descendants("Project")) + { + string? path = (string?) project.Attribute("Path"); + if (path is not null) + { + projects.Add(path.Replace('\\', '/')); + } + } + + return projects; + } + + /// The Platform Project mapping of a solution project, or . + private static string? SolutionPlatform(string projectPath) + { + foreach (XElement project in XDocument.Load(RepositoryRoot.SolutionPath).Descendants("Project")) + { + if (string.Equals(((string?) project.Attribute("Path"))?.Replace('\\', '/'), projectPath, + StringComparison.Ordinal)) + { + return (string?) project.Element("Platform")?.Attribute("Project"); + } + } + + return null; + } + + private static string Absolute(string repositoryRelativePath) + { + return Path.Combine(RepositoryRoot.Path, repositoryRelativePath.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs new file mode 100644 index 00000000..521d8ab1 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs @@ -0,0 +1,66 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Solution; + +/// The solution is the single inventory CI builds and tests; a project outside it is never compiled. +public sealed class SolutionInventoryTests +{ + /// Projects deliberately kept out of the solution, with the reason. Adding one is a review decision. + private static readonly Dictionary s_outOfSolution = new(StringComparer.Ordinal) + { + ["tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj"] = + "Native AOT executable probe, restored and published on its own by the CI aot job." + }; + + [Fact] + public void Every_project_on_disk_is_in_the_solution_or_explicitly_excluded() + { + HashSet listed = ReadSolutionProjects(); + List missing = []; + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + if (!listed.Contains(project) && !s_outOfSolution.ContainsKey(project)) + { + missing.Add(project); + } + } + + Assert.True(missing.Count == 0, + $"Add these projects to CheatEngine.SDK.slnx (dotnet sln add) or justify them in {nameof(s_outOfSolution)}: {string.Join(", ", missing)}"); + } + + [Fact] + public void Every_project_in_the_solution_exists_and_no_exclusion_is_stale() + { + HashSet listed = ReadSolutionProjects(); + foreach (string project in listed) + { + Assert.True(File.Exists(Path.Combine(RepositoryRoot.Path, project)), + $"CheatEngine.SDK.slnx lists '{project}', which does not exist."); + } + + foreach (string excluded in s_outOfSolution.Keys) + { + Assert.True(File.Exists(Path.Combine(RepositoryRoot.Path, excluded)), + $"The exclusion '{excluded}' names a project that no longer exists."); + Assert.False(listed.Contains(excluded), + $"'{excluded}' is in the solution now; remove it from {nameof(s_outOfSolution)}."); + } + } + + private static HashSet ReadSolutionProjects() + { + XDocument solution = XDocument.Load(RepositoryRoot.SolutionPath); + HashSet projects = new(StringComparer.Ordinal); + foreach (XElement project in solution.Descendants("Project")) + { + string? path = (string?) project.Attribute("Path"); + if (path is not null) + { + projects.Add(path.Replace('\\', '/')); + } + } + + return projects; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/SourceScanning/CSharpCode.cs b/tests/CheatEngine.SDK.Repository.Tests/SourceScanning/CSharpCode.cs new file mode 100644 index 00000000..5f017403 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/SourceScanning/CSharpCode.cs @@ -0,0 +1,147 @@ +using System.Text; + +namespace CheatEngine.SDK.Repository.Tests.SourceScanning; + +/// +/// Lexical helpers for C# source read as text: the repository tests reference no Roslyn package. Comments, string +/// literals (regular, verbatim, interpolated and raw, interpolation holes included) and character literals can be +/// blanked, so a rule that looks for identifiers never matches documentation or text. +/// +/// +/// This is a heuristic lexer, not a C# parser, and it has two known limits for interpolated strings. First, the code +/// inside an interpolation hole is blanked with the literal, so an identifier used only there, such as a raw +/// LuaApi call in $"{LuaApi.lua_gettop(state)}", is invisible to every scan built on this class. +/// Second, a non-raw interpolated string whose hole contains its own string literal ($"{Name("x")}") ends at +/// the first nested quote, so the rest of that line is split at the wrong places. The first shape does not occur in +/// libs/** today. The second occurs once, in an error message of LuaApi, where the mis-split stays on +/// that line and exposes no identifier a rule looks for. A rule that must see such uses needs a Roslyn-based scan. +/// +internal static class CSharpCode +{ + /// Replaces comments and literals by spaces, keeping every newline so line numbers do not move. + internal static string BlankCommentsAndLiterals(string source) + { + StringBuilder output = new(source.Length); + int index = 0; + while (index < source.Length) + { + int end = EndOfCommentOrLiteral(source, index); + if (end == index) + { + output.Append(source[index]); + index++; + continue; + } + + foreach (char character in source.AsSpan(index, end - index)) + { + output.Append(character == '\n' ? '\n' : ' '); + } + + index = end; + } + + return output.ToString(); + } + + // Returns the end (exclusive) of the comment or literal that starts at index, or index when none starts there. + private static int EndOfCommentOrLiteral(string source, int index) + { + if (StartsWith(source, index, "//")) + { + int newline = source.IndexOf('\n', index); + return newline < 0 ? source.Length : newline; + } + + if (StartsWith(source, index, "/*")) + { + int close = source.IndexOf("*/", index + 2, StringComparison.Ordinal); + return close < 0 ? source.Length : close + 2; + } + + int prefix = index; + while (prefix < source.Length && source[prefix] is '$' or '@') + { + prefix++; + } + + if (prefix < source.Length && source[prefix] == '"') + { + bool verbatim = source.AsSpan(index, prefix - index).Contains('@'); + return EndOfString(source, prefix, verbatim); + } + + return prefix == index && source[index] == '\'' ? EndOfQuoted(source, index, '\'') : index; + } + + private static int EndOfString(string source, int quote, bool verbatim) + { + int quotes = 0; + while (quote + quotes < source.Length && source[quote + quotes] == '"') + { + quotes++; + } + + if (quotes >= 3) + { + string delimiter = new('"', quotes); + int close = source.IndexOf(delimiter, quote + quotes, StringComparison.Ordinal); + return close < 0 ? source.Length : close + quotes; + } + + return verbatim ? EndOfVerbatim(source, quote) : EndOfQuoted(source, quote, '"'); + } + + private static int EndOfVerbatim(string source, int quote) + { + int position = quote + 1; + while (position < source.Length) + { + if (source[position] == '"') + { + if (position + 1 < source.Length && source[position + 1] == '"') + { + position += 2; + continue; + } + + return position + 1; + } + + position++; + } + + return source.Length; + } + + private static int EndOfQuoted(string source, int open, char quote) + { + int position = open + 1; + while (position < source.Length && source[position] != quote && source[position] != '\n') + { + position += source[position] == '\\' ? 2 : 1; + } + + return Math.Min(position + 1, source.Length); + } + + private static bool StartsWith(string source, int index, string value) + { + return string.CompareOrdinal(source, index, value, 0, value.Length) == 0; + } + + /// The 1-based line of in . + internal static int LineOf(string text, int index) + { + int line = 1; + for (int position = 0; position < index; position++) + { + if (text[position] == '\n') + { + line++; + } + } + + return line; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/SourceScanning/TestMethodTraits.cs b/tests/CheatEngine.SDK.Repository.Tests/SourceScanning/TestMethodTraits.cs new file mode 100644 index 00000000..adec4ff9 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/SourceScanning/TestMethodTraits.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Abi; + +namespace CheatEngine.SDK.Repository.Tests.SourceScanning; + +/// +/// A small heuristic reader of [Trait("Qualification", "Qxx")] attributes on a named test method, read as +/// text (no Roslyn package). It looks at the attribute block directly above the method declaration, which is this +/// repository's consistent style: one blank line separates test methods, and every attribute of a method sits on +/// its own line immediately above it. +/// +internal static partial class TestMethodTraits +{ + /// + /// The Qualification trait values declared immediately above in the file at + /// , or when the method does not exist there. + /// + internal static IReadOnlyList? Of(string repositoryRelativePath, string methodName) + { + string source = RepositoryDocument.ReadNormalizedText(repositoryRelativePath); + Match method = MethodDeclaration(methodName).Match(source); + if (!method.Success) + { + return null; + } + + int start = source.LastIndexOf("\n\n", method.Index, StringComparison.Ordinal); + start = start < 0 ? 0 : start + 2; + string attributeBlock = source[start..method.Index]; + return [.. QualificationTrait().Matches(attributeBlock).Select(static match => match.Groups["id"].Value)]; + } + + private static Regex MethodDeclaration(string methodName) + { + // Test methods return void, Task or ValueTask (async or not). + return new Regex($@"\b(?:void|Task|ValueTask)\s+{Regex.Escape(methodName)}\s*\(", RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(1)); + } + + [GeneratedRegex(@"Trait\(\s*""Qualification""\s*,\s*""(?[^""]+)""\s*\)", RegexOptions.CultureInvariant, 1000)] + private static partial Regex QualificationTrait(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs new file mode 100644 index 00000000..5066ab8e --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs @@ -0,0 +1,248 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Toolchain; + +/// +/// The toolchain is pinned exactly: one .NET SDK (global.json with rollForward: disable, required by the +/// committed NuGet lock files because the SDK's implicit ILLink/ILCompiler packages move with its version) and one +/// code-analysis level, so the rule set only changes in a reviewed commit. The build enforces the analysis-level pin +/// with CESDK9004; these tests keep the committed values themselves honest. +/// +public sealed partial class ToolchainPinTests +{ + private const string GlobalJson = "global.json"; + private const string DirectoryBuildProps = "Directory.Build.props"; + private const string AnalysisLevelPinProperty = "_CheatEngineSdkPinnedAnalysisLevel"; + + private static readonly string[] s_msbuildFilePatterns = ["*.csproj", "*.props", "*.targets"]; + + [Fact] + public void Global_json_requires_the_exact_sdk_with_roll_forward_disabled() + { + JsonElement sdk = ReadGlobalJsonSdk(); + + string? version = sdk.GetProperty("version").GetString(); + Assert.NotNull(version); + Assert.Matches(ExactSdkVersion(), version); + Assert.Equal("disable", sdk.GetProperty("rollForward").GetString()); + Assert.Equal(JsonValueKind.False, sdk.GetProperty("allowPrerelease").ValueKind); + } + + [Fact] + public void Global_json_error_message_names_the_pinned_sdk_version() + { + JsonElement sdk = ReadGlobalJsonSdk(); + string version = sdk.GetProperty("version").GetString()!; + + Assert.True(sdk.TryGetProperty("errorMessage", out JsonElement errorMessage), + "global.json must carry sdk.errorMessage so a missing SDK fails with install instructions (.NET 10 SDK feature)."); + string? message = errorMessage.GetString(); + Assert.False(string.IsNullOrWhiteSpace(message)); + Assert.Contains(version, message, StringComparison.Ordinal); + Assert.Contains($"--version {version}", message, StringComparison.Ordinal); + } + + [Fact] + public void Analysis_level_is_pinned_to_a_release_not_latest() + { + XDocument props = XDocument.Load(RepositoryFile(DirectoryBuildProps)); + + string pin = Assert.Single(PropertyValues(props, AnalysisLevelPinProperty)); + Assert.Matches(PinnedAnalysisLevel(), pin); + Assert.DoesNotContain("latest", pin, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("preview", pin, StringComparison.OrdinalIgnoreCase); + + // Every AnalysisLevel assignment in the root props goes through the pin, so CESDK9004 compares like with like. + Assert.All(PropertyValues(props, "AnalysisLevel"), + static value => Assert.Equal($"$({AnalysisLevelPinProperty})", value)); + } + + [Fact] + public void Analysis_level_pin_moves_with_the_pinned_sdk_major_and_minor() + { + string sdkVersion = ReadGlobalJsonSdk().GetProperty("version").GetString()!; + string pin = Assert.Single(PropertyValues(XDocument.Load(RepositoryFile(DirectoryBuildProps)), + AnalysisLevelPinProperty)); + + string sdkMajorMinor = string.Join('.', sdkVersion.Split('.')[..2]); + string pinMajorMinor = pin[..pin.IndexOf('-', StringComparison.Ordinal)]; + Assert.True(string.Equals(sdkMajorMinor, pinMajorMinor, StringComparison.Ordinal), + $"global.json pins SDK {sdkVersion} but Directory.Build.props pins AnalysisLevel {pin}: raise both together."); + } + + [Fact] + public void No_project_or_props_file_overrides_the_pinned_analysis_level() + { + List offenders = []; + foreach (string pattern in s_msbuildFilePatterns) + { + foreach (string file in RepositoryRoot.EnumerateSourceFiles(pattern)) + { + if (string.Equals(file, DirectoryBuildProps, StringComparison.Ordinal)) + { + continue; + } + + XDocument document = XDocument.Load(RepositoryFile(file)); + if (PropertyValues(document, "AnalysisLevel").Count != 0) + { + offenders.Add(file); + } + } + } + + Assert.True(offenders.Count == 0, + $"Only Directory.Build.props may set AnalysisLevel (CESDK9004 also fails the build): {string.Join(", ", offenders)}"); + } + + [Fact] + public void Nuget_audit_blocks_high_and_critical_advisories_in_every_build() + { + XDocument props = XDocument.Load(RepositoryFile(DirectoryBuildProps)); + + Assert.Equal(["true"], PropertyValues(props, "NuGetAudit")); + Assert.Equal(["all"], PropertyValues(props, "NuGetAuditMode")); + Assert.Equal(["low"], PropertyValues(props, "NuGetAuditLevel")); + + foreach (string value in PropertyValues(props, "WarningsNotAsErrors")) + { + HashSet codes = ExpandCodes(props, value); + Assert.DoesNotContain("NU1903", codes); + Assert.DoesNotContain("NU1904", codes); + } + + // Ordinary builds and the dedicated audit run both append the high and critical codes to WarningsAsErrors, so they + // block even in a project that turns TreatWarningsAsErrors off. + List warningsAsErrors = PropertyValues(props, "WarningsAsErrors"); + Assert.Equal(2, warningsAsErrors.Count); + Assert.All(warningsAsErrors, value => + { + Assert.StartsWith("$(WarningsAsErrors);", value, StringComparison.Ordinal); + HashSet codes = ExpandCodes(props, value); + Assert.Contains("NU1903", codes); + Assert.Contains("NU1904", codes); + }); + } + + [Fact] + public void Ci_solution_restores_assert_that_nuget_audit_covered_every_project() + { + // Directory.Solution.targets is imported by the solution metaproject (verified for CheatEngine.SDK.slnx), where + // NuGet's Restore target and its RestoreProjectCount / RestoreProjectsAuditedCount / RestoreSkippedCount outputs live. + XDocument targets = XDocument.Load(RepositoryFile("Directory.Solution.targets")); + + XElement target = Assert.Single(targets.Descendants("Target"), + static t => string.Equals((string?) t.Attribute("AfterTargets"), "Restore", StringComparison.Ordinal)); + Assert.Contains("'$(CI)' == 'true'", (string?) target.Attribute("Condition") ?? "", StringComparison.Ordinal); + XElement error = Assert.Single(target.Elements("Error")); + string condition = (string?) error.Attribute("Condition") ?? ""; + Assert.Contains("$(RestoreProjectCount)", condition, StringComparison.Ordinal); + Assert.Contains("$(RestoreProjectsAuditedCount)", condition, StringComparison.Ordinal); + Assert.Contains("$(RestoreSkippedCount)", condition, StringComparison.Ordinal); + Assert.Equal("CESDK9009", (string?) error.Attribute("Code")); + } + + [Fact] + public void Nuget_audit_suppressions_live_in_the_root_props_with_a_justification_and_an_expiry() + { + List misplaced = []; + foreach (string pattern in s_msbuildFilePatterns) + { + foreach (string file in RepositoryRoot.EnumerateSourceFiles(pattern)) + { + XDocument document = XDocument.Load(RepositoryFile(file)); + foreach (XElement suppression in document.Descendants("NuGetAuditSuppress")) + { + if (!string.Equals(file, DirectoryBuildProps, StringComparison.Ordinal)) + { + misplaced.Add(file); + continue; + } + + string advisory = (string?) suppression.Attribute("Include") ?? ""; + Assert.StartsWith("https://", advisory, StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(MetadataValue(suppression, "Justification")), + $"NuGetAuditSuppress '{advisory}' has no Justification."); + Assert.True(DateOnly.TryParseExact(MetadataValue(suppression, "Expires"), "yyyy-MM-dd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out _), + $"NuGetAuditSuppress '{advisory}' has no Expires date in yyyy-MM-dd form."); + } + } + } + + Assert.True(misplaced.Count == 0, + $"Declare NuGetAuditSuppress only in Directory.Build.props (CESDK9009 also fails restore): {string.Join(", ", misplaced)}"); + } + + internal static string RepositoryFile(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + /// The values of every <PropertyGroup> child element with the given name, in document order. + internal static List PropertyValues(XDocument document, string propertyName) + { + List values = []; + foreach (XElement group in document.Descendants("PropertyGroup")) + { + foreach (XElement property in group.Elements(propertyName)) + { + values.Add(property.Value.Trim()); + } + } + + return values; + } + + /// + /// Splits an MSBuild code list on ;, , and whitespace, expanding $(Name) references to + /// properties defined in the same document (last definition wins); references to anything else stay opaque. + /// + private static HashSet ExpandCodes(XDocument document, string value) + { + HashSet codes = new(StringComparer.OrdinalIgnoreCase); + foreach (string token in value.Split([';', ',', ' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + if (token.StartsWith("$(", StringComparison.Ordinal) && token.EndsWith(')')) + { + string name = token[2..^1]; + List definitions = PropertyValues(document, name); + if (definitions.Count != 0 && !definitions[^1].Contains(token, StringComparison.Ordinal)) + { + codes.UnionWith(ExpandCodes(document, definitions[^1])); + continue; + } + } + + codes.Add(token); + } + + return codes; + } + + private static string? MetadataValue(XElement item, string name) + { + return (string?) item.Attribute(name) ?? item.Element(name)?.Value; + } + + private static JsonElement ReadGlobalJsonSdk() + { + JsonDocumentOptions options = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(RepositoryFile(GlobalJson)), options); + return document.RootElement.GetProperty("sdk").Clone(); + } + + [GeneratedRegex(@"^\d+\.\d+\.\d{3}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ExactSdkVersion(); + + [GeneratedRegex(@"^\d+\.\d+-recommended$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PinnedAnalysisLevel(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/PendingViolation.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/PendingViolation.cs new file mode 100644 index 00000000..ebf3a626 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/PendingViolation.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// A known violation in a file another work item owns, removed from the list together with its fix. +internal sealed record PendingViolation(string File, string Rule, string Subject, string Reason); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/PipelineJob.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/PipelineJob.cs new file mode 100644 index 00000000..0485e6db --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/PipelineJob.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// A ci.yml job of shared contract 1.6. Runner and timeout are null for a reusable-workflow call. +internal sealed record PipelineJob(string Id, string Name, string? RunsOn, int? TimeoutMinutes); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/Violation.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/Violation.cs new file mode 100644 index 00000000..f8eb92c5 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/Violation.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// A rule violation: the file, the subject (usually a job id) and a message naming the fix. +internal sealed record Violation(string File, string Subject, string Message); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs new file mode 100644 index 00000000..6bcb54e6 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs @@ -0,0 +1,175 @@ +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// The frozen CI interface (shared contract section 1): job ids and names, runners, timeouts, the Sonar expectation, +/// the reserved artifact names. Changing a value here is a contract change, reviewed with the workflow it describes. +/// +internal static class WorkflowContract +{ + /// The reusable pipeline. + public const string Pipeline = "ci.yml"; + + /// The reusable Sonar workflow. + public const string Sonar = "sonar.yml"; + + /// The composite action every dotnet job uses. + public const string SetupAction = "./.github/actions/setup-dotnet"; + + /// The composite action file. + public const string SetupActionFile = ".github/actions/setup-dotnet/action.yml"; + + /// The caller job id that, with , produces the required check "CI / Gate". + public const string CallerJobId = "ci"; + + /// The caller job name. + public const string CallerJobName = "CI"; + + /// The gate job id. + public const string GateJobId = "gate"; + + /// The gate job name. + public const string GateJobName = "Gate"; + + /// SONAR_EXPECTED (contract 1.7), whitespace-normalized. + public const string SonarExpected = + "${{ inputs.sonar && github.event_name != 'merge_group' && github.actor != 'dependabot[bot]' && " + + "(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}"; + + /// The environment variable naming the exact nupkg the Release leg packed (contract 1.8). + public const string ExactPackageVariable = "CESDK_PACKAGED_UMBRELLA_NUPKG"; + + /// The xUnit v3 trait the Debug leg filters out: the packaging tests run in the Release leg only. + public const string PackagingTrait = "Category=Packaging"; + + /// The C0-only live probe: compiled by CI, never loaded or run. + public const string LiveProbeProject = "tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj"; + + /// Retention of every binlogs-* artifact. + public const string BinlogRetention = "5"; + + /// The only runner labels a job may use (never a floating -latest label). + public static readonly HashSet RunnerLabels = new(StringComparer.Ordinal) { "windows-2025", "ubuntu-24.04" }; + + /// The workflows that call ci.yml and so produce "CI / Gate". + public static readonly string[] Callers = ["pull-request-ci.yml", "main-ci.yml", "release.yml"]; + + /// The workflows of the pipeline itself, which declare defaults.run.shell: pwsh. + public static readonly string[] PipelineWorkflows = [Pipeline, Sonar, "main-ci.yml", "pull-request-ci.yml"]; + + /// Workflows whose every job is reachable from a release, Sonar or CodeQL run: no package cache there. + public static readonly string[] CacheFreeWorkflows = [Pipeline, Sonar, "codeql.yml", "release.yml"]; + + /// Jobs that may be absent from gate.needs: advisory, continue-on-error: true. + public static readonly HashSet AdvisoryJobs = new(StringComparer.Ordinal) { "client-canary" }; + + /// The jobs of ci.yml after Wave 1 (contract 1.6): id, name, runner and timeout in minutes. + public static readonly PipelineJob[] Jobs = + [ + new("native", "Build native bridge", "windows-2025", 15), + new("build-test", "Build and test (${{ matrix.configuration }})", "windows-2025", 45), + new("aot", "Native AOT publication probe", "windows-2025", 25), + new("sonar", "Sonar", null, null), + new("lint", "Lint", "ubuntu-24.04", 10), + new("format", "Format", "ubuntu-24.04", 10), + new("dependency-review", "Dependency review", "ubuntu-24.04", 10), + new("lock-files", "Lock files", "windows-2025", 15), + new(GateJobId, GateJobName, "ubuntu-24.04", 5) + ]; + + /// Job ids reserved for later waves, accepted with exactly these names once the integrator wires them. + public static readonly Dictionary ReservedJobs = new(StringComparer.Ordinal) + { + ["native-host-emulator"] = "Native host emulator", + ["lua-surface"] = "Lua surface catalogue", + ["client-canary"] = "Client canary (advisory)", + ["examples"] = "Compile examples" + }; + + /// + /// Every artifact name a workflow may upload (contract 1.9, the orchestrator's attestation-bundles, and the + /// names of the advisory governance workflows), with its retention in days, or null where the contract leaves it + /// to the producer. {configuration} is Debug or + /// Release; binlogs-* names are checked by pattern with . + /// + public static readonly Dictionary ReservedArtifacts = new(StringComparer.Ordinal) + { + ["lua-protection-bridge"] = "14", + ["classic-abi-fixture-facts"] = "14", + ["nuget-package"] = "${{ inputs.package-retention-days }}", + ["coverage"] = "7", + ["test-results-{configuration}"] = "7", + ["test-dumps-{configuration}"] = "5", + ["release-notes"] = "90", + ["native-host-emulator"] = "14", + ["lua-surface-report"] = "30", + ["client-canary-report"] = "14", + ["attestation-bundles"] = null, + // Advisory workflow outside the gate: dependency-submission.yml hands its snapshot from the detect job to the + // submit job. + ["dependency-snapshot"] = "5" + }; + + /// + /// Known violations in files other work items own, each with the work that removes it. The list only shrinks: a + /// test fails when an entry no longer matches a violation, so the entry is deleted in the commit that fixes it. + /// + public static readonly PendingViolation[] Pending = []; + + /// Reports minus the pending ones, and pending entries that no longer match. + public static void AssertNoViolations(string rule, IReadOnlyCollection violations) + { + List unexpected = []; + HashSet matched = []; + foreach (Violation violation in violations) + { + PendingViolation? pending = FindPending(rule, violation); + if (pending is null) + { + unexpected.Add(violation.Message); + } + else + { + matched.Add(pending); + } + } + + foreach (PendingViolation pending in Pending) + { + if (string.Equals(pending.Rule, rule, StringComparison.Ordinal) && !matched.Contains(pending)) + { + unexpected.Add( + $"The pending {rule} violation {pending.File} '{pending.Subject}' is fixed: remove it from {nameof(WorkflowContract)}.{nameof(Pending)}."); + } + } + + Assert.True(unexpected.Count == 0, string.Join(Environment.NewLine, unexpected)); + } + + private static PendingViolation? FindPending(string rule, Violation violation) + { + foreach (PendingViolation pending in Pending) + { + if (string.Equals(pending.Rule, rule, StringComparison.Ordinal) && + string.Equals(pending.File, violation.File, StringComparison.Ordinal) && + string.Equals(pending.Subject, violation.Subject, StringComparison.Ordinal)) + { + return pending; + } + } + + return null; + } + + /// Rule names used by . + public static class Rules + { + /// A job without a pinned runner label or a timeout. + public const string Runner = "runner"; + + /// A job that runs dotnet without the composite setup action. + public const string DotnetSetup = "dotnet-setup"; + + /// A native command whose exit code is not checked on the next line. + public const string ExitCode = "exit-code"; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.BuildTest.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.BuildTest.cs new file mode 100644 index 00000000..c167d977 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.BuildTest.cs @@ -0,0 +1,415 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// The build-test and aot jobs of ci.yml (contract 1.8): one build, Pack before Test in Release, the exact package +/// handed to the packaging tests, the Debug trait filter, dumps, the module inventory, binary logs and artifacts. +/// +public sealed partial class WorkflowContractTests +{ + private const string BuildTestJob = "build-test"; + + /// The MTP options of the Test step and the Microsoft.Testing.Extensions package that provides each one. + private static readonly Dictionary s_testOptionExtensions = new(StringComparer.Ordinal) + { + ["--report-trx"] = "Microsoft.Testing.Extensions.TrxReport", + ["--report-gh"] = "Microsoft.Testing.Extensions.GitHubActionsReport", + ["--hangdump"] = "Microsoft.Testing.Extensions.HangDump", + ["--crashdump"] = "Microsoft.Testing.Extensions.CrashDump", + ["--coverage"] = "Microsoft.Testing.Extensions.CodeCoverage" + }; + + [Fact] + public void Build_test_runs_both_configurations_without_fail_fast() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + YamlMappingNode strategy = Assert.IsType(WorkflowFile.Mapping(job.Node, "strategy")); + Assert.Equal("false", WorkflowFile.Scalar(strategy, "fail-fast")); + YamlMappingNode matrix = Assert.IsType(WorkflowFile.Mapping(strategy, "matrix")); + Assert.Equal(["Debug", "Release"], WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(matrix, "configuration")))); + Assert.Equal(["native", "native-host-emulator"], job.Needs()); + + // One build of the whole solution per leg, logged for failure analysis; every later step reuses it. + string build = WorkflowFile.Scalar(job.Step("Build"), "run") ?? ""; + Assert.Contains("dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore", build, StringComparison.Ordinal); + Assert.Contains("\"-bl:artifacts/logs/build-test-$env:CONFIGURATION.binlog\"", build, StringComparison.Ordinal); + } + + [Fact] + public void Release_leg_packs_before_testing_and_exports_the_exact_nupkg() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + int build = job.StepIndex("Build"); + int pack = job.StepIndex("Pack"); + int test = job.StepIndex("Test"); + int verify = job.StepIndex("Verify the tested package is unchanged"); + int upload = job.StepIndex("Upload package"); + Assert.True(build >= 0 && build < pack && pack < test && test < verify && verify < upload, + "build-test must run Build, Pack, Test, then verify and upload the tested package, in that order."); + + YamlMappingNode packStep = job.Steps[pack]; + Assert.Equal("pack", WorkflowFile.Scalar(packStep, "id")); + Assert.Equal("matrix.configuration == 'Release'", WorkflowFile.Scalar(packStep, "if")); + Assert.Equal("${{ inputs.package-version }}", WorkflowJob.Env(packStep, "PACKAGE_VERSION")); + string packRun = WorkflowFile.Scalar(packStep, "run") ?? ""; + Assert.Contains("dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog", + packRun, StringComparison.Ordinal); + + // The packaging tests consume the packed file itself, in the Release leg only, and log its SHA-256. + YamlMappingNode testStep = job.Steps[test]; + Assert.Equal("${{ matrix.configuration == 'Release' && steps.pack.outputs.nupkg || '' }}", + WorkflowJob.Env(testStep, WorkflowContract.ExactPackageVariable)); + Assert.Equal("${{ steps.pack.outputs.sha256 }}", WorkflowJob.Env(testStep, "PACKED_NUPKG_SHA256")); + string testRun = WorkflowFile.Scalar(testStep, "run") ?? ""; + Assert.Contains("$consumed -ne $env:PACKED_NUPKG_SHA256", testRun, StringComparison.Ordinal); + Assert.Contains("Out-File -FilePath $env:GITHUB_STEP_SUMMARY", testRun, StringComparison.Ordinal); + + Assert.Equal("artifacts/nuget/*.nupkg", WorkflowJob.With(job.Steps[upload], "path")); + + // The pre-publish sanity check is inlined in the Pack step (no bespoke eng/ci script): one package, its exact + // name when a version is required, the embedded SBOM and the CI-built native bridge. + Assert.Contains("'_manifest/spdx_2.2/manifest.spdx.json'", packRun, StringComparison.Ordinal); + Assert.Contains("\"nupkg=$($package.FullName)\"", packRun, StringComparison.Ordinal); + Assert.Contains("\"sha256=$sha256\"", packRun, StringComparison.Ordinal); + Assert.Contains("$package.Name -cne \"CheatEngine.SDK.$($env:PACKAGE_VERSION).nupkg\"", packRun, StringComparison.Ordinal); + Assert.Contains("'build/native/cheatengine-sdk-lua-bridge.dll'", packRun, StringComparison.Ordinal); + } + + [Fact] + public void Debug_leg_excludes_packaging_tests_by_trait_never_by_skip() + { + string run = WorkflowFile.Scalar(Pipeline().Job(BuildTestJob).Step("Test"), "run") ?? ""; + + // A skipped test fails the run in both legs; packaging is excluded by an xUnit v3 trait filter instead. + int options = run.IndexOf("$options = @(", StringComparison.Ordinal); + int debug = run.IndexOf("if ($env:CONFIGURATION -eq 'Debug') {", StringComparison.Ordinal); + int release = run.IndexOf("else {", debug + 1, StringComparison.Ordinal); + int failSkips = run.IndexOf("'--fail-skips', 'on'", StringComparison.Ordinal); + int filter = run.IndexOf($"'--filter-not-trait', '{WorkflowContract.PackagingTrait}'", StringComparison.Ordinal); + Assert.True(options >= 0 && options < failSkips && failSkips < debug, + "--fail-skips on must be a common option of both legs."); + Assert.True(debug < filter && filter < release, "The packaging trait filter belongs to the Debug leg only."); + + // No other way to hide a test: no second filter, no ignored exit code, no retries in the required run. + Assert.Equal(1, Occurrences(run, "--filter")); + foreach (string forbidden in new[] { "--ignore-exit-code", "--retry-failed-tests", "TESTINGPLATFORM_EXITCODE_IGNORE" }) + { + Assert.DoesNotContain(forbidden, run, StringComparison.Ordinal); + } + + // The Debug leg keeps the managed ABI comparison against the native fixture mandatory (audit A04-04). + Assert.Contains("$env:CE77_NATIVE_ABI_REQUIRED = 'true'", run, StringComparison.Ordinal); + + // The Debug leg keeps the C2 native host emulator's coexistence evidence mandatory (SDK-COEX-1, A20-Q09-1). + Assert.Contains("$env:CESDK_NATIVE_HOST_EMULATOR_REQUIRED = 'true'", run, StringComparison.Ordinal); + } + + [Fact] + public void Test_step_runs_every_module_once_with_the_contract_options() + { + string run = WorkflowFile.Scalar(Pipeline().Job(BuildTestJob).Step("Test"), "run") ?? ""; + + Assert.Equal(1, Occurrences(run, "dotnet test")); + Assert.Contains("dotnet test @options", run, StringComparison.Ordinal); + foreach (string option in new[] + { + "'--solution', 'CheatEngine.SDK.slnx'", "'--no-build'", "'--results-directory', $env:RESULTS", "'--report-trx'", + "'--report-gh', '--report-gh-groups', 'off'", "'--hangdump', '--hangdump-timeout'", "'--crashdump'", + "'--coverage', '--coverage-output-format', 'xml'" + }) + { + Assert.Contains(option, run, StringComparison.Ordinal); + } + + // SDK 10 passes MTP options directly: a '--' separator would hand them to the wrong parser. + Assert.DoesNotContain("'--',", run, StringComparison.Ordinal); + } + + [Fact] + public void Every_test_module_references_the_extensions_the_test_step_uses() + { + // dotnet test passes every option to every module; a module without the extension fails with exit code 5. + string run = WorkflowFile.Scalar(Pipeline().Job(BuildTestJob).Step("Test"), "run") ?? ""; + XDocument props = XDocument.Parse(ReadRepositoryText("eng/Tests.props")); + HashSet referenced = new(StringComparer.Ordinal); + foreach (XElement group in props.Descendants("ItemGroup")) + { + if (!((string?) group.Attribute("Condition") ?? "").Contains("$(MSBuildProjectName.EndsWith('.Tests'))", StringComparison.Ordinal)) + { + continue; + } + + foreach (XElement reference in group.Elements("PackageReference")) + { + referenced.Add((string?) reference.Attribute("Include") ?? ""); + } + } + + foreach ((string option, string package) in s_testOptionExtensions) + { + Assert.Contains($"'{option}'", run, StringComparison.Ordinal); + Assert.True(referenced.Contains(package), + $"The Test step passes {option}; every *.Tests project needs {package} through eng/Tests.props."); + } + } + + [Fact] + public void Hang_dump_timeout_is_well_below_the_build_test_job_timeout() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + string run = WorkflowFile.Scalar(job.Step("Test"), "run") ?? ""; + Match hang = HangDumpTimeout().Match(run); + Assert.True(hang.Success, "The Test step must pass '--hangdump-timeout', 'm'."); + int hangMinutes = int.Parse(hang.Groups["minutes"].Value, NumberStyles.None, CultureInfo.InvariantCulture); + int jobMinutes = int.Parse(WorkflowFile.Scalar(job.Node, "timeout-minutes") ?? "0", NumberStyles.None, CultureInfo.InvariantCulture); + + // The dump must be written, uploaded and the job reported well before the runner kills it. + Assert.True(hangMinutes > 0 && hangMinutes * 2 <= jobMinutes, + $"--hangdump-timeout {hangMinutes}m must be at most half of build-test's timeout-minutes ({jobMinutes})."); + } + + [Fact] + public void Every_uploaded_artifact_name_is_reserved() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + HashSet names = new(StringComparer.Ordinal); + foreach (WorkflowJob job in workflow.Jobs()) + { + foreach (YamlMappingNode step in job.StepsUsing("actions/upload-artifact@")) + { + string template = WorkflowJob.With(step, "name") ?? ""; + string retention = WorkflowJob.With(step, "retention-days") ?? ""; + foreach (string name in ExpandConfiguration(template)) + { + Assert.True(names.Add(name), $"{workflow.FileName} uploads '{name}' twice; artifact names are unique per run."); + } + + string? expectedRetention = ExpectedRetention(template); + Assert.True(expectedRetention is not null || IsReservedWithoutRetention(template), + $"{job.Location} uploads '{template}', which is not a reserved artifact name (shared contract 1.9)."); + if (expectedRetention is not null) + { + Assert.True(string.Equals(expectedRetention, retention, StringComparison.Ordinal), + $"{job.Location} keeps '{template}' {retention} days; the contract says {expectedRetention}."); + } + } + } + } + } + + [Fact] + public void Binlogs_are_uploaded_only_on_failure_and_never_from_sonar_or_release() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + bool mayLogBuilds = workflow.FileName is not (WorkflowContract.Sonar or "release.yml"); + foreach (WorkflowJob job in workflow.Jobs()) + { + // A binary log records the environment the build saw: never where a token or a signing step lives. + Assert.True(mayLogBuilds || !BinaryLogSwitch().IsMatch(job.RunText()), + $"{job.Location} writes a binary log; sonar.yml and release.yml never do."); + foreach (YamlMappingNode step in job.StepsUsing("actions/upload-artifact@")) + { + string name = WorkflowJob.With(step, "name") ?? ""; + if (name.StartsWith("binlogs-", StringComparison.Ordinal) || name.StartsWith("test-dumps-", StringComparison.Ordinal)) + { + Assert.True(mayLogBuilds, $"{job.Location} uploads '{name}'; sonar.yml and release.yml never do."); + Assert.True(string.Equals(WorkflowFile.Scalar(step, "if"), "failure()", StringComparison.Ordinal), + $"{job.Location} uploads '{name}' outside 'if: failure()'."); + } + } + } + } + + // The jobs that build, pack or publish keep their logs for a failed run. + WorkflowFile pipeline = Pipeline(); + Assert.Single(pipeline.Job(BuildTestJob).StepsUsing("actions/upload-artifact@"), + static step => string.Equals(WorkflowJob.With(step, "name"), "binlogs-build-test-${{ matrix.configuration }}", StringComparison.Ordinal)); + Assert.Single(pipeline.Job("aot").StepsUsing("actions/upload-artifact@"), + static step => string.Equals(WorkflowJob.With(step, "name"), "binlogs-aot", StringComparison.Ordinal)); + Assert.Equal(3, BinaryLogSwitch().Count(pipeline.Job("aot").RunText())); + } + + [Fact] + public void Jobs_that_pack_or_test_fetch_full_history() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + foreach (WorkflowJob job in workflow.Jobs()) + { + string run = job.RunText(); + bool packs = VersionedPack().IsMatch(run) || run.Contains("dotnet-sonarscanner", StringComparison.Ordinal); + bool builds = VersionedBuild().IsMatch(run); + if (!packs && !builds) + { + continue; + } + + // MinVer computes the version from tags and history; a shallow clone packs 0.0.0-alpha.0. + YamlMappingNode checkout = Assert.Single(job.StepsUsing("actions/checkout@")); + bool fullHistory = string.Equals(WorkflowJob.With(checkout, "fetch-depth"), "0", StringComparison.Ordinal); + bool skipsVersioning = !packs && run.Contains("MinVerSkip=true", StringComparison.Ordinal); + Assert.True(fullHistory || skipsVersioning, + $"{job.Location} builds, packs, tests or analyses: check out with fetch-depth: 0 (MinVer)."); + } + } + } + + [Fact] + public void Aot_job_publishes_the_native_aot_probes() + { + WorkflowJob aot = Pipeline().Job("aot"); + Assert.Equal(["native"], aot.Needs()); + + // The three probes restore locked through the composite action; their locks carry the win-x64 ILCompiler. + string[] probes = + [ + "tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj", + "tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj", + "tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj" + ]; + YamlMappingNode setup = Assert.Single(aot.StepsUsing(WorkflowContract.SetupAction)); + Assert.Equal(probes, RestoreTargets(setup), StringComparer.Ordinal); + + // Publication and inspection only: a NativeAOT publish is never presented as a Cheat Engine load (audit A20-07). + string run = aot.RunText(); + Assert.Contains("dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore", run, + StringComparison.Ordinal); + Assert.Contains("$libraryProject = 'tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj'", + run, StringComparison.Ordinal); + Assert.Contains("dotnet publish $libraryProject -c Release --no-restore", run, StringComparison.Ordinal); + Assert.Contains("& $harness --analyze $library", run, StringComparison.Ordinal); + Assert.Contains("lua-protection-bridge", WorkflowJob.With(Assert.Single(aot.StepsUsing("actions/download-artifact@")), "name"), + StringComparison.Ordinal); + } + + [Fact] + public void Live_probe_is_compiled_by_the_ci_solution_build() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + bool inSolution = SolutionProjects().Contains(WorkflowContract.LiveProbeProject); + int explicitStep = job.StepIndex("Compile live probe"); + bool restoredExplicitly = RestoreTargets(Assert.Single(job.StepsUsing(WorkflowContract.SetupAction))) + .Contains(WorkflowContract.LiveProbeProject); + + // C0 only (PR-CQ-60): compiled once per run, by the solution build as soon as the probe is in the solution. + if (inSolution) + { + Assert.True(explicitStep < 0 && !restoredExplicitly, + $"{WorkflowContract.LiveProbeProject} is in CheatEngine.SDK.slnx, so the solution build compiles it: delete the " + + "'Compile live probe' step of build-test and its line in the step's composite restore list."); + } + else + { + Assert.True(explicitStep > job.StepIndex("Build") && restoredExplicitly, + "Until the live probe joins CheatEngine.SDK.slnx, build-test restores it and compiles it in a 'Compile live probe' step."); + YamlMappingNode step = job.Steps[explicitStep]; + Assert.Equal("matrix.configuration == 'Release'", WorkflowFile.Scalar(step, "if")); + Assert.Contains($"dotnet build {WorkflowContract.LiveProbeProject} -c Release --no-restore", + WorkflowFile.Scalar(step, "run"), StringComparison.Ordinal); + } + + // Never loaded, run or shipped by CI: nothing uploads its output. + foreach (WorkflowJob pipelineJob in Pipeline().Jobs()) + { + foreach (YamlMappingNode upload in pipelineJob.StepsUsing("actions/upload-artifact@")) + { + Assert.DoesNotContain("LiveProbe", WorkflowJob.With(upload, "path") ?? "", StringComparison.OrdinalIgnoreCase); + } + } + } + + /// The newline-separated restore input of a composite setup step. + private static List RestoreTargets(YamlMappingNode setupStep) + { + List targets = []; + foreach (string line in (WorkflowJob.With(setupStep, "restore") ?? "").Split('\n')) + { + if (line.Trim().Length > 0) + { + targets.Add(line.Trim()); + } + } + + return targets; + } + + /// The project paths CheatEngine.SDK.slnx lists. + private static HashSet SolutionProjects() + { + HashSet projects = new(StringComparer.Ordinal); + foreach (XElement project in XDocument.Load(RepositoryRoot.SolutionPath).Descendants("Project")) + { + projects.Add(((string?) project.Attribute("Path") ?? "").Replace('\\', '/')); + } + + return projects; + } + + private static int Occurrences(string text, string value) + { + int count = 0; + for (int index = text.IndexOf(value, StringComparison.Ordinal); + index >= 0; + index = text.IndexOf(value, index + value.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } + + private static IEnumerable ExpandConfiguration(string template) + { + const string placeholder = "${{ matrix.configuration }}"; + if (!template.Contains(placeholder, StringComparison.Ordinal)) + { + return [template]; + } + + return + [ + template.Replace(placeholder, "Debug", StringComparison.Ordinal), + template.Replace(placeholder, "Release", StringComparison.Ordinal) + ]; + } + + /// The contract retention of a reserved name, or null when the name is not reserved with one. + private static string? ExpectedRetention(string template) + { + string key = template.Replace("${{ matrix.configuration }}", "{configuration}", StringComparison.Ordinal); + if (WorkflowContract.ReservedArtifacts.TryGetValue(key, out string? retention)) + { + return retention; + } + + return BinlogName().IsMatch(template) ? WorkflowContract.BinlogRetention : null; + } + + private static bool IsReservedWithoutRetention(string template) + { + return WorkflowContract.ReservedArtifacts.TryGetValue(template, out string? retention) && retention is null; + } + + [GeneratedRegex(@"'--hangdump-timeout',\s*'(?\d+)m'", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex HangDumpTimeout(); + + [GeneratedRegex(@"\bdotnet\s+pack\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex VersionedPack(); + + [GeneratedRegex(@"\bdotnet\s+(?:build|test|publish)\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex VersionedBuild(); + + [GeneratedRegex(@"(?:^|\s|"")[-/]bl(?::|\s|$)", RegexOptions.Multiline | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex BinaryLogSwitch(); + + [GeneratedRegex(@"^binlogs-[a-z0-9-]+?(?:-\$\{\{ matrix\.configuration \}\})?$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex BinlogName(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs new file mode 100644 index 00000000..3f75b01f --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs @@ -0,0 +1,304 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// The required check "CI / Gate" (contract 1.2 and 1.7): the callers, the gate job and its inputs, the job table of +/// ci.yml, the Sonar expectation and the triggers. +/// +public sealed partial class WorkflowContractTests +{ + private const string PipelineUses = "./.github/workflows/ci.yml"; + + [Fact] + public void Callers_invoke_ci_through_job_ci_named_CI() + { + foreach (string caller in WorkflowContract.Callers) + { + WorkflowJob job = WorkflowFile.LoadWorkflow(caller).Job(WorkflowContract.CallerJobId); + Assert.True(string.Equals(WorkflowContract.CallerJobName, job.Name, StringComparison.Ordinal), + $"{job.Location} must be named '{WorkflowContract.CallerJobName}': the required check is 'CI / Gate'."); + Assert.Equal(PipelineUses, job.Uses); + } + + // No other job calls the pipeline: a second caller would publish the gate under another check name. + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + foreach (WorkflowJob job in workflow.Jobs()) + { + if (string.Equals(job.Uses, PipelineUses, StringComparison.Ordinal)) + { + Assert.True(string.Equals(job.Id, WorkflowContract.CallerJobId, StringComparison.Ordinal) && + string.Equals(job.Name, WorkflowContract.CallerJobName, StringComparison.Ordinal), + $"{job.Location} calls ci.yml; only a job 'ci' named 'CI' may."); + } + } + } + } + + [Fact] + public void Pull_request_and_main_callers_request_sonar_and_the_release_run_never_does() + { + foreach (string caller in new[] { "pull-request-ci.yml", "main-ci.yml" }) + { + WorkflowJob job = WorkflowFile.LoadWorkflow(caller).Job(WorkflowContract.CallerJobId); + Assert.Equal("true", WorkflowJob.With(job.Node, "sonar")); + YamlMappingNode secrets = Assert.IsType(WorkflowFile.Mapping(job.Node, "secrets")); + Assert.Equal("${{ secrets.SONAR_TOKEN }}", WorkflowFile.Scalar(secrets, "SONAR_TOKEN")); + } + + // The tag run builds the release candidate; SONAR_EXPECTED is false there (contract 1.11). + WorkflowJob release = WorkflowFile.LoadWorkflow("release.yml").Job(WorkflowContract.CallerJobId); + Assert.Null(WorkflowJob.With(release.Node, "sonar")); + } + + [Fact] + public void Gate_job_is_named_Gate_runs_always_and_has_no_permissions() + { + WorkflowJob gate = Pipeline().Job(WorkflowContract.GateJobId); + + Assert.Equal(WorkflowContract.GateJobName, gate.Name); + Assert.Equal("always()", gate.Condition); + Assert.True(gate.Node.Children.TryGetValue(new YamlScalarNode("permissions"), out YamlNode? permissions), + "The gate must declare 'permissions: {}'."); + Assert.Empty(Assert.IsType(permissions).Children); + Assert.Empty(gate.StepsUsing("actions/checkout@")); + + YamlMappingNode step = Assert.Single(gate.Steps); + Assert.Equal("${{ toJSON(needs) }}", WorkflowJob.Env(step, "NEEDS")); + Assert.Equal("${{ github.event_name }}", WorkflowJob.Env(step, "EVENT")); + string run = WorkflowFile.Scalar(step, "run") ?? ""; + // Data-driven: the verdict comes from a required result per job, never from chained -and/-or conditions. + Assert.DoesNotContain(" -and ", run, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(" -or ", run, StringComparison.OrdinalIgnoreCase); + Assert.Contains("$required = 'success'", run, StringComparison.Ordinal); + Assert.Contains("$required = 'skipped'", run, StringComparison.Ordinal); + Assert.Contains("if ($result -ne $required)", run, StringComparison.Ordinal); + Assert.Contains("| Job | Result | Required | Reason |", run, StringComparison.Ordinal); + Assert.Contains("Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8", run, StringComparison.Ordinal); + Assert.Contains("exit 1", run, StringComparison.Ordinal); + } + + [Fact] + public void Gate_needs_every_other_ci_job_except_the_advisory_allowlist() + { + WorkflowFile pipeline = Pipeline(); + HashSet needs = new(pipeline.Job(WorkflowContract.GateJobId).Needs(), StringComparer.Ordinal); + HashSet expected = new(StringComparer.Ordinal); + foreach (WorkflowJob job in pipeline.Jobs()) + { + if (string.Equals(job.Id, WorkflowContract.GateJobId, StringComparison.Ordinal)) + { + continue; + } + + if (WorkflowContract.AdvisoryJobs.Contains(job.Id)) + { + Assert.Equal("true", WorkflowFile.Scalar(job.Node, "continue-on-error")); + Assert.False(needs.Contains(job.Id), $"The advisory job '{job.Id}' must stay out of gate.needs."); + continue; + } + + expected.Add(job.Id); + } + + List missing = [.. expected.Except(needs, StringComparer.Ordinal)]; + List extra = [.. needs.Except(expected, StringComparer.Ordinal)]; + Assert.True(missing.Count == 0 && extra.Count == 0, + $"gate.needs must list every other ci.yml job. Missing: {string.Join(", ", missing)}. Unknown: {string.Join(", ", extra)}."); + } + + [Fact] + public void Ci_jobs_match_the_frozen_contract_ids_and_names() + { + Dictionary jobs = new(StringComparer.Ordinal); + foreach (WorkflowJob job in Pipeline().Jobs()) + { + jobs.Add(job.Id, job); + } + + foreach (PipelineJob expected in WorkflowContract.Jobs) + { + Assert.True(jobs.Remove(expected.Id, out WorkflowJob? job), $"ci.yml has no job '{expected.Id}'."); + Assert.Equal(expected.Name, job.Name); + Assert.Equal(expected.RunsOn, job.RunsOn); + Assert.Equal(expected.TimeoutMinutes?.ToString(System.Globalization.CultureInfo.InvariantCulture), + WorkflowFile.Scalar(job.Node, "timeout-minutes")); + } + + // Anything else must be a job id reserved for a later wave, with its reserved name. + foreach (WorkflowJob job in jobs.Values) + { + Assert.True(WorkflowContract.ReservedJobs.TryGetValue(job.Id, out string? name), + $"ci.yml job '{job.Id}' is not in the contract (shared contract 1.6)."); + Assert.Equal(name, job.Name); + } + } + + [Fact] + public void Ci_declares_exactly_the_contract_inputs_and_secret() + { + YamlMappingNode call = Assert.IsType(Pipeline().Trigger("workflow_call")); + Assert.Equal(["workflow_call"], Pipeline().Triggers()); + + YamlMappingNode inputs = Assert.IsType(WorkflowFile.Mapping(call, "inputs")); + Assert.Equal(["sonar", "package-version", "package-retention-days"], WorkflowFile.Keys(inputs)); + AssertInput(inputs, "sonar", "boolean", "false"); + AssertInput(inputs, "package-version", "string", ""); + AssertInput(inputs, "package-retention-days", "number", "7"); + + YamlMappingNode secrets = Assert.IsType(WorkflowFile.Mapping(call, "secrets")); + Assert.Equal(["SONAR_TOKEN"], WorkflowFile.Keys(secrets)); + Assert.Equal("false", WorkflowFile.Scalar(Assert.IsType(WorkflowFile.Mapping(secrets, "SONAR_TOKEN")), "required")); + Assert.False(WorkflowFile.Has(call, "outputs"), "ci.yml has no workflow_call outputs in v1 (contract 1.4)."); + + // No repository variable steers the pipeline (contract 1.4). + Assert.DoesNotContain("vars.", Pipeline().Text, StringComparison.Ordinal); + } + + [Fact] + public void Sonar_condition_equals_the_gate_sonar_expected_expression() + { + WorkflowFile pipeline = Pipeline(); + string condition = WorkflowFile.NormalizeWhitespace(pipeline.Job("sonar").Condition ?? ""); + YamlMappingNode gateStep = Assert.Single(pipeline.Job(WorkflowContract.GateJobId).Steps); + string expected = WorkflowFile.NormalizeWhitespace(WorkflowJob.Env(gateStep, "SONAR_EXPECTED") ?? ""); + + Assert.Equal(WorkflowContract.SonarExpected, condition); + Assert.Equal(condition, expected); + } + + [Fact] + public void Sonar_waits_for_the_quality_gate_outside_push_events() + { + WorkflowJob sonar = Pipeline().Job("sonar"); + Assert.Equal("./.github/workflows/sonar.yml", sonar.Uses); + Assert.Equal(["build-test"], sonar.Needs()); + Assert.Equal("${{ github.event_name != 'push' }}", WorkflowJob.With(sonar.Node, "wait-quality-gate")); + Assert.Equal("${{ secrets.SONAR_TOKEN }}", + WorkflowFile.Scalar(Assert.IsType(WorkflowFile.Mapping(sonar.Node, "secrets")), "SONAR_TOKEN")); + + WorkflowJob analyze = WorkflowFile.LoadWorkflow(WorkflowContract.Sonar).Job("analyze"); + YamlMappingNode env = Assert.IsType(WorkflowFile.Mapping(analyze.Node, "env")); + Assert.Equal("${{ inputs.wait-quality-gate }}", WorkflowFile.Scalar(env, "SONAR_WAIT_QUALITY_GATE")); + Assert.Contains("/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE", + WorkflowFile.Scalar(analyze.Step("Begin analysis"), "run"), StringComparison.Ordinal); + + // A failed quality gate stays one click away: the summary links the analysis even when End analysis fails. + YamlMappingNode link = analyze.Step("Link the analysis"); + Assert.True(analyze.StepIndex("End analysis") < analyze.StepIndex("Link the analysis")); + Assert.Equal("${{ !cancelled() }}", WorkflowFile.Scalar(link, "if")); + Assert.Contains("Out-File -FilePath $env:GITHUB_STEP_SUMMARY", WorkflowFile.Scalar(link, "run"), StringComparison.Ordinal); + } + + [Fact] + public void Sonar_excludes_non_product_trees_from_analysis_and_coverage() + { + string begin = WorkflowFile.Scalar( + WorkflowFile.LoadWorkflow(WorkflowContract.Sonar).Job("analyze").Step("Begin analysis"), "run") ?? ""; + + HashSet exclusions = new(SonarProperty(begin, "sonar.exclusions").Split(','), StringComparer.Ordinal); + foreach (string tree in new[] + { + "artifacts/**", "tests/CheatEngine.SDK.QualificationTarget/**", "tests/native-host-emulator/**", "eng/tools/**", + "docs/**" + }) + { + Assert.True(exclusions.Contains(tree), $"sonar.exclusions must list {tree}."); + } + + // Tests stay analysed (their issues are triaged by rule), but they never count as product coverage. + Assert.False(exclusions.Contains("tests/**"), "Tests stay analysed; only their coverage is excluded."); + HashSet coverage = new(SonarProperty(begin, "sonar.coverage.exclusions").Split(','), StringComparer.Ordinal); + foreach (string tree in new[] { "tests/**", "eng/**", "docs/**" }) + { + Assert.True(coverage.Contains(tree), $"sonar.coverage.exclusions must list {tree}."); + } + } + + [Fact] + public void No_workflow_uses_pull_request_target_or_a_merge_group_trigger() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + IReadOnlyList triggers = workflow.Triggers(); + Assert.False(triggers.Contains("pull_request_target", StringComparer.Ordinal), + $"{workflow.FileName} must not use pull_request_target: it runs pull-request code with the base repository's secrets."); + Assert.False(triggers.Contains("merge_group", StringComparer.Ordinal), + $"{workflow.FileName} must not listen to merge_group: there is no merge queue, and an untested event path must not feed the required check."); + } + } + + [Fact] + public void Pull_request_and_policy_workflows_have_no_path_filters() + { + foreach (string fileName in new[] { "pull-request-ci.yml", "pr-policy.yml" }) + { + WorkflowFile? workflow = WorkflowFile.TryLoadWorkflow(fileName); + if (workflow is null) + { + // pr-policy.yml no longer exists (the PR title/changelog policy engine was removed); pull-request-ci.yml + // must always exist. + Assert.False(string.Equals(fileName, "pull-request-ci.yml", StringComparison.Ordinal), $"{fileName} is missing."); + continue; + } + + foreach (string trigger in workflow.Triggers()) + { + YamlMappingNode? configuration = workflow.Trigger(trigger); + if (configuration is null) + { + continue; + } + + Assert.False(WorkflowFile.Has(configuration, "paths") || WorkflowFile.Has(configuration, "paths-ignore"), + $"{fileName} '{trigger}' must not filter paths: its required check must report on every pull request."); + } + } + } + + [Fact] + public void Main_ci_runs_every_push_to_main_without_a_concurrency_group() + { + WorkflowFile main = WorkflowFile.LoadWorkflow("main-ci.yml"); + + Assert.Equal(["push", "workflow_dispatch"], main.Triggers()); + YamlMappingNode push = Assert.IsType(main.Trigger("push")); + Assert.Equal(["main"], WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(push, "branches")))); + Assert.False(WorkflowFile.Has(main.Root, "concurrency"), + "main-ci.yml keeps every main commit's run: a concurrency group would cancel or drop intermediate runs."); + } + + [Fact] + public void Pull_request_ci_skips_drafts_and_cancels_superseded_runs() + { + WorkflowFile pullRequest = WorkflowFile.LoadWorkflow("pull-request-ci.yml"); + + Assert.Equal(["pull_request"], pullRequest.Triggers()); + YamlMappingNode trigger = Assert.IsType(pullRequest.Trigger("pull_request")); + Assert.Equal(["opened", "synchronize", "reopened", "ready_for_review"], + WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(trigger, "types")))); + Assert.Equal("${{ !github.event.pull_request.draft }}", pullRequest.Job(WorkflowContract.CallerJobId).Condition); + + YamlMappingNode concurrency = Assert.IsType(WorkflowFile.Mapping(pullRequest.Root, "concurrency")); + Assert.Equal("${{ github.workflow }}-${{ github.event.pull_request.number }}", WorkflowFile.Scalar(concurrency, "group")); + Assert.Equal("true", WorkflowFile.Scalar(concurrency, "cancel-in-progress")); + } + + private static void AssertInput(YamlMappingNode inputs, string name, string type, string defaultValue) + { + YamlMappingNode input = Assert.IsType(WorkflowFile.Mapping(inputs, name)); + Assert.Equal(type, WorkflowFile.Scalar(input, "type")); + Assert.Equal(defaultValue, WorkflowFile.Scalar(input, "default")); + } + + private static string SonarProperty(string beginScript, string property) + { + string marker = $"/d:{property}="; + int start = beginScript.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0, $"The Begin analysis step does not set {property}."); + start += marker.Length; + int end = beginScript.IndexOf('\'', start); + return beginScript[start..end]; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Hygiene.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Hygiene.cs new file mode 100644 index 00000000..d97272ce --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Hygiene.cs @@ -0,0 +1,273 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// Rules for every workflow and composite action: runners, timeouts, permissions, pins and run scripts. +public sealed partial class WorkflowContractTests +{ + [Fact] + public void Every_job_has_a_timeout_and_a_pinned_runner_label() + { + List violations = []; + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + foreach (WorkflowJob job in workflow.Jobs()) + { + // GitHub rejects runs-on and timeout-minutes on a reusable-workflow call; the callee's jobs are checked. + if (job.CallsReusableWorkflow) + { + continue; + } + + if (job.RunsOn is null || !WorkflowContract.RunnerLabels.Contains(job.RunsOn)) + { + violations.Add(new Violation(workflow.FileName, job.Id, + $"{job.Location} runs on '{job.RunsOn}': use a literal windows-2025 or ubuntu-24.04 label, never -latest or an expression.")); + } + + string? timeout = WorkflowFile.Scalar(job.Node, "timeout-minutes"); + if (!int.TryParse(timeout, NumberStyles.None, CultureInfo.InvariantCulture, out int minutes) || minutes <= 0) + { + violations.Add(new Violation(workflow.FileName, job.Id, + $"{job.Location} has no positive timeout-minutes ('{timeout}').")); + } + } + } + + WorkflowContract.AssertNoViolations(WorkflowContract.Rules.Runner, violations); + } + + [Fact] + public void Workflows_grant_only_read_permissions_at_the_top_level() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + Assert.True(workflow.Root.Children.TryGetValue(new YamlScalarNode("permissions"), out YamlNode? permissions), + $"{workflow.FileName} must declare top-level permissions (contents: read) and elevate per job only."); + if (permissions is YamlScalarNode scalar) + { + Assert.True(string.Equals(scalar.Value, "read-all", StringComparison.Ordinal), + $"{workflow.FileName} grants '{scalar.Value}' to every job; elevate in the job that needs it."); + continue; + } + + YamlMappingNode scopes = Assert.IsType(permissions); + foreach (KeyValuePair scope in scopes.Children) + { + string value = ((YamlScalarNode) scope.Value).Value ?? ""; + Assert.True(value is "read" or "none", + $"{workflow.FileName} grants '{scope.Key}: {value}' to every job; elevate in the job that needs it."); + } + + if (Array.IndexOf(WorkflowContract.PipelineWorkflows, workflow.FileName) >= 0) + { + Assert.Equal(["contents"], WorkflowFile.Keys(scopes)); + Assert.Equal("read", WorkflowFile.Scalar(scopes, "contents")); + } + } + } + + [Fact] + public void Pipeline_jobs_never_elevate_permissions() + { + // ci.yml and sonar.yml run pull-request code: no job there may hold a write scope (contract 1.6). + foreach (string fileName in new[] { WorkflowContract.Pipeline, WorkflowContract.Sonar }) + { + foreach (WorkflowJob job in WorkflowFile.LoadWorkflow(fileName).Jobs()) + { + if (!job.Node.Children.TryGetValue(new YamlScalarNode("permissions"), out YamlNode? permissions)) + { + continue; + } + + YamlMappingNode scopes = Assert.IsType(permissions); + foreach (KeyValuePair scope in scopes.Children) + { + string value = ((YamlScalarNode) scope.Value).Value ?? ""; + Assert.True(value is "read" or "none", $"{job.Location} grants '{scope.Key}: {value}'."); + } + } + } + } + + [Fact] + public void Every_remote_action_is_pinned_to_a_full_sha_with_a_version_comment() + { + Dictionary pins = new(StringComparer.Ordinal); + foreach (WorkflowFile file in AllActionFiles()) + { + foreach (string line in file.Text.Split('\n')) + { + Match uses = UsesLine().Match(line); + if (!uses.Success || uses.Groups["reference"].Value.StartsWith("./", StringComparison.Ordinal)) + { + continue; + } + + Match pinned = PinnedReference().Match(uses.Groups["reference"].Value + uses.Groups["rest"].Value); + Assert.True(pinned.Success, + $"{file.RelativePath}: '{line.Trim()}' must be owner/repo@<40-hex commit> # vX.Y.Z (the tag of that commit)."); + + // One action, one pin: the same action at two commits would be two supply-chain inputs to review. + string action = pinned.Groups["action"].Value; + string sha = pinned.Groups["sha"].Value; + if (pins.TryGetValue(action, out string? other)) + { + Assert.True(string.Equals(other, sha, StringComparison.Ordinal), + $"{action} is pinned to both {other} and {sha}; use one commit everywhere."); + } + else + { + pins.Add(action, sha); + } + } + } + + Assert.NotEmpty(pins); + } + + [Fact] + public void Every_checkout_disables_credential_persistence() + { + int checkouts = 0; + foreach (WorkflowFile file in AllActionFiles()) + { + foreach (YamlMappingNode step in AllSteps(file)) + { + string? uses = WorkflowFile.Scalar(step, "uses"); + if (uses is null || !uses.StartsWith("actions/checkout@", StringComparison.Ordinal)) + { + continue; + } + + checkouts++; + Assert.True(string.Equals(WorkflowJob.With(step, "persist-credentials"), "false", StringComparison.Ordinal), + $"{file.RelativePath} line {step.Start.Line}: actions/checkout must set persist-credentials: false."); + } + } + + Assert.True(checkouts > 0, "No actions/checkout step was found."); + } + + [Fact] + public void Every_native_command_in_a_workflow_script_checks_its_exit_code() + { + List violations = []; + foreach (WorkflowFile file in AllActionFiles()) + { + foreach ((string subject, YamlMappingNode step) in AllStepsWithSubject(file)) + { + if (WorkflowFile.Scalar(step, "run") is not { } run) + { + continue; + } + + string[] lines = run.Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + if (!NativeInvocation().IsMatch(lines[index])) + { + continue; + } + + // A command continued with a trailing backtick ends on its last continued line. + int last = index; + while (last + 1 < lines.Length && lines[last].TrimEnd().EndsWith('`')) + { + last++; + } + + int next = last + 1; + while (next < lines.Length && string.IsNullOrWhiteSpace(lines[next])) + { + next++; + } + + if (next >= lines.Length || + !lines[next].TrimStart().StartsWith("if ($LASTEXITCODE -ne 0)", StringComparison.Ordinal)) + { + violations.Add(new Violation(file.FileName, subject, + $"{file.RelativePath} ({subject}): '{lines[index].Trim()}' must be followed by if ($LASTEXITCODE -ne 0) {{ throw ... }}.")); + } + } + } + } + + WorkflowContract.AssertNoViolations(WorkflowContract.Rules.ExitCode, violations); + } + + [Fact] + public void No_run_script_interpolates_an_expression() + { + foreach (WorkflowFile file in AllActionFiles()) + { + foreach (YamlMappingNode step in AllSteps(file)) + { + string run = WorkflowFile.Scalar(step, "run") ?? ""; + Assert.False(run.Contains("${{", StringComparison.Ordinal), + $"{file.RelativePath} line {step.Start.Line}: pass expressions to run scripts through env:, never inline (template injection)."); + } + } + } + + [Fact] + public void Pipeline_workflows_and_composite_actions_run_scripts_in_pwsh() + { + foreach (string fileName in WorkflowContract.PipelineWorkflows) + { + WorkflowFile workflow = WorkflowFile.LoadWorkflow(fileName); + YamlMappingNode? defaults = WorkflowFile.Mapping(workflow.Root, "defaults"); + YamlMappingNode? run = defaults is null ? null : WorkflowFile.Mapping(defaults, "run"); + Assert.True(run is not null && string.Equals(WorkflowFile.Scalar(run, "shell"), "pwsh", StringComparison.Ordinal), + $"{fileName} must declare defaults.run.shell: pwsh."); + } + + foreach (WorkflowFile action in WorkflowFile.LoadActions()) + { + foreach (YamlMappingNode step in action.ActionSteps()) + { + if (WorkflowFile.Has(step, "run")) + { + Assert.True(string.Equals(WorkflowFile.Scalar(step, "shell"), "pwsh", StringComparison.Ordinal), + $"{action.RelativePath} line {step.Start.Line}: a composite run step must declare shell: pwsh."); + } + } + } + } + + [Fact] + public void No_workflow_references_the_local_qualification_runner() + { + foreach (WorkflowFile file in AllActionFiles()) + { + // The exact-host runner starts Cheat Engine; CI never does (levels C0-C2 only, audit ch.20). + Assert.False(file.Text.Contains("eng/qualification", StringComparison.OrdinalIgnoreCase), + $"{file.RelativePath} references eng/qualification: the local qualification runner never runs in CI."); + } + } + + [Fact] + public void No_workflow_passes_ApiCompatGenerateSuppressionFile() + { + foreach (WorkflowFile file in AllActionFiles()) + { + // Suppressions are regenerated by the integrator only; CI must fail on an undeclared break instead. + Assert.False(file.Text.Contains("ApiCompatGenerateSuppressionFile", StringComparison.OrdinalIgnoreCase) || + file.Text.Contains("GenerateCompatibilitySuppressionFile", StringComparison.OrdinalIgnoreCase), + $"{file.RelativePath} generates an ApiCompat suppression file; CI must fail on an undeclared break instead."); + } + } + + [GeneratedRegex(@"^\s*(?:-\s+)?uses:\s*(?\S+)(?.*)$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex UsesLine(); + + [GeneratedRegex(@"^(?[A-Za-z0-9-]+/[A-Za-z0-9._/-]+)@(?[0-9a-f]{40}) # v\d+\.\d+\.\d+\s*$", + RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PinnedReference(); + + [GeneratedRegex(@"^\s*(?:dotnet|xmake|git|gh|tar)\s|^\s*\./|^\s*&\s", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex NativeInvocation(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Lint.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Lint.cs new file mode 100644 index 00000000..0fe5929e --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Lint.cs @@ -0,0 +1,113 @@ +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// The lint and format jobs: actionlint, zizmor and PSScriptAnalyzer pinned and verified, whitespace verified. +public sealed partial class WorkflowContractTests +{ + private const string ZizmorConfig = ".github/zizmor.yml"; + + [Fact] + public void Lint_job_checks_out_the_repository_and_runs_every_linter() + { + WorkflowJob lint = Pipeline().Job("lint"); + + // tests/native-abi-fixture/build.ps1 and other repository scripts live outside .github: the whole tree is + // checked out, not a sparse .github. + YamlMappingNode checkout = Assert.Single(lint.StepsUsing("actions/checkout@")); + Assert.Null(WorkflowJob.With(checkout, "sparse-checkout")); + Assert.Empty(lint.Needs()); + foreach (string step in new[] { "Run actionlint", "Run zizmor" }) + { + Assert.True(lint.StepIndex(step) >= 0, $"The lint job has no '{step}' step."); + } + } + + [Fact] + public void Zizmor_and_actionlint_are_pinned_by_version_and_checksum() + { + WorkflowJob lint = Pipeline().Job("lint"); + YamlMappingNode env = Assert.IsType(WorkflowFile.Mapping(lint.Node, "env")); + Assert.Matches(ExactVersion(), WorkflowFile.Scalar(env, "ACTIONLINT_VERSION") ?? ""); + Assert.Matches(Sha256(), WorkflowFile.Scalar(env, "ACTIONLINT_SHA256") ?? ""); + string actionlint = WorkflowFile.Scalar(lint.Step("Run actionlint"), "run") ?? ""; + Assert.Contains("(Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()", actionlint, + StringComparison.Ordinal); + Assert.Contains("if ($actual -ne $env:ACTIONLINT_SHA256)", actionlint, StringComparison.Ordinal); + + // The action commit and the zizmor version are both pinned, and the gate never depends on the GitHub API. + YamlMappingNode zizmor = Assert.Single(lint.StepsUsing("zizmorcore/zizmor-action@")); + Assert.Matches(ExactVersion(), WorkflowJob.With(zizmor, "version") ?? ""); + Assert.Equal("false", WorkflowJob.With(zizmor, "online-audits")); + Assert.Equal("false", WorkflowJob.With(zizmor, "advanced-security")); + Assert.Equal(ZizmorConfig, WorkflowJob.With(zizmor, "config")); + Assert.Null(WorkflowJob.With(zizmor, "persona")); + Assert.Null(WorkflowJob.With(zizmor, "min-severity")); + Assert.NotNull(ReadRepositoryText(ZizmorConfig)); + } + + [Fact] + public void Every_zizmor_exception_carries_a_justification_comment() + { + string[] lines = ReadRepositoryText(ZizmorConfig).Split('\n'); + YamlStream stream = []; + stream.Load(new StringReader(string.Join('\n', lines))); + YamlMappingNode rules = Assert.IsType( + WorkflowFile.Mapping(Assert.IsType(stream.Documents[0].RootNode), "rules")); + foreach (KeyValuePair rule in rules.Children) + { + YamlMappingNode body = Assert.IsType(rule.Value); + Assert.True(WorkflowFile.Has(body, "ignore") || WorkflowFile.Has(body, "disable"), + $"{ZizmorConfig} rule '{rule.Key}' neither ignores nor disables anything."); + int line = (int) rule.Key.Start.Line - 2; + while (line >= 0 && string.IsNullOrWhiteSpace(lines[line])) + { + line--; + } + + Assert.True(line >= 0 && lines[line].TrimStart().StartsWith('#'), + $"{ZizmorConfig} rule '{rule.Key}' needs a comment directly above it giving the reason."); + } + + // Inline suppressions (the only kind a composite action supports) carry their reason on the same comment. + foreach (WorkflowFile file in AllActionFiles()) + { + foreach (Match ignore in InlineZizmorIgnore().Matches(file.Text)) + { + Assert.True(ignore.Groups["reason"].Value.Trim().Length >= 20, + $"{file.RelativePath}: '{ignore.Value.Trim()}' must explain why the finding is acceptable."); + } + } + } + + [Fact] + public void Format_job_verifies_whitespace_without_restore() + { + WorkflowJob format = Pipeline().Job("format"); + Assert.Empty(format.Needs()); + + // The pinned SDK comes from the composite action; --folder needs no restore and no MSBuild workspace. + YamlMappingNode setup = Assert.Single(format.StepsUsing(WorkflowContract.SetupAction)); + Assert.Null(WorkflowJob.With(setup, "restore")); + string run = format.RunText(); + Assert.Contains("dotnet format whitespace . --folder --verify-no-changes --exclude artifacts", run, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet restore", run, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet build", run, StringComparison.Ordinal); + // Style rules are enforced by the build (EnforceCodeStyleInBuild, warnings as errors), not by this job. + Assert.DoesNotContain("format style", run, StringComparison.Ordinal); + + // Valid on Linux only because the checkout is CRLF and .editorconfig says so. + Assert.Contains("end_of_line = crlf", ReadRepositoryText(".editorconfig"), StringComparison.Ordinal); + } + + [GeneratedRegex(@"#\s*zizmor:\s*ignore\[[^\]]+\](?[^\n]*)", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex InlineZizmorIgnore(); + + [GeneratedRegex(@"^\d+\.\d+\.\d+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ExactVersion(); + + [GeneratedRegex(@"^[0-9a-f]{64}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Sha256(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Restore.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Restore.cs new file mode 100644 index 00000000..c194d59b --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Restore.cs @@ -0,0 +1,173 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// Locked restores through the composite action, the pinned SDK, and no package cache (contract 1.5). +public sealed partial class WorkflowContractTests +{ + [Fact] + public void Composite_setup_restores_in_locked_mode() + { + WorkflowFile action = SetupAction(); + + YamlMappingNode inputs = Assert.IsType(WorkflowFile.Mapping(action.Root, "inputs")); + Assert.Equal("false", WorkflowFile.Scalar(Assert.IsType(WorkflowFile.Mapping(inputs, "cache")), "default")); + Assert.Equal("", WorkflowFile.Scalar(Assert.IsType(WorkflowFile.Mapping(inputs, "restore")), "default")); + + IReadOnlyList steps = action.ActionSteps(); + YamlMappingNode install = Assert.Single(steps, + static step => (WorkflowFile.Scalar(step, "uses") ?? "").StartsWith("actions/setup-dotnet@", StringComparison.Ordinal)); + Assert.Equal("global.json", WorkflowJob.With(install, "global-json-file")); + Assert.Equal("${{ inputs.cache }}", WorkflowJob.With(install, "cache")); + Assert.Equal("**/packages.lock.json", WorkflowJob.With(install, "cache-dependency-path")); + + YamlMappingNode restore = Assert.Single(steps, + static step => string.Equals(WorkflowFile.Scalar(step, "name"), "Restore", StringComparison.Ordinal)); + Assert.Equal("inputs.restore != ''", WorkflowFile.Scalar(restore, "if")); + Assert.Equal("${{ inputs.restore }}", WorkflowJob.Env(restore, "RESTORE_TARGETS")); + string run = WorkflowFile.Scalar(restore, "run") ?? ""; + // One locked restore per listed solution or project, each checked, with the fix in the message. + Assert.Contains("-split \"`n\"", run, StringComparison.Ordinal); + Assert.Contains("foreach ($target in $targets)", run, StringComparison.Ordinal); + Assert.Contains("dotnet restore $target --locked-mode", run, StringComparison.Ordinal); + // NU1005: locked mode and force-evaluate cannot be combined. + Assert.DoesNotContain("--force-evaluate", run, StringComparison.Ordinal); + } + + [Fact] + public void Every_restore_in_the_pipeline_is_locked() + { + List unlocked = []; + foreach (string fileName in new[] { WorkflowContract.Pipeline, WorkflowContract.Sonar }) + { + foreach (WorkflowJob job in WorkflowFile.LoadWorkflow(fileName).Jobs()) + { + foreach (string line in StripComments(job.RunText()).Split('\n')) + { + if (DotnetRestore().IsMatch(line) && !line.Contains("--locked-mode", StringComparison.Ordinal)) + { + unlocked.Add($"{job.Location}: {line.Trim()}"); + } + } + } + } + + Assert.True(unlocked.Count == 0, + "Restore with --locked-mode, or through the composite action's restore input: " + string.Join("; ", unlocked)); + } + + [Fact] + public void Release_reachable_workflows_never_enable_a_package_cache() + { + foreach (string fileName in WorkflowContract.CacheFreeWorkflows) + { + WorkflowFile? workflow = WorkflowFile.TryLoadWorkflow(fileName); + if (workflow is null) + { + continue; + } + + foreach (WorkflowJob job in workflow.Jobs()) + { + foreach (YamlMappingNode step in job.Steps) + { + string uses = WorkflowFile.Scalar(step, "uses") ?? ""; + Assert.False(uses.StartsWith("actions/cache", StringComparison.Ordinal), + $"{job.Location} uses {uses}: a cache written by a pull request run must never feed a release build."); + string? cache = WorkflowJob.With(step, "cache"); + Assert.True(cache is null || string.Equals(cache, "false", StringComparison.Ordinal), + $"{job.Location} passes cache: {cache}; every job of {fileName} is reachable from a release, Sonar or CodeQL run."); + } + } + } + } + + [Fact] + public void Every_dotnet_job_uses_the_composite_setup_action() + { + List violations = []; + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + foreach (WorkflowJob job in workflow.Jobs()) + { + int firstDotnetStep = -1; + int setupStep = -1; + IReadOnlyList steps = job.Steps; + for (int index = 0; index < steps.Count; index++) + { + if (setupStep < 0 && + string.Equals(WorkflowFile.Scalar(steps[index], "uses"), WorkflowContract.SetupAction, StringComparison.Ordinal)) + { + setupStep = index; + } + + if (firstDotnetStep < 0 && WorkflowFile.Scalar(steps[index], "run") is { } run && RunsDotnet(run)) + { + firstDotnetStep = index; + } + } + + if (firstDotnetStep >= 0 && (setupStep < 0 || setupStep > firstDotnetStep)) + { + violations.Add(new Violation(workflow.FileName, job.Id, + $"{job.Location} runs dotnet without first using {WorkflowContract.SetupAction}: global.json pins the SDK with rollForward: disable and runner images do not ship it.")); + } + } + } + + WorkflowContract.AssertNoViolations(WorkflowContract.Rules.DotnetSetup, violations); + } + + [Fact] + public void Sonar_restores_locked_from_nuget_org_before_the_scanner_begins() + { + WorkflowJob analyze = WorkflowFile.LoadWorkflow(WorkflowContract.Sonar).Job("analyze"); + + int restore = analyze.StepIndex("Restore from NuGet.org"); + int begin = analyze.StepIndex("Begin analysis"); + int build = analyze.StepIndex("Build"); + Assert.True(restore >= 0 && restore < begin && begin < build, + "sonar.yml must restore before the scanner begins, then build without restoring: no pull-request-controlled source may run while scanner credentials are configured."); + string restoreRun = WorkflowFile.Scalar(analyze.Steps[restore], "run") ?? ""; + Assert.Contains("--configfile $env:NUGET_CONFIG", restoreRun, StringComparison.Ordinal); + Assert.Contains("--locked-mode", restoreRun, StringComparison.Ordinal); + Assert.Contains("--no-restore", WorkflowFile.Scalar(analyze.Steps[build], "run"), StringComparison.Ordinal); + + // The composite action installs the SDK only: the checked-out nuget.config never resolves the scanner packages. + YamlMappingNode setup = Assert.Single(analyze.StepsUsing(WorkflowContract.SetupAction)); + Assert.Null(WorkflowJob.With(setup, "restore")); + } + + /// Whether a run script, or a repository script it invokes, runs the dotnet CLI. + private static bool RunsDotnet(string run) + { + if (DotnetInvocation().IsMatch(StripComments(run))) + { + return true; + } + + foreach (Match script in ScriptReference().Matches(run)) + { + string path = Path.Combine(RepositoryRoot.Path, script.Groups["path"].Value); + if (File.Exists(path) && DotnetInvocation().IsMatch(StripComments(File.ReadAllText(path)))) + { + return true; + } + } + + return false; + } + + [GeneratedRegex(@"(?m)(?:^|[\s;(|{&])dotnet\s", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex DotnetInvocation(); + + [GeneratedRegex(@"\bdotnet\s+restore\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex DotnetRestore(); + + [GeneratedRegex(@"(?:^|[\s(])\.?/?(?(?:eng|tests)/[\w./-]+\.ps1)\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ScriptReference(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Supply.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Supply.cs new file mode 100644 index 00000000..fba26c96 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Supply.cs @@ -0,0 +1,81 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// Supply-chain jobs of ci.yml: the dependency review that never skips and the lock-file drift check. +public sealed partial class WorkflowContractTests +{ + private const string DependencyReviewConfig = ".github/dependency-review-config.yml"; + + [Fact] + public void Dependency_review_job_always_runs_and_reviews_only_pull_requests() + { + WorkflowJob job = Pipeline().Job("dependency-review"); + + // The gate requires success: the job itself never skips, only its steps choose by event (contract 1.11). + Assert.Null(job.Condition); + Assert.Empty(job.Needs()); + Assert.False(WorkflowFile.Has(job.Node, "permissions"), "dependency-review keeps the read-only top-level permissions."); + + YamlMappingNode review = Assert.Single(job.StepsUsing("actions/dependency-review-action@")); + Assert.Equal("github.event_name == 'pull_request'", WorkflowFile.Scalar(review, "if")); + Assert.Equal($"./{DependencyReviewConfig}", WorkflowJob.With(review, "config-file")); + Assert.Equal("never", WorkflowJob.With(review, "comment-summary-in-pr")); + Assert.Equal("true", WorkflowJob.With(review, "retry-on-snapshot-warnings")); + + YamlMappingNode notice = job.Step("Nothing to review"); + Assert.Equal("github.event_name != 'pull_request'", WorkflowFile.Scalar(notice, "if")); + Assert.Contains("::notice", WorkflowFile.Scalar(notice, "run"), StringComparison.Ordinal); + + // Every other step serves the review and runs on pull requests only. + foreach (YamlMappingNode step in job.Steps) + { + string? condition = WorkflowFile.Scalar(step, "if"); + Assert.True(condition is "github.event_name == 'pull_request'" or "github.event_name != 'pull_request'", + $"{job.Location} step '{WorkflowFile.Scalar(step, "name")}' must choose by event, never skip the job."); + } + } + + [Fact] + public void Dependency_review_configuration_blocks_advisories_and_unreviewed_licenses() + { + YamlStream stream = []; + stream.Load(new StringReader(ReadRepositoryText(DependencyReviewConfig))); + YamlMappingNode config = Assert.IsType(stream.Documents[0].RootNode); + + Assert.Equal("moderate", WorkflowFile.Scalar(config, "fail-on-severity")); + Assert.Equal(["runtime", "development"], + WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(config, "fail-on-scopes")))); + Assert.Contains("MIT", + WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(config, "allow-licenses"))), + StringComparer.Ordinal); + foreach (string purl in WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(config, "allow-dependencies-licenses")))) + { + Assert.StartsWith("pkg:nuget/", purl, StringComparison.Ordinal); + } + + // The workflow sets these inline (contract 1.6); a second value here could silently disagree. + foreach (string inline in new[] { "comment-summary-in-pr", "retry-on-snapshot-warnings", "config-file" }) + { + Assert.False(WorkflowFile.Has(config, inline), $"{DependencyReviewConfig} must not repeat '{inline}', which ci.yml sets."); + } + } + + [Fact] + public void Lock_file_job_restores_the_solution_and_every_out_of_solution_project_locked_on_windows() + { + WorkflowJob job = Pipeline().Job("lock-files"); + + // Native AOT lock sections record the host-RID ILCompiler packages: only a Windows restore reproduces them. + Assert.Equal("windows-2025", job.RunsOn); + Assert.Empty(job.Needs()); + + // The composite action's own --locked-mode restore IS the verification (NU1004 the moment a committed + // packages.lock.json no longer matches a fresh restore): no separate script needed. AotProbe is the only + // project outside the solution (Solution/SolutionInventoryTests.cs). + YamlMappingNode setup = Assert.Single(job.StepsUsing(WorkflowContract.SetupAction)); + List targets = RestoreTargets(setup); + Assert.Contains("CheatEngine.SDK.slnx", targets, StringComparer.Ordinal); + Assert.Contains("tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj", targets, StringComparer.Ordinal); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.cs new file mode 100644 index 00000000..8f679c45 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.cs @@ -0,0 +1,97 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// Freezes the CI interface of shared contract section 1 over the workflows and composite actions that exist, so a +/// workflow added later is checked on arrival. Each WorkflowContractTests.<Concern>.cs file covers one +/// concern of the contract (hygiene, restores, jobs, lint, gate); this file holds the shared readers. +/// +public sealed partial class WorkflowContractTests +{ + private static WorkflowFile Pipeline() + { + return WorkflowFile.LoadWorkflow(WorkflowContract.Pipeline); + } + + private static WorkflowFile SetupAction() + { + foreach (WorkflowFile action in WorkflowFile.LoadActions()) + { + if (string.Equals(action.RelativePath, WorkflowContract.SetupActionFile, StringComparison.Ordinal)) + { + return action; + } + } + + Assert.Fail($"The composite action {WorkflowContract.SetupActionFile} is missing."); + return null!; + } + + private static string ReadRepositoryText(string relativePath) + { + string path = Path.Combine(RepositoryRoot.Path, relativePath); + Assert.True(File.Exists(path), $"{relativePath} is missing."); + return File.ReadAllText(path).Replace("\r\n", "\n", StringComparison.Ordinal); + } + + /// Every workflow and composite action. + private static List AllActionFiles() + { + return [.. WorkflowFile.LoadWorkflows(), .. WorkflowFile.LoadActions()]; + } + + private static List AllSteps(WorkflowFile file) + { + List steps = []; + foreach ((string _, YamlMappingNode step) in AllStepsWithSubject(file)) + { + steps.Add(step); + } + + return steps; + } + + /// Every step with its job id (workflows) or composite (actions). + private static List<(string Subject, YamlMappingNode Step)> AllStepsWithSubject(WorkflowFile file) + { + List<(string, YamlMappingNode)> steps = []; + foreach (WorkflowJob job in file.Jobs()) + { + foreach (YamlMappingNode step in job.Steps) + { + steps.Add((job.Id, step)); + } + } + + foreach (YamlMappingNode step in file.ActionSteps()) + { + steps.Add(("composite", step)); + } + + return steps; + } + + /// A PowerShell script without its comment lines and comment blocks. + private static string StripComments(string script) + { + string withoutBlocks = CommentBlock().Replace(script, ""); + List lines = []; + foreach (string line in withoutBlocks.Split('\n')) + { + if (!line.TrimStart().StartsWith('#')) + { + lines.Add(line); + } + } + + return string.Join('\n', lines); + } + + [GeneratedRegex(@"<#.*?#>", RegexOptions.Singleline | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex CommentBlock(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowFile.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowFile.cs new file mode 100644 index 00000000..eba296f6 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowFile.cs @@ -0,0 +1,244 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// One GitHub Actions YAML file (a workflow or a composite action) with its raw text, for rules that need comments, +/// and its YAML tree. Keys are read as plain strings (on is never a boolean), and a missing key is null. +/// +internal sealed class WorkflowFile +{ + private const string WorkflowDirectory = ".github/workflows"; + private const string ActionDirectory = ".github/actions"; + + private WorkflowFile(string relativePath, string text, YamlMappingNode root) + { + RelativePath = relativePath; + Text = text; + Root = root; + } + + /// The repository-relative path with forward slashes. + public string RelativePath + { + get; + } + + /// The file name, for example ci.yml. + public string FileName => Path.GetFileName(RelativePath); + + /// The raw text with LF line endings. + public string Text + { + get; + } + + /// The document's root mapping. + public YamlMappingNode Root + { + get; + } + + /// Every workflow under .github/workflows that exists now, so new workflows are checked on arrival. + public static IReadOnlyList LoadWorkflows() + { + return LoadDirectory(WorkflowDirectory, "*.y*ml"); + } + + /// Every composite action under .github/actions. + public static IReadOnlyList LoadActions() + { + return LoadDirectory(ActionDirectory, "action.y*ml"); + } + + /// A workflow by file name, or null when it does not exist. + public static WorkflowFile? TryLoadWorkflow(string fileName) + { + string path = Path.Combine(RepositoryRoot.Path, WorkflowDirectory, fileName); + return File.Exists(path) ? Load(path) : null; + } + + /// A workflow that must exist. + public static WorkflowFile LoadWorkflow(string fileName) + { + WorkflowFile? workflow = TryLoadWorkflow(fileName); + Assert.True(workflow is not null, $"{WorkflowDirectory}/{fileName} does not exist."); + return workflow; + } + + /// The jobs of a workflow, in file order. + public IReadOnlyList Jobs() + { + List jobs = []; + YamlMappingNode? node = Mapping(Root, "jobs"); + if (node is null) + { + return jobs; + } + + foreach (KeyValuePair entry in node.Children) + { + jobs.Add(new WorkflowJob(this, ((YamlScalarNode) entry.Key).Value!, (YamlMappingNode) entry.Value)); + } + + return jobs; + } + + /// A job that must exist. + public WorkflowJob Job(string id) + { + foreach (WorkflowJob job in Jobs()) + { + if (string.Equals(job.Id, id, StringComparison.Ordinal)) + { + return job; + } + } + + Assert.Fail($"{RelativePath} has no job '{id}'."); + return null!; + } + + /// The event names of the on key, whether it is a scalar, a sequence or a mapping. + public IReadOnlyList Triggers() + { + if (!Root.Children.TryGetValue(new YamlScalarNode("on"), out YamlNode? on)) + { + return []; + } + + return on switch + { + YamlScalarNode scalar => [scalar.Value!], + YamlSequenceNode sequence => ScalarValues(sequence), + YamlMappingNode mapping => Keys(mapping), + _ => [] + }; + } + + /// The configuration of one trigger, or null when it has none. + public YamlMappingNode? Trigger(string eventName) + { + return Mapping(Root, "on") is { } on ? Mapping(on, eventName) : null; + } + + /// The steps of a composite action. + public IReadOnlyList ActionSteps() + { + YamlMappingNode? runs = Mapping(Root, "runs"); + return runs is null ? [] : Mappings(Sequence(runs, "steps")); + } + + /// A child scalar's value, or null. + public static string? Scalar(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) && value is YamlScalarNode scalar + ? scalar.Value + : null; + } + + /// A child mapping, or null. + public static YamlMappingNode? Mapping(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) + ? value as YamlMappingNode + : null; + } + + /// A child sequence, or null. + public static YamlSequenceNode? Sequence(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) + ? value as YamlSequenceNode + : null; + } + + /// Whether the mapping has the key, whatever its value. + public static bool Has(YamlMappingNode node, string key) + { + return node.Children.ContainsKey(new YamlScalarNode(key)); + } + + /// The keys of a mapping, in order. + public static List Keys(YamlMappingNode node) + { + List keys = []; + foreach (YamlNode key in node.Children.Keys) + { + keys.Add(((YamlScalarNode) key).Value!); + } + + return keys; + } + + /// The mapping items of a sequence (a null sequence is empty). + public static List Mappings(YamlSequenceNode? sequence) + { + List items = []; + if (sequence is null) + { + return items; + } + + foreach (YamlNode item in sequence.Children) + { + if (item is YamlMappingNode mapping) + { + items.Add(mapping); + } + } + + return items; + } + + /// The scalar items of a sequence. + public static List ScalarValues(YamlSequenceNode sequence) + { + List values = []; + foreach (YamlNode item in sequence.Children) + { + if (item is YamlScalarNode scalar) + { + values.Add(scalar.Value!); + } + } + + return values; + } + + /// Collapses every run of whitespace to one space and trims, for comparing folded expressions. + public static string NormalizeWhitespace(string value) + { + return string.Join(' ', value.Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries)); + } + + private static List LoadDirectory(string relativeDirectory, string pattern) + { + List files = []; + string directory = Path.Combine(RepositoryRoot.Path, relativeDirectory); + if (!Directory.Exists(directory)) + { + return files; + } + + foreach (string path in Directory.EnumerateFiles(directory, pattern, SearchOption.AllDirectories)) + { + files.Add(Load(path)); + } + + files.Sort(static (left, right) => string.CompareOrdinal(left.RelativePath, right.RelativePath)); + return files; + } + + private static WorkflowFile Load(string path) + { + string text = File.ReadAllText(path).Replace("\r\n", "\n", StringComparison.Ordinal); + YamlStream stream = []; + stream.Load(new StringReader(text)); + Assert.Single(stream.Documents); + YamlMappingNode root = Assert.IsType(stream.Documents[0].RootNode); + return new WorkflowFile(RepositoryRoot.ToRelative(path), text, root); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowJob.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowJob.cs new file mode 100644 index 00000000..c38c05dc --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowJob.cs @@ -0,0 +1,135 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// One job of a workflow: either a runner job with steps, or a call to a reusable workflow (uses:). +internal sealed class WorkflowJob +{ + internal WorkflowJob(WorkflowFile workflow, string id, YamlMappingNode node) + { + Workflow = workflow; + Id = id; + Node = node; + } + + /// The file that declares the job. + public WorkflowFile Workflow + { + get; + } + + /// The job id (its key under jobs). + public string Id + { + get; + } + + /// The job's mapping. + public YamlMappingNode Node + { + get; + } + + /// The display name, or null. + public string? Name => WorkflowFile.Scalar(Node, "name"); + + /// The if: condition, or null. + public string? Condition => WorkflowFile.Scalar(Node, "if"); + + /// The runner label when it is a scalar, or null. + public string? RunsOn => WorkflowFile.Scalar(Node, "runs-on"); + + /// The reusable workflow this job calls, or null for a runner job. + public string? Uses => WorkflowFile.Scalar(Node, "uses"); + + /// Whether the job calls a reusable workflow (GitHub rejects runs-on and timeout-minutes there). + public bool CallsReusableWorkflow => Uses is not null; + + /// A readable locator for messages. + public string Location => $"{Workflow.FileName} job '{Id}'"; + + /// The steps, in order. + public IReadOnlyList Steps => WorkflowFile.Mappings(WorkflowFile.Sequence(Node, "steps")); + + /// The job ids this job needs, whether needs is a scalar or a sequence. + public IReadOnlyList Needs() + { + if (!Node.Children.TryGetValue(new YamlScalarNode("needs"), out YamlNode? needs)) + { + return []; + } + + return needs switch + { + YamlScalarNode scalar => [scalar.Value!], + YamlSequenceNode sequence => WorkflowFile.ScalarValues(sequence), + _ => [] + }; + } + + /// The step with the given name, which must exist. + public YamlMappingNode Step(string name) + { + int index = StepIndex(name); + Assert.True(index >= 0, $"{Location} has no step named '{name}'."); + return Steps[index]; + } + + /// The index of the step with the given name, or -1. + public int StepIndex(string name) + { + IReadOnlyList steps = Steps; + for (int index = 0; index < steps.Count; index++) + { + if (string.Equals(WorkflowFile.Scalar(steps[index], "name"), name, StringComparison.Ordinal)) + { + return index; + } + } + + return -1; + } + + /// The steps whose uses: starts with the given action reference (for example actions/checkout@). + public List StepsUsing(string actionPrefix) + { + List matches = []; + foreach (YamlMappingNode step in Steps) + { + string? uses = WorkflowFile.Scalar(step, "uses"); + if (uses is not null && uses.StartsWith(actionPrefix, StringComparison.Ordinal)) + { + matches.Add(step); + } + } + + return matches; + } + + /// Every run: script of the job, joined, for text rules. + public string RunText() + { + List scripts = []; + foreach (YamlMappingNode step in Steps) + { + if (WorkflowFile.Scalar(step, "run") is { } run) + { + scripts.Add(run); + } + } + + return string.Join('\n', scripts); + } + + /// A value of the step's with: mapping, or null. + public static string? With(YamlMappingNode step, string key) + { + return WorkflowFile.Mapping(step, "with") is { } with ? WorkflowFile.Scalar(with, key) : null; + } + + /// A value of the step's env: mapping, or null. + public static string? Env(YamlMappingNode step, string key) + { + return WorkflowFile.Mapping(step, "env") is { } env ? WorkflowFile.Scalar(env, key) : null; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/packages.lock.json b/tests/CheatEngine.SDK.Repository.Tests/packages.lock.json new file mode 100644 index 00000000..105b4eff --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/packages.lock.json @@ -0,0 +1,217 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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]" + } + }, + "YamlDotNet": { + "type": "Direct", + "requested": "[18.1.0, )", + "resolved": "18.1.0", + "contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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]" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests.csproj b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests.csproj index 850ec1e0..8da7df2c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests.csproj +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests.csproj @@ -19,6 +19,18 @@ + + + + + + + + diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/EndToEnd/EngineApiOptionalEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/EndToEnd/EngineApiOptionalEndToEndTests.cs new file mode 100644 index 00000000..36466208 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/EndToEnd/EngineApiOptionalEndToEndTests.cs @@ -0,0 +1,169 @@ +using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Marshalling; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.EndToEnd; + +/// +/// The opt:, opt-result:, rest: and form: outcome grammar, compiled and run against +/// stand-in globals on the bundled Lua 5.3 (audit F10, A06-14, A06-15): an omitted optional argument is not pushed, +/// Nil is pushed as nil, an optional Address keeps its state through the facade, zero results are not +/// nil, and an absent global is . Every test asserts +/// L.Top == 0. +/// +[Collection(LuaRuntimeSuite.Name)] +[Trait("Category", "NativeLua")] +public sealed class EngineApiOptionalEndToEndTests(RoslynFixture roslyn) : IClassFixture +{ + private const string BindingsType = "Demo.Optional.OptionalProbes"; + + private static readonly string Spec = SpecSources.Ce77Header("Demo.Optional", "OptionalProbes") + """ + global: allocateMemory + method: TryAllocate + form: try + arg: size:int64 + opt: preferredBaseAddress:address + opt: protection:int32 + result: address:address + nil: expected-failure + doc: Allocates target memory. + + global: shape + method: Shape + form: outcome + arg: mode:int32 + opt-result: first:address + nil: absence + doc: Returns zero, one nil or one value. + + global: missing + method: Missing + form: outcome + result: value:int32 + nil: none + doc: Calls a global that does not exist. + + global: seq + method: Sequence + form: outcome + arg: n:int32 + rest: values:int64 + nil: none + doc: Returns n values. + """; + + private static ReadOnlySpan StandIns => """ + function allocateMemory(...) + local n = select('#', ...) + local size, base, protection = ... + if n == 1 then return 0x10000 + size end + if base == nil then return 0x20000 + n end + if n == 2 then return base end + return base + protection + end + function shape(mode) + if mode == 0 then return end + if mode == 1 then return nil end + return 0x100000000 + end + function seq(n) + local t = {} + for i = 1, n do t[i] = i end + return table.unpack(t, 1, n) + end + """u8; + + [Fact] + public void Optional_argument_is_omitted_nil_or_pushed() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + TryAllocateDelegate allocate = LoadSuite(roslyn).Delegate(BindingsType, "TryAllocate"); + + Assert.True(allocate(0x10, default, default, out Address omitted)); + Assert.Equal(0x10010UL, omitted.ToUInt64()); + Assert.True(allocate(0x10, LuaOptional.Nil
(), LuaOptional.Of(4), out Address nilBase)); + Assert.Equal(0x20003UL, nilBase.ToUInt64()); + Assert.True(allocate(0x10, LuaOptional.Of(new Address(0x1_0000_0000UL)), default, out Address based)); + Assert.Equal(0x1_0000_0000UL, based.ToUInt64()); + Assert.True(allocate(0x10, LuaOptional.Of(new Address(0x5000UL)), LuaOptional.Of(4), out Address both)); + Assert.Equal(0x5004UL, both.ToUInt64()); + Assert.Throws(() => allocate(0x10, default, LuaOptional.Of(4), out _)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Optional_result_distinguishes_zero_results_from_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + ShapeDelegate shape = LoadSuite(roslyn).Delegate(BindingsType, "Shape"); + + Assert.Equal(LuaOperationStatusKind.Success, shape(0, out LuaOptional
none).Kind); + Assert.True(none.IsOmitted); + Assert.Equal(LuaOperationStatusKind.Success, shape(1, out LuaOptional
nil).Kind); + Assert.True(nil.IsNil); + Assert.Equal(LuaOperationStatusKind.Success, shape(2, out LuaOptional
value).Kind); + Assert.Equal(0x1_0000_0000UL, value.Value.ToUInt64()); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Absent_global_is_global_unavailable_in_the_outcome_form() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + MissingDelegate missing = LoadSuite(roslyn).Delegate(BindingsType, "Missing"); + + LuaOperationStatus status = missing(out int value); + + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, status.Kind); + Assert.Equal(0, value); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Rest_result_copies_every_value() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, StandIns); + SequenceDelegate sequence = LoadSuite(roslyn).Delegate(BindingsType, "Sequence"); + long[] values = new long[4]; + + Assert.Equal(LuaOperationStatusKind.Success, sequence(3, values, out int count).Kind); + Assert.Equal(3, count); + Assert.Equal([1L, 2L, 3L], values[..3]); + Assert.Equal(LuaOperationStatusKind.ResultCapacityExceeded, sequence(5, values, out int needed).Kind); + Assert.Equal(5, needed); + Assert.Equal(0, L.Top); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run("optional.cheatengine-sdk-api.txt", Spec)); + } + + private delegate bool TryAllocateDelegate(long size, LuaOptional
preferredBaseAddress, + LuaOptional protection, out Address address); + + private delegate LuaOperationStatus ShapeDelegate(int mode, out LuaOptional
first); + + private delegate LuaOperationStatus MissingDelegate(out int value); + + private delegate LuaOperationStatus SequenceDelegate(int n, Span values, out int valuesCount); +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Expected/memory-scalars.EngineApi.g.cs.txt b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Expected/memory-scalars.EngineApi.g.cs.txt new file mode 100644 index 00000000..b8619547 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Expected/memory-scalars.EngineApi.g.cs.txt @@ -0,0 +1,189 @@ +// +#nullable enable + +namespace CheatEngine.SDK.Engine.Generated +{ + /// Wrapper members generated from the Cheat Engine API spec file curated for this type. + public static partial class MemoryScalars + { + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_readInteger = new(); + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_readQword = new(); + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_writeInteger = new(); + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + private static readonly global::CheatEngine.SDK.Lua.References.LuaRef s_luaGlobal_writeQword = new(); + + // Raw core of 'TryReadInt32': nuint address, through AddressMarshaller. Never called except by the wrapper below. + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + private static bool __TryReadInt32Raw(nuint address, out int value) + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_readInteger, "readInteger"u8)) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); + global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.Push(__L, true); + if (!__L.TryCall(2, 1).IsOk) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + bool __ok = global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(__L, -1, out value); + return __ok; + } + catch (global::CheatEngine.SDK.Lua.Calls.LuaException) + { + value = default; + return false; + } + finally + { + __L.SetTop(__top); + } + } + + /// Reads a 32-bit integer from the target process at the given address; returns false when the address is not readable as a 32-bit integer. + /// CE >= 7.7.0.10621; architecture: x64; thread: unknown; ownership: none; return: bool with out results; nil: absence; provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00. + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + public static bool TryReadInt32(global::CheatEngine.SDK.Engine.Values.Address address, out int value) + { + return __TryReadInt32Raw(unchecked((nuint)address.ToUInt64()), out value); + } + + // Raw core of 'TryReadInt64': nuint address, through AddressMarshaller. Never called except by the wrapper below. + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + private static bool __TryReadInt64Raw(nuint address, out long value) + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_readQword, "readQword"u8)) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); + if (!__L.TryCall(1, 1).IsOk) + { + return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, out value); + } + + bool __ok = global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(__L, -1, out value); + return __ok; + } + catch (global::CheatEngine.SDK.Lua.Calls.LuaException) + { + value = default; + return false; + } + finally + { + __L.SetTop(__top); + } + } + + /// Reads a 64-bit integer from the target process at the given address; returns false when the address is not readable as a 64-bit integer. + /// CE >= 7.7.0.10621; architecture: x64; thread: unknown; ownership: none; return: bool with out results; nil: absence; provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00. + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + public static bool TryReadInt64(global::CheatEngine.SDK.Engine.Values.Address address, out long value) + { + return __TryReadInt64Raw(unchecked((nuint)address.ToUInt64()), out value); + } + + // Raw core of 'WriteInt32': nuint address, through AddressMarshaller. Never called except by the wrapper below. + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + private static bool __WriteInt32Raw(nuint address, int value) + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_writeInteger, "writeInteger"u8)) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(__L, __top, "writeInteger"); + } + + global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); + global::CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.Push(__L, value); + global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(2, 1); + if (!__status.IsOk) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(__L, __top, __status); + } + + if (!global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.TryRead(__L, -1, out bool __result)) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnexpectedResult(__L, __top, -1, "writeInteger", "a boolean"); + } + + return __result; + } + finally + { + __L.SetTop(__top); + } + } + + /// Writes a 32-bit integer to the target process at the given address; the returned value reports whether the write succeeded. + /// CE >= 7.7.0.10621; architecture: x64; thread: unknown; ownership: none; return: throwing bool; nil: none; provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00. + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + public static bool WriteInt32(global::CheatEngine.SDK.Engine.Values.Address address, int value) + { + return __WriteInt32Raw(unchecked((nuint)address.ToUInt64()), value); + } + + // Raw core of 'WriteInt64': nuint address, through AddressMarshaller. Never called except by the wrapper below. + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + private static bool __WriteInt64Raw(nuint address, long value) + { + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_writeQword, "writeQword"u8)) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(__L, __top, "writeQword"); + } + + global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address); + global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.Push(__L, value); + global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(2, 1); + if (!__status.IsOk) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(__L, __top, __status); + } + + if (!global::CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.TryRead(__L, -1, out bool __result)) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnexpectedResult(__L, __top, -1, "writeQword", "a boolean"); + } + + return __result; + } + finally + { + __L.SetTop(__top); + } + } + + /// Writes a 64-bit integer to the target process at the given address; the returned value reports whether the write succeeded. + /// CE >= 7.7.0.10621; architecture: x64; thread: unknown; ownership: none; return: throwing bool; nil: none; provenance: ExactInstalledFile: CE 7.7.0.10621 celua.txt, SHA-256 AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00. + [global::System.CodeDom.Compiler.GeneratedCode("CheatEngine.SDK.SourceGenerators.EngineApi", "{GeneratorVersion}")] + public static bool WriteInt64(global::CheatEngine.SDK.Engine.Values.Address address, long value) + { + return __WriteInt64Raw(unchecked((nuint)address.ToUInt64()), value); + } + } +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs index 8c3ecfa1..e989c604 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs @@ -14,7 +14,7 @@ public sealed class DiagnosticsTests(RoslynFixture roslyn) : IClassFixture string.Equals(diagnostic.Id, "CESDK3002", StringComparison.Ordinal) - && diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains("member", StringComparison.Ordinal)); + && diagnostic.GetMessage(CultureInfo.InvariantCulture) + .Contains("member", StringComparison.Ordinal)); Assert.Contains(run.GeneratorDiagnostics, static diagnostic => string.Equals(diagnostic.Id, "CESDK3002", StringComparison.Ordinal) - && diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains("cache field", StringComparison.Ordinal)); - AssertConflictLocation(run, "Generated member", "Specs/first.cheatengine-sdk-api.txt", 4, 10); - AssertConflictLocation(run, "Generated member", "Specs/second.cheatengine-sdk-api.txt", 4, 10); - AssertConflictLocation(run, "Generated cache field", "Specs/first.cheatengine-sdk-api.txt", 3, 10); - AssertConflictLocation(run, "Generated cache field", "Specs/second.cheatengine-sdk-api.txt", 3, 10); + && diagnostic.GetMessage(CultureInfo.InvariantCulture) + .Contains("cache field", StringComparison.Ordinal)); + AssertConflictLocation(run, "Generated member", "Specs/first.cheatengine-sdk-api.txt", 10, 10); + AssertConflictLocation(run, "Generated member", "Specs/second.cheatengine-sdk-api.txt", 10, 10); + AssertConflictLocation(run, "Generated cache field", "Specs/first.cheatengine-sdk-api.txt", 9, 10); + AssertConflictLocation(run, "Generated cache field", "Specs/second.cheatengine-sdk-api.txt", 9, 10); } /// Repeated file names from different directories always receive separate deterministic source hint names. @@ -117,11 +117,11 @@ public void Conflicting_member_and_cache_identities_are_diagnosed_on_both_spec_f public void Three_same_named_spec_files_receive_unique_case_insensitive_hint_names() { const string First = - "namespace: Demo\ntype: First\n\nglobal: first\nmethod: LoadFirst\nform: throwing\ndoc: first.\n"; + "namespace: Demo\ntype: First\n" + SpecSources.Ce77 + "\nglobal: first\nmethod: LoadFirst\nform: throwing\nnil: none\ndoc: first.\n"; const string Second = - "namespace: Demo\ntype: Second\n\nglobal: second\nmethod: LoadSecond\nform: throwing\ndoc: second.\n"; + "namespace: Demo\ntype: Second\n" + SpecSources.Ce77 + "\nglobal: second\nmethod: LoadSecond\nform: throwing\nnil: none\ndoc: second.\n"; const string Third = - "namespace: Demo\ntype: Third\n\nglobal: third\nmethod: LoadThird\nform: throwing\ndoc: third.\n"; + "namespace: Demo\ntype: Third\n" + SpecSources.Ce77 + "\nglobal: third\nmethod: LoadThird\nform: throwing\nnil: none\ndoc: third.\n"; GeneratorRun run = roslyn.Run( ("One/shared.cheatengine-sdk-api.txt", First), @@ -134,13 +134,66 @@ public void Three_same_named_spec_files_receive_unique_case_insensitive_hint_nam Assert.Equal(3, distinct.Count); } + [Fact] + public void A_spec_with_entries_and_no_contract_reports_CESDK3003_on_its_header() + { + const string Path = "Specs/legacy.cheatengine-sdk-api.txt"; + const string Text = + "# legacy\nnamespace: Demo\ntype: Legacy\n\nglobal: readInteger\nmethod: TryReadInt32\nform: try\nresult: value:int32\ndoc: d.\n"; + + GeneratorRun run = roslyn.Run(Path, Text); + + run.AssertNoGeneratedSource(); + AssertLocated(Assert.Single(run.GeneratorDiagnostics), "CESDK3003", Path, 1, 0, "'contract: ce77'"); + } + + [Fact] + public void An_argument_after_an_optional_one_reports_CESDK3004_at_the_argument() + { + const string Path = "Specs/optional.cheatengine-sdk-api.txt"; + string text = SpecSources.Ce77Header("Demo", "Optional") + + "global: g\nmethod: G\nform: throwing\nopt: a:int32\narg: b:int64\nnil: none\ndoc: d.\n"; + + GeneratorRun run = roslyn.Run(Path, text); + + run.AssertNoGeneratedSource(); + AssertLocated(Assert.Single(run.GeneratorDiagnostics), "CESDK3004", Path, 13, 5, "follows an 'opt' argument"); + } + + [Fact] + public void A_required_result_after_an_optional_one_reports_CESDK3005_at_the_result() + { + const string Path = "Specs/results.cheatengine-sdk-api.txt"; + string text = SpecSources.Ce77Header("Demo", "Results") + + "global: g\nmethod: G\nform: outcome\nopt-result: a:int32\nresult: b:int64\nnil: none\ndoc: d.\n"; + + GeneratorRun run = roslyn.Run(Path, text); + + run.AssertNoGeneratedSource(); + AssertLocated(Assert.Single(run.GeneratorDiagnostics), "CESDK3005", Path, 13, 8, "after an 'opt-result'"); + } + + private static void AssertLocated(Diagnostic diagnostic, string id, string path, int line, int character, + string messageFragment) + { + Assert.Equal(id, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Equal("https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/" + id + ".md", + diagnostic.Descriptor.HelpLinkUri); + FileLinePositionSpan span = diagnostic.Location.GetLineSpan(); + Assert.Equal(path, span.Path); + Assert.Equal(line, span.StartLinePosition.Line); + Assert.Equal(character, span.StartLinePosition.Character); + Assert.Contains(messageFragment, diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + private static void AssertConflictLocation(GeneratorRun run, string messageFragment, string path, int line, int character) { foreach (Diagnostic diagnostic in run.GeneratorDiagnostics) { if (!diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains(messageFragment, StringComparison.Ordinal)) + .Contains(messageFragment, StringComparison.Ordinal)) { continue; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EmissionTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EmissionTests.cs index 4dc7d209..c1a398b3 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EmissionTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EmissionTests.cs @@ -140,12 +140,19 @@ public void The_global_namespace_is_emitted_without_a_namespace_block() const string Text = """ namespace: type: Root + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: TryReadInt32 form: try arg: address:address result: value:int32 + nil: none doc: Reads an integer. """; @@ -162,7 +169,7 @@ public void The_global_namespace_is_emitted_without_a_namespace_block() public void An_address_try_result_is_exposed_as_Address_and_converted_from_the_raw_core() { const string Text = - "namespace: Demo\ntype: Addresses\n\nglobal: getAddress\nmethod: TryGetAddress\nform: try\nresult: value:address\ndoc: Gets a target address.\n"; + "namespace: Demo\ntype: Addresses\n" + SpecSources.Ce77 + "\nglobal: getAddress\nmethod: TryGetAddress\nform: try\nresult: value:address\nnil: none\ndoc: Gets a target address.\n"; GeneratorRun run = roslyn.Run("address-result.cheatengine-sdk-api.txt", Text); @@ -183,7 +190,7 @@ public void An_address_try_result_is_exposed_as_Address_and_converted_from_the_r public void An_address_throwing_return_is_exposed_as_Address_and_converted_from_the_raw_core() { const string Text = - "namespace: Demo\ntype: Addresses\n\nglobal: getAddress\nmethod: GetAddress\nform: throwing\nreturn: address\ndoc: Gets a target address.\n"; + "namespace: Demo\ntype: Addresses\n" + SpecSources.Ce77 + "\nglobal: getAddress\nmethod: GetAddress\nform: throwing\nreturn: address\nnil: none\ndoc: Gets a target address.\n"; GeneratorRun run = roslyn.Run("address-return.cheatengine-sdk-api.txt", Text); @@ -203,7 +210,7 @@ public void An_address_throwing_return_is_exposed_as_Address_and_converted_from_ public void Address_arguments_and_try_results_keep_the_complete_public_signature_strongly_typed() { const string Text = - "namespace: Demo\ntype: Addresses\n\nglobal: resolvePointer\nmethod: TryResolvePointer\nform: try\narg: address:address\nresult: result:address\ndoc: Resolves a target pointer.\n"; + "namespace: Demo\ntype: Addresses\n" + SpecSources.Ce77 + "\nglobal: resolvePointer\nmethod: TryResolvePointer\nform: try\narg: address:address\nresult: result:address\nnil: none\ndoc: Resolves a target pointer.\n"; GeneratorRun run = roslyn.Run("address-argument-and-result.cheatengine-sdk-api.txt", Text); @@ -216,6 +223,116 @@ public void Address_arguments_and_try_results_keep_the_complete_public_signature Assert.Contains("unchecked((nuint)address.ToUInt64())", text, StringComparison.Ordinal); } + [Fact] + public void Optional_address_argument_is_converted_by_the_facade_without_losing_its_state() + { + string text = SpecSources.Ce77Header("Demo", "Allocation") + + "global: allocateMemory\nmethod: TryAllocate\nform: try\narg: size:int64\nopt: preferredBaseAddress:address\nopt: protection:int32\nresult: address:address\nnil: expected-failure\ndoc: Allocates.\n"; + + GeneratorRun run = roslyn.Run("allocation.cheatengine-sdk-api.txt", text); + + run.AssertCompilesClean(); + string generated = run.SingleGeneratedText; + Assert.Contains( + "private static bool __TryAllocateRaw(long size, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional preferredBaseAddress, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional protection, out nuint address)", + generated, StringComparison.Ordinal); + Assert.Contains( + "public static bool TryAllocate(long size, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional preferredBaseAddress, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional protection, out global::CheatEngine.SDK.Engine.Values.Address address)", + generated, StringComparison.Ordinal); + Assert.Contains( + "__TryAllocateRaw(size, (preferredBaseAddress.HasValue ? global::CheatEngine.SDK.Lua.Marshalling.LuaOptional.Of(unchecked((nuint)preferredBaseAddress.Value.ToUInt64())) : preferredBaseAddress.IsNil ? global::CheatEngine.SDK.Lua.Marshalling.LuaOptional.Nil() : default), protection, out __engineApiRawResult0)", + generated, StringComparison.Ordinal); + Assert.Contains("int __argc = !protection.IsOmitted ? 3 : !preferredBaseAddress.IsOmitted ? 2 : 1;", generated, + StringComparison.Ordinal); + Assert.Contains( + "return: bool with out results; nil: expected-failure; provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture.", + generated, StringComparison.Ordinal); + } + + [Fact] + public void Outcome_form_with_address_results_is_exposed_through_the_facade() + { + string text = SpecSources.Ce77Header("Demo", "Symbols") + + "global: getAddressSafe\nmethod: ResolveAddress\nform: outcome\narg: expression:string\nresult: address:address\nopt-result: alternate:address\nnil: absence\ndoc: Resolves.\n"; + + GeneratorRun run = roslyn.Run("symbols.cheatengine-sdk-api.txt", text); + + run.AssertCompilesClean(); + string generated = run.SingleGeneratedText; + Assert.Contains( + "private static global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus __ResolveAddressRaw(string expression, out nuint address, out global::CheatEngine.SDK.Lua.Marshalling.LuaOptional alternate)", + generated, StringComparison.Ordinal); + Assert.Contains( + "public static global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus ResolveAddress(string expression, out global::CheatEngine.SDK.Engine.Values.Address address, out global::CheatEngine.SDK.Lua.Marshalling.LuaOptional alternate)", + generated, StringComparison.Ordinal); + Assert.Contains( + "global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus __engineApiStatus = __ResolveAddressRaw(expression, out __engineApiRawResult0, out __engineApiRawResult1);", + generated, StringComparison.Ordinal); + Assert.Contains( + "alternate = __engineApiRawResult1.HasValue ? global::CheatEngine.SDK.Lua.Marshalling.LuaOptional.Of(new global::CheatEngine.SDK.Engine.Values.Address(unchecked((ulong)__engineApiRawResult1.Value))) : __engineApiRawResult1.IsNil ? global::CheatEngine.SDK.Lua.Marshalling.LuaOptional.Nil() : default;", + generated, StringComparison.Ordinal); + Assert.Contains("return __engineApiStatus;", generated, StringComparison.Ordinal); + Assert.Contains("return: LuaOperationStatus with out results; nil: absence;", generated, StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.State.LuaState.MultipleResults", generated, StringComparison.Ordinal); + } + + [Fact] + public void Rest_result_is_a_span_and_a_count_in_the_outcome_form() + { + string text = SpecSources.Ce77Header("Demo", "Bytes") + + "global: readBytes\nmethod: ReadBytes\nform: outcome\narg: address:address\narg: count:int32\nrest: values:int32\nnil: expected-failure\ndoc: Reads bytes.\n"; + + GeneratorRun run = roslyn.Run("bytes.cheatengine-sdk-api.txt", text); + + run.AssertCompilesClean(); + string generated = run.SingleGeneratedText; + Assert.Contains( + "public static global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus ReadBytes(global::CheatEngine.SDK.Engine.Values.Address address, int count, global::System.Span values, out int valuesCount)", + generated, StringComparison.Ordinal); + Assert.Contains("return __ReadBytesRaw(unchecked((nuint)address.ToUInt64()), count, values, out valuesCount);", + generated, StringComparison.Ordinal); + } + + [Fact] + [Trait("Qualification", "Q21")] + public void Address_facade_converts_through_ulong_without_a_floating_point_step() + { + GeneratorRun run = roslyn.Run("memory-scalars.cheatengine-sdk-api.txt", SpecSources.Memory); + + string generated = run.SingleGeneratedText; + Assert.Contains("unchecked((nuint)address.ToUInt64())", generated, StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(__L, address);", generated, + StringComparison.Ordinal); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.Push(__L, value);", generated, + StringComparison.Ordinal); + foreach (string lossy in (string[]) ["double", "float", "DoubleMarshaller", "SingleMarshaller", "PushNumber"]) + { + Assert.DoesNotContain(lossy, generated, StringComparison.Ordinal); + } + } + + [Fact] + public void Memory_scalars_output_is_unchanged_by_the_new_grammar() + { + GeneratorRun run = roslyn.Run("memory-scalars.cheatengine-sdk-api.txt", + ProductionSpecs.Text("memory-scalars.cheatengine-sdk-api.txt")); + string version = typeof(EngineApiGenerator).Assembly.GetName().Version!.ToString(); + string expected = ExpectedText("memory-scalars.EngineApi.g.cs.txt") + .Replace("{GeneratorVersion}", version, StringComparison.Ordinal); + + run.AssertCompilesClean(); + Assert.Equal(expected, run.SingleGeneratedText.ReplaceLineEndings("\n")); + } + + private static string ExpectedText(string fileName) + { + using Stream stream = typeof(EmissionTests).Assembly.GetManifestResourceStream( + "CheatEngine.SDK.EngineApi.Tests.Expected." + fileName) + ?? throw new InvalidOperationException("Missing expected text " + fileName + "."); + using StreamReader reader = new(stream); + return reader.ReadToEnd().ReplaceLineEndings("\n"); + } + private static int CountOccurrences(string text, string value) { int count = 0; diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EngineApiDiagnosticCatalogTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EngineApiDiagnosticCatalogTests.cs new file mode 100644 index 00000000..cb4eae1c --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/EngineApiDiagnosticCatalogTests.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using System.Reflection; + +using Microsoft.CodeAnalysis; + +namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Generator; + +/// +/// The EngineApi generator's own diagnostic catalog (binding DoD F): every descriptor has the identifier range, +/// category and help link of the SDK rules, a documentation page, a release-tracking row and a row in the rule index. +/// The Analyzers catalog test covers only the analyzer assembly, so the generator's CESDK3xxx rules are checked here. +/// +public sealed class EngineApiDiagnosticCatalogTests +{ + private const string HelpLinkBase = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/"; + + [Fact] + public void Every_engine_api_descriptor_has_a_page_a_help_link_and_a_release_row() + { + DiagnosticDescriptor[] descriptors = Descriptors(); + string root = RepositoryRoot(); + string releases = File.ReadAllText(Path.Combine(root, + "source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/AnalyzerReleases.Unshipped.md")); + string index = File.ReadAllText(Path.Combine(root, "analyzers/docs/README.md")); + + Assert.Equal(["CESDK3001", "CESDK3002", "CESDK3003", "CESDK3004", "CESDK3005"], + descriptors.Select(static d => d.Id).Order(StringComparer.Ordinal), StringComparer.Ordinal); + foreach (DiagnosticDescriptor descriptor in descriptors) + { + string title = descriptor.Title.ToString(CultureInfo.InvariantCulture); + Assert.Equal("CheatEngine.SDK.EngineApi", descriptor.Category); + Assert.Equal(DiagnosticSeverity.Error, descriptor.DefaultSeverity); + Assert.Equal(HelpLinkBase + descriptor.Id + ".md", descriptor.HelpLinkUri); + Assert.DoesNotMatch(@"\.$", title); + Assert.EndsWith(".", descriptor.Description.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal); + + string page = Path.Combine(root, "analyzers", "docs", descriptor.Id + ".md"); + Assert.True(File.Exists(page), "Missing documentation page " + page + "."); + Assert.StartsWith("# " + descriptor.Id + ": " + title, File.ReadAllText(page), StringComparison.Ordinal); + Assert.Contains(" " + descriptor.Id + " | CheatEngine.SDK.EngineApi | Error |", releases, + StringComparison.Ordinal); + Assert.Contains("| [" + descriptor.Id + "](" + descriptor.Id + ".md) | " + title, index, + StringComparison.Ordinal); + } + } + + private static DiagnosticDescriptor[] Descriptors() + { + return + [ + .. typeof(EngineApiGenerator).Assembly.GetType("CheatEngine.SDK.SourceGenerators.EngineApi.EngineApiDiagnostics", + true)! + .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Where(static field => field.FieldType == typeof(DiagnosticDescriptor)) + .Select(static field => (DiagnosticDescriptor) field.GetValue(null)!) + ]; + } + + // The directory that holds CheatEngine.SDK.slnx, found from the test binaries upwards. + private static string RepositoryRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "CheatEngine.SDK.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException("CheatEngine.SDK.slnx was not found above " + AppContext.BaseDirectory + "."); + } +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs index 11923f81..def3f8ca 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs @@ -117,24 +117,55 @@ public void Pipeline_removing_a_spec_file_removes_only_its_output() Assert.Equal(first.GeneratedTextByContent("TryReadInt32"), second.SingleGeneratedText, StringComparer.Ordinal); } + [Fact] + public void Pipeline_editing_an_optional_argument_reruns_only_that_files_output() + { + string optional = SpecSources.Ce77Header("Demo.Optional", "Optional") + + "global: load\nmethod: Load\nform: throwing\narg: path:string\nopt: merge:boolean\nnil: none\ndoc: Loads.\n"; + InMemoryAdditionalText original = new("a.cheatengine-sdk-api.txt", optional); + InMemoryAdditionalText other = new("b.cheatengine-sdk-api.txt", SpecSources.BeepOnly); + CSharpCompilation compilation = roslyn.CreateCompilation(); + GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original, other), compilation); + Assert.Equal(2, first.GeneratedSources.Length); + + InMemoryAdditionalText edited = new("a.cheatengine-sdk-api.txt", + optional.Replace("opt: merge:boolean", "opt: merge:int32", StringComparison.Ordinal)); + GeneratorRun second = GeneratorRun.Execute(first.Driver.ReplaceAdditionalText(original, edited), compilation); + + Assert.Contains(IncrementalStepRunReason.Modified, + StepAssert.Reasons(second.Result, EngineApiTrackingNames.ParsedSpec)); + Assert.Contains(IncrementalStepRunReason.Modified, + StepAssert.Reasons(second.Result, EngineApiTrackingNames.SpecFileOutput)); + Assert.Contains("global::CheatEngine.SDK.Lua.Marshalling.LuaOptional merge", + second.GeneratedTextByContent("Load("), StringComparison.Ordinal); + Assert.Equal(first.GeneratedTextByContent("Beep"), second.GeneratedTextByContent("Beep"), + StringComparer.Ordinal); + } + [Fact] public void Pipeline_step_values_hold_no_roslyn_objects() { - GeneratorRun run = roslyn.Run("a.cheatengine-sdk-api.txt", SpecSources.Memory); + string optional = SpecSources.Ce77Header("Demo.Optional", "Optional") + + "global: g\nmethod: G\nform: outcome\narg: a:address\nopt: b:int32\nresult: r:int64\nopt-result: s:address\nrest: values:double\nnil: absence\ndoc: d.\n"; + GeneratorRun run = roslyn.Run(("a.cheatengine-sdk-api.txt", SpecSources.Memory), + ("b.cheatengine-sdk-api.txt", optional)); + Assert.Equal(2, run.GeneratedSources.Length); int visited = 0; foreach (string stepName in EngineApiTrackingNames.All) { if (string.Equals(stepName, EngineApiTrackingNames.SpecTextFile, StringComparison.Ordinal)) - // Legitimately holds the raw AdditionalText: that is the point of this filter step. + // Legitimately holds the raw AdditionalText: that is the point of this filter step. { continue; } foreach (IncrementalGeneratorRunStep step in run.Result.TrackedSteps[stepName]) - foreach ((object value, IncrementalStepRunReason _) in step.Outputs) { - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/NoOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/NoOutputTests.cs index 300b65f1..f588a428 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/NoOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/NoOutputTests.cs @@ -37,12 +37,19 @@ public void A_spec_file_whose_only_entry_is_invalid_produces_no_output_and_repor const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: not a name method: Bad form: try arg: address:address result: value:int32 + nil: none doc: bad. """; diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs index 99962168..49ab4a53 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs @@ -43,7 +43,7 @@ public static GeneratedAssembly Load(GeneratorRun run) image.Position = 0; string assemblyName = "CheatEngine.SDK.EngineApi.Tests." + - Interlocked.Increment(ref s_counter).ToString(CultureInfo.InvariantCulture); + Interlocked.Increment(ref s_counter).ToString(CultureInfo.InvariantCulture); AssemblyLoadContext context = new(assemblyName); return new GeneratedAssembly(context.LoadFromStream(image)); } @@ -56,7 +56,7 @@ public TDelegate Delegate(string typeName, string methodName) Type[] parameterTypes = [.. parameters.Select(static parameter => parameter.ParameterType)]; Type type = Assembly.GetType(typeName, true)!; MethodInfo method = type.GetMethod(methodName, StaticMembers, parameterTypes) ?? - throw new MissingMethodException(typeName, methodName); + throw new MissingMethodException(typeName, methodName); return method.CreateDelegate(); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs index b9de061c..0a34b77d 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -61,8 +61,8 @@ public static ImmutableArray FromTargetingPack() { string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out Version? version) - && (bestVersion is null || version > bestVersion)) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) { best = candidate; bestVersion = version; diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs index a4030574..3a7c5c80 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs @@ -69,7 +69,7 @@ private static void Visit(object? value, string path, HashSet visited, i } foreach (FieldInfo field in - type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ProductionSpecs.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ProductionSpecs.cs new file mode 100644 index 00000000..e64aef7a --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ProductionSpecs.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using System.Xml.Linq; + +namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; + +/// +/// The production spec files of source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/ and the +/// CheatEngine.SDK.Engine project file that wires some of them, embedded into this test assembly at build time: +/// the tests read the committed inputs, never a workspace path at run time. +/// +internal static class ProductionSpecs +{ + private const string SpecPrefix = "CheatEngine.SDK.EngineApi.Tests.Specs."; + private const string EngineProjectResource = "CheatEngine.SDK.EngineApi.Tests.EngineProject.xml"; + private const string SpecDirectory = "source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Specs/"; + + private static readonly Assembly Resources = typeof(ProductionSpecs).Assembly; + + /// Every embedded production spec: file name to text, ordered by file name. + public static IReadOnlyList<(string FileName, string Text)> All + { + get + { + List<(string FileName, string Text)> specs = []; + foreach (string name in Resources.GetManifestResourceNames().Order(StringComparer.Ordinal)) + { + if (name.StartsWith(SpecPrefix, StringComparison.Ordinal)) + { + specs.Add((name[SpecPrefix.Length..], Read(name))); + } + } + + return specs; + } + } + + /// The text of one production spec, by file name. + public static string Text(string fileName) + { + return Read(SpecPrefix + fileName); + } + + /// + /// The spec file names the Engine project passes to the generator as AdditionalFiles (the wired specs). + /// + public static IReadOnlySet WiredFileNames() + { + XDocument project = XDocument.Parse(Read(EngineProjectResource)); + HashSet wired = new(StringComparer.Ordinal); + foreach (XElement item in project.Descendants("AdditionalFiles")) + { + string include = ((string?) item.Attribute("Include") ?? string.Empty).Replace('\\', '/'); + int index = include.IndexOf(SpecDirectory, StringComparison.Ordinal); + Assert.True(index >= 0, "An Engine AdditionalFiles item is not a production spec: " + include); + wired.Add(include[(index + SpecDirectory.Length)..]); + } + + return wired; + } + + private static string Read(string resourceName) + { + using Stream stream = Resources.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException("Missing embedded resource " + resourceName + "."); + using StreamReader reader = new(stream); + return reader.ReadToEnd(); + } +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/SpecSources.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/SpecSources.cs index defb5d17..904eece5 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/SpecSources.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/SpecSources.cs @@ -3,6 +3,19 @@ namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; /// Spec-file texts reused across the parser, generator and end-to-end tests. internal static class SpecSources { + /// + /// The CE 7.7 evidence keys every spec file with entries declares after namespace and type + /// (CESDK3003), with a fixture provenance. Each entry then also needs a nil key. + /// + public const string Ce77 = + "contract: ce77\nprovenance: ExactInstalledFile: CE 7.7 celua.txt test fixture\nminimum-ce: 7.7.0.10621\narchitecture: x64\nthread: unknown\nownership: none\n"; + + /// A ce77 header block for and , blank line included. + public static string Ce77Header(string ns, string type) + { + return "namespace: " + ns + "\ntype: " + type + "\n" + Ce77 + "\n"; + } + /// /// The nominal case: two scalar reads (Try form) and two scalar writes (throwing form, boolean return), sharing /// no global. @@ -57,12 +70,19 @@ internal static class SpecSources public const string SingleTry = """ namespace: Demo.One type: One + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: TryReadInt32 form: try arg: address:address result: value:int32 + nil: none doc: Reads a 32-bit integer. """; @@ -70,12 +90,19 @@ internal static class SpecSources public const string SharedGlobal = """ namespace: Demo.Shared type: Shared + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: TryReadInt32 form: try arg: address:address result: value:int32 + nil: none doc: Reads a 32-bit integer, reporting failure. global: readInteger @@ -83,6 +110,7 @@ internal static class SpecSources form: throwing arg: address:address return: int32 + nil: none doc: Reads a 32-bit integer, raising on failure. """; @@ -93,10 +121,17 @@ internal static class SpecSources public const string BeepOnly = """ namespace: Demo.Other type: Other + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: beep method: Beep form: throwing + nil: none doc: Calls a global with no arguments and no result. """; @@ -104,6 +139,12 @@ internal static class SpecSources public const string EndToEnd = """ namespace: Demo.EndToEnd type: MemoryScalars + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: TryReadInt32 @@ -111,6 +152,7 @@ internal static class SpecSources arg: address:address fixed: boolean:true result: value:int32 + nil: none doc: Reads a 32-bit integer from the target process at the given address. global: writeInteger @@ -119,6 +161,7 @@ internal static class SpecSources arg: address:address arg: value:int32 return: boolean + nil: none doc: Writes a 32-bit integer to the target process at the given address. global: readQword @@ -126,6 +169,7 @@ internal static class SpecSources form: try arg: address:address result: value:int64 + nil: none doc: Reads a 64-bit integer from the target process at the given address. global: writeQword @@ -134,11 +178,13 @@ internal static class SpecSources arg: address:address arg: value:int64 return: boolean + nil: none doc: Writes a 64-bit integer to the target process at the given address. global: beep method: Beep form: throwing + nil: none doc: Calls a global with no arguments and no result. """; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/ProductionSpecsTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/ProductionSpecsTests.cs new file mode 100644 index 00000000..b3c1c3f4 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/ProductionSpecsTests.cs @@ -0,0 +1,113 @@ +using CheatEngine.SDK.SourceGenerators.EngineApi.Model; +using CheatEngine.SDK.SourceGenerators.EngineApi.Parsing; +using CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Infrastructure; + +namespace CheatEngine.SDK.SourceGenerators.EngineApi.Tests.Parsing; + +/// +/// The committed production specs, parsed as the generator parses them (audit F10, A23-F10-1, A06-14, AX06-18, +/// SRC02-02): every spec is valid and declares the CE 7.7 contract with the pinned celua.txt digest, the wired +/// specs have entries, the unwired specs with entries are the reviewed reservations, and no spec claims main-thread +/// affinity without evidence. +/// +public sealed class ProductionSpecsTests +{ + // SHA-256 of the installed CE 7.7.0.10621 celua.txt every production spec cites (shared-contracts, support profile). + private const string CeluaSha256 = "AA1342B4A5D5D5C65B255FB3A8FD7B6BCBBAC1CD138961669D9F37F43E0B9C00"; + + // Unwired specs with entries, each reviewed: a wired spec may still be listed, so wiring one needs no edit here. + private static readonly HashSet ReviewedUnwiredSpecs = new(StringComparer.Ordinal) + { + "allocation-protection.cheatengine-sdk-api.txt", + "modules-symbols-regions.cheatengine-sdk-api.txt", + "runtime-capabilities.cheatengine-sdk-api.txt" + }; + + [Fact] + public void Every_production_spec_parses_without_issues() + { + Assert.Equal(7, ProductionSpecs.All.Count); + foreach ((string fileName, string text) in ProductionSpecs.All) + { + SpecFileModel spec = SpecFileParser.Parse(fileName, text); + + Assert.True(spec.Issues.IsEmpty, + fileName + ": " + string.Join(" | ", spec.Issues.AsImmutableArray().Select(static i => i.Line + ": " + i.Message))); + Assert.NotEqual(string.Empty, spec.TypeName, StringComparer.Ordinal); + } + } + + [Fact] + public void Every_production_spec_declares_the_ce77_contract() + { + foreach ((string fileName, string text) in ProductionSpecs.All) + { + SpecFileModel spec = SpecFileParser.Parse(fileName, text); + + SpecFileContract contract = Assert.IsType(spec.Contract); + Assert.Equal("7.7.0.10621", contract.MinimumCheatEngineVersion); + Assert.Equal("x64", contract.Architecture); + foreach (SpecCallModel call in spec.Calls) + { + Assert.NotNull(call.Contract); + Assert.False(string.IsNullOrEmpty(call.Contract.NilSemantics), fileName + " " + call.Call.MethodName); + } + } + } + + [Fact] + public void Every_production_spec_cites_the_pinned_celua_sha256() + { + foreach ((string fileName, string text) in ProductionSpecs.All) + { + SpecFileContract contract = Assert.IsType(SpecFileParser.Parse(fileName, text).Contract); + + Assert.StartsWith("ExactInstalledFile: CE 7.7.0.10621 celua.txt", contract.Provenance, StringComparison.Ordinal); + Assert.EndsWith("SHA-256 " + CeluaSha256, contract.Provenance, StringComparison.Ordinal); + } + } + + [Fact] + public void Every_wired_spec_has_entries_and_no_issue() + { + IReadOnlySet wired = ProductionSpecs.WiredFileNames(); + + Assert.Contains("memory-scalars.cheatengine-sdk-api.txt", wired); + foreach (string fileName in wired) + { + SpecFileModel spec = SpecFileParser.Parse(fileName, ProductionSpecs.Text(fileName)); + + Assert.True(spec.Issues.IsEmpty, fileName); + Assert.False(spec.Calls.IsEmpty, fileName + " is wired but generates nothing."); + } + } + + [Fact] + public void Unwired_specs_with_entries_are_reviewed_reservations() + { + IReadOnlySet wired = ProductionSpecs.WiredFileNames(); + foreach ((string fileName, string text) in ProductionSpecs.All) + { + SpecFileModel spec = SpecFileParser.Parse(fileName, text); + if (wired.Contains(fileName) || spec.Calls.IsEmpty) + { + continue; + } + + Assert.True(ReviewedUnwiredSpecs.Contains(fileName), + fileName + " declares entries but is neither wired into CheatEngine.SDK.Engine nor a reviewed reservation."); + } + } + + [Fact] + public void Production_specs_never_claim_main_thread_affinity_without_evidence() + { + foreach ((string fileName, string text) in ProductionSpecs.All) + { + SpecFileContract contract = Assert.IsType(SpecFileParser.Parse(fileName, text).Contract); + + Assert.True(contract.ThreadAffinity is "unknown" or "any", + fileName + " claims thread '" + contract.ThreadAffinity + "' without CE 7.7 evidence (audit SRC02-02)."); + } + } +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs index 0a14a9fd..37a52a68 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs @@ -74,6 +74,13 @@ public void Comments_and_blank_lines_are_ignored_wherever_they_appear() namespace: Demo # a comment between header keys type: T + contract: ce77 + # a comment between contract keys + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none # a comment before an entry global: readInteger @@ -82,6 +89,7 @@ public void Comments_and_blank_lines_are_ignored_wherever_they_appear() form: try arg: address:address result: value:int32 + nil: none doc: Reads an integer. # a trailing comment """; @@ -97,8 +105,9 @@ public void Indentation_and_CRLF_line_endings_are_tolerated() { string text = SpecSources.SingleTry.Replace("\r\n", "\n", StringComparison.Ordinal) .Replace("\n", "\r\n", StringComparison.Ordinal); - text = " namespace: Demo.One\r\n type: One\r\n\r\n" + - text[text.IndexOf("global:", StringComparison.Ordinal)..]; + text = " namespace: Demo.One\r\n type: One\r\n" + + SpecSources.Ce77.Replace("\n", "\r\n ", StringComparison.Ordinal) + "\r\n" + + text[text.IndexOf("global:", StringComparison.Ordinal)..]; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); @@ -133,12 +142,19 @@ public void The_global_namespace_is_written_as_an_empty_namespace_value() const string Text = """ namespace: type: Root + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: TryReadInt32 form: try arg: address:address result: value:int32 + nil: none doc: Reads an integer. """; @@ -155,12 +171,19 @@ public void A_line_without_a_colon_marks_its_whole_block_malformed() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global readInteger method: TryReadInt32 form: try arg: address:address result: value:int32 + nil: none doc: Reads an integer. """; @@ -247,6 +270,7 @@ public void An_entry_missing_a_required_key_is_dropped(string missingKey) form: try arg: address:address result: value:int32 + nil: none doc: Reads an integer. """; @@ -254,7 +278,7 @@ public void An_entry_missing_a_required_key_is_dropped(string missingKey) '\n', entry.Split('\n').Where(line => !line.StartsWith(missingKey + ":", StringComparison.Ordinal))); - SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", "namespace: Demo\ntype: T\n\n" + edited); + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", SpecSources.Ce77Header("Demo", "T") + edited); Assert.Empty(spec.Calls.AsSpan().ToArray()); Assert.Contains(spec.Issues, issue => issue.Message.Contains("'" + missingKey + "'", StringComparison.Ordinal)); @@ -266,12 +290,19 @@ public void An_unknown_entry_key_drops_the_entry() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: TryReadInt32 form: try arg: address:address result: value:int32 + nil: none doc: Reads an integer. extra: nonsense """; @@ -289,6 +320,12 @@ public void A_duplicate_entry_key_drops_the_entry() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger global: writeInteger @@ -296,6 +333,7 @@ public void A_duplicate_entry_key_drops_the_entry() form: try arg: address:address result: value:int32 + nil: none doc: Reads an integer. """; @@ -312,8 +350,8 @@ public void A_duplicate_entry_key_drops_the_entry() [InlineData("end")] // a Lua reserved word public void An_invalid_lua_global_name_drops_the_entry(string badName) { - string text = "namespace: Demo\ntype: T\n\nglobal: " + badName + - "\nmethod: M\nform: try\narg: address:address\nresult: value:int32\ndoc: d.\n"; + string text = "namespace: Demo\ntype: T\n" + SpecSources.Ce77 + "\nglobal: " + badName + + "\nmethod: M\nform: try\narg: address:address\nresult: value:int32\nnil: none\ndoc: d.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); @@ -328,12 +366,19 @@ public void An_invalid_method_name_drops_the_entry() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: 1Bad form: try arg: address:address result: value:int32 + nil: none doc: d. """; @@ -350,12 +395,19 @@ public void A_method_name_that_is_a_reserved_word_is_escaped_with_at() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: class form: try arg: address:address result: value:int32 + nil: none doc: d. """; @@ -371,12 +423,19 @@ public void An_invalid_form_value_drops_the_entry() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: M form: maybe arg: address:address result: value:int32 + nil: none doc: d. """; @@ -393,11 +452,18 @@ public void A_try_entry_with_no_result_is_dropped() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: M form: try arg: address:address + nil: none doc: d. """; @@ -414,6 +480,12 @@ public void A_try_entry_with_a_return_is_dropped() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: M @@ -421,6 +493,7 @@ public void A_try_entry_with_a_return_is_dropped() arg: address:address result: value:int32 return: int32 + nil: none doc: d. """; @@ -437,12 +510,19 @@ public void A_throwing_entry_with_a_result_is_dropped() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: writeInteger method: M form: throwing arg: address:address result: value:int32 + nil: none doc: d. """; @@ -459,10 +539,17 @@ public void A_throwing_entry_without_a_return_is_a_void_wrapper() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: beep method: Beep form: throwing + nil: none doc: Calls a global with no arguments and no result. """; @@ -480,8 +567,8 @@ public void A_throwing_entry_without_a_return_is_a_void_wrapper() [InlineData("arg: 1bad:address")] public void A_malformed_or_unknown_kind_argument_drops_the_entry(string argLine) { - string text = "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: M\nform: try\n" + argLine + - "\nresult: value:int32\ndoc: d.\n"; + string text = "namespace: Demo\ntype: T\n" + SpecSources.Ce77 + "\nglobal: readInteger\nmethod: M\nform: try\n" + argLine + + "\nresult: value:int32\nnil: none\ndoc: d.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); @@ -495,8 +582,8 @@ public void A_malformed_or_unknown_kind_argument_drops_the_entry(string argLine) [InlineData("fixed: boolean:true; System.Console.WriteLine()")] public void A_fixed_argument_accepts_only_boolean_literals(string fixedLine) { - string text = "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: M\nform: try\narg: address:address\n" + - fixedLine + "\nresult: value:int32\ndoc: d.\n"; + string text = "namespace: Demo\ntype: T\n" + SpecSources.Ce77 + "\nglobal: readInteger\nmethod: M\nform: try\narg: address:address\n" + + fixedLine + "\nresult: value:int32\nnil: none\ndoc: d.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); @@ -511,12 +598,19 @@ public void A_utf8_result_is_rejected_because_it_would_dangle() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: M form: try arg: address:address result: value:utf8 + nil: none doc: d. """; @@ -533,12 +627,19 @@ public void A_utf8_return_is_rejected_because_it_would_dangle() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: M form: throwing arg: address:address return: utf8 + nil: none doc: d. """; @@ -555,12 +656,19 @@ public void An_invalid_return_kind_drops_the_entry() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: M form: throwing arg: address:address return: notakind + nil: none doc: d. """; @@ -577,12 +685,19 @@ public void A_string_argument_may_be_declared_nullable() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: upper method: Upper form: throwing arg: text:string? return: string + nil: none doc: Upper-cases a string. """; @@ -600,12 +715,19 @@ public void A_duplicate_method_name_drops_every_entry_that_uses_it_with_one_issu const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: readInteger method: M form: try arg: address:address result: value:int32 + nil: none doc: first. global: readQword @@ -613,6 +735,7 @@ public void A_duplicate_method_name_drops_every_entry_that_uses_it_with_one_issu form: try arg: address:address result: value:int64 + nil: none doc: second. """; @@ -631,12 +754,19 @@ public void An_invalid_entry_does_not_prevent_other_entries_from_being_emitted() const string Text = """ namespace: Demo type: T + contract: ce77 + provenance: ExactInstalledFile: CE 7.7 celua.txt test fixture + minimum-ce: 7.7.0.10621 + architecture: x64 + thread: unknown + ownership: none global: notAName! method: Bad form: try arg: address:address result: value:int32 + nil: none doc: bad. global: readInteger @@ -644,6 +774,7 @@ public void An_invalid_entry_does_not_prevent_other_entries_from_being_emitted() form: try arg: address:address result: value:int32 + nil: none doc: good. """; @@ -733,12 +864,12 @@ public void Invalid_ce77_contract_facts_report_each_exact_value_location() public void An_invalid_argument_kind_records_its_value_column() { const string Text = - "namespace: Demo\ntype: T\n\n global: readInteger\n method: M\n form: try\n arg: address:notakind\n result: value:int32\n doc: d.\n"; + "namespace: Demo\ntype: T\n" + SpecSources.Ce77 + "\n global: readInteger\n method: M\n form: try\n arg: address:notakind\n result: value:int32\n nil: none\n doc: d.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); SpecIssue issue = Assert.Single(spec.Issues); - Assert.Equal(7, issue.Line); + Assert.Equal(13, issue.Line); Assert.Equal(10, issue.Column); } @@ -747,7 +878,7 @@ public void An_invalid_argument_kind_records_its_value_column() public void Parameter_and_generated_member_identity_collisions_drop_the_affected_entries() { const string Text = - "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: BadParameter\nform: try\narg: __L:int32\nresult: value:int32\ndoc: bad.\n\nglobal: readInteger\nmethod: Read\nform: try\narg: address:address\nresult: value:int32\ndoc: raw core.\n\nglobal: readQword\nmethod: __ReadRaw\nform: try\nresult: value:int64\ndoc: collision.\n"; + "namespace: Demo\ntype: T\n" + SpecSources.Ce77 + "\nglobal: readInteger\nmethod: BadParameter\nform: try\narg: __L:int32\nresult: value:int32\nnil: none\ndoc: bad.\n\nglobal: readInteger\nmethod: Read\nform: try\narg: address:address\nresult: value:int32\nnil: none\ndoc: raw core.\n\nglobal: readQword\nmethod: __ReadRaw\nform: try\nresult: value:int64\nnil: none\ndoc: collision.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); @@ -762,7 +893,7 @@ public void Parameter_and_generated_member_identity_collisions_drop_the_affected public void A_parameter_named_operation_is_rejected_as_an_emitter_local_collision() { const string Text = - "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: BadOperation\nform: try\narg: __operation:int32\nresult: value:int32\ndoc: bad.\n"; + "namespace: Demo\ntype: T\n" + SpecSources.Ce77 + "\nglobal: readInteger\nmethod: BadOperation\nform: try\narg: __operation:int32\nresult: value:int32\nnil: none\ndoc: bad.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", Text); @@ -772,6 +903,142 @@ public void A_parameter_named_operation_is_rejected_as_an_emitter_local_collisio Assert.Contains("reserved local", issue.Message, StringComparison.Ordinal); } + [Fact] + public void Spec_with_entries_without_contract_reports_the_missing_ce77_contract() + { + const string Legacy = + "# a legacy fixture\nnamespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: TryReadInt32\nform: try\narg: address:address\nresult: value:int32\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("legacy.cheatengine-sdk-api.txt", Legacy); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + SpecIssue issue = Assert.Single(spec.Issues); + Assert.Equal(SpecIssueKind.MissingContract, issue.Kind); + Assert.Equal(2, issue.Line); + Assert.Equal(1, issue.Column); + Assert.Contains("'contract: ce77'", issue.Message, StringComparison.Ordinal); + + // A header-only reservation stays readable without the contract: it generates nothing either way. + SpecFileModel reservation = SpecFileParser.Parse("reservation.cheatengine-sdk-api.txt", "namespace: Demo\ntype: T\n"); + Assert.True(reservation.Issues.IsEmpty); + Assert.Null(reservation.Contract); + } + + [Fact] + public void Fixed_and_optional_arguments_keep_their_declaration_order() + { + string text = SpecSources.Ce77Header("Demo", "T") + + "global: g\nmethod: G\nform: throwing\narg: a:int32\nfixed: boolean:true\narg: b:int64\nopt: c:address\nopt: d:string\nnil: none\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.True(spec.Issues.IsEmpty); + LuaArgumentModel[] arguments = spec.Calls[0].Call.Arguments.AsSpan().ToArray(); + Assert.Equal(["a", "true", "b", "c", "d"], arguments.Select(static a => a.Name), StringComparer.Ordinal); + Assert.Equal([false, true, false, false, false], arguments.Select(static a => a.IsFixed)); + Assert.Equal([false, false, false, true, true], arguments.Select(static a => a.IsOptional)); + Assert.Equal(LuaValueKind.Address, arguments[3].Kind); + Assert.Equal("global::CheatEngine.SDK.Lua.Marshalling.LuaOptional", arguments[4].GeneratedTypeName); + } + + [Theory] + [InlineData("opt: a:int32\narg: b:int32\n", "'b:int32' follows an 'opt' argument")] + [InlineData("opt: a:int32\nfixed: boolean:true\n", "'boolean:true' follows an 'opt' argument")] + [InlineData("opt: a:utf8\n", "'utf8' cannot be an 'opt' kind")] + [InlineData("opt: a:string?\n", "'string?' cannot be an 'opt' kind")] + public void Argument_after_an_optional_argument_is_refused(string arguments, string message) + { + string text = SpecSources.Ce77Header("Demo", "T") + "global: g\nmethod: G\nform: throwing\n" + arguments + + "nil: none\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + SpecIssue issue = Assert.Single(spec.Issues); + Assert.Equal(SpecIssueKind.OptionalArgument, issue.Kind); + Assert.Contains(message, issue.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("try", "opt-result: a:int32\nresult: b:int32\n", "'b:int32' is a required 'result' after an 'opt-result'")] + [InlineData("outcome", "rest: a:int64\nresult: b:int32\n", "'b:int32' must be declared before the 'rest' result")] + [InlineData("outcome", "opt-result: a:string?\n", "'string?' cannot be an 'opt-result' kind")] + [InlineData("outcome", "rest: a:address\n", "'address' cannot be a 'rest' kind")] + [InlineData("outcome", "rest: a:string\n", "'string' cannot be a 'rest' kind")] + public void Optional_result_before_a_required_result_is_refused(string form, string results, string message) + { + string text = SpecSources.Ce77Header("Demo", "T") + "global: g\nmethod: G\nform: " + form + "\n" + results + + "nil: none\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + SpecIssue issue = Assert.Single(spec.Issues); + Assert.Equal(SpecIssueKind.ResultShape, issue.Kind); + Assert.Contains(message, issue.Message, StringComparison.Ordinal); + } + + [Fact] + public void Outcome_form_is_parsed() + { + string text = SpecSources.Ce77Header("Demo", "T") + + "global: g\nmethod: G\nform: outcome\narg: a:address\nresult: first:int64\nopt-result: second:address\nrest: values:double\nnil: absence\ndoc: d.\n\n" + + "global: beep\nmethod: Beep\nform: outcome\nnil: none\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.True(spec.Issues.IsEmpty); + SpecCallModel beep = spec.Calls[0]; + Assert.Equal(LuaCallForm.Outcome, beep.Call.Form); + Assert.True(beep.Call.Results.IsEmpty); + LuaGlobalCallModel call = spec.Calls[1].Call; + Assert.Equal(LuaCallForm.Outcome, call.Form); + Assert.Equal([LuaResultShape.Value, LuaResultShape.Optional, LuaResultShape.Variadic], + call.Results.AsSpan().ToArray().Select(static r => r.Shape)); + Assert.Equal("values", call.Results[2].DestinationName); + Assert.Equal("valuesCount", call.Results[2].Name); + Assert.True(call.HasDynamicResults); + } + + [Fact] + public void Rest_result_requires_the_outcome_form() + { + string text = SpecSources.Ce77Header("Demo", "T") + + "global: g\nmethod: TryG\nform: try\nresult: first:int32\nrest: values:int32\nnil: none\ndoc: d.\n"; + + SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); + + Assert.Empty(spec.Calls.AsSpan().ToArray()); + SpecIssue issue = Assert.Single(spec.Issues); + Assert.Equal(SpecIssueKind.ResultShape, issue.Kind); + Assert.Equal(14, issue.Line); + Assert.Equal(7, issue.Column); + Assert.Contains("only the 'outcome' form", issue.Message, StringComparison.Ordinal); + } + + [Fact] + public void Reformatting_a_spec_keeps_its_contract_and_call_model() + { + string compact = SpecSources.Ce77Header("Demo", "T") + + "global: g\nmethod: G\nform: outcome\narg: a:int32\nopt: b:address\nresult: r:int64\nopt-result: s:string\nnil: absence\ndoc: Reads.\n"; + string reformatted = "# reformatted\r\n namespace: Demo \r\n\ttype: T\r\n" + + SpecSources.Ce77.Replace("\n", "\r\n # a comment inside the header\r\n", StringComparison.Ordinal) + + "\r\n\r\n # a comment before the entry\r\n global: g\r\n method: G\r\n # a comment inside the entry\r\n" + + " form:outcome\r\n arg: a:int32\r\n opt: b:address \r\n result: r:int64\r\n opt-result: s:string\r\n" + + " nil: absence\r\n doc: Reads.\r\n\r\n"; + + SpecFileModel first = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", compact); + SpecFileModel second = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", reformatted); + + Assert.True(first.Issues.IsEmpty); + Assert.True(second.Issues.IsEmpty, + string.Join(" | ", second.Issues.AsImmutableArray().Select(static i => i.Line + ": " + i.Message))); + Assert.Equal(first.Contract, second.Contract); + Assert.Equal(first.Calls[0].Call, second.Calls[0].Call); + Assert.Equal(first.Calls[0].Contract, second.Calls[0].Contract); + Assert.Equal(first.Calls[0].Summary, second.Calls[0].Summary); + } + private static void AssertIssue(SpecFileModel spec, string messageFragment, int line, int column) { SpecIssue issue = Assert.Single(spec.Issues.AsSpan().ToArray(), issue => diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/README.md b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/README.md index 4eddeb38..4c32975f 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/README.md +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/README.md @@ -19,14 +19,14 @@ directly. See the [generator README](../../source-generators/CheatEngine.SDK.Sou ## How it works -| Suite | What it proves | -|----------------|------------------------------------------------------------------------------------------------------------------| -| Parsing | `SpecFileParser.Parse` and `IsSpecFile` handle the grammar and report issues, with no Roslyn type involved | -| Emission | Exact text of a wrapper body, one cache field per global, the global namespace, a clean compilation | -| Diagnostics | A malformed entry or CE 7.7 contract is a localized `CESDK3001`; a conflicting generated identity is `CESDK3002` | -| Silence | A foreign file name, a header-only file, or no spec at all emits nothing and reports no diagnostic | -| Incrementality | An unrelated edit recomputes nothing, and one spec change reruns only that file's output | -| End to end | Emitted wrappers are compiled, loaded and called against Lua stand-ins for `readInteger` and its siblings | +| Suite | What it proves | +|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Parsing | `SpecFileParser.Parse` and `IsSpecFile` handle the grammar (`contract: ce77`, `opt`, `opt-result`, `rest`, `form: outcome`) and report issues; every production spec parses clean (`ProductionSpecsTests`) | +| Emission | Exact text of a wrapper body, one cache field per global, the global namespace, a clean compilation | +| Diagnostics | A malformed entry is `CESDK3001`, a conflicting identity `CESDK3002`, a missing ce77 contract `CESDK3003`, an invalid optional argument or result `CESDK3004`/`CESDK3005` (`EngineApiDiagnosticCatalogTests`) | +| Silence | A foreign file name, a header-only file, or no spec at all emits nothing and reports no diagnostic | +| Incrementality | An unrelated edit recomputes nothing, and one spec change reruns only that file's output | +| End to end | Emitted wrappers are compiled, loaded and called against Lua stand-ins, including the optional grammar (`EngineApiOptionalEndToEndTests`) | The generator reads only `AdditionalTextsProvider`. So the harness compiles an almost empty compilation and passes spec text through `InMemoryAdditionalText`. The compilation references the real `CheatEngine.SDK.Annotations`, @@ -60,3 +60,8 @@ dotnet test --project tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests --f - Unchanged input recomputes nothing, and step values hold no Roslyn objects (`IncrementalityTests`). - The 32-bit and 64-bit wrappers use independent storage, a detached runtime throws, and the warm success path allocates nothing (`MemoryScalarsEndToEndTests`). +- Every production spec declares `contract: ce77` and parses without an issue, and the committed memory-scalars output + is byte-for-byte unchanged by the new grammar (`ProductionSpecsTests`, `EmissionTests`). +- `opt` arguments, `opt-result` and `rest` results behave as declared on a real Lua state + (`EngineApiOptionalEndToEndTests`), and the `Address` facade converts through `ulong`, never through a floating-point + step (`EmissionTests`, traited `Qualification=Q21`). diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/packages.lock.json b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/packages.lock.json new file mode 100644 index 00000000..4902922c --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/packages.lock.json @@ -0,0 +1,485 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing": { + "type": "Direct", + "requested": "[1.1.4, )", + "resolved": "1.1.4", + "contentHash": "gdwvnsx9QrPfU3Uz4iJNhJFoJRL5Al/F0VRoFE6+QgBmibJbEfZf06v5B+NDg14QzGIiU2OP9kEQM20lx7rZdw==", + "dependencies": { + "Microsoft.CodeAnalysis.CSharp.Workspaces": "3.8.0", + "Microsoft.CodeAnalysis.SourceGenerators.Testing": "[1.1.4]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "D2zqK/k16fto0yMz0hcXMTkzOxEwMDJyA1mu/KXF9Befwz4zub3MpHQD8FeRxJtVSSsC3dQFYBw7zu7r/pfO7g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.CSharp": "[5.9.0]", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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]" + } + }, + "DiffPlex": { + "type": "Transitive", + "resolved": "1.7.2", + "contentHash": "qJEjdxEDBWSFZGB8paBB9HDeJXHGlHlOXeGX3kbTuXWuOsgv2iSAEOOzo5V1/B39Vcxr9IVVrNKewRcX+rsn4g==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "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.CodeAnalysis.Analyzer.Testing": { + "type": "Transitive", + "resolved": "1.1.4", + "contentHash": "IQoZbcXE7uVLngTtFFkSnh813ItZj4+nDfW7L0F1p9YaTlpefyL+poge3J1+eRSgreiFpBmTK4HI/kZhGJMVNg==", + "dependencies": { + "DiffPlex": "1.7.2", + "Microsoft.CodeAnalysis.Workspaces.Common": "1.0.1", + "Microsoft.VisualStudio.Composition": "16.1.8", + "NuGet.Common": "7.0.3", + "NuGet.Packaging": "7.0.3", + "NuGet.Protocol": "7.0.3", + "NuGet.Resolver": "7.0.3" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + } + }, + "Microsoft.CodeAnalysis.SourceGenerators.Testing": { + "type": "Transitive", + "resolved": "1.1.4", + "contentHash": "Vog522X/3d0vKMAHzZAkMrwSII+ticKSw3ioGLCFHGeishJNjl5irkWRoACuDRQ0dSbWj2UkMgNan9kIGX1tVw==", + "dependencies": { + "DiffPlex": "1.7.2", + "Microsoft.CodeAnalysis.Analyzer.Testing": "[1.1.4]", + "Microsoft.CodeAnalysis.Workspaces.Common": "3.8.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "1A6jz50NG4nOEW8tX5+h+MyHqjWL0mPGwrUdwu+OlTfyknLo0GfxSqj4zEks8uVUdHdo9v8Ir9dHxACf8iYNEA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.VisualStudio.Composition": { + "type": "Transitive", + "resolved": "16.1.8", + "contentHash": "N+thv3dcT7kjn0Xz3U0uBm2CH4uoaMvH8wC6Gy2HWx7HLNdEpqGoMraLyoBdizmypD1owLCJQIa2uKmWe4/o8A==", + "dependencies": { + "Microsoft.VisualStudio.Composition.NetFxAttributes": "16.1.8", + "Microsoft.VisualStudio.Validation": "15.0.82", + "System.Composition": "1.0.31" + } + }, + "Microsoft.VisualStudio.Composition.NetFxAttributes": { + "type": "Transitive", + "resolved": "16.1.8", + "contentHash": "EbwZWTvdzit68qZSuTI8nd1PZ87pYjhpCwtsis8lrUKJ7XLdbE5rxY6YrY7OFze+YUsguzqZlNjX4Yn5nL9qBw==", + "dependencies": { + "System.ComponentModel.Composition": "4.5.0" + } + }, + "Microsoft.VisualStudio.Validation": { + "type": "Transitive", + "resolved": "15.0.82", + "contentHash": "XwZyVCsHuEtnd6nYScJnA8XkXPzy4Ok0DV5/hqqAe5ccgOhJ6yap7Qh/sU/i6QxEzuYyECPYDQ7IOyEQ3yRQgQ==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Common": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "vFBP1TkmeFxUjOqlGn7M3QbZfsg+hyFcCUFy8hPCdmk9VPhFQPwUMH4zKZnd8Bf8x89CFnpRXb8EC6tmWfDgNA==", + "dependencies": { + "NuGet.Frameworks": "7.0.3" + } + }, + "NuGet.Configuration": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "DZC5/eP6X0qDU4FK1X318nT9gBmjbBOQxphEFnn67B5kKi1/ODRANGOlnNn0tUkZDFtPtLl7gC6bfnf/cM7j5Q==", + "dependencies": { + "NuGet.Common": "7.0.3", + "System.Security.Cryptography.ProtectedData": "9.0.6" + } + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "JsV24AwAS93mQhlJr1Fx9zGHO6dVkexYp8ZZoDuoIBLyN9KwPEZOqo2SJVq0k2EPYO2oomj96Ue2yrO0hMOHrw==" + }, + "NuGet.Packaging": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "eMdAeZU4ugSC5WwO2u9CcY+jBjt/E6YunQ25oO6M5mM5A5+5QM1AdfoZPtFfwCuIgb/tbY52ciGOAXpBAqLVDA==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "NuGet.Configuration": "7.0.3", + "NuGet.Versioning": "7.0.3", + "System.Security.Cryptography.Pkcs": "9.0.6" + } + }, + "NuGet.Protocol": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "B9BwrQ0sOsSuCFijIL1LmX/hVVIhDf1WmI86neWak1yVvQ2r13TuFbMok6LXUM5htU8890XinB5ouE5hMHaxTA==", + "dependencies": { + "NuGet.Packaging": "7.0.3" + } + }, + "NuGet.Resolver": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "3L5FNJmHSBhHQms7YKrrMjPn5Q1iTgi0Ws0nyYN0cPeGKpA4p4Ey3X9oWWqz9FspTI7IqiPEu79NB7ipBYafvw==", + "dependencies": { + "NuGet.Protocol": "7.0.3" + } + }, + "NuGet.Versioning": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "jwZmJzau0kUTxUvtmjbEXyvhx0ui9wGPByDT8qV3qDSVK9rChgsSeZqb1Bybu258G9urdEcE9HqZCdRbi7h9BA==" + }, + "System.ComponentModel.Composition": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "+iB9FoZnfdqMEGq6np28X6YNSUrse16CakmIhV3h6PxEWt7jYxUN3Txs1D8MZhhf4QmyvK0F/EcIN0f4gGN0dA==", + "dependencies": { + "System.Security.Permissions": "4.5.0" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DJkqaWQfho/ReTzKcZD3zJJ6K4GcS154k+T0UCPMBNIOZ2U/lNpyiiWZ6Etw0onWyTH1K+yhICsdmwA5xy2aPQ==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Convention": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1", + "System.Composition.TypedParts": "10.0.1" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "mRxYvpCVPAeuLEk0c0kxWJVjbW1/HUoxCgYotOj9eDeQiYcTDOMdCQApsTrHYMN3pHBA8WoF00KGolG632Etaw==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "dYynUByfVBzYDheNPGxS8UN8AvG/4tXf/coSs1odHOyoh4etv1kad/FrLWLMq4f8NO49NV20Xu+0/y613woTUA==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4UGmyBdKWEN1nkqspJlji/nV7XIVm6KGlOC2So0mtM/gKvaNgLz+tUkcbY+6Zpr7dr6ohX1S5yl0RLID5otRHw==", + "dependencies": { + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "TVfys1gnUIhmXuYfFzyez0fOkDyELe9UwlxYeVlq6FmqmWmt1ouF0OQJ+6ozkHbkaop7uBUaXw7Qb+/o0m+nMg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "koSfjkdQZfgQr3SyiSIBboDn+GiR0vZ3x9Uek9FJbXK0w5AiATV8KrnMEP8B8OAlO+Y3zQf0CPCNzwH+VIYDKg==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "Gny8p2mX0jc5rjh+PA4Gx5GG66sj2C+e+ro7+j/3IsKT/bmQ84tGRV+XKaG+5/CTCdwkSSKDWEQ1rJd0J5jE0Q==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "yErfw/3pZkJE/VKza/Cm5idTpIKOy/vsmVi59Ta5SruPVtubzxb8CtnE8tyUpzs5pr0Y28GUFfSVzAhCLN3F/Q==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "9gdyuARhUR7H+p5CjyUB/zPk7/Xut3wUSP8NJQB6iZr8L3XUXTMdoLeVAg9N4rqF8oIpE7MpdqHdDHQ7XgJe0g==" + }, + "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.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.sourcegenerators.engineapi": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.SourceGenerators.Shared": "[1.0.0, )" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + }, + "cheatengine.sdk.tests.shared": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs index 5399c285..eb0bdbdc 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs @@ -187,8 +187,8 @@ private static void AssertNoSourceErrors(GeneratorRun run) Assert.DoesNotContain( run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken), static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error - && (diagnostic.Location.SourceTree is null - || !diagnostic.Location.SourceTree.FilePath.EndsWith(".g.cs", - StringComparison.Ordinal))); + && (diagnostic.Location.SourceTree is null + || !diagnostic.Location.SourceTree.FilePath.EndsWith(".g.cs", + StringComparison.Ordinal))); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs index 249eef09..6700113a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs @@ -260,10 +260,14 @@ public void Pipeline_step_values_hold_no_roslyn_objects() int visited = 0; foreach (string stepName in EntryPointTrackingNames.All) - foreach (IncrementalGeneratorRunStep step in run.Result.TrackedSteps[stepName]) - foreach ((object value, IncrementalStepRunReason _) in step.Outputs) { - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + foreach (IncrementalGeneratorRunStep step in run.Result.TrackedSteps[stepName]) + { + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } + } } Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NominalOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NominalOutputTests.cs index 78243f61..e4c12e78 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NominalOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/NominalOutputTests.cs @@ -6,6 +6,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; namespace CheatEngine.SDK.SourceGenerators.EntryPoint.Tests.Generator; @@ -96,4 +97,40 @@ public void Generator_any_input_reports_no_diagnostics() Assert.Empty(invalid.GeneratorDiagnostics); Assert.Empty(invalid.Result.Diagnostics); } + + [Fact] + public void Generator_bootstrap_constructs_the_plugin_without_reflection() + { + // Audit A04-13: the plugin type is known at compile time; eng/BannedSymbols.txt bans the reflection routes. + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + string text = run.SingleGeneratedText; + + foreach (string reflective in (string[]) ["Activator", "CreateInstance", "GetTypes", "GetType(", "typeof(", + "System.Reflection", "Assembly."]) + { + Assert.DoesNotContain(reflective, text, StringComparison.Ordinal); + } + + Assert.Contains("Create() => new global::Demo.DemoPlugin();", text, StringComparison.Ordinal); + } + + [Fact] + public void Generator_entry_point_catches_every_exception_before_native_code() + { + GeneratorRun run = roslyn.Run(PluginSources.Nominal); + MethodDeclarationSyntax initialize = CSharpSyntaxTree.ParseText(run.SingleGeneratedText, + cancellationToken: TestContext.Current.CancellationToken) + .GetRoot(TestContext.Current.CancellationToken).DescendantNodes().OfType() + .Single(static method => + string.Equals(method.Identifier.ValueText, "CEPluginInitialize", StringComparison.Ordinal)); + + // One try statement is the whole body; its only handler catches System.Exception unfiltered and returns 0. + TryStatementSyntax body = Assert.IsType(Assert.Single(initialize.Body!.Statements)); + CatchClauseSyntax handler = Assert.Single(body.Catches); + Assert.Equal("global::System.Exception", handler.Declaration!.Type.ToString()); + Assert.Null(handler.Filter); + Assert.Null(body.Finally); + ReturnStatementSyntax fallback = Assert.IsType(Assert.Single(handler.Block.Statements)); + Assert.Equal("0", fallback.Expression!.ToString()); + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs index d6c8333c..025f7679 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs @@ -87,7 +87,7 @@ [new EntryPointGenerator().AsSourceGenerator()], .. outputCompilation .GetDiagnostics(TestContext.Current.CancellationToken) .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) + !IsMissingDocumentationInTestInput(diagnostic)) ]; Assert.True( @@ -101,7 +101,7 @@ .. outputCompilation private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) { return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs index ff87ce3a..fcf3ffe4 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs @@ -76,7 +76,7 @@ public void AssertCompilesClean() .. OutputCompilation .GetDiagnostics(TestContext.Current.CancellationToken) .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) + !IsMissingDocumentationInTestInput(diagnostic)) ]; Assert.True(problems.Length == 0, @@ -88,7 +88,7 @@ .. OutputCompilation private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) { return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs index 138fd492..3a6d4aa7 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs @@ -25,7 +25,7 @@ private LoadedBootstrap(AssemblyLoadContext context, Assembly hosting, Assembly // Same lookup as the host: type 'CESDK.CESDK' in the plugin assembly, static method 'CEPluginInitialize'. Type entryPoint = plugin.GetType("CESDK.CESDK", true)!; MethodInfo method = entryPoint.GetMethod("CEPluginInitialize", BindingFlags.Public | BindingFlags.Static) - ?? throw new MissingMethodException("CESDK.CESDK", "CEPluginInitialize"); + ?? throw new MissingMethodException("CESDK.CESDK", "CEPluginInitialize"); _initialize = method.CreateDelegate>(); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs index 5cb4b2ef..678a7dff 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -63,8 +63,8 @@ public static ImmutableArray FromTargetingPack() { string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out Version? version) - && (bestVersion is null || version > bestVersion)) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) { best = candidate; bestVersion = version; diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs index ee75bfd1..357f5c77 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs @@ -69,7 +69,7 @@ private static void Visit(object? value, string path, HashSet visited, i } foreach (FieldInfo field in - type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs index eaf158ee..cbb2c58a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs @@ -68,17 +68,32 @@ public void PluginModel_is_valid_only_without_issues() { Assert.True(ValidA.IsValid); Assert.False(Invalid.IsValid); - Assert.False((ValidA with { Issues = PluginShapeIssues.InvalidName }).IsValid); + Assert.False((ValidA with + { + Issues = PluginShapeIssues.InvalidName + }).IsValid); } [Fact] public void Models_compare_by_value() { Assert.Equal(new PluginModel("global::A", "Plugin A", "", PluginShapeIssues.None), ValidA); - Assert.NotEqual(ValidA with { DisplayName = "other" }, ValidA); - Assert.NotEqual(ValidA with { FullyQualifiedTypeName = "global::Z" }, ValidA); - Assert.NotEqual(ValidA with { DeclaredDiagnosticIds = "EXP001" }, ValidA); - Assert.NotEqual(ValidA with { Issues = PluginShapeIssues.Static }, ValidA); + Assert.NotEqual(ValidA with + { + DisplayName = "other" + }, ValidA); + Assert.NotEqual(ValidA with + { + FullyQualifiedTypeName = "global::Z" + }, ValidA); + Assert.NotEqual(ValidA with + { + DeclaredDiagnosticIds = "EXP001" + }, ValidA); + Assert.NotEqual(ValidA with + { + Issues = PluginShapeIssues.Static + }, ValidA); Assert.Equal(new EntryPointOptions(true), On); Assert.NotEqual(Off, On); Assert.Equal( diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/README.md b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/README.md index 459064d4..95859728 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/README.md +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/README.md @@ -57,3 +57,5 @@ dotnet test --project tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests -- when the host or the plugin constructor throws (`BootstrapExecutionTests`). - Same-named annotation/base symbols from a foreign reference do not generate a bootstrap (`ContractIdentityTests`). - Unchanged input recomputes nothing (`IncrementalityTests`). +- The bootstrap constructs the plugin with `new`, never through reflection, and `CEPluginInitialize` is one `try` whose + only, unfiltered `catch (System.Exception)` returns 0 (`NominalOutputTests`). diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/packages.lock.json b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/packages.lock.json new file mode 100644 index 00000000..482ceb2a --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/packages.lock.json @@ -0,0 +1,491 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing": { + "type": "Direct", + "requested": "[1.1.4, )", + "resolved": "1.1.4", + "contentHash": "gdwvnsx9QrPfU3Uz4iJNhJFoJRL5Al/F0VRoFE6+QgBmibJbEfZf06v5B+NDg14QzGIiU2OP9kEQM20lx7rZdw==", + "dependencies": { + "Microsoft.CodeAnalysis.CSharp.Workspaces": "3.8.0", + "Microsoft.CodeAnalysis.SourceGenerators.Testing": "[1.1.4]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "D2zqK/k16fto0yMz0hcXMTkzOxEwMDJyA1mu/KXF9Befwz4zub3MpHQD8FeRxJtVSSsC3dQFYBw7zu7r/pfO7g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.CSharp": "[5.9.0]", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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]" + } + }, + "DiffPlex": { + "type": "Transitive", + "resolved": "1.7.2", + "contentHash": "qJEjdxEDBWSFZGB8paBB9HDeJXHGlHlOXeGX3kbTuXWuOsgv2iSAEOOzo5V1/B39Vcxr9IVVrNKewRcX+rsn4g==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "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.CodeAnalysis.Analyzer.Testing": { + "type": "Transitive", + "resolved": "1.1.4", + "contentHash": "IQoZbcXE7uVLngTtFFkSnh813ItZj4+nDfW7L0F1p9YaTlpefyL+poge3J1+eRSgreiFpBmTK4HI/kZhGJMVNg==", + "dependencies": { + "DiffPlex": "1.7.2", + "Microsoft.CodeAnalysis.Workspaces.Common": "1.0.1", + "Microsoft.VisualStudio.Composition": "16.1.8", + "NuGet.Common": "7.0.3", + "NuGet.Packaging": "7.0.3", + "NuGet.Protocol": "7.0.3", + "NuGet.Resolver": "7.0.3" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + } + }, + "Microsoft.CodeAnalysis.SourceGenerators.Testing": { + "type": "Transitive", + "resolved": "1.1.4", + "contentHash": "Vog522X/3d0vKMAHzZAkMrwSII+ticKSw3ioGLCFHGeishJNjl5irkWRoACuDRQ0dSbWj2UkMgNan9kIGX1tVw==", + "dependencies": { + "DiffPlex": "1.7.2", + "Microsoft.CodeAnalysis.Analyzer.Testing": "[1.1.4]", + "Microsoft.CodeAnalysis.Workspaces.Common": "3.8.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "1A6jz50NG4nOEW8tX5+h+MyHqjWL0mPGwrUdwu+OlTfyknLo0GfxSqj4zEks8uVUdHdo9v8Ir9dHxACf8iYNEA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.VisualStudio.Composition": { + "type": "Transitive", + "resolved": "16.1.8", + "contentHash": "N+thv3dcT7kjn0Xz3U0uBm2CH4uoaMvH8wC6Gy2HWx7HLNdEpqGoMraLyoBdizmypD1owLCJQIa2uKmWe4/o8A==", + "dependencies": { + "Microsoft.VisualStudio.Composition.NetFxAttributes": "16.1.8", + "Microsoft.VisualStudio.Validation": "15.0.82", + "System.Composition": "1.0.31" + } + }, + "Microsoft.VisualStudio.Composition.NetFxAttributes": { + "type": "Transitive", + "resolved": "16.1.8", + "contentHash": "EbwZWTvdzit68qZSuTI8nd1PZ87pYjhpCwtsis8lrUKJ7XLdbE5rxY6YrY7OFze+YUsguzqZlNjX4Yn5nL9qBw==", + "dependencies": { + "System.ComponentModel.Composition": "4.5.0" + } + }, + "Microsoft.VisualStudio.Validation": { + "type": "Transitive", + "resolved": "15.0.82", + "contentHash": "XwZyVCsHuEtnd6nYScJnA8XkXPzy4Ok0DV5/hqqAe5ccgOhJ6yap7Qh/sU/i6QxEzuYyECPYDQ7IOyEQ3yRQgQ==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Common": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "vFBP1TkmeFxUjOqlGn7M3QbZfsg+hyFcCUFy8hPCdmk9VPhFQPwUMH4zKZnd8Bf8x89CFnpRXb8EC6tmWfDgNA==", + "dependencies": { + "NuGet.Frameworks": "7.0.3" + } + }, + "NuGet.Configuration": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "DZC5/eP6X0qDU4FK1X318nT9gBmjbBOQxphEFnn67B5kKi1/ODRANGOlnNn0tUkZDFtPtLl7gC6bfnf/cM7j5Q==", + "dependencies": { + "NuGet.Common": "7.0.3", + "System.Security.Cryptography.ProtectedData": "9.0.6" + } + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "JsV24AwAS93mQhlJr1Fx9zGHO6dVkexYp8ZZoDuoIBLyN9KwPEZOqo2SJVq0k2EPYO2oomj96Ue2yrO0hMOHrw==" + }, + "NuGet.Packaging": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "eMdAeZU4ugSC5WwO2u9CcY+jBjt/E6YunQ25oO6M5mM5A5+5QM1AdfoZPtFfwCuIgb/tbY52ciGOAXpBAqLVDA==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "NuGet.Configuration": "7.0.3", + "NuGet.Versioning": "7.0.3", + "System.Security.Cryptography.Pkcs": "9.0.6" + } + }, + "NuGet.Protocol": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "B9BwrQ0sOsSuCFijIL1LmX/hVVIhDf1WmI86neWak1yVvQ2r13TuFbMok6LXUM5htU8890XinB5ouE5hMHaxTA==", + "dependencies": { + "NuGet.Packaging": "7.0.3" + } + }, + "NuGet.Resolver": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "3L5FNJmHSBhHQms7YKrrMjPn5Q1iTgi0Ws0nyYN0cPeGKpA4p4Ey3X9oWWqz9FspTI7IqiPEu79NB7ipBYafvw==", + "dependencies": { + "NuGet.Protocol": "7.0.3" + } + }, + "NuGet.Versioning": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "jwZmJzau0kUTxUvtmjbEXyvhx0ui9wGPByDT8qV3qDSVK9rChgsSeZqb1Bybu258G9urdEcE9HqZCdRbi7h9BA==" + }, + "System.ComponentModel.Composition": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "+iB9FoZnfdqMEGq6np28X6YNSUrse16CakmIhV3h6PxEWt7jYxUN3Txs1D8MZhhf4QmyvK0F/EcIN0f4gGN0dA==", + "dependencies": { + "System.Security.Permissions": "4.5.0" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DJkqaWQfho/ReTzKcZD3zJJ6K4GcS154k+T0UCPMBNIOZ2U/lNpyiiWZ6Etw0onWyTH1K+yhICsdmwA5xy2aPQ==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Convention": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1", + "System.Composition.TypedParts": "10.0.1" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "mRxYvpCVPAeuLEk0c0kxWJVjbW1/HUoxCgYotOj9eDeQiYcTDOMdCQApsTrHYMN3pHBA8WoF00KGolG632Etaw==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "dYynUByfVBzYDheNPGxS8UN8AvG/4tXf/coSs1odHOyoh4etv1kad/FrLWLMq4f8NO49NV20Xu+0/y613woTUA==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4UGmyBdKWEN1nkqspJlji/nV7XIVm6KGlOC2So0mtM/gKvaNgLz+tUkcbY+6Zpr7dr6ohX1S5yl0RLID5otRHw==", + "dependencies": { + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "TVfys1gnUIhmXuYfFzyez0fOkDyELe9UwlxYeVlq6FmqmWmt1ouF0OQJ+6ozkHbkaop7uBUaXw7Qb+/o0m+nMg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "koSfjkdQZfgQr3SyiSIBboDn+GiR0vZ3x9Uek9FJbXK0w5AiATV8KrnMEP8B8OAlO+Y3zQf0CPCNzwH+VIYDKg==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "Gny8p2mX0jc5rjh+PA4Gx5GG66sj2C+e+ro7+j/3IsKT/bmQ84tGRV+XKaG+5/CTCdwkSSKDWEQ1rJd0J5jE0Q==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "yErfw/3pZkJE/VKza/Cm5idTpIKOy/vsmVi59Ta5SruPVtubzxb8CtnE8tyUpzs5pr0Y28GUFfSVzAhCLN3F/Q==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "9gdyuARhUR7H+p5CjyUB/zPk7/Xut3wUSP8NJQB6iZr8L3XUXTMdoLeVAg9N4rqF8oIpE7MpdqHdDHQ7XgJe0g==" + }, + "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.sdk.abi": { + "type": "Project" + }, + "cheatengine.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Abi": "[1.0.0, )", + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.sourcegenerators.entrypoint": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.SourceGenerators.Shared": "[1.0.0, )" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaFunctionEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaFunctionEndToEndTests.cs index 59b728f0..b1dd3b39 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaFunctionEndToEndTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaFunctionEndToEndTests.cs @@ -263,6 +263,24 @@ public static partial class NullableArgs Assert.Equal(0, L.Top); } + [Fact] + [Trait("Qualification", "Q20")] + public void String_argument_with_embedded_nul_keeps_its_length() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + Register(LoadSuite(roslyn), L); + + // 'echo' takes ReadOnlySpan and 'greet' takes string: both keep the bytes after a NUL. + Assert.Equal(3, LuaTest.RunForInteger(L, "return #echo('a\\0b')"u8)); + Assert.Equal("a\0b", LuaTest.RunForString(L, "return echo('a\\0b')"u8)); + Assert.Equal(10, LuaTest.RunForInteger(L, "return #greet('a\\0b')"u8)); + Assert.Equal("hello, a\0b\u00E9", LuaTest.RunForString(L, "return greet('a\\0b\\u{E9}')"u8)); + Assert.Equal(0, L.Top); + } + [Fact] public void Calling_a_thunk_from_lua_allocates_nothing_on_the_managed_side() { @@ -284,6 +302,72 @@ public void Calling_a_thunk_from_lua_allocates_nothing_on_the_managed_side() }); } + [Fact] + public void Optional_argument_reads_absent_as_omitted_and_nil_as_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + RegisterOptional(GeneratedAssembly.Load(roslyn.Run(OptionalBindingSources.FunctionSuite)), L); + + Assert.Equal("1|omitted|omitted", LuaTest.RunForString(L, "return optdescribe(1)"u8)); + Assert.Equal("1|nil|omitted", LuaTest.RunForString(L, "return optdescribe(1, nil)"u8)); + Assert.Equal("1|nil|nil", LuaTest.RunForString(L, "return optdescribe(1, nil, nil)"u8)); + Assert.Equal("1|2|x", LuaTest.RunForString(L, "return optdescribe(1, 2, 'x')"u8)); + Assert.Equal("omitted", LuaTest.RunForString(L, "return optflag()"u8)); + Assert.Equal("yes", LuaTest.RunForString(L, "return optflag(true)"u8)); + Assert.Equal("no", LuaTest.RunForString(L, "return optflag(false)"u8)); + Assert.Equal("nil", LuaTest.RunForString(L, "return optflag(nil)"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Optional_argument_count_outside_the_accepted_range_names_the_range() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + RegisterOptional(GeneratedAssembly.Load(roslyn.Run(OptionalBindingSources.FunctionSuite)), L); + + Assert.Equal("test:1: wrong number of arguments to 'optdescribe' (1 to 3 expected)", + LuaTest.RunForError(L, "return pcall(function() optdescribe() end)"u8)); + Assert.Equal("test:1: wrong number of arguments to 'optdescribe' (1 to 3 expected)", + LuaTest.RunForError(L, "return pcall(function() optdescribe(1, 2, 'x', 4) end)"u8)); + Assert.Equal("test:1: wrong number of arguments to 'optflag' (0 to 1 expected)", + LuaTest.RunForError(L, "return pcall(function() optflag(true, true) end)"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Wrong_kind_for_an_optional_argument_is_a_bad_argument_error() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + RegisterOptional(GeneratedAssembly.Load(roslyn.Run(OptionalBindingSources.FunctionSuite)), L); + + Assert.Equal("test:1: bad argument #2 (integer expected, got string)", + LuaTest.RunForError(L, "return pcall(function() optdescribe(1, 'x') end)"u8)); + Assert.Equal("test:1: bad argument #3 (string expected, got boolean)", + LuaTest.RunForError(L, "return pcall(function() optdescribe(1, 2, true) end)"u8)); + Assert.Equal("test:1: bad argument #1 (boolean expected, got number)", + LuaTest.RunForError(L, "return pcall(function() optflag(1) end)"u8)); + Assert.Equal("1|2|x", LuaTest.RunForString(L, "return optdescribe(1, 2, 'x')"u8)); + Assert.Equal(0, L.Top); + } + + private static void RegisterOptional(GeneratedAssembly assembly, LuaState L) + { + LuaStatus status = (LuaStatus) assembly.Method("Demo.OptionalFunctions", "RegisterLuaFunctions") + .Invoke(null, [L])!; + Assert.True(status.IsOk, + "Registration failed: " + (status.IsOk ? string.Empty : LuaError.FromStack(L, status).ToString())); + Assert.Equal(0, L.Top); + } + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) { return GeneratedAssembly.Load(roslyn.Run(BindingSources.FunctionSuite)); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalNumericBoundaryEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalNumericBoundaryEndToEndTests.cs new file mode 100644 index 00000000..012947fb --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalNumericBoundaryEndToEndTests.cs @@ -0,0 +1,179 @@ +using System.Text; + +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.EndToEnd; + +/// +/// Qualification Q21 at C2: values at the 32-bit, 2^53 and 64-bit boundaries, produced by Lua itself, reach +/// , and results in every form, and exported function +/// arguments, with every bit kept or a refusal, never a value rounded through a (audit A07-02, +/// A07-08, A12-08, A18-18). The stand-in is . +/// +[Collection(LuaRuntimeSuite.Name)] +[Trait("Category", "NativeLua")] +public sealed class LuaGlobalNumericBoundaryEndToEndTests(RoslynFixture roslyn) : IClassFixture +{ + private const string NumbersType = "Demo.Numbers"; + + /// A Lua expression, whether it fits 32 bits, whether it is an exact 64-bit integer, and that integer. + public static TheoryData Boundaries => new() + { + { "2147483647", true, true, int.MaxValue }, + { "-2147483648", true, true, int.MinValue }, + { "2147483648", false, true, 2147483648L }, + { "4294967296", false, true, 4294967296L }, + { "9007199254740992", false, true, 9007199254740992L }, + { "9007199254740993", false, true, 9007199254740993L }, + { "math.maxinteger", false, true, long.MaxValue }, + { "math.mininteger", false, true, long.MinValue }, + { "2^53 - 1", false, true, 9007199254740991L }, + { "2^31", false, true, 2147483648L }, + { "2^53", false, false, 0L }, + { "2^53 + 2", false, false, 0L }, + { "-2^53", false, false, 0L }, + { "2^63", false, false, 0L }, + { "1.5", false, false, 0L } + }; + + [Theory] + [Trait("Qualification", "Q21")] + [MemberData(nameof(Boundaries))] + public void Integer_results_keep_every_bit_or_are_refused_never_rounded(string expression, bool fitsInt32, + bool exact, long expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.NumericStandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + + AssertInt32Forms(assembly, expression, fitsInt32, (int) (fitsInt32 ? expected : 0)); + AssertInt64Forms(assembly, expression, exact, exact ? expected : 0); + AssertAddressForms(assembly, expression, exact, exact ? unchecked((nuint) (ulong) expected) : 0); + Assert.Equal(0, L.Top); + } + + [Theory] + [Trait("Qualification", "Q21")] + [MemberData(nameof(Boundaries))] + public void Function_arguments_refuse_floats_at_or_above_2_pow_53(string expression, bool fitsInt32, bool exact, + long expected) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaStatus registered = (LuaStatus) LoadSuite(roslyn).Method(NumbersType, "RegisterLuaFunctions") + .Invoke(null, [L])!; + Assert.True(registered.IsOk); + + AssertArgument(L, "n_int", expression, fitsInt32, expected); + AssertArgument(L, "n_long", expression, exact, expected); + AssertArgument(L, "n_addr", expression, exact, expected); + Assert.Equal(0, L.Top); + } + + private static void AssertInt32Forms(GeneratedAssembly assembly, string expression, bool accepted, int expected) + { + TryInt32Delegate tryForm = assembly.Delegate(NumbersType, "TryInt32"); + OutcomeInt32Delegate outcome = assembly.Delegate(NumbersType, "OutcomeInt32"); + Int32Delegate throwing = assembly.Delegate(NumbersType, "Int32"); + + Assert.Equal(accepted, tryForm(expression, out int tried)); + Assert.Equal(expected, tried); + Assert.Equal(Kind(accepted), outcome(expression, out int detailed).Kind); + Assert.Equal(expected, detailed); + AssertThrowing(accepted, expected, () => throwing(expression)); + } + + private static void AssertInt64Forms(GeneratedAssembly assembly, string expression, bool accepted, long expected) + { + TryInt64Delegate tryForm = assembly.Delegate(NumbersType, "TryInt64"); + OutcomeInt64Delegate outcome = assembly.Delegate(NumbersType, "OutcomeInt64"); + Int64Delegate throwing = assembly.Delegate(NumbersType, "Int64"); + + Assert.Equal(accepted, tryForm(expression, out long tried)); + Assert.Equal(expected, tried); + Assert.Equal(Kind(accepted), outcome(expression, out long detailed).Kind); + Assert.Equal(expected, detailed); + AssertThrowing(accepted, expected, () => throwing(expression)); + } + + private static void AssertAddressForms(GeneratedAssembly assembly, string expression, bool accepted, nuint expected) + { + TryAddressDelegate tryForm = assembly.Delegate(NumbersType, "TryAddress"); + OutcomeAddressDelegate outcome = assembly.Delegate(NumbersType, "OutcomeAddress"); + AddressDelegate throwing = assembly.Delegate(NumbersType, "Address"); + + Assert.Equal(accepted, tryForm(expression, out nuint tried)); + Assert.Equal(expected, tried); + Assert.Equal(Kind(accepted), outcome(expression, out nuint detailed).Kind); + Assert.Equal(expected, detailed); + AssertThrowing(accepted, expected, () => throwing(expression)); + } + + private static void AssertThrowing(bool accepted, T expected, Func call) + { + if (accepted) + { + Assert.Equal(expected, call()); + return; + } + + Assert.Equal("The Lua global 'boundary' returned a number value, not an integer.", + Assert.Throws(() => call()).Message); + } + + private static void AssertArgument(LuaState L, string function, string expression, bool accepted, long expected) + { + string call = function + "(" + expression + ")"; + if (accepted) + { + // The value comes back as the same Lua integer: every bit kept, integer subtype. + Assert.Equal("integer", LuaTest.RunForString(L, Encode("return math.type(" + call + ")"))); + Assert.Equal(expected, LuaTest.RunForInteger(L, Encode("return " + call))); + return; + } + + Assert.Equal("test:1: bad argument #1 (integer expected, got number)", + LuaTest.RunForError(L, Encode("return pcall(function() " + call + " end)"))); + } + + private static LuaOperationStatusKind Kind(bool accepted) + { + return accepted ? LuaOperationStatusKind.Success : LuaOperationStatusKind.InvalidResult; + } + + private static byte[] Encode(string source) + { + return Encoding.UTF8.GetBytes(source); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run(FidelityBindingSources.NumericSuite)); + } + + private delegate bool TryInt32Delegate(string expression, out int value); + + private delegate bool TryInt64Delegate(string expression, out long value); + + private delegate bool TryAddressDelegate(string expression, out nuint value); + + private delegate LuaOperationStatus OutcomeInt32Delegate(string expression, out int value); + + private delegate LuaOperationStatus OutcomeInt64Delegate(string expression, out long value); + + private delegate LuaOperationStatus OutcomeAddressDelegate(string expression, out nuint value); + + private delegate int Int32Delegate(string expression); + + private delegate long Int64Delegate(string expression); + + private delegate nuint AddressDelegate(string expression); +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalOptionalArgumentEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalOptionalArgumentEndToEndTests.cs new file mode 100644 index 00000000..d127e7b0 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalOptionalArgumentEndToEndTests.cs @@ -0,0 +1,166 @@ +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Marshalling; +using CheatEngine.SDK.Lua.Runtime; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.EndToEnd; + +/// +/// Generated [LuaGlobal] wrappers with LuaOptional<T> arguments run against stand-in globals that +/// report exactly what they received (select('#', ...), the Lua type of every argument): an omitted argument +/// is not pushed, Nil is pushed as nil, and an omitted argument before a present one is refused before +/// Lua is touched (audit A19-07, A07-28, A07-29, A06-15; binding DoD A.1, A.2, A.8). +/// +[Collection(LuaRuntimeSuite.Name)] +[Trait("Category", "NativeLua")] +public sealed class LuaGlobalOptionalArgumentEndToEndTests(RoslynFixture roslyn) : IClassFixture +{ + private const string OptionalsType = "Demo.Optionals"; + + public static TheoryData PresentCounts => [0, 1, 2]; + + [Fact] + public void Omitted_optional_argument_is_not_pushed_and_nil_is_pushed_as_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + KindsDelegate kinds = assembly.Delegate(OptionalsType, "Kinds"); + ArityDelegate arity = assembly.Delegate(OptionalsType, "Arity"); + + Assert.Equal("number", kinds(1, default)); + Assert.Equal("number,nil", kinds(1, LuaOptional.Nil())); + Assert.Equal("number,number", kinds(1, LuaOptional.Of(2L))); + Assert.Equal(1, arity(1, LuaOptional.Omitted(), default)); + Assert.Equal(2, arity(1, LuaOptional.Nil(), default)); + Assert.Equal(3, arity(1, LuaOptional.Nil(), LuaOptional.Nil())); + Assert.Equal(0, L.Top); + } + + [Theory] + [MemberData(nameof(PresentCounts))] + public void Every_optional_arity_of_one_binding_reaches_lua_with_its_exact_argument_count(int present) + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + ArityDelegate arity = assembly.Delegate(OptionalsType, "Arity"); + TryArityDelegate tryArity = assembly.Delegate(OptionalsType, "TryArity"); + ArityDetailedDelegate detailed = assembly.Delegate(OptionalsType, "ArityDetailed"); + ArityOnDelegate arityOn = assembly.Delegate(OptionalsType, "ArityOn"); + + LuaOptional second = present >= 1 ? LuaOptional.Of(5L) : default; + LuaOptional third = present >= 2 ? LuaOptional.Of("x") : default; + + Assert.Equal(1 + present, arity(1, second, third)); + Assert.True(tryArity(1, second, out long tryCount)); + Assert.Equal(present >= 1 ? 2 : 1, tryCount); + LuaOperationStatus status = detailed(1, present >= 1 ? LuaOptional.Of(1.5) : default, + present >= 2 ? LuaOptional.Of(true) : default, out long detailedCount); + Assert.Equal(LuaOperationStatusKind.Success, status.Kind); + Assert.Equal(1 + present, detailedCount); + Assert.Equal(present >= 1 ? 1 : 0, arityOn(L, present >= 1 ? LuaOptional.Of((nuint) 0x1000) : default)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Omitted_optional_argument_followed_by_a_present_one_throws_before_touching_lua() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + GeneratedAssembly assembly = LoadSuite(roslyn); + ArityDelegate arity = assembly.Delegate(OptionalsType, "Arity"); + ReadBytesLikeDelegate readBytes = assembly.Delegate(OptionalsType, "ReadBytesLike"); + + // Detached: the argument check runs before the runtime is asked for an operation. + LuaRuntime.Detach(); + ArgumentException detached = Assert.Throws(() => arity(1, default, LuaOptional.Of("x"))); + Assert.Equal("second", detached.ParamName); + + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + ArgumentException attached = Assert.Throws(() => arity(1, default, LuaOptional.Of("x"))); + Assert.Equal("second", attached.ParamName); + Assert.Contains("Lua global 'arity'", attached.Message, StringComparison.Ordinal); + ArgumentException nilLater = + Assert.Throws(() => readBytes(0x1000, default, LuaOptional.Nil())); + Assert.Equal("count", nilLater.ParamName); + Assert.Equal(0, LuaTest.RunForInteger(L, "return calls"u8)); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Nil_and_omitted_select_different_overloads_of_a_readBytes_like_stand_in() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + ReadBytesLikeDelegate readBytes = + LoadSuite(roslyn).Delegate(OptionalsType, "ReadBytesLike"); + + Assert.Equal("one byte", readBytes(0x1000, default, default)); + Assert.Equal("values", readBytes(0x1000, LuaOptional.Of(4), default)); + Assert.Equal("nil flag", readBytes(0x1000, LuaOptional.Of(4), LuaOptional.Nil())); + Assert.Equal("table", readBytes(0x1000, LuaOptional.Of(4), LuaOptional.Of(true))); + Assert.Equal("values", readBytes(0x1000, LuaOptional.Of(4), LuaOptional.Of(false))); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Warm_optional_argument_form_allocates_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TryArityDelegate tryArity = assembly.Delegate(OptionalsType, "TryArity"); + ArityDetailedDelegate detailed = assembly.Delegate(OptionalsType, "ArityDetailed"); + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!tryArity(1, LuaOptional.Of(2L), out long present) || present != 2 + || !tryArity(1, default, out long omitted) || omitted != 1 + || !detailed(1, LuaOptional.Nil(), LuaOptional.Of(true), out long nil).IsSuccess || nil != 3) + { + throw new InvalidOperationException("wrong count"); + } + + sink += present + omitted + nil; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run(OptionalBindingSources.GlobalSuite)); + } + + private delegate long ArityDelegate(long first, LuaOptional second, LuaOptional third); + + private delegate string KindsDelegate(long first, LuaOptional second); + + private delegate bool TryArityDelegate(long first, LuaOptional second, out long count); + + private delegate LuaOperationStatus ArityDetailedDelegate(long first, LuaOptional second, + LuaOptional third, out long count); + + private delegate long ArityOnDelegate(LuaState state, LuaOptional first); + + private delegate string ReadBytesLikeDelegate(nuint address, LuaOptional count, LuaOptional asTable); +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalQ22MatrixEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalQ22MatrixEndToEndTests.cs new file mode 100644 index 00000000..0110fc75 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalQ22MatrixEndToEndTests.cs @@ -0,0 +1,350 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Marshalling; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.EndToEnd; + +/// +/// Qualification Q22 at C2, per form: a bound global returning nil, false, 0, '', {}, no +/// value, or raising (a string, or a table whose __tostring raises) stays distinguishable as each form +/// defines it, and every exit restores the stack (audit A07-09, A07-10, A07-21, A07-30 to A07-38). The stand-ins are +/// ; every test asserts L.Top == 0. +/// +[Collection(LuaRuntimeSuite.Name)] +[Trait("Category", "NativeLua")] +public sealed class LuaGlobalQ22MatrixEndToEndTests(RoslynFixture roslyn) : IClassFixture +{ + private const string Q22Type = "Demo.Q22"; + + private static readonly string[] s_failures = ["nil", "false", "empty", "table", "none", "raise", "raise_table"]; + + [Fact] + [Trait("Qualification", "Q22")] + public void Try_form_reports_only_a_readable_value_as_success() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + TryInt32Delegate tryInt32 = LoadSuite(roslyn).Delegate(Q22Type, "TryInt32"); + + Assert.False(tryInt32("value", out int unresolved)); // the global is not defined yet + Assert.Equal(0, unresolved); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + + Assert.True(tryInt32("value", out int value)); + Assert.Equal(42, value); + Assert.True(tryInt32("zero", out int zero)); + Assert.Equal(0, zero); + foreach (string kind in s_failures) + { + Assert.False(tryInt32(kind, out int failed), kind); + Assert.Equal(0, failed); + Assert.Equal(0, L.Top); + } + + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Try_form_boolean_result_false_is_a_successful_call_not_a_failure() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + TryBooleanDelegate tryBoolean = LoadSuite(roslyn).Delegate(Q22Type, "TryBoolean"); + + // The Try form's bool is the binding's success; the Lua value travels in the out parameter. + Assert.True(tryBoolean("false", out bool isFalse)); + Assert.False(isFalse); + Assert.True(tryBoolean("true", out bool isTrue)); + Assert.True(isTrue); + + // Booleans are strict: nil, 0 and '' are not booleans, and a raise is a failed call. + Assert.False(tryBoolean("nil", out bool nil)); + Assert.False(nil); + Assert.False(tryBoolean("zero", out _)); + Assert.False(tryBoolean("empty", out _)); + Assert.False(tryBoolean("none", out _)); + Assert.False(tryBoolean("raise", out _)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Outcome_form_classifies_nil_false_zero_empty_table_none_and_raise_distinctly() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + OutcomeInt32Delegate outcome = LoadSuite(roslyn).Delegate(Q22Type, "OutcomeInt32"); + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, outcome("value", out _).Kind); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + + AssertOutcome(LuaOperationStatusKind.NilResult, LuaStatus.Ok, outcome("nil", out _)); + AssertOutcome(LuaOperationStatusKind.InvalidResult, LuaStatus.Ok, outcome("false", out _)); + AssertOutcome(LuaOperationStatusKind.InvalidResult, LuaStatus.Ok, outcome("empty", out _)); + AssertOutcome(LuaOperationStatusKind.InvalidResult, LuaStatus.Ok, outcome("table", out _)); + AssertOutcome(LuaOperationStatusKind.Success, LuaStatus.Ok, outcome("zero", out int zero)); + Assert.Equal(0, zero); + + // A fixed-count binding asks Lua for exactly one result: a global that returns nothing reads as nil there. + AssertOutcome(LuaOperationStatusKind.NilResult, LuaStatus.Ok, outcome("none", out _)); + AssertOutcome(LuaOperationStatusKind.LuaFailure, LuaStatus.RuntimeError, outcome("raise", out int raised)); + Assert.Equal(0, raised); + AssertOutcome(LuaOperationStatusKind.LuaFailure, LuaStatus.RuntimeError, outcome("raise_table", out _)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Optional_result_outcome_form_distinguishes_zero_results_from_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + OutcomeOptionalDelegate outcome = + LoadSuite(roslyn).Delegate(Q22Type, "OutcomeOptional"); + + AssertOutcome(LuaOperationStatusKind.Success, LuaStatus.Ok, outcome("none", out LuaOptional none)); + Assert.True(none.IsOmitted); + AssertOutcome(LuaOperationStatusKind.Success, LuaStatus.Ok, outcome("nil", out LuaOptional nil)); + Assert.True(nil.IsNil); + AssertOutcome(LuaOperationStatusKind.Success, LuaStatus.Ok, outcome("zero", out LuaOptional zero)); + Assert.Equal(LuaOptional.Of(0), zero); + AssertOutcome(LuaOperationStatusKind.InvalidResult, LuaStatus.Ok, outcome("false", out LuaOptional no)); + Assert.True(no.IsOmitted); + AssertOutcome(LuaOperationStatusKind.LuaFailure, LuaStatus.RuntimeError, + outcome("raise", out LuaOptional raised)); + Assert.True(raised.IsOmitted); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Empty_string_result_is_a_value_not_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TryTextDelegate tryText = assembly.Delegate(Q22Type, "TryText"); + TryTextCopyDelegate copyOut = assembly.Delegate(Q22Type, "TryTextCopy"); + TextDelegate text = assembly.Delegate(Q22Type, "Text"); + Span buffer = stackalloc byte[8]; + + Assert.True(tryText("empty", out string? empty)); + Assert.Equal(string.Empty, empty); + Assert.True(copyOut("empty", buffer, out int written)); + Assert.Equal(0, written); + Assert.Equal(string.Empty, text("empty")); + + Assert.False(tryText("nil", out string? nil)); + Assert.Null(nil); + Assert.False(copyOut("nil", buffer, out int nothing)); + Assert.Equal(0, nothing); + Assert.False(tryText("zero", out _)); // a number is not a string: no numeric coercion + Assert.Equal("The Lua global 'q22' returned a nil value, not a string.", + Assert.Throws(() => text("nil")).Message); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Throwing_form_raises_a_distinct_message_per_exit_and_restores_the_stack() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + GeneratedAssembly assembly = LoadSuite(roslyn); + Int32Delegate int32 = assembly.Delegate(Q22Type, "Int32"); + BooleanDelegate boolean = assembly.Delegate(Q22Type, "Boolean"); + + Assert.Equal("The Lua global 'q22' is undefined or is not a function.", + Assert.Throws(() => int32("value")).Message); + Assert.Equal(0, L.Top); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + + Assert.Equal(0, int32("zero")); + Assert.False(boolean("false")); + LuaException raised = Assert.Throws(() => int32("raise")); + Assert.Equal(LuaStatus.RuntimeError, raised.Status); + Assert.Contains("boom", raised.Message, StringComparison.Ordinal); + Assert.Equal(0, L.Top); + + AssertUnexpected("nil value, not an integer", () => int32("nil")); + AssertUnexpected("nil value, not an integer", () => int32("none")); + AssertUnexpected("boolean value, not an integer", () => int32("false")); + AssertUnexpected("string value, not an integer", () => int32("empty")); + AssertUnexpected("table value, not an integer", () => int32("table")); + AssertUnexpected("nil value, not a boolean", () => boolean("nil")); + AssertUnexpected("number value, not a boolean", () => boolean("zero")); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Throwing_form_keeps_the_runtime_error_status_when_the_error_object_tostring_raises() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + Int32Delegate int32 = assembly.Delegate(Q22Type, "Int32"); + OutcomeInt32Delegate outcome = assembly.Delegate(Q22Type, "OutcomeInt32"); + + // The error object is described by its type: no metamethod runs, so '__tostring' cannot raise a second error. + LuaException raised = Assert.Throws(() => int32("raise_table")); + Assert.Equal(LuaStatus.RuntimeError, raised.Status); + Assert.Equal("(error object is a table value)", raised.Message); + Assert.Equal(0, L.Top); + AssertOutcome(LuaOperationStatusKind.LuaFailure, LuaStatus.RuntimeError, outcome("raise_table", out _)); + Assert.Equal(42, int32("value")); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Next_call_on_the_same_state_succeeds_after_every_failure_kind() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TryInt32Delegate tryInt32 = assembly.Delegate(Q22Type, "TryInt32"); + OutcomeInt32Delegate outcome = assembly.Delegate(Q22Type, "OutcomeInt32"); + Int32Delegate int32 = assembly.Delegate(Q22Type, "Int32"); + OutcomeEvenDelegate even = assembly.Delegate(Q22Type, "OutcomeEven"); + + foreach (string kind in s_failures) + { + Assert.False(tryInt32(kind, out _)); + Assert.True(tryInt32("value", out int afterTry), kind); + Assert.False(outcome(kind, out _).IsSuccess); + Assert.Equal(LuaOperationStatusKind.Success, outcome("value", out int afterOutcome).Kind); + Assert.Throws(() => int32(kind)); + Assert.Equal(42, int32("value")); + Assert.Equal(afterTry, afterOutcome); + Assert.Equal(0, L.Top); + } + + Assert.Throws(() => even("negative", out _)); + Assert.Equal(LuaOperationStatusKind.Success, even("zero", out long recovered).Kind); + Assert.Equal(0, recovered); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Custom_marshaller_failure_after_a_successful_call_is_invalid_result_not_lua_failure() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TryEvenDelegate tryEven = assembly.Delegate(Q22Type, "TryEven"); + OutcomeEvenDelegate outcome = assembly.Delegate(Q22Type, "OutcomeEven"); + EvenDelegate even = assembly.Delegate(Q22Type, "Even"); + + // The call succeeded (LuaStatus.Ok); only the marshaller refused 7. + AssertOutcome(LuaOperationStatusKind.InvalidResult, LuaStatus.Ok, outcome("odd", out long odd)); + Assert.Equal(0, odd); + AssertOutcome(LuaOperationStatusKind.NilResult, LuaStatus.Ok, outcome("nil", out _)); + AssertOutcome(LuaOperationStatusKind.Success, LuaStatus.Ok, outcome("value", out long value)); + Assert.Equal(42, value); + Assert.False(tryEven("odd", out _)); + Assert.True(tryEven("zero", out long zero)); + Assert.Equal(0, zero); + LuaException unexpected = Assert.Throws(() => even("odd")); + Assert.Equal(LuaStatus.Ok, unexpected.Status); + Assert.StartsWith("The Lua global 'q22' returned a number value, not ", unexpected.Message, + StringComparison.Ordinal); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Custom_marshaller_exception_after_a_successful_call_propagates_with_the_stack_restored() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.Q22StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TryEvenDelegate tryEven = assembly.Delegate(Q22Type, "TryEven"); + OutcomeEvenDelegate outcome = assembly.Delegate(Q22Type, "OutcomeEven"); + EvenDelegate even = assembly.Delegate(Q22Type, "Even"); + + // A marshaller's own exception is not a Lua failure: no form swallows or reclassifies it. + Assert.Equal("negative token", Assert.Throws(() => tryEven("negative", out _)).Message); + Assert.Equal(0, L.Top); + Assert.Throws(() => outcome("negative", out _)); + Assert.Equal(0, L.Top); + Assert.Throws(() => even("negative")); + Assert.Equal(0, L.Top); + Assert.Equal(42, even("value")); + Assert.Equal(0, L.Top); + } + + private static void AssertOutcome(LuaOperationStatusKind kind, LuaStatus luaStatus, LuaOperationStatus actual) + { + Assert.Equal(kind, actual.Kind); + Assert.Equal(luaStatus, actual.LuaStatus); + } + + private static void AssertUnexpected(string description, Action call) + { + LuaException exception = Assert.Throws(call); + Assert.Equal("The Lua global 'q22' returned a " + description + ".", exception.Message); + Assert.Equal(LuaStatus.Ok, exception.Status); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run(FidelityBindingSources.Q22Suite)); + } + + private delegate bool TryInt32Delegate(string kind, out int value); + + private delegate bool TryBooleanDelegate(string kind, out bool value); + + private delegate bool TryTextDelegate(string kind, [MaybeNullWhen(false)] out string value); + + private delegate bool TryTextCopyDelegate(string kind, Span destination, out int written); + + private delegate LuaOperationStatus OutcomeInt32Delegate(string kind, out int value); + + private delegate LuaOperationStatus OutcomeOptionalDelegate(string kind, out LuaOptional value); + + private delegate int Int32Delegate(string kind); + + private delegate bool BooleanDelegate(string kind); + + private delegate string TextDelegate(string kind); + + private delegate bool TryEvenDelegate(string kind, out long value); + + private delegate LuaOperationStatus OutcomeEvenDelegate(string kind, out long value); + + private delegate long EvenDelegate(string kind); +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalResultCountEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalResultCountEndToEndTests.cs new file mode 100644 index 00000000..09f6ebef --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalResultCountEndToEndTests.cs @@ -0,0 +1,284 @@ +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Marshalling; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.EndToEnd; + +/// +/// Generated wrappers with optional and variadic results read the factual number of values a global returned +/// (LUA_MULTRET): zero results are not nil, a missing required result is +/// , extra values are ignored by a scalar binding, and a variadic +/// tail is copied or refused with the needed capacity (audit A19-08, A07-10, A07-14, A07-26, A06-09, Q22; spike C3 +/// D1 and D5 shapes). Every test asserts L.Top == 0. +/// +[Collection(LuaRuntimeSuite.Name)] +[Trait("Category", "NativeLua")] +public sealed class LuaGlobalResultCountEndToEndTests(RoslynFixture roslyn) : IClassFixture +{ + private const string OptionalsType = "Demo.Optionals"; + + [Fact] + [Trait("Qualification", "Q22")] + public void Zero_results_and_nil_are_distinct_for_an_optional_result() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + ShapeOptionalDelegate shape = LoadSuite(roslyn).Delegate(OptionalsType, "ShapeOptional"); + + // Mode 0 returns no value at all (the AOBScan zero-match shape of spike C3 D1); mode 1 returns an explicit nil. + Assert.Equal(LuaOperationStatusKind.Success, shape(0, out LuaOptional none).Kind); + Assert.True(none.IsOmitted); + Assert.Equal(LuaOperationStatusKind.Success, shape(1, out LuaOptional nil).Kind); + Assert.True(nil.IsNil); + Assert.Equal(LuaOperationStatusKind.Success, shape(2, out LuaOptional value).Kind); + Assert.Equal(LuaOptional.Of(7L), value); + Assert.Equal(LuaOperationStatusKind.InvalidResult, shape(6, out LuaOptional text).Kind); + Assert.True(text.IsOmitted); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Missing_required_result_before_an_optional_one_is_missing_not_nil() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + ShapePairDelegate detailed = assembly.Delegate(OptionalsType, "ShapeRequiredThenOptional"); + TryShapePairDelegate tryShape = + assembly.Delegate(OptionalsType, "TryShapeRequiredThenOptional"); + + Assert.Equal(LuaOperationStatusKind.MissingResult, detailed(0, out long missing, out _).Kind); + Assert.Equal(0, missing); + Assert.Equal(LuaOperationStatusKind.NilResult, detailed(1, out _, out _).Kind); + Assert.Equal(LuaOperationStatusKind.Success, detailed(2, out long onlyFirst, out LuaOptional absent).Kind); + Assert.Equal(7, onlyFirst); + Assert.True(absent.IsOmitted); + Assert.Equal(LuaOperationStatusKind.Success, detailed(3, out long first, out LuaOptional second).Kind); + Assert.Equal(7, first); + Assert.Equal(LuaOptional.Of(8L), second); + Assert.Equal(LuaOperationStatusKind.Success, detailed(8, out _, out LuaOptional explicitNil).Kind); + Assert.True(explicitNil.IsNil); + Assert.Equal(LuaOperationStatusKind.InvalidResult, detailed(7, out long defaulted, out LuaOptional bad).Kind); + Assert.Equal(0, defaulted); + Assert.True(bad.IsOmitted); + + Assert.False(tryShape(0, out _, out _)); + Assert.True(tryShape(3, out long tryFirst, out LuaOptional trySecond)); + Assert.Equal(7, tryFirst); + Assert.Equal(LuaOptional.Of(8L), trySecond); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Second_result_is_kept_when_the_first_is_a_boolean() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + FlagAndWarningDelegate shape = + LoadSuite(roslyn).Delegate(OptionalsType, "ShapeFlagAndWarning"); + + Assert.Equal(LuaOperationStatusKind.Success, shape(4, out bool ok, out LuaOptional warning).Kind); + Assert.True(ok); + Assert.Equal(LuaOptional.Of("warn"), warning); + Assert.Equal(LuaOperationStatusKind.InvalidResult, shape(2, out bool notBoolean, out _).Kind); + Assert.False(notBoolean); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Extra_non_scalar_results_are_ignored_by_a_scalar_binding() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TryShapeScalarDelegate scalar = assembly.Delegate(OptionalsType, "TryShapeScalar"); + ShapeOptionalDelegate optional = assembly.Delegate(OptionalsType, "ShapeOptional"); + + // Mode 5 returns (integer, table), the getCheatEngineFileVersion shape of spike C3 D5. + Assert.True(scalar(5, out long packed)); + Assert.Equal(1970354901756285, packed); + Assert.Equal(LuaOperationStatusKind.Success, optional(5, out LuaOptional first).Kind); + Assert.Equal(LuaOptional.Of(1970354901756285L), first); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Variadic_result_copies_every_value_and_reports_the_count() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + SequenceDelegate sequence = assembly.Delegate(OptionalsType, "Sequence"); + SequenceAfterOneDelegate afterOne = assembly.Delegate(OptionalsType, "SequenceAfterOne"); + Span values = stackalloc long[8]; + + Assert.Equal(LuaOperationStatusKind.Success, sequence(3, default, values, out int count).Kind); + Assert.Equal(3, count); + Assert.Equal([10L, 20L, 30L], values[..count].ToArray()); + Assert.Equal(LuaOperationStatusKind.Success, sequence(0, default, values, out int none).Kind); + Assert.Equal(0, none); + + Assert.Equal(LuaOperationStatusKind.Success, + afterOne(3, out long first, out LuaOptional second, values, out int restCount).Kind); + Assert.Equal(10, first); + Assert.Equal(LuaOptional.Of(20L), second); + Assert.Equal(1, restCount); + Assert.Equal(30, values[0]); + Assert.Equal(LuaOperationStatusKind.Success, + afterOne(1, out long single, out LuaOptional noSecond, values, out int noRest).Kind); + Assert.Equal(10, single); + Assert.True(noSecond.IsOmitted); + Assert.Equal(0, noRest); + Assert.Equal(LuaOperationStatusKind.MissingResult, afterOne(0, out _, out _, values, out int missing).Kind); + Assert.Equal(0, missing); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Variadic_result_above_capacity_is_refused_with_the_needed_count_and_no_stack_residue() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + SequenceDelegate sequence = assembly.Delegate(OptionalsType, "Sequence"); + SequenceAfterOneDelegate afterOne = assembly.Delegate(OptionalsType, "SequenceAfterOne"); + Span small = stackalloc long[2]; + + Assert.Equal(LuaOperationStatusKind.ResultCapacityExceeded, sequence(5, default, small, out int needed).Kind); + Assert.Equal(5, needed); + Assert.Equal([0L, 0L], small.ToArray()); + Assert.Equal(LuaOperationStatusKind.ResultCapacityExceeded, + afterOne(6, out long first, out LuaOptional second, small, out int restNeeded).Kind); + Assert.Equal(4, restNeeded); + Assert.Equal(0, first); + Assert.True(second.IsOmitted); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Variadic_nil_or_wrong_kind_element_is_classified_without_reading_error_text() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + SequenceDelegate sequence = LoadSuite(roslyn).Delegate(OptionalsType, "Sequence"); + Span values = stackalloc long[8]; + + LuaOperationStatus nil = sequence(0, LuaOptional.Of(1), values, out int nilCount); + Assert.Equal(LuaOperationStatusKind.NilResult, nil.Kind); + Assert.Equal(LuaStatus.Ok, nil.LuaStatus); + Assert.Equal(0, nilCount); + Assert.Equal(0, values[0]); + LuaOperationStatus text = sequence(0, LuaOptional.Of(2), values, out int textCount); + Assert.Equal(LuaOperationStatusKind.InvalidResult, text.Kind); + Assert.Equal(0, textCount); + Assert.Equal(0, values[0]); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q22")] + public void Lua_error_after_partial_results_restores_the_stack_and_the_next_call_succeeds() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + SequenceDelegate sequence = assembly.Delegate(OptionalsType, "Sequence"); + ShapeOptionalDelegate shape = assembly.Delegate(OptionalsType, "ShapeOptional"); + Span values = stackalloc long[4]; + + LuaOperationStatus raised = sequence(-1, default, values, out int raisedCount); + Assert.Equal(LuaOperationStatusKind.LuaFailure, raised.Kind); + Assert.Equal(LuaStatus.RuntimeError, raised.LuaStatus); + Assert.Equal(0, raisedCount); + Assert.Equal(0, L.Top); + Assert.Equal(LuaOperationStatusKind.Success, sequence(2, default, values, out int count).Kind); + Assert.Equal(2, count); + + Assert.Equal(LuaOperationStatusKind.LuaFailure, shape(99, out LuaOptional failed).Kind); + Assert.True(failed.IsOmitted); + Assert.Equal(LuaOperationStatusKind.Success, shape(2, out LuaOptional recovered).Kind); + Assert.Equal(LuaOptional.Of(7L), recovered); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Warm_optional_result_and_variadic_forms_allocate_nothing() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, OptionalBindingSources.StandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + ShapePairDelegate pair = assembly.Delegate(OptionalsType, "ShapeRequiredThenOptional"); + SequenceDelegate sequence = assembly.Delegate(OptionalsType, "Sequence"); + long[] buffer = new long[8]; + long sink = 0; + + AllocationGate.AssertZero(() => + { + if (!pair(3, out long first, out LuaOptional second).IsSuccess || !second.TryGetValue(out long value) + || !sequence(3, default, buffer, out int count).IsSuccess || count != 3) + { + throw new InvalidOperationException("wrong results"); + } + + sink += first + value + buffer[2]; + }); + + Assert.NotEqual(0, sink); + Assert.Equal(0, L.Top); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run(OptionalBindingSources.GlobalSuite)); + } + + private delegate LuaOperationStatus ShapeOptionalDelegate(int mode, out LuaOptional first); + + private delegate LuaOperationStatus ShapePairDelegate(int mode, out long first, out LuaOptional second); + + private delegate bool TryShapePairDelegate(int mode, out long first, out LuaOptional second); + + private delegate LuaOperationStatus FlagAndWarningDelegate(int mode, out bool ok, out LuaOptional warning); + + private delegate bool TryShapeScalarDelegate(int mode, out long value); + + private delegate LuaOperationStatus SequenceDelegate(int count, LuaOptional bad, Span values, + out int valueCount); + + private delegate LuaOperationStatus SequenceAfterOneDelegate(int count, out long first, out LuaOptional second, + Span rest, out int restCount); +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalStringFidelityEndToEndTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalStringFidelityEndToEndTests.cs new file mode 100644 index 00000000..707d3779 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/EndToEnd/LuaGlobalStringFidelityEndToEndTests.cs @@ -0,0 +1,177 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.State; +using CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; +using CheatEngine.SDK.Tests.Shared.NativeLua; + +namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.EndToEnd; + +/// +/// Qualification Q20 at C2: strings with embedded NULs, multibyte UTF-8 and invalid sequences cross a bound global +/// with their exact byte length, and each text conversion is explicit (the UTF-16 forms decode with U+FFFD, the +/// byte forms keep the raw bytes) (audit A07-30 to A07-33, A12-11 to A12-15). The stand-ins are +/// ; every test asserts L.Top == 0. +/// +[Collection(LuaRuntimeSuite.Name)] +[Trait("Category", "NativeLua")] +public sealed class LuaGlobalStringFidelityEndToEndTests(RoslynFixture roslyn) : IClassFixture +{ + private const string StringsType = "Demo.Strings"; + + [Fact] + [Trait("Qualification", "Q20")] + public void Embedded_nul_survives_string_and_copy_out_results_with_exact_length() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.StringStandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + Span buffer = stackalloc byte[16]; + + Assert.True(assembly.Delegate(StringsType, "TryText")("nul", out string? text)); + Assert.Equal("a\0b\0", text); + Assert.True(assembly.Delegate(StringsType, "TryTextCopy")("nul", buffer, out int written)); + Assert.Equal(4, written); + Assert.True(buffer[..written].SequenceEqual("a\0b\0"u8)); + Assert.Equal("a\0b\0", assembly.Delegate(StringsType, "Text")("nul")); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Utf8_argument_with_embedded_nul_reaches_lua_with_its_exact_length() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.StringStandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + Utf8LengthDelegate utf8Length = assembly.Delegate(StringsType, "Utf8Length"); + TextLengthDelegate textLength = assembly.Delegate(StringsType, "TextLength"); + + Assert.Equal(3, utf8Length("a\0b"u8)); + Assert.Equal(2, utf8Length("\0\0"u8)); + Assert.Equal(0, utf8Length([])); + Assert.Equal(3, textLength("a\0b")); + Assert.Equal(0, textLength(string.Empty)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Multibyte_utf8_round_trips_through_string_and_utf8_forms() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.StringStandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + const string Multibyte = "\u00E9\u6F22\U0001F600"; + byte[] utf8 = Encoding.UTF8.GetBytes(Multibyte); + Span buffer = stackalloc byte[16]; + + Assert.Equal(9, utf8.Length); + Assert.Equal(Multibyte, assembly.Delegate(StringsType, "Text")("multibyte")); + Assert.Equal(Multibyte, assembly.Delegate(StringsType, "Echo")(Multibyte)); + Assert.Equal(9, assembly.Delegate(StringsType, "TextLength")(Multibyte)); + Assert.True(assembly.Delegate(StringsType, "TryEchoCopy")(utf8, buffer, out int written)); + Assert.True(buffer[..written].SequenceEqual(utf8)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Invalid_utf8_from_lua_becomes_replacement_in_string_and_stays_raw_in_copy_out() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.StringStandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + ReadOnlySpan invalid = [0xFF, 0xFE, (byte) 'A']; + Span buffer = stackalloc byte[16]; + + Assert.True(assembly.Delegate(StringsType, "TryText")("invalid", out string? text)); + Assert.Equal("\uFFFD\uFFFDA", text); + Assert.True(assembly.Delegate(StringsType, "TryTextCopy")("invalid", buffer, + out int written)); + Assert.True(buffer[..written].SequenceEqual(invalid)); + Assert.True(assembly.Delegate(StringsType, "TryEchoCopy")(invalid, buffer, out written)); + Assert.True(buffer[..written].SequenceEqual(invalid)); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Lone_surrogate_argument_is_pushed_as_the_utf8_replacement_character() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.StringStandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TextLengthDelegate textLength = assembly.Delegate(StringsType, "TextLength"); + EchoDelegate echo = assembly.Delegate(StringsType, "Echo"); + + // U+FFFD is three UTF-8 bytes; a well-formed surrogate pair is one four-byte scalar. + Assert.Equal(3, textLength("\uD800")); + Assert.Equal("a\uFFFDb", echo("a\uD800b")); + Assert.Equal("\uFFFD", echo("\uDC00")); + Assert.Equal(4, textLength("\uD83D\uDE00")); + Assert.Equal("\uD83D\uDE00", echo("\uD83D\uDE00")); + Assert.Equal(0, L.Top); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Nullable_string_argument_null_is_pushed_as_nil_for_a_global_but_rejected_by_a_function() + { + LuaTest.RequireNativeLua(); + using NativeLuaState state = new(); + LuaState L = LuaTest.View(state); + using RuntimeScope scope = new(state); + LuaTest.Run(L, FidelityBindingSources.StringStandIns); + GeneratedAssembly assembly = LoadSuite(roslyn); + TypeOfDelegate typeOf = assembly.Delegate(StringsType, "TypeOf"); + LuaStatus registered = (LuaStatus) assembly.Method(StringsType, "RegisterLuaFunctions").Invoke(null, [L])!; + Assert.True(registered.IsOk); + + // The kept asymmetry: a global wrapper pushes C# null as nil; an exported function reads strings strictly, so + // nil is "no string" there (LuaFunctionEndToEndTests.Nil_is_rejected_for_a_nullable_string_argument_...). + Assert.Equal("nil", typeOf(null)); + Assert.Equal("string", typeOf(string.Empty)); + Assert.Equal("text:3", LuaTest.RunForString(L, "return f_describe('a\\0b')"u8)); + Assert.Equal("test:1: bad argument #1 (string expected, got nil)", + LuaTest.RunForError(L, "return pcall(function() f_describe(nil) end)"u8)); + Assert.Equal(0, L.Top); + } + + private static GeneratedAssembly LoadSuite(RoslynFixture roslyn) + { + return GeneratedAssembly.Load(roslyn.Run(FidelityBindingSources.StringSuite)); + } + + private delegate bool TryTextDelegate(string key, [MaybeNullWhen(false)] out string value); + + private delegate bool TryTextCopyDelegate(string key, Span destination, out int written); + + private delegate string TextDelegate(string key); + + private delegate long Utf8LengthDelegate(ReadOnlySpan text); + + private delegate long TextLengthDelegate(string text); + + private delegate string EchoDelegate(string text); + + private delegate bool TryEchoCopyDelegate(ReadOnlySpan text, Span destination, out int written); + + private delegate string TypeOfDelegate(string? text); +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs index 19d9becc..26790695 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs @@ -45,7 +45,7 @@ internal static partial class Bindings public void Generator_global_namespace_has_no_namespace_block() { GeneratorRun run = roslyn.Run(Usings + - "public static partial class Top { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + "public static partial class Top { [LuaFunction(\"f\")] public static int F(int a) => a; }"); run.AssertCompilesClean(); string text = run.GeneratedText("Top.LuaFunctions.g.cs"); @@ -66,7 +66,7 @@ public void Generator_global_namespace_has_no_namespace_block() public void Generator_type_kinds_are_reopened_with_their_keyword(string declaration, string expectedPart) { GeneratorRun run = roslyn.Run(Usings + "namespace Demo; " + declaration + - " Holder { [LuaFunction(\"f\")] public static int F(int a) => a; [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); }"); + " Holder { [LuaFunction(\"f\")] public static int F(int a) => a; [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); }"); run.AssertCompilesClean(); Assert.Contains("\n " + expectedPart + " Holder\n {\n", @@ -109,7 +109,7 @@ public void Generator_types_differing_only_by_case_get_distinct_hint_names() // cannot tell "DemoType" and "demoType" apart (neither has a replaced character), so without disambiguation // AddSource throws ArgumentException on the second one and the whole generation pass crashes. const string Source = Usings + - "namespace Demo; public static partial class DemoType { [LuaFunction(\"f1\")] public static int F(int a) => a; } public static partial class demoType { [LuaFunction(\"f2\")] public static int F(int a) => a; }"; + "namespace Demo; public static partial class DemoType { [LuaFunction(\"f1\")] public static int F(int a) => a; } public static partial class demoType { [LuaFunction(\"f2\")] public static int F(int a) => a; }"; GeneratorRun run = roslyn.Run(Source); @@ -124,7 +124,7 @@ public void Generator_types_differing_only_by_case_get_distinct_hint_names() public void Generator_non_ascii_type_name_gets_a_hashed_hint_name_and_an_unescaped_declaration() { GeneratorRun run = roslyn.Run(Usings + - "namespace Demo; public static partial class Caf\u00E9 { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + "namespace Demo; public static partial class Caf\u00E9 { [LuaFunction(\"f\")] public static int F(int a) => a; }"); run.AssertCompilesClean(); string hintName = Assert.Single(run.HintNames); @@ -186,8 +186,8 @@ public void Generator_type_split_across_files_gets_one_file() public void Generator_repeats_the_defining_declarations_modifiers(string declared, string emitted) { GeneratorRun run = roslyn.Run(Usings + "namespace Demo; public partial class Holder { [LuaGlobal(\"g\")] " + - declared + - " bool TryG(nuint a, out int v); }"); + declared + + " bool TryG(nuint a, out int v); }"); run.AssertCompilesClean(); Assert.Contains("\n " + emitted + " bool TryG(nuint a, out int v)\n", run.SingleGeneratedText, @@ -214,7 +214,7 @@ namespace Demo; public void Generator_old_style_partial_void_without_accessibility_is_implemented_without_one() { GeneratorRun run = roslyn.Run(Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"beep\")] static partial void Beep(); }"); + "namespace Demo; public static partial class Holder { [LuaGlobal(\"beep\")] static partial void Beep(); }"); run.AssertCompilesClean(); Assert.Contains("\n static partial void Beep()\n", run.SingleGeneratedText, StringComparison.Ordinal); @@ -224,7 +224,7 @@ public void Generator_old_style_partial_void_without_accessibility_is_implemente public void Generator_private_target_is_reachable_from_the_thunk() { GeneratorRun run = roslyn.Run(Usings + - "namespace Demo; internal static partial class Holder { [LuaFunction(\"f\")] private static int F(int a) => a; }"); + "namespace Demo; internal static partial class Holder { [LuaFunction(\"f\")] private static int F(int a) => a; }"); run.AssertCompilesClean(); Assert.Contains("global::Demo.Holder.F(__arg0)", run.SingleGeneratedText, StringComparison.Ordinal); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs index b6415ec7..53a0bcc1 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs @@ -50,7 +50,8 @@ private static CSharpSourceGeneratorTest { // A framework moniker WITHOUT a reference-assembly package: nothing is resolved through NuGet; the // framework and the SDK come from the local installation and this process (no network). - ReferenceAssemblies = new ReferenceAssemblies("net10.0"), CompilerDiagnostics = CompilerDiagnostics.Warnings + ReferenceAssemblies = new ReferenceAssemblies("net10.0"), + CompilerDiagnostics = CompilerDiagnostics.Warnings }; // The test declarations are undocumented public members: CS1591 is theirs, not the generated file's. diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs index 3d61398c..38f3dd4b 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs @@ -37,8 +37,8 @@ public void Pipeline_first_run_tracks_every_named_step() GeneratorRun run = roslyn.Run(BindingSources.Functions, BindingSources.Globals, ObjectBindings); foreach (string stepName in run.Result.TrackedSteps.Keys - .Where(TrackingNames.IsCheatEngineSdkStep) - .Order(StringComparer.Ordinal)) + .Where(TrackingNames.IsCheatEngineSdkStep) + .Order(StringComparer.Ordinal)) { Assert.All(StepAssert.Reasons(run.Result, stepName), static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); @@ -275,34 +275,99 @@ public void Pipeline_unsafe_switched_off_removes_only_function_output() LuaBindingsTrackingNames.LuaGlobalTables); } + [Fact] + public void Pipeline_optional_signature_edited_reruns_the_global_output_only() + { + CSharpCompilation compilation = + roslyn.CreateCompilation(BindingSources.Functions, OptionalBindingSources.GlobalSuite); + GeneratorRun first = RoslynFixture.Run(compilation); + + SyntaxTree original = compilation.SyntaxTrees.Last(); + string edited = OptionalBindingSources.GlobalSuite.Replace( + "Kinds(long first, LuaOptional second)", "Kinds(long first, LuaOptional second)", + StringComparison.Ordinal); + Assert.NotEqual(OptionalBindingSources.GlobalSuite, edited, StringComparer.Ordinal); + GeneratorRun second = GeneratorRun.Execute(first.Driver, + compilation.ReplaceSyntaxTree(original, RoslynFixture.Parse(edited, original.FilePath))); + + Assert.Contains(IncrementalStepRunReason.Modified, + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaGlobal)); + Assert.Equal([IncrementalStepRunReason.Modified], + StepAssert.Reasons(second.Result, LuaBindingsTrackingNames.LuaGlobalOutput)); + AssertUntouched(second.Result, LuaBindingsTrackingNames.LuaFunction, LuaBindingsTrackingNames.LuaFunctionTables, + LuaBindingsTrackingNames.LuaFunctionOutput); + Assert.Contains("Kinds(long first, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional second)", + second.GeneratedText("Demo.Optionals.LuaGlobals.g.cs"), StringComparison.Ordinal); + } + + [Fact] + public void Pipeline_reordering_optional_bindings_keeps_hint_names_and_member_names() + { + string[] members = OptionalMembers(); + string forward = OptionalType(members); + string reversed = OptionalType([.. members.Reverse()]); + Assert.NotEqual(forward, reversed, StringComparer.Ordinal); + + GeneratorRun first = roslyn.Run(forward); + GeneratorRun second = roslyn.Run(reversed); + + Assert.Equal(first.HintNames, second.HintNames); + Assert.Equal(first.SingleGeneratedText, second.SingleGeneratedText); + first.AssertCompilesClean(); + } + [Fact] public void Pipeline_step_values_hold_no_roslyn_objects() { GeneratorRun run = roslyn.Run( BindingSources.FunctionSuite, BindingSources.GlobalSuite, + OptionalBindingSources.GlobalSuite, + OptionalBindingSources.FunctionSuite, "namespace Demo; public static partial class Broken { [CheatEngine.SDK.Annotations.Lua.LuaFunction(\"bad\")] public static int Bad(object o) => 0; [CheatEngine.SDK.Annotations.Lua.LuaGlobal(\"bad\")] public static partial bool TryBad(out object o); }", ObjectBindings); int visited = 0; foreach (string stepName in run.Result.TrackedSteps.Keys - .Where(TrackingNames.IsCheatEngineSdkStep) - .Order(StringComparer.Ordinal)) + .Where(TrackingNames.IsCheatEngineSdkStep) + .Order(StringComparer.Ordinal)) { Assert.True( run.Result.TrackedSteps.TryGetValue(stepName, out ImmutableArray steps), $"Tracked step '{stepName}' was not present."); foreach (IncrementalGeneratorRunStep step in steps) - foreach ((object value, IncrementalStepRunReason _) in step.Outputs) { - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } } } Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); } + // The attributed members of OptionalBindingSources.GlobalSuite, one declaration each. + private static string[] OptionalMembers() + { + string body = OptionalBindingSources.GlobalSuite.ReplaceLineEndings("\n"); + int open = body.IndexOf("{\n", body.IndexOf("class Optionals", StringComparison.Ordinal), StringComparison.Ordinal); + int close = body.LastIndexOf('}'); + return + [ + .. body[(open + 2)..close] + .Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ]; + } + + private static string OptionalType(string[] members) + { + string suite = OptionalBindingSources.GlobalSuite.ReplaceLineEndings("\n"); + string header = suite[..suite.IndexOf("public static partial class Optionals", StringComparison.Ordinal)]; + return header + "public static partial class Optionals\n{\n" + string.Join("\n\n", members) + "\n}\n"; + } + private static void AssertUntouched(GeneratorRunResult result, params string[] stepNames) { foreach (string stepName in stepNames) diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs index 9097b1ac..abc96665 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs @@ -49,10 +49,10 @@ public void Generator_function_suite_compiles_clean_and_declares_one_thunk_per_f run.AssertCompilesClean(); string text = run.SingleGeneratedText; foreach (string name in new[] - { - "add", "greet", "ping", "isint", "boom", "echo", "half", "negate", "step", "small", "scale", - "maybe" - }) + { + "add", "greet", "ping", "isint", "boom", "echo", "half", "negate", "step", "small", "scale", + "maybe" + }) { Assert.Contains("private static int __LuaThunk_" + name + "(nint __handle)", text, StringComparison.Ordinal); @@ -144,6 +144,24 @@ public void Generator_target_parameters_named_like_generated_thunk_locals_do_not StringComparison.Ordinal); } + [Fact] + public void Generator_optional_thunk_accepts_the_declared_argument_range() + { + GeneratorRun run = roslyn.Run(OptionalBindingSources.FunctionSuite); + + run.AssertCompilesClean(); + string text = run.SingleGeneratedText; + Assert.Contains( + "if (__L.Top < 1 || __L.Top > 3)\n {\n return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, \"wrong number of arguments to 'optdescribe' (1 to 3 expected)\"u8);", + text, StringComparison.Ordinal); + Assert.Contains( + "if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.TryReadOptional(__L, 2, out global::CheatEngine.SDK.Lua.Marshalling.LuaOptional __arg1))", + text, StringComparison.Ordinal); + Assert.Contains("return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(__L, 3, \"string\"u8);", + text, StringComparison.Ordinal); + Assert.Contains("if (__L.Top < 0 || __L.Top > 1)", text, StringComparison.Ordinal); + } + [Fact] public void Generator_any_input_reports_no_diagnostics() { diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs index c65929b2..94b32321 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs @@ -238,9 +238,9 @@ public void Generator_unannotated_out_string_result_gets_exactly_cs8601_the_docu .. run.OutputCompilation .GetDiagnostics(TestContext.Current.CancellationToken) .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning - && !(string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } && - !path.EndsWith(".g.cs", StringComparison.Ordinal))) + && !(string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) + && diagnostic.Location.SourceTree is { FilePath: string path } && + !path.EndsWith(".g.cs", StringComparison.Ordinal))) ]; Diagnostic problem = Assert.Single(problems); Assert.Equal("CS8601", problem.Id); @@ -263,6 +263,132 @@ public void Generator_compiles_clean_for_a_consumer_with_nullable_disabled() run.AssertCompilesClean(); } + [Fact] + public void Generator_optional_suite_compiles_clean() + { + GeneratorRun run = roslyn.Run(OptionalBindingSources.GlobalSuite); + + Assert.Single(run.GeneratedSources); + run.AssertCompilesClean(); + } + + [Fact] + public void Generator_optional_arguments_compute_the_argument_count_before_acquiring_the_state() + { + GeneratorRun run = roslyn.Run(OptionalBindingSources.GlobalSuite); + + string body = Section(run.SingleGeneratedText, + "public static partial long Arity(long first, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional second, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional third)", + "\n }\n"); + int count = body.IndexOf("int __argc = !third.IsOmitted ? 3 : !second.IsOmitted ? 2 : 1;", + StringComparison.Ordinal); + int gap = body.IndexOf("if (__argc > 2 && second.IsOmitted)", StringComparison.Ordinal); + int acquire = body.IndexOf("AcquireOperation()", StringComparison.Ordinal); + Assert.True(count >= 0 && gap > count && acquire > gap, "The argument count is not computed first:\n" + body); + Assert.Contains("throw new global::System.ArgumentException(", body, StringComparison.Ordinal); + Assert.Contains( + "if (__argc > 1)\n {\n global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.PushOptional(__L, second);", + body, StringComparison.Ordinal); + Assert.Contains("PushOptional(__L, third);", + body, StringComparison.Ordinal); + Assert.Contains("__L.TryCall(__argc, 1);", body, StringComparison.Ordinal); + + // A single optional argument needs no gap check. + string single = Section(run.SingleGeneratedText, "public static partial bool TryArity(", "\n }\n"); + Assert.Contains("int __argc = !second.IsOmitted ? 2 : 1;\n\n", single, StringComparison.Ordinal); + Assert.DoesNotContain("ArgumentException", single, StringComparison.Ordinal); + } + + [Fact] + public void Generator_optional_results_use_multiple_results_and_read_absolute_indices() + { + GeneratorRun run = roslyn.Run(OptionalBindingSources.GlobalSuite); + + string body = Section(run.SingleGeneratedText, + "public static partial global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus ShapeRequiredThenOptional(", + "\n }\n"); + Assert.Contains("__L.TryCall(1, global::CheatEngine.SDK.Lua.State.LuaState.MultipleResults);", body, + StringComparison.Ordinal); + Assert.Contains("if (__L.Top - __top < 1)", body, StringComparison.Ordinal); + Assert.Contains("LuaOperationStatus.MissingResult, out first);", body, StringComparison.Ordinal); + Assert.Contains("Int64Marshaller.TryRead(__L, __top + 1, out first)", body, StringComparison.Ordinal); + Assert.Contains( + "LuaCallSupport.TryReadOptional(__L, __top + 2, out second)", + body, StringComparison.Ordinal); + Assert.DoesNotContain("-1", body, StringComparison.Ordinal); + + // A declaration without an optional or variadic result keeps the fixed-count call and negative indices. + string scalar = Section(run.SingleGeneratedText, "public static partial bool TryShapeScalar(", "\n }\n"); + Assert.Contains("__L.TryCall(1, 1).IsOk", scalar, StringComparison.Ordinal); + Assert.Contains("TryRead(__L, -1, out value)", scalar, StringComparison.Ordinal); + } + + [Fact] + public void Generator_variadic_pair_is_emitted_only_for_the_outcome_form() + { + GeneratorRun run = roslyn.Run(OptionalBindingSources.GlobalSuite); + + string body = Section(run.SingleGeneratedText, + "public static partial global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus Sequence(int count, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional bad, global::System.Span values, out int valueCount)", + "\n }\n"); + Assert.Contains( + "global::CheatEngine.SDK.Lua.Calls.LuaOperationStatus __rest = global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ReadResults(__L, __top + 1, values, out valueCount);", + body, StringComparison.Ordinal); + Assert.Contains("return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, __rest);", + body, StringComparison.Ordinal); + + const string TryForm = "using System;\nusing CheatEngine.SDK.Annotations.Lua;\nnamespace Demo; public static partial class Holder { [LuaGlobal(\"seq\")] public static partial bool TrySeq(int n, Span values, out int count); }"; + roslyn.Run(TryForm).AssertNoOutput(); + } + + [Fact] + public void Generator_outcome_form_with_several_results_defaults_every_other_result_on_failure() + { + const string Source = + "using CheatEngine.SDK.Annotations.Lua;\nusing CheatEngine.SDK.Lua.Calls;\nnamespace Demo; public static partial class Holder { [LuaGlobal(\"divide\")] public static partial LuaOperationStatus Divide(long a, long b, out long quotient, out string? remainder); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + string body = Section(run.SingleGeneratedText, "Divide(long a, long b", "\n }\n"); + Assert.Contains("remainder = default!;\n return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, __L.IsNil(-2)", + body, StringComparison.Ordinal); + Assert.Contains("quotient = default;\n return global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(__L, __top, __L.IsNil(-1)", + body, StringComparison.Ordinal); + } + + [Fact] + [Trait("Qualification", "Q20")] + public void Generator_text_results_are_copied_or_decoded_before_the_stack_is_restored() + { + GeneratorRun run = roslyn.Run(BindingSources.GlobalSuite); + string text = run.SingleGeneratedText; + + string copyOut = Section(text, + "public static partial bool TryReadString(nuint address, int maxLength, global::System.Span destination, out int written)", + "\n }\n"); + string decoded = Section(text, + "public static partial bool TryReadString(nuint address, int maxLength, out string value)", + "\n }\n"); + string throwing = Section(text, "public static partial string ReadString(", "\n }\n"); + + AssertReadBeforeRestore(copyOut, "__L.TryCopyUtf8(-1, destination, out written)"); + AssertReadBeforeRestore(decoded, "StringMarshaller.TryRead(__L, -1, out value)"); + AssertReadBeforeRestore(throwing, "StringMarshaller.TryRead(__L, -1, out string? __result)"); + + // No generated wrapper returns or stores a span that could point into a popped Lua string. + Assert.DoesNotContain("out global::System.ReadOnlySpan", text, StringComparison.Ordinal); + Assert.DoesNotContain("Utf8Marshaller.TryRead", text, StringComparison.Ordinal); + } + + private static void AssertReadBeforeRestore(string body, string read) + { + int readAt = body.IndexOf(read, StringComparison.Ordinal); + int restoreAt = body.IndexOf("__L.SetTop(__top);", StringComparison.Ordinal); + Assert.True(readAt >= 0 && restoreAt > readAt, + "The text result is not read before the stack is restored:\n" + body); + } + // The text from the first occurrence of 'start' to the first 'end' after it (the closing brace of the method). private static string Section(string text, string start, string end) { diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs index 7a50be69..d8e451f5 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs @@ -615,7 +615,7 @@ private static MethodDeclarationSyntax FindGeneratedMethod(CompilationUnitSyntax foreach (SyntaxNode node in root.DescendantNodes()) { if (node is MethodDeclarationSyntax candidate - && string.Equals(candidate.Identifier.ValueText, methodName, StringComparison.Ordinal)) + && string.Equals(candidate.Identifier.ValueText, methodName, StringComparison.Ordinal)) { Assert.Null(result); result = candidate; diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs index 98c41bb8..cd59d095 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs @@ -57,6 +57,11 @@ public static TheoryData InvalidFunctions data.Add(shape, source); } + foreach ((string shape, string source) in FunctionOptionalRejections()) + { + data.Add(shape, source); + } + return data; } } @@ -101,6 +106,11 @@ public static TheoryData InvalidGlobals data.Add(shape, source); } + foreach ((string shape, string source) in GlobalOptionalRejections()) + { + data.Add(shape, source); + } + return data; } } @@ -367,6 +377,150 @@ public static TheoryData InvalidGlobals "namespace Other { public sealed class LuaGlobalAttribute : Attribute { public LuaGlobalAttribute(string n) { } } } public static partial class T { [Other.LuaGlobal(\"g\")] public static partial int G(nuint a); public static partial int G(nuint a) => 0; }"); } + // CESDK2010, CESDK2011 and CESDK2013 shapes of a bound global (LuaOptional spelled in full: the row usings are + // shared with the other rows). + private static IEnumerable<(string Shape, string Source)> GlobalOptionalRejections() + { + const string Optional = "CheatEngine.SDK.Lua.Marshalling.LuaOptional"; + const string Status = "CheatEngine.SDK.Lua.Calls.LuaOperationStatus"; + yield return ( + "required argument after an optional one", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(" + Optional + " a, int b); }"); + yield return ( + "required result after an optional one", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(out " + Optional + " a, out int b); }"); + yield return ( + "copy-out result after an optional one", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(out " + Optional + " a, Span d, out int w); }"); + yield return ( + "variadic pair in the Try form", + "public static partial class T { [LuaGlobal(\"g\")] public static partial bool TryG(Span v, out int c); }"); + yield return ( + "variadic pair in the throwing form", + "public static partial class T { [LuaGlobal(\"g\")] public static partial void G(Span v, out int c); }"); + yield return ( + "result after the variadic pair", + "public static partial class T { [LuaGlobal(\"g\")] public static partial " + Status + " G(Span v, out int c, out int d); }"); + yield return ( + "two variadic pairs", + "public static partial class T { [LuaGlobal(\"g\")] public static partial " + Status + " G(Span v, out int c, Span w, out int e); }"); + yield return ( + "variadic string element", + "public static partial class T { [LuaGlobal(\"g\")] public static partial " + Status + " G(Span v, out int c); }"); + yield return ( + "variadic span without a count", + "public static partial class T { [LuaGlobal(\"g\")] public static partial " + Status + " G(Span v); }"); + yield return ( + "optional nullable string", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(" + Optional + " a); }"); + yield return ( + "optional object", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(" + Optional + " a); }"); + yield return ( + "nested optional", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(" + Optional + "<" + Optional + "> a); }"); + yield return ( + "optional throwing return", + "public static partial class T { [LuaGlobal(\"g\")] public static partial " + Optional + " G(int a); }"); + yield return ( + "optional with a C# default value", + "public static partial class T { [LuaGlobal(\"g\")] public static partial int G(" + Optional + " a = default); }"); + } + + // CESDK2010 and CESDK2013 shapes of an exported function. + private static IEnumerable<(string Shape, string Source)> FunctionOptionalRejections() + { + const string Optional = "CheatEngine.SDK.Lua.Marshalling.LuaOptional"; + yield return ( + "required parameter after an optional one", + "public static partial class T { [LuaFunction(\"f\")] public static int F(" + Optional + " a, int b) => b; }"); + yield return ( + "optional return", + "public static partial class T { [LuaFunction(\"f\")] public static " + Optional + " F(int a) => default; }"); + yield return ( + "optional nullable string parameter", + "public static partial class T { [LuaFunction(\"f\")] public static int F(" + Optional + " a) => 0; }"); + } + + [Fact] + public void Generator_non_trailing_optional_argument_emits_nothing() + { + const string Source = "using CheatEngine.SDK.Annotations.Lua;\nusing CheatEngine.SDK.Lua.Marshalling;\n" + + "namespace Demo; public static partial class T { [LuaGlobal(\"g\")] public static partial int G(LuaOptional a, int b); " + + "[LuaFunction(\"f\")] public static int F(LuaOptional a, long b) => 0; }"; + + roslyn.Run(Source).AssertNoOutput(); + } + + [Fact] + public void Generator_same_named_LuaOptional_from_source_is_not_a_binding_contract() + { + // The real CheatEngine.SDK.Lua is referenced; the source copy wins C# name lookup (CS0436) but is a look-alike. + const string Source = """ + using CheatEngine.SDK.Annotations.Lua; + + namespace CheatEngine.SDK.Lua.Marshalling + { + public readonly struct LuaOptional + { + public bool IsOmitted => true; + } + } + + namespace Demo + { + using CheatEngine.SDK.Lua.Marshalling; + + public static partial class T + { + [LuaGlobal("g")] + public static partial int G(int a, LuaOptional b); + + [LuaGlobal("h")] + public static partial bool TryH(int a, out LuaOptional b); + + [LuaFunction("f")] + public static int F(LuaOptional b) => 0; + } + } + """; + + roslyn.Run(Source).AssertNoOutput(); + } + + [Fact] + public void Generator_same_named_LuaOperationStatus_from_another_assembly_does_not_select_the_outcome_form() + { + CSharpCompilation lookAlikeAssembly = CSharpCompilation.Create( + "Fake.Lua.Contracts", + [ + RoslynFixture.Parse( + "namespace CheatEngine.SDK.Lua.Calls { public readonly struct LuaOperationStatus { } }", + "Fake.cs") + ], + roslyn.Environment.FrameworkReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + MetadataReference fake = lookAlikeAssembly.ToMetadataReference(["Fake"]); + const string Source = """ + extern alias Fake; + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class T + { + [LuaGlobal("readInteger")] + public static partial Fake::CheatEngine.SDK.Lua.Calls.LuaOperationStatus TryReadInt32(nuint address, out int value); + + [LuaGlobal("beep")] + public static partial Fake::CheatEngine.SDK.Lua.Calls.LuaOperationStatus Beep(); + } + """; + CSharpCompilation compilation = roslyn.CreateCompilation(Source).AddReferences(fake); + + RoslynFixture.Run(compilation).AssertNoOutput(); + } + [Theory] [MemberData(nameof(InvalidFunctions))] public void Generator_invalid_function_shape_emits_nothing(string shape, string source) @@ -393,7 +547,7 @@ public void Generator_invalid_global_shape_emits_nothing(string shape, string so public void Generator_no_attribute_emits_nothing() { GeneratorRun run = roslyn.Run(Usings + - "public static partial class T { public static int F(int a) => a; public static partial int G(int a); public static partial int G(int a) => a; }"); + "public static partial class T { public static int F(int a) => a; public static partial int G(int a); public static partial int G(int a) => a; }"); run.AssertNoOutput(); } @@ -406,7 +560,7 @@ public void Generator_same_name_source_LuaState_without_the_sdk_runtime_emits_no foreach (MetadataReference reference in roslyn.Environment.SdkReferences) { if (string.Equals(reference.Display, typeof(LuaState).Assembly.Location, - StringComparison.OrdinalIgnoreCase)) + StringComparison.OrdinalIgnoreCase)) { continue; } @@ -560,8 +714,8 @@ private static int Count(string text, string needle) { int count = 0; for (int index = text.IndexOf(needle, StringComparison.Ordinal); - index >= 0; - index = text.IndexOf(needle, index + needle.Length, StringComparison.Ordinal)) + index >= 0; + index = text.IndexOf(needle, index + needle.Length, StringComparison.Ordinal)) { count++; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs index 6fa31a12..9f641df7 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs @@ -15,7 +15,7 @@ public sealed class PartialMethodSignatureTests(RoslynFixture roslyn) : IClassFi public void Generator_repeats_an_explicit_scoped_readonlyspan_argument() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(scoped System.ReadOnlySpan data, out int v); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(scoped System.ReadOnlySpan data, out int v); }"; GeneratorRun run = roslyn.Run(Source); @@ -24,11 +24,44 @@ public void Generator_repeats_an_explicit_scoped_readonlyspan_argument() run.SingleGeneratedText, StringComparison.Ordinal); } + [Theory] + [InlineData("LuaOptional count", "global::CheatEngine.SDK.Lua.Marshalling.LuaOptional count")] + [InlineData("LuaOptional text", "global::CheatEngine.SDK.Lua.Marshalling.LuaOptional text")] + [InlineData("LuaOptional address", "global::CheatEngine.SDK.Lua.Marshalling.LuaOptional address")] + public void Generator_repeats_optional_argument_and_result_types_exactly(string declared, string generated) + { + string source = Usings + "using CheatEngine.SDK.Lua.Calls;\nusing CheatEngine.SDK.Lua.Marshalling;\n" + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(int first, " + + declared + ", out LuaOptional result); }"; + + GeneratorRun run = roslyn.Run(source); + + run.AssertCompilesClean(); + Assert.Contains( + "public static partial bool TryG(int first, " + generated + + ", out global::CheatEngine.SDK.Lua.Marshalling.LuaOptional result)", + run.SingleGeneratedText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_repeats_an_explicit_scoped_variadic_span() + { + const string Source = Usings + "using CheatEngine.SDK.Lua.Calls;\n" + + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial LuaOperationStatus G(scoped System.Span values, out int count); }"; + + GeneratorRun run = roslyn.Run(Source); + + run.AssertCompilesClean(); + Assert.Contains( + "G(scoped global::System.Span values, out int count)", run.SingleGeneratedText, + StringComparison.Ordinal); + } + [Fact] public void Generator_omits_scoped_when_the_defining_declaration_did() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(System.ReadOnlySpan data, out int v); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(System.ReadOnlySpan data, out int v); }"; GeneratorRun run = roslyn.Run(Source); @@ -43,7 +76,7 @@ public void Generator_omits_scoped_when_the_defining_declaration_did() public void Generator_repeats_an_explicit_scoped_copyout_destination() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped System.Span destination, out int written); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped System.Span destination, out int written); }"; GeneratorRun run = roslyn.Run(Source); @@ -56,7 +89,7 @@ public void Generator_repeats_an_explicit_scoped_copyout_destination() public void Generator_omits_scoped_on_the_copyout_destination_when_the_defining_declaration_did() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, System.Span destination, out int written); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, System.Span destination, out int written); }"; GeneratorRun run = roslyn.Run(Source); @@ -72,7 +105,7 @@ public void Generator_scoped_out_result_of_a_non_ref_struct_type_needs_no_specia // 'scoped' on an 'out' parameter of a non-ref-struct type does not affect partial-signature matching (the // compiler accepts a mismatch there), so the emitter never needs to repeat it; this pins that down. const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped out int v); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped out int v); }"; GeneratorRun run = roslyn.Run(Source); @@ -85,7 +118,7 @@ public void Generator_scoped_out_result_of_a_non_ref_struct_type_needs_no_specia public void Generator_preserves_the_extension_receiver_in_the_implementing_declaration() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this nuint address, out int value); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this nuint address, out int value); }"; GeneratorRun run = roslyn.Run(Source); @@ -98,7 +131,7 @@ public void Generator_preserves_the_extension_receiver_in_the_implementing_decla public void Generator_preserves_the_extension_receiver_on_a_copyout_destination() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this System.Span destination, out int written); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this System.Span destination, out int written); }"; GeneratorRun run = roslyn.Run(Source); @@ -111,7 +144,7 @@ public void Generator_preserves_the_extension_receiver_on_a_copyout_destination( public void Generator_qualifies_the_cache_when_a_parameter_uses_its_name() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial void G(int s_luaGlobal_g); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial void G(int s_luaGlobal_g); }"; GeneratorRun run = roslyn.Run(Source); @@ -126,7 +159,7 @@ public void Generator_parameter_named_like_a_generated_local_is_skipped_without_ // A generated partial body shares its parameter scope with the defining declaration. Do not emit CS0136 and // leave the analyzer to report the precise CESDK2007 collision at the author declaration. const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint __L, out int v); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint __L, out int v); }"; GeneratorRun run = roslyn.Run(Source); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/FidelityBindingSources.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/FidelityBindingSources.cs new file mode 100644 index 00000000..a36ffa8a --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/FidelityBindingSources.cs @@ -0,0 +1,215 @@ +namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; + +/// +/// The declarations and stand-in globals of the qualification suites Q20 (string and byte fidelity), Q21 (integer +/// boundaries) and Q22 (nil, false, zero, zero results and Lua errors): compiled warning-free against +/// the real SDK assemblies and executed on the bundled Lua fixture. Kept apart from so +/// that the pinned outputs of the existing shapes stay untouched. +/// +internal static class FidelityBindingSources +{ + /// + /// q22(kind) returns one shape per kind: nil, false, true, 0, 42, 7 (odd), -2 + /// (negative), '', {}, no value at all, a string error and a table error whose __tostring + /// raises. + /// + public static ReadOnlySpan Q22StandIns => """ + local raising = setmetatable({}, { __tostring = function() error('tostring boom') end }) + local cases = { + ['nil'] = function() return nil end, + ['false'] = function() return false end, + ['true'] = function() return true end, + zero = function() return 0 end, + value = function() return 42 end, + odd = function() return 7 end, + negative = function() return -2 end, + empty = function() return '' end, + table = function() return {} end, + none = function() end, + raise = function() error('boom') end, + raise_table = function() error(raising) end, + } + function q22(kind) return cases[kind]() end + """u8; + + /// Every Q22 form: Try, Outcome and Throwing over integer, boolean, string, optional and custom results. + public const string Q22Suite = """ + using System; + using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.Marshalling; + using CheatEngine.SDK.Lua.State; + + namespace Demo; + + public readonly struct EvenMarshaller : ILuaMarshaller + { + public static void Push(LuaState state, long value) => state.PushInteger(value); + + public static bool TryRead(LuaState state, int index, out long value) + { + if (!state.TryReadInteger(index, out value)) + { + return false; + } + + if (value < 0) + { + throw new InvalidOperationException("negative token"); + } + + if (value % 2 != 0) + { + value = 0; + return false; + } + + return true; + } + } + + public static partial class Q22 + { + [LuaGlobal("q22")] + public static partial bool TryInt32(string kind, out int value); + + [LuaGlobal("q22")] + public static partial bool TryBoolean(string kind, out bool value); + + [LuaGlobal("q22")] + public static partial bool TryText(string kind, [MaybeNullWhen(false)] out string value); + + [LuaGlobal("q22")] + public static partial bool TryTextCopy(string kind, Span destination, out int written); + + [LuaGlobal("q22")] + public static partial LuaOperationStatus OutcomeInt32(string kind, out int value); + + [LuaGlobal("q22")] + public static partial LuaOperationStatus OutcomeOptional(string kind, out LuaOptional value); + + [LuaGlobal("q22")] + public static partial int Int32(string kind); + + [LuaGlobal("q22")] + public static partial bool Boolean(string kind); + + [LuaGlobal("q22")] + public static partial string Text(string kind); + + [LuaGlobal("q22")] + public static partial bool TryEven(string kind, [LuaMarshaller(typeof(EvenMarshaller))] out long value); + + [LuaGlobal("q22")] + public static partial LuaOperationStatus OutcomeEven(string kind, [LuaMarshaller(typeof(EvenMarshaller))] out long value); + + [LuaGlobal("q22")] + [return: LuaMarshaller(typeof(EvenMarshaller))] + public static partial long Even(string kind); + } + """; + + /// boundary(expression) evaluates a Lua expression, so each row is a value Lua itself produced. + public static ReadOnlySpan NumericStandIns => """ + function boundary(expression) return assert(load('return ' .. expression))() end + """u8; + + /// Integer, 64-bit and address results in every form, and the same kinds as exported function arguments. + public const string NumericSuite = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + + namespace Demo; + + public static partial class Numbers + { + [LuaGlobal("boundary")] + public static partial bool TryInt32(string expression, out int value); + + [LuaGlobal("boundary")] + public static partial bool TryInt64(string expression, out long value); + + [LuaGlobal("boundary")] + public static partial bool TryAddress(string expression, out nuint value); + + [LuaGlobal("boundary")] + public static partial LuaOperationStatus OutcomeInt32(string expression, out int value); + + [LuaGlobal("boundary")] + public static partial LuaOperationStatus OutcomeInt64(string expression, out long value); + + [LuaGlobal("boundary")] + public static partial LuaOperationStatus OutcomeAddress(string expression, out nuint value); + + [LuaGlobal("boundary")] + public static partial int Int32(string expression); + + [LuaGlobal("boundary")] + public static partial long Int64(string expression); + + [LuaGlobal("boundary")] + public static partial nuint Address(string expression); + + [LuaFunction("n_int")] + public static int EchoInt32(int value) => value; + + [LuaFunction("n_long")] + public static long EchoInt64(long value) => value; + + [LuaFunction("n_addr")] + public static nuint EchoAddress(nuint value) => value; + } + """; + + /// + /// s_value(key) returns a string with embedded NULs, multibyte UTF-8, invalid UTF-8 or the empty string; + /// s_len returns the byte length Lua received, s_echo the string itself, s_type its type. + /// + public static ReadOnlySpan StringStandIns => """ + local values = { nul = 'a\0b\0', multibyte = '\u{E9}\u{6F22}\u{1F600}', invalid = '\255\254A', empty = '' } + function s_value(key) return values[key] end + function s_len(s) return #s end + function s_echo(s) return s end + function s_type(s) return type(s) end + """u8; + + /// String and UTF-8 arguments and results in every shape, and a nullable string function argument. + public const string StringSuite = """ + using System; + using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lua; + + namespace Demo; + + public static partial class Strings + { + [LuaGlobal("s_value")] + public static partial bool TryText(string key, [MaybeNullWhen(false)] out string value); + + [LuaGlobal("s_value")] + public static partial bool TryTextCopy(string key, Span destination, out int written); + + [LuaGlobal("s_value")] + public static partial string Text(string key); + + [LuaGlobal("s_len")] + public static partial long Utf8Length(ReadOnlySpan text); + + [LuaGlobal("s_len")] + public static partial long TextLength(string text); + + [LuaGlobal("s_echo")] + public static partial string Echo(string text); + + [LuaGlobal("s_echo")] + public static partial bool TryEchoCopy(ReadOnlySpan text, Span destination, out int written); + + [LuaGlobal("s_type")] + public static partial string TypeOf(string? text); + + [LuaFunction("f_describe")] + public static string Describe(string? text) => text is null ? "null" : "text:" + text.Length; + } + """; +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs index 72cd2bc4..959c4c4f 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs @@ -94,7 +94,7 @@ public void AssertCompilesClean() .. OutputCompilation .GetDiagnostics(TestContext.Current.CancellationToken) .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) + !IsMissingDocumentationInTestInput(diagnostic)) ]; Assert.True(problems.Length == 0, @@ -106,7 +106,7 @@ .. OutputCompilation private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) { return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs index 8b157554..b179ad3a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -63,8 +63,8 @@ public static ImmutableArray FromTargetingPack() { string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out Version? version) - && (bestVersion is null || version > bestVersion)) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) { best = candidate; bestVersion = version; diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs index 27df8352..4517cfd2 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs @@ -70,7 +70,7 @@ private static void Visit(object? value, string path, HashSet visited, i } foreach (FieldInfo field in - type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/OptionalBindingSources.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/OptionalBindingSources.cs new file mode 100644 index 00000000..77977459 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/OptionalBindingSources.cs @@ -0,0 +1,126 @@ +namespace CheatEngine.SDK.SourceGenerators.LuaBindings.Tests.Infrastructure; + +/// +/// Declarations with LuaOptional<T> arguments, optional and variadic results (audit A19-07, A19-08, +/// F10): compiled warning-free against the real SDK assemblies and executed by the EndToEnd suites. Kept apart from +/// so that the pinned outputs of the existing shapes stay untouched. +/// +internal static class OptionalBindingSources +{ + /// The stand-in globals every optional suite runs against. + public static ReadOnlySpan StandIns => """ + calls = 0 + function arity(...) calls = calls + 1 return select('#', ...) end + function kinds(...) + local t = table.pack(...) + local out = {} + for i = 1, t.n do out[#out + 1] = type(t[i]) end + return table.concat(out, ',') + end + function readBytesLike(...) + local n = select('#', ...) + local address, count, asTable = ... + if n == 1 then return 'one byte' end + if n == 2 then return 'values' end + if asTable == nil then return 'nil flag' end + if asTable then return 'table' end + return 'values' + end + function shape(mode) + if mode == 0 then return end + if mode == 1 then return nil end + if mode == 2 then return 7 end + if mode == 3 then return 7, 8 end + if mode == 4 then return true, 'warn' end + if mode == 5 then return 1970354901756285, {} end + if mode == 6 then return 'text' end + if mode == 7 then return 7, 'x' end + if mode == 8 then return 7, nil end + error('boom') + end + function seq(n, bad) + if bad == 1 then return 1, nil, 3 end + if bad == 2 then return 1, 'x', 3 end + if n < 0 then error('negative') end + local t = {} + for i = 1, n do t[i] = i * 10 end + return table.unpack(t, 1, n) + end + """u8; + + /// Every supported optional argument, optional result and variadic shape of a bound global. + public const string GlobalSuite = """ + using System; + using System.Diagnostics.CodeAnalysis; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.Marshalling; + using CheatEngine.SDK.Lua.State; + + namespace Demo; + + public static partial class Optionals + { + [LuaGlobal("arity")] + public static partial long Arity(long first, LuaOptional second, LuaOptional third); + + [LuaGlobal("kinds")] + public static partial string Kinds(long first, LuaOptional second); + + [LuaGlobal("arity")] + public static partial bool TryArity(long first, LuaOptional second, out long count); + + [LuaGlobal("arity")] + public static partial LuaOperationStatus ArityDetailed(long first, LuaOptional second, LuaOptional third, out long count); + + [LuaGlobal("arity")] + public static partial long ArityOn(LuaState state, LuaOptional first); + + [LuaGlobal("readBytesLike")] + public static partial string ReadBytesLike(nuint address, LuaOptional count, LuaOptional asTable); + + [LuaGlobal("shape")] + public static partial LuaOperationStatus ShapeOptional(int mode, out LuaOptional first); + + [LuaGlobal("shape")] + public static partial LuaOperationStatus ShapeRequiredThenOptional(int mode, out long first, out LuaOptional second); + + [LuaGlobal("shape")] + public static partial bool TryShapeRequiredThenOptional(int mode, out long first, out LuaOptional second); + + [LuaGlobal("shape")] + public static partial LuaOperationStatus ShapeFlagAndWarning(int mode, out bool ok, out LuaOptional warning); + + [LuaGlobal("shape")] + public static partial bool TryShapeScalar(int mode, out long value); + + [LuaGlobal("seq")] + public static partial LuaOperationStatus Sequence(int count, LuaOptional bad, Span values, out int valueCount); + + [LuaGlobal("seq")] + public static partial LuaOperationStatus SequenceAfterOne(int count, out long first, out LuaOptional second, Span rest, out int restCount); + } + """; + + /// Exported functions with optional trailing parameters. + public const string FunctionSuite = """ + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Marshalling; + + namespace Demo; + + public static partial class OptionalFunctions + { + [LuaFunction("optdescribe")] + public static string Describe(long first, LuaOptional second, LuaOptional third) => + first + "|" + State(second) + "|" + State(third); + + [LuaFunction("optflag")] + public static string Flag(LuaOptional value) => value.IsOmitted ? "omitted" : value.IsNil ? "nil" : value.Value ? "yes" : "no"; + + private static string State(LuaOptional value) + where T : notnull => + value.IsOmitted ? "omitted" : value.IsNil ? "nil" : value.Value.ToString()!; + } + """; +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs index 2b56bfe2..21d707fa 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs @@ -58,10 +58,14 @@ public void Group_skips_invalid_members_and_types_left_without_a_valid_one() { LuaFunctionModel invalid = Function(Alpha, "bad") with { - Issues = LuaFunctionShapeIssues.NotStatic, Thunk = null + Issues = LuaFunctionShapeIssues.NotStatic, + Thunk = null }; LuaFunctionModel invalidType = - Function(Zeta, "ok") with { ContainingTypeIssues = ContainingTypeIssues.NotPartial }; + Function(Zeta, "ok") with + { + ContainingTypeIssues = ContainingTypeIssues.NotPartial + }; EquatableArray tables = LuaFunctionTables.Group([invalid, invalidType, Function(Alpha, "good")]); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs index 87a5b8bc..a4ecba71 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs @@ -38,7 +38,8 @@ public void Group_skips_invalid_members_and_empty_inputs() { LuaGlobalModel invalid = Global("g", "G()") with { - Issues = LuaGlobalShapeIssues.NotPartialDefinition, Call = null + Issues = LuaGlobalShapeIssues.NotPartialDefinition, + Call = null }; Assert.True(LuaGlobalTables.Group([invalid]).IsEmpty); @@ -79,6 +80,47 @@ public void Group_assigns_case_insensitive_collision_names_independent_of_input_ HintNames.Disambiguated("Demo.type", LuaGlobalTableModel.HintSuffix)); } + [Fact] + public void Optional_argument_model_is_value_equal_across_runs() + { + LuaGlobalCallModel first = OptionalCall(); + LuaGlobalCallModel second = OptionalCall(); + + Assert.Equal(first, second); + Assert.Equal(first.GetHashCode(), second.GetHashCode()); + Assert.NotEqual(first, first with + { + Arguments = new EquatableArray([ + new LuaArgumentModel("address", LuaValueKind.Address, false), + new LuaArgumentModel("count", LuaValueKind.Int32, false) + ]) + }); + Assert.NotEqual(first, first with + { + Results = new EquatableArray([LuaResultModel.Value(LuaValueKind.Int64, "value")]) + }); + Assert.Equal( + new LuaArgumentModel("count", LuaValueKind.Int32, false, false, null, null), + new LuaArgumentModel("count", LuaValueKind.Int32, false)); + Assert.False(new LuaArgumentModel("count", LuaValueKind.Int32, false, false, null, null).IsOptional); + } + + private static LuaGlobalCallModel OptionalCall() + { + return new LuaGlobalCallModel("readBytes", LuaGlobalCallModel.CacheFieldFor("readBytes"), "public static partial", + "ReadBytes", string.Empty, + new EquatableArray([ + new LuaArgumentModel("address", LuaValueKind.Address, false), + LuaArgumentModel.Optional("count", LuaValueKind.Int32) + ]), + LuaCallForm.Outcome, + new EquatableArray([ + LuaResultModel.Optional(LuaValueKind.Int64, "value"), + LuaResultModel.Variadic(LuaValueKind.Int32, "values", "count") + ]), + null, false); + } + private static LuaGlobalModel Global(string name, string sortKey) { return Global(Memory, name, sortKey); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/README.md b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/README.md index 65833df9..33659247 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/README.md +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/README.md @@ -18,14 +18,16 @@ the [generator README](../../source-generators/CheatEngine.SDK.SourceGenerators. ## How it works -| Suite | What it proves | -|-----------------|----------------------------------------------------------------------------------------------------------------------------------| -| Output | Exact text for the nominal sources, and clean compilation of every supported shape, containing type and partial-method signature | -| Input isolation | Invalid shapes and look-alike attributes emit no conflicting source; a globals-only project works without `AllowUnsafeBlocks` | -| Object handles | Borrowed-handle identity, marshalling, protected methods/properties and valid-sibling isolation | -| Incrementality | Edits that cannot change the output recompute nothing, and an edit to one kind of binding leaves the other's output cached | -| End to end | Generated thunks and wrappers are compiled, loaded and run against a real Lua 5.3 state through the real `LuaRuntime` | -| Shared code | Unit tests of the linked `LuaEmit` emitters, `LuaNames`, `LuaValueKinds`, `HintNames` and the grouping models | +| Suite | What it proves | +|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Output | Exact text for the nominal sources, and clean compilation of every supported shape, containing type and partial-method signature | +| Input isolation | Invalid shapes and look-alike attributes emit no conflicting source; a globals-only project works without `AllowUnsafeBlocks` | +| Object handles | Borrowed-handle identity, marshalling, protected methods/properties and valid-sibling isolation | +| Incrementality | Edits that cannot change the output recompute nothing, and an edit to one kind of binding leaves the other's output cached | +| End to end | Generated thunks and wrappers are compiled, loaded and run against a real Lua 5.3 state through the real `LuaRuntime` | +| Optional shapes | `OptionalBindingSources`: omitted, `nil` and present arguments, the factual result count and variadic capacity (`LuaGlobalOptionalArgumentEndToEndTests`, `LuaGlobalResultCountEndToEndTests`) | +| Qualification | `FidelityBindingSources`: Q20 strings (`LuaGlobalStringFidelityEndToEndTests`), Q21 integer boundaries (`LuaGlobalNumericBoundaryEndToEndTests`) and the Q22 per-form matrix (`LuaGlobalQ22MatrixEndToEndTests`), each method traited `Qualification=Q2x` | +| Shared code | Unit tests of the linked `LuaEmit` emitters, `LuaNames`, `LuaValueKinds`, `HintNames` and the grouping models | Expected text is written by hand, independent of the emitter, and normalized to LF. "Compiles clean" means no warning or error at warning level 9999 on C# 14 with nullable on. Emitted assemblies load into their own context, which resolves @@ -59,3 +61,7 @@ dotnet test --project tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests - (`LuaGlobalEndToEndTests`). - Warm thunk calls and the warm `Try` form allocate nothing on the managed side (`LuaFunctionEndToEndTests`, `LuaGlobalEndToEndTests`). +- Optional arguments and results keep omitted, `nil` and values apart, and results are read by their factual count + (`LuaGlobalOptionalArgumentEndToEndTests`, `LuaGlobalResultCountEndToEndTests`). +- The qualification suites Q20, Q21 and Q22 run at C2 on the bundled Lua and assert `L.Top == 0` after every exit + (`LuaGlobalStringFidelityEndToEndTests`, `LuaGlobalNumericBoundaryEndToEndTests`, `LuaGlobalQ22MatrixEndToEndTests`). diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs index 27ecdc26..49049442 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs @@ -91,6 +91,6 @@ public void AllocateUnique_rejects_a_case_sensitive_reservation_set() private static string WithOrdinal(string hintName, string suffix, int ordinal) { return hintName[..^suffix.Length] + "_" + ordinal.ToString(CultureInfo.InvariantCulture) + - suffix; + suffix; } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs index 732aa358..38aac32d 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs @@ -42,7 +42,7 @@ public void Every_CheatEngine_SDK_name_written_into_generated_code_denotes_a_rea { int dot = text.LastIndexOf('.'); Type owner = FindType(text[..dot]) - ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); + ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); Assert.NotEmpty(owner.GetMember( text[(dot + 1)..^2], MemberTypes.Method, @@ -51,7 +51,7 @@ public void Every_CheatEngine_SDK_name_written_into_generated_code_denotes_a_rea else { _ = FindType(text) - ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); + ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); } } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs index 1b193a99..9cbb8d14 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs @@ -187,7 +187,10 @@ public void ResultCount_follows_the_form() LuaGlobalCallModel throwingVoid = new("g", "s", "static", "G", string.Empty, EquatableArray.Empty, LuaCallForm.Throwing, EquatableArray.Empty, null, false); - LuaGlobalCallModel throwingValue = throwingVoid with { ReturnKind = LuaValueKind.Double }; + LuaGlobalCallModel throwingValue = throwingVoid with + { + ReturnKind = LuaValueKind.Double + }; Assert.Equal(0, throwingVoid.ResultCount); Assert.Equal(1, throwingValue.ResultCount); @@ -196,6 +199,106 @@ public void ResultCount_follows_the_form() Assert.Equal("s_luaGlobal_readInteger", LuaGlobalCallModel.CacheFieldFor("readInteger")); } + [Fact] + public void Emit_optional_arguments_pushes_nil_and_stops_at_the_first_omission() + { + LuaGlobalCallModel model = new( + "loadTable", + "s_luaGlobal_loadTable", + "public static", + "LoadTable", + string.Empty, + new EquatableArray([ + new LuaArgumentModel("path", LuaValueKind.String, false), + LuaArgumentModel.Optional("merge", LuaValueKind.Boolean), + LuaArgumentModel.Optional("@base", LuaValueKind.Address) + ]), + LuaCallForm.Throwing, + EquatableArray.Empty, + null, + false); + + Assert.Equal( + """ + public static void LoadTable(string path, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional merge, global::CheatEngine.SDK.Lua.Marshalling.LuaOptional @base) + { + int __argc = !@base.IsOmitted ? 3 : !merge.IsOmitted ? 2 : 1; + if (__argc > 2 && merge.IsOmitted) + { + throw new global::System.ArgumentException("The optional argument 'merge' of the Lua global 'loadTable' is omitted while a later optional argument is present; Lua cannot receive an argument after an absent one. Pass LuaOptional.Nil() to send nil in its place.", "merge"); + } + + using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation __operation = global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(); + global::CheatEngine.SDK.Lua.State.LuaState __L = __operation.State; + int __top = __L.Top; + try + { + if (!global::CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(__L, s_luaGlobal_loadTable, "loadTable"u8)) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(__L, __top, "loadTable"); + } + + global::CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(__L, path); + if (__argc > 1) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.PushOptional(__L, merge); + } + if (__argc > 2) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.PushOptional(__L, @base); + } + global::CheatEngine.SDK.Lua.Calls.LuaStatus __status = __L.TryCall(__argc, 0); + if (!__status.IsOk) + { + global::CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(__L, __top, __status); + } + } + finally + { + __L.SetTop(__top); + } + } + + """.ReplaceLineEndings("\n"), + Emit(model)); + } + + [Fact] + public void Optional_and_variadic_shapes_are_reported_by_the_model_and_their_locals_are_reserved() + { + LuaGlobalCallModel model = new("seq", "s_luaGlobal_seq", "public static", "Seq", string.Empty, + new EquatableArray([LuaArgumentModel.Optional("n", LuaValueKind.Int32)]), + LuaCallForm.Outcome, + new EquatableArray([ + LuaResultModel.Value(LuaValueKind.Int64, "first"), + LuaResultModel.Optional(LuaValueKind.String, "second"), + LuaResultModel.Variadic(LuaValueKind.Double, "values", "count", true) + ]), + null, + false); + + Assert.True(model.HasOptionalArguments); + Assert.True(model.HasDynamicResults); + Assert.Equal(1, model.RequiredResultCount); + Assert.False(model.Results[1].IsReferenceType); + Assert.Equal("global::CheatEngine.SDK.Lua.Marshalling.LuaOptional", model.Results[1].GeneratedTypeName); + + SourceWriter writer = new(); + LuaGlobalCallEmitter.WriteParameterList(writer, model); + Assert.Equal( + "(global::CheatEngine.SDK.Lua.Marshalling.LuaOptional n, out long first, out global::CheatEngine.SDK.Lua.Marshalling.LuaOptional second, scoped global::System.Span values, out int count)", + writer.ToString()); + + foreach (string local in (string[]) ["__L", "__operation", "__top", "__ok", "__status", "__result", + "__resolution", "__exception", "__argc", "__rest"]) + { + Assert.True(LuaGlobalCallEmitter.IsReservedLocal(local), local); + } + + Assert.False(LuaGlobalCallEmitter.IsReservedLocal("__count")); + Assert.False(LuaGlobalCallEmitter.IsReservedLocal("argc")); + } + [Fact] public void Emit_rejects_null_arguments() { diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaThunkEmitterTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaThunkEmitterTests.cs index 41ec2b49..d4a38e64 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaThunkEmitterTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaThunkEmitterTests.cs @@ -75,6 +75,50 @@ public void WrongArgumentCountMessage_names_the_function_and_the_count() Assert.Equal("__LuaThunk_add", LuaThunkModel.ThunkNameFor("add")); } + [Fact] + public void WrongArgumentCountMessage_names_the_accepted_range() + { + Assert.Equal("wrong number of arguments to 'f' (1 to 3 expected)", + LuaThunkEmitter.WrongArgumentCountMessage("f", 1, 3)); + Assert.Equal("wrong number of arguments to 'g' (0 to 1 expected)", + LuaThunkEmitter.WrongArgumentCountMessage("g", 0, 1)); + Assert.Equal(LuaThunkEmitter.WrongArgumentCountMessage("add", 2), + LuaThunkEmitter.WrongArgumentCountMessage("add", 2, 2)); + + LuaThunkModel optional = new("f", "__LuaThunk_f", "global::Demo.Suite.F", false, + new EquatableArray([ + new LuaArgumentModel("a", LuaValueKind.Int64, false), + LuaArgumentModel.Optional("b", LuaValueKind.String) + ]), null); + string text = Emit(optional); + Assert.Equal(1, optional.RequiredArgumentCount); + Assert.Contains("if (__L.Top < 1 || __L.Top > 2)\n", text, StringComparison.Ordinal); + Assert.Contains( + "LuaCallSupport.TryReadOptional(__L, 2, out global::CheatEngine.SDK.Lua.Marshalling.LuaOptional __arg1)", + text, StringComparison.Ordinal); + Assert.Contains("global::Demo.Suite.F(__arg0, __arg1);\n", text, StringComparison.Ordinal); + } + + [Fact] + public void Thunk_is_cdecl_unmanaged_callers_only_and_catches_every_exception() + { + foreach (LuaThunkModel model in (LuaThunkModel[]) [Ping, IsInteger]) + { + string text = Emit(model); + + Assert.StartsWith( + "[global::System.Runtime.InteropServices.UnmanagedCallersOnly(CallConvs = new[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })]\nprivate static int ", + text, StringComparison.Ordinal); + Assert.Contains("(nint __handle)\n", text, StringComparison.Ordinal); + Assert.Contains( + "catch (global::System.Exception __exception)\n {\n return global::CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(__L, __exception);", + text, StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences(text, "catch (")); + Assert.DoesNotContain("lua_error", text, StringComparison.Ordinal); + Assert.DoesNotContain("throw", text, StringComparison.Ordinal); + } + } + [Fact] public void Registration_emits_a_lease_and_legacy_registration_pair_in_the_given_order() { @@ -141,4 +185,17 @@ private static string Emit(LuaThunkModel model) LuaThunkEmitter.Emit(writer, model); return writer.ToString(); } + + private static int CountOccurrences(string text, string value) + { + int count = 0; + for (int index = text.IndexOf(value, StringComparison.Ordinal); + index >= 0; + index = text.IndexOf(value, index + value.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaValueKindsTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaValueKindsTests.cs index cb65ce68..4cd8f3e1 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaValueKindsTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaValueKindsTests.cs @@ -57,6 +57,37 @@ public void String_is_the_only_nullable_and_reference_kind() Assert.False(LuaValueKinds.IsReferenceType(LuaValueKind.Utf8)); } + [Fact] + [Trait("Qualification", "Q21")] + public void Integer_and_address_kinds_never_use_the_double_marshaller() + { + foreach (LuaValueKind kind in (LuaValueKind[]) [LuaValueKind.Int32, LuaValueKind.Int64, LuaValueKind.Address]) + { + Assert.DoesNotContain("Double", LuaValueKinds.MarshallerTypeName(kind), StringComparison.Ordinal); + Assert.DoesNotContain("Single", LuaValueKinds.MarshallerTypeName(kind), StringComparison.Ordinal); + Assert.Equal("integer", LuaValueKinds.ExpectedArgument(kind)); + } + + Assert.Equal("global::CheatEngine.SDK.Lua.Marshalling.Int64Marshaller", + LuaValueKinds.MarshallerTypeName(LuaValueKind.Int64)); + Assert.Equal("global::CheatEngine.SDK.Lua.Marshalling.AddressMarshaller", + LuaValueKinds.MarshallerTypeName(LuaValueKind.Address)); + } + + [Fact] + public void Optional_and_variadic_kinds_are_the_marshalled_scalars() + { + foreach (LuaValueKind kind in Enum.GetValues()) + { + Assert.Equal(kind != LuaValueKind.Utf8, LuaValueKinds.CanBeOptional(kind)); + Assert.Equal(kind is not (LuaValueKind.Utf8 or LuaValueKind.String), + LuaValueKinds.CanBeVariadicElement(kind)); + } + + Assert.Equal("global::CheatEngine.SDK.Lua.Marshalling.LuaOptional", + LuaValueKinds.OptionalTypeName(LuaValueKind.String)); + } + [Fact] public void Utf8_is_the_only_kind_that_cannot_be_a_result() { diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/packages.lock.json b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/packages.lock.json new file mode 100644 index 00000000..ffda23d3 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/packages.lock.json @@ -0,0 +1,493 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing": { + "type": "Direct", + "requested": "[1.1.4, )", + "resolved": "1.1.4", + "contentHash": "gdwvnsx9QrPfU3Uz4iJNhJFoJRL5Al/F0VRoFE6+QgBmibJbEfZf06v5B+NDg14QzGIiU2OP9kEQM20lx7rZdw==", + "dependencies": { + "Microsoft.CodeAnalysis.CSharp.Workspaces": "3.8.0", + "Microsoft.CodeAnalysis.SourceGenerators.Testing": "[1.1.4]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "D2zqK/k16fto0yMz0hcXMTkzOxEwMDJyA1mu/KXF9Befwz4zub3MpHQD8FeRxJtVSSsC3dQFYBw7zu7r/pfO7g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.CSharp": "[5.9.0]", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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]" + } + }, + "DiffPlex": { + "type": "Transitive", + "resolved": "1.7.2", + "contentHash": "qJEjdxEDBWSFZGB8paBB9HDeJXHGlHlOXeGX3kbTuXWuOsgv2iSAEOOzo5V1/B39Vcxr9IVVrNKewRcX+rsn4g==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "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.CodeAnalysis.Analyzer.Testing": { + "type": "Transitive", + "resolved": "1.1.4", + "contentHash": "IQoZbcXE7uVLngTtFFkSnh813ItZj4+nDfW7L0F1p9YaTlpefyL+poge3J1+eRSgreiFpBmTK4HI/kZhGJMVNg==", + "dependencies": { + "DiffPlex": "1.7.2", + "Microsoft.CodeAnalysis.Workspaces.Common": "1.0.1", + "Microsoft.VisualStudio.Composition": "16.1.8", + "NuGet.Common": "7.0.3", + "NuGet.Packaging": "7.0.3", + "NuGet.Protocol": "7.0.3", + "NuGet.Resolver": "7.0.3" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + } + }, + "Microsoft.CodeAnalysis.SourceGenerators.Testing": { + "type": "Transitive", + "resolved": "1.1.4", + "contentHash": "Vog522X/3d0vKMAHzZAkMrwSII+ticKSw3ioGLCFHGeishJNjl5irkWRoACuDRQ0dSbWj2UkMgNan9kIGX1tVw==", + "dependencies": { + "DiffPlex": "1.7.2", + "Microsoft.CodeAnalysis.Analyzer.Testing": "[1.1.4]", + "Microsoft.CodeAnalysis.Workspaces.Common": "3.8.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "1A6jz50NG4nOEW8tX5+h+MyHqjWL0mPGwrUdwu+OlTfyknLo0GfxSqj4zEks8uVUdHdo9v8Ir9dHxACf8iYNEA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Composition": "10.0.1" + } + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.VisualStudio.Composition": { + "type": "Transitive", + "resolved": "16.1.8", + "contentHash": "N+thv3dcT7kjn0Xz3U0uBm2CH4uoaMvH8wC6Gy2HWx7HLNdEpqGoMraLyoBdizmypD1owLCJQIa2uKmWe4/o8A==", + "dependencies": { + "Microsoft.VisualStudio.Composition.NetFxAttributes": "16.1.8", + "Microsoft.VisualStudio.Validation": "15.0.82", + "System.Composition": "1.0.31" + } + }, + "Microsoft.VisualStudio.Composition.NetFxAttributes": { + "type": "Transitive", + "resolved": "16.1.8", + "contentHash": "EbwZWTvdzit68qZSuTI8nd1PZ87pYjhpCwtsis8lrUKJ7XLdbE5rxY6YrY7OFze+YUsguzqZlNjX4Yn5nL9qBw==", + "dependencies": { + "System.ComponentModel.Composition": "4.5.0" + } + }, + "Microsoft.VisualStudio.Validation": { + "type": "Transitive", + "resolved": "15.0.82", + "contentHash": "XwZyVCsHuEtnd6nYScJnA8XkXPzy4Ok0DV5/hqqAe5ccgOhJ6yap7Qh/sU/i6QxEzuYyECPYDQ7IOyEQ3yRQgQ==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Common": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "vFBP1TkmeFxUjOqlGn7M3QbZfsg+hyFcCUFy8hPCdmk9VPhFQPwUMH4zKZnd8Bf8x89CFnpRXb8EC6tmWfDgNA==", + "dependencies": { + "NuGet.Frameworks": "7.0.3" + } + }, + "NuGet.Configuration": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "DZC5/eP6X0qDU4FK1X318nT9gBmjbBOQxphEFnn67B5kKi1/ODRANGOlnNn0tUkZDFtPtLl7gC6bfnf/cM7j5Q==", + "dependencies": { + "NuGet.Common": "7.0.3", + "System.Security.Cryptography.ProtectedData": "9.0.6" + } + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "JsV24AwAS93mQhlJr1Fx9zGHO6dVkexYp8ZZoDuoIBLyN9KwPEZOqo2SJVq0k2EPYO2oomj96Ue2yrO0hMOHrw==" + }, + "NuGet.Packaging": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "eMdAeZU4ugSC5WwO2u9CcY+jBjt/E6YunQ25oO6M5mM5A5+5QM1AdfoZPtFfwCuIgb/tbY52ciGOAXpBAqLVDA==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "NuGet.Configuration": "7.0.3", + "NuGet.Versioning": "7.0.3", + "System.Security.Cryptography.Pkcs": "9.0.6" + } + }, + "NuGet.Protocol": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "B9BwrQ0sOsSuCFijIL1LmX/hVVIhDf1WmI86neWak1yVvQ2r13TuFbMok6LXUM5htU8890XinB5ouE5hMHaxTA==", + "dependencies": { + "NuGet.Packaging": "7.0.3" + } + }, + "NuGet.Resolver": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "3L5FNJmHSBhHQms7YKrrMjPn5Q1iTgi0Ws0nyYN0cPeGKpA4p4Ey3X9oWWqz9FspTI7IqiPEu79NB7ipBYafvw==", + "dependencies": { + "NuGet.Protocol": "7.0.3" + } + }, + "NuGet.Versioning": { + "type": "Transitive", + "resolved": "7.0.3", + "contentHash": "jwZmJzau0kUTxUvtmjbEXyvhx0ui9wGPByDT8qV3qDSVK9rChgsSeZqb1Bybu258G9urdEcE9HqZCdRbi7h9BA==" + }, + "System.ComponentModel.Composition": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "+iB9FoZnfdqMEGq6np28X6YNSUrse16CakmIhV3h6PxEWt7jYxUN3Txs1D8MZhhf4QmyvK0F/EcIN0f4gGN0dA==", + "dependencies": { + "System.Security.Permissions": "4.5.0" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DJkqaWQfho/ReTzKcZD3zJJ6K4GcS154k+T0UCPMBNIOZ2U/lNpyiiWZ6Etw0onWyTH1K+yhICsdmwA5xy2aPQ==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Convention": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1", + "System.Composition.TypedParts": "10.0.1" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "mRxYvpCVPAeuLEk0c0kxWJVjbW1/HUoxCgYotOj9eDeQiYcTDOMdCQApsTrHYMN3pHBA8WoF00KGolG632Etaw==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "dYynUByfVBzYDheNPGxS8UN8AvG/4tXf/coSs1odHOyoh4etv1kad/FrLWLMq4f8NO49NV20Xu+0/y613woTUA==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4UGmyBdKWEN1nkqspJlji/nV7XIVm6KGlOC2So0mtM/gKvaNgLz+tUkcbY+6Zpr7dr6ohX1S5yl0RLID5otRHw==", + "dependencies": { + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "TVfys1gnUIhmXuYfFzyez0fOkDyELe9UwlxYeVlq6FmqmWmt1ouF0OQJ+6ozkHbkaop7uBUaXw7Qb+/o0m+nMg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "koSfjkdQZfgQr3SyiSIBboDn+GiR0vZ3x9Uek9FJbXK0w5AiATV8KrnMEP8B8OAlO+Y3zQf0CPCNzwH+VIYDKg==", + "dependencies": { + "System.Composition.AttributedModel": "10.0.1", + "System.Composition.Hosting": "10.0.1", + "System.Composition.Runtime": "10.0.1" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "Gny8p2mX0jc5rjh+PA4Gx5GG66sj2C+e+ro7+j/3IsKT/bmQ84tGRV+XKaG+5/CTCdwkSSKDWEQ1rJd0J5jE0Q==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "yErfw/3pZkJE/VKza/Cm5idTpIKOy/vsmVi59Ta5SruPVtubzxb8CtnE8tyUpzs5pr0Y28GUFfSVzAhCLN3F/Q==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "9gdyuARhUR7H+p5CjyUB/zPk7/Xut3wUSP8NJQB6iZr8L3XUXTMdoLeVAg9N4rqF8oIpE7MpdqHdDHQ7XgJe0g==" + }, + "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.sdk.annotations": { + "type": "Project" + }, + "cheatengine.sdk.engine": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Annotations": "[1.0.0, )", + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + }, + "cheatengine.sdk.sourcegenerators.luabindings": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.SourceGenerators.Shared": "[1.0.0, )" + } + }, + "cheatengine.sdk.sourcegenerators.shared": { + "type": "Project" + }, + "cheatengine.sdk.tests.shared": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK.Lua.Interop": "[1.0.0, )" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.csproj b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.csproj index fcd78339..5e9d800c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.csproj +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.csproj @@ -11,7 +11,7 @@ - string.Equals(diagnostic.Id, "CESDK4001", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, - StringComparison.Ordinal)); + && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, + StringComparison.Ordinal)); Assert.Contains(run.GeneratorDiagnostics, static diagnostic => string.Equals(diagnostic.Id, "CESDK4002", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, ValidPath, - StringComparison.Ordinal)); + && string.Equals(diagnostic.Location.GetLineSpan().Path, ValidPath, + StringComparison.Ordinal)); Assert.Contains(run.GeneratorDiagnostics, static diagnostic => string.Equals(diagnostic.Id, "CESDK4002", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, - StringComparison.Ordinal)); + && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, + StringComparison.Ordinal)); } [Fact] public void Malformed_json_reports_the_additional_file_and_emits_nothing() { - const string Path = "eng/lua-bridge/protected-operations.json"; + const string Path = "libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json"; GeneratorRun run = RoslynFixture.Run(Path, "{ \"schemaVersion\":"); Assert.Empty(run.GeneratedSources); @@ -103,7 +103,7 @@ public void Malformed_json_reports_the_additional_file_and_emits_nothing() [Fact] public void A_duplicate_json_property_reports_the_second_property_and_emits_nothing() { - const string Path = "eng/lua-bridge/protected-operations.json"; + const string Path = "libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json"; const string Text = "{\n \"schemaVersion\": 1,\n \"schemaVersion\": 1\n}"; GeneratorRun run = RoslynFixture.Run(Path, Text); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs index a8311263..0ced0481 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs @@ -12,7 +12,7 @@ public sealed class CatalogEmissionTests public void Catalog_operations_emit_a_numeric_sorted_enum_and_required_bitmap() { GeneratorRun run = - RoslynFixture.Run("eng/lua-bridge/protected-operations.json", CatalogSources.ReverseOpcodeOrder); + RoslynFixture.Run("libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json", CatalogSources.ReverseOpcodeOrder); run.AssertCompilesClean(); string generated = run.SingleGeneratedText; @@ -20,7 +20,7 @@ public void Catalog_operations_emit_a_numeric_sorted_enum_and_required_bitmap() Assert.Contains("PushBytes = 0", generated, StringComparison.Ordinal); Assert.Contains("PushHostObject = 10", generated, StringComparison.Ordinal); Assert.True(generated.IndexOf("PushBytes = 0", StringComparison.Ordinal) - < generated.IndexOf("PushHostObject = 10", StringComparison.Ordinal)); + < generated.IndexOf("PushHostObject = 10", StringComparison.Ordinal)); Assert.Contains("internal const int Count = 2;", generated, StringComparison.Ordinal); Assert.Contains("internal const ulong RequiredBitmap = 0x0000000000000401UL;", generated, StringComparison.Ordinal); @@ -30,7 +30,7 @@ public void Catalog_operations_emit_a_numeric_sorted_enum_and_required_bitmap() [Fact] public void Production_catalog_emits_all_C11_operations_and_the_exact_required_bitmap() { - GeneratorRun run = RoslynFixture.Run("eng/lua-bridge/protected-operations.json", ProductionCatalog.Read()); + GeneratorRun run = RoslynFixture.Run("libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json", ProductionCatalog.Read()); run.AssertCompilesClean(); string generated = run.SingleGeneratedText; @@ -62,7 +62,7 @@ public void Production_catalog_emits_all_C11_operations_and_the_exact_required_b [Fact] public void Catalog_an_identical_rerun_is_deterministic() { - InMemoryAdditionalText additionalText = new("eng/lua-bridge/protected-operations.json", + InMemoryAdditionalText additionalText = new("libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json", CatalogSources.ReverseOpcodeOrder); CSharpCompilation compilation = RoslynFixture.CreateCompilation(); GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(additionalText), compilation); @@ -76,7 +76,7 @@ public void Catalog_an_identical_rerun_is_deterministic() [Fact] public void Replacing_a_valid_catalogue_updates_members_and_the_required_bitmap_without_stale_source() { - InMemoryAdditionalText original = new("eng/lua-bridge/protected-operations.json", + InMemoryAdditionalText original = new("libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json", CatalogSources.ReverseOpcodeOrder); CSharpCompilation compilation = RoslynFixture.CreateCompilation(); GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original), compilation); @@ -101,7 +101,7 @@ public void Replacing_a_valid_catalogue_updates_members_and_the_required_bitmap_ [Fact] public void Replacing_a_valid_catalogue_with_an_invalid_one_removes_generated_source() { - InMemoryAdditionalText original = new("eng/lua-bridge/protected-operations.json", + InMemoryAdditionalText original = new("libs/CheatEngine.SDK.Lua.Interop/Protected/protected-operations.json", CatalogSources.ReverseOpcodeOrder); CSharpCompilation compilation = RoslynFixture.CreateCompilation(); GeneratorRun first = GeneratorRun.Execute(RoslynFixture.CreateDriver(original), compilation); @@ -122,7 +122,7 @@ public void Replacing_a_valid_catalogue_with_an_invalid_one_removes_generated_so [Fact] public void An_unrelated_additional_file_is_silent() { - GeneratorRun run = RoslynFixture.Run("eng/lua-bridge/notes.json", CatalogSources.ReverseOpcodeOrder); + GeneratorRun run = RoslynFixture.Run("libs/CheatEngine.SDK.Lua.Interop/Protected/notes.json", CatalogSources.ReverseOpcodeOrder); Assert.Empty(run.GeneratedSources); Assert.Empty(run.GeneratorDiagnostics); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/packages.lock.json b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/packages.lock.json new file mode 100644 index 00000000..6d46d526 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/packages.lock.json @@ -0,0 +1,238 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]" + } + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + } + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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.sdk.sourcegenerators.luabridgecontract": { + "type": "Project" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs index eb90ba44..5f3393ed 100644 --- a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs +++ b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs @@ -65,7 +65,7 @@ private static bool TryGetFullPath(string candidate, out string fullPath) return true; } catch (Exception exception) when (exception is ArgumentException or NotSupportedException - or PathTooLongException or SecurityException) + or PathTooLongException or SecurityException) { fullPath = string.Empty; return false; diff --git a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/PartialLuaModule.cs b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/PartialLuaModule.cs new file mode 100644 index 00000000..1541a551 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/PartialLuaModule.cs @@ -0,0 +1,159 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; + +namespace CheatEngine.SDK.Tests.Shared.NativeLua; + +/// +/// A copy of the Lua fixture in which exactly one export is missing, loaded from a private temporary folder: the +/// "module that lacks exports the SDK binds" of qualification scenario Q11. The name is +/// overwritten in the PE export name table by , which has the same length and sorts to +/// the same position, so the table stays sorted and GetProcAddress's binary search still finds every other +/// export. +/// +/// +/// Only the bytes of the copy are patched; the committed fixture is never modified. The copy is loaded in the test +/// process only, under another file name, and freed and deleted by . Production code never +/// loads a second Lua module. This file stays free of xUnit types, like the rest of the fixture. +/// +internal sealed class PartialLuaModule : IDisposable +{ + /// The export the copy lacks. + public const string RemovedExport = "lua_rotate"; + + /// The name that replaces it: same length, same sorted position between its neighbours. + public const string ReplacementName = "lua_rotatf"; + + private const int PeHeaderPointerOffset = 0x3C; + private const int OptionalHeaderOffsetFromSignature = 24; + private const int ExportDirectoryOffsetInOptionalHeader = 112; + private const int SectionHeaderSize = 40; + private const ushort Pe32PlusMagic = 0x20B; + + private readonly string _directory; + + private PartialLuaModule(string directory, nint handle) + { + _directory = directory; + Handle = handle; + } + + /// The handle of the loaded copy; zero after . + public nint Handle + { + get; + private set; + } + + /// Copies , removes from the copy and loads it. + /// The image is not PE32+ or does not export . + public static PartialLuaModule Load(string fixturePath) + { + byte[] image = File.ReadAllBytes(fixturePath); + RenameExport(image, RemovedExport, ReplacementName); + string directory = Path.Combine(Path.GetTempPath(), "CheatEngine.SDK.Tests", + "partial-lua-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, "lua53-partial.dll"); + File.WriteAllBytes(path, image); + return new PartialLuaModule(directory, NativeLibrary.Load(path)); + } + + /// Frees the copy and deletes its folder. + public void Dispose() + { + if (Handle != 0) + { + NativeLibrary.Free(Handle); + Handle = 0; + } + + try + { + Directory.Delete(_directory, true); + } + catch (IOException) + { + // Another handle still maps the file; the folder name is unique, so a leftover never collides. + } + catch (UnauthorizedAccessException) + { + // Same as above. + } + } + + /// + /// Overwrites the export name of a PE32+ in place with + /// , found through the export directory rather than by searching the bytes. + /// + /// + /// The image is not PE32+, has no such export, or the replacement would leave the name table unsorted. + /// + internal static void RenameExport(byte[] image, string existing, string replacement) + { + if (replacement.Length != existing.Length) + { + throw new ArgumentException("The replacement must have the length of the original name.", nameof(replacement)); + } + + int signature = BinaryPrimitives.ReadInt32LittleEndian(image.AsSpan(PeHeaderPointerOffset)); + int optionalHeader = signature + OptionalHeaderOffsetFromSignature; + if (BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(signature)) != 0x4550 || + BinaryPrimitives.ReadUInt16LittleEndian(image.AsSpan(optionalHeader)) != Pe32PlusMagic) + { + throw new InvalidOperationException("The Lua fixture is not a PE32+ image."); + } + + int sectionCount = BinaryPrimitives.ReadUInt16LittleEndian(image.AsSpan(signature + 6)); + int sections = optionalHeader + BinaryPrimitives.ReadUInt16LittleEndian(image.AsSpan(signature + 20)); + uint exportRva = BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(optionalHeader + ExportDirectoryOffsetInOptionalHeader)); + int exportDirectory = ToFileOffset(image, sections, sectionCount, exportRva); + int nameCount = BinaryPrimitives.ReadInt32LittleEndian(image.AsSpan(exportDirectory + 24)); + int nameTable = ToFileOffset(image, sections, sectionCount, + BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(exportDirectory + 32))); + + List<(string Name, int Offset)> names = []; + for (int index = 0; index < nameCount; index++) + { + int offset = ToFileOffset(image, sections, sectionCount, + BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(nameTable + (4 * index)))); + int length = image.AsSpan(offset).IndexOf((byte) 0); + names.Add((Encoding.ASCII.GetString(image, offset, length), offset)); + } + + int target = names.FindIndex(entry => string.Equals(entry.Name, existing, StringComparison.Ordinal)); + if (target < 0) + { + throw new InvalidOperationException($"The Lua fixture does not export '{existing}'."); + } + + bool staysSorted = (target == 0 || string.CompareOrdinal(names[target - 1].Name, replacement) < 0) && + (target == names.Count - 1 || string.CompareOrdinal(replacement, names[target + 1].Name) < 0); + if (!staysSorted) + { + throw new InvalidOperationException($"'{replacement}' would leave the export name table unsorted."); + } + + Encoding.ASCII.GetBytes(replacement).CopyTo(image, names[target].Offset); + } + + private static int ToFileOffset(byte[] image, int sections, int sectionCount, uint rva) + { + for (int index = 0; index < sectionCount; index++) + { + ReadOnlySpan header = image.AsSpan(sections + (index * SectionHeaderSize), SectionHeaderSize); + uint virtualSize = BinaryPrimitives.ReadUInt32LittleEndian(header[8..]); + uint virtualAddress = BinaryPrimitives.ReadUInt32LittleEndian(header[12..]); + uint rawSize = BinaryPrimitives.ReadUInt32LittleEndian(header[16..]); + uint rawPointer = BinaryPrimitives.ReadUInt32LittleEndian(header[20..]); + if (rva >= virtualAddress && rva < virtualAddress + Math.Max(virtualSize, rawSize)) + { + return checked((int) (rva - virtualAddress + rawPointer)); + } + } + + throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture, + $"RVA 0x{rva:X} is in no section of the Lua fixture.")); + } +} diff --git a/tests/CheatEngine.SDK.Tests.Shared/README.md b/tests/CheatEngine.SDK.Tests.Shared/README.md index dfe635f1..d4a369aa 100644 --- a/tests/CheatEngine.SDK.Tests.Shared/README.md +++ b/tests/CheatEngine.SDK.Tests.Shared/README.md @@ -20,7 +20,9 @@ reference graph and an `InternalsVisibleTo` list in the project file. `ThrowIfUnavailable()`. `NativeLuaProbe.Run` performs one lookup and never throws. `NativeLuaState` owns one independent `lua_State` (`L`, `Pointer`) and closes it on `Dispose()`, after which `L` and `Pointer` throw `ObjectDisposedException`. It has no finalizer and is not thread-safe: dispose every state on the thread that created -it. The `openLibraries: false` argument skips `luaL_openlibs` and yields a bare state. +it. The `openLibraries: false` argument skips `luaL_openlibs` and yields a bare state. `PartialLuaModule` loads, from a +private temporary folder, a copy of the fixture whose `lua_rotate` export name is overwritten in the PE export table, +for the tests of a module that lacks one export (Q11); it frees and deletes the copy on `Dispose()`. The first use of `NativeLuaLibrary` runs the lookup once. It reads `CHEATENGINE_SDK_LUA53_PATH` when the value is not blank and trims it. No fallback follows, so an override is never replaced silently. Otherwise it uses `BundledPath`, the diff --git a/tests/CheatEngine.SDK.Tests.Shared/packages.lock.json b/tests/CheatEngine.SDK.Tests.Shared/packages.lock.json new file mode 100644 index 00000000..dabc335c --- /dev/null +++ b/tests/CheatEngine.SDK.Tests.Shared/packages.lock.json @@ -0,0 +1,22 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "cheatengine.sdk.lua.interop": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs b/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs index d6dac8d3..39c8ddd6 100644 --- a/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs +++ b/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs @@ -80,7 +80,7 @@ public void Shipping_libraries_keep_the_declared_lower_layer_runtime_graph() discoveredLibraryProjects.Add(projectRelativePath); if (!ExpectedLibraryRuntimeDependencies.TryGetValue(projectRelativePath, - out string[]? expectedDependencies)) + out string[]? expectedDependencies)) { violations.Add($"{projectRelativePath}: is not declared in the shipping library graph."); continue; @@ -236,18 +236,20 @@ public void SDK_build_metadata_has_no_Client_or_Mcp_dependency() } foreach (string projectPath in EnumerateRepositoryFiles("*.csproj")) - foreach (ProjectReferenceInfo reference in ReadProjectReferences(projectPath)) { - if (reference.Include.Contains("$(", StringComparison.Ordinal)) + foreach (ProjectReferenceInfo reference in ReadProjectReferences(projectPath)) { - violations.Add( - $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' is dynamic and cannot be checked for a higher-layer dependency."); - } + if (reference.Include.Contains("$(", StringComparison.Ordinal)) + { + violations.Add( + $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' is dynamic and cannot be checked for a higher-layer dependency."); + } - if (reference.TargetRelativePath.StartsWith("../", StringComparison.Ordinal)) - { - violations.Add( - $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' escapes the SDK repository."); + if (reference.TargetRelativePath.StartsWith("../", StringComparison.Ordinal)) + { + violations.Add( + $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' escapes the SDK repository."); + } } } @@ -264,7 +266,7 @@ private static void AddForbiddenDependencyViolations(List violations, st } if (value.Contains("CheatEngine.Client", StringComparison.OrdinalIgnoreCase) || - value.Contains("CheatEngine.Mcp", StringComparison.OrdinalIgnoreCase)) + value.Contains("CheatEngine.Mcp", StringComparison.OrdinalIgnoreCase)) { violations.Add( $"{GetRepositoryRelativePath(metadataPath)}: {node.LocalName} {attributeName}='{value}' references a higher layer."); @@ -327,7 +329,7 @@ private static IEnumerable EnumerateBuildMetadataFiles() private static IEnumerable EnumerateRepositoryFiles(string searchPattern) { foreach (string path in - Directory.EnumerateFiles(RepositoryLayout.Root, searchPattern, SearchOption.AllDirectories)) + Directory.EnumerateFiles(RepositoryLayout.Root, searchPattern, SearchOption.AllDirectories)) { string relativePath = GetRepositoryRelativePath(path); if (!IsGeneratedPath(relativePath)) @@ -351,20 +353,20 @@ private static string GetRepositoryRelativePath(string fullPath) private static bool IsGeneratedPath(string relativePath) { return relativePath.StartsWith("artifacts/", StringComparison.Ordinal) || - relativePath.Contains("/bin/", StringComparison.Ordinal) || - relativePath.Contains("/obj/", StringComparison.Ordinal); + relativePath.Contains("/bin/", StringComparison.Ordinal) || + relativePath.Contains("/obj/", StringComparison.Ordinal); } private static bool IsRoslynComponent(string projectRelativePath) { return projectRelativePath.StartsWith("analyzers/", StringComparison.Ordinal) || - projectRelativePath.StartsWith("source-generators/", StringComparison.Ordinal); + projectRelativePath.StartsWith("source-generators/", StringComparison.Ordinal); } private static bool IsShippingProject(string projectRelativePath) { return projectRelativePath.StartsWith("libs/", StringComparison.Ordinal) || - string.Equals(projectRelativePath, UmbrellaProject, StringComparison.Ordinal); + string.Equals(projectRelativePath, UmbrellaProject, StringComparison.Ordinal); } private static XmlDocument LoadProjectDocument(string projectPath) diff --git a/tests/CheatEngine.SDK.Tests/Build/NativeAotProfileTargetTests.cs b/tests/CheatEngine.SDK.Tests/Build/NativeAotProfileTargetTests.cs new file mode 100644 index 00000000..4cecb7ff --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Build/NativeAotProfileTargetTests.cs @@ -0,0 +1,126 @@ +using System.Globalization; + +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Build; + +/// +/// The packaged build target CheatEngineSdkWarnNativeAotPluginProfile (src/CheatEngine.SDK/build/CheatEngine.SDK.targets) +/// warns with CESDK9102 when a direct consumer library sets PublishAot=true: a NativeAOT plugin +/// DLL cannot be unloaded by Cheat Engine's FreeLibrary (audit F02, EXT-01), so the only supported plugin +/// profile is the managed hostfxr route. An executable publishing with NativeAOT is not warned. +/// +/// +/// Not a packaging test (no Category=Packaging trait): each case writes a throwaway SDK-style project under +/// , outside any repository, that imports the repository's +/// build/CheatEngine.SDK.props and .targets exactly like NuGet imports them for a direct package +/// reference, pins the repository's global.json, and runs only that target with dotnet msbuild (no +/// restore: the target depends on nothing). The packaged consumers of PackagedUmbrellaFixture (an AOT +/// executable among them) prove the same file stays silent for executables in the real package. +/// +public sealed class NativeAotProfileTargetTests +{ + private const string TargetName = "CheatEngineSdkWarnNativeAotPluginProfile"; + private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3); + + [Theory] + [InlineData("")] + [InlineData("Shared")] + [InlineData("Static")] + [Trait("Qualification", "Q41")] + public async Task PublishAot_library_consumer_gets_CESDK9102(string nativeLib) + { + ProcessResult result = await RunTargetAsync("Library", publishAot: "true", nativeLib, noWarn: false); + + Assert.True(result.ExitCode == 0, result.CombinedOutput); + Assert.Contains("warning CESDK9102", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains($"NativeLib={nativeLib})", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains("FreeLibrary", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public void CESDK9102_warning_links_its_rule_page_and_runs_before_the_build() + { + XDocument targets = XDocument.Load(RepositoryLayout.PathOf("src/CheatEngine.SDK/build/CheatEngine.SDK.targets")); + + XElement target = Assert.Single(targets.Root!.Elements("Target"), + static element => string.Equals((string?) element.Attribute("Name"), TargetName, StringComparison.Ordinal)); + Assert.Equal("BeforeBuild", (string?) target.Attribute("BeforeTargets")); + XElement warning = Assert.Single(target.Elements("Warning")); + Assert.Equal("CESDK9102", (string?) warning.Attribute("Code")); + Assert.Equal("https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/CESDK9102.md", + (string?) warning.Attribute("HelpLink")); + Assert.True(File.Exists(RepositoryLayout.PathOf("analyzers/docs/CESDK9102.md"))); + } + + [Theory] + [InlineData("Exe")] + [InlineData("WinExe")] + [Trait("Qualification", "Q41")] + public async Task PublishAot_executable_consumer_gets_no_CESDK9102(string outputType) + { + ProcessResult result = await RunTargetAsync(outputType, publishAot: "true", nativeLib: "", noWarn: false); + + Assert.True(result.ExitCode == 0, result.CombinedOutput); + Assert.DoesNotContain("CESDK9102", result.CombinedOutput, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData("false")] + [Trait("Qualification", "Q41")] + public async Task Consumer_without_PublishAot_gets_no_CESDK9102(string publishAot) + { + ProcessResult result = await RunTargetAsync("Library", publishAot, nativeLib: "Shared", noWarn: false); + + Assert.True(result.ExitCode == 0, result.CombinedOutput); + Assert.DoesNotContain("CESDK9102", result.CombinedOutput, StringComparison.Ordinal); + } + + [Fact] + [Trait("Qualification", "Q41")] + public async Task NoWarn_demotes_CESDK9102_to_a_message() + { + ProcessResult result = await RunTargetAsync("Library", publishAot: "true", nativeLib: "Shared", noWarn: true); + + // Detailed verbosity: MSBuild logs a warning demoted by NoWarn as a low-importance message. + Assert.True(result.ExitCode == 0, result.CombinedOutput); + Assert.DoesNotContain("warning CESDK9102", result.CombinedOutput, StringComparison.Ordinal); + Assert.Contains("CESDK9102", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains("FreeLibrary", result.StandardOutput, StringComparison.Ordinal); + } + + private static async Task RunTargetAsync(string outputType, string publishAot, string nativeLib, + bool noWarn) + { + string directory = Path.Combine(Path.GetTempPath(), "cesdk9102-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string buildDirectory = RepositoryLayout.PathOf("src/CheatEngine.SDK/build"); + string project = Path.Combine(directory, "NativeAotProfileConsumer.csproj"); + File.Copy(RepositoryLayout.PathOf("global.json"), Path.Combine(directory, "global.json")); + await File.WriteAllTextAsync(project, $""" + + + + net10.0 + {outputType} + {publishAot} + {nativeLib} + $(NoWarn);CESDK9102 + + + + """, TestContext.Current.CancellationToken); + + string arguments = string.Create(CultureInfo.InvariantCulture, + $"msbuild \"{project}\" -t:{TargetName} -nologo -v:{(noWarn ? "d" : "n")} -p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false -p:ImportDirectoryPackagesProps=false"); + return await ProcessRunner.RunAsync("dotnet", arguments, directory, Timeout); + } + finally + { + Directory.Delete(directory, true); + } + } +} diff --git a/tests/CheatEngine.SDK.Tests/CheatEngine.SDK.Tests.csproj b/tests/CheatEngine.SDK.Tests/CheatEngine.SDK.Tests.csproj index c39d9f9e..f75f2c1e 100644 --- a/tests/CheatEngine.SDK.Tests/CheatEngine.SDK.Tests.csproj +++ b/tests/CheatEngine.SDK.Tests/CheatEngine.SDK.Tests.csproj @@ -2,8 +2,9 @@ diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/CommittedFile.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/CommittedFile.cs new file mode 100644 index 00000000..ab9744bb --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/CommittedFile.cs @@ -0,0 +1,76 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; + +namespace CheatEngine.SDK.Tests.Infrastructure; + +/// +/// Reads a file as committed in HEAD, not as it sits in the working tree. CI overwrites the checked-in native +/// bridge with the one it just built before building, so only the committed blob is the audited asset. The bytes are +/// copied from the binary standard output stream: decodes text and would corrupt a DLL. +/// +internal static class CommittedFile +{ + private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(1); + + /// The exact bytes of HEAD:<repoRelativePath>. + /// A repository-relative path with forward slashes. + /// git is missing, the tree is not a git checkout, or the path is not committed. + public static async Task ReadBytesAsync(string repoRelativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repoRelativePath); + ProcessStartInfo startInfo = new("git") + { + WorkingDirectory = RepositoryLayout.Root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("-C"); + startInfo.ArgumentList.Add(RepositoryLayout.Root); + startInfo.ArgumentList.Add("cat-file"); + startInfo.ArgumentList.Add("blob"); + startInfo.ArgumentList.Add($"HEAD:{repoRelativePath}"); + + using Process process = new() + { + StartInfo = startInfo + }; + try + { + process.Start(); + } + catch (Win32Exception exception) + { + throw new InvalidOperationException( + $"Reading the committed '{repoRelativePath}' requires a git checkout and git on PATH.", exception); + } + + using CancellationTokenSource cancellation = new(Timeout); + using MemoryStream blob = new(); + Task copy = process.StandardOutput.BaseStream.CopyToAsync(blob, cancellation.Token); + Task errorRead = process.StandardError.ReadToEndAsync(cancellation.Token); + string error; + try + { + await copy.ConfigureAwait(false); + error = await errorRead.ConfigureAwait(false); + await process.WaitForExitAsync(cancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (cancellation.IsCancellationRequested) + { + process.Kill(true); + throw new TimeoutException($"'git cat-file blob HEAD:{repoRelativePath}' did not exit within {Timeout}.", exception); + } + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Reading the committed '{repoRelativePath}' requires a git checkout that contains it: 'git cat-file' " + + $"exited with {process.ExitCode.ToString(CultureInfo.InvariantCulture)}: {error.Trim()}"); + } + + return blob.ToArray(); + } +} diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs index ac57aa94..2997b0f7 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs @@ -27,7 +27,7 @@ public static (bool TypeExists, bool MethodExists) Probe(string assemblyPath) { TypeDefinition type = reader.GetTypeDefinition(typeHandle); if (!string.Equals(reader.GetString(type.Namespace), TypeNamespace, StringComparison.Ordinal) - || !string.Equals(reader.GetString(type.Name), TypeName, StringComparison.Ordinal)) + || !string.Equals(reader.GetString(type.Name), TypeName, StringComparison.Ordinal)) { continue; } @@ -36,7 +36,7 @@ public static (bool TypeExists, bool MethodExists) Probe(string assemblyPath) { MethodDefinition method = reader.GetMethodDefinition(methodHandle); if (string.Equals(reader.GetString(method.Name), MethodName, StringComparison.Ordinal) && - method.GetParameters().Count == 2) + method.GetParameters().Count == 2) { return (TypeExists: true, MethodExists: true); } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/NupkgInspector.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/NupkgInspector.cs index 682c14a4..e156d958 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/NupkgInspector.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/NupkgInspector.cs @@ -19,6 +19,25 @@ public static (IReadOnlyList Entries, XDocument Nuspec) Read(string nupk return (entries, nuspec); } + /// The exact bytes of one entry, matched by its full name (ordinal, forward slashes as the zip stores them). + /// The package has no such entry. + public static byte[] ReadEntryBytes(string nupkgPath, string entryPath) + { + using ZipArchive archive = ZipFile.OpenRead(nupkgPath); + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (string.Equals(entry.FullName, entryPath, StringComparison.Ordinal)) + { + using Stream stream = entry.Open(); + using MemoryStream copy = new(); + stream.CopyTo(copy); + return copy.ToArray(); + } + } + + throw new InvalidOperationException($"'{Path.GetFileName(nupkgPath)}' has no '{entryPath}' entry."); + } + /// Every id attribute of every <dependency> element, across every target-framework group. public static IReadOnlyList GetDependencyIds(XDocument nuspec) { diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs index 012779fc..d23b845e 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs @@ -1,20 +1,29 @@ using System.Globalization; +using System.Security.Cryptography; namespace CheatEngine.SDK.Tests.Infrastructure; /// -/// Packs src/CheatEngine.SDK/CheatEngine.SDK.csproj once, to a throwaway local feed, then restores + builds -/// direct -/// and indirect plugin consumers against it: a default one that takes every package default, one that sets +/// Puts one CheatEngine.SDK package into a throwaway local feed, then restores + builds direct and indirect +/// plugin consumers against it: a default one that takes every package default, one that sets /// AllowUnsafeBlocks=false itself, one that sets CheatEngineSdkGenerateEntryPoint=false, and one that /// reaches the umbrella only through a second packed package. It also builds direct consumers for every supported /// and explicitly unsupported PlatformTarget value, runs a package-only executable against the bundled /// offline Lua fixture, and publishes then runs a separate package-only trim and Native AOT executable. Every fact -/// Packaging/*.cs asserts on is read here, once, through , -/// because the pipeline (real package, restore, build, clean/rebuild and publish operations) -/// is too expensive to repeat per test. +/// the classes of collection assert on is read here, once, because the pipeline +/// (real package, restore, build, clean/rebuild and publish operations) is too expensive to repeat per test. /// /// +/// +/// The package has one of two origins (), decided by +/// before anything is created. When +/// names a file, the fixture copies exactly that file into +/// its feed and never packs: in the CI Release leg it is the .nupkg that is uploaded as +/// nuget-package, attested and published, so these tests are evidence about the shipped file. Otherwise, +/// outside CI, it packs src/CheatEngine.SDK/CheatEngine.SDK.csproj itself: the facts then describe the +/// working tree, not a shipped file. Under CI=true a missing variable is an error, never a silent self-pack. +/// +/// /// Every consumer restore is pointed (dotnet restore --packages) at , a /// directory scoped to this fixture's own _tempRoot, never the machine-wide global-packages folder /// (typically %USERPROFILE%\.nuget\packages). This matters because every pack in one session gets the SAME @@ -27,6 +36,7 @@ namespace CheatEngine.SDK.Tests.Infrastructure; /// (EntryPointTests, BuildPropertyDefaultsTests); only the nuspec/file-list tests, which read the /// .nupkg directly via , would be unaffected. Starting every fixture run from an /// empty, run-scoped packages directory removes the sharing that makes that possible. +/// /// public sealed class PackagedUmbrellaFixture : IAsyncLifetime { @@ -87,6 +97,61 @@ public IReadOnlyList NuspecDependencyIds private set; } = []; + /// Full path of the packed .nupkg the fixture read: the copy inside its local feed. + public string PackagePath + { + get; + private set; + } = ""; + + /// Whether the fixture tests a supplied package or one it packed itself. + public UmbrellaPackageOrigin PackageOrigin + { + get; + private set; + } + + /// + /// The absolute path named by when + /// is ; empty for a self-pack. + /// + public string SuppliedPackagePath + { + get; + private set; + } = ""; + + /// SHA-256 of the feed copy (), 64 lowercase hex digits. + public string PackageSha256 + { + get; + private set; + } = ""; + + /// + /// SHA-512 of the feed copy as standard base64 with padding: for an unsigned package this is the NuGet + /// contentHash that restore records in .deps.json and in consumer lock files. + /// + public string PackageSha512Base64 + { + get; + private set; + } = ""; + + /// How many times the fixture ran dotnet pack on the SDK; always zero for a supplied package. + public int SelfPackAttempts + { + get; + private set; + } + + /// The packed .nuspec. + public XDocument Nuspec + { + get; + private set; + } = new(); + /// Whether CESDK.CESDK exists in the default consumer's built assembly. public bool DefaultEntryPointTypeExists { @@ -143,6 +208,13 @@ public string DefaultPublishedNativeBridgePath private set; } = ""; + /// The direct consumer's dotnet publish output directory. + public string DefaultPublishDirectory + { + get; + private set; + } = ""; + /// /// AllowUnsafeBlocks, EnableDynamicLoading, CheatEngineSdkGenerateEntryPoint for the default /// consumer. @@ -365,6 +437,12 @@ public string IndirectPublishedNativeBridgePath /// public async ValueTask InitializeAsync() { + // Decided before anything is created, so a CI run without its package fails in seconds, not after a pack. + UmbrellaPackageSelection selection = UmbrellaPackageSource.Select( + Environment.GetEnvironmentVariable(UmbrellaPackage.PrebuiltPackageVariable), + Environment.GetEnvironmentVariable("CI")); + PackageOrigin = selection.Origin; + _tempRoot = Directory.CreateTempSubdirectory("cheatengine-sdk-umbrella-tests-"); string feedDirectory = Path.Combine(_tempRoot.FullName, "feed"); Directory.CreateDirectory(feedDirectory); @@ -372,7 +450,17 @@ public async ValueTask InitializeAsync() Directory.CreateDirectory(packagesDirectory); PackagesDirectory = packagesDirectory; - await PackUmbrellaAsync(feedDirectory).ConfigureAwait(false); + if (selection.Origin == UmbrellaPackageOrigin.Prebuilt) + { + string suppliedPath = selection.PrebuiltPath!; + SuppliedPackagePath = suppliedPath; + File.Copy(suppliedPath, Path.Combine(feedDirectory, Path.GetFileName(suppliedPath))); + } + else + { + await PackUmbrellaAsync(feedDirectory).ConfigureAwait(false); + } + ReadPackedNupkg(feedDirectory); await InitializeDefaultConsumerAsync(_tempRoot.FullName, feedDirectory, packagesDirectory) @@ -420,19 +508,20 @@ public ValueTask DisposeAsync() } /// - /// Packing builds every project the umbrella embeds (the six libs, four active shipping components and their - /// shared loader dependency), through the + /// Local runs only (). Packing builds every project the umbrella + /// embeds (the six libs, four active shipping components and their shared loader dependency), through the /// repository's own shared artifacts/ directory - the one resource another build running at the same /// time might be touching, so a file-lock error is retried rather than treated as a real failure. A plain, /// unrelated compile error is not retried. /// - private static async Task PackUmbrellaAsync(string feedDirectory) + private async Task PackUmbrellaAsync(string feedDirectory) { const int maxAttempts = 5; string cheatEngineSdkProjectPath = RepositoryLayout.PathOf(UmbrellaPackage.ProjectPath); ProcessResult result = default; for (int attempt = 1; attempt <= maxAttempts; attempt++) { + SelfPackAttempts++; result = await ProcessRunner .RunAsync("dotnet", $"pack \"{cheatEngineSdkProjectPath}\" -c Release -o \"{feedDirectory}\" --nologo", RepositoryLayout.Root, PackTimeout) @@ -458,10 +547,10 @@ await Task.Delay(TimeSpan.FromSeconds(20 * attempt), TestContext.Current.Cancell private static bool LooksLikeFileLockContention(string output) { return output.Contains("being used by another process", StringComparison.OrdinalIgnoreCase) - || output.Contains("cannot access the file", StringComparison.OrdinalIgnoreCase) - || output.Contains("MSB3021", StringComparison.Ordinal) - || output.Contains("MSB3027", StringComparison.Ordinal) - || output.Contains("MSB3061", StringComparison.Ordinal); + || output.Contains("cannot access the file", StringComparison.OrdinalIgnoreCase) + || output.Contains("MSB3021", StringComparison.Ordinal) + || output.Contains("MSB3027", StringComparison.Ordinal) + || output.Contains("MSB3061", StringComparison.Ordinal); } private async Task InitializeDefaultConsumerAsync(string tempRoot, string feedDirectory, string packagesDirectory) @@ -490,6 +579,7 @@ private async Task InitializeDefaultConsumerAsync(string tempRoot, string feedDi string publishDirectory = Path.Combine(tempRoot, "published-default"); await PublishAsync(consumer, publishDirectory).ConfigureAwait(false); + DefaultPublishDirectory = publishDirectory; DefaultPublishedNativeBridgePath = Path.Combine(publishDirectory, "cheatengine-sdk-lua-bridge.dll"); } @@ -538,7 +628,8 @@ private async Task InitializeLuaFunctionConsumersAsync(string tempRoot, string f feedDirectory, new ThrowawayConsumer.CreateOptions { - ExtraProperties = " true\n", IncludeLuaFunction = true + ExtraProperties = " true\n", + IncludeLuaFunction = true }); ProcessResult optInRestore = await optInConsumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); @@ -729,8 +820,14 @@ private void ReadPackedNupkg(string feedDirectory) PackageVersion = fileName[(UmbrellaPackage.Id.Length + 1)..^".nupkg".Length]; (IReadOnlyList entries, XDocument nuspec) = NupkgInspector.Read(nupkgPaths[0]); + PackagePath = nupkgPaths[0]; + Nuspec = nuspec; PackageEntries = entries; NuspecDependencyIds = NupkgInspector.GetDependencyIds(nuspec); + + byte[] package = File.ReadAllBytes(PackagePath); + PackageSha256 = Convert.ToHexStringLower(SHA256.HashData(package)); + PackageSha512Base64 = Convert.ToBase64String(SHA512.HashData(package)); } private static async Task RestoreAndBuildAsync(ThrowawayConsumer consumer, string packagesDirectory) diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs index 7b013832..7e82eff5 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs @@ -24,7 +24,10 @@ public static async Task RunAsync(string fileName, string argumen CreateNoWindow = true }; - using Process process = new() { StartInfo = startInfo }; + using Process process = new() + { + StartInfo = startInfo + }; StringBuilder standardOutput = new(); StringBuilder standardError = new(); process.OutputDataReceived += (_, e) => @@ -51,11 +54,12 @@ public static async Task RunAsync(string fileName, string argumen { await process.WaitForExitAsync(cancellation.Token).ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + catch (OperationCanceledException exception) when (cancellation.IsCancellationRequested) { TryKill(process); throw new TimeoutException( - $"'{fileName} {arguments}' in '{workingDirectory}' did not exit within {timeout}. Output so far:{Environment.NewLine}{standardOutput}{Environment.NewLine}{standardError}"); + $"'{fileName} {arguments}' in '{workingDirectory}' did not exit within {timeout}. Output so far:{Environment.NewLine}{standardOutput}{Environment.NewLine}{standardError}", + exception); } return new ProcessResult(process.ExitCode, standardOutput.ToString(), standardError.ToString()); diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs index fbb0c418..2ad3cdfc 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs @@ -20,8 +20,8 @@ public static string PathOf(string relativePath) private static string FindRoot() { for (DirectoryInfo? directory = new(AppContext.BaseDirectory); - directory is not null; - directory = directory.Parent) + directory is not null; + directory = directory.Parent) { if (File.Exists(Path.Combine(directory.FullName, SolutionFileName))) { diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs index 72c4e81f..7ebdc68e 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs @@ -267,6 +267,7 @@ private static long ExecuteForInteger(LuaState state, ReadOnlySpan source) using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Interop.Api; using CheatEngine.SDK.Lua.Interop.Types; + using CheatEngine.SDK.Lua.Marshalling; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -276,6 +277,15 @@ internal static partial class GeneratedAotBinding { [LuaGlobal("sdk022_aot_probe")] public static partial LuaOperationStatus TryProbe(LuaState state); + + // The optional and variadic shapes: their emitted code must publish with no IL2xxx/IL3xxx warning. + [LuaGlobal("sdk022_aot_optional")] + public static partial LuaOperationStatus TryOptional(LuaState state, int first, LuaOptional second, + out LuaOptional sum); + + [LuaGlobal("sdk022_aot_sequence")] + public static partial LuaOperationStatus TrySequence(LuaState state, int count, Span values, + out int valueCount); } // This is a standalone native-Lua probe. It uses the test fixture's Lua 5.3 DLL, @@ -342,6 +352,8 @@ private static void Run(string luaLibraryPath) 0, "=sdk022-aot-assertion"u8); if (!invocationCount.IsOk) throw new InvalidOperationException("The generated AOT binding invocation assertion failed with " + invocationCount + "."); + + RunOptionalAndVariadicShapes(state); } finally { @@ -355,6 +367,28 @@ private static void Run(string luaLibraryPath) } } + private static void RunOptionalAndVariadicShapes(LuaState state) + { + LuaStatus setup = state.TryExecute( + "function sdk022_aot_optional(first, second) if second == nil then return end return first + second end function sdk022_aot_sequence(n) local t = {} for i = 1, n do t[i] = i * 3 end return table.unpack(t, 1, n) end"u8, + 0, "=sdk022-aot-shapes"u8); + if (!setup.IsOk) + throw new InvalidOperationException("The Lua fixture shape setup failed with " + setup + "."); + + if (!GeneratedAotBinding.TryOptional(state, 40, LuaOptional.Of(2), out LuaOptional sum).IsSuccess || sum != LuaOptional.Of(42L)) + throw new InvalidOperationException("The generated optional binding returned " + sum + " instead of 42."); + + if (!GeneratedAotBinding.TryOptional(state, 1, default, out LuaOptional none).IsSuccess || !none.IsOmitted) + throw new InvalidOperationException("The generated optional binding did not report zero results as omitted."); + + Span values = stackalloc long[4]; + if (!GeneratedAotBinding.TrySequence(state, 3, values, out int count).IsSuccess || count != 3 || values[0] != 3 || values[2] != 9) + throw new InvalidOperationException("The generated variadic binding returned " + count + " values."); + + if (GeneratedAotBinding.TrySequence(state, 5, values, out int needed).Kind != LuaOperationStatusKind.ResultCapacityExceeded || needed != 5) + throw new InvalidOperationException("The generated variadic binding accepted more values than its capacity."); + } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] private static void* ProvideState() { diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayPackageCarrier.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayPackageCarrier.cs index efefb81a..45ae8712 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayPackageCarrier.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayPackageCarrier.cs @@ -57,6 +57,8 @@ public sealed class PackageBoundary { } """); + // Same mapping as the consumers' NuGet.Config: without it, once a version is on nuget.org, a re-run could restore + // the repository-signed nuget.org copy of CheatEngine.SDK instead of the file under test. File.WriteAllText(Path.Combine(directory, "NuGet.Config"), $""" @@ -65,6 +67,15 @@ public sealed class PackageBoundary + + + + + + + + + """); return new ThrowawayPackageCarrier(directory, projectPath); diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackage.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackage.cs index 26a59ede..0ba57cd6 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackage.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackage.cs @@ -13,6 +13,19 @@ internal static class UmbrellaPackage /// Repository-relative path, with forward slashes, of the project that packs . public const string ProjectPath = "src/CheatEngine.SDK/CheatEngine.SDK.csproj"; + /// + /// The environment variable through which CI hands the fixture the exact .nupkg its Release leg packed, + /// uploads as nuget-package, attests and publishes: an absolute file path. When it is + /// set, never packs; see . + /// + public const string PrebuiltPackageVariable = "CESDK_PACKAGED_UMBRELLA_NUPKG"; + + /// + /// The Category trait value of every class that shares . The Debug CI leg + /// excludes them with --filter-not-trait "Category=Packaging", so it never packs. + /// + public const string PackagingCategory = "Packaging"; + /// /// The folder name NuGet extracts into inside a global-packages folder /// (<packages>/cheatengine.sdk/<version>): NuGet lower-cases the id there. diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageOrigin.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageOrigin.cs new file mode 100644 index 00000000..557254cd --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageOrigin.cs @@ -0,0 +1,17 @@ +namespace CheatEngine.SDK.Tests.Infrastructure; + +/// Where the package under test of comes from. +public enum UmbrellaPackageOrigin +{ + /// + /// The fixture packed src/CheatEngine.SDK itself (local runs). The facts then describe the working tree, + /// not a file that CI uploaded, attested or published. + /// + SelfPacked, + + /// + /// The fixture copied the file named by and never packed: in + /// the CI Release leg, the exact .nupkg that is uploaded as nuget-package, attested and published. + /// + Prebuilt +} diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageSelection.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageSelection.cs new file mode 100644 index 00000000..31b42b51 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageSelection.cs @@ -0,0 +1,6 @@ +namespace CheatEngine.SDK.Tests.Infrastructure; + +/// The package the fixture must test, as decided by . +/// Whether the fixture packs or copies a supplied file. +/// The absolute path of the supplied file, or for a self-pack. +internal sealed record UmbrellaPackageSelection(UmbrellaPackageOrigin Origin, string? PrebuiltPath); diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageSource.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageSource.cs new file mode 100644 index 00000000..7d9d82ae --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/UmbrellaPackageSource.cs @@ -0,0 +1,97 @@ +using System.Text.RegularExpressions; + +namespace CheatEngine.SDK.Tests.Infrastructure; + +/// +/// Decides which package the packaging fixture tests. It is a pure function of the two environment values, read by the +/// fixture only, so the rules are unit-tested in parallel without touching the process environment: +/// +/// +/// set: it must be the absolute path of an existing +/// CheatEngine.SDK.<SemVer>.nupkg file, which the fixture copies instead of packing. +/// +/// +/// Unset while CI is true (GitHub Actions sets it on every run): an error, because a CI run must +/// test the file it ships, and the Debug leg must not run these tests at all. +/// +/// Unset outside CI: the fixture packs the working tree, as a developer expects. +/// +/// +internal static partial class UmbrellaPackageSource +{ + private const string NupkgExtension = ".nupkg"; + + /// The message of a CI run that did not hand the fixture its package. + public static string MissingUnderContinuousIntegrationMessage => + $"{UmbrellaPackage.PrebuiltPackageVariable} is not set while CI=true. The Release leg must pack before testing " + + "and pass the absolute path of the packed file in that variable, so these tests run on the exact package that is " + + "uploaded, attested and published; the Debug leg must exclude these tests with " + + $"--filter-not-trait \"Category={UmbrellaPackage.PackagingCategory}\". " + + "See tests/CheatEngine.SDK.Tests/README.md#run-the-tests."; + + /// Chooses between the supplied package and a self-pack, or throws when CI supplied nothing usable. + /// The value of . + /// The value of the CI environment variable. + /// The supplied value is not an umbrella package, or CI supplied none. + public static UmbrellaPackageSelection Select(string? variableValue, string? continuousIntegrationValue) + { + if (string.IsNullOrWhiteSpace(variableValue)) + { + if (string.Equals(continuousIntegrationValue?.Trim(), "true", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(MissingUnderContinuousIntegrationMessage); + } + + return new UmbrellaPackageSelection(UmbrellaPackageOrigin.SelfPacked, null); + } + + if (!Path.IsPathFullyQualified(variableValue)) + { + throw Rejected(variableValue, "is not an absolute path"); + } + + if (!File.Exists(variableValue)) + { + throw Rejected(variableValue, "names no existing file"); + } + + if (!IsUmbrellaPackageFileName(Path.GetFileName(variableValue))) + { + throw Rejected(variableValue, + $"is not the umbrella package: expected a file named {UmbrellaPackage.Id}.{NupkgExtension} " + + "(a relay package such as CheatEngine.SDK.PackageAssetCarrier.1.0.0.nupkg or a .snupkg symbol package is " + + "not the package under test)"); + } + + return new UmbrellaPackageSelection(UmbrellaPackageOrigin.Prebuilt, variableValue); + } + + /// + /// Whether is CheatEngine.SDK.<SemVer>.nupkg: the umbrella, not the relay + /// carrier package the fixture packs into the same feed, and not a symbol package. + /// + public static bool IsUmbrellaPackageFileName(string fileName) + { + ArgumentNullException.ThrowIfNull(fileName); + string prefix = UmbrellaPackage.Id + "."; + return fileName.StartsWith(prefix, StringComparison.Ordinal) + && fileName.EndsWith(NupkgExtension, StringComparison.Ordinal) + && fileName.Length > prefix.Length + NupkgExtension.Length + && SemanticVersion().IsMatch(fileName[prefix.Length..^NupkgExtension.Length]); + } + + private static InvalidOperationException Rejected(string value, string reason) + { + return new InvalidOperationException( + $"{UmbrellaPackage.PrebuiltPackageVariable}='{value}' {reason}. Set it to the absolute path of the " + + $"{UmbrellaPackage.Id}.{NupkgExtension} file that 'dotnet pack src/CheatEngine.SDK' produced, or unset " + + "it outside CI to let the fixture pack the working tree."); + } + + /// A SemVer 2.0 version without build metadata, as NuGet writes it into a package file name. + [GeneratedRegex( + "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)" + + "(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$", + RegexOptions.CultureInvariant, 1000)] + private static partial Regex SemanticVersion(); +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/BridgeAuditManifestTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/BridgeAuditManifestTests.cs new file mode 100644 index 00000000..560a965b --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/BridgeAuditManifestTests.cs @@ -0,0 +1,277 @@ +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json records the audited bridge: the SHA-256 of its two +/// LF-pinned build inputs, the fingerprint embedded in the DLL, the DLL SHA-256, its PE facts and the pinned xmake +/// version. The DLL facts are read from the committed blob (git cat-file), never from the working tree: +/// CI overwrites the working-tree DLL with the one it builds, and a byte difference between the two is drift that CI +/// reports, never a failure. No collection and no trait, so both CI legs run it. On a mismatch the message prints the +/// complete expected manifest: replacing the file with it is the regeneration procedure. +/// +public sealed partial class BridgeAuditManifestTests +{ + private const string BridgeFolder = "native/cheatengine-sdk-lua-bridge"; + private const string ManifestPath = BridgeFolder + "/bridge-audit-manifest.json"; + private const string SourceFile = "cheatengine_sdk_lua_bridge.c"; + private const string BuildFile = "xmake.lua"; + private const string FingerprintExport = "cheatengine_sdk_lua_bridge_source_fingerprint"; + + private static readonly JsonSerializerOptions s_manifestFormat = new() + { + WriteIndented = true, + IndentSize = 2, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + [Fact] + public async Task Bridge_audit_manifest_records_the_current_source_hashes_and_fingerprint() + { + JsonNode manifest = ReadManifest(); + string sourceSha256 = Sha256(File.ReadAllBytes(BridgeInput(SourceFile))); + string buildSha256 = Sha256(File.ReadAllBytes(BridgeInput(BuildFile))); + string message = await StaleMessageAsync().ConfigureAwait(true); + + Assert.True(string.Equals(sourceSha256, (string?) manifest["source"]?["hashes"]?[SourceFile], StringComparison.Ordinal), + message); + Assert.True(string.Equals(buildSha256, (string?) manifest["source"]?["hashes"]?[BuildFile], StringComparison.Ordinal), + message); + Assert.True(string.Equals($"{sourceSha256}:{buildSha256}", (string?) manifest["source"]?["fingerprint"], + StringComparison.Ordinal), message); + } + + [Fact] + public async Task Bridge_audit_manifest_matches_the_committed_bridge_blob() + { + JsonNode manifest = ReadManifest(); + BridgeFacts committed = await ReadCommittedBridgeAsync().ConfigureAwait(true); + string message = await StaleMessageAsync().ConfigureAwait(true); + JsonNode? asset = manifest["nativeAsset"]; + + Assert.True(string.Equals(committed.Sha256, (string?) asset?["sha256"], StringComparison.Ordinal), message); + Assert.True(string.Equals(committed.Format, (string?) asset?["pe"]?["format"], StringComparison.Ordinal), message); + Assert.True(string.Equals(committed.Machine, (string?) asset?["pe"]?["machine"], StringComparison.Ordinal), message); + Assert.True(committed.IsDll && (bool?) asset?["pe"]?["isDll"] == true, message); + Assert.True(SetEquals(committed.Exports, Strings(asset?["exports"])), message); + Assert.True(SetEquals(committed.Imports, Strings(asset?["imports"])), message); + Assert.True(!committed.HasDelayImports && Strings(asset?["delayImports"]).Count == 0, message); + Assert.True(string.Equals(committed.Fingerprint, (string?) manifest["source"]?["fingerprint"], + StringComparison.Ordinal), message); + } + + [Fact] + public async Task Bridge_audit_manifest_names_the_pinned_xmake_version() + { + JsonNode manifest = ReadManifest(); + string configuration = (string?) manifest["toolchain"]?["buildSystem"]?["configuration"] ?? ""; + string message = await StaleMessageAsync().ConfigureAwait(true); + + Assert.True(File.Exists(RepositoryLayout.PathOf(configuration)), + $"toolchain.buildSystem.configuration names '{configuration}', which does not exist. {message}"); + string? pinned = PinnedXmakeVersion(configuration); + Assert.True(pinned is not null, $"'{configuration}' holds no single xmake-version pin. {message}"); + Assert.True(string.Equals(pinned, (string?) manifest["toolchain"]?["buildSystem"]?["version"], + StringComparison.Ordinal), message); + } + + [Fact] + public void Bridge_audit_manifest_asset_path_exists_with_exact_case() + { + string asset = (string?) ReadManifest()["asset"] ?? ""; + string current = RepositoryLayout.PathOf(BridgeFolder); + + Assert.False(string.IsNullOrEmpty(asset), "The manifest names no asset."); + foreach (string segment in asset.Split('/')) + { + string? match = null; + foreach (string entry in Directory.GetFileSystemEntries(current)) + { + if (string.Equals(Path.GetFileName(entry), segment, StringComparison.Ordinal)) + { + match = entry; + } + } + + Assert.True(match is not null, $"'{segment}' of asset '{asset}' does not exist with that exact case in '{current}'."); + current = match!; + } + + Assert.True(File.Exists(current), $"Asset '{asset}' is not a file."); + } + + /// + /// The manifest the repository should hold, derived from the working-tree build inputs, the committed DLL blob and + /// the pinned xmake version, with every descriptive field kept from the committed manifest. + /// + private static async Task ExpectedManifestJsonAsync() + { + JsonNode expected = ReadManifest(); + BridgeFacts committed = await ReadCommittedBridgeAsync().ConfigureAwait(false); + string sourceSha256 = Sha256(File.ReadAllBytes(BridgeInput(SourceFile))); + string buildSha256 = Sha256(File.ReadAllBytes(BridgeInput(BuildFile))); + + JsonObject source = Object(expected, "source"); + JsonObject hashes = Object(source, "hashes"); + hashes[SourceFile] = sourceSha256; + hashes[BuildFile] = buildSha256; + source["fingerprint"] = $"{sourceSha256}:{buildSha256}"; + + JsonObject asset = Object(expected, "nativeAsset"); + asset["sha256"] = committed.Sha256; + JsonObject pe = Object(asset, "pe"); + pe["format"] = committed.Format; + pe["machine"] = committed.Machine; + pe["isDll"] = committed.IsDll; + asset["exports"] = JsonStrings(committed.Exports); + asset["delayImports"] = JsonStrings(committed.HasDelayImports ? ["(delay-import directory present)"] : []); + asset["imports"] = JsonStrings(committed.Imports); + + JsonObject buildSystem = Object(Object(expected, "toolchain"), "buildSystem"); + string? pinned = PinnedXmakeVersion((string?) buildSystem["configuration"] ?? ""); + if (pinned is not null) + { + buildSystem["version"] = pinned; + } + + return expected.ToJsonString(s_manifestFormat) + Environment.NewLine; + } + + private static async Task StaleMessageAsync() + { + return $"{ManifestPath} does not describe the committed bridge. Replace it with:{Environment.NewLine}" + + await ExpectedManifestJsonAsync().ConfigureAwait(false); + } + + private static async Task ReadCommittedBridgeAsync() + { + string asset = (string?) ReadManifest()["asset"] ?? ""; + byte[] blob = await CommittedFile.ReadBytesAsync($"{BridgeFolder}/{asset}").ConfigureAwait(false); + DirectoryInfo scratch = Directory.CreateTempSubdirectory("cheatengine-sdk-bridge-blob-"); + try + { + string path = Path.Combine(scratch.FullName, Path.GetFileName(asset)); + await File.WriteAllBytesAsync(path, blob).ConfigureAwait(false); + PortableExecutableInspector inspector = PortableExecutableInspector.Read(path); + + List exports = []; + foreach (PortableExecutableExport export in inspector.GetExports()) + { + exports.Add(export.Name); + } + + List imports = []; + foreach (PortableExecutableImport import in inspector.GetImports()) + { + imports.Add(import.ModuleName); + } + + exports.Sort(StringComparer.Ordinal); + imports.Sort(StringComparer.Ordinal); + return new BridgeFacts( + Sha256(blob), + inspector.Magic == PEMagic.PE32Plus ? "PE32+" : inspector.Magic.ToString(), + inspector.Machine == Machine.Amd64 ? "AMD64" : inspector.Machine.ToString(), + inspector.IsDll, + exports, + imports, + inspector.HasDelayImports, + inspector.ReadExportedAsciiZ(FingerprintExport)); + } + finally + { + scratch.Delete(true); + } + } + + /// The single xmake-version value pinned in , or . + private static string? PinnedXmakeVersion(string configuration) + { + string path = RepositoryLayout.PathOf(configuration); + if (configuration.Length == 0 || !File.Exists(path)) + { + return null; + } + + HashSet versions = new(StringComparer.Ordinal); + foreach (Match match in XmakeVersionPin().Matches(File.ReadAllText(path))) + { + versions.Add(match.Groups["version"].Value); + } + + return versions.Count == 1 ? versions.First() : null; + } + + private static JsonNode ReadManifest() + { + return JsonNode.Parse(File.ReadAllText(RepositoryLayout.PathOf(ManifestPath))) + ?? throw new InvalidDataException($"{ManifestPath} is empty."); + } + + private static string BridgeInput(string fileName) + { + return RepositoryLayout.PathOf($"{BridgeFolder}/{fileName}"); + } + + private static JsonObject Object(JsonNode parent, string name) + { + return parent[name] as JsonObject + ?? throw new InvalidDataException($"{ManifestPath} has no '{name}' object."); + } + + private static JsonArray JsonStrings(IReadOnlyList values) + { + JsonArray array = []; + foreach (string value in values) + { + array.Add(value); + } + + return array; + } + + private static List Strings(JsonNode? node) + { + List values = []; + if (node is JsonArray array) + { + foreach (JsonNode? item in array) + { + values.Add((string?) item ?? ""); + } + } + + return values; + } + + private static bool SetEquals(IReadOnlyList expected, List actual) + { + return expected.Count == actual.Count + && new HashSet(expected, StringComparer.Ordinal).SetEquals(actual); + } + + private static string Sha256(byte[] content) + { + return Convert.ToHexStringLower(SHA256.HashData(content)); + } + + [GeneratedRegex("xmake-version:\\s*['\"]?(?[0-9][0-9A-Za-z.+-]*)['\"]?", + RegexOptions.CultureInvariant, 1000)] + private static partial Regex XmakeVersionPin(); + + private sealed record BridgeFacts( + string Sha256, + string Format, + string Machine, + bool IsDll, + IReadOnlyList Exports, + IReadOnlyList Imports, + bool HasDelayImports, + string Fingerprint); +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/BuildPropertyDefaultsTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/BuildPropertyDefaultsTests.cs index 030fa6ed..2c2a5e0a 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/BuildPropertyDefaultsTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/BuildPropertyDefaultsTests.cs @@ -9,6 +9,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// itself. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class BuildPropertyDefaultsTests(PackagedUmbrellaFixture fixture) { [Fact] diff --git a/tests/CheatEngine.SDK.Tests/Packaging/CleanConsumerIsolationTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/CleanConsumerIsolationTests.cs new file mode 100644 index 00000000..53e8f75e --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/CleanConsumerIsolationTests.cs @@ -0,0 +1,132 @@ +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// Qualification scenario Q40 at C1/C2 (audit ch.21 on the native bridge, ch.04 on the deployment folder): a +/// direct consumer restored from the package under test, in a folder with no adjacent SDK workspace, must take nothing +/// from the development tree. Its manifests name the package (with that file's hash), never a project or a repository +/// path; every deployed SDK assembly is the packed one, byte for byte; no Lua runtime is deployed. The C3 half of Q40 +/// (loading such a folder in the exact Cheat Engine host) is not claimed here. +/// +[Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] +public sealed class CleanConsumerIsolationTests(PackagedUmbrellaFixture fixture) +{ + private const string ConsumerName = "DefaultConsumer"; + private const string LibraryPrefix = "lib/net10.0/"; + + [Fact] + [Trait("Qualification", "Q40")] + public void Default_consumer_deps_json_resolves_the_sdk_as_the_package_under_test() + { + foreach (string depsJson in DepsJsonFiles()) + { + List problems = ConsumerManifestRules.SdkLibraryProblems(File.ReadAllText(depsJson), + fixture.PackageVersion, fixture.PackageSha512Base64); + Assert.True(problems.Count == 0, $"{depsJson}: {string.Join("; ", problems)}"); + } + } + + [Fact] + [Trait("Qualification", "Q40")] + public void Default_consumer_deps_json_has_no_project_typed_sdk_library() + { + foreach (string depsJson in DepsJsonFiles()) + { + List offenders = ConsumerManifestRules.ProjectTypedSdkLibraries(File.ReadAllText(depsJson)); + Assert.True(offenders.Count == 0, $"{depsJson}: {string.Join("; ", offenders)}"); + } + } + + [Fact] + [Trait("Qualification", "Q40")] + public void Consumer_manifests_contain_no_repository_or_developer_path() + { + string[] manifests = [$"{ConsumerName}.deps.json", $"{ConsumerName}.runtimeconfig.json"]; + List offenders = []; + foreach (string folder in DeploymentFolders()) + { + foreach (string manifest in manifests) + { + string path = Path.Combine(folder, manifest); + Assert.True(File.Exists(path), $"'{path}' was not produced."); + foreach (string leak in ConsumerManifestRules.WorkspaceLeaks(File.ReadAllText(path), RepositoryLayout.Root)) + { + offenders.Add($"{path}: {leak}"); + } + } + + foreach (string devConfig in Directory.GetFiles(folder, "*.runtimeconfig.dev.json")) + { + offenders.Add($"{devConfig}: a development runtimeconfig adds probing paths of this machine"); + } + } + + Assert.True(offenders.Count == 0, string.Join(Environment.NewLine, offenders)); + } + + [Fact] + [Trait("Qualification", "Q40")] + public void Every_deployed_sdk_assembly_is_byte_identical_to_its_packed_lib_entry() + { + int compared = 0; + foreach (string entry in fixture.PackageEntries) + { + if (!entry.StartsWith(LibraryPrefix, StringComparison.Ordinal) + || !entry.EndsWith(".dll", StringComparison.Ordinal)) + { + continue; + } + + byte[] packed = NupkgInspector.ReadEntryBytes(fixture.PackagePath, entry); + string fileName = entry[LibraryPrefix.Length..]; + foreach (string folder in DeploymentFolders()) + { + string deployed = Path.Combine(folder, fileName); + Assert.True(File.Exists(deployed), $"'{fileName}' was not deployed to '{folder}'."); + Assert.True(packed.AsSpan().SequenceEqual(File.ReadAllBytes(deployed)), + $"'{deployed}' differs from the package entry '{entry}' of {Path.GetFileName(fixture.PackagePath)}."); + } + + compared++; + } + + Assert.Equal(7, compared); + } + + [Fact] + [Trait("Qualification", "Q40")] + public void Deployment_and_publish_folders_contain_no_lua_runtime() + { + List offenders = []; + foreach (string folder in DeploymentFolders()) + { + Assert.True(File.Exists(Path.Combine(folder, "cheatengine-sdk-lua-bridge.dll")), + $"'{folder}' has no bridge, so it is not the deployment folder these facts are about."); + foreach (string file in Directory.GetFiles(folder, "*", SearchOption.AllDirectories)) + { + if (ConsumerManifestRules.IsLuaRuntimeFileName(Path.GetFileName(file))) + { + offenders.Add(file); + } + } + } + + Assert.True(offenders.Count == 0, $"A Lua runtime was deployed: {string.Join(", ", offenders)}"); + } + + private string[] DeploymentFolders() + { + return [fixture.DefaultDeploymentDirectory, fixture.DefaultPublishDirectory]; + } + + private string[] DepsJsonFiles() + { + return + [ + fixture.DefaultDepsJsonPath, + Path.Combine(fixture.DefaultPublishDirectory, $"{ConsumerName}.deps.json") + ]; + } +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/ConsumerManifestRuleTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/ConsumerManifestRuleTests.cs new file mode 100644 index 00000000..2b440d4e --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/ConsumerManifestRuleTests.cs @@ -0,0 +1,93 @@ +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// Proves, on synthetic manifests and without the packaging fixture, that each rule +/// accepts a clean package consumer and rejects the workspace leak it exists for. Runs in both CI legs. +/// +public sealed class ConsumerManifestRuleTests +{ + private const string Version = "2.0.0-alpha.0.18"; + private const string Sha512 = "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA=="; + private const string Root = @"D:\a\CheatEngine.SDK\CheatEngine.SDK"; + + [Fact] + public void A_package_typed_library_with_the_package_hash_satisfies_the_sdk_library_rule() + { + string depsJson = DepsJson("package", "cheatengine.sdk/2.0.0-alpha.0.18", "sha512-" + Sha512); + + Assert.Empty(ConsumerManifestRules.SdkLibraryProblems(depsJson, Version, Sha512)); + Assert.Empty(ConsumerManifestRules.ProjectTypedSdkLibraries(depsJson)); + Assert.Empty(ConsumerManifestRules.WorkspaceLeaks(depsJson, Root)); + } + + [Fact] + public void A_project_typed_sdk_library_is_reported_by_both_deps_json_rules() + { + string depsJson = DepsJson("project", "", ""); + + List problems = ConsumerManifestRules.SdkLibraryProblems(depsJson, Version, Sha512); + + Assert.Contains(problems, static p => p.Contains("has type 'project'", StringComparison.Ordinal)); + Assert.Contains(problems, static p => p.Contains("has sha512 ''", StringComparison.Ordinal)); + Assert.Equal($"library '{UmbrellaPackage.Id}/{Version}' is project-typed", + Assert.Single(ConsumerManifestRules.ProjectTypedSdkLibraries(depsJson))); + } + + [Fact] + public void Another_package_hash_or_an_absent_library_is_reported_as_not_the_package_under_test() + { + string otherHash = DepsJson("package", "cheatengine.sdk/2.0.0-alpha.0.18", "sha512-AAAA"); + + Assert.Contains(ConsumerManifestRules.SdkLibraryProblems(otherHash, Version, Sha512), + static p => p.Contains("(the package under test)", StringComparison.Ordinal)); + Assert.Equal($"library '{UmbrellaPackage.Id}/2.0.0' is absent", + Assert.Single(ConsumerManifestRules.SdkLibraryProblems(otherHash, "2.0.0", Sha512))); + } + + [Theory] + [InlineData(@"{""path"":""D:\\a\\CheatEngine.SDK\\CheatEngine.SDK\\artifacts\\bin""}", @"\\")] + [InlineData(@"D:\A\CheatEngine.SDK\CheatEngine.SDK\native\lua53-64.dll", @"\")] + [InlineData("d:/a/CheatEngine.SDK/CheatEngine.SDK/src", "/")] + public void The_repository_root_in_any_separator_form_is_a_workspace_leak(string manifest, string separator) + { + string leak = Assert.Single(ConsumerManifestRules.WorkspaceLeaks(manifest, Root)); + + Assert.Contains($"a{separator}CheatEngine.SDK", leak, StringComparison.Ordinal); + } + + [Fact] + public void Additional_probing_paths_are_a_workspace_leak() + { + const string runtimeConfig = + """{"runtimeOptions":{"additionalProbingPaths":["C:\\Users\\dev\\.nuget\\packages"]}}"""; + + Assert.Equal("declares additionalProbingPaths", + Assert.Single(ConsumerManifestRules.WorkspaceLeaks(runtimeConfig, Root))); + } + + [Theory] + [InlineData("lua53-64.dll", true)] + [InlineData("LUA54.DLL", true)] + [InlineData("lua5.1.dll", true)] + [InlineData("cheatengine-sdk-lua-bridge.dll", false)] + [InlineData("CheatEngine.SDK.Lua.dll", false)] + [InlineData("lua53-64.pdb", false)] + public void Only_lua_dll_file_names_count_as_a_lua_runtime(string fileName, bool isLuaRuntime) + { + Assert.Equal(isLuaRuntime, ConsumerManifestRules.IsLuaRuntimeFileName(fileName)); + } + + private static string DepsJson(string type, string path, string sha512) + { + return $$""" + { + "libraries": { + "DefaultConsumer/1.0.0": { "type": "project", "serviceable": false, "sha512": "" }, + "{{UmbrellaPackage.Id}}/{{Version}}": { "type": "{{type}}", "serviceable": true, "sha512": "{{sha512}}", "path": "{{path}}" } + } + } + """; + } +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/ConsumerManifestRules.cs b/tests/CheatEngine.SDK.Tests/Packaging/ConsumerManifestRules.cs new file mode 100644 index 00000000..0fdc6775 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/ConsumerManifestRules.cs @@ -0,0 +1,126 @@ +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// What a clean package consumer's manifests must and must not say (audit ch.21: a consumer that works thanks to a file +/// found in the development folder is not a qualified consumer). Pure functions of the manifest text, each returning +/// one message per problem, so applies them to the real consumer and +/// proves on synthetic manifests that each rule fails on the leak it exists for. +/// +internal static class ConsumerManifestRules +{ + private const string AdditionalProbingPaths = "additionalProbingPaths"; + + /// + /// Problems with the CheatEngine.SDK/<version> library of a .deps.json: it must exist, be + /// package-typed, live under cheatengine.sdk/<lower-case version> and carry the SHA-512 of the + /// package under test (for an unsigned package, the NuGet content hash of exactly that file). + /// + public static List SdkLibraryProblems(string depsJson, string packageVersion, string packageSha512Base64) + { + List problems = []; + string key = $"{UmbrellaPackage.Id}/{packageVersion}"; + using JsonDocument document = JsonDocument.Parse(depsJson); + if (!document.RootElement.TryGetProperty("libraries", out JsonElement libraries) + || !libraries.TryGetProperty(key, out JsonElement library)) + { + problems.Add($"library '{key}' is absent"); + return problems; + } + + string type = StringProperty(library, "type"); + string path = StringProperty(library, "path"); + string sha512 = StringProperty(library, "sha512"); + string expectedPath = $"{UmbrellaPackage.ExtractionFolderName}/{packageVersion.ToLowerInvariant()}"; + string expectedSha512 = "sha512-" + packageSha512Base64; + if (!string.Equals(type, "package", StringComparison.Ordinal)) + { + problems.Add($"library '{key}' has type '{type}', expected 'package'"); + } + + if (!string.Equals(path, expectedPath, StringComparison.Ordinal)) + { + problems.Add($"library '{key}' has path '{path}', expected '{expectedPath}'"); + } + + if (!string.Equals(sha512, expectedSha512, StringComparison.Ordinal)) + { + problems.Add($"library '{key}' has sha512 '{sha512}', expected '{expectedSha512}' (the package under test)"); + } + + return problems; + } + + /// Every CheatEngine.SDK* library of a .deps.json that is project-typed. + public static List ProjectTypedSdkLibraries(string depsJson) + { + List offenders = []; + using JsonDocument document = JsonDocument.Parse(depsJson); + if (!document.RootElement.TryGetProperty("libraries", out JsonElement libraries)) + { + return offenders; + } + + foreach (JsonProperty library in libraries.EnumerateObject()) + { + if (library.Name.StartsWith(UmbrellaPackage.Id, StringComparison.OrdinalIgnoreCase) + && string.Equals(StringProperty(library.Value, "type"), "project", StringComparison.Ordinal)) + { + offenders.Add($"library '{library.Name}' is project-typed"); + } + } + + return offenders; + } + + /// + /// Every way reaches a development workspace: the repository root written with + /// \, JSON-escaped \\ or / separators (case-insensitive), or an additionalProbingPaths + /// entry, which is how a runtimeconfig points the host at a developer's NuGet folder. + /// + public static List WorkspaceLeaks(string manifestText, string repositoryRoot) + { + List offenders = []; + string backslashed = repositoryRoot.TrimEnd('\\', '/').Replace('/', '\\'); + string[] forms = + [ + backslashed, + backslashed.Replace("\\", "\\\\", StringComparison.Ordinal), + backslashed.Replace('\\', '/') + ]; + foreach (string form in forms) + { + if (manifestText.Contains(form, StringComparison.OrdinalIgnoreCase)) + { + offenders.Add($"contains the repository root as '{form}'"); + } + } + + if (manifestText.Contains(AdditionalProbingPaths, StringComparison.Ordinal)) + { + offenders.Add($"declares {AdditionalProbingPaths}"); + } + + return offenders; + } + + /// + /// Whether a deployed file is a Lua runtime (lua*.dll, case-insensitive). The bridge, + /// cheatengine-sdk-lua-bridge.dll, is not one: it binds to the host's already-loaded Lua. + /// + public static bool IsLuaRuntimeFileName(string fileName) + { + return fileName.StartsWith("lua", StringComparison.OrdinalIgnoreCase) + && fileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); + } + + private static string StringProperty(JsonElement element, string name) + { + return element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(name, out JsonElement value) + && value.ValueKind == JsonValueKind.String + ? value.GetString()! + : ""; + } +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/DeploymentLayoutTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/DeploymentLayoutTests.cs index 30ea56fd..1dac8127 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/DeploymentLayoutTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/DeploymentLayoutTests.cs @@ -7,6 +7,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// manifests, and Lua bridge. The fixture also cleans and rebuilds this folder before these facts are observed. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class DeploymentLayoutTests(PackagedUmbrellaFixture fixture) { private static readonly string[] ExpectedSdkAssemblies = diff --git a/tests/CheatEngine.SDK.Tests/Packaging/DirectReferenceIsolationTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/DirectReferenceIsolationTests.cs index 65dda883..ebb0656b 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/DirectReferenceIsolationTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/DirectReferenceIsolationTests.cs @@ -7,6 +7,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// a project that references that package. Those behaviors belong only to a direct CheatEngine.SDK PackageReference. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class DirectReferenceIsolationTests(PackagedUmbrellaFixture fixture) { [Theory] diff --git a/tests/CheatEngine.SDK.Tests/Packaging/EntryPointTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/EntryPointTests.cs index a7be5b37..2cc7fd8c 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/EntryPointTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/EntryPointTests.cs @@ -15,6 +15,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// really reaches the generator, not just its own internal default. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class EntryPointTests(PackagedUmbrellaFixture fixture) { [Fact] diff --git a/tests/CheatEngine.SDK.Tests/Packaging/LivePluginClosureTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/LivePluginClosureTests.cs new file mode 100644 index 00000000..75e41954 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/LivePluginClosureTests.cs @@ -0,0 +1,260 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; + +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// The plugin folders the solution builds for the live Cheat Engine runs (tests/CheatEngine.SDK.LivePlugin and +/// the two coexistence plugins) are deployable units on their own: everything their .deps.json promises is in +/// the folder, the runtime policy is the net10.0 / Microsoft.NETCore.App profile Cheat Engine's hostfxr +/// loader reads, no Lua runtime is copied, and the native bridge is byte-identical to the workspace bridge (the +/// CI-built one in CI). This measures the "plugin output" link of the release tuple (audit ch.09, A09-13) at C1 only: +/// these bundles are built from source through project references, so project-typed SDK libraries are expected +/// here, unlike in the package consumers of . The folders of the running +/// test configuration are read; the tests run in both CI legs and never skip. +/// +public sealed class LivePluginClosureTests +{ + private const string BridgeFile = "cheatengine-sdk-lua-bridge.dll"; + private const string WorkspaceBridge = "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/" + BridgeFile; + + /// The SDK assemblies the managed bootstrap loads before any plugin code runs. + private static readonly string[] s_bootstrapLibraries = + [ + "CheatEngine.SDK.Abi", "CheatEngine.SDK.Annotations", "CheatEngine.SDK.Hosting", "CheatEngine.SDK.Lua", + "CheatEngine.SDK.Lua.Interop" + ]; + + /// + /// Bundles built without the Cheat Engine entry point. The entry-point generator emits CESDK.CESDK only when + /// CheatEngineSdkGenerateEntryPoint is compiler-visible and true, which the package's + /// build/CheatEngine.SDK.props gives a direct consumer. CheatEngine.SDK.LivePlugin references the + /// libraries and the generator as projects and sets neither, so Cheat Engine finds no entry point in it. The + /// coexistence fixtures set both directly in their own CoexistencePlugin.props, so they are no longer + /// pending. The list only shrinks: a bundle that gains the entry point fails until it is removed from here. + /// + private static readonly HashSet s_pendingEntryPoint = new(StringComparer.Ordinal) + { + "CheatEngine.SDK.LivePlugin" + }; + + [Theory] + [InlineData("CheatEngine.SDK.LivePlugin")] + [InlineData("CheatEngine.SDK.LivePlugin.Coexistence.PluginA")] + [InlineData("CheatEngine.SDK.LivePlugin.Coexistence.PluginB")] + public void Live_plugin_output_closure_matches_the_workspace_bridge(string project) + { + string folder = BundleFolder(project); + + // Everything the dependency manifest promises is deployed, and no SDK assembly is deployed without being listed. + using JsonDocument deps = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(folder, project + ".deps.json"))); + JsonElement target = Assert.Single(deps.RootElement.GetProperty("targets").EnumerateObject()).Value; + HashSet promised = new(StringComparer.OrdinalIgnoreCase); + HashSet sdkLibraries = new(StringComparer.Ordinal); + foreach (JsonProperty library in target.EnumerateObject()) + { + string name = library.Name[..library.Name.IndexOf('/', StringComparison.Ordinal)]; + if (name.StartsWith("CheatEngine.SDK.", StringComparison.Ordinal) && !string.Equals(name, project, StringComparison.Ordinal)) + { + sdkLibraries.Add(name); + } + + if (library.Value.TryGetProperty("runtime", out JsonElement runtime)) + { + foreach (JsonProperty asset in runtime.EnumerateObject()) + { + promised.Add(Path.GetFileName(asset.Name)); + } + } + } + + Assert.Superset(new HashSet(s_bootstrapLibraries, StringComparer.Ordinal), sdkLibraries); + foreach (string file in promised) + { + Assert.True(File.Exists(Path.Combine(folder, file)), $"{project}.deps.json promises {file}, which is not in {folder}."); + } + + foreach (string assembly in Directory.EnumerateFiles(folder, "CheatEngine.SDK.*.dll")) + { + Assert.True(promised.Contains(Path.GetFileName(assembly)), + $"{Path.GetFileName(assembly)} is deployed in {folder} but {project}.deps.json does not list it."); + } + + // The runtime policy is the one the managed hostfxr profile loads: net10.0 on the shared Microsoft.NETCore.App. + using JsonDocument runtimeConfig = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(folder, project + ".runtimeconfig.json"))); + JsonElement options = runtimeConfig.RootElement.GetProperty("runtimeOptions"); + Assert.Equal("net10.0", options.GetProperty("tfm").GetString()); + Assert.Equal("Microsoft.NETCore.App", options.GetProperty("framework").GetProperty("name").GetString()); + Assert.False(options.TryGetProperty("includedFrameworks", out _), $"{project} must not be self-contained."); + + // The bridge is the workspace one, and no Lua runtime travels with the plugin (Cheat Engine's own is used). + string bridge = Path.Combine(folder, BridgeFile); + Assert.True(File.Exists(bridge), $"{folder} has no {BridgeFile}."); + Assert.Equal(Sha256(RepositoryLayout.PathOf(WorkspaceBridge)), Sha256(bridge)); + foreach (string file in Directory.EnumerateFiles(folder, "*.dll")) + { + string name = Path.GetFileName(file); + Assert.False(name.StartsWith("lua", StringComparison.OrdinalIgnoreCase), $"{folder} deploys a Lua runtime, {name}."); + } + } + + [Theory] + [InlineData("CheatEngine.SDK.LivePlugin")] + [InlineData("CheatEngine.SDK.LivePlugin.Coexistence.PluginA")] + [InlineData("CheatEngine.SDK.LivePlugin.Coexistence.PluginB")] + public void Live_plugin_entry_point_is_present_unless_listed_as_a_pending_fix(string project) + { + string assembly = Path.Combine(BundleFolder(project), project + ".dll"); + + string? signature = FindEntryPointSignature(assembly); + + if (s_pendingEntryPoint.Contains(project)) + { + Assert.True(signature is null, + $"{project} now declares CESDK.CESDK.CEPluginInitialize: remove it from {nameof(s_pendingEntryPoint)}."); + return; + } + + Assert.Equal("public static int CEPluginInitialize(System.IntPtr, int)", signature); + } + + /// + /// The folder of for the configuration of the running test assembly, which lives in + /// artifacts/bin/CheatEngine.SDK.Tests/<configuration>/. + /// + private static string BundleFolder(string project) + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)).Name; + string folder = RepositoryLayout.PathOf($"artifacts/bin/{project}/{configuration}"); + Assert.True(File.Exists(Path.Combine(folder, project + ".dll")), + $"{project} has no {configuration} output in {folder}: build CheatEngine.SDK.slnx -c {configuration} first."); + return folder; + } + + /// The accessibility, return type and parameters of CESDK.CESDK.CEPluginInitialize, or null without it. + private static string? FindEntryPointSignature(string assemblyPath) + { + using FileStream stream = File.OpenRead(assemblyPath); + using PEReader peReader = new(stream); + MetadataReader reader = peReader.GetMetadataReader(); + foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) + { + TypeDefinition type = reader.GetTypeDefinition(typeHandle); + if (!string.Equals(reader.GetString(type.Namespace), "CESDK", StringComparison.Ordinal) + || !string.Equals(reader.GetString(type.Name), "CESDK", StringComparison.Ordinal)) + { + continue; + } + + foreach (MethodDefinitionHandle methodHandle in type.GetMethods()) + { + MethodDefinition method = reader.GetMethodDefinition(methodHandle); + if (!string.Equals(reader.GetString(method.Name), "CEPluginInitialize", StringComparison.Ordinal)) + { + continue; + } + + MethodSignature decoded = method.DecodeSignature(SignatureNames.Instance, genericContext: null); + string access = (method.Attributes & MethodAttributes.MemberAccessMask) == MethodAttributes.Public ? "public" : "non-public"; + string binding = (method.Attributes & MethodAttributes.Static) != MethodAttributes.PrivateScope ? "static" : "instance"; + return $"{access} {binding} {decoded.ReturnType} CEPluginInitialize({string.Join(", ", decoded.ParameterTypes)})"; + } + } + + return null; + } + + private static string Sha256(string path) + { + return Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); + } + + /// Names signature types the way the assertion spells them: C# keywords for the primitives the entry point uses. + private sealed class SignatureNames : ISignatureTypeProvider + { + public static readonly SignatureNames Instance = new(); + + public string GetPrimitiveType(PrimitiveTypeCode typeCode) + { + return typeCode switch + { + PrimitiveTypeCode.Int32 => "int", + PrimitiveTypeCode.Void => "void", + PrimitiveTypeCode.IntPtr => "System.IntPtr", + _ => typeCode.ToString() + }; + } + + public string GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + { + TypeDefinition definition = reader.GetTypeDefinition(handle); + return reader.GetString(definition.Namespace) + "." + reader.GetString(definition.Name); + } + + public string GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + { + TypeReference reference = reader.GetTypeReference(handle); + return reader.GetString(reference.Namespace) + "." + reader.GetString(reference.Name); + } + + public string GetTypeFromSpecification(MetadataReader reader, object? genericContext, TypeSpecificationHandle handle, byte rawTypeKind) + { + return "typespec"; + } + + public string GetSZArrayType(string elementType) + { + return elementType + "[]"; + } + + public string GetArrayType(string elementType, ArrayShape shape) + { + return elementType + "[" + new string(',', shape.Rank - 1) + "]"; + } + + public string GetByReferenceType(string elementType) + { + return "ref " + elementType; + } + + public string GetPointerType(string elementType) + { + return elementType + "*"; + } + + public string GetGenericInstantiation(string genericType, ImmutableArray typeArguments) + { + return genericType + "<" + string.Join(", ", typeArguments) + ">"; + } + + public string GetGenericMethodParameter(object? genericContext, int index) + { + return "!!" + index.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + public string GetGenericTypeParameter(object? genericContext, int index) + { + return "!" + index.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + public string GetModifiedType(string modifier, string unmodifiedType, bool isRequired) + { + return unmodifiedType; + } + + public string GetPinnedType(string elementType) + { + return elementType; + } + + public string GetFunctionPointerType(MethodSignature signature) + { + return "delegate*"; + } + } +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePackagingAuditTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePackagingAuditTests.cs index a309ee66..70ecfcab 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePackagingAuditTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePackagingAuditTests.cs @@ -9,11 +9,14 @@ namespace CheatEngine.SDK.Tests.Packaging; /// the exact audited bridge, rather than another native asset that merely happens to share its file name. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class NativeBridgePackagingAuditTests(PackagedUmbrellaFixture fixture) { private const string BridgeRelativePath = "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"; + private const string PackedBridgeEntry = "build/native/cheatengine-sdk-lua-bridge.dll"; + [Fact] public void Direct_consumer_build_and_publish_copy_the_exact_audited_bridge_asset() { @@ -27,6 +30,20 @@ public void Direct_consumer_build_and_publish_copy_the_exact_audited_bridge_asse Assert.Equal(auditedHash, CalculateSha256(fixture.DefaultPublishedNativeBridgePath)); } + /// + /// The workspace comparison above holds only when the package was packed from this workspace. This one holds for + /// every origin, including the exact CI package: what a direct consumer deploys is the bridge the package carries. + /// + [Fact] + public void Direct_consumer_bridges_are_byte_identical_to_the_packed_build_native_entry() + { + string packedHash = Convert.ToHexString(SHA256.HashData( + NupkgInspector.ReadEntryBytes(fixture.PackagePath, PackedBridgeEntry))).ToLowerInvariant(); + + Assert.Equal(packedHash, CalculateSha256(fixture.DefaultNativeBridgePath)); + Assert.Equal(packedHash, CalculateSha256(fixture.DefaultPublishedNativeBridgePath)); + } + private static string CalculateSha256(string path) { return Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); diff --git a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs index 88ae1000..4f8229e3 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs @@ -19,6 +19,9 @@ public sealed class NativeBridgePeAuditTests private const string BuildRelativePath = "native/cheatengine-sdk-lua-bridge/xmake.lua"; private const string ContinuousIntegrationWorkflowRelativePath = ".github/workflows/ci.yml"; private const string PinnedXmakeVersion = "3.0.9"; + private const string PinnedMsvcToolset = "14.44"; + private const string PinnedWindowsSdk = "10.0.26100.0"; + private const string PinnedWindowsRunner = "windows-2025"; private static readonly string[] ExpectedExports = [ @@ -103,31 +106,47 @@ public void Native_bridge_xmake_configuration_pins_the_required_compilation_cont } [Fact] - public void Native_bridge_ci_pins_xmake_and_enforces_a_double_build_reproducibility_gate() + public void Native_bridge_ci_builds_once_with_pinned_xmake_and_reports_its_sha256() { - string workflow = ReadRepositoryText(ContinuousIntegrationWorkflowRelativePath); + string job = ReadNativeJob(); - Assert.Contains("xmake-io/github-action-setup-xmake@", workflow, StringComparison.Ordinal); - Assert.Contains($"xmake-version: '{PinnedXmakeVersion}'", workflow, StringComparison.Ordinal); - Assert.Contains("$primaryOutput = 'artifacts/native/cheatengine-sdk-lua-bridge'", workflow, - StringComparison.Ordinal); - Assert.Contains("$reproducibilityOutput = 'artifacts/native/cheatengine-sdk-lua-bridge-repro'", workflow, - StringComparison.Ordinal); - Assert.Contains("The primary and reproducibility bridge output directories must be distinct.", workflow, - StringComparison.Ordinal); - Assert.Contains("xmake f -P native/cheatengine-sdk-lua-bridge -o $primaryOutput", workflow, - StringComparison.Ordinal); - Assert.Contains("xmake f -P native/cheatengine-sdk-lua-bridge -o $reproducibilityOutput", workflow, - StringComparison.Ordinal); - Assert.Contains("$primaryHash = (Get-FileHash -LiteralPath $primaryBridge -Algorithm SHA256).Hash", workflow, - StringComparison.Ordinal); + // The native job installs the pinned xmake and configures/builds the bridge directly (no bespoke eng/ci + // wrapper script): one build feeds every downstream job, and its SHA-256 becomes a job output. + Assert.Contains("xmake-io/github-action-setup-xmake@", job, StringComparison.Ordinal); + Assert.Contains($"xmake-version: '{PinnedXmakeVersion}'", job, StringComparison.Ordinal); Assert.Contains( - "$reproducibilityHash = (Get-FileHash -LiteralPath $reproducibilityBridge -Algorithm SHA256).Hash", - workflow, StringComparison.Ordinal); - Assert.Contains("$primaryHash, $reproducibilityHash, [StringComparison]::OrdinalIgnoreCase", workflow, + "xmake f -P native/cheatengine-sdk-lua-bridge -o artifacts/native/cheatengine-sdk-lua-bridge -p windows -a x64 -m release -y --ccache=n", + job, StringComparison.Ordinal); + Assert.Contains("xmake -P native/cheatengine-sdk-lua-bridge -y", job, StringComparison.Ordinal); + Assert.Contains("path: artifacts/native/cheatengine-sdk-lua-bridge/cheatengine-sdk-lua-bridge.dll", job, + StringComparison.Ordinal); + Assert.Contains("bridge-sha256: ${{ steps.bridge.outputs.bridge-sha256 }}", job, StringComparison.Ordinal); + Assert.Contains("\"bridge-sha256=$hash\"", job, StringComparison.Ordinal); + + // Every build compiles from source: a compiler-cache hit must not stand in for a real compilation. + Assert.Contains("--ccache=n", job, StringComparison.Ordinal); + } + + [Fact] + public void Native_bridge_ci_pins_the_msvc_toolset_and_windows_sdk() + { + string job = ReadNativeJob(); + + Assert.Contains($"BRIDGE_VS_TOOLSET: '{PinnedMsvcToolset}'", job, StringComparison.Ordinal); + Assert.Contains($"BRIDGE_VS_SDKVER: '{PinnedWindowsSdk}'", job, StringComparison.Ordinal); + Assert.Contains("\"--vs_toolset=$env:BRIDGE_VS_TOOLSET\" \"--vs_sdkver=$env:BRIDGE_VS_SDKVER\"", job, StringComparison.Ordinal); } + [Fact] + public void Native_bridge_ci_runs_on_the_pinned_windows_label() + { + string job = ReadNativeJob(); + + Assert.Contains($"runs-on: {PinnedWindowsRunner}\n", job, StringComparison.Ordinal); + Assert.DoesNotContain("-latest", job, StringComparison.Ordinal); + } + private static PortableExecutableInspector ReadBridge() { return PortableExecutableInspector.Read(BridgePath); @@ -156,9 +175,34 @@ private static void AssertExactSet(string[] expected, List actual) } } + /// Reads a committed text file with LF line endings, whatever the checkout's line-ending conversion. private static string ReadRepositoryText(string relativePath) { - return File.ReadAllText(RepositoryLayout.PathOf(relativePath)); + return File.ReadAllText(RepositoryLayout.PathOf(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + } + + /// + /// The native job of ci.yml: from its key to the next line indented like a job key (the next job, or the + /// comment that introduces it), so an assertion cannot be satisfied by text of another job. + /// + private static string ReadNativeJob() + { + string[] lines = ReadRepositoryText(ContinuousIntegrationWorkflowRelativePath).Split('\n'); + int start = Array.IndexOf(lines, " native:"); + Assert.True(start >= 0, $"{ContinuousIntegrationWorkflowRelativePath} has no 'native' job."); + + int end = start + 1; + while (end < lines.Length && !IsJobLevelLine(lines[end])) + { + end++; + } + + return string.Join('\n', lines, start, end - start) + "\n"; + } + + private static bool IsJobLevelLine(string line) + { + return line.Length > 2 && line.StartsWith(" ", StringComparison.Ordinal) && line[2] != ' '; } private static string CalculateSha256(string path) diff --git a/tests/CheatEngine.SDK.Tests/Packaging/NuspecDependencyTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/NuspecDependencyTests.cs index 2d069655..f9143ca6 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/NuspecDependencyTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/NuspecDependencyTests.cs @@ -9,6 +9,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// keeps them out; this is the check that would fail if that metadata were ever dropped by accident. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class NuspecDependencyTests(PackagedUmbrellaFixture fixture) { [Fact] diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PackageContentsTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PackageContentsTests.cs index e86e6cc5..8de6554f 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/PackageContentsTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/PackageContentsTests.cs @@ -9,6 +9,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// repository-internal and does not ship. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class PackageContentsTests(PackagedUmbrellaFixture fixture) { private static readonly string[] ExpectedLibraries = diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PackageProvenanceTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PackageProvenanceTests.cs new file mode 100644 index 00000000..f970bf89 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/PackageProvenanceTests.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; + +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// Which file every other packaging fact is about. In the CI Release leg the fixture must consume the exact +/// .nupkg named by (the file uploaded as +/// nuget-package, attested and published) and never pack; only a local run outside CI may pack the working tree. +/// The identity of the consumed file is written to the test output, so it lands in the TRX next to the results. +/// +[Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] +public sealed class PackageProvenanceTests(PackagedUmbrellaFixture fixture) +{ + [Fact] + public void Fixture_consumes_the_ci_supplied_package_or_self_packs_only_outside_ci() + { + string? supplied = Environment.GetEnvironmentVariable(UmbrellaPackage.PrebuiltPackageVariable); + string? continuousIntegration = Environment.GetEnvironmentVariable("CI"); + TestContext.Current.TestOutputHelper?.WriteLine( + $"Consumed {Path.GetFileName(fixture.PackagePath)} sha256={fixture.PackageSha256} origin={fixture.PackageOrigin}"); + + if (!string.IsNullOrWhiteSpace(supplied)) + { + Assert.Equal(UmbrellaPackageOrigin.Prebuilt, fixture.PackageOrigin); + Assert.Equal(supplied, fixture.SuppliedPackagePath); + Assert.Equal(Path.GetFileName(supplied), Path.GetFileName(fixture.PackagePath)); + Assert.True(fixture.SelfPackAttempts == 0, + $"The fixture ran 'dotnet pack' {fixture.SelfPackAttempts} time(s) although CI supplied '{supplied}'."); + } + else + { + Assert.Equal(UmbrellaPackageOrigin.SelfPacked, fixture.PackageOrigin); + Assert.False(string.Equals(continuousIntegration?.Trim(), "true", StringComparison.OrdinalIgnoreCase), + "A CI run reached the packaging facts without a supplied package; the fixture must have refused it."); + Assert.Equal("", fixture.SuppliedPackagePath); + Assert.InRange(fixture.SelfPackAttempts, 1, 5); + } + } + + [Fact] + public void Feed_copy_is_byte_identical_to_the_supplied_package() + { + byte[] feedCopy = File.ReadAllBytes(fixture.PackagePath); + Assert.Matches("^[0-9a-f]{64}$", fixture.PackageSha256); + Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(feedCopy)), fixture.PackageSha256); + Assert.Equal(Convert.ToBase64String(SHA512.HashData(feedCopy)), fixture.PackageSha512Base64); + Assert.Equal(88, fixture.PackageSha512Base64.Length); + + if (fixture.PackageOrigin == UmbrellaPackageOrigin.Prebuilt) + { + byte[] supplied = File.ReadAllBytes(fixture.SuppliedPackagePath); + Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(supplied)), fixture.PackageSha256); + Assert.Equal(supplied.Length, feedCopy.Length); + } + else + { + List umbrellas = []; + foreach (string file in Directory.GetFiles(Path.GetDirectoryName(fixture.PackagePath)!, "*.nupkg")) + { + if (UmbrellaPackageSource.IsUmbrellaPackageFileName(Path.GetFileName(file))) + { + umbrellas.Add(file); + } + } + + Assert.Equal(fixture.PackagePath, Assert.Single(umbrellas)); + } + } +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PackagedUmbrellaTraitTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PackagedUmbrellaTraitTests.cs new file mode 100644 index 00000000..18b1de83 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/PackagedUmbrellaTraitTests.cs @@ -0,0 +1,169 @@ +using System.Reflection; + +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// The Debug CI leg excludes the packaging fixture with --filter-not-trait "Category=Packaging", and the Release +/// leg hands it the exact package. That only works if the trait and the collection coincide: a collection class +/// without the trait would start a pack in the Debug leg (or fail it under CI=true), and a traited class +/// outside the collection would silently drop out of the Debug leg. Read through , +/// so nothing is instantiated. +/// +public sealed class PackagedUmbrellaTraitTests +{ + private const string CategoryTrait = "Category"; + + [Fact] + public void Every_packaged_umbrella_collection_class_carries_the_packaging_trait() + { + List members = []; + List offenders = []; + foreach (Type type in TestTypes()) + { + bool joinsCollection = JoinsPackagedUmbrellaCollection(type); + if (joinsCollection) + { + members.Add(type); + if (!HasPackagingTrait(type)) + { + offenders.Add($"{type.FullName} joins '{PackagedUmbrellaSuite.Name}' without " + + $"[Trait(\"{CategoryTrait}\", UmbrellaPackage.PackagingCategory)]."); + } + } + else if (ReceivesTheFixture(type)) + { + offenders.Add($"{type.FullName} receives {nameof(PackagedUmbrellaFixture)} outside collection " + + $"'{PackagedUmbrellaSuite.Name}', so it would get its own pack."); + } + } + + Assert.Contains(typeof(RestoreIsolationTests), members); + Assert.Contains(typeof(SupplyChainPackageTests), members); + Assert.Contains(typeof(PackageProvenanceTests), members); + Assert.Contains(typeof(CleanConsumerIsolationTests), members); + Assert.Empty(offenders); + } + + [Fact] + public void No_class_outside_the_packaged_umbrella_collection_carries_the_packaging_trait() + { + List offenders = []; + int outside = 0; + foreach (Type type in TestTypes()) + { + if (JoinsPackagedUmbrellaCollection(type)) + { + continue; + } + + outside++; + if (HasPackagingTrait(type) || HasPackagingTraitOnAMethod(type)) + { + offenders.Add(type.FullName!); + } + } + + Assert.True(outside >= 5, + $"Only {outside} test classes live outside the collection; the Debug module must never become empty."); + Assert.Contains(typeof(PackagedUmbrellaTraitTests), TestTypes()); + Assert.Empty(offenders); + } + + private static List TestTypes() + { + List types = []; + foreach (Type type in typeof(PackagedUmbrellaFixture).Assembly.GetTypes()) + { + if (type is { IsClass: true, IsAbstract: false } && HasTestMethod(type)) + { + types.Add(type); + } + } + + return types; + } + + private static bool HasTestMethod(Type type) + { + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly)) + { + foreach (CustomAttributeData attribute in method.GetCustomAttributesData()) + { + if (typeof(FactAttribute).IsAssignableFrom(attribute.AttributeType)) + { + return true; + } + } + } + + return false; + } + + private static bool JoinsPackagedUmbrellaCollection(Type type) + { + foreach (CustomAttributeData attribute in type.GetCustomAttributesData()) + { + if (attribute.AttributeType != typeof(CollectionAttribute) || attribute.ConstructorArguments.Count != 1) + { + continue; + } + + object? argument = attribute.ConstructorArguments[0].Value; + if (Equals(argument, PackagedUmbrellaSuite.Name) || Equals(argument, typeof(PackagedUmbrellaSuite))) + { + return true; + } + } + + return false; + } + + private static bool ReceivesTheFixture(Type type) + { + foreach (ConstructorInfo constructor in type.GetConstructors()) + { + foreach (ParameterInfo parameter in constructor.GetParameters()) + { + if (parameter.ParameterType == typeof(PackagedUmbrellaFixture)) + { + return true; + } + } + } + + return false; + } + + private static bool HasPackagingTrait(MemberInfo member) + { + foreach (CustomAttributeData attribute in member.GetCustomAttributesData()) + { + if (attribute.AttributeType == typeof(TraitAttribute) + && attribute.ConstructorArguments.Count == 2 + && Equals(attribute.ConstructorArguments[0].Value, CategoryTrait) + && Equals(attribute.ConstructorArguments[1].Value, UmbrellaPackage.PackagingCategory)) + { + return true; + } + } + + return false; + } + + private static bool HasPackagingTraitOnAMethod(Type type) + { + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly)) + { + if (HasPackagingTrait(method)) + { + return true; + } + } + + return false; + } +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs index 3bc108fe..a9084ab4 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs @@ -7,6 +7,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// runtime and the package boundary contains precisely the assets that direct consumers require. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class PackedConsumerBindingTests(PackagedUmbrellaFixture fixture) { private static readonly string[] RequiredAotWarningIds = ["IL2026", "IL3050", "IL3058"]; diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PlatformTargetTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PlatformTargetTests.cs index b7efbe18..06defe1e 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/PlatformTargetTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/PlatformTargetTests.cs @@ -8,6 +8,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// architecture for the consumer and an indirect package reference never imports this target. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class PlatformTargetTests(PackagedUmbrellaFixture fixture) { public static TheoryData SupportedPlatformTargets => diff --git a/tests/CheatEngine.SDK.Tests/Packaging/RestoreIsolationTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/RestoreIsolationTests.cs index 13e7d62a..a24d5599 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/RestoreIsolationTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/RestoreIsolationTests.cs @@ -15,6 +15,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// (dotnet restore --packages); this test is the check that would fail if that isolation were ever dropped. /// [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class RestoreIsolationTests(PackagedUmbrellaFixture fixture) { [Fact] diff --git a/tests/CheatEngine.SDK.Tests/Packaging/SupplyChainPackageTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/SupplyChainPackageTests.cs new file mode 100644 index 00000000..f134c846 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/SupplyChainPackageTests.cs @@ -0,0 +1,249 @@ +using System.IO.Compression; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// Supply-chain facts of the packed .nupkg: the embedded SPDX 2.2 SBOM describes this exact package and every +/// file in it, the nuspec and the embedded symbols point at the exact repository commit, the version follows the +/// MinVer line and the assembly version its major, and no repository contract file leaks into the package. Nothing +/// is loaded for execution: assemblies are read with System.Reflection.Metadata. +/// +[Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] +public sealed partial class SupplyChainPackageTests(PackagedUmbrellaFixture fixture) +{ + private const string SbomEntry = "_manifest/spdx_2.2/manifest.spdx.json"; + private const string SbomChecksumEntry = SbomEntry + ".sha256"; + private const string RepositoryUrl = "https://github.com/CheatEngineNet/CheatEngine.SDK"; + private const string NativeBridgeEntry = "build/native/cheatengine-sdk-lua-bridge.dll"; + + /// Kind GUID of the Source Link custom debug information in a Portable PDB. + private static readonly Guid s_sourceLinkKind = new("CC110556-A091-4D38-9FEC-25AB9A351A6A"); + + private static readonly string[] s_repositoryContractFiles = + ["CompatibilitySuppressions.xml", "PublicAPI.Shipped.txt", "PublicAPI.Unshipped.txt", "packages.lock.json"]; + + [Fact] + public void Package_embeds_an_spdx_2_2_sbom_describing_itself() + { + Assert.Contains(SbomEntry, fixture.PackageEntries, StringComparer.Ordinal); + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + byte[] manifest = ReadEntry(archive, SbomEntry); + using JsonDocument sbom = JsonDocument.Parse(manifest); + JsonElement root = sbom.RootElement; + + Assert.Equal("SPDX-2.2", root.GetProperty("spdxVersion").GetString()); + string described = Assert.Single(root.GetProperty("documentDescribes").EnumerateArray()).GetString()!; + JsonElement package = Assert.Single(root.GetProperty("packages").EnumerateArray(), + p => string.Equals(p.GetProperty("SPDXID").GetString(), described, StringComparison.Ordinal)); + Assert.Equal(NuspecMetadata("id"), package.GetProperty("name").GetString()); + Assert.Equal(fixture.PackageVersion, package.GetProperty("versionInfo").GetString()); + Assert.Equal(fixture.PackageVersion, NuspecMetadata("version")); + Assert.StartsWith($"{RepositoryUrl}/", root.GetProperty("documentNamespace").GetString(), StringComparison.Ordinal); + + // The sidecar checksum the SBOM tool writes next to the manifest matches it. + string sidecar = Encoding.ASCII.GetString(ReadEntry(archive, SbomChecksumEntry)).Trim(); + Assert.Equal(Sha256(manifest), sidecar, ignoreCase: true); + } + + [Fact] + public void Sbom_lists_every_shipped_assembly_and_the_native_bridge_with_its_sha256() + { + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + Dictionary sbomSha256 = SbomFileSha256(archive); + + List shipped = [NativeBridgeEntry]; + foreach (string entry in fixture.PackageEntries) + { + if ((entry.StartsWith("lib/net10.0/", StringComparison.Ordinal) + || entry.StartsWith("analyzers/dotnet/cs/", StringComparison.Ordinal)) + && entry.EndsWith(".dll", StringComparison.Ordinal)) + { + shipped.Add(entry); + } + } + + Assert.True(shipped.Count >= 13, $"Expected the 7 libraries, 5 components and the bridge, found {shipped.Count}."); + foreach (string entry in shipped) + { + Assert.True(sbomSha256.TryGetValue(entry, out string? declared), $"The SBOM does not list '{entry}'."); + Assert.Equal(Sha256(ReadEntry(archive, entry)), declared, ignoreCase: true); + } + } + + [Fact] + public void Sbom_file_inventory_equals_the_package_entries() + { + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + SortedSet listed = new(SbomFileSha256(archive).Keys, StringComparer.Ordinal); + SortedSet packed = new(StringComparer.Ordinal); + foreach (string entry in fixture.PackageEntries) + { + if (!entry.StartsWith("_manifest/", StringComparison.Ordinal)) + { + packed.Add(entry); + } + } + + Assert.Equal(packed, listed); + } + + [Fact] + public void Package_carries_no_repository_contract_file() + { + foreach (string entry in fixture.PackageEntries) + { + string fileName = entry[(entry.LastIndexOf('/') + 1)..]; + Assert.DoesNotContain(fileName, s_repositoryContractFiles, StringComparer.OrdinalIgnoreCase); + } + } + + [Fact] + public void Package_version_is_on_the_minver_minimum_line_or_later() + { + XDocument props = XDocument.Load(RepositoryLayout.PathOf("Directory.Build.props")); + string minimum = Assert.Single(props.Descendants("MinVerMinimumMajorMinor")).Value.Trim(); + Version floor = Version.Parse(minimum); + + Version package = CoreVersion(fixture.PackageVersion); + Assert.True(new Version(package.Major, package.Minor) >= floor, + $"Package {fixture.PackageVersion} is below the MinVer line {minimum}."); + } + + [Fact] + public void Embedded_assemblies_carry_the_package_major_as_assembly_version() + { + Version expected = new(CoreVersion(fixture.PackageVersion).Major, 0, 0, 0); + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + List libraries = LibraryEntries(); + Assert.Equal(7, libraries.Count); + foreach (string entry in libraries) + { + using PEReader pe = new(new MemoryStream(ReadEntry(archive, entry))); + Version actual = pe.GetMetadataReader().GetAssemblyDefinition().Version; + Assert.True(expected == actual, $"{entry} has AssemblyVersion {actual}, expected {expected}."); + } + } + + [Fact] + public void Nuspec_names_the_repository_and_the_exact_commit() + { + XElement repository = Repository(); + Assert.Equal("git", (string?) repository.Attribute("type")); + Assert.Equal(RepositoryUrl, (string?) repository.Attribute("url")); + Assert.Matches(CommitSha(), (string?) repository.Attribute("commit") ?? ""); + } + + [Fact] + public void Embedded_libraries_carry_source_link_to_the_repository_commit() + { + string commit = (string?) Repository().Attribute("commit") ?? ""; + string expectedPrefix = $"https://raw.githubusercontent.com/CheatEngineNet/CheatEngine.SDK/{commit}/"; + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + foreach (string entry in LibraryEntries()) + { + using PEReader pe = new(new MemoryStream(ReadEntry(archive, entry))); + DebugDirectoryEntry embedded = Assert.Single(pe.ReadDebugDirectory(), + static d => d.Type == DebugDirectoryEntryType.EmbeddedPortablePdb); + using MetadataReaderProvider pdbProvider = pe.ReadEmbeddedPortablePdbDebugDirectoryData(embedded); + MetadataReader pdb = pdbProvider.GetMetadataReader(); + + string? sourceLink = null; + foreach (CustomDebugInformationHandle handle in pdb.GetCustomDebugInformation(EntityHandle.ModuleDefinition)) + { + CustomDebugInformation information = pdb.GetCustomDebugInformation(handle); + if (pdb.GetGuid(information.Kind) == s_sourceLinkKind) + { + sourceLink = Encoding.UTF8.GetString(pdb.GetBlobBytes(information.Value)); + } + } + + Assert.True(sourceLink is not null, $"{entry} has no Source Link information in its embedded PDB."); + using JsonDocument map = JsonDocument.Parse(sourceLink); + List targets = []; + foreach (JsonProperty document in map.RootElement.GetProperty("documents").EnumerateObject()) + { + targets.Add(document.Value.GetString() ?? ""); + } + + Assert.NotEmpty(targets); + Assert.All(targets, target => Assert.StartsWith(expectedPrefix, target, StringComparison.Ordinal)); + } + } + + private List LibraryEntries() + { + List libraries = []; + foreach (string entry in fixture.PackageEntries) + { + if (entry.StartsWith("lib/net10.0/CheatEngine.SDK", StringComparison.Ordinal) + && entry.EndsWith(".dll", StringComparison.Ordinal)) + { + libraries.Add(entry); + } + } + + return libraries; + } + + private XElement Repository() + { + XNamespace ns = fixture.Nuspec.Root!.GetDefaultNamespace(); + return Assert.Single(fixture.Nuspec.Descendants(ns + "repository")); + } + + private string? NuspecMetadata(string name) + { + XNamespace ns = fixture.Nuspec.Root!.GetDefaultNamespace(); + return fixture.Nuspec.Root.Element(ns + "metadata")?.Element(ns + name)?.Value; + } + + /// The SBOM files array as package entry path (the ./ prefix removed) to SHA-256. + private static Dictionary SbomFileSha256(ZipArchive archive) + { + using JsonDocument sbom = JsonDocument.Parse(ReadEntry(archive, SbomEntry)); + Dictionary files = new(StringComparer.Ordinal); + foreach (JsonElement file in sbom.RootElement.GetProperty("files").EnumerateArray()) + { + string name = file.GetProperty("fileName").GetString()!; + Assert.StartsWith("./", name, StringComparison.Ordinal); + JsonElement sha256 = Assert.Single(file.GetProperty("checksums").EnumerateArray(), + static c => string.Equals(c.GetProperty("algorithm").GetString(), "SHA256", StringComparison.Ordinal)); + files.Add(name[2..], sha256.GetProperty("checksumValue").GetString()!); + } + + return files; + } + + private static byte[] ReadEntry(ZipArchive archive, string entryName) + { + ZipArchiveEntry entry = archive.GetEntry(entryName) + ?? throw new InvalidOperationException($"The package has no '{entryName}' entry."); + using Stream stream = entry.Open(); + using MemoryStream copy = new(); + stream.CopyTo(copy); + return copy.ToArray(); + } + + private static string Sha256(byte[] content) + { + return Convert.ToHexStringLower(SHA256.HashData(content)); + } + + private static Version CoreVersion(string packageVersion) + { + string core = packageVersion.Split('-', '+')[0]; + Assert.True(core.Split('.').Length == 3, $"'{packageVersion}' is not a SemVer version."); + return Version.Parse(core); + } + + [GeneratedRegex("^[0-9a-f]{40}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex CommitSha(); +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/TargetBoundAllocationConsumerTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/TargetBoundAllocationConsumerTests.cs index 80ea7169..d3133bb2 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/TargetBoundAllocationConsumerTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/TargetBoundAllocationConsumerTests.cs @@ -4,6 +4,7 @@ namespace CheatEngine.SDK.Tests.Packaging; /// Verifies the packed SDK exposes the target-bound allocation backend seam to an independent consumer. [Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", UmbrellaPackage.PackagingCategory)] public sealed class TargetBoundAllocationConsumerTests(PackagedUmbrellaFixture fixture) { [Fact] diff --git a/tests/CheatEngine.SDK.Tests/Packaging/UmbrellaPackageSourceTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/UmbrellaPackageSourceTests.cs new file mode 100644 index 00000000..427e3d57 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/UmbrellaPackageSourceTests.cs @@ -0,0 +1,107 @@ +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// The rules that decide which package the packaging fixture tests (). They +/// are a pure function of two values, so these facts run in both CI legs without the fixture and without touching the +/// process environment. The files are empty stand-ins under a private temporary folder: only the path is judged. +/// +public sealed class UmbrellaPackageSourceTests : IDisposable +{ + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("cheatengine-sdk-package-source-"); + + /// + public void Dispose() + { + _directory.Delete(true); + } + + [Theory] + [InlineData("CheatEngine.SDK.2.0.0.nupkg", null)] + [InlineData("CheatEngine.SDK.2.0.0-alpha.0.18.nupkg", "true")] + [InlineData("CheatEngine.SDK.2.1.0-rc.1.nupkg", "false")] + public void Supplied_absolute_umbrella_nupkg_is_selected_as_prebuilt(string fileName, string? ci) + { + string path = CreateFile(fileName); + + UmbrellaPackageSelection selection = UmbrellaPackageSource.Select(path, ci); + + Assert.Equal(UmbrellaPackageOrigin.Prebuilt, selection.Origin); + Assert.Equal(path, selection.PrebuiltPath); + } + + [Fact] + public void Relative_or_missing_supplied_path_is_rejected_and_the_message_names_the_variable() + { + string missing = Path.Combine(_directory.FullName, "CheatEngine.SDK.2.0.0.nupkg"); + + InvalidOperationException relative = Assert.Throws(() => + UmbrellaPackageSource.Select(Path.Combine("artifacts", "nuget", "CheatEngine.SDK.2.0.0.nupkg"), "true")); + InvalidOperationException absent = Assert.Throws(() => + UmbrellaPackageSource.Select(missing, "true")); + + Assert.Contains(UmbrellaPackage.PrebuiltPackageVariable, relative.Message, StringComparison.Ordinal); + Assert.Contains("is not an absolute path", relative.Message, StringComparison.Ordinal); + Assert.Contains(UmbrellaPackage.PrebuiltPackageVariable, absent.Message, StringComparison.Ordinal); + Assert.Contains("names no existing file", absent.Message, StringComparison.Ordinal); + Assert.Contains(missing, absent.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("CheatEngine.SDK.PackageAssetCarrier.1.0.0.nupkg")] + [InlineData("CheatEngine.SDK.2.0.0.snupkg")] + [InlineData("CheatEngine.SDK.Abi.2.0.0.nupkg")] + [InlineData("CheatEngine.Client.0.1.0.nupkg")] + [InlineData("CheatEngine.SDK.nupkg")] + [InlineData("CheatEngine.SDK.2.0.nupkg")] + [InlineData("CheatEngine.SDK.02.0.0.nupkg")] + [InlineData("cheatengine.sdk.2.0.0.nupkg")] + [InlineData("CheatEngine.SDK.2.0.0.zip")] + public void Relay_carrier_or_symbol_package_is_rejected_as_not_the_umbrella(string fileName) + { + string path = CreateFile(fileName); + + InvalidOperationException rejection = + Assert.Throws(() => UmbrellaPackageSource.Select(path, "true")); + + Assert.Contains(UmbrellaPackage.PrebuiltPackageVariable, rejection.Message, StringComparison.Ordinal); + Assert.Contains("is not the umbrella package", rejection.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null, "true")] + [InlineData("", "TRUE")] + [InlineData(" ", "True")] + public void Missing_variable_under_ci_fails_with_an_actionable_message(string? variable, string ci) + { + InvalidOperationException failure = + Assert.Throws(() => UmbrellaPackageSource.Select(variable, ci)); + + Assert.Equal(UmbrellaPackageSource.MissingUnderContinuousIntegrationMessage, failure.Message); + Assert.StartsWith(UmbrellaPackage.PrebuiltPackageVariable + " is not set while CI=true.", failure.Message, + StringComparison.Ordinal); + Assert.Contains("--filter-not-trait \"Category=Packaging\"", failure.Message, StringComparison.Ordinal); + Assert.Contains("tests/CheatEngine.SDK.Tests/README.md#run-the-tests", failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", "false")] + [InlineData(null, "")] + [InlineData(" ", "1")] + public void Missing_variable_outside_ci_selects_a_self_pack(string? variable, string? ci) + { + UmbrellaPackageSelection selection = UmbrellaPackageSource.Select(variable, ci); + + Assert.Equal(UmbrellaPackageOrigin.SelfPacked, selection.Origin); + Assert.Null(selection.PrebuiltPath); + } + + private string CreateFile(string fileName) + { + string path = Path.Combine(_directory.FullName, fileName); + File.WriteAllBytes(path, []); + return path; + } +} diff --git a/tests/CheatEngine.SDK.Tests/README.md b/tests/CheatEngine.SDK.Tests/README.md index 78d40ae0..6da39775 100644 --- a/tests/CheatEngine.SDK.Tests/README.md +++ b/tests/CheatEngine.SDK.Tests/README.md @@ -14,12 +14,22 @@ A project reference proves that the source compiles, not that the installed pack ## How it works -1. One collection fixture (`PackagedUmbrellaFixture`) packs `src/CheatEngine.SDK/CheatEngine.SDK.csproj` in Release - into a temporary local feed. The package id, that project path and the lower-cased id NuGet uses as the extraction - folder name live in one place, `UmbrellaPackage`. +1. One collection fixture (`PackagedUmbrellaFixture`) puts one `CheatEngine.SDK` package into a temporary local feed. + Its origin is decided first, by `UmbrellaPackageSource`: + - `CESDK_PACKAGED_UMBRELLA_NUPKG` set: it must be the absolute path of a `CheatEngine.SDK..nupkg` file. The + fixture copies exactly that file and never packs. This is the CI Release leg: it packs once, passes the packed file, + and the same file is uploaded as `nuget-package`, attested and published, so these tests are evidence about the + shipped file. + - Unset while `CI=true`: the fixture fails at once with an actionable message. A CI run must test the file it ships, + and the Debug leg excludes these tests with `--filter-not-trait "Category=Packaging"`. + - Unset outside CI: the fixture packs `src/CheatEngine.SDK/CheatEngine.SDK.csproj` in Release itself. That package is + built from your working tree; it is evidence about the source (C1/C2), not about a file that was ever shipped. + + The package id, that project path, the variable name, the `Packaging` category and the lower-cased id NuGet uses as + the extraction folder name live in one place, `UmbrellaPackage`. Every class of the `PackagedUmbrellaSuite` + collection, and no other class, carries `[Trait("Category", "Packaging")]`. 2. It restores the consumers below and builds them in Release. Every consumer shares the fixture-local package - directory, - which is isolated from other fixture runs. + directory, which is isolated from other fixture runs. 3. The generated `NuGet.Config` maps the exact `CheatEngine.SDK` package id to the freshly packed local feed, never an earlier NuGet extraction or another package source. Nuget.org remains available for other package ids. The packed Lua runtime consumer then runs against the checked-in offline Lua 5.3 fixture. @@ -63,10 +73,34 @@ repository, so they import none of its build settings. Each generated `NuGet.Con ## Run the tests +Local run: the fixture packs the working tree (it needs nuget.org once, for package validation against 1.0.0). + +```powershell +dotnet test --project tests/CheatEngine.SDK.Tests --fail-skips on +``` + +Fast run without the fixture, as the Debug CI leg does it (no pack, no consumer build): + ```powershell -dotnet test --project tests/CheatEngine.SDK.Tests +dotnet test --project tests/CheatEngine.SDK.Tests --fail-skips on --filter-not-trait "Category=Packaging" ``` +Exact-package run, as the Release CI leg does it: pack once, then hand the fixture that file. Restore nothing from +this package into the machine-wide NuGet folder; the fixture restores into its own isolated folder. + +```powershell +dotnet build CheatEngine.SDK.slnx -c Release +$feed = Join-Path ([IO.Path]::GetTempPath()) 'cheatengine-sdk-exact-feed' +Remove-Item $feed -Recurse -Force -ErrorAction SilentlyContinue +dotnet pack src/CheatEngine.SDK -c Release --no-restore -o $feed +$env:CESDK_PACKAGED_UMBRELLA_NUPKG = (Get-ChildItem $feed -Filter 'CheatEngine.SDK.*.nupkg').FullName +dotnet test --project tests/CheatEngine.SDK.Tests -c Release --no-build --fail-skips on +Remove-Item Env:CESDK_PACKAGED_UMBRELLA_NUPKG +``` + +`PackageProvenanceTests` writes `Consumed sha256= origin=` to the test output, so +the TRX report names the file the facts are about. + ## Promise - `lib/net10.0` holds the six libraries (`Abi`, `Annotations`, `Engine`, `Hosting`, `Lua`, `Lua.Interop`) with their XML @@ -103,9 +137,51 @@ dotnet test --project tests/CheatEngine.SDK.Tests `CESDK9101` by the packaged build target (`PlatformTargetTests`). - The direct consumer remains deployable after clean/rebuild: its output folder contains the plugin, all SDK runtime assemblies, `.deps.json`, `.runtimeconfig.json` and the native bridge (`DeploymentLayoutTests`). +- A clean consumer takes nothing from the development tree (qualification scenario Q40, C1/C2 evidence only): in the + build and publish folders of the default consumer, `.deps.json` resolves `CheatEngine.SDK` as a `package` library at + `cheatengine.sdk/` whose `sha512` is the hash of the package under test, and no `CheatEngine.SDK*` library + is `project`-typed; neither `.deps.json` nor `.runtimeconfig.json` names the repository root (in any separator form) + or `additionalProbingPaths`, and no `*.runtimeconfig.dev.json` exists; every `lib/net10.0` assembly of the package is + deployed byte for byte; no `lua*.dll` is deployed (`CleanConsumerIsolationTests`). Each rule is shown to fail on the + leak it exists for with synthetic manifests (`ConsumerManifestRuleTests`). The relay carrier maps `CheatEngine.SDK` to + the fixture feed only, like every consumer, so a version already on nuget.org can never be restored in its place. +- A direct consumer's build and publish bridges are byte-identical to the package's `build/native` entry, whatever the + package origin (`Direct_consumer_bridges_are_byte_identical_to_the_packed_build_native_entry`). - The checked-in C11 Lua protection bridge is parsed as PE/COFF without loading it: it is PE32+ AMD64, exports exactly four symbols, imports only its reviewed CRT/Kernel32 contract, has no delay-load table and cannot acquire a Lua - module. Its build and publish copies are SHA-256-identical to the audited source asset (`NativeBridgePeAuditTests` and - `NativeBridgePackagingAuditTests`; the detailed contract is - `native/cheatengine-sdk-lua-bridge/AUDIT.md`). + module. Its build and publish copies are SHA-256-identical to the checked-in source asset (`NativeBridgePeAuditTests` + and `NativeBridgePackagingAuditTests`; the bridge contract is described in the + [bridge README](../../native/cheatengine-sdk-lua-bridge/README.md)). Its `bridge-audit-manifest.json` records the + committed bridge, and `BridgeAuditManifestTests` compare it with the committed blob read through `git cat-file`, + never with the working-tree DLL that CI replaces with its own build: source hashes and fingerprint, DLL SHA-256, PE + facts, embedded fingerprint, pinned xmake version and exact-case asset path. They carry no trait, so both CI legs run + them, and a failure prints the complete expected manifest. - Consumers build against the package packed by this run, never an earlier extraction (`RestoreIsolationTests`). +- With `CESDK_PACKAGED_UMBRELLA_NUPKG` set, every packaging fact is about exactly that file: the feed copy is + byte-identical to it and the fixture never packs; without it, only a run outside CI may pack, and a CI run fails + before any work (`PackageProvenanceTests`). The selection rules reject a relative or missing path, the relay carrier + and symbol packages, and a missing variable under `CI=true` (`UmbrellaPackageSourceTests`). Every class sharing the + fixture carries the `Packaging` category and no other class does, so the Debug filter never packs and never empties + the module (`PackagedUmbrellaTraitTests`). +- The packed `.nupkg` embeds an SPDX 2.2 SBOM at `_manifest/spdx_2.2/manifest.spdx.json` that describes this package id + and version and lists every other entry of the package with its SHA-256, including the seven libraries, the five + Roslyn components and the native bridge (`Package_embeds_an_spdx_2_2_sbom_describing_itself`, + `Sbom_lists_every_shipped_assembly_and_the_native_bridge_with_its_sha256`, + `Sbom_file_inventory_equals_the_package_entries`). +- The nuspec names the repository and the exact 40-hex commit, and the embedded PDB of every `lib/net10.0` assembly maps + its sources to that commit through Source Link (`Nuspec_names_the_repository_and_the_exact_commit`, + `Embedded_libraries_carry_source_link_to_the_repository_commit`). +- The package version is on the `MinVerMinimumMajorMinor` line or later, every `lib/net10.0` assembly carries + `.0.0.0` as its assembly version, and no repository contract file (`CompatibilitySuppressions.xml`, PublicAPI + files, lock files) is packed (`Package_version_is_on_the_minver_minimum_line_or_later`, + `Embedded_assemblies_carry_the_package_major_as_assembly_version`, `Package_carries_no_repository_contract_file`). + The pack itself also runs package validation against the published 1.0.0 baseline, so it needs nuget.org once. +- The live plugin folders the solution builds (`CheatEngine.SDK.LivePlugin` and the two coexistence plugins, in the + configuration of the test run) are deployable on their own: every file their `.deps.json` promises is present, the + bootstrap assemblies are listed, no unlisted SDK assembly is deployed, the runtime policy is `net10.0` on + `Microsoft.NETCore.App`, no Lua runtime is copied, and the bridge is byte-identical to the workspace bridge + (`Live_plugin_output_closure_matches_the_workspace_bridge`). These folders are built from source, so this is C1 + evidence only, and they need a solution build first. They do not contain the `CESDK.CESDK` entry point today: the + entry-point generator runs only with the package's `CheatEngineSdkGenerateEntryPoint` build property, which these + project-reference fixtures never set; a shrink-only pending list records that gap until the fixtures are fixed + (`Live_plugin_entry_point_is_present_unless_listed_as_a_pending_fix`). diff --git a/tests/CheatEngine.SDK.Tests/packages.lock.json b/tests/CheatEngine.SDK.Tests/packages.lock.json new file mode 100644 index 00000000..fe279b25 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/packages.lock.json @@ -0,0 +1,211 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.283, )", + "resolved": "3.0.283", + "contentHash": "8UKUK7wiL+DMA/c8mGPK9J24wLKDZ5DHKDv38GlyeJNkza9Q1yQziudEt1Op7/+0CL1vVi41PhACj1e6948f7w==" + }, + "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.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.GitHubActionsReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "YxEopj6xrG5Lk8OkRZri3E89DUHTA3ux0pAcMy74izHtUZtGCBgQuTm/EmVFpKQvrZtRNMMXUMht3GW0V4mXZg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.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)" + } + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + }, + "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.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "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.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, + "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.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]" + } + } + } + } +} \ No newline at end of file diff --git a/tests/native-abi-fixture/README.md b/tests/native-abi-fixture/README.md index 2be201e3..4b85059e 100644 --- a/tests/native-abi-fixture/README.md +++ b/tests/native-abi-fixture/README.md @@ -1,31 +1,41 @@ # CE 7.7 native ABI fixture This directory contains a small **Windows x64 C++ fixture**, not a Cheat Engine plugin and not a replacement SDK. It -compiles a deliberately local transcription of a minimal CE classic-plugin-header subset, builds a DLL with the three -classic plugin exports, loads that DLL back through `GetProcAddress`, and emits 104 stable `key=value` ABI facts. +compiles a deliberately local transcription of the classic- and managed-plugin records, builds a DLL with the three +classic plugin exports, loads that DLL back through `GetProcAddress`, and emits 318 stable `key=value` ABI facts +(`fixture.schema=3`). ## Evidence boundary -The source is deliberately pinned to the upstream revision -[ -`ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37`](https://github.com/cheat-engine/cheat-engine/tree/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37): -[ -`Cheat Engine/plugin/cepluginsdk.h`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/plugin/cepluginsdk.h). -`ce77_plugin_abi_contract.h` transcribes only the measured declaration subset, with source line ranges in its header; -it does not vendor the upstream header or any Cheat Engine binary. +The sources are deliberately pinned to the upstream revision +[`ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37`](https://github.com/cheat-engine/cheat-engine/tree/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37). +`ce77_plugin_abi_contract.h` transcribes only the measured declarations, with the C# field names of +`CheatEngine.SDK.Abi`. It records names, types, line ranges and SHA-256 only; it does not vendor any upstream file and +no Cheat Engine binary or installation is read. + +| Source (under `Cheat Engine/`) | SHA-256 | Transcribed declarations | +|---|---|---| +| `plugin/cepluginsdk.h` | `b6500df1e94d7bb011b38e173b2603197b7a1f304496d751ede82e57e36e532f` | lines 15-160, 163-180 and 271-456: `PluginVersion`, `PLUGINTYPE0_RECORD`, the nine init records, `REGISTERMODIFICATIONINFO`, the 144-byte physical prefix of `ExportedFunctions`, the three exports | +| `plugin.pas` (host authority) | `358f51a39ad14d00ecba3c9137f440152d4ab85f1d2498068fa81fca906d09db` | `TPluginDotNetInitResult` (packed record, lines 29-36) as `managed_plugin_init_record`; `TExportedFunctionsDotNetV1` (lines 38-45) as `managed_exported_functions`; `TPlugin0_SelectedRecord` (lines 726-735) as `host_plugin0_selected_record` | +| `plugin/cepluginsdk.pas` (kit mirror, negative oracle) | `cda5269f441120e5a3bff2f87e289cd71de9158ca2a619c7d0a734eb98ee6052` | `TPlugin0_SelectedRecord` (lines 161-170, `address: dword`) as `pascal_dword_mirror_selected_record`; `TSelectedRecord` (lines 147-156, `ispointer: boolean`) as `pascal_boolean_mirror_selected_record`; natural alignment deduced from `{$MODE Delphi}` (line 3) | This fixture proves the MSVC x64 shape of the checked-in transcription, not the behavior of a live CE host and not the -contents of an installed `cepluginsdk.h`. In particular, the popup callback retains the header's four-byte `BOOL* show` -declaration. The planned CE 7.7 live canary is still required before a Pascal-side one-byte representation can replace -it in a runtime contract. +contents of an installed file. The two Pascal mirrors are transcribed only so that the managed oracle can prove the +SDK record differs from them (`SelectedRecordOracleTests`); they are known-wrong for x64. The popup callback retains +the header's four-byte `BOOL* show` declaration; a CE 7.7 live canary is still required before a Pascal-side one-byte +representation can replace it in a runtime contract. ## What it checks - x64 pointer, `BOOL`, `UINT_PTR` and enum widths; -- `sizeof`, `offsetof`, and `alignof` facts for `PluginVersion`, `PLUGINTYPE0_RECORD`, the nine init records, - `REGISTERMODIFICATIONINFO`, and the physically contiguous prefix of `ExportedFunctions`; -- a CI-only comparison of those emitted layout facts with the `sizeof`, address-of offset, and alignment measurements - from the compiled managed ABI records; +- `sizeof` and `alignof` of every transcribed record, and `offsetof` and field width (`fieldsize..`) of + **every** transcribed field; +- the packed managed bootstrap record: 36 bytes with byte alignment, and a by-value copy into a buffer at an odd address + leaves every guard byte intact (`sentinel.managed_plugin_init_record.tail_guard=passed`); a negative self-test copies + the naturally aligned 40-byte mirror the same way and the probe fails unless that overrun is detected; +- a CI-only comparison of those facts with the managed measurements (`sizeof`, alignment, and every field's offset and + width) in `tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs`, and of the host and + mirror selection records with the SDK record in `tests/CheatEngine.SDK.Abi.Tests/Native/SelectedRecordOracleTests.cs`; - explicit `__stdcall` callback and classic export signatures at compile time; - exactly `CEPlugin_GetVersion`, `CEPlugin_InitializePlugin`, and `CEPlugin_DisablePlugin` exported from the fixture DLL; @@ -40,7 +50,8 @@ references, Lua, CE object ownership, buffer capacities, or any live-process beh ## Run it The script imports an x64 Visual Studio C++ developer environment when necessary. It needs the installed Visual Studio -x64 C++ build tools, but reads no Cheat Engine installation. +x64 C++ build tools, but reads no Cheat Engine installation. A relative `-OutputDirectory` is resolved against the +PowerShell location. ```powershell ./tests/native-abi-fixture/build.ps1 -OutputDirectory ./artifacts/native-abi-fixture @@ -49,23 +60,28 @@ x64 C++ build tools, but reads no Cheat Engine installation. The script writes only the selected output directory and prints lines such as: ```text -fixture.schema=2 +fixture.schema=3 source.upstream_commit=ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37 -source.contract=transcribed-pinned-header-subset +source.contract=transcribed-pinned-header-and-host-pascal-subset architecture=win-x64 sizeof.plugin_version=16 alignof.plugin_version=8 offsetof.plugin_type0_record.ValueType=40 -sizeof.register_modification_info=264 -sizeof.exported_functions_prefix=144 +fieldsize.plugin_type0_record.IsPointer=4 +sizeof.managed_plugin_init_record=36 +alignof.managed_plugin_init_record=1 +fieldsize.pascal_boolean_mirror_selected_record.IsPointer=1 calling_convention.classic_callbacks=__stdcall export.0=CEPlugin_GetVersion -sentinel.plugin_version.outer_guard=passed +sentinel.managed_plugin_init_record.tail_guard=passed ``` -The script writes `ce77-native-abi-facts.txt` and validates every emitted key and value with -`Validate-Facts.ps1`. CI invokes this fixture in the native job and publishes the facts as the -`classic-abi-fixture-facts` artifact; the Debug build-test job passes that path to the compiled managed ABI test with -`CE77_NATIVE_ABI_REQUIRED=true`; that required mode fails the comparison gate if the path is absent. Ordinary -ABI tests remain pure .NET tests in `tests/CheatEngine.SDK.Abi.Tests`: their local opt-out does not require a C++ -compiler or the facts file. +The script writes `ce77-native-abi-facts.txt` and validates every emitted key and value with `Validate-Facts.ps1`, an +exact-set validator: a missing, extra or different fact fails. CI invokes this fixture in the native job and publishes +the facts as the `classic-abi-fixture-facts` artifact; the Debug build-test job passes that path to the compiled managed +ABI tests with `CE77_NATIVE_ABI_REQUIRED=true`; that required mode fails the comparison gate if the path is absent. +Ordinary ABI tests remain pure .NET tests in `tests/CheatEngine.SDK.Abi.Tests`: their local opt-out does not require a +C++ compiler or the facts file. + +Changing an ABI record means changing `ce77_plugin_abi_contract.h`, `ce77_native_abi_probe.cpp`, `Validate-Facts.ps1` +and the managed expectations (`tests/CheatEngine.SDK.Abi.Tests/Support/FieldLayoutExpectations.cs`) in the same commit. diff --git a/tests/native-abi-fixture/Validate-Facts.ps1 b/tests/native-abi-fixture/Validate-Facts.ps1 index 455408d4..21107f2f 100644 --- a/tests/native-abi-fixture/Validate-Facts.ps1 +++ b/tests/native-abi-fixture/Validate-Facts.ps1 @@ -23,11 +23,17 @@ foreach ($line in Get-Content -LiteralPath $FactsPath) { $actual[$key] = $value } +$invariant = [Globalization.CultureInfo]::InvariantCulture + +# Schema 3: every transcribed record, from the pinned C header and the pinned host Pascal types, with every field's +# offset and width. The set is exact: a missing, extra or different fact fails. $expected = @{ - 'fixture.schema' = '2' + 'fixture.schema' = '3' 'source.upstream_commit' = 'ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37' 'source.path' = 'Cheat Engine/plugin/cepluginsdk.h' - 'source.contract' = 'transcribed-pinned-header-subset' + 'source.host_path' = 'Cheat Engine/plugin.pas' + 'source.mirror_path' = 'Cheat Engine/plugin/cepluginsdk.pas' + 'source.contract' = 'transcribed-pinned-header-and-host-pascal-subset' 'architecture' = 'win-x64' 'sizeof.pointer' = '8' 'sizeof.bool' = '4' @@ -49,87 +55,65 @@ $expected = @{ 'sentinel.plugin_version.outer_guard' = 'passed' 'sentinel.plugin_version.padding' = 'passed' 'sentinel.exports.return_values' = 'passed' + 'sentinel.managed_plugin_init_record.tail_guard' = 'passed' } -$layouts = @{ - 'plugin_version' = 16 - 'plugin_type0_record' = 48 - 'plugin_type0_init' = 16 - 'plugin_type1_init' = 24 - 'plugin_type2_init' = 8 - 'plugin_type3_init' = 8 - 'plugin_type4_init' = 8 - 'plugin_type5_init' = 24 - 'plugin_type6_init' = 32 - 'plugin_type7_init' = 8 - 'plugin_type8_init' = 8 - 'register_modification_info' = 264 - 'exported_functions_prefix' = 144 -} - -foreach ($layout in $layouts.GetEnumerator()) { - $expected["sizeof.$($layout.Key)"] = $layout.Value.ToString([Globalization.CultureInfo]::InvariantCulture) - $expected["alignof.$($layout.Key)"] = '8' -} +# Record => size, alignment, and 'Field=offset/width' entries in declaration order. +$selectedRecordFields = @( + 'InterpretedAddress=0/8', 'Address=8/8', 'IsPointer=16/4', 'CountOffsets=20/4', 'Offsets=24/8', + 'Description=32/8', 'ValueType=40/1', 'Size=41/1' +) +$registerFields = @('Address=0/8') +$registerNames = @('Eax', 'Ebx', 'Ecx', 'Edx', 'Esi', 'Edi', 'Ebp', 'Esp', 'Eip', 'R8', 'R9', 'R10', 'R11', 'R12', + 'R13', 'R14', 'R15') +$flagNames = @('Cf', 'Pf', 'Af', 'Zf', 'Sf', 'Of') +$offset = 8 +foreach ($name in $registerNames + $flagNames) { $registerFields += "Change$name=$offset/4"; $offset += 4 } +$offset = 104 +foreach ($name in $registerNames) { $registerFields += "New$name=$offset/8"; $offset += 8 } +foreach ($name in $flagNames) { $registerFields += "New$name=$offset/4"; $offset += 4 } +$prefixNames = @('SizeOfExportedFunctions', 'ShowMessage', 'RegisterFunction', 'UnregisterFunction', 'OpenedProcessId', + 'OpenedProcessHandle', 'GetMainWindowHandle', 'AutoAssemble', 'Assembler', 'Disassembler', + 'ChangeRegistersAtAddress', 'InjectDll', 'FreezeMemory', 'UnfreezeMemory', 'FixMemory', 'ProcessList', + 'ReloadSettings', 'GetAddressFromPointer') +$prefixFields = @('SizeOfExportedFunctions=0/4') +for ($slot = 1; $slot -lt $prefixNames.Count; $slot++) { $prefixFields += "$($prefixNames[$slot])=$(8 * $slot)/8" } -$offsets = @{ - 'plugin_version.Version' = 0 - 'plugin_version.PluginName' = 8 - 'plugin_type0_record.InterpretedAddress' = 0 - 'plugin_type0_record.Address' = 8 - 'plugin_type0_record.IsPointer' = 16 - 'plugin_type0_record.CountOffsets' = 20 - 'plugin_type0_record.Offsets' = 24 - 'plugin_type0_record.Description' = 32 - 'plugin_type0_record.ValueType' = 40 - 'plugin_type0_record.Size' = 41 - 'plugin_type0_init.Name' = 0 - 'plugin_type0_init.Callback' = 8 - 'plugin_type1_init.Name' = 0 - 'plugin_type1_init.Callback' = 8 - 'plugin_type1_init.Shortcut' = 16 - 'plugin_type2_init.Callback' = 0 - 'plugin_type3_init.Callback' = 0 - 'plugin_type4_init.Callback' = 0 - 'plugin_type5_init.Name' = 0 - 'plugin_type5_init.Callback' = 8 - 'plugin_type5_init.Shortcut' = 16 - 'plugin_type6_init.Name' = 0 - 'plugin_type6_init.Callback' = 8 - 'plugin_type6_init.CallbackOnPopup' = 16 - 'plugin_type6_init.Shortcut' = 24 - 'plugin_type7_init.Callback' = 0 - 'plugin_type8_init.Callback' = 0 - 'register_modification_info.Address' = 0 - 'register_modification_info.ChangeEax' = 8 - 'register_modification_info.ChangeR15' = 72 - 'register_modification_info.ChangeOf' = 96 - 'register_modification_info.NewEax' = 104 - 'register_modification_info.NewR15' = 232 - 'register_modification_info.NewCf' = 240 - 'register_modification_info.NewOf' = 260 - 'exported_functions_prefix.SizeOfExportedFunctions' = 0 - 'exported_functions_prefix.ShowMessage' = 8 - 'exported_functions_prefix.RegisterFunction' = 16 - 'exported_functions_prefix.UnregisterFunction' = 24 - 'exported_functions_prefix.OpenedProcessId' = 32 - 'exported_functions_prefix.OpenedProcessHandle' = 40 - 'exported_functions_prefix.GetMainWindowHandle' = 48 - 'exported_functions_prefix.AutoAssemble' = 56 - 'exported_functions_prefix.Assembler' = 64 - 'exported_functions_prefix.Disassembler' = 72 - 'exported_functions_prefix.ChangeRegistersAtAddress' = 80 - 'exported_functions_prefix.InjectDll' = 88 - 'exported_functions_prefix.FreezeMemory' = 96 - 'exported_functions_prefix.UnfreezeMemory' = 104 - 'exported_functions_prefix.FixMemory' = 112 - 'exported_functions_prefix.ProcessList' = 120 - 'exported_functions_prefix.ReloadSettings' = 128 - 'exported_functions_prefix.GetAddressFromPointer' = 136 +$records = [ordered]@{ + 'plugin_version' = @(16, 8, @('Version=0/4', 'PluginName=8/8')) + 'plugin_type0_record' = @(48, 8, $selectedRecordFields) + 'plugin_type0_init' = @(16, 8, @('Name=0/8', 'Callback=8/8')) + 'plugin_type1_init' = @(24, 8, @('Name=0/8', 'Callback=8/8', 'Shortcut=16/8')) + 'plugin_type2_init' = @(8, 8, @('Callback=0/8')) + 'plugin_type3_init' = @(8, 8, @('Callback=0/8')) + 'plugin_type4_init' = @(8, 8, @('Callback=0/8')) + 'plugin_type5_init' = @(24, 8, @('Name=0/8', 'Callback=8/8', 'Shortcut=16/8')) + 'plugin_type6_init' = @(32, 8, @('Name=0/8', 'Callback=8/8', 'CallbackOnPopup=16/8', 'Shortcut=24/8')) + 'plugin_type7_init' = @(8, 8, @('Callback=0/8')) + 'plugin_type8_init' = @(8, 8, @('Callback=0/8')) + 'register_modification_info' = @(264, 8, $registerFields) + 'exported_functions_prefix' = @(144, 8, $prefixFields) + 'managed_plugin_init_record' = @(36, 1, @('Name=0/8', 'GetVersion=8/8', 'EnablePlugin=16/8', 'DisablePlugin=24/8', + 'Version=32/4')) + 'managed_exported_functions' = @(48, 8, @('SizeOfExportedFunctions=0/4', 'GetLuaState=8/8', 'LuaRegister=16/8', + 'LuaPushClassInstance=24/8', 'ProcessMessages=32/8', 'CheckSynchronize=40/8')) + 'host_plugin0_selected_record' = @(48, 8, $selectedRecordFields) + 'pascal_dword_mirror_selected_record' = @(48, 8, @('InterpretedAddress=0/8', 'Address=8/4', 'IsPointer=12/4', + 'CountOffsets=16/4', 'Offsets=24/8', 'Description=32/8', 'ValueType=40/1', 'Size=41/1')) + 'pascal_boolean_mirror_selected_record' = @(48, 8, @('InterpretedAddress=0/8', 'Address=8/8', 'IsPointer=16/1', + 'CountOffsets=20/4', 'Offsets=24/8', 'Description=32/8', 'ValueType=40/1', 'Size=41/1')) } -foreach ($offset in $offsets.GetEnumerator()) { - $expected["offsetof.$($offset.Key)"] = $offset.Value.ToString([Globalization.CultureInfo]::InvariantCulture) +foreach ($record in $records.GetEnumerator()) { + $size, $alignment, $fields = $record.Value + $expected["sizeof.$($record.Key)"] = $size.ToString($invariant) + $expected["alignof.$($record.Key)"] = $alignment.ToString($invariant) + foreach ($field in $fields) { + $name, $layout = $field.Split('=') + $fieldOffset, $fieldWidth = $layout.Split('/') + $expected["offsetof.$($record.Key).$name"] = $fieldOffset + $expected["fieldsize.$($record.Key).$name"] = $fieldWidth + } } foreach ($key in $expected.Keys) { @@ -143,4 +127,4 @@ foreach ($key in $actual.Keys) { if (-not $expected.ContainsKey($key)) { throw "The ABI fixture emitted unexpected fact '$key'." } } -Write-Host "Validated $($expected.Count) ABI fixture facts from '$FactsPath'." +Write-Host "Validated $($expected.Count) ABI fixture facts (schema 3) from '$FactsPath'." diff --git a/tests/native-abi-fixture/build.ps1 b/tests/native-abi-fixture/build.ps1 index fdd38e13..1cf1972e 100644 --- a/tests/native-abi-fixture/build.ps1 +++ b/tests/native-abi-fixture/build.ps1 @@ -52,7 +52,9 @@ if ($null -eq $compiler -or $env:VSCMD_ARG_TGT_ARCH -ne 'x64') { } $fixtureDirectory = Split-Path -Parent $PSCommandPath -$resolvedOutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) +# Resolve against the PowerShell location, not the process working directory: Set-Location does not move the latter, +# so [System.IO.Path]::GetFullPath would write a relative -OutputDirectory into whatever folder pwsh started in. +$resolvedOutputDirectory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory) [System.IO.Directory]::CreateDirectory($resolvedOutputDirectory) | Out-Null $common = @('/nologo', '/std:c++20', '/W4', '/WX', '/EHsc', '/O2', '/I', $fixtureDirectory) diff --git a/tests/native-abi-fixture/ce77_native_abi_probe.cpp b/tests/native-abi-fixture/ce77_native_abi_probe.cpp index 12aae805..3d9953f1 100644 --- a/tests/native-abi-fixture/ce77_native_abi_probe.cpp +++ b/tests/native-abi-fixture/ce77_native_abi_probe.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT -// Emits the classic CE plugin ABI facts after checking the fixture's three exports. +// Emits the classic and managed CE plugin ABI facts after checking the fixture's three +// exports and the guarded by-value write of the packed managed bootstrap record. #include "ce77_plugin_abi_contract.h" @@ -83,18 +84,74 @@ bool ValidateGetVersion(CE77GetVersionExport getVersion) return getVersionSucceeded && sentinelsAreIntact && paddingWasNotWritten; } +// The natural-alignment mirror of the packed host bootstrap record, as the +// historical C# template declares it: 40 bytes, four more than the host's 36. +struct NaturallyAlignedInitRecordMirror +{ + char* Name; + void* GetVersion; + void* EnablePlugin; + void* DisablePlugin; + uint32_t Version; +}; + +static_assert(sizeof(NaturallyAlignedInitRecordMirror) == 40, "The natural mirror is the 40-byte defect."); + +// Copies writeSize bytes of a filled record into a guarded buffer at an odd +// address, exactly like a host that copies the record by value, and reports +// whether every guard byte around the 36-byte host slot kept its value. The +// buffer is 8-byte aligned, so the destination one byte in is always odd. +bool CopyLeavesInitRecordGuardsIntact(const void* source, size_t writeSize) +{ + constexpr size_t LeadingGuard = 1; + constexpr size_t TrailingGuard = 8; + alignas(8) std::array buffer{}; + buffer.fill(Guard); + unsigned char* destination = buffer.data() + LeadingGuard; + std::memcpy(destination, source, writeSize); + return IsGuarded(buffer.data(), LeadingGuard) && + IsGuarded(destination + sizeof(CE77ManagedPluginInitRecord), TrailingGuard); +} + +// Positive: the packed 36-byte record copied by value leaves every guard intact. +// Negative self-test: the 40-byte natural mirror copied the same way must be +// detected, otherwise the guard check itself is broken and the probe fails. +bool ValidatePackedInitRecordWrite() +{ + CE77ManagedPluginInitRecord packed{}; + packed.Name = ExpectedPluginName; + packed.GetVersion = reinterpret_cast(static_cast(0x0101010101010101ull)); + packed.EnablePlugin = reinterpret_cast(static_cast(0x0202020202020202ull)); + packed.DisablePlugin = reinterpret_cast(static_cast(0x0303030303030303ull)); + packed.Version = 6; + + NaturallyAlignedInitRecordMirror natural{}; + natural.Name = packed.Name; + natural.GetVersion = packed.GetVersion; + natural.EnablePlugin = packed.EnablePlugin; + natural.DisablePlugin = packed.DisablePlugin; + natural.Version = packed.Version; + + bool packedWriteIsContained = CopyLeavesInitRecordGuardsIntact(&packed, sizeof(packed)); + bool naturalMirrorOverrunIsDetected = !CopyLeavesInitRecordGuardsIntact(&natural, sizeof(natural)); + return packedWriteIsContained && naturalMirrorOverrunIsDetected; +} + void EmitFacts() { #define EMIT_LAYOUT(key, type) \ std::printf("sizeof." key "=%zu\n", sizeof(type)); \ std::printf("alignof." key "=%zu\n", alignof(type)) -#define EMIT_OFFSET(key, type, field) \ - std::printf("offsetof." key "." #field "=%zu\n", offsetof(type, field)) +#define EMIT_FIELD(key, type, field) \ + std::printf("offsetof." key "." #field "=%zu\n", offsetof(type, field)); \ + std::printf("fieldsize." key "." #field "=%zu\n", sizeof(((type*)0)->field)) std::printf("fixture.schema=%d\n", CE77_ABI_FIXTURE_SCHEMA_VERSION); std::printf("source.upstream_commit=%s\n", CE77_UPSTREAM_COMMIT); std::printf("source.path=%s\n", CE77_CEPLUGINSDK_PATH); - std::printf("source.contract=transcribed-pinned-header-subset\n"); + std::printf("source.host_path=%s\n", CE77_PLUGIN_PAS_PATH); + std::printf("source.mirror_path=%s\n", CE77_CEPLUGINSDK_PAS_PATH); + std::printf("source.contract=transcribed-pinned-header-and-host-pascal-subset\n"); std::printf("architecture=win-x64\n"); std::printf("sizeof.pointer=%zu\n", sizeof(void*)); std::printf("sizeof.bool=%zu\n", sizeof(CE77_BOOL)); @@ -103,76 +160,159 @@ void EmitFacts() std::printf("sizeof.auto_assembler_phase=%zu\n", sizeof(CE77AutoAssemblerPhase)); EMIT_LAYOUT("plugin_version", CE77PluginVersion); - EMIT_OFFSET("plugin_version", CE77PluginVersion, Version); - EMIT_OFFSET("plugin_version", CE77PluginVersion, PluginName); + EMIT_FIELD("plugin_version", CE77PluginVersion, Version); + EMIT_FIELD("plugin_version", CE77PluginVersion, PluginName); EMIT_LAYOUT("plugin_type0_record", CE77PluginType0Record); - EMIT_OFFSET("plugin_type0_record", CE77PluginType0Record, InterpretedAddress); - EMIT_OFFSET("plugin_type0_record", CE77PluginType0Record, Address); - EMIT_OFFSET("plugin_type0_record", CE77PluginType0Record, IsPointer); - EMIT_OFFSET("plugin_type0_record", CE77PluginType0Record, CountOffsets); - EMIT_OFFSET("plugin_type0_record", CE77PluginType0Record, Offsets); - EMIT_OFFSET("plugin_type0_record", CE77PluginType0Record, Description); - EMIT_OFFSET("plugin_type0_record", CE77PluginType0Record, ValueType); - EMIT_OFFSET("plugin_type0_record", CE77PluginType0Record, Size); + EMIT_FIELD("plugin_type0_record", CE77PluginType0Record, InterpretedAddress); + EMIT_FIELD("plugin_type0_record", CE77PluginType0Record, Address); + EMIT_FIELD("plugin_type0_record", CE77PluginType0Record, IsPointer); + EMIT_FIELD("plugin_type0_record", CE77PluginType0Record, CountOffsets); + EMIT_FIELD("plugin_type0_record", CE77PluginType0Record, Offsets); + EMIT_FIELD("plugin_type0_record", CE77PluginType0Record, Description); + EMIT_FIELD("plugin_type0_record", CE77PluginType0Record, ValueType); + EMIT_FIELD("plugin_type0_record", CE77PluginType0Record, Size); EMIT_LAYOUT("plugin_type0_init", CE77PluginType0Init); - EMIT_OFFSET("plugin_type0_init", CE77PluginType0Init, Name); - EMIT_OFFSET("plugin_type0_init", CE77PluginType0Init, Callback); + EMIT_FIELD("plugin_type0_init", CE77PluginType0Init, Name); + EMIT_FIELD("plugin_type0_init", CE77PluginType0Init, Callback); EMIT_LAYOUT("plugin_type1_init", CE77PluginType1Init); - EMIT_OFFSET("plugin_type1_init", CE77PluginType1Init, Name); - EMIT_OFFSET("plugin_type1_init", CE77PluginType1Init, Callback); - EMIT_OFFSET("plugin_type1_init", CE77PluginType1Init, Shortcut); + EMIT_FIELD("plugin_type1_init", CE77PluginType1Init, Name); + EMIT_FIELD("plugin_type1_init", CE77PluginType1Init, Callback); + EMIT_FIELD("plugin_type1_init", CE77PluginType1Init, Shortcut); EMIT_LAYOUT("plugin_type2_init", CE77PluginType2Init); - EMIT_OFFSET("plugin_type2_init", CE77PluginType2Init, Callback); + EMIT_FIELD("plugin_type2_init", CE77PluginType2Init, Callback); EMIT_LAYOUT("plugin_type3_init", CE77PluginType3Init); - EMIT_OFFSET("plugin_type3_init", CE77PluginType3Init, Callback); + EMIT_FIELD("plugin_type3_init", CE77PluginType3Init, Callback); EMIT_LAYOUT("plugin_type4_init", CE77PluginType4Init); - EMIT_OFFSET("plugin_type4_init", CE77PluginType4Init, Callback); + EMIT_FIELD("plugin_type4_init", CE77PluginType4Init, Callback); EMIT_LAYOUT("plugin_type5_init", CE77PluginType5Init); - EMIT_OFFSET("plugin_type5_init", CE77PluginType5Init, Name); - EMIT_OFFSET("plugin_type5_init", CE77PluginType5Init, Callback); - EMIT_OFFSET("plugin_type5_init", CE77PluginType5Init, Shortcut); + EMIT_FIELD("plugin_type5_init", CE77PluginType5Init, Name); + EMIT_FIELD("plugin_type5_init", CE77PluginType5Init, Callback); + EMIT_FIELD("plugin_type5_init", CE77PluginType5Init, Shortcut); EMIT_LAYOUT("plugin_type6_init", CE77PluginType6Init); - EMIT_OFFSET("plugin_type6_init", CE77PluginType6Init, Name); - EMIT_OFFSET("plugin_type6_init", CE77PluginType6Init, Callback); - EMIT_OFFSET("plugin_type6_init", CE77PluginType6Init, CallbackOnPopup); - EMIT_OFFSET("plugin_type6_init", CE77PluginType6Init, Shortcut); + EMIT_FIELD("plugin_type6_init", CE77PluginType6Init, Name); + EMIT_FIELD("plugin_type6_init", CE77PluginType6Init, Callback); + EMIT_FIELD("plugin_type6_init", CE77PluginType6Init, CallbackOnPopup); + EMIT_FIELD("plugin_type6_init", CE77PluginType6Init, Shortcut); EMIT_LAYOUT("plugin_type7_init", CE77PluginType7Init); - EMIT_OFFSET("plugin_type7_init", CE77PluginType7Init, Callback); + EMIT_FIELD("plugin_type7_init", CE77PluginType7Init, Callback); EMIT_LAYOUT("plugin_type8_init", CE77PluginType8Init); - EMIT_OFFSET("plugin_type8_init", CE77PluginType8Init, Callback); + EMIT_FIELD("plugin_type8_init", CE77PluginType8Init, Callback); EMIT_LAYOUT("register_modification_info", CE77RegisterModificationInfo); - EMIT_OFFSET("register_modification_info", CE77RegisterModificationInfo, Address); - EMIT_OFFSET("register_modification_info", CE77RegisterModificationInfo, ChangeEax); - EMIT_OFFSET("register_modification_info", CE77RegisterModificationInfo, ChangeR15); - EMIT_OFFSET("register_modification_info", CE77RegisterModificationInfo, ChangeOf); - EMIT_OFFSET("register_modification_info", CE77RegisterModificationInfo, NewEax); - EMIT_OFFSET("register_modification_info", CE77RegisterModificationInfo, NewR15); - EMIT_OFFSET("register_modification_info", CE77RegisterModificationInfo, NewCf); - EMIT_OFFSET("register_modification_info", CE77RegisterModificationInfo, NewOf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, Address); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEax); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEbx); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEcx); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEdx); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEsi); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEdi); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEbp); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEsp); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeEip); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeR8); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeR9); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeR10); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeR11); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeR12); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeR13); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeR14); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeR15); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeCf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangePf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeAf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeZf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeSf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, ChangeOf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEax); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEbx); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEcx); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEdx); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEsi); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEdi); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEbp); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEsp); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewEip); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewR8); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewR9); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewR10); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewR11); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewR12); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewR13); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewR14); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewR15); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewCf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewPf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewAf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewZf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewSf); + EMIT_FIELD("register_modification_info", CE77RegisterModificationInfo, NewOf); EMIT_LAYOUT("exported_functions_prefix", CE77ExportedFunctionsPrefix); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, SizeOfExportedFunctions); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, ShowMessage); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, RegisterFunction); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, UnregisterFunction); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, OpenedProcessId); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, OpenedProcessHandle); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, GetMainWindowHandle); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, AutoAssemble); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, Assembler); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, Disassembler); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, ChangeRegistersAtAddress); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, InjectDll); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, FreezeMemory); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, UnfreezeMemory); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, FixMemory); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, ProcessList); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, ReloadSettings); - EMIT_OFFSET("exported_functions_prefix", CE77ExportedFunctionsPrefix, GetAddressFromPointer); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, SizeOfExportedFunctions); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, ShowMessage); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, RegisterFunction); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, UnregisterFunction); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, OpenedProcessId); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, OpenedProcessHandle); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, GetMainWindowHandle); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, AutoAssemble); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, Assembler); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, Disassembler); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, ChangeRegistersAtAddress); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, InjectDll); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, FreezeMemory); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, UnfreezeMemory); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, FixMemory); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, ProcessList); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, ReloadSettings); + EMIT_FIELD("exported_functions_prefix", CE77ExportedFunctionsPrefix, GetAddressFromPointer); + + EMIT_LAYOUT("managed_plugin_init_record", CE77ManagedPluginInitRecord); + EMIT_FIELD("managed_plugin_init_record", CE77ManagedPluginInitRecord, Name); + EMIT_FIELD("managed_plugin_init_record", CE77ManagedPluginInitRecord, GetVersion); + EMIT_FIELD("managed_plugin_init_record", CE77ManagedPluginInitRecord, EnablePlugin); + EMIT_FIELD("managed_plugin_init_record", CE77ManagedPluginInitRecord, DisablePlugin); + EMIT_FIELD("managed_plugin_init_record", CE77ManagedPluginInitRecord, Version); + + EMIT_LAYOUT("managed_exported_functions", CE77ManagedExportedFunctions); + EMIT_FIELD("managed_exported_functions", CE77ManagedExportedFunctions, SizeOfExportedFunctions); + EMIT_FIELD("managed_exported_functions", CE77ManagedExportedFunctions, GetLuaState); + EMIT_FIELD("managed_exported_functions", CE77ManagedExportedFunctions, LuaRegister); + EMIT_FIELD("managed_exported_functions", CE77ManagedExportedFunctions, LuaPushClassInstance); + EMIT_FIELD("managed_exported_functions", CE77ManagedExportedFunctions, ProcessMessages); + EMIT_FIELD("managed_exported_functions", CE77ManagedExportedFunctions, CheckSynchronize); + + EMIT_LAYOUT("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord); + EMIT_FIELD("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord, InterpretedAddress); + EMIT_FIELD("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord, Address); + EMIT_FIELD("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord, IsPointer); + EMIT_FIELD("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord, CountOffsets); + EMIT_FIELD("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord, Offsets); + EMIT_FIELD("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord, Description); + EMIT_FIELD("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord, ValueType); + EMIT_FIELD("host_plugin0_selected_record", CE77HostPlugin0SelectedRecord, Size); + + EMIT_LAYOUT("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord); + EMIT_FIELD("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord, InterpretedAddress); + EMIT_FIELD("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord, Address); + EMIT_FIELD("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord, IsPointer); + EMIT_FIELD("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord, CountOffsets); + EMIT_FIELD("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord, Offsets); + EMIT_FIELD("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord, Description); + EMIT_FIELD("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord, ValueType); + EMIT_FIELD("pascal_dword_mirror_selected_record", CE77PascalDwordMirrorSelectedRecord, Size); + EMIT_LAYOUT("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord); + EMIT_FIELD("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord, InterpretedAddress); + EMIT_FIELD("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord, Address); + EMIT_FIELD("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord, IsPointer); + EMIT_FIELD("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord, CountOffsets); + EMIT_FIELD("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord, Offsets); + EMIT_FIELD("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord, Description); + EMIT_FIELD("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord, ValueType); + EMIT_FIELD("pascal_boolean_mirror_selected_record", CE77PascalBooleanMirrorSelectedRecord, Size); std::printf("sizeof.plugin_type6_popup_show=%zu\n", sizeof(CE77_BOOL)); std::printf("calling_convention.classic_callbacks=__stdcall\n"); auto topology = CreateTopologyFixture(); @@ -192,7 +332,7 @@ void EmitFacts() std::printf("export.1=CEPlugin_InitializePlugin\n"); std::printf("export.2=CEPlugin_DisablePlugin\n"); -#undef EMIT_OFFSET +#undef EMIT_FIELD #undef EMIT_LAYOUT } } @@ -228,10 +368,12 @@ int wmain(int argc, wchar_t** argv) bool paddingNegativeTestRejected = !ValidateGetVersion(&PaddingWritingGetVersion); bool getVersionSucceeded = ValidateGetVersion(getVersion); + bool packedInitRecordWriteIsContained = ValidatePackedInitRecordWrite(); CE77ExportedFunctionsPrefix prefix{}; bool exportsSucceeded = initialize(&prefix, 0x10203040) != 0 && disable() != 0; - if (!paddingNegativeTestRejected || !getVersionSucceeded || !exportsSucceeded) + if (!paddingNegativeTestRejected || !getVersionSucceeded || !exportsSucceeded || + !packedInitRecordWriteIsContained) { std::fputs("fixture sentinel call failed\n", stderr); result = 67; @@ -242,6 +384,7 @@ int wmain(int argc, wchar_t** argv) std::printf("sentinel.plugin_version.outer_guard=passed\n"); std::printf("sentinel.plugin_version.padding=passed\n"); std::printf("sentinel.exports.return_values=passed\n"); + std::printf("sentinel.managed_plugin_init_record.tail_guard=passed\n"); } while (false); FreeLibrary(fixture); diff --git a/tests/native-abi-fixture/ce77_plugin_abi_contract.h b/tests/native-abi-fixture/ce77_plugin_abi_contract.h index 4c825d58..a8a5c521 100644 --- a/tests/native-abi-fixture/ce77_plugin_abi_contract.h +++ b/tests/native-abi-fixture/ce77_plugin_abi_contract.h @@ -1,16 +1,28 @@ // SPDX-License-Identifier: MIT // -// Minimal, header-derived classic-plugin ABI contract for an x64 fixture. +// Minimal classic- and managed-plugin ABI contract for an x64 fixture, transcribed +// from pinned Cheat Engine sources (names, types and line ranges only). // // Provenance (do not replace this with a locally installed header): // Cheat Engine upstream commit ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37 -// Cheat Engine/plugin/cepluginsdk.h, lines 15-160, 163-180 and 271-456 -// https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/plugin/cepluginsdk.h +// Cheat Engine/plugin/cepluginsdk.h (SHA-256 b6500df1e94d7bb011b38e173b2603197b7a1f304496d751ede82e57e36e532f), +// lines 15-160, 163-180 and 271-456: PluginVersion, PLUGINTYPE0_RECORD, the nine init records, +// REGISTERMODIFICATIONINFO, the physical ExportedFunctions prefix and the three exports. +// Cheat Engine/plugin.pas (SHA-256 358f51a39ad14d00ecba3c9137f440152d4ab85f1d2498068fa81fca906d09db), the host +// authority: TPluginDotNetInitResult (packed record, lines 29-36) -> CE77ManagedPluginInitRecord, +// TExportedFunctionsDotNetV1 (lines 38-45) -> CE77ManagedExportedFunctions, TPlugin0_SelectedRecord (lines +// 726-735) -> CE77HostPlugin0SelectedRecord. +// Cheat Engine/plugin/cepluginsdk.pas (SHA-256 cda5269f441120e5a3bff2f87e289cd71de9158ca2a619c7d0a734eb98ee6052), +// the Pascal kit mirror ({$MODE Delphi}, line 3; natural alignment deduced): TPlugin0_SelectedRecord (lines +// 161-170, address: dword) -> CE77PascalDwordMirrorSelectedRecord and TSelectedRecord (lines 147-156, +// ispointer: boolean) -> CE77PascalBooleanMirrorSelectedRecord. Both mirrors are known-wrong for x64 and are +// transcribed only as negative oracles. +// https://github.com/cheat-engine/cheat-engine/tree/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37 // -// This is intentionally not a vendored copy of cepluginsdk.h. It carries only -// the declarations that the fixture measures. The fixture is an x64 MSVC -// build; Windows SDK headers supply no CE declarations and no CE installation -// is consulted. +// This is intentionally not a vendored copy of any of those files. It carries only +// the declarations that the fixture measures, with the C# field names of +// CheatEngine.SDK.Abi. The fixture is an x64 MSVC build; Windows SDK headers +// supply no CE declarations and no CE installation is consulted. #pragma once @@ -21,9 +33,11 @@ #error The CE 7.7 fixture is deliberately Windows x64 only. #endif -#define CE77_ABI_FIXTURE_SCHEMA_VERSION 2 +#define CE77_ABI_FIXTURE_SCHEMA_VERSION 3 #define CE77_UPSTREAM_COMMIT "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37" #define CE77_CEPLUGINSDK_PATH "Cheat Engine/plugin/cepluginsdk.h" +#define CE77_PLUGIN_PAS_PATH "Cheat Engine/plugin.pas" +#define CE77_CEPLUGINSDK_PAS_PATH "Cheat Engine/plugin/cepluginsdk.pas" typedef int32_t CE77_BOOL; typedef uint32_t CE77_ULONG; @@ -72,6 +86,73 @@ typedef struct CE77PluginType0Record char Size; } CE77PluginType0Record; +// Host type actually passed to a type-0 callback (plugin.pas lines 726-735): +// address is ptrUint, ispointer is the four-byte BOOL. Same layout as the C header. +typedef struct CE77HostPlugin0SelectedRecord +{ + char* InterpretedAddress; + uintptr_t Address; + int32_t IsPointer; + int32_t CountOffsets; + uint32_t* Offsets; + char* Description; + uint8_t ValueType; + uint8_t Size; +} CE77HostPlugin0SelectedRecord; + +// Pascal kit mirror TPlugin0_SelectedRecord (cepluginsdk.pas lines 161-170): +// address is a 32-bit dword, so every later field up to countoffsets moves. +typedef struct CE77PascalDwordMirrorSelectedRecord +{ + char* InterpretedAddress; + uint32_t Address; + int32_t IsPointer; + int32_t CountOffsets; + uint32_t* Offsets; + char* Description; + uint8_t ValueType; + uint8_t Size; +} CE77PascalDwordMirrorSelectedRecord; + +// Pascal kit mirror TSelectedRecord (cepluginsdk.pas lines 147-156): +// ispointer is a one-byte Pascal boolean; the offsets match the host type. +typedef struct CE77PascalBooleanMirrorSelectedRecord +{ + char* InterpretedAddress; + uintptr_t Address; + uint8_t IsPointer; + int32_t CountOffsets; + uint32_t* Offsets; + char* Description; + uint8_t ValueType; + uint8_t Size; +} CE77PascalBooleanMirrorSelectedRecord; + +// Managed bootstrap record TPluginDotNetInitResult (plugin.pas lines 29-36, a +// packed record): 36 bytes, byte alignment. A naturally aligned copy is 40. +#pragma pack(push, 1) +typedef struct CE77ManagedPluginInitRecord +{ + char* Name; + void* GetVersion; + void* EnablePlugin; + void* DisablePlugin; + uint32_t Version; +} CE77ManagedPluginInitRecord; +#pragma pack(pop) + +// Managed services table TExportedFunctionsDotNetV1 (plugin.pas lines 38-45): +// one integer followed by five pointers, natural alignment, 48 bytes. +typedef struct CE77ManagedExportedFunctions +{ + int32_t SizeOfExportedFunctions; + void* GetLuaState; + void* LuaRegister; + void* LuaPushClassInstance; + void* ProcessMessages; + void* CheckSynchronize; +} CE77ManagedExportedFunctions; + typedef CE77_BOOL(CE77_STDCALL* CE77PluginType0Callback)(CE77PluginType0Record* selectedRecord); typedef CE77_BOOL(CE77_STDCALL* CE77PluginType1Callback)(CE77_UINT_PTR* disassemblerAddress, CE77_UINT_PTR* selectedDisassemblerAddress, CE77_UINT_PTR* hexViewAddress); @@ -265,6 +346,39 @@ static_assert(offsetof(CE77PluginType0Record, Description) == 32); static_assert(offsetof(CE77PluginType0Record, ValueType) == 40); static_assert(offsetof(CE77PluginType0Record, Size) == 41); +static_assert(sizeof(CE77HostPlugin0SelectedRecord) == 48); +static_assert(alignof(CE77HostPlugin0SelectedRecord) == 8); +static_assert(offsetof(CE77HostPlugin0SelectedRecord, Address) == 8); +static_assert(offsetof(CE77HostPlugin0SelectedRecord, IsPointer) == 16); +static_assert(offsetof(CE77HostPlugin0SelectedRecord, CountOffsets) == 20); +static_assert(offsetof(CE77HostPlugin0SelectedRecord, Offsets) == 24); +static_assert(offsetof(CE77HostPlugin0SelectedRecord, ValueType) == 40); +static_assert(sizeof(CE77PascalDwordMirrorSelectedRecord) == 48); +static_assert(offsetof(CE77PascalDwordMirrorSelectedRecord, Address) == 8); +static_assert(offsetof(CE77PascalDwordMirrorSelectedRecord, IsPointer) == 12); +static_assert(offsetof(CE77PascalDwordMirrorSelectedRecord, CountOffsets) == 16); +static_assert(offsetof(CE77PascalDwordMirrorSelectedRecord, Offsets) == 24); +static_assert(sizeof(CE77PascalBooleanMirrorSelectedRecord) == 48); +static_assert(offsetof(CE77PascalBooleanMirrorSelectedRecord, IsPointer) == 16); +static_assert(sizeof(((CE77PascalBooleanMirrorSelectedRecord*)0)->IsPointer) == 1); +static_assert(offsetof(CE77PascalBooleanMirrorSelectedRecord, CountOffsets) == 20); + +static_assert(sizeof(CE77ManagedPluginInitRecord) == 36); +static_assert(alignof(CE77ManagedPluginInitRecord) == 1); +static_assert(offsetof(CE77ManagedPluginInitRecord, Name) == 0); +static_assert(offsetof(CE77ManagedPluginInitRecord, GetVersion) == 8); +static_assert(offsetof(CE77ManagedPluginInitRecord, EnablePlugin) == 16); +static_assert(offsetof(CE77ManagedPluginInitRecord, DisablePlugin) == 24); +static_assert(offsetof(CE77ManagedPluginInitRecord, Version) == 32); +static_assert(sizeof(CE77ManagedExportedFunctions) == 48); +static_assert(alignof(CE77ManagedExportedFunctions) == 8); +static_assert(offsetof(CE77ManagedExportedFunctions, SizeOfExportedFunctions) == 0); +static_assert(offsetof(CE77ManagedExportedFunctions, GetLuaState) == 8); +static_assert(offsetof(CE77ManagedExportedFunctions, LuaRegister) == 16); +static_assert(offsetof(CE77ManagedExportedFunctions, LuaPushClassInstance) == 24); +static_assert(offsetof(CE77ManagedExportedFunctions, ProcessMessages) == 32); +static_assert(offsetof(CE77ManagedExportedFunctions, CheckSynchronize) == 40); + static_assert(sizeof(CE77PluginType0Init) == 16); static_assert(alignof(CE77PluginType0Init) == 8); static_assert(offsetof(CE77PluginType0Init, Name) == 0); diff --git a/tests/native-host-emulator/README.md b/tests/native-host-emulator/README.md new file mode 100644 index 00000000..2a8bdb03 --- /dev/null +++ b/tests/native-host-emulator/README.md @@ -0,0 +1,143 @@ +# native-host-emulator + +A native hostfxr host, built and run outside .NET, that plays the coexistence A/B protocol against the two managed +`CheatEngine.SDK.LivePlugin.Coexistence` plugins through the same managed (hostfxr) load path Cheat Engine uses. It +is C2 evidence (SDK-COEX-1, F03, Q09) for a real `.NET hosting` component load: **it is not Cheat Engine's loader**, +and its facts must never be read as an executed Cheat Engine result. + +## Objective + +Measure, from a real hostfxr host, what a managed plugin load actually does when two independently built plugin +assemblies that both depend on `CheatEngine.SDK.Hosting` are loaded side by side: whether they land in the same or a +different `AssemblyLoadContext`, whether their `CheatEngine.SDK.Hosting` static state (the `PluginHost` instance) is +shared or separate, and what happens to the second plugin when it is. Q09 C4 (an exact Cheat Engine host, two SDK +copies, receipts) remains required and separate; this program only ever produces C2 evidence. + +## Why it exists + +`docs/…/matrix.json` and this repository do not exist without a bundled qualification runner any more (the +maintainer's docs/eng pivot), and Checkpoint B's local Cheat Engine run is cancelled for this branch. The coexistence +question (does the loader used by Cheat Engine give two plugin DLLs separate SDK statics, or one shared instance) is +still answerable *for the hostfxr component-hosting route* without Cheat Engine at all, because that route is +publicly documented .NET hosting behaviour +() that a native program can drive directly. That +is exactly what this program does, and only that: it never claims Cheat Engine's own loader uses this route. + +## How it works + +`ce_host_emulator.cpp` (C++20, MSVC x64, `/W4 /WX /EHsc /O2 /DUNICODE`): + +1. Loads `--lua` (the repository's bundled `lua53-64.dll`) with `LoadLibraryW`, so it resolves under its own module + name for the plugins' own dependent load, and creates one `lua_State` (`luaL_newstate` + `luaL_openlibs`). No Lua + headers are vendored (same choice as `native/cheatengine-sdk-lua-bridge`): the handful of Lua 5.3 C API entry + points this protocol needs are declared locally and resolved with `GetProcAddress`. +2. Locates `hostfxr` with `nethost`'s `get_hostfxr_path`, using an explicit `--dotnet-root` (never the environment, + never the registry, never an installed Cheat Engine), then calls `hostfxr_initialize_for_runtime_config` against + `ce-like.runtimeconfig.json` — **one runtime for the whole process** (only one `.NET` runtime can ever be loaded + per process; the consumer tests run one emulator process per scenario). +3. Resolves each plugin's bootstrap entry point one of two ways, selected by `--alc`: + - `component` (the well-documented route): `hdt_load_assembly_and_get_function_pointer` on the plugin's own + assembly path. This is Microsoft Learn's documented component-hosting route, and it isolates each assembly path + into its own `AssemblyLoadContext` — **measured** below, not assumed. + - `default` (not documented on Microsoft Learn; declarations came from the installed SDK's + `Microsoft.NETCore.App.Host.win-x64` pack `hostfxr.h`/`coreclr_delegates.h`, never a guess): `hostfxr_set_runtime_property_value` + sets `APP_PATHS` to the plugins' shared directory *before* the first runtime delegate is requested (properties + can only be set before the runtime loads), then `hdt_load_assembly` + `hdt_get_function_pointer` load both + plugins into the **default** `AssemblyLoadContext`. +4. Calls the resolved `CESDK.CESDK.CEPluginInitialize(IntPtr, int)` bootstrap **twice** (Cheat Engine's own documented + name-query-then-load sequence) into a 36-byte `PluginInitRecord` surrounded by 64 guard bytes (`0xCD`) on each + side, and checks that the guard bytes and the `Name` pointer are unchanged afterwards. +5. Calls `GetVersion`, then `EnablePlugin` with a `ManagedExportedFunctions` record whose five slots are implemented + by this program (`GetLuaState` returns the one Lua state; `LuaRegister` is a stub that only counts a call, since + the managed side documents it as "do not call"; `LuaPushClassInstance` pushes a light userdata; `ProcessMessages` + is a no-op; `CheckSynchronize` always reports one dispatched call). +6. Runs the coexistence protocol over Lua: enable A, then B; call each plugin's own + `cheatengine_sdk_coexistence_{a,b}_identity()`/`_ping()`; disable A and confirm A's globals go `nil` while B (if + enabled) keeps answering; re-enable A and confirm a new `Epoch`; disable B then A and confirm every global is + `nil`. +7. Writes an ordered `key=value` facts file to `--facts`. Every value is redacted for absolute paths before it is + written (`ALC` names of isolated component load contexts embed the component's assembly path), so the facts file, + and everything derived from it, never carries a local path. + +`ce_host_emulator_abi.h` declares the same 36-byte `PluginInitRecord` / 48-byte `ManagedExportedFunctions` / +16-byte `PluginVersion` shapes as `CheatEngine.SDK.Abi`, with `static_assert` checks on every size and offset. It is +intentionally **not** `tests/native-abi-fixture/ce77_plugin_abi_contract.h`: that header is S-ABI's, and this program +is a separate C2 evidence artifact with its own facts. + +`ce-like.runtimeconfig.json` mirrors the qualifiable host profile's runtime shape (`net10.0`; +`Microsoft.NETCore.App`, `Microsoft.WindowsDesktop.App`, `Microsoft.AspNetCore.App`, each `10.0.0` / +`LatestMinor`) exactly as recorded, and is never copied from an installed Cheat Engine. + +## Build it + +```powershell +. \tools\Enter-CeEnv.ps1 -Lot +./tests/native-host-emulator/build.ps1 -OutputDirectory artifacts/native-host-emulator +``` + +`build.ps1` locates an x64 Visual Studio Developer environment (the same `VsDevCmd.bat` pattern as +`tests/native-abi-fixture/build.ps1`, with an optional `-VcToolsVersion` pin), locates `nethost.h`, `nethost.lib`, +`nethost.dll`, `hostfxr.h` and `coreclr_delegates.h` under the newest `Microsoft.NETCore.App.Host.win-x64` pack of +`-DotNetRoot` (default: the `dotnet.exe` on `PATH`), compiles `ce-host-emulator.exe`, copies `nethost.dll` and the +runtimeconfig next to it, and writes `native-host-emulator.manifest.txt` (SHA-256 of the exe, `nethost.dll`, the +runtimeconfig and every source file, plus the observed MSVC toolset, Windows SDK and pack versions). It checks +`$LASTEXITCODE` after every native command. + +## Promise + +- The ABI header's `static_assert` lines match `CheatEngine.SDK.Abi`'s managed record sizes and offsets + (`Emulator_abi_header_declares_the_managed_record_sizes` in `CheatEngine.SDK.Hosting.Tests`). +- Both bootstrap calls write the guarded 36-byte record without touching a byte outside it, and the `Name` pointer + is the same pointer on both calls (`Bootstrap_canaries_and_name_pointers_stay_intact_for_both_plugins`). +- The component route measures each plugin's `PluginHost` static identity and Lua-visible behaviour under separate + and shared output folders (`Separate_folders_measure_distinct_hosting_instances_and_disabling_A_leaves_B_callable`, + `Shared_folder_under_the_component_route_is_measured_and_disabling_A_leaves_B_callable`). +- The default-ALC route's rejection of the second plugin is measured, not asserted from documentation + (`Default_context_loading_rejects_the_second_plugin_deterministically`). +- Re-enabling the first plugin after a disable answers under a new `Epoch` (`Reenabling_A_answers_with_a_new_epoch`). +- No fact and no exit path ever contains an absolute path (`Emulator_facts_contain_no_absolute_path`), a + `C:\Program Files\Cheat Engine` reference, or `cheatengine-x86_64` (`NativeHostEmulatorScriptTests`, Repository + project). +- `build.ps1` checks `$LASTEXITCODE` after `cl.exe`, never reads an installed Cheat Engine, and writes only under its + `-OutputDirectory` (`NativeHostEmulatorScriptTests`). + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj -c Debug --no-build --fail-skips on +dotnet test --project tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj -c Debug --no-build --filter-class "*NativeHostEmulatorScriptTests" --fail-skips on +``` + +`CESDK_NATIVE_HOST_EMULATOR_DIR` (absolute path to this script's `-OutputDirectory`) and +`CESDK_NATIVE_HOST_EMULATOR_REQUIRED` (`true`/`false`) gate `NativeHostEmulatorTests` exactly like the native ABI +fixture's own `CE77_NATIVE_ABI_FACTS_PATH`/`CE77_NATIVE_ABI_REQUIRED` pair: unset locally, the emulator-dependent +tests return after asserting that documented opt-out; `REQUIRED=true` without the directory is an actionable error, +never a silent skip. + +## Formerly known local dependency (now fixed) + +`tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistencePlugin.props` builds both plugins through a direct +`ProjectReference` chain, never through the packaged `CheatEngine.SDK` NuGet's `build/CheatEngine.SDK.props` asset. +That packaged asset was the **only** place in this repository that set `CheatEngineSdkGenerateEntryPoint=true` and +registered it as a `CompilerVisibleProperty`; without both, the `CheatEngine.SDK.SourceGenerators.EntryPoint` +generator stayed silent (by its own documented design — see +`source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/README.md`, "an indirect package reference leaves it +absent and produces no bootstrap") and neither plugin assembly contained a `CESDK.CESDK` type. hostfxr's +`load_assembly_and_get_function_pointer` therefore never resolved an entry point to call, so this program's own +`plugin.entryResolved` stayed `false` and every fact that depends on a call into the plugin (`{a,b}.bootstrap.*`, +`{a,b}.getversion`, `{a,b}.enable.*`) reported `skipped` — not `failed`, because `boolText()` reports `skipped` +whenever the call itself was never attempted. `CoexistencePlugin.props` now carries the same two-line opt-in the +packaged consumer gets for free: + +```xml + + true + + + + +``` + +With the fix landed, `load_assembly_and_get_function_pointer` resolves the bootstrap for both plugins and every +`AssertBootstrapAndEnableSucceeded` assertion reports `ok`/`true` instead of `skipped`: all 9 +`NativeHostEmulatorTests` pass with `CESDK_NATIVE_HOST_EMULATOR_DIR`/`REQUIRED=true` set. diff --git a/tests/native-host-emulator/build.ps1 b/tests/native-host-emulator/build.ps1 new file mode 100644 index 00000000..7163ae24 --- /dev/null +++ b/tests/native-host-emulator/build.ps1 @@ -0,0 +1,133 @@ +[CmdletBinding()] +param( + [Parameter()] + [string]$OutputDirectory = (Join-Path (Get-Location) 'artifacts/native-host-emulator'), + + [Parameter()] + [string]$VcToolsVersion, + + [Parameter()] + [string]$DotNetRoot = (Split-Path -Parent (Get-Command dotnet.exe -ErrorAction Stop).Source) +) + +$ErrorActionPreference = 'Stop' + +if (-not [Environment]::Is64BitOperatingSystem) { + throw 'The native host emulator requires a 64-bit Windows host.' +} + +function Import-X64VisualStudioEnvironment { + param([string]$RequestedVcToolsVersion) + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path -LiteralPath $vswhere -PathType Leaf)) { + throw "cl.exe was not found and '$vswhere' is unavailable to locate the Visual Studio build tools." + } + + $installationPath = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath + if ([string]::IsNullOrWhiteSpace($installationPath)) { + throw 'No Visual Studio installation with the x64 C++ build tools was found.' + } + + $developerCommand = Join-Path $installationPath.Trim() 'Common7\Tools\VsDevCmd.bat' + if (-not (Test-Path -LiteralPath $developerCommand -PathType Leaf)) { + throw "Visual Studio did not contain '$developerCommand'." + } + + # Pin the toolset when the caller supplies one (CI: the 'native' job's MSVC version output). Left unset locally, + # vswhere picks whatever toolset is installed, which can be a newer major Visual Studio version than CI's pinned + # runner image and can therefore accept or reject different /W4 warnings. + $vcToolsArgument = if ($RequestedVcToolsVersion) { "-vcvars_ver=$RequestedVcToolsVersion" } else { '' } + $command = '"{0}" -no_logo -arch=x64 -host_arch=x64 {1} && set' -f $developerCommand, $vcToolsArgument + $environment = @(& $env:ComSpec /d /s /c $command) + if ($LASTEXITCODE -ne 0) { throw "VsDevCmd.bat failed with exit code $LASTEXITCODE." } + + foreach ($line in $environment) { + $separator = $line.IndexOf('=') + if ($separator -le 0) { continue } + + $name = $line.Substring(0, $separator) + $value = $line.Substring($separator + 1) + Set-Item -LiteralPath "Env:$name" -Value $value + } +} + +$compiler = Get-Command cl.exe -ErrorAction SilentlyContinue +if ($null -eq $compiler -or $env:VSCMD_ARG_TGT_ARCH -ne 'x64') { + Import-X64VisualStudioEnvironment -RequestedVcToolsVersion $VcToolsVersion + $compiler = Get-Command cl.exe -ErrorAction SilentlyContinue +} + +if ($null -eq $compiler -or $env:VSCMD_ARG_TGT_ARCH -ne 'x64') { + throw 'The native host emulator requires cl.exe from an x64 Visual Studio Developer environment.' +} + +if (-not (Test-Path -LiteralPath $DotNetRoot -PathType Container)) { + throw "-DotNetRoot '$DotNetRoot' does not exist." +} + +# Locate the newest installed Microsoft.NETCore.App.Host.win-x64 pack: it carries nethost.h, nethost.lib, nethost.dll, +# hostfxr.h and coreclr_delegates.h. Never read an installed Cheat Engine; this pack ships with the .NET SDK. +$hostPackRoot = Join-Path $DotNetRoot 'packs\Microsoft.NETCore.App.Host.win-x64' +if (-not (Test-Path -LiteralPath $hostPackRoot -PathType Container)) { + throw "'$hostPackRoot' does not exist: install the Microsoft.NETCore.App.Host.win-x64 pack (it ships with the .NET SDK)." +} + +$hostPackVersionDirectory = Get-ChildItem -LiteralPath $hostPackRoot -Directory | + Sort-Object { [version] ($_.Name -replace '-.*$', '') } -Descending | + Select-Object -First 1 +if ($null -eq $hostPackVersionDirectory) { + throw "No version directory was found under '$hostPackRoot'." +} + +$nativePackDirectory = Join-Path $hostPackVersionDirectory.FullName 'runtimes\win-x64\native' +foreach ($requiredFile in 'nethost.h', 'nethost.lib', 'nethost.dll', 'hostfxr.h', 'coreclr_delegates.h') { + if (-not (Test-Path -LiteralPath (Join-Path $nativePackDirectory $requiredFile) -PathType Leaf)) { + throw "'$requiredFile' was not found under '$nativePackDirectory'." + } +} + +$emulatorDirectory = Split-Path -Parent $PSCommandPath +# Resolve against the PowerShell location, not the process working directory (see tests/native-abi-fixture/build.ps1 +# for the same rationale): Set-Location does not move the latter. +$resolvedOutputDirectory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory) +[System.IO.Directory]::CreateDirectory($resolvedOutputDirectory) | Out-Null + +$exe = Join-Path $resolvedOutputDirectory 'ce-host-emulator.exe' +$objectFile = Join-Path $resolvedOutputDirectory 'ce_host_emulator.obj' +$sourceFile = Join-Path $emulatorDirectory 'ce_host_emulator.cpp' +$headerFile = Join-Path $emulatorDirectory 'ce_host_emulator_abi.h' +$runtimeConfigSource = Join-Path $emulatorDirectory 'ce-like.runtimeconfig.json' + +$common = @('/nologo', '/std:c++20', '/W4', '/WX', '/EHsc', '/O2', '/DUNICODE', '/I', $emulatorDirectory, '/I', $nativePackDirectory) +& $compiler.Source @common $sourceFile "/Fo$objectFile" "/Fe$exe" '/link' (Join-Path $nativePackDirectory 'nethost.lib') '/MACHINE:X64' +if ($LASTEXITCODE -ne 0) { throw "cl.exe failed while building $exe (exit code $LASTEXITCODE)." } + +$nethostDllDestination = Join-Path $resolvedOutputDirectory 'nethost.dll' +Copy-Item -LiteralPath (Join-Path $nativePackDirectory 'nethost.dll') -Destination $nethostDllDestination -Force + +$runtimeConfigDestination = Join-Path $resolvedOutputDirectory 'ce-like.runtimeconfig.json' +Copy-Item -LiteralPath $runtimeConfigSource -Destination $runtimeConfigDestination -Force + +function Get-FileSha256Hex { + param([Parameter(Mandatory)][string]$LiteralPath) + + return (Get-FileHash -LiteralPath $LiteralPath -Algorithm SHA256).Hash.ToLowerInvariant() +} + +$manifestLines = [System.Collections.Generic.List[string]]::new() +$manifestLines.Add("schema=1") +$manifestLines.Add("exe.sha256=$(Get-FileSha256Hex -LiteralPath $exe)") +$manifestLines.Add("nethost.dll.sha256=$(Get-FileSha256Hex -LiteralPath $nethostDllDestination)") +$manifestLines.Add("runtimeconfig.sha256=$(Get-FileSha256Hex -LiteralPath $runtimeConfigDestination)") +$manifestLines.Add("source.ce_host_emulator_cpp.sha256=$(Get-FileSha256Hex -LiteralPath $sourceFile)") +$manifestLines.Add("source.ce_host_emulator_abi_h.sha256=$(Get-FileSha256Hex -LiteralPath $headerFile)") +$manifestLines.Add("msvc.toolset=$env:VCToolsVersion") +$manifestLines.Add("windows.sdk=$env:WindowsSDKVersion") +$manifestLines.Add("nethost.pack=$($hostPackVersionDirectory.Name)") + +$manifestPath = Join-Path $resolvedOutputDirectory 'native-host-emulator.manifest.txt' +$manifestLines | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + +$manifestLines diff --git a/tests/native-host-emulator/ce-like.runtimeconfig.json b/tests/native-host-emulator/ce-like.runtimeconfig.json new file mode 100644 index 00000000..edbd4e04 --- /dev/null +++ b/tests/native-host-emulator/ce-like.runtimeconfig.json @@ -0,0 +1,22 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "10.0.0", + "rollForward": "LatestMinor" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "10.0.0", + "rollForward": "LatestMinor" + }, + { + "name": "Microsoft.AspNetCore.App", + "version": "10.0.0", + "rollForward": "LatestMinor" + } + ] + } +} diff --git a/tests/native-host-emulator/ce_host_emulator.cpp b/tests/native-host-emulator/ce_host_emulator.cpp new file mode 100644 index 00000000..406f9650 --- /dev/null +++ b/tests/native-host-emulator/ce_host_emulator.cpp @@ -0,0 +1,966 @@ +// SPDX-License-Identifier: MIT +// +// C2 evidence only (SDK-COEX-1, F03, Q09): a native hostfxr host that plays the two coexistence plugins' A/B protocol +// through the managed (hostfxr) load path, exactly as documented at +// https://learn.microsoft.com/dotnet/core/tutorials/netcore-hosting for the well-documented steps (nethost lookup, +// hostfxr_initialize_for_runtime_config, hostfxr_get_runtime_delegate, hdt_load_assembly_and_get_function_pointer). +// The "default ALC" route (hdt_load_assembly + hdt_get_function_pointer, with APP_PATHS set on the host context) is +// not documented on Learn; its declarations come from the installed SDK's +// Microsoft.NETCore.App.Host.win-x64 pack `hostfxr.h`/`coreclr_delegates.h`, and its behaviour here is a *measured* +// fact, never an assumption (see the README and every "measured, not asserted" comment below). +// +// This program never starts, reads, or references an installed Cheat Engine. It never claims Cheat Engine's actual +// loader behaviour: `evidence=C2-host-emulated-hostfxr-component-route-not-cheat-engine` is stamped on every run. +// +// Exit codes: 0 once the protocol ran (whatever the recorded facts say); non-zero only for an infrastructure failure +// that prevented the protocol from running at all (bad arguments, Lua load failure, hostfxr initialization failure). + +#define WIN32_LEAN_AND_MEAN + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ce_host_emulator_abi.h" +#include +#include +#include + +#pragma comment(lib, "bcrypt.lib") + +namespace +{ + // --------------------------------------------------------------------------------------------------------- + // String helpers + // --------------------------------------------------------------------------------------------------------- + + std::string WideToUtf8(const std::wstring& wide) + { + if (wide.empty()) + { + return std::string(); + } + + int size = WideCharToMultiByte(CP_UTF8, 0, wide.c_str(), static_cast(wide.size()), nullptr, 0, nullptr, nullptr); + std::string result(static_cast(size), '\0'); + WideCharToMultiByte(CP_UTF8, 0, wide.c_str(), static_cast(wide.size()), result.data(), size, nullptr, nullptr); + return result; + } + + std::string ToHex(const unsigned char* bytes, size_t count) + { + static const char* digits = "0123456789abcdef"; + std::string hex(count * 2, '0'); + for (size_t index = 0; index < count; ++index) + { + hex[index * 2] = digits[(bytes[index] >> 4) & 0xF]; + hex[index * 2 + 1] = digits[bytes[index] & 0xF]; + } + + return hex; + } + + std::string Trim(const std::string& text) + { + size_t begin = text.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) + { + return std::string(); + } + + size_t end = text.find_last_not_of(" \t\r\n"); + return text.substr(begin, end - begin + 1); + } + + // Redacts every absolute Windows path token (":\..." until the next separator) down to "". + // ALC names of isolated component load contexts embed the component's absolute assembly path (pitfall #9 of + // s-host.md section 5): this keeps the facts file, and any identity text copied into it, path-free. + std::string RedactAbsolutePaths(const std::string& text) + { + std::string redacted; + redacted.reserve(text.size()); + + size_t position = 0; + while (position < text.size()) + { + bool isDriveLetter = position + 1 < text.size() && isalpha(static_cast(text[position])) != 0 && + text[position + 1] == ':' && position + 2 < text.size() && (text[position + 2] == '\\' || text[position + 2] == '/'); + if (!isDriveLetter) + { + redacted += text[position]; + ++position; + continue; + } + + size_t tokenEnd = position; + while (tokenEnd < text.size() && text[tokenEnd] != ';' && text[tokenEnd] != ',' && text[tokenEnd] != ' ' && + text[tokenEnd] != '\t' && text[tokenEnd] != '(' && text[tokenEnd] != ')') + { + ++tokenEnd; + } + + std::string token = text.substr(position, tokenEnd - position); + size_t lastSeparator = token.find_last_of("\\/"); + std::string fileName = lastSeparator == std::string::npos ? token : token.substr(lastSeparator + 1); + redacted += " std::string is + // a user-defined one), which would silently turn every "1"/"ok"/"failed" literal into "true". Every boolean + // fact is spelled out at the call site instead (`AddBool`) so no call can be misresolved this way. + void Add(const std::string& key, const std::string& value) + { + m_entries.emplace_back(key, RedactAbsolutePaths(value)); + } + + void AddBool(const std::string& key, bool value) + { + Add(key, value ? std::string("true") : std::string("false")); + } + + bool Save(const std::wstring& path) const + { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) + { + return false; + } + + for (const std::pair& entry : m_entries) + { + file << entry.first << "=" << entry.second << "\n"; + } + + return static_cast(file); + } + + private: + std::vector> m_entries; + }; + + // --------------------------------------------------------------------------------------------------------- + // SHA-256 (Windows CNG, no external dependency): used for lua.sha256. + // --------------------------------------------------------------------------------------------------------- + + std::string Sha256File(const std::wstring& path) + { + std::ifstream file(path, std::ios::binary); + if (!file) + { + return "unavailable"; + } + + BCRYPT_ALG_HANDLE algorithm = nullptr; + if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) != 0) + { + return "unavailable"; + } + + BCRYPT_HASH_HANDLE hash = nullptr; + std::string result = "unavailable"; + if (BCryptCreateHash(algorithm, &hash, nullptr, 0, nullptr, 0, 0) == 0) + { + std::vector buffer(1 << 16); + bool hashingFailed = false; + while (file) + { + file.read(reinterpret_cast(buffer.data()), static_cast(buffer.size())); + std::streamsize read = file.gcount(); + if (read <= 0) + { + break; + } + + if (BCryptHashData(hash, buffer.data(), static_cast(read), 0) != 0) + { + hashingFailed = true; + break; + } + } + + if (!hashingFailed) + { + unsigned char digest[32]; + if (BCryptFinishHash(hash, digest, sizeof(digest), 0) == 0) + { + result = ToHex(digest, sizeof(digest)); + } + } + + BCryptDestroyHash(hash); + } + + BCryptCloseAlgorithmProvider(algorithm, 0); + return result; + } + + // --------------------------------------------------------------------------------------------------------- + // Lua 5.3 C API, resolved by ordinal-free GetProcAddress from the module loaded from --lua. Deliberately not + // `#include `: no Lua headers are vendored into this repository (see native/cheatengine-sdk-lua-bridge's + // own comment on the same choice). Only the handful of entry points this protocol needs. + // --------------------------------------------------------------------------------------------------------- + + struct lua_State; + typedef intptr_t lua_KContext; + typedef int(__cdecl* lua_KFunction)(lua_State*, int, lua_KContext); + + enum + { + CeLuaTNil = 0, + CeLuaTFunction = 6, + }; + + struct LuaApi + { + lua_State* (__cdecl* newstate)() = nullptr; + void(__cdecl* openlibs)(lua_State*) = nullptr; + int(__cdecl* getglobal)(lua_State*, const char*) = nullptr; + int(__cdecl* pcallk)(lua_State*, int, int, int, lua_KContext, lua_KFunction) = nullptr; + int(__cdecl* type)(lua_State*, int) = nullptr; + void(__cdecl* settop)(lua_State*, int) = nullptr; + int(__cdecl* gettop)(lua_State*) = nullptr; + long long(__cdecl* tointegerx)(lua_State*, int, int*) = nullptr; + const char* (__cdecl* tolstring)(lua_State*, int, size_t*) = nullptr; + void(__cdecl* close)(lua_State*) = nullptr; + void(__cdecl* pushlightuserdata)(lua_State*, void*) = nullptr; + + bool ResolveFrom(HMODULE module) + { + newstate = reinterpret_cast(GetProcAddress(module, "luaL_newstate")); + openlibs = reinterpret_cast(GetProcAddress(module, "luaL_openlibs")); + getglobal = reinterpret_cast(GetProcAddress(module, "lua_getglobal")); + pcallk = reinterpret_cast(GetProcAddress(module, "lua_pcallk")); + type = reinterpret_cast(GetProcAddress(module, "lua_type")); + settop = reinterpret_cast(GetProcAddress(module, "lua_settop")); + gettop = reinterpret_cast(GetProcAddress(module, "lua_gettop")); + tointegerx = reinterpret_cast(GetProcAddress(module, "lua_tointegerx")); + tolstring = reinterpret_cast(GetProcAddress(module, "lua_tolstring")); + close = reinterpret_cast(GetProcAddress(module, "lua_close")); + pushlightuserdata = reinterpret_cast(GetProcAddress(module, "lua_pushlightuserdata")); + return newstate && openlibs && getglobal && pcallk && type && settop && gettop && tointegerx && tolstring && close && + pushlightuserdata; + } + }; + + LuaApi g_lua; + lua_State* g_luaState = nullptr; + + // Reads the type of a global without leaving it on the stack. + int GlobalType(const char* name) + { + int type = g_lua.getglobal(g_luaState, name); + g_lua.settop(g_luaState, -2); + return type; + } + + // Calls a zero-argument global Lua function. On success, returns true and fills `resultText`/`resultInteger` + // from the single returned value (a string is read for identity(), an integer for ping()). + bool CallGlobalFunction(const char* name, std::string& resultText, long long& resultInteger, bool& wasNil) + { + int top = g_lua.gettop(g_luaState); + int type = g_lua.getglobal(g_luaState, name); + if (type == CeLuaTNil) + { + g_lua.settop(g_luaState, top); + wasNil = true; + return false; + } + + wasNil = false; + int status = g_lua.pcallk(g_luaState, 0, 1, 0, 0, nullptr); + if (status != 0) + { + size_t length = 0; + const char* message = g_lua.tolstring(g_luaState, -1, &length); + resultText = message ? std::string(message, length) : "pcall-failed"; + g_lua.settop(g_luaState, top); + return false; + } + + size_t length = 0; + const char* text = g_lua.tolstring(g_luaState, -1, &length); + if (text) + { + resultText = std::string(text, length); + } + + int isNumber = 0; + resultInteger = g_lua.tointegerx(g_luaState, -1, &isNumber); + g_lua.settop(g_luaState, top); + return true; + } + + // --------------------------------------------------------------------------------------------------------- + // The emulator's own implementation of the five ManagedExportedFunctions slots (host -> managed direction). + // --------------------------------------------------------------------------------------------------------- + + void* CE_STDCALL EmulatorGetLuaState() + { + return g_luaState; + } + + void CE_STDCALL EmulatorLuaPushClassInstance(void* state, void* object) + { + if (state) + { + g_lua.pushlightuserdata(reinterpret_cast(state), object); + } + } + + void CE_STDCALL EmulatorProcessMessages() + { + // No GUI: nothing to pump. Matches ManagedExportedFunctions.ProcessMessages's "no arguments, no result". + } + + CeBool8 CE_STDCALL EmulatorCheckSynchronize(int32_t) + { + // The emulator has no worker threads queuing cross-thread work; always report "at least one call ran". + return 1; + } + + // LuaRegister is declared `void*` in ManagedExportedFunctions on purpose ("do not call", see the SDK's own XML + // docs); the SDK never calls it (it registers Lua globals through the protected bridge instead). If it is ever + // invoked here, that is itself evidence, so the stub records it rather than crashing. + volatile LONG g_luaRegisterCalls = 0; + + void CE_STDCALL EmulatorLuaRegisterStub() + { + InterlockedIncrement(&g_luaRegisterCalls); + } + + // --------------------------------------------------------------------------------------------------------- + // hostfxr, resolved dynamically from the path nethost reports. + // --------------------------------------------------------------------------------------------------------- + + struct HostfxrApi + { + hostfxr_set_error_writer_fn set_error_writer = nullptr; + hostfxr_initialize_for_runtime_config_fn initialize_for_runtime_config = nullptr; + hostfxr_get_runtime_delegate_fn get_runtime_delegate = nullptr; + hostfxr_set_runtime_property_value_fn set_runtime_property_value = nullptr; + hostfxr_close_fn close = nullptr; + + bool ResolveFrom(HMODULE module) + { + set_error_writer = + reinterpret_cast(GetProcAddress(module, "hostfxr_set_error_writer")); + initialize_for_runtime_config = reinterpret_cast( + GetProcAddress(module, "hostfxr_initialize_for_runtime_config")); + get_runtime_delegate = + reinterpret_cast(GetProcAddress(module, "hostfxr_get_runtime_delegate")); + set_runtime_property_value = reinterpret_cast( + GetProcAddress(module, "hostfxr_set_runtime_property_value")); + close = reinterpret_cast(GetProcAddress(module, "hostfxr_close")); + return initialize_for_runtime_config && get_runtime_delegate && close; + } + }; + + std::wstring g_lastHostfxrError; + + void __cdecl HostfxrErrorWriter(const wchar_t* message) + { + g_lastHostfxrError += message; + g_lastHostfxrError += L" "; + } + + std::string HexResult(int32_t result) + { + char buffer[16]; + sprintf_s(buffer, "0x%08X", static_cast(result)); + return buffer; + } + + // --------------------------------------------------------------------------------------------------------- + // Command-line arguments + // --------------------------------------------------------------------------------------------------------- + + struct Arguments + { + std::wstring luaPath; + std::wstring dotnetRoot; + std::wstring runtimeConfigPath; + std::wstring pluginADir; + std::wstring pluginAAssembly; + std::wstring pluginBDir; + std::wstring pluginBAssembly; + std::wstring alcRoute = L"component"; + std::wstring factsPath; + }; + + bool ParseArguments(int argc, wchar_t* argv[], Arguments& arguments, std::wstring& error) + { + auto next = [&](int& index) -> std::wstring + { + ++index; + return index < argc ? std::wstring(argv[index]) : std::wstring(); + }; + + for (int index = 1; index < argc; ++index) + { + std::wstring option = argv[index]; + if (option == L"--lua") + { + arguments.luaPath = next(index); + } + else if (option == L"--dotnet-root") + { + arguments.dotnetRoot = next(index); + } + else if (option == L"--runtimeconfig") + { + arguments.runtimeConfigPath = next(index); + } + else if (option == L"--plugin-a-dir") + { + arguments.pluginADir = next(index); + } + else if (option == L"--plugin-a-assembly") + { + arguments.pluginAAssembly = next(index); + } + else if (option == L"--plugin-b-dir") + { + arguments.pluginBDir = next(index); + } + else if (option == L"--plugin-b-assembly") + { + arguments.pluginBAssembly = next(index); + } + else if (option == L"--alc") + { + arguments.alcRoute = next(index); + } + else if (option == L"--facts") + { + arguments.factsPath = next(index); + } + else + { + error = L"Unrecognized argument: " + option; + return false; + } + } + + if (arguments.luaPath.empty() || arguments.dotnetRoot.empty() || arguments.runtimeConfigPath.empty() || + arguments.pluginADir.empty() || arguments.pluginAAssembly.empty() || arguments.pluginBDir.empty() || + arguments.pluginBAssembly.empty() || arguments.factsPath.empty()) + { + error = L"Missing a required argument (--lua, --dotnet-root, --runtimeconfig, --plugin-a-dir, " + L"--plugin-a-assembly, --plugin-b-dir, --plugin-b-assembly, --facts)."; + return false; + } + + if (arguments.alcRoute != L"component" && arguments.alcRoute != L"default") + { + error = L"--alc must be 'component' or 'default'."; + return false; + } + + return true; + } + + // --------------------------------------------------------------------------------------------------------- + // One plugin's bootstrap + lifecycle state. + // --------------------------------------------------------------------------------------------------------- + + struct PluginState + { + std::wstring label; + std::wstring dir; + std::wstring assemblyName; + uint32_t pluginId = 0; + + CeComponentEntryPointFn entry = nullptr; + unsigned char guardBuffer[64 + sizeof(CePluginInitRecord) + 64]{}; + CePluginInitRecord* record = nullptr; + + bool entryResolved = false; + bool bootstrapFirstOk = false; + bool bootstrapSecondOk = false; + bool guardIntact = true; + bool namePointerStable = false; + bool getVersionOk = false; + bool enabled = false; + + void* namePointerFirst = nullptr; + }; + + bool GuardIsIntact(const unsigned char* buffer, size_t recordOffset, size_t recordSize) + { + for (size_t index = 0; index < 64; ++index) + { + if (buffer[index] != 0xCD || buffer[recordOffset + recordSize + index] != 0xCD) + { + return false; + } + } + + return true; + } + + CeManagedExportedFunctions BuildExportedFunctions() + { + CeManagedExportedFunctions exports{}; + exports.SizeOfExportedFunctions = static_cast(sizeof(CeManagedExportedFunctions)); + exports.GetLuaState = &EmulatorGetLuaState; + exports.LuaRegister = reinterpret_cast(&EmulatorLuaRegisterStub); + exports.LuaPushClassInstance = &EmulatorLuaPushClassInstance; + exports.ProcessMessages = &EmulatorProcessMessages; + exports.CheckSynchronize = &EmulatorCheckSynchronize; + return exports; + } + + void RunBootstrap(PluginState& plugin) + { + plugin.record = reinterpret_cast(plugin.guardBuffer + 64); + + if (!plugin.entryResolved) + { + return; + } + + // First call ("name query"). + memset(plugin.guardBuffer, 0xCD, sizeof(plugin.guardBuffer)); + int32_t firstResult = plugin.entry(plugin.record, static_cast(sizeof(CePluginInitRecord))); + plugin.bootstrapFirstOk = firstResult != 0; + plugin.guardIntact = plugin.guardIntact && GuardIsIntact(plugin.guardBuffer, 64, sizeof(CePluginInitRecord)); + plugin.namePointerFirst = plugin.bootstrapFirstOk ? plugin.record->Name : nullptr; + + // Second call ("load"). + int32_t secondResult = plugin.entry(plugin.record, static_cast(sizeof(CePluginInitRecord))); + plugin.bootstrapSecondOk = secondResult != 0; + plugin.guardIntact = plugin.guardIntact && GuardIsIntact(plugin.guardBuffer, 64, sizeof(CePluginInitRecord)); + + if (plugin.bootstrapFirstOk && plugin.bootstrapSecondOk) + { + plugin.namePointerStable = plugin.namePointerFirst == plugin.record->Name; + } + + if (!plugin.bootstrapSecondOk) + { + return; + } + + CePluginVersion version{}; + CeBool32 versionResult = plugin.record->GetVersion(&version, static_cast(sizeof(CePluginVersion))); + plugin.getVersionOk = versionResult != 0; + + CeManagedExportedFunctions exports = BuildExportedFunctions(); + CeBool32 enableResult = plugin.record->EnablePlugin(&exports, plugin.pluginId); + plugin.enabled = enableResult != 0; + } + + void RecordIdentity(FactsWriter& facts, const std::string& label, bool enabled, const std::string& key) + { + std::string identityText; + std::string pingText; + long long pingValue = 0; + bool identityNil = true; + bool pingNil = true; + + if (enabled) + { + long long ignored = 0; + bool wasNil = false; + CallGlobalFunction(("cheatengine_sdk_coexistence_" + label + "_identity").c_str(), identityText, ignored, wasNil); + identityNil = wasNil; + bool pingWasNil = false; + CallGlobalFunction(("cheatengine_sdk_coexistence_" + label + "_ping").c_str(), pingText, pingValue, pingWasNil); + pingNil = pingWasNil; + } + + facts.Add(key + ".plugin_mvid", identityNil ? "unavailable" : ExtractField(identityText, "PluginMvid=")); + facts.Add(key + ".hosting_mvid", identityNil ? "unavailable" : ExtractField(identityText, "HostingMvid=")); + facts.Add(key + ".plugin_alc", identityNil ? "unavailable" : ExtractField(identityText, "PluginALC=")); + facts.Add(key + ".hosting_alc", identityNil ? "unavailable" : ExtractField(identityText, "HostingALC=")); + facts.Add(key + ".same_alc", identityNil ? "unavailable" : ExtractField(identityText, "SameALC=")); + facts.Add(key + ".hosting_type_handle", identityNil ? "unavailable" : ExtractField(identityText, "HostingTypeHandle=")); + facts.Add(key + ".epoch", identityNil ? "unavailable" : ExtractField(identityText, "Epoch=")); + } +} + +int wmain(int argc, wchar_t* argv[]) +{ + Arguments arguments; + std::wstring parseError; + if (!ParseArguments(argc, argv, arguments, parseError)) + { + fwprintf(stderr, L"native-host-emulator: %ls\n", parseError.c_str()); + return 1; + } + + FactsWriter facts; + facts.Add("emulator.schema", "1"); + facts.Add("evidence", "C2-host-emulated-hostfxr-component-route-not-cheat-engine"); + + bool sameDirectory = _wcsicmp(arguments.pluginADir.c_str(), arguments.pluginBDir.c_str()) == 0; + facts.Add("layout", sameDirectory ? "shared" : "separate"); + facts.Add("alc.route", WideToUtf8(arguments.alcRoute)); + + // 1. Lua: resolvable by module name (LoadLibraryW with a full path also registers the module under its base + // name, which is what the plugin's own dependent load of "lua53-64.dll" will find). + std::string luaSha256 = Sha256File(arguments.luaPath); + facts.Add("lua.sha256", luaSha256); + + HMODULE luaModule = LoadLibraryW(arguments.luaPath.c_str()); + if (!luaModule || !g_lua.ResolveFrom(luaModule)) + { + facts.Add("runtime.init", "failed:lua-load"); + facts.Save(arguments.factsPath); + fwprintf(stderr, L"native-host-emulator: failed to load or resolve the Lua module at '%ls'.\n", arguments.luaPath.c_str()); + return 2; + } + + g_luaState = g_lua.newstate(); + if (!g_luaState) + { + facts.Add("runtime.init", "failed:lua-newstate"); + facts.Save(arguments.factsPath); + return 2; + } + + g_lua.openlibs(g_luaState); + + // bridge.fingerprint: read once, from whichever plugin directory the caller supplied first. Both directories + // carry an identical bridge in every scenario this emulator runs (the consumer test verifies that before + // invoking the emulator). + std::string bridgeFingerprint = "unavailable"; + { + std::wstring bridgePath = arguments.pluginADir + L"\\cheatengine-sdk-lua-bridge.dll"; + HMODULE bridgeModule = LoadLibraryW(bridgePath.c_str()); + if (bridgeModule) + { + const char* exported = + reinterpret_cast(GetProcAddress(bridgeModule, "cheatengine_sdk_lua_bridge_source_fingerprint")); + if (exported) + { + size_t length = strnlen_s(exported, 200); + bridgeFingerprint = std::string(exported, length); + } + + FreeLibrary(bridgeModule); + } + } + facts.Add("bridge.fingerprint", bridgeFingerprint); + + // 2. nethost: locate hostfxr under the explicit --dotnet-root. Never consult the environment or the registry. + get_hostfxr_parameters hostfxrParameters{}; + hostfxrParameters.size = sizeof(hostfxrParameters); + hostfxrParameters.assembly_path = nullptr; + hostfxrParameters.dotnet_root = arguments.dotnetRoot.c_str(); + + wchar_t hostfxrPath[MAX_PATH]; + size_t hostfxrPathSize = MAX_PATH; + int32_t hostfxrLookup = get_hostfxr_path(hostfxrPath, &hostfxrPathSize, &hostfxrParameters); + if (hostfxrLookup != 0) + { + facts.Add("runtime.init", "failed:nethost-" + HexResult(hostfxrLookup)); + facts.Save(arguments.factsPath); + return 3; + } + + HMODULE hostfxrModule = LoadLibraryW(hostfxrPath); + HostfxrApi hostfxr; + if (!hostfxrModule || !hostfxr.ResolveFrom(hostfxrModule)) + { + facts.Add("runtime.init", "failed:hostfxr-load"); + facts.Save(arguments.factsPath); + return 3; + } + + if (hostfxr.set_error_writer) + { + hostfxr.set_error_writer(&HostfxrErrorWriter); + } + + // 3. hostfxr_initialize_for_runtime_config: one runtime for the whole process (pitfall #10 of s-host.md + // section 5). A missing shared framework (WindowsDesktop.App / AspNetCore.App) fails here with + // 0x80008096; that failure is reported, never silently downgraded to a smaller runtimeconfig. + hostfxr_handle context = nullptr; + int32_t initResult = hostfxr.initialize_for_runtime_config(arguments.runtimeConfigPath.c_str(), nullptr, &context); + if (initResult < 0 || context == nullptr) + { + facts.Add("runtime.init", "failed:" + HexResult(initResult)); + facts.Save(arguments.factsPath); + fwprintf(stderr, L"native-host-emulator: hostfxr_initialize_for_runtime_config failed (%ls).\n", + g_lastHostfxrError.empty() ? L"no detail" : g_lastHostfxrError.c_str()); + return 3; + } + + facts.Add("runtime.init", "ok"); + + // From here on, whatever happens next is protocol evidence, not an infrastructure failure: the run always + // exits 0 after this point. + bool useDefaultAlc = arguments.alcRoute == L"default"; + + load_assembly_and_get_function_pointer_fn loadAssemblyAndGetFunctionPointer = nullptr; + load_assembly_fn loadAssembly = nullptr; + get_function_pointer_fn getFunctionPointer = nullptr; + + if (useDefaultAlc) + { + // Not documented on Microsoft Learn (s-host.md WI-7): APP_PATHS must be set before the first runtime + // delegate is obtained, because that first call is what loads CoreCLR. It lets the default + // AssemblyLoadContext resolve a component assembly's managed dependencies (CheatEngine.SDK.Hosting.dll and + // the rest) that are not part of this host's own runtimeconfig/deps.json. + if (hostfxr.set_runtime_property_value) + { + hostfxr.set_runtime_property_value(context, L"APP_PATHS", arguments.pluginADir.c_str()); + } + + hostfxr.get_runtime_delegate(context, hdt_load_assembly, reinterpret_cast(&loadAssembly)); + hostfxr.get_runtime_delegate(context, hdt_get_function_pointer, reinterpret_cast(&getFunctionPointer)); + } + else + { + hostfxr.get_runtime_delegate( + context, hdt_load_assembly_and_get_function_pointer, reinterpret_cast(&loadAssemblyAndGetFunctionPointer)); + } + + auto resolveEntry = [&](PluginState& plugin) + { + std::wstring assemblyPath = plugin.dir + L"\\" + plugin.assemblyName + L".dll"; + std::wstring typeName = L"CESDK.CESDK, " + plugin.assemblyName; + const wchar_t* methodName = L"CEPluginInitialize"; + + if (useDefaultAlc) + { + if (!loadAssembly || !getFunctionPointer) + { + return; + } + + int32_t loadResult = loadAssembly(assemblyPath.c_str(), nullptr, nullptr); + if (loadResult != 0) + { + // A load failure here (for example, the second assembly cannot be found) is itself protocol + // evidence for the default-ALC scenario, not an infrastructure error: leave entryResolved false + // and let the bootstrap facts show "failed". + return; + } + + int32_t getResult = + getFunctionPointer(typeName.c_str(), methodName, nullptr, nullptr, nullptr, reinterpret_cast(&plugin.entry)); + plugin.entryResolved = getResult == 0 && plugin.entry != nullptr; + } + else + { + if (!loadAssemblyAndGetFunctionPointer) + { + return; + } + + int32_t getResult = loadAssemblyAndGetFunctionPointer( + assemblyPath.c_str(), typeName.c_str(), methodName, nullptr, nullptr, reinterpret_cast(&plugin.entry)); + plugin.entryResolved = getResult == 0 && plugin.entry != nullptr; + } + }; + + PluginState pluginA; + pluginA.label = L"A"; + pluginA.dir = arguments.pluginADir; + pluginA.assemblyName = arguments.pluginAAssembly; + pluginA.pluginId = 1; + + PluginState pluginB; + pluginB.label = L"B"; + pluginB.dir = arguments.pluginBDir; + pluginB.assemblyName = arguments.pluginBAssembly; + pluginB.pluginId = 2; + + // Protocol: enable A, then B. + resolveEntry(pluginA); + RunBootstrap(pluginA); + resolveEntry(pluginB); + RunBootstrap(pluginB); + + auto boolText = [](bool ok, bool attempted) + { + if (!attempted) + { + return std::string("skipped"); + } + + return std::string(ok ? "ok" : "failed"); + }; + + facts.Add("a.bootstrap.first", boolText(pluginA.bootstrapFirstOk, pluginA.entryResolved)); + facts.Add("a.bootstrap.second", boolText(pluginA.bootstrapSecondOk, pluginA.entryResolved)); + facts.Add("a.bootstrap.guard", pluginA.guardIntact ? "intact" : "corrupted"); + facts.AddBool("a.bootstrap.name_pointer_stable", pluginA.namePointerStable); + facts.Add("a.getversion", boolText(pluginA.getVersionOk, pluginA.bootstrapSecondOk)); + facts.Add("a.enable.1", boolText(pluginA.enabled, pluginA.bootstrapSecondOk)); + + facts.Add("b.bootstrap.first", boolText(pluginB.bootstrapFirstOk, pluginB.entryResolved)); + facts.Add("b.bootstrap.second", boolText(pluginB.bootstrapSecondOk, pluginB.entryResolved)); + facts.Add("b.bootstrap.guard", pluginB.guardIntact ? "intact" : "corrupted"); + facts.AddBool("b.bootstrap.name_pointer_stable", pluginB.namePointerStable); + facts.Add("b.getversion", boolText(pluginB.getVersionOk, pluginB.bootstrapSecondOk)); + facts.Add("b.enable.1", boolText(pluginB.enabled, pluginB.bootstrapSecondOk)); + + RecordIdentity(facts, "a", pluginA.enabled, "a.identity"); + RecordIdentity(facts, "b", pluginB.enabled, "b.identity"); + + // ab.hosting_mvid_equal / ab.hosting_type_handle_distinct need both identity strings side by side, so the + // identity functions are called once more here rather than threading their result out of RecordIdentity. + std::string identityA; + std::string identityB; + { + long long ignoredInt = 0; + bool wasNil = false; + if (pluginA.enabled) + { + CallGlobalFunction("cheatengine_sdk_coexistence_a_identity", identityA, ignoredInt, wasNil); + } + + if (pluginB.enabled) + { + CallGlobalFunction("cheatengine_sdk_coexistence_b_identity", identityB, ignoredInt, wasNil); + } + } + + std::string hostingMvidA = pluginA.enabled ? ExtractField(identityA, "HostingMvid=") : "unavailable"; + std::string hostingMvidB = pluginB.enabled ? ExtractField(identityB, "HostingMvid=") : "unavailable"; + std::string typeHandleA = pluginA.enabled ? ExtractField(identityA, "HostingTypeHandle=") : "unavailable"; + std::string typeHandleB = pluginB.enabled ? ExtractField(identityB, "HostingTypeHandle=") : "unavailable"; + + bool bothIdentitiesKnown = pluginA.enabled && pluginB.enabled; + facts.Add("ab.hosting_mvid_equal", bothIdentitiesKnown ? (hostingMvidA == hostingMvidB ? "true" : "false") : "unknown"); + facts.Add("ab.hosting_type_handle_distinct", + bothIdentitiesKnown ? (typeHandleA != typeHandleB ? "true" : "false") : "unknown"); + + // Disable A; record that A's own globals go nil while B (if enabled) keeps answering. + if (pluginA.bootstrapSecondOk) + { + pluginA.record->DisablePlugin(); + } + + facts.Add("after_disable_a.a_identity_type", + GlobalType("cheatengine_sdk_coexistence_a_identity") == CeLuaTNil ? "nil" : "present"); + facts.Add("after_disable_a.a_ping_type", GlobalType("cheatengine_sdk_coexistence_a_ping") == CeLuaTNil ? "nil" : "present"); + + if (pluginB.enabled) + { + std::string identityAfter; + long long ignoredInt = 0; + bool wasNil = false; + bool called = CallGlobalFunction("cheatengine_sdk_coexistence_b_identity", identityAfter, ignoredInt, wasNil); + facts.Add("after_disable_a.b_identity_call", wasNil ? "nil" : (called ? "ok" : "failed")); + + std::string pingText; + long long pingValue = 0; + bool pingWasNil = false; + bool pingCalled = CallGlobalFunction("cheatengine_sdk_coexistence_b_ping", pingText, pingValue, pingWasNil); + char pingBuffer[32]; + sprintf_s(pingBuffer, "%lld", pingValue); + facts.Add("after_disable_a.b_ping", pingWasNil ? "nil" : (pingCalled ? pingBuffer : "failed")); + } + else + { + facts.Add("after_disable_a.b_identity_call", "skipped"); + facts.Add("after_disable_a.b_ping", "skipped"); + } + + // Re-enable A: same loaded assembly instance, a fresh EnablePlugin call. A new fact not in the brief's minimal + // list (a.identity.epoch_after_reenable) is added so "answers with a new epoch" (Q05) has something concrete + // to assert on; see the README and the final report for this documented addition. + bool reenableAttempted = pluginA.bootstrapSecondOk; + bool reenabled = false; + if (reenableAttempted) + { + CeManagedExportedFunctions exports = BuildExportedFunctions(); + CeBool32 enableResult = pluginA.record->EnablePlugin(&exports, pluginA.pluginId); + reenabled = enableResult != 0; + } + + facts.Add("a.enable.2", boolText(reenabled, reenableAttempted)); + + std::string epochAfterReenable = "unavailable"; + if (reenabled) + { + std::string identityAfterReenable; + long long ignoredInt = 0; + bool wasNil = false; + CallGlobalFunction("cheatengine_sdk_coexistence_a_identity", identityAfterReenable, ignoredInt, wasNil); + if (!wasNil) + { + epochAfterReenable = ExtractField(identityAfterReenable, "Epoch="); + } + } + + facts.Add("a.identity.epoch_after_reenable", epochAfterReenable); + + // Disable B, then A: every global should now answer nil. + if (pluginB.bootstrapSecondOk) + { + pluginB.record->DisablePlugin(); + } + + if (reenabled) + { + pluginA.record->DisablePlugin(); + } + else if (pluginA.bootstrapSecondOk) + { + pluginA.record->DisablePlugin(); + } + + bool aIdentityNil = GlobalType("cheatengine_sdk_coexistence_a_identity") == CeLuaTNil; + bool aPingNil = GlobalType("cheatengine_sdk_coexistence_a_ping") == CeLuaTNil; + bool bIdentityNil = GlobalType("cheatengine_sdk_coexistence_b_identity") == CeLuaTNil; + bool bPingNil = GlobalType("cheatengine_sdk_coexistence_b_ping") == CeLuaTNil; + + facts.Add("final.a_globals", aIdentityNil && aPingNil ? "nil" : "present"); + facts.Add("final.b_globals", bIdentityNil && bPingNil ? "nil" : "present"); + + facts.Add("result", "completed"); + + bool saved = facts.Save(arguments.factsPath); + if (hostfxr.close) + { + hostfxr.close(context); + } + + g_lua.close(g_luaState); + return saved ? 0 : 4; +} diff --git a/tests/native-host-emulator/ce_host_emulator_abi.h b/tests/native-host-emulator/ce_host_emulator_abi.h new file mode 100644 index 00000000..68ca52a6 --- /dev/null +++ b/tests/native-host-emulator/ce_host_emulator_abi.h @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// +// The x64 hostfxr/managed-plugin ABI shapes the C2 native host emulator writes to and reads from. Transcribed from +// CheatEngine.SDK.Abi (source of authority, ADR-01), never from an installed Cheat Engine. This header does not +// include S-ABI's `tests/native-abi-fixture/ce77_plugin_abi_contract.h`: the emulator is a separate C2 evidence +// program with its own build and its own facts, and must not gain a compile-time dependency on the ABI fixture. +// +// Field names mirror the C# types so the emulator's static_assert lines can be parsed and compared against +// `sizeof(...)` of the managed records by CheatEngine.SDK.Hosting.Tests (see the "Emulator_abi_header_declares_the_ +// managed_record_sizes" test). +// +// CheatEngine.SDK.Abi.Managed.PluginInitRecord (libs/CheatEngine.SDK.Abi/Managed/PluginInitRecord.cs) +// CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions (libs/CheatEngine.SDK.Abi/Managed/ManagedExportedFunctions.cs) +// CheatEngine.SDK.Abi.PluginVersion (libs/CheatEngine.SDK.Abi/PluginVersion.cs) + +#pragma once + +#include +#include + +#if !defined(_WIN64) +#error The native host emulator is deliberately Windows x64 only. +#endif + +#define CE_HOST_EMULATOR_SCHEMA 1 + +typedef int32_t CeBool32; +typedef uint8_t CeBool8; + +#define CE_STDCALL __stdcall + +// PluginVersion (libs/CheatEngine.SDK.Abi/PluginVersion.cs): natural alignment, 16 bytes. +#pragma pack(push, 8) +typedef struct CePluginVersion +{ + uint32_t Version; + char* PluginName; +} CePluginVersion; +#pragma pack(pop) + +// Function pointer shapes the managed bootstrap writes into CePluginInitRecord (declared before the record so the +// record's fields can be the correctly typed, directly callable pointers, not opaque `void*`). The x64 Microsoft +// calling convention is one ABI regardless of the __stdcall/__cdecl keyword; the keyword documents intent only. +typedef CeBool32(CE_STDCALL* CeGetVersionFn)(CePluginVersion*, int32_t); +typedef struct CeManagedExportedFunctions CeManagedExportedFunctions; +typedef CeBool32(CE_STDCALL* CeEnablePluginFn)(CeManagedExportedFunctions*, uint32_t); +typedef CeBool32(CE_STDCALL* CeDisablePluginFn)(void); + +// Function pointer shapes the emulator implements and places into CeManagedExportedFunctions. +typedef void* (CE_STDCALL* CeGetLuaStateFn)(void); +typedef void(CE_STDCALL* CeLuaPushClassInstanceFn)(void*, void*); +typedef void(CE_STDCALL* CeProcessMessagesFn)(void); +typedef CeBool8(CE_STDCALL* CeCheckSynchronizeFn)(int32_t); + +// The default hostfxr component entry-point shape (coreclr_delegates.h `component_entry_point_fn`): the generated +// `CESDK.CESDK.CEPluginInitialize(IntPtr, int)` bootstrap method. +typedef int32_t(__stdcall* CeComponentEntryPointFn)(void*, int32_t); + +// PluginInitRecord (libs/CheatEngine.SDK.Abi/Managed/PluginInitRecord.cs): the managed plugin's bootstrap writes this, +// byte-packed, 36 bytes, no tail padding. Every field here is directly callable/usable, unlike the fixture's own +// deliberately generic ABI contract header, because this emulator calls through every one of them. +#pragma pack(push, 1) +typedef struct CePluginInitRecord +{ + char* Name; + CeGetVersionFn GetVersion; + CeEnablePluginFn EnablePlugin; + CeDisablePluginFn DisablePlugin; + uint32_t Version; +} CePluginInitRecord; +#pragma pack(pop) + +// ManagedExportedFunctions (libs/CheatEngine.SDK.Abi/Managed/ManagedExportedFunctions.cs): the emulator (host) +// fills this in and hands it to EnablePlugin. Natural alignment, 48 bytes. `LuaRegister` stays `void*`: the managed +// side declares it untyped on purpose ("do not call"), so this mirror keeps it untyped too. +#pragma pack(push, 8) +struct CeManagedExportedFunctions +{ + int32_t SizeOfExportedFunctions; + CeGetLuaStateFn GetLuaState; + void* LuaRegister; + CeLuaPushClassInstanceFn LuaPushClassInstance; + CeProcessMessagesFn ProcessMessages; + CeCheckSynchronizeFn CheckSynchronize; +}; +#pragma pack(pop) + +#ifdef __cplusplus +static_assert(sizeof(void*) == 8, "The emulator requires x64 pointers."); + +static_assert(sizeof(CePluginVersion) == 16, "PluginVersion is 16 bytes on x64."); +static_assert(offsetof(CePluginVersion, Version) == 0, "PluginVersion.Version is at offset 0."); +static_assert(offsetof(CePluginVersion, PluginName) == 8, "PluginVersion.PluginName is at offset 8."); + +static_assert(sizeof(CePluginInitRecord) == 36, "PluginInitRecord is 36 bytes, byte-packed, on x64."); +static_assert(offsetof(CePluginInitRecord, Name) == 0, "PluginInitRecord.Name is at offset 0."); +static_assert(offsetof(CePluginInitRecord, GetVersion) == 8, "PluginInitRecord.GetVersion is at offset 8."); +static_assert(offsetof(CePluginInitRecord, EnablePlugin) == 16, "PluginInitRecord.EnablePlugin is at offset 16."); +static_assert(offsetof(CePluginInitRecord, DisablePlugin) == 24, "PluginInitRecord.DisablePlugin is at offset 24."); +static_assert(offsetof(CePluginInitRecord, Version) == 32, "PluginInitRecord.Version is at offset 32."); + +static_assert(sizeof(CeManagedExportedFunctions) == 48, "ManagedExportedFunctions is 48 bytes, naturally aligned, on x64."); +static_assert(offsetof(CeManagedExportedFunctions, SizeOfExportedFunctions) == 0, + "ManagedExportedFunctions.SizeOfExportedFunctions is at offset 0."); +static_assert(offsetof(CeManagedExportedFunctions, GetLuaState) == 8, "ManagedExportedFunctions.GetLuaState is at offset 8."); +static_assert(offsetof(CeManagedExportedFunctions, LuaRegister) == 16, "ManagedExportedFunctions.LuaRegister is at offset 16."); +static_assert(offsetof(CeManagedExportedFunctions, LuaPushClassInstance) == 24, + "ManagedExportedFunctions.LuaPushClassInstance is at offset 24."); +static_assert(offsetof(CeManagedExportedFunctions, ProcessMessages) == 32, + "ManagedExportedFunctions.ProcessMessages is at offset 32."); +static_assert(offsetof(CeManagedExportedFunctions, CheckSynchronize) == 40, + "ManagedExportedFunctions.CheckSynchronize is at offset 40."); +#endif